feat(springboard): add get_icon_state and set_icon_state methods (#63)

* feat(springboard): add get_icon_state method for reading home screen layout

Add get_icon_state() method to SpringBoardServicesClient that retrieves
the current home screen icon layout from iOS devices.

Features:
- Read complete home screen layout including icon positions and folders
- Support for optional formatVersion parameter
- Works on all iOS versions (tested on iOS 18.7.3)
- Comprehensive documentation with usage examples

Note: This PR intentionally does NOT include set_icon_state() as that
functionality is non-operational on iOS 18+ (see issue #62 for details).

Tested on:
- Device: iPhone 16,2 (iPhone 15 Pro)
- iOS: 18.7.3 (Build 22H217)

* feat(springboard): add set_icon_state method with date precision fix

- Implement set_icon_state() to modify home screen layout
- Implement set_icon_state_with_version() with format_version parameter
- Add truncate_dates_to_seconds() to convert nanosecond precision dates to second precision
- Fix iOS compatibility issue where high-precision dates were rejected
- Successfully tested on iOS 18.7.3 (previously believed to be restricted)
- Follows pymobiledevice3 implementation pattern

* refactor(utils): extract truncate_dates_to_seconds to utils::plist module

- Move date truncation logic from springboardservices to reusable utils::plist module
- Add comprehensive unit tests for date truncation functionality
- Add public API documentation for the utility function
- This makes the date normalization logic available for other services that may need it

* perf(springboard): normalize dates on read instead of write

- Move date truncation from set_icon_state to get_icon_state
- Eliminates unnecessary clone() operation in set_icon_state
- Better performance when setting icon state multiple times
- Cleaner API: data from get_icon_state is directly usable in set_icon_state
- Users don't need to worry about date precision issues

* refactor(springboard): address PR feedback - use Option<&str> and add error validation

- Change format_version parameter from Option<String> to Option<&str> for consistency
- Remove outdated iOS 18+ restriction comments since setIconState works on iOS 18+
- Add error validation to get_icon_state method similar to get_icon_pngdata
- Update documentation to reflect accurate iOS compatibility

* Fix cargo clippy warnings

* Fix clippy warnings in plist.rs

* Add springboard CLI commands

---------

Co-authored-by: Jackson Coxson <jkcoxson@gmail.com>
This commit is contained in:
fulln
2026-01-23 06:32:01 +08:00
committed by GitHub
parent 142708c289
commit 9a71279fe9
8 changed files with 371 additions and 5 deletions

4
Cargo.lock generated
View File

@@ -1782,9 +1782,9 @@ dependencies = [
[[package]] [[package]]
name = "plist-macro" name = "plist-macro"
version = "0.1.0" version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb72007326fe20721ef27304fcf2d1bd5877b92d13dbd8df735fd33407e31c2a" checksum = "8888e02e251eba3258cc58fb79f0d8675c34b3428749e738562d58a0271bf035"
dependencies = [ dependencies = [
"plist", "plist",
] ]

View File

@@ -21,7 +21,7 @@ tokio-openssl = { version = "0.6", optional = true }
openssl = { version = "0.10", optional = true } openssl = { version = "0.10", optional = true }
plist = { version = "1.8" } plist = { version = "1.8" }
plist-macro = { version = "0.1" } plist-macro = { version = "0.1.3" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
ns-keyed-archive = { version = "0.1.4", optional = true } ns-keyed-archive = { version = "0.1.4", optional = true }
crossfire = { version = "2.1", optional = true } crossfire = { version = "2.1", optional = true }

View File

@@ -3,7 +3,7 @@
//! Provides functionality for interacting with the SpringBoard services on iOS devices, //! Provides functionality for interacting with the SpringBoard services on iOS devices,
//! which manages home screen and app icon related operations. //! 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 /// Client for interacting with the iOS SpringBoard services
/// ///
@@ -70,4 +70,132 @@ impl SpringBoardServicesClient {
_ => Err(IdeviceError::UnexpectedResponse), _ => 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(())
}
} }

View File

@@ -2,3 +2,5 @@
#[cfg(all(feature = "afc", feature = "installation_proxy"))] #[cfg(all(feature = "afc", feature = "installation_proxy"))]
pub mod installation; pub mod installation;
pub mod plist;

155
idevice/src/utils/plist.rs Normal file
View File

@@ -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(_)));
}
}

View File

@@ -33,7 +33,7 @@ ureq = { version = "3" }
clap = { version = "4.5" } clap = { version = "4.5" }
jkcli = { version = "0.1" } jkcli = { version = "0.1" }
plist = { version = "1.7" } plist = { version = "1.7" }
plist-macro = { version = "0.1" } plist-macro = { version = "0.1.3" }
ns-keyed-archive = "0.1.2" ns-keyed-archive = "0.1.2"
uuid = "1.16" uuid = "1.16"
futures-util = { version = "0.3" } futures-util = { version = "0.3" }

View File

@@ -42,6 +42,7 @@ mod process_control;
mod remotexpc; mod remotexpc;
mod restore_service; mod restore_service;
mod screenshot; mod screenshot;
mod springboardservices;
mod syslog_relay; mod syslog_relay;
mod pcap; mod pcap;
@@ -120,6 +121,7 @@ async fn main() {
.with_subcommand("remotexpc", remotexpc::register()) .with_subcommand("remotexpc", remotexpc::register())
.with_subcommand("restore_service", restore_service::register()) .with_subcommand("restore_service", restore_service::register())
.with_subcommand("screenshot", screenshot::register()) .with_subcommand("screenshot", screenshot::register())
.with_subcommand("springboard", springboardservices::register())
.with_subcommand("syslog_relay", syslog_relay::register()) .with_subcommand("syslog_relay", syslog_relay::register())
.subcommand_required(true) .subcommand_required(true)
.collect() .collect()
@@ -236,6 +238,9 @@ async fn main() {
"screenshot" => { "screenshot" => {
screenshot::main(sub_args, provider).await; screenshot::main(sub_args, provider).await;
} }
"springboard" => {
springboardservices::main(sub_args, provider).await;
}
"syslog_relay" => { "syslog_relay" => {
syslog_relay::main(sub_args, provider).await; syslog_relay::main(sub_args, provider).await;
} }

View File

@@ -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<dyn IdeviceProvider>) {
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<String> = 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::<String>("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::<String>().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!(),
}
}