Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion idevice/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
130 changes: 129 additions & 1 deletion idevice/src/services/springboardservices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down Expand Up @@ -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<plist::Value, IdeviceError> {
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(())
}
}
2 changes: 2 additions & 0 deletions idevice/src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@

#[cfg(all(feature = "afc", feature = "installation_proxy"))]
pub mod installation;

pub mod plist;
155 changes: 155 additions & 0 deletions idevice/src/utils/plist.rs
Original file line number Diff line number Diff line change
@@ -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(_)));
}
}
2 changes: 1 addition & 1 deletion tools/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
5 changes: 5 additions & 0 deletions tools/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ mod process_control;
mod remotexpc;
mod restore_service;
mod screenshot;
mod springboardservices;
mod syslog_relay;

mod pcap;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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;
}
Expand Down
Loading