diff --git a/Cargo.lock b/Cargo.lock index 4944606..4bee460 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1782,9 +1782,9 @@ dependencies = [ [[package]] name = "plist-macro" -version = "0.1.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb72007326fe20721ef27304fcf2d1bd5877b92d13dbd8df735fd33407e31c2a" +checksum = "8888e02e251eba3258cc58fb79f0d8675c34b3428749e738562d58a0271bf035" dependencies = [ "plist", ] diff --git a/idevice/Cargo.toml b/idevice/Cargo.toml index 606d3b9..5a474bf 100644 --- a/idevice/Cargo.toml +++ b/idevice/Cargo.toml @@ -21,7 +21,7 @@ tokio-openssl = { version = "0.6", optional = true } openssl = { version = "0.10", optional = true } plist = { version = "1.8" } -plist-macro = { version = "0.1" } +plist-macro = { version = "0.1.3" } serde = { version = "1", features = ["derive"] } ns-keyed-archive = { version = "0.1.4", optional = true } crossfire = { version = "2.1", optional = true } diff --git a/idevice/src/services/springboardservices.rs b/idevice/src/services/springboardservices.rs index 81a28b4..15177d1 100644 --- a/idevice/src/services/springboardservices.rs +++ b/idevice/src/services/springboardservices.rs @@ -3,7 +3,7 @@ //! Provides functionality for interacting with the SpringBoard services on iOS devices, //! which manages home screen and app icon related operations. -use crate::{Idevice, IdeviceError, IdeviceService, obf}; +use crate::{Idevice, IdeviceError, IdeviceService, obf, utils::plist::truncate_dates_to_seconds}; /// Client for interacting with the iOS SpringBoard services /// @@ -70,4 +70,132 @@ impl SpringBoardServicesClient { _ => Err(IdeviceError::UnexpectedResponse), } } + + /// Retrieves the current icon state from the device + /// + /// The icon state contains the layout and organization of all apps on the home screen, + /// including folder structures and icon positions. This is a read-only operation. + /// + /// # Arguments + /// * `format_version` - Optional format version string for the icon state format + /// + /// # Returns + /// A plist Value containing the complete icon state structure + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The response is malformed + /// + /// # Example + /// ```rust + /// use idevice::services::springboardservices::SpringBoardServicesClient; + /// + /// let mut client = SpringBoardServicesClient::connect(&provider).await?; + /// let icon_state = client.get_icon_state(None).await?; + /// println!("Icon state: {:?}", icon_state); + /// ``` + /// + /// # Notes + /// This method successfully reads the home screen layout on all iOS versions. + pub async fn get_icon_state( + &mut self, + format_version: Option<&str>, + ) -> Result { + let req = crate::plist!({ + "command": "getIconState", + "formatVersion":? format_version, + }); + + self.idevice.send_plist(req).await?; + let mut res = self.idevice.read_plist_value().await?; + + // Some devices may return an error dictionary instead of icon state. + // Detect this and surface it as an UnexpectedResponse, similar to get_icon_pngdata. + if let plist::Value::Dictionary(ref dict) = res + && (dict.contains_key("error") || dict.contains_key("Error")) + { + return Err(IdeviceError::UnexpectedResponse); + } + + truncate_dates_to_seconds(&mut res); + + Ok(res) + } + + /// Sets the icon state on the device + /// + /// This method allows you to modify the home screen layout by providing a new icon state. + /// The icon state structure should match the format returned by `get_icon_state`. + /// + /// # Arguments + /// * `icon_state` - A plist Value containing the complete icon state structure + /// + /// # Returns + /// Ok(()) if the icon state was successfully set + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The icon state format is invalid + /// - The device rejects the new layout + /// + /// # Example + /// ```rust + /// use idevice::services::springboardservices::SpringBoardServicesClient; + /// + /// let mut client = SpringBoardServicesClient::connect(&provider).await?; + /// let mut icon_state = client.get_icon_state(None).await?; + /// + /// // Modify the icon state (e.g., swap two icons) + /// // ... modify icon_state ... + /// + /// client.set_icon_state(icon_state).await?; + /// println!("Icon state updated successfully"); + /// ``` + /// + /// # Notes + /// - Changes take effect immediately + /// - The device may validate the icon state structure before applying + /// - Invalid icon states will be rejected by the device + pub async fn set_icon_state(&mut self, icon_state: plist::Value) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "command": "setIconState", + "iconState": icon_state, + }); + + self.idevice.send_plist(req).await?; + Ok(()) + } + + /// Sets the icon state with a specific format version + /// + /// This is similar to `set_icon_state` but allows specifying a format version. + /// + /// # Arguments + /// * `icon_state` - A plist Value containing the complete icon state structure + /// * `format_version` - Optional format version string + /// + /// # Returns + /// Ok(()) if the icon state was successfully set + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The icon state format is invalid + /// - The device rejects the new layout + pub async fn set_icon_state_with_version( + &mut self, + icon_state: plist::Value, + format_version: Option<&str>, + ) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "command": "setIconState", + "iconState": icon_state, + "formatVersion":? format_version, + }); + + self.idevice.send_plist(req).await?; + Ok(()) + } } diff --git a/idevice/src/utils/mod.rs b/idevice/src/utils/mod.rs index d30df75..ded61d4 100644 --- a/idevice/src/utils/mod.rs +++ b/idevice/src/utils/mod.rs @@ -2,3 +2,5 @@ #[cfg(all(feature = "afc", feature = "installation_proxy"))] pub mod installation; + +pub mod plist; diff --git a/idevice/src/utils/plist.rs b/idevice/src/utils/plist.rs new file mode 100644 index 0000000..fb763ac --- /dev/null +++ b/idevice/src/utils/plist.rs @@ -0,0 +1,155 @@ +/// Utilities for working with plist values +/// +/// Truncates all Date values in a plist structure to second precision. +/// +/// This function recursively walks through a plist Value and truncates any Date values +/// from nanosecond precision to second precision. This is necessary for compatibility +/// with iOS devices that reject high-precision date formats. +/// +/// # Arguments +/// * `value` - The plist Value to normalize (modified in place) +/// +/// # Example +/// ```rust,no_run +/// use idevice::utils::plist::truncate_dates_to_seconds; +/// use plist::Value; +/// +/// let mut icon_state = Value::Array(vec![]); +/// truncate_dates_to_seconds(&mut icon_state); +/// ``` +/// +/// # Details +/// - Converts dates from format: `2026-01-17T03:09:58.332738876Z` (nanosecond precision) +/// - To format: `2026-01-17T03:09:58Z` (second precision) +/// - Recursively processes Arrays and Dictionaries +/// - Other value types are left unchanged +pub fn truncate_dates_to_seconds(value: &mut plist::Value) { + match value { + plist::Value::Date(date) => { + let xml_string = date.to_xml_format(); + if let Some(dot_pos) = xml_string.find('.') + && xml_string[dot_pos..].contains('Z') + { + let truncated_string = format!("{}Z", &xml_string[..dot_pos]); + if let Ok(new_date) = plist::Date::from_xml_format(&truncated_string) { + *date = new_date; + } + } + } + plist::Value::Array(arr) => { + for item in arr.iter_mut() { + truncate_dates_to_seconds(item); + } + } + plist::Value::Dictionary(dict) => { + for (_, v) in dict.iter_mut() { + truncate_dates_to_seconds(v); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_date_with_nanoseconds() { + let date_str = "2026-01-17T03:09:58.332738876Z"; + let date = plist::Date::from_xml_format(date_str).unwrap(); + let mut value = plist::Value::Date(date); + + truncate_dates_to_seconds(&mut value); + + if let plist::Value::Date(truncated_date) = value { + let result = truncated_date.to_xml_format(); + assert!( + !result.contains('.'), + "Date should not contain fractional seconds" + ); + assert!(result.ends_with('Z'), "Date should end with Z"); + assert!( + result.starts_with("2026-01-17T03:09:58"), + "Date should preserve main timestamp" + ); + } else { + panic!("Value should still be a Date"); + } + } + + #[test] + fn test_truncate_date_already_truncated() { + let date_str = "2026-01-17T03:09:58Z"; + let date = plist::Date::from_xml_format(date_str).unwrap(); + let original_format = date.to_xml_format(); + let mut value = plist::Value::Date(date); + + truncate_dates_to_seconds(&mut value); + + if let plist::Value::Date(truncated_date) = value { + let result = truncated_date.to_xml_format(); + assert_eq!( + result, original_format, + "Already truncated date should remain unchanged" + ); + } + } + + #[test] + fn test_truncate_dates_in_array() { + let date1 = plist::Date::from_xml_format("2026-01-17T03:09:58.123456Z").unwrap(); + let date2 = plist::Date::from_xml_format("2026-01-18T04:10:59.987654Z").unwrap(); + let mut value = + plist::Value::Array(vec![plist::Value::Date(date1), plist::Value::Date(date2)]); + + truncate_dates_to_seconds(&mut value); + + if let plist::Value::Array(arr) = value { + for item in arr { + if let plist::Value::Date(date) = item { + let formatted = date.to_xml_format(); + assert!( + !formatted.contains('.'), + "Dates in array should be truncated" + ); + } + } + } + } + + #[test] + fn test_truncate_dates_in_dictionary() { + let date = plist::Date::from_xml_format("2026-01-17T03:09:58.999999Z").unwrap(); + let mut dict = plist::Dictionary::new(); + dict.insert("timestamp".to_string(), plist::Value::Date(date)); + let mut value = plist::Value::Dictionary(dict); + + truncate_dates_to_seconds(&mut value); + + if let plist::Value::Dictionary(dict) = value + && let Some(plist::Value::Date(date)) = dict.get("timestamp") + { + let formatted = date.to_xml_format(); + assert!( + !formatted.contains('.'), + "Date in dictionary should be truncated" + ); + } + } + + #[test] + fn test_other_value_types_unchanged() { + let mut string_val = plist::Value::String("test".to_string()); + let mut int_val = plist::Value::Integer(42.into()); + let mut bool_val = plist::Value::Boolean(true); + + truncate_dates_to_seconds(&mut string_val); + truncate_dates_to_seconds(&mut int_val); + truncate_dates_to_seconds(&mut bool_val); + + assert!(matches!(string_val, plist::Value::String(_))); + assert!(matches!(int_val, plist::Value::Integer(_))); + assert!(matches!(bool_val, plist::Value::Boolean(_))); + } +} diff --git a/tools/Cargo.toml b/tools/Cargo.toml index 28cc33c..1eaea88 100644 --- a/tools/Cargo.toml +++ b/tools/Cargo.toml @@ -33,7 +33,7 @@ ureq = { version = "3" } clap = { version = "4.5" } jkcli = { version = "0.1" } plist = { version = "1.7" } -plist-macro = { version = "0.1" } +plist-macro = { version = "0.1.3" } ns-keyed-archive = "0.1.2" uuid = "1.16" futures-util = { version = "0.3" } diff --git a/tools/src/main.rs b/tools/src/main.rs index 5e215b4..4bd3d77 100644 --- a/tools/src/main.rs +++ b/tools/src/main.rs @@ -42,6 +42,7 @@ mod process_control; mod remotexpc; mod restore_service; mod screenshot; +mod springboardservices; mod syslog_relay; mod pcap; @@ -120,6 +121,7 @@ async fn main() { .with_subcommand("remotexpc", remotexpc::register()) .with_subcommand("restore_service", restore_service::register()) .with_subcommand("screenshot", screenshot::register()) + .with_subcommand("springboard", springboardservices::register()) .with_subcommand("syslog_relay", syslog_relay::register()) .subcommand_required(true) .collect() @@ -236,6 +238,9 @@ async fn main() { "screenshot" => { screenshot::main(sub_args, provider).await; } + "springboard" => { + springboardservices::main(sub_args, provider).await; + } "syslog_relay" => { syslog_relay::main(sub_args, provider).await; } diff --git a/tools/src/springboardservices.rs b/tools/src/springboardservices.rs new file mode 100644 index 0000000..5f0fb56 --- /dev/null +++ b/tools/src/springboardservices.rs @@ -0,0 +1,76 @@ +// Jackson Coxson + +use idevice::{ + IdeviceService, provider::IdeviceProvider, springboardservices::SpringBoardServicesClient, +}; +use jkcli::{CollectedArguments, JkArgument, JkCommand, JkFlag}; +use plist_macro::{plist_value_to_xml_bytes, pretty_print_plist}; + +pub fn register() -> JkCommand { + JkCommand::new() + .help("Manage the springboard service") + .with_subcommand( + "get_icon_state", + JkCommand::new() + .help("Gets the icon state from the device") + .with_argument( + JkArgument::new() + .with_help("Version to query by") + .required(false), + ) + .with_flag( + JkFlag::new("save") + .with_help("Path to save to") + .with_argument(JkArgument::new().required(true)), + ), + ) + .with_subcommand( + "set_icon_state", + JkCommand::new().help("Sets the icon state").with_argument( + JkArgument::new() + .with_help("plist to set based on") + .required(true), + ), + ) + .subcommand_required(true) +} + +pub async fn main(arguments: &CollectedArguments, provider: Box) { + let mut sbc = SpringBoardServicesClient::connect(&*provider) + .await + .expect("Failed to connect to springboardservices"); + + let (sub_name, sub_args) = arguments.first_subcommand().expect("No subcommand passed"); + let mut sub_args = sub_args.clone(); + + match sub_name.as_str() { + "get_icon_state" => { + let version: Option = sub_args.next_argument(); + let version = version.as_deref(); + let state = sbc + .get_icon_state(version) + .await + .expect("Failed to get icon state"); + println!("{}", pretty_print_plist(&state)); + + if let Some(path) = sub_args.get_flag::("save") { + tokio::fs::write(path, plist_value_to_xml_bytes(&state)) + .await + .expect("Failed to save to path"); + } + } + "set_icon_state" => { + let load_path = sub_args.next_argument::().unwrap(); + let load = tokio::fs::read(load_path) + .await + .expect("Failed to read plist"); + let load: plist::Value = + plist::from_bytes(&load).expect("Failed to parse bytes as plist"); + + sbc.set_icon_state(load) + .await + .expect("Failed to set icon state"); + } + _ => unreachable!(), + } +}