diff --git a/README.md b/README.md index 433a1b9..f8115ea 100644 --- a/README.md +++ b/README.md @@ -11,20 +11,90 @@ Should work on macOS 10.10+ according to the availability sec. [in the docs](htt #### Features - Poll XPC for jobs and display changes as they happen -- Filter by system (/System/Library/), "global" (/Library), user (~/) `LaunchAgents` and `LaunchDaemons` +- Filter by `LaunchAgents` and `LaunchDaemons` in scopes: + - System (/System/Library/) + - Global (/Library) + - User (~/) - fsnotify detection for new plists added to above directories - `:load/:unload` -> `launchctl load/unload` - `:edit` -> Open plist in `$EDITOR`, defaulting to `vim`. Supports binary plists -> shown as XML for edit, then marshalled back into binary format on save. ### xpc-sys crate -There is some "convenience glue" for dealing with XPC objects. Eventually, this will be broken out into its own crate. Most of the tests (for now) are written around not breaking data going across the FFI barrier. +There is some "convenience glue" for dealing with XPC objects. Eventually, this will be broken out into its own crate. Some tests exist for not breaking data to/from FFI. + +##### Object lifecycle XPCObject wraps `xpc_object_t` in an `Arc`. `Drop` will invoke `xpc_release()` on objects being dropped with no other [strong refs](https://doc.rust-lang.org/std/sync/struct.Arc.html#method.strong_count). -#### xpc_pipe_routine +**NOTE**: When using Objective-C blocks with the [block crate](https://crates.io/crates/block) (e.g. looping over an array), make sure to invoke `xpc_retain()` on any object you wish to keep after the closure is dropped, or else the XPC objects in the closure will be dropped as well! See the `XPCDictionary` implementation for more details. xpc-sys handles this for you for its conversions. + +#### XPCDictionary and QueryBuilder + +While we can go from `HashMap<&str, XPCObject>` to `XPCObject`, it can be a little verbose. A `QueryBuilder` trait exposes some builder methods to make building an XPC dictionary a little easier (without all of the `into()`s, and some additional error checking). + +To write the query for `launchctl list`: + +```rust + let LIST_SERVICES: XPCDictionary = XPCDictionary::new() + // "list com.apple.Spotlight" (if specified) + // .entry("name", "com.apple.Spotlight"); + .entry("subsystem", 3 as u64) + .entry("handle", 0 as u64) + .entry("routine", 815 as u64) + .entry("legacy", true); + + let reply: Result = XPCDictionary::new() + // LIST_SERVICES is a proto + .extend(&LIST_SERVICES) + // Specify the domain type, or fall back on requester domain + .with_domain_type_or_default(Some(domain_type)) + .entry_if_present("name", name) + .pipe_routine_with_error_handling(); +``` + +In addition to checking `errno` is 0, `pipe_routine_with_error_handling` also looks for possible `error` and `errors` keys in the response dictionary and provides an `Err()` with `xpc_strerror` contents. + +#### FFI Type Conversions + +Conversions to/from Rust/XPC objects uses the [xpc.h functions documented on Apple Developer](https://developer.apple.com/documentation/xpc/xpc_services_xpc_h?language=objc) using the `From` trait. + +| Rust | XPC | +|----------------------------------------|-----------------------| +| i64 | _xpc_type_int64 | +| u64 | _xpc_type_uint64 | +| f64 | _xpc_type_double | +| bool | _xpc_bool_true/false | +| Into | _xpc_type_string | +| HashMap, Into> | _xpc_type_dictionary | +| Vec> | _xpc_type_array | + +Make XPC objects for anything with `From`. From earlier example, even Mach ports: +```rust +let mut message: HashMap<&str, XPCObject> = HashMap::new(); + +message.insert( + "domain-port", + XPCObject::from(get_bootstrap_port() as mach_port_t), +); +``` + +Go from an XPC object to value via the `TryXPCValue` trait. It checks your object's type via `xpc_get_type()` and yields a clear error if you're using the wrong type: +```rust +#[test] +fn deserialize_as_wrong_type() { + let an_i64: XPCObject = XPCObject::from(42 as i64); + let as_u64: Result = an_i64.xpc_value(); + assert_eq!( + as_u64.err().unwrap(), + XPCValueError("Cannot get int64 as uint64".to_string()) + ); +} +``` + +##### XPC Dictionaries -Form your messages as a HashMap: +Go from a `HashMap` to `xpc_object_t` with the `XPCObject` type: ```rust let mut message: HashMap<&str, XPCObject> = HashMap::new(); @@ -88,27 +158,41 @@ let XPCDictionary(hm) = response.unwrap(); let whatever = hm.get("..."); ``` -#### Making XPC Objects +##### XPC Arrays + +An XPC array can be made from either `Vec` or `Vec>`: -Make XPC objects for anything with `From`. From earlier example, even Mach ports: ```rust -let mut message: HashMap<&str, XPCObject> = HashMap::new(); +let xpc_array = XPCObject::from(vec![XPCObject::from("eins"), XPCObject::from("zwei"), XPCObject::from("polizei")]); -message.insert( - "domain-port", - XPCObject::from(get_bootstrap_port() as mach_port_t), -); +let xpc_array = XPCObject::from(vec!["eins", "zwei", "polizei"]); ``` -Go from an XPC object to value via `TryXPCValue`. It checks your object's type via `xpc_get_type()` and yields a clear error if you're using the wrong type: +Go back to `Vec` using `xpc_value`: + ```rust -#[test] -fn deserialize_as_wrong_type() { - let an_i64: XPCObject = XPCObject::from(42 as i64); - let as_u64: Result = an_i64.xpc_value(); - assert_eq!( - as_u64.err().unwrap(), - XPCValueError("Cannot get int64 as uint64".to_string()) - ); -} +let rs_vec: Vec = xpc_array.xpc_value().unwrap(); ``` + +### Credits + +A big thanks to these open source projects and general resources: + +- [block](https://crates.io/crates/block) Obj-C block support, necessary for any XPC function taking `xpc_*_applier_t` +- [Cursive](https://github.com/gyscos/cursive) TUI +- [tokio](https://github.com/tokio-rs/tokio) ASIO +- [plist](https://crates.io/crates/plist) Parsing & validation for XML and binary plists +- [notify](https://docs.rs/notify/4.0.16/notify/) fsnotify +- [bitflags](https://docs.rs/bitflags/1.2.1/bitflags/) + +- [Apple Developer XPC services](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingXPCServices.html) +- [Apple Developer XPC API reference](https://developer.apple.com/documentation/xpc?language=objc) +- [MOXIL / launjctl](http://newosxbook.com/articles/jlaunchctl.html) +- [geosnow - A Long Evening With macOS' sandbox](https://geosn0w.github.io/A-Long-Evening-With-macOS%27s-Sandbox/) +- [Bits of launchd - @5aelo](https://saelo.github.io/presentations/bits_of_launchd.pdf) +- [Audit tokens explained (e.g. ASID)](https://knight.sc/reverse%20engineering/2020/03/20/audit-tokens-explained.html) +- [objc.io XPC guide](https://www.objc.io/issues/14-mac/xpc/) +- The various source links found in comments, from Chrome's sandbox and other headers with definitions for private API functions. +- Last but not least, this is Apple's launchd after all, right :>)? I did not know systemd was inspired by launchd until I read [this HN comment](https://news.ycombinator.com/item?id=2565780), which sent me down this eventual rabbit hole :) + +Everything else (C) David Stancu & Contributors 2021 \ No newline at end of file diff --git a/doc/launchctl_messages.md b/doc/launchctl_messages.md index 7108815..702c695 100644 --- a/doc/launchctl_messages.md +++ b/doc/launchctl_messages.md @@ -1,6 +1,6 @@ # launchctl messages -This is a handy reference of XPC messages sent by `launchctl` for some basic commands, as presented by `xpc_copy_description`. +XPC messages sent by `launchctl` for some basic commands as presented by `xpc_copy_description`. #### `launchctl print system` @@ -177,6 +177,28 @@ type 8 strerr Domain does not support specified action ``` +Response: + +``` + "EnableTransactions" => : true + "Sockets" => { count = 1, transaction: 0, voucher = 0x0, contents = + "Listeners" => { count = 1, capacity = 1, contents = + 0: { type = (invalid descriptor), path = (invalid path) } + } + } + "LimitLoadToSessionType" => { length = 6, contents = "System" } + "Label" => { length = 17, contents = "com.apple.usbmuxd" } + "OnDemand" => : false + "LastExitStatus" => : 0 + "PID" => : 165 + "Program" => { length = 85, contents = "/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/Resources/usbmuxd" } + "ProgramArguments" => { count = 2, capacity = 2, contents = + 0: { length = 85, contents = "/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/Resources/usbmuxd" } + 1: { length = 8, contents = "-launchd" } + } + } +``` + #### `launchctl unload ~/Library/LaunchAgents/homebrew.mxcl.elasticsearch.plist` ``` @@ -196,8 +218,6 @@ strerr Domain does not support specified action "domain-port" => { name = 1799, right = send, urefs = 5 } ``` -Type seems same even if trying from `/Library`: - ``` { count = 11, transaction: 0, voucher = 0x0, contents = "subsystem" => : 3 @@ -250,3 +270,85 @@ As root: "domain-port" => { name = 1799, right = send, urefs = 5 } ``` +#### `launchctl disable user/501/homebrew.mxcl.postgresql` + +- postgresql runs in the `Aqua` domain +- Interesting that both `name` and `names` are sent! + +``` + { count = 6, transaction: 0, voucher = 0x0, contents = + "subsystem" => : 3 + "handle" => : 501 + "routine" => : 809 + "name" => { length = 24, contents = "homebrew.mxcl.postgresql" } + "type" => : 2 + "names" => { count = 1, capacity = 8, contents = + 0: { length = 24, contents = "homebrew.mxcl.postgresql" } + } +``` + + +#### `launchctl enable user/501/homebrew.mxcl.postgresql` + +``` + { count = 6, transaction: 0, voucher = 0x0, contents = + "subsystem" => : 3 + "handle" => : 501 + "routine" => : 808 + "name" => { length = 24, contents = "homebrew.mxcl.postgresql" } + "type" => : 2 + "names" => { count = 1, capacity = 8, contents = + 0: { length = 24, contents = "homebrew.mxcl.postgresql" } + } +``` + +#### `launchctl disable system/com.apple.FontWorker` + +- Must run as root +- Type `1` + +``` + { count = 6, transaction: 0, voucher = 0x0, contents = + "subsystem" => : 3 + "handle" => : 0 + "routine" => : 809 + "name" => { length = 20, contents = "com.apple.FontWorker" } + "type" => : 1 + "names" => { count = 1, capacity = 8, contents = + 0: { length = 20, contents = "com.apple.FontWorker" } + } +``` + +#### `launchctl enable system/com.apple.FontWorker` + +- This has a different `LimitLoadToSessionType` set as background, wanted to see if `type` would change + +``` + { count = 6, transaction: 0, voucher = 0x0, contents = + "subsystem" => : 3 + "handle" => : 0 + "routine" => : 808 + "name" => { length = 20, contents = "com.apple.FontWorker" } + "type" => : 1 + "names" => { count = 1, capacity = 8, contents = + 0: { length = 20, contents = "com.apple.FontWorker" } + } +``` + +Using a `gui/` domain target: + +``` + { count = 6, transaction: 0, voucher = 0x0, contents = + "subsystem" => : 3 + "handle" => : 501 + "routine" => : 808 + "name" => { length = 17, contents = "com.docker.vmnetd" } + "type" => : 8 + "names" => { count = 1, capacity = 8, contents = + 0: { length = 17, contents = "com.docker.vmnetd" } + } +``` + +1: System +2: User +8: Login (GUI)? \ No newline at end of file diff --git a/launchk/src/launchd/entry_status.rs b/launchk/src/launchd/entry_status.rs index 01298d0..3d6b67f 100644 --- a/launchk/src/launchd/entry_status.rs +++ b/launchk/src/launchd/entry_status.rs @@ -3,8 +3,9 @@ use std::convert::TryInto; use std::sync::Mutex; use std::time::{Duration, SystemTime}; +use crate::launchd::enums::{SessionType, DomainType}; use crate::launchd::plist::LaunchdPlist; -use crate::launchd::query::{find_in_all, LimitLoadToSessionType}; +use crate::launchd::query::find_in_all; use xpc_sys::traits::xpc_value::TryXPCValue; const ENTRY_INFO_QUERY_TTL: Duration = Duration::from_secs(15); @@ -17,7 +18,8 @@ lazy_static! { #[derive(Debug, Clone, Eq, PartialEq)] pub struct LaunchdEntryStatus { pub plist: Option, - pub limit_load_to_session_type: LimitLoadToSessionType, + pub limit_load_to_session_type: SessionType, + pub domain: DomainType, // So, there is a pid_t, but it's i32, and the XPC response has an i64? pub pid: i64, tick: SystemTime, @@ -26,7 +28,8 @@ pub struct LaunchdEntryStatus { impl Default for LaunchdEntryStatus { fn default() -> Self { LaunchdEntryStatus { - limit_load_to_session_type: LimitLoadToSessionType::Unknown, + limit_load_to_session_type: SessionType::Unknown, + domain: DomainType::Unknown, plist: None, pid: 0, tick: SystemTime::now(), @@ -63,21 +66,27 @@ fn build_entry_status>(label: S) -> LaunchdEntryStatus { let pid: i64 = response .as_ref() .map_err(|e| e.clone()) - .and_then(|r| r.get(&["service", "PID"])) + .and_then(|(_, r)| r.get(&["service", "PID"])) .and_then(|o| o.xpc_value()) .unwrap_or(0); let limit_load_to_session_type = response .as_ref() .map_err(|e| e.clone()) - .and_then(|r| r.get(&["service", "LimitLoadToSessionType"])) + .and_then(|(_, r)| r.get(&["service", "LimitLoadToSessionType"])) .and_then(|o| o.try_into()) - .unwrap_or(LimitLoadToSessionType::Unknown); + .unwrap_or(SessionType::Unknown); + + let domain = response + .as_ref() + .map(|(d, _)| d.clone()) + .unwrap_or(DomainType::Unknown); let entry_config = crate::launchd::plist::for_label(label_string.clone()); LaunchdEntryStatus { limit_load_to_session_type, + domain, plist: entry_config, pid, tick: SystemTime::now(), diff --git a/launchk/src/launchd/enums.rs b/launchk/src/launchd/enums.rs new file mode 100644 index 0000000..d4e1ae2 --- /dev/null +++ b/launchk/src/launchd/enums.rs @@ -0,0 +1,102 @@ +use std::convert::TryFrom; +use std::fmt; +use xpc_sys::objects::xpc_error::XPCError; +use xpc_sys::objects::xpc_object::XPCObject; +use xpc_sys::objects::xpc_type; +use xpc_sys::objects::xpc_type::check_xpc_type; +use xpc_sys::traits::xpc_value::TryXPCValue; + +/// LimitLoadToSessionType key in XPC response +/// https://developer.apple.com/library/archive/technotes/tn2083/_index.html +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum SessionType { + Aqua = 0, + StandardIO, + Background, + LoginWindow, + System, + Unknown, +} + +impl fmt::Display for SessionType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for SessionType { + fn from(session_type: u64) -> Self { + match session_type { + 0 => SessionType::Aqua, + 1 => SessionType::StandardIO, + 2 => SessionType::Background, + 3 => SessionType::LoginWindow, + 4 => SessionType::System, + _ => SessionType::Unknown, + } + } +} + +// TODO: This feels terrible +impl From for SessionType { + fn from(value: String) -> Self { + let aqua: String = SessionType::Aqua.to_string(); + let standard_io: String = SessionType::StandardIO.to_string(); + let background: String = SessionType::Background.to_string(); + let login_window: String = SessionType::LoginWindow.to_string(); + let system: String = SessionType::System.to_string(); + + match value { + s if s == aqua => SessionType::Aqua, + s if s == standard_io => SessionType::StandardIO, + s if s == background => SessionType::Background, + s if s == login_window => SessionType::LoginWindow, + s if s == system => SessionType::System, + _ => SessionType::Unknown, + } + } +} + +impl TryFrom for SessionType { + type Error = XPCError; + + fn try_from(value: XPCObject) -> Result { + check_xpc_type(&value, &xpc_type::String)?; + let string: String = value.xpc_value().unwrap(); + Ok(string.into()) + } +} + +// Huge thanks to: https://saelo.github.io/presentations/bits_of_launchd.pdf +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum DomainType { + System = 1, + User = 2, + UserLogin = 3, + Session = 4, + PID = 5, + RequestorUserDomain = 6, + RequestorDomain = 7, + Unknown, +} + +impl From for DomainType { + fn from(value: u64) -> Self { + match value { + 1 => DomainType::System, + 2 => DomainType::User, + 3 => DomainType::UserLogin, + 4 => DomainType::Session, + 5 => DomainType::PID, + 6 => DomainType::RequestorUserDomain, + 7 => DomainType::RequestorDomain, + _ => DomainType::Unknown, + } + } +} + +impl fmt::Display for DomainType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} diff --git a/launchk/src/launchd/message.rs b/launchk/src/launchd/message.rs index 2579578..0270728 100644 --- a/launchk/src/launchd/message.rs +++ b/launchk/src/launchd/message.rs @@ -1,64 +1,48 @@ -use std::collections::HashMap; -use xpc_sys::objects::xpc_object::XPCObject; -use xpc_sys::{get_bootstrap_port, mach_port_t}; +use crate::launchd::query_builder::QueryBuilder; +use xpc_sys::objects::xpc_dictionary::XPCDictionary; lazy_static! { /// launchctl list [name] - pub static ref LIST_SERVICES: HashMap<&'static str, XPCObject> = { + pub static ref LIST_SERVICES: XPCDictionary = XPCDictionary::new() // "list com.apple.Spotlight" (if specified) - // msg.insert("name", XPCObject::from("com.apple.Spotlight")); - - let mut msg = HashMap::new(); - msg.insert("subsystem", XPCObject::from(3 as u64)); - msg.insert("handle", XPCObject::from(0 as u64)); - msg.insert("routine", XPCObject::from(815 as u64)); - msg.insert("type", XPCObject::from(1 as u64)); - - msg.insert("legacy", XPCObject::from(true)); - msg - }; + // .entry("name", "com.apple.Spotlight"); + .entry("subsystem", 3 as u64) + .entry("handle", 0 as u64) + .entry("routine", 815 as u64) + .entry("legacy", true); /// launchctl load [path] - pub static ref LOAD_PATHS: HashMap<&'static str, XPCObject> = { - let mut msg = HashMap::new(); - msg.insert("routine", XPCObject::from(800 as u64)); - msg.insert("subsystem", XPCObject::from(3 as u64)); - msg.insert("handle", XPCObject::from(0 as u64)); - msg.insert("legacy", XPCObject::from(true)); - msg.insert("legacy-load", XPCObject::from(true)); - msg.insert("enable", XPCObject::from(false)); - msg.insert("no-einprogress", XPCObject::from(true)); - - msg.insert( - "domain-port", - XPCObject::from(get_bootstrap_port() as mach_port_t), - ); - - msg - }; + pub static ref LOAD_PATHS: XPCDictionary = XPCDictionary::new() + .with_domain_port() + .entry("routine", 800 as u64) + .entry("subsystem", 3 as u64) + .entry("handle", 0 as u64) + .entry("legacy", true) + .entry("legacy-load", true) + .entry("enable", false) + .entry("no-einprogress", true); /// launchctl unload [path] - pub static ref UNLOAD_PATHS: HashMap<&'static str, XPCObject> = { - let mut msg = HashMap::new(); - msg.insert("routine", XPCObject::from(801 as u64)); - msg.insert("subsystem", XPCObject::from(3 as u64)); - msg.insert("handle", XPCObject::from(0 as u64)); - msg.insert("legacy", XPCObject::from(true)); - msg.insert("legacy-load", XPCObject::from(true)); - msg.insert("enable", XPCObject::from(false)); - msg.insert("no-einprogress", XPCObject::from(true)); - - msg.insert( - "domain-port", - XPCObject::from(get_bootstrap_port() as mach_port_t), - ); - - msg - }; -} - -pub fn from_msg<'a>(proto: &HashMap<&'a str, XPCObject>) -> HashMap<&'a str, XPCObject> { - let mut new_msg: HashMap<&str, XPCObject> = HashMap::new(); - new_msg.extend(proto.iter().map(|(k, v)| (k.clone(), v.clone()))); - new_msg + pub static ref UNLOAD_PATHS: XPCDictionary = XPCDictionary::new() + .with_domain_port() + .entry("routine", 801 as u64) + .entry("subsystem", 3 as u64) + .entry("handle", 0 as u64) + .entry("legacy", true) + .entry("legacy-load", true) + .entry("enable", false) + .entry("no-einprogress", true); + + + pub static ref ENABLE_NAMES: XPCDictionary = XPCDictionary::new() + .with_domain_port() + // .entry("handle", UID or ASID) + .entry("routine", 808 as u64) + .entry("subsystem", 3 as u64); + + pub static ref DISABLE_NAMES: XPCDictionary = XPCDictionary::new() + .with_domain_port() + // .entry("handle", UID or ASID) + .entry("routine", 809 as u64) + .entry("subsystem", 3 as u64); } diff --git a/launchk/src/launchd/mod.rs b/launchk/src/launchd/mod.rs index e1d0ed5..e57dcd1 100644 --- a/launchk/src/launchd/mod.rs +++ b/launchk/src/launchd/mod.rs @@ -1,9 +1,12 @@ pub mod message; +pub mod entry_status; /// queries (sorta?) pub mod query; +pub mod query_builder; -pub mod entry_status; +pub mod enums; pub mod job_type_filter; + /// plist management pub mod plist; diff --git a/launchk/src/launchd/plist.rs b/launchk/src/launchd/plist.rs index 2302b9c..e8fe1a3 100644 --- a/launchk/src/launchd/plist.rs +++ b/launchk/src/launchd/plist.rs @@ -154,18 +154,18 @@ fn build_label_map_entry(plist_path: DirEntry) -> Option<(String, LaunchdPlist)> .and_then(|d| d.get("Label")) .and_then(|v| v.as_string()); - let entry_type = if path_string.contains(ADMIN_LAUNCH_DAEMONS) - || path_string.contains(GLOBAL_LAUNCH_DAEMONS) + let entry_type = if path_string.starts_with(ADMIN_LAUNCH_DAEMONS) + || path_string.starts_with(GLOBAL_LAUNCH_DAEMONS) { LaunchdEntryType::Daemon } else { LaunchdEntryType::Agent }; - let entry_location = if path_string.contains(USER_LAUNCH_AGENTS) { + let entry_location = if path_string.starts_with(USER_LAUNCH_AGENTS) { LaunchdEntryLocation::User - } else if path_string.contains(GLOBAL_LAUNCH_AGENTS) - || path_string.contains(ADMIN_LAUNCH_DAEMONS) + } else if path_string.starts_with(GLOBAL_LAUNCH_AGENTS) + || path_string.starts_with(ADMIN_LAUNCH_DAEMONS) { LaunchdEntryLocation::Global } else { @@ -243,7 +243,7 @@ pub fn init_plist_map(runtime_handle: &Handle) { insert_plists(plists); // Spawn fsnotify subscriber - runtime_handle.spawn(async { fsnotify_subscriber().await }); + runtime_handle.spawn(fsnotify_subscriber()); } /// Get plist for a label @@ -291,7 +291,8 @@ pub fn edit_and_replace(plist_meta: &LaunchdPlist) -> Result<(), String> { } // temp file -> validate with crate -> original - let plist = plist::Value::from_file(&temp_path).map_err(|e| e.to_string())?; + let plist = + plist::Value::from_file(&temp_path).map_err(|e| format!("Changes not saved: {}", e))?; let writer = if is_binary { plist::Value::to_file_binary } else { diff --git a/launchk/src/launchd/query.rs b/launchk/src/launchd/query.rs index 45a3e75..03b02fc 100644 --- a/launchk/src/launchd/query.rs +++ b/launchk/src/launchd/query.rs @@ -1,112 +1,50 @@ -use crate::launchd::message::{from_msg, LIST_SERVICES, LOAD_PATHS, UNLOAD_PATHS}; -use std::collections::{HashMap, HashSet}; -use std::convert::{TryFrom, TryInto}; -use std::fmt; +use crate::launchd::message::{ + DISABLE_NAMES, ENABLE_NAMES, LIST_SERVICES, LOAD_PATHS, UNLOAD_PATHS, +}; +use std::collections::HashSet; -use xpc_sys::objects::xpc_object::XPCObject; -use xpc_sys::objects::xpc_type; -use xpc_sys::traits::xpc_pipeable::{XPCPipeResult, XPCPipeable}; -use xpc_sys::traits::xpc_value::TryXPCValue; +use xpc_sys::traits::xpc_pipeable::XPCPipeable; use crate::launchd::entry_status::ENTRY_STATUS_CACHE; use std::iter::FromIterator; use xpc_sys::objects::xpc_dictionary::XPCDictionary; use xpc_sys::objects::xpc_error::XPCError; -use xpc_sys::objects::xpc_type::check_xpc_type; -#[link(name = "c")] -extern "C" { - fn geteuid() -> u32; -} - -lazy_static! { - static ref IS_ROOT: bool = unsafe { geteuid() } == 0; -} - -/// LimitLoadToSessionType key in XPC response -/// https://developer.apple.com/library/archive/technotes/tn2083/_index.html -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum LimitLoadToSessionType { - Aqua, - StandardIO, - Background, - LoginWindow, - System, - Unknown, -} - -impl fmt::Display for LimitLoadToSessionType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{:?}", self) - } -} - -// TODO: This feels terrible -impl From for LimitLoadToSessionType { - fn from(value: String) -> Self { - let aqua: String = LimitLoadToSessionType::Aqua.to_string(); - let standard_io: String = LimitLoadToSessionType::StandardIO.to_string(); - let background: String = LimitLoadToSessionType::Background.to_string(); - let login_window: String = LimitLoadToSessionType::LoginWindow.to_string(); - let system: String = LimitLoadToSessionType::System.to_string(); - - match value { - s if s == aqua => LimitLoadToSessionType::Aqua, - s if s == standard_io => LimitLoadToSessionType::StandardIO, - s if s == background => LimitLoadToSessionType::Background, - s if s == login_window => LimitLoadToSessionType::LoginWindow, - s if s == system => LimitLoadToSessionType::System, - _ => LimitLoadToSessionType::Unknown, - } - } -} - -impl TryFrom for LimitLoadToSessionType { - type Error = XPCError; - - fn try_from(value: XPCObject) -> Result { - check_xpc_type(&value, &xpc_type::String)?; - let string: String = value.xpc_value().unwrap(); - Ok(string.into()) - } -} +use crate::launchd::enums::{DomainType, SessionType}; +use crate::launchd::query_builder::QueryBuilder; -pub fn find_in_all>(label: S) -> Result { +pub fn find_in_all>(label: S) -> Result<(DomainType, XPCDictionary), XPCError> { let label_string = label.into(); - for domain_type in 0..8 { - let response = list(domain_type, Some(label_string.clone())); + for domain_type in DomainType::System as u64..DomainType::RequestorDomain as u64 { + let response = XPCDictionary::new() + .extend(&LIST_SERVICES) + .entry("type", domain_type) + .entry("name", label_string.clone()) + .pipe_routine_with_error_handling(); + if response.is_ok() { - return response; + return response.map(|r| (domain_type.into(), r)); } } Err(XPCError::NotFound) } -pub fn list(domain_type: u64, name: Option) -> Result { - let mut msg = from_msg(&LIST_SERVICES); - msg.insert("type", domain_type.into()); - - if name.is_some() { - msg.insert("name", name.unwrap().into()); - } - - let msg: XPCObject = msg.into(); - msg.pipe_routine() - .and_then(|o| o.try_into()) - .and_then(|dict: XPCDictionary| { - dict.0 - .get("error") - .map(|e| Err(XPCError::PipeError(e.to_string()))) - .unwrap_or(Ok(dict)) - }) +/// Query for jobs in a domain +pub fn list(domain_type: DomainType, name: Option) -> Result { + XPCDictionary::new() + .extend(&LIST_SERVICES) + .with_domain_type_or_default(Some(domain_type)) + .entry_if_present("name", name) + .pipe_routine_with_error_handling() } +/// Query for jobs across all domain types pub fn list_all() -> HashSet { - let everything = (0..8) + let everything = (DomainType::System as u64..DomainType::RequestorDomain as u64) .filter_map(|t| { - let svc_for_type = list(t as u64, None) + let svc_for_type = list(t.into(), None) .and_then(|d| d.get_as_dictionary(&["services"])) .map(|XPCDictionary(ref hm)| hm.keys().map(|k| k.clone()).collect()); @@ -117,81 +55,74 @@ pub fn list_all() -> HashSet { HashSet::from_iter(everything) } -pub fn load>(label: S, plist_path: S) -> XPCPipeResult { - let mut message: HashMap<&str, XPCObject> = from_msg(&LOAD_PATHS); - let label_string = label.into(); - - message.insert( - "type", - if *IS_ROOT { - XPCObject::from(1 as u64) - } else { - XPCObject::from(7 as u64) - }, - ); - - let paths = vec![XPCObject::from(plist_path.into())]; - message.insert("paths", XPCObject::from(paths)); - message.insert("session", XPCObject::from("Aqua")); - - let message: XPCObject = message.into(); - +pub fn load>( + label: S, + plist_path: S, + domain_type: Option, + session: Option, + handle: Option, +) -> Result { ENTRY_STATUS_CACHE .lock() .expect("Must invalidate") - .remove(&label_string); - - handle_load_unload_errors(label_string, message.pipe_routine()?) + .remove(&label.into()); + + XPCDictionary::new() + .extend(&LOAD_PATHS) + .with_domain_type_or_default(domain_type) + .with_session_type_or_default(session) + .with_handle_or_default(handle) + .entry("paths", vec![plist_path.into()]) + .pipe_routine_with_error_handling() } -pub fn unload>(label: S, plist_path: S) -> XPCPipeResult { - let mut message: HashMap<&str, XPCObject> = from_msg(&UNLOAD_PATHS); - let label_string = label.into(); - - message.insert( - "type", - if *IS_ROOT { - XPCObject::from(1 as u64) - } else { - XPCObject::from(7 as u64) - }, - ); - - let paths = vec![XPCObject::from(plist_path.into())]; - message.insert("paths", XPCObject::from(paths)); - message.insert("session", XPCObject::from("Aqua")); - - let message: XPCObject = message.into(); - +pub fn unload>( + label: S, + plist_path: S, + domain_type: Option, + session: Option, + handle: Option, +) -> Result { ENTRY_STATUS_CACHE .lock() .expect("Must invalidate") - .remove(&label_string); - - handle_load_unload_errors(label_string, message.pipe_routine()?) + .remove(&label.into()); + + XPCDictionary::new() + .extend(&UNLOAD_PATHS) + .with_domain_type_or_default(domain_type) + .with_session_type_or_default(session) + .with_handle_or_default(handle) + .entry("paths", vec![plist_path.into()]) + .pipe_routine_with_error_handling() } -fn handle_load_unload_errors(label: String, result: XPCObject) -> XPCPipeResult { - let dict: XPCDictionary = result.clone().try_into()?; - let error_dict = dict.get_as_dictionary(&["errors"]); - - if error_dict.is_err() { - Ok(result) - } else { - let mut error_string = "".to_string(); - let XPCDictionary(hm) = error_dict.unwrap(); +pub fn enable>( + label: S, + domain_type: DomainType, +) -> Result { + let label_string = label.into(); - if hm.is_empty() { - return Ok(result); - } + XPCDictionary::new() + .extend(&ENABLE_NAMES) + .with_domain_type_or_default(Some(domain_type)) + .entry("name", label_string.clone()) + .entry("names", vec![label_string]) + .with_handle_or_default(None) + .pipe_routine_with_error_handling() +} - for (_, errcode) in hm { - let errcode: i64 = errcode.xpc_value().unwrap(); - error_string.push_str( - format!("{}: {}\n", label, xpc_sys::rs_xpc_strerror(errcode as i32)).as_str(), - ); - } +pub fn disable>( + label: S, + domain_type: DomainType, +) -> Result { + let label_string = label.into(); - Err(XPCError::QueryError(error_string)) - } + XPCDictionary::new() + .extend(&DISABLE_NAMES) + .with_domain_type_or_default(Some(domain_type)) + .entry("name", label_string.clone()) + .entry("names", vec![label_string]) + .with_handle_or_default(None) + .pipe_routine_with_error_handling() } diff --git a/launchk/src/launchd/query_builder.rs b/launchk/src/launchd/query_builder.rs new file mode 100644 index 0000000..dda5cf0 --- /dev/null +++ b/launchk/src/launchd/query_builder.rs @@ -0,0 +1,70 @@ +use crate::launchd::enums::{DomainType, SessionType}; +use xpc_sys::objects::xpc_dictionary::XPCDictionary; +use xpc_sys::objects::xpc_object::XPCObject; +use xpc_sys::{get_bootstrap_port, mach_port_t}; + +pub trait QueryBuilder { + fn entry, O: Into>(self, key: S, value: O) -> XPCDictionary; + fn entry_if_present, O: Into>( + self, + key: S, + value: Option, + ) -> XPCDictionary; + + fn extend(self, other: &XPCDictionary) -> XPCDictionary; + + fn with_domain_port(self) -> XPCDictionary + where + Self: Sized, + { + self.entry("domain-port", get_bootstrap_port() as mach_port_t) + } + + fn with_session_type_or_default(self, session: Option) -> XPCDictionary + where + Self: Sized, + { + self.entry("session", session.unwrap_or(SessionType::Aqua).to_string()) + } + + fn with_handle_or_default(self, session: Option) -> XPCDictionary + where + Self: Sized, + { + self.entry("handle", session.unwrap_or(0)) + } + + fn with_domain_type_or_default(self, t: Option) -> XPCDictionary + where + Self: Sized, + { + self.entry("type", t.unwrap_or(DomainType::RequestorDomain) as u64) + } +} + +impl QueryBuilder for XPCDictionary { + fn entry, O: Into>(mut self, key: S, value: O) -> XPCDictionary { + let Self(hm) = &mut self; + hm.insert(key.into(), value.into()); + self + } + + fn entry_if_present, O: Into>( + self, + key: S, + value: Option, + ) -> XPCDictionary { + if value.is_none() { + self + } else { + self.entry(key, value.unwrap()) + } + } + + fn extend(mut self, other: &XPCDictionary) -> XPCDictionary { + let Self(self_hm) = &mut self; + let Self(other_hm) = other; + self_hm.extend(other_hm.iter().map(|(s, o)| (s.clone(), o.clone()))); + self + } +} diff --git a/launchk/src/main.rs b/launchk/src/main.rs index 05a6bd1..82ebdc9 100644 --- a/launchk/src/main.rs +++ b/launchk/src/main.rs @@ -9,8 +9,8 @@ extern crate bitflags; extern crate plist; -use cursive::view::Resizable; -use cursive::views::Panel; +use cursive::view::{Resizable, AnyView}; +use cursive::views::{Panel, NamedView}; use cursive::Cursive; use std::process::exit; @@ -28,7 +28,7 @@ fn main() { .build() .expect("Must build tokio runtime"); - // Cache launchd job plists, spawn fsnotify to keep up with changes + // Cache launchd job plist paths, spawn fsnotify to keep up with changes PLIST_MAP_INIT.call_once(|| init_plist_map(runtime.handle())); let mut siv: Cursive = cursive::default(); @@ -36,6 +36,7 @@ fn main() { .expect("Must load styles"); let root_layout = RootLayout::new(&mut siv, runtime.handle()); + let root_layout = NamedView::new("root_layout", root_layout); let panel = Panel::new(root_layout) .title("launchk") @@ -44,6 +45,10 @@ fn main() { siv.add_layer(panel); siv.run(); + siv.quit(); + // Fix reset on exit + // https://github.com/gyscos/cursive/issues/415 + drop(siv); exit(0); } diff --git a/launchk/src/tui/dialog.rs b/launchk/src/tui/dialog.rs index 97db63a..cdb2b9e 100644 --- a/launchk/src/tui/dialog.rs +++ b/launchk/src/tui/dialog.rs @@ -1,11 +1,19 @@ use std::sync::mpsc::Sender; -use cursive::views::{Dialog, TextView}; use cursive::Cursive; +use cursive::{ + theme::Effect, + view::Margins, + views::{Dialog, DummyView, LinearLayout, RadioGroup, TextView}, +}; -use crate::tui::omnibox::command::OmniboxCommand; use crate::tui::omnibox::view::OmniboxEvent; use crate::tui::root::CbSinkMessage; +use crate::{ + launchd::enums::{DomainType, SessionType}, + tui::omnibox::command::OmniboxCommand, +}; +use crate::launchd::entry_status::{get_entry_status, LaunchdEntryStatus}; /// The XPC error key sometimes contains information that is not necessarily a failure, /// so let's just call it "Notice" until we figure out what to do next? @@ -32,16 +40,95 @@ pub fn show_prompt( let cl = move |siv: &mut Cursive| { let ask = Dialog::around(TextView::new(prompt.clone())) .button("Yes", move |s| { - for cmd in &commands { - tx.send(OmniboxEvent::Command(cmd.clone())) - .expect("Must send command"); - } + commands + .iter() + .try_for_each(|c| tx.send(OmniboxEvent::Command(c.clone()))) + .expect("Must sent commands"); + s.pop_layer(); }) - .button("No", |s| { + .dismiss_button("No") + .title("Notice"); + + siv.add_layer(ask); + }; + + Box::new(cl) +} + +pub fn domain_session_prompt>( + label: S, + domain_only: bool, + tx: Sender, + f: fn(DomainType, Option) -> Vec, +) -> CbSinkMessage { + let LaunchdEntryStatus { + limit_load_to_session_type, + domain, + .. + } = get_entry_status(label); + + let cl = move |siv: &mut Cursive| { + let mut domain_group: RadioGroup = RadioGroup::new(); + let mut st_group: RadioGroup = RadioGroup::new(); + + // Build domain type list + let mut domain_type_layout = LinearLayout::vertical() + .child(TextView::new("Domain Type").effect(Effect::Bold)) + .child(DummyView); + + for d in DomainType::System as u64..DomainType::Unknown as u64 { + let as_domain: DomainType = d.into(); + let mut button = domain_group.button(as_domain.clone(), format!("{}: {}", d, &as_domain)); + if as_domain == domain { + button = button.selected(); + } + + domain_type_layout = domain_type_layout.child(button); + } + + let mut session_type_layout = LinearLayout::vertical(); + + if !domain_only { + session_type_layout = session_type_layout + .child(TextView::new("Session Type").effect(Effect::Bold)) + .child(DummyView); + + for s in SessionType::Aqua as u64..SessionType::Unknown as u64 { + let as_session: SessionType = s.into(); + + let mut button = st_group.button(as_session.clone(), as_session.to_string()); + if as_session == limit_load_to_session_type { + button = button.selected(); + } + session_type_layout = session_type_layout.child(button); + } + } + + let layout = LinearLayout::horizontal() + .child(domain_type_layout) + .child(session_type_layout); + + let ask = Dialog::new() + .title("Please select to continue") + .content(layout) + .button("OK", move |s| { + let dt = domain_group.selection().as_ref().clone(); + let st = if domain_only { + None + } else { + Some(st_group.selection().as_ref().clone()) + }; + + f(dt, st) + .iter() + .try_for_each(|c| tx.send(OmniboxEvent::Command(c.clone()))) + .expect("Must send commands"); + s.pop_layer(); }) - .title("Notice"); + .dismiss_button("Cancel") + .padding(Margins::trbl(5, 5, 5, 5)); siv.add_layer(ask); }; diff --git a/launchk/src/tui/omnibox/command.rs b/launchk/src/tui/omnibox/command.rs index b157db1..9c30df8 100644 --- a/launchk/src/tui/omnibox/command.rs +++ b/launchk/src/tui/omnibox/command.rs @@ -1,12 +1,31 @@ +use crate::launchd::enums::{DomainType, SessionType}; use std::fmt; #[derive(Debug, Clone, Eq, PartialEq)] pub enum OmniboxCommand { - Load, - Unload, + Chain(Vec), + // Try to see if we have session type & domain in entry_status, + // to avoid having to prompt the user + LoadRequest, + UnloadRequest, + EnableRequest, + DisableRequest, + Load(SessionType, DomainType, Option), + Unload(DomainType, Option), + // Reuses domain, handle, limit load to session type from existing + Reload, + Enable(DomainType), + Disable(DomainType), Edit, // (message, on ok) - Prompt(String, Vec), + Confirm(String, Vec), + // (unit label, prompt for domain only?, action gen fn) + DomainSessionPrompt( + String, + bool, + fn(DomainType, Option) -> Vec, + ), + FocusServiceList, Quit, } @@ -16,12 +35,37 @@ impl fmt::Display for OmniboxCommand { } } -pub static OMNIBOX_COMMANDS: [(OmniboxCommand, &str); 4] = [ - (OmniboxCommand::Load, "▶️ Load highlighted job"), - (OmniboxCommand::Unload, "⏏️ Unload highlighted job"), +pub static OMNIBOX_COMMANDS: [(&str, &str, OmniboxCommand); 7] = [ ( - OmniboxCommand::Edit, + "load", + "▶️ Load highlighted job", + OmniboxCommand::LoadRequest, + ), + ( + "unload", + "⏏️ Unload highlighted job", + OmniboxCommand::UnloadRequest, + // OmniboxCommand::DomainSessionPrompt(false, |dt, _| vec![OmniboxCommand::Unload(dt, None)]), + ), + ( + "enable", + "▶️ Enable highlighted job (enables load)", + OmniboxCommand::EnableRequest, + ), + ( + "disable", + "⏏️ Disable highlighted job (prevents load)", + OmniboxCommand::DisableRequest + ), + ( + "edit", "✍️ Edit plist with $EDITOR, then reload job", + OmniboxCommand::Edit, + ), + ( + "reload", + "🔄 Reload highlighted job", + OmniboxCommand::Reload, ), - (OmniboxCommand::Quit, "🚪 see ya!"), + ("exit", "🚪 see ya!", OmniboxCommand::Quit), ]; diff --git a/launchk/src/tui/omnibox/state.rs b/launchk/src/tui/omnibox/state.rs index c56a792..ad9f3c9 100644 --- a/launchk/src/tui/omnibox/state.rs +++ b/launchk/src/tui/omnibox/state.rs @@ -32,7 +32,7 @@ impl OmniboxState { } /// Suggest a command based on name filter - pub fn suggest_command(&self) -> Option<(OmniboxCommand, &str)> { + pub fn suggest_command(&self) -> Option<(&str, &str, OmniboxCommand)> { let OmniboxState { mode, command_filter, @@ -45,7 +45,7 @@ impl OmniboxState { OMNIBOX_COMMANDS .iter() - .filter(|(c, _)| c.to_string().contains(command_filter)) + .filter(|(c, _, _)| c.to_string().contains(command_filter)) .next() .map(|s| s.clone()) } diff --git a/launchk/src/tui/omnibox/view.rs b/launchk/src/tui/omnibox/view.rs index 39075f0..f8d8a26 100644 --- a/launchk/src/tui/omnibox/view.rs +++ b/launchk/src/tui/omnibox/view.rs @@ -15,11 +15,10 @@ use crate::launchd::job_type_filter::JobTypeFilter; use crate::tui::omnibox::command::OmniboxCommand; use crate::tui::omnibox::state::OmniboxState; -/// Consumers impl OmniboxSubscriber receive these commands +/// Consumers impl OmniboxSubscriber receive these events /// via a channel in a wrapped view #[derive(Debug, Clone, Eq, PartialEq)] pub enum OmniboxEvent { - FocusServiceList, StateUpdate(OmniboxState), Command(OmniboxCommand), } @@ -42,7 +41,7 @@ pub enum OmniboxMode { /// Move OmniboxState back to idle some time after the user stops /// interacting with it async fn tick(state: Arc>, tx: Sender) { - let mut tick_rate = interval(Duration::from_millis(500)); + let mut tick_rate = interval(Duration::from_millis(250)); loop { tick_rate.tick().await; @@ -70,14 +69,13 @@ async fn tick(state: Arc>, tx: Sender) { drop(read); let mut write = state.write().expect("Must write"); - let out = [ - tx.send(OmniboxEvent::FocusServiceList), - tx.send(OmniboxEvent::StateUpdate(new.clone())), - ]; - - for msg in out.iter() { - msg.as_ref().expect("Must send"); - } + [ + OmniboxEvent::Command(OmniboxCommand::FocusServiceList), + OmniboxEvent::StateUpdate(new.clone()), + ] + .iter() + .try_for_each(|e| tx.send(e.clone())) + .expect("Must send events"); log::debug!("[omnibox/tick]: New state: {:?}", &new); @@ -102,7 +100,7 @@ impl OmniboxView { let tx_state = state.clone(); let tx_tick = tx.clone(); - handle.spawn(async move { tick(tx_state, tx_tick).await }); + handle.spawn(tick(tx_state, tx_tick)); ( Self { @@ -127,8 +125,8 @@ impl OmniboxView { let matched_command = suggested_command .as_ref() - .filter(|(cmd, _)| cmd.to_string() == *command_filter) - .map(|(cmd, _)| cmd.clone()); + .filter(|(cmd, _, _)| *cmd == *command_filter) + .map(|(_, _, oc)| oc.clone()); let (lf_char, cf_char) = match (event, mode) { (Event::Char(c), OmniboxMode::LabelFilter) => { @@ -167,7 +165,7 @@ impl OmniboxView { } // Complete suggestion (Event::Key(Key::Tab), OmniboxMode::CommandFilter) if suggested_command.is_some() => { - let (cmd, _) = suggested_command.unwrap(); + let (cmd, _, _) = suggested_command.unwrap(); // Can submit from here, but catching a glimpse of the whole command // highlighting before flushing back out is confirmation that it did something @@ -272,7 +270,7 @@ impl OmniboxView { if suggestion.is_none() { return; } - let (cmd, desc) = suggestion.unwrap(); + let (cmd, desc, ..) = suggestion.unwrap(); let cmd_string = cmd.to_string().replace(&state.command_filter, ""); printer.with_style(Style::from(Color::Light(BaseColor::Black)), |p| { @@ -359,13 +357,13 @@ impl View for OmniboxView { let new_state = match (event, mode) { (Event::CtrlChar('u'), _) => { self.tx - .send(OmniboxEvent::FocusServiceList) + .send(OmniboxEvent::Command(OmniboxCommand::FocusServiceList)) .expect("Must focus"); Some(OmniboxState::default()) } (Event::Key(Key::Esc), _) => { self.tx - .send(OmniboxEvent::FocusServiceList) + .send(OmniboxEvent::Command(OmniboxCommand::FocusServiceList)) .expect("Must focus"); Some(state.with_new(Some(OmniboxMode::Idle), None, Some("".to_string()), None)) } diff --git a/launchk/src/tui/root.rs b/launchk/src/tui/root.rs index 22b09f5..be9a74e 100644 --- a/launchk/src/tui/root.rs +++ b/launchk/src/tui/root.rs @@ -5,9 +5,9 @@ use std::time::Duration; use cursive::event::{Event, EventResult, Key}; use cursive::traits::{Resizable, Scrollable}; -use cursive::view::ViewWrapper; +use cursive::view::{ViewWrapper, AnyView}; use cursive::views::{LinearLayout, NamedView, Panel}; -use cursive::{Cursive, Vec2, View}; +use cursive::{Cursive, Vec2, View, Printer}; use tokio::runtime::Handle; use tokio::time::interval; @@ -25,9 +25,7 @@ pub type CbSinkMessage = Box; pub struct RootLayout { layout: LinearLayout, - omnibox_rx: Receiver, omnibox_tx: Sender, - last_focus_index: RefCell, runtime_handle: Handle, cbsink_channel: Sender, key_ring: VecDeque, @@ -41,16 +39,33 @@ enum RootLayoutChildren { ServiceList, } +async fn poll_omnibox(cb_sink: Sender, rx: Receiver) { + loop { + let recv = rx + .recv() + .expect("Must receive event"); + + log::info!("[root_layout/poll_omnibox]: RECV {:?}", recv); + + cb_sink.send(Box::new(|siv| { + siv.call_on_name("root_layout", |v: &mut NamedView| { + v.get_mut().handle_omnibox_event(recv); + }); + })); + } +} + impl RootLayout { pub fn new(siv: &mut Cursive, runtime_handle: &Handle) -> Self { let (omnibox, omnibox_tx, omnibox_rx) = OmniboxView::new(runtime_handle); + let cbsink_channel = RootLayout::cbsink_channel(siv, runtime_handle); + + runtime_handle.spawn(poll_omnibox(cbsink_channel.clone(), omnibox_rx)); let mut new = Self { omnibox_tx, - omnibox_rx, + cbsink_channel, layout: LinearLayout::vertical(), - last_focus_index: RefCell::new(RootLayoutChildren::ServiceList as usize), - cbsink_channel: RootLayout::cbsink_channel(siv, runtime_handle), runtime_handle: runtime_handle.clone(), key_ring: VecDeque::with_capacity(3), }; @@ -89,11 +104,7 @@ impl RootLayout { let sink = siv.cb_sink().clone(); handle.spawn(async move { - let mut interval = interval(Duration::from_millis(500)); - loop { - interval.tick().await; - if let Ok(cb_sink_msg) = rx.recv() { sink.send(cb_sink_msg) .expect("Cannot forward CbSink message") @@ -111,31 +122,17 @@ impl RootLayout { self.layout.on_event(event) } - /// Poll for Omnibox commands without blocking - fn poll_omnibox(&mut self) { - let recv = self.omnibox_rx.try_recv(); - - if recv.is_err() { - return; - } - - let recv = recv.unwrap(); - log::info!("[root/poll_omnibox]: {:?}", recv); - + fn handle_omnibox_event(&mut self, recv: OmniboxEvent) { self.on_omnibox(recv.clone()) .expect("Root for effects only"); - // The Omnibox command is sent to the actively focused view let target = self .layout - .get_child_mut(*self.last_focus_index.borrow()) - .and_then(|v| v.as_any_mut().downcast_mut::()); + .get_child_mut(RootLayoutChildren::ServiceList as usize) + .and_then(|v| v.as_any_mut().downcast_mut::()) + .expect("Must forward to ServiceList"); - if target.is_none() { - return; - } - - match target.unwrap().on_omnibox(recv) { + match target.on_omnibox(recv) { // Forward Omnibox command responses from view Ok(Some(c)) => self .omnibox_tx @@ -183,11 +180,10 @@ impl RootLayout { impl ViewWrapper for RootLayout { wrap_impl!(self.layout: LinearLayout); - + fn wrap_on_event(&mut self, event: Event) -> EventResult { log::debug!("[root/event]: {:?}", event); - self.poll_omnibox(); let ev = match event { Event::Char('/') | Event::Char(':') @@ -197,7 +193,10 @@ impl ViewWrapper for RootLayout { | Event::Char('u') | Event::Char('a') | Event::Char('d') - | Event::Char('l') => self.focus_and_forward(RootLayoutChildren::Omnibox, event), + | Event::Char('l') + | Event::Key(Key::Backspace) => { + self.focus_and_forward(RootLayoutChildren::Omnibox, event) + } // TODO: wtf? // After exiting $EDITOR, for some reason we get a termcap issue. iTerm and Apple Terminal // exhibit the same behavior. This was the easiest way to solve the problem for now. @@ -214,13 +213,10 @@ impl ViewWrapper for RootLayout { _ => self.layout.on_event(event), }; - self.poll_omnibox(); - ev } fn wrap_layout(&mut self, size: Vec2) { - self.poll_omnibox(); self.layout.layout(size) } } @@ -228,6 +224,12 @@ impl ViewWrapper for RootLayout { impl OmniboxSubscriber for RootLayout { fn on_omnibox(&mut self, cmd: OmniboxEvent) -> OmniboxResult { match cmd { + OmniboxEvent::Command(OmniboxCommand::Chain(cmds)) => { + cmds.iter() + .try_for_each(|c| self.omnibox_tx.send(OmniboxEvent::Command(c.clone()))) + .expect("Must send commands"); + Ok(None) + } OmniboxEvent::Command(OmniboxCommand::Quit) => { self.cbsink_channel .send(Box::new(|s| { @@ -237,18 +239,29 @@ impl OmniboxSubscriber for RootLayout { Ok(None) } // Triggered when toggling to idle - OmniboxEvent::FocusServiceList => { + OmniboxEvent::Command(OmniboxCommand::FocusServiceList) => { self.layout .set_focus_index(RootLayoutChildren::ServiceList as usize) .expect("Must focus SL"); Ok(None) } - OmniboxEvent::Command(OmniboxCommand::Prompt(p, c)) => { + OmniboxEvent::Command(OmniboxCommand::Confirm(p, c)) => { self.cbsink_channel .send(dialog::show_prompt(self.omnibox_tx.clone(), p, c)) .expect("Must show prompt"); Ok(None) } + OmniboxEvent::Command(OmniboxCommand::DomainSessionPrompt(label, domain_only, f)) => { + self.cbsink_channel + .send(dialog::domain_session_prompt( + label, + domain_only, + self.omnibox_tx.clone(), + f, + )) + .expect("Must show prompt"); + Ok(None) + } _ => Ok(None), } } diff --git a/launchk/src/tui/service_list/list_item.rs b/launchk/src/tui/service_list/list_item.rs index 27cf03c..1de8b35 100644 --- a/launchk/src/tui/service_list/list_item.rs +++ b/launchk/src/tui/service_list/list_item.rs @@ -4,7 +4,7 @@ use crate::launchd::entry_status::LaunchdEntryStatus; use crate::launchd::job_type_filter::JobTypeFilter; use crate::tui::table::table_list_view::TableListItem; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ServiceListItem { pub name: String, pub status: LaunchdEntryStatus, diff --git a/launchk/src/tui/service_list/view.rs b/launchk/src/tui/service_list/view.rs index 679ad22..d6de41d 100644 --- a/launchk/src/tui/service_list/view.rs +++ b/launchk/src/tui/service_list/view.rs @@ -13,10 +13,12 @@ use cursive::{Cursive, View, XY}; use tokio::runtime::Handle; use tokio::time::interval; -use crate::launchd::entry_status::get_entry_status; use crate::launchd::job_type_filter::JobTypeFilter; use crate::launchd::plist::{edit_and_replace, LABEL_TO_ENTRY_CONFIG}; -use crate::launchd::query::{list_all, load, unload}; +use crate::launchd::query::{disable, enable, list_all, load, unload}; +use crate::launchd::{ + entry_status::get_entry_status, entry_status::LaunchdEntryStatus, plist::LaunchdPlist, +}; use crate::tui::omnibox::command::OmniboxCommand; use crate::tui::omnibox::state::OmniboxState; use crate::tui::omnibox::subscribed_view::{OmniboxResult, OmniboxSubscriber}; @@ -24,6 +26,8 @@ use crate::tui::omnibox::view::{OmniboxError, OmniboxEvent, OmniboxMode}; use crate::tui::root::CbSinkMessage; use crate::tui::service_list::list_item::ServiceListItem; use crate::tui::table::table_list_view::TableListView; +use crate::launchd::enums::{SessionType, DomainType}; +use crate::tui::omnibox::command::OmniboxCommand::DomainSessionPrompt; /// Polls XPC for job list async fn poll_running_jobs(svcs: Arc>>, cb_sink: Sender) { @@ -45,6 +49,7 @@ async fn poll_running_jobs(svcs: Arc>>, cb_sink: Sender, running_jobs: Arc>>, table_list_view: TableListView, label_filter: RefCell, @@ -54,11 +59,10 @@ pub struct ServiceListView { impl ServiceListView { pub fn new(runtime_handle: &Handle, cb_sink: Sender) -> Self { let arc_svc = Arc::new(RwLock::new(HashSet::new())); - let ref_clone = arc_svc.clone(); - - runtime_handle.spawn(async move { poll_running_jobs(ref_clone, cb_sink).await }); + runtime_handle.spawn(poll_running_jobs(arc_svc.clone(), cb_sink.clone())); Self { + cb_sink, running_jobs: arc_svc.clone(), label_filter: RefCell::new("".into()), job_type_filter: RefCell::new(JobTypeFilter::launchk_default()), @@ -168,45 +172,144 @@ impl ServiceListView { .ok_or_else(|| OmniboxError::CommandError("Cannot get highlighted row".to_string())) } - fn handle_command(&mut self, cmd: OmniboxCommand) -> OmniboxResult { + fn with_active_item_plist(&self) -> Result<(ServiceListItem, LaunchdPlist), OmniboxError> { + let item = &*self.get_active_list_item()?; + let plist = item + .status + .plist + .as_ref() + .ok_or_else(|| OmniboxError::CommandError("Cannot find plist".to_string()))?; + + Ok((item.clone(), plist.clone())) + } + + fn handle_command(&self, cmd: OmniboxCommand) -> OmniboxResult { match cmd { - OmniboxCommand::Edit => { - let ServiceListItem { - name, - status: entry_info, + OmniboxCommand::Reload => { + let (ServiceListItem { name, .. }, ..) = self.with_active_item_plist()?; + let LaunchdEntryStatus { + limit_load_to_session_type, + domain, .. - } = &*self.get_active_list_item()?; - - let plist = entry_info - .plist - .as_ref() - .ok_or_else(|| OmniboxError::CommandError("Cannot find plist".to_string()))?; + } = get_entry_status(&name); + + match (limit_load_to_session_type, domain) { + (_, DomainType::Unknown) | (SessionType::Unknown, _) => { + Ok(Some(OmniboxCommand::DomainSessionPrompt( + name.clone(), + false, + |dt, st| { + vec![ + OmniboxCommand::Unload(dt.clone(), None), + OmniboxCommand::Load(st.expect("Must provide"), dt, None), + ] + }, + ))) + }, + (st, dt) => Ok(Some(OmniboxCommand::Chain(vec![ + OmniboxCommand::Unload(dt.clone(), None), + OmniboxCommand::Load(st, dt, None), + ]))) + } + }, + OmniboxCommand::LoadRequest => { + let (ServiceListItem { name, .. }, ..) = self.with_active_item_plist()?; + Ok(Some(OmniboxCommand::DomainSessionPrompt( + name.clone(), + false, + |dt, st| { + vec![OmniboxCommand::Load( + st.expect("Must be provided"), + dt, + None, + )] + } + ))) + }, + OmniboxCommand::UnloadRequest => { + let (ServiceListItem { name, .. }, ..) = self.with_active_item_plist()?; + let LaunchdEntryStatus { + domain, + .. + } = get_entry_status(&name); - edit_and_replace(plist).map_err(OmniboxError::CommandError)?; - Ok(Some(OmniboxCommand::Prompt( - format!("Reload {}?", name), - vec![OmniboxCommand::Unload, OmniboxCommand::Load], + match domain { + DomainType::Unknown => Ok(Some(OmniboxCommand::DomainSessionPrompt(name.clone(), true, |dt, _| vec![OmniboxCommand::Unload(dt, None)]))), + _ => Ok(Some(OmniboxCommand::Unload(domain, None))) + } + }, + OmniboxCommand::EnableRequest => { + let (ServiceListItem { name, .. }, ..) = self.with_active_item_plist()?; + Ok(Some(OmniboxCommand::DomainSessionPrompt( + name.clone(), + true, + |dt, _| vec![OmniboxCommand::Enable(dt)] ))) } - OmniboxCommand::Load | OmniboxCommand::Unload => { - let ServiceListItem { - name, - status: entry_info, + OmniboxCommand::DisableRequest => { + let (ServiceListItem { name, .. }, ..) = self.with_active_item_plist()?; + let LaunchdEntryStatus { + domain, .. - } = &*self.get_active_list_item()?; + } = get_entry_status(&name); + + match domain { + DomainType::Unknown => Ok(Some(OmniboxCommand::DomainSessionPrompt( + name.clone(), + true, + |dt, _| vec![OmniboxCommand::Disable(dt)] + ))), + _ => Ok(Some(OmniboxCommand::Chain(vec![ + OmniboxCommand::Disable(domain) + ]))) + } + } + OmniboxCommand::Edit => { + let (ServiceListItem { name, .. }, plist) = self.with_active_item_plist()?; + edit_and_replace(&plist).map_err(OmniboxError::CommandError)?; - let plist = entry_info - .plist - .as_ref() - .ok_or_else(|| OmniboxError::CommandError("Cannot find plist".to_string()))?; + // Clear term + self.cb_sink + .send(Box::new(Cursive::clear)) + .expect("Must clear"); - let xpc_query = if cmd == OmniboxCommand::Load { - load - } else { - unload - }; + Ok(Some(OmniboxCommand::Confirm( + format!("Reload {}?", name), + vec![OmniboxCommand::Reload], + ))) + } + OmniboxCommand::Load(st, dt, _handle) => { + let (ServiceListItem { name, .. }, plist) = self.with_active_item_plist()?; + load(name, plist.plist_path, Some(dt), Some(st), None) + .map(|_| None) + .map_err(|e| OmniboxError::CommandError(e.to_string())) + }, + OmniboxCommand::Unload(dt, _handle) => { + let (ServiceListItem { name, .. }, plist) = self.with_active_item_plist()?; + let LaunchdEntryStatus { + limit_load_to_session_type, + .. + } = get_entry_status(&name); - xpc_query(name, &plist.plist_path) + unload( + name, + plist.plist_path, + Some(dt), + Some(limit_load_to_session_type), + None, + ) + .map(|_| None) + .map_err(|e| OmniboxError::CommandError(e.to_string())) + } + OmniboxCommand::Enable(dt) => { + let (ServiceListItem { name, .. }, _) = self.with_active_item_plist()?; + enable(name, dt) + .map(|_| None) + .map_err(|e| OmniboxError::CommandError(e.to_string())) + } + OmniboxCommand::Disable(dt) => { + let (ServiceListItem { name, .. }, _) = self.with_active_item_plist()?; + disable(name, dt) .map(|_| None) .map_err(|e| OmniboxError::CommandError(e.to_string())) } @@ -236,7 +339,6 @@ impl OmniboxSubscriber for ServiceListView { match event { OmniboxEvent::StateUpdate(state) => self.handle_state_update(state), OmniboxEvent::Command(cmd) => self.handle_command(cmd), - _ => Ok(None), } } } diff --git a/xpc-sys/src/objects/xpc_dictionary.rs b/xpc-sys/src/objects/xpc_dictionary.rs index b7a3149..da90531 100644 --- a/xpc-sys/src/objects/xpc_dictionary.rs +++ b/xpc-sys/src/objects/xpc_dictionary.rs @@ -14,45 +14,12 @@ use crate::{xpc_dictionary_apply, xpc_dictionary_create, xpc_dictionary_set_valu use block::ConcreteBlock; +#[derive(Debug, Clone)] pub struct XPCDictionary(pub HashMap); impl XPCDictionary { - /// Reify xpc_object_t dictionary as a Rust HashMap - pub fn new(object: &XPCObject) -> Result { - let XPCObject(_, object_type) = *object; - - if object_type != *objects::xpc_type::Dictionary { - return Err(DictionaryError( - "Only XPC_TYPE_DICTIONARY allowed".to_string(), - )); - } - - let map: Rc>> = Rc::new(RefCell::new(HashMap::new())); - let map_rc_clone = map.clone(); - - let block = ConcreteBlock::new(move |key: *const c_char, value: xpc_object_t| { - // Prevent xpc_release() collection on block exit - unsafe { xpc_retain(value) }; - - let str_key = unsafe { CStr::from_ptr(key).to_string_lossy().to_string() }; - map_rc_clone.borrow_mut().insert(str_key, value.into()); - }); - let block = block.copy(); - - let ok = unsafe { xpc_dictionary_apply(object.as_ptr(), &*block as *const _ as *mut _) }; - - // Explicitly drop the block so map is the only live reference - // so we can collect it below - std::mem::drop(block); - - if ok { - match Rc::try_unwrap(map) { - Ok(cell) => Ok(XPCDictionary(cell.into_inner())), - Err(_) => Err(DictionaryError("Unable to unwrap Rc".to_string())), - } - } else { - Err(DictionaryError("xpc_dictionary_apply failed".to_string())) - } + pub fn new() -> Self { + XPCDictionary(HashMap::new()) } /// Get value from XPCDictionary with support for nesting @@ -106,8 +73,41 @@ impl From> for XPCDictionary { impl TryFrom<&XPCObject> for XPCDictionary { type Error = XPCError; - fn try_from(value: &XPCObject) -> Result { - XPCDictionary::new(value) + fn try_from(object: &XPCObject) -> Result { + let XPCObject(_, object_type) = *object; + + if object_type != *objects::xpc_type::Dictionary { + return Err(DictionaryError( + "Only XPC_TYPE_DICTIONARY allowed".to_string(), + )); + } + + let map: Rc>> = Rc::new(RefCell::new(HashMap::new())); + let map_rc_clone = map.clone(); + + let block = ConcreteBlock::new(move |key: *const c_char, value: xpc_object_t| { + // Prevent xpc_release() collection on block exit + unsafe { xpc_retain(value) }; + + let str_key = unsafe { CStr::from_ptr(key).to_string_lossy().to_string() }; + map_rc_clone.borrow_mut().insert(str_key, value.into()); + }); + let block = block.copy(); + + let ok = unsafe { xpc_dictionary_apply(object.as_ptr(), &*block as *const _ as *mut _) }; + + // Explicitly drop the block so map is the only live reference + // so we can collect it below + std::mem::drop(block); + + if ok { + match Rc::try_unwrap(map) { + Ok(cell) => Ok(XPCDictionary(cell.into_inner())), + Err(_) => Err(DictionaryError("Unable to unwrap Rc".to_string())), + } + } else { + Err(DictionaryError("xpc_dictionary_apply failed".to_string())) + } } } @@ -115,7 +115,7 @@ impl TryFrom for XPCDictionary { type Error = XPCError; fn try_from(value: XPCObject) -> Result { - XPCDictionary::new(&value) + (&value).try_into() } } @@ -126,7 +126,7 @@ impl TryFrom for XPCDictionary { /// related to passing in objects other than XPC_TYPE_DICTIONARY fn try_from(value: xpc_object_t) -> Result { let obj: XPCObject = value.into(); - XPCDictionary::new(&obj) + obj.try_into() } } diff --git a/xpc-sys/src/objects/xpc_object.rs b/xpc-sys/src/objects/xpc_object.rs index 3534f75..18afed6 100644 --- a/xpc-sys/src/objects/xpc_object.rs +++ b/xpc-sys/src/objects/xpc_object.rs @@ -8,6 +8,7 @@ use std::ffi::{CStr, CString}; use std::ptr::null_mut; use std::sync::Arc; +use crate::objects::xpc_dictionary::XPCDictionary; use std::fmt; #[derive(Debug, Clone, PartialEq, Eq)] @@ -102,11 +103,11 @@ impl From<&str> for XPCObject { } } -impl From> for XPCObject { - fn from(slice: Vec) -> Self { +impl> From> for XPCObject { + fn from(value: Vec) -> Self { let xpc_array = unsafe { xpc_array_create(null_mut(), 0) }; - for object in slice { - unsafe { xpc_array_append_value(xpc_array, object.as_ptr()) } + for object in value { + unsafe { xpc_array_append_value(xpc_array, object.into().as_ptr()) } } XPCObject::new(xpc_array) @@ -121,6 +122,14 @@ impl From for XPCObject { } } +impl From for XPCObject { + /// Use From, XPCObject>> + fn from(xpcd: XPCDictionary) -> Self { + let XPCDictionary(hm) = xpcd; + hm.into() + } +} + /// Cloning an XPC object will clone the underlying Arc -- we will /// call xpc_release() only if we are the last valid reference /// (and underlying data is not null) diff --git a/xpc-sys/src/traits/xpc_pipeable.rs b/xpc-sys/src/traits/xpc_pipeable.rs index 1894d7b..60cc47e 100644 --- a/xpc-sys/src/traits/xpc_pipeable.rs +++ b/xpc-sys/src/traits/xpc_pipeable.rs @@ -3,10 +3,12 @@ use crate::objects::xpc_error::XPCError; use crate::objects::xpc_error::XPCError::PipeError; use crate::objects::xpc_object::XPCObject; use crate::{ - get_xpc_bootstrap_pipe, rs_strerror, xpc_object_t, xpc_pipe_routine, + get_xpc_bootstrap_pipe, rs_strerror, rs_xpc_strerror, xpc_object_t, xpc_pipe_routine, xpc_pipe_routine_with_flags, }; +use crate::traits::xpc_value::TryXPCValue; +use std::convert::TryInto; use std::ptr::null_mut; pub type XPCPipeResult = Result; @@ -15,6 +17,38 @@ pub trait XPCPipeable { fn pipe_routine(&self) -> XPCPipeResult; fn pipe_routine_with_flags(&self, flags: u64) -> XPCPipeResult; + /// Pipe routine expecting XPC dictionary reply, with checking of "error" and "errors" keys + fn pipe_routine_with_error_handling(&self) -> Result { + let response = self.pipe_routine()?.try_into()?; + let XPCDictionary(hm) = &response; + + if hm.contains_key("error") { + let errcode: i64 = response.get(&["error"])?.xpc_value()?; + Err(XPCError::QueryError(format!( + "{}: {}", + errcode, + rs_xpc_strerror(errcode as i32) + ))) + } else if hm.contains_key("errors") { + let XPCDictionary(errors_hm) = response.get_as_dictionary(&["errors"])?; + if errors_hm.is_empty() { + return Ok(response); + } + + let errors: Vec = errors_hm + .iter() + .flat_map(|(_, e)| { + let e: Result = e.xpc_value(); + e.map(|e_i64| format!("{}: {}", e_i64, rs_xpc_strerror(e_i64 as i32))) + }) + .collect(); + + Err(XPCError::QueryError(errors.join("\n"))) + } else { + Ok(response) + } + } + fn handle_pipe_routine(ptr: xpc_object_t, errno: i32) -> XPCPipeResult { if errno == 0 { Ok(ptr.into()) diff --git a/xpc-sys/src/traits/xpc_value.rs b/xpc-sys/src/traits/xpc_value.rs index 187cc4c..5d60d99 100644 --- a/xpc-sys/src/traits/xpc_value.rs +++ b/xpc-sys/src/traits/xpc_value.rs @@ -130,4 +130,22 @@ mod tests { let rs_u64: u64 = xpc_u64.xpc_value().unwrap(); assert_eq!(std::u64::MAX, rs_u64); } + + #[test] + fn xpc_value_array() { + let xpc_array = XPCObject::from(vec!["eins", "zwei", "polizei"]); + let rs_vec: Vec = xpc_array.xpc_value().unwrap(); + + assert_eq!( + rs_vec + .iter() + .map(|o| o.xpc_value().unwrap()) + .collect::>(), + vec![ + "eins".to_string(), + "zwei".to_string(), + "polizei".to_string() + ] + ); + } }