├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── blank.yml │ ├── feature.yml │ └── bug.yml └── workflows │ └── build.yml ├── demo.png ├── .gitignore ├── package ├── linux │ └── icon.png ├── windows │ ├── icon.ico │ ├── icon.rgba │ └── installer.nsi └── macos │ ├── background.png │ └── Impactor.app │ └── Contents │ ├── Resources │ ├── Assets.car │ └── AppIcon.icns │ └── Info.plist ├── crates ├── types │ ├── src │ │ ├── ellekit.deb │ │ ├── lib.rs │ │ ├── options.rs │ │ ├── package.rs │ │ ├── device.rs │ │ ├── bundle.rs │ │ └── tweak.rs │ └── Cargo.toml ├── core │ ├── src │ │ ├── apple_root.der │ │ ├── developer │ │ │ ├── mod.rs │ │ │ ├── v1 │ │ │ │ ├── mod.rs │ │ │ │ ├── capabilities.rs │ │ │ │ └── app_ids.rs │ │ │ ├── qh │ │ │ │ ├── mod.rs │ │ │ │ ├── account.rs │ │ │ │ ├── profile.rs │ │ │ │ ├── teams.rs │ │ │ │ ├── devices.rs │ │ │ │ ├── app_groups.rs │ │ │ │ ├── certs.rs │ │ │ │ └── app_ids.rs │ │ │ └── session.rs │ │ ├── auth │ │ │ ├── account │ │ │ │ ├── mod.rs │ │ │ │ ├── token.rs │ │ │ │ ├── two_factor_auth.rs │ │ │ │ └── login.rs │ │ │ ├── anisette_data.rs │ │ │ └── mod.rs │ │ ├── utils │ │ │ ├── mod.rs │ │ │ └── provision.rs │ │ ├── lib.rs │ │ └── store │ │ │ └── mod.rs │ └── Cargo.toml ├── gestalt │ ├── build.rs │ ├── Cargo.toml │ └── src │ │ └── lib.rs └── shared │ ├── Cargo.toml │ └── src │ └── lib.rs ├── .vscode └── extensions.json ├── .editorconfig ├── apps ├── plumesign │ ├── src │ │ ├── main.rs │ │ └── commands │ │ │ ├── mod.rs │ │ │ ├── macho.rs │ │ │ ├── device.rs │ │ │ ├── sign.rs │ │ │ └── account.rs │ └── Cargo.toml └── plumeimpactor │ ├── src │ ├── pages │ │ ├── mod.rs │ │ ├── work.rs │ │ ├── default.rs │ │ └── settings.rs │ └── main.rs │ ├── Cargo.toml │ └── build.rs ├── LICENSE ├── Cargo.toml └── README.md /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khcrysalis/PlumeImpactor/HEAD/demo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | state.plist 3 | .zsign_cache 4 | *.DS_Store 5 | /tests 6 | /build 7 | -------------------------------------------------------------------------------- /package/linux/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khcrysalis/PlumeImpactor/HEAD/package/linux/icon.png -------------------------------------------------------------------------------- /package/windows/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khcrysalis/PlumeImpactor/HEAD/package/windows/icon.ico -------------------------------------------------------------------------------- /package/windows/icon.rgba: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khcrysalis/PlumeImpactor/HEAD/package/windows/icon.rgba -------------------------------------------------------------------------------- /crates/types/src/ellekit.deb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khcrysalis/PlumeImpactor/HEAD/crates/types/src/ellekit.deb -------------------------------------------------------------------------------- /package/macos/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khcrysalis/PlumeImpactor/HEAD/package/macos/background.png -------------------------------------------------------------------------------- /crates/core/src/apple_root.der: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khcrysalis/PlumeImpactor/HEAD/crates/core/src/apple_root.der -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "rust-lang.rust-analyzer", 4 | "editorconfig.editorconfig" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /package/macos/Impactor.app/Contents/Resources/Assets.car: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khcrysalis/PlumeImpactor/HEAD/package/macos/Impactor.app/Contents/Resources/Assets.car -------------------------------------------------------------------------------- /package/macos/Impactor.app/Contents/Resources/AppIcon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/khcrysalis/PlumeImpactor/HEAD/package/macos/Impactor.app/Contents/Resources/AppIcon.icns -------------------------------------------------------------------------------- /crates/gestalt/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!("cargo:rustc-link-lib=MobileGestalt"); 3 | println!("cargo:rustc-link-lib=framework=CoreFoundation"); 4 | println!("cargo:rerun-if-changed=build.rs"); 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = false 9 | insert_final_newline = true 10 | 11 | [*.{yml,yaml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | -------------------------------------------------------------------------------- /crates/shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "plume_shared" 3 | description = "Commons for Impactor tools." 4 | edition.workspace = true 5 | version.workspace = true 6 | authors.workspace = true 7 | license.workspace = true 8 | repository.workspace = true 9 | 10 | [dependencies] 11 | -------------------------------------------------------------------------------- /crates/gestalt/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gestalt" 3 | description = "MobileGestalt.dylib wrapper for Rust, primarily for retrieving the Mac UDID." 4 | edition.workspace = true 5 | version.workspace = true 6 | authors.workspace = true 7 | license.workspace = true 8 | repository.workspace = true 9 | 10 | [dependencies] 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/blank.yml: -------------------------------------------------------------------------------- 1 | name: Create Blank Issue 2 | description: Blank issue report for Impactor. Use this for anything other than a bug or feature request. 3 | title: '[Help] ' 4 | labels: 5 | - "help wanted" 6 | 7 | body: 8 | - type: textarea 9 | id: text 10 | attributes: 11 | label: Content 12 | validations: 13 | required: true 14 | -------------------------------------------------------------------------------- /crates/shared/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{env, fs, path::{Path, PathBuf}}; 2 | 3 | pub fn get_data_path() -> PathBuf { 4 | let base = if cfg!(windows) { 5 | env::var("APPDATA").unwrap() 6 | } else { 7 | env::var("HOME").unwrap() + "/.config" 8 | }; 9 | 10 | let dir = Path::new(&base) 11 | .join("PlumeImpactor"); 12 | 13 | fs::create_dir_all(&dir).ok(); 14 | 15 | dir 16 | } 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yml: -------------------------------------------------------------------------------- 1 | name: Create Feature Request 2 | description: Suggest a feature to be implemented to Impactor. 3 | title: '[Feature] ' 4 | labels: 5 | - enhancement 6 | 7 | body: 8 | - type: textarea 9 | id: text 10 | attributes: 11 | label: Request 12 | validations: 13 | required: true 14 | 15 | - type: checkboxes 16 | id: agreement 17 | attributes: 18 | label: Contributor Checks 19 | options: 20 | - label: I am willing to attempt to make a pull request with these requested features 21 | required: false 22 | -------------------------------------------------------------------------------- /apps/plumesign/src/main.rs: -------------------------------------------------------------------------------- 1 | mod commands; 2 | 3 | use clap::Parser; 4 | use commands::{Commands, Cli}; 5 | 6 | #[tokio::main] 7 | async fn main() -> anyhow::Result<()> { 8 | env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init(); 9 | let cli = Cli::parse(); 10 | 11 | match cli.command { 12 | Commands::Sign(args) => commands::sign::execute(args).await?, 13 | Commands::MachO(args) => commands::macho::execute(args).await?, 14 | Commands::Account(args) => commands::account::execute(args).await?, 15 | Commands::Device(args) => commands::device::execute(args).await?, 16 | } 17 | 18 | Ok(()) 19 | } 20 | -------------------------------------------------------------------------------- /crates/types/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "plume_utils" 3 | description = "Utility functions and types for Impactor tools." 4 | edition.workspace = true 5 | version.workspace = true 6 | authors.workspace = true 7 | license.workspace = true 8 | repository.workspace = true 9 | 10 | [dependencies] 11 | idevice.workspace = true 12 | thiserror.workspace = true 13 | uuid.workspace = true 14 | plist.workspace = true 15 | tokio.workspace = true 16 | futures.workspace = true 17 | log.workspace = true 18 | plume_core = { path = "../core" } 19 | 20 | # TODO: replace zip with decompress 21 | zip = { version = "4.3", default-features = false, features = ["deflate"] } 22 | decompress = { git = "https://github.com/PlumeImpactor/decompress", rev = "75a3016" } 23 | goblin = "0.9.3" 24 | -------------------------------------------------------------------------------- /crates/core/src/developer/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod qh; 2 | pub mod v1; 3 | mod session; 4 | 5 | pub use session::{ 6 | DeveloperSession, 7 | RequestType 8 | }; 9 | 10 | #[macro_export] 11 | macro_rules! developer_endpoint { 12 | ($endpoint:expr) => { 13 | format!("https://developerservices2.apple.com/services{}", $endpoint) 14 | }; 15 | } 16 | 17 | // Apple apis restrict certain characters in app names 18 | pub fn strip_invalid_chars(str: &str) -> String { 19 | const INVALID_CHARS: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|', '.']; 20 | 21 | str.chars() 22 | .filter(|c| 23 | c.is_ascii() 24 | && !c.is_control() 25 | && !INVALID_CHARS.contains(c) 26 | ) 27 | .collect() 28 | } 29 | -------------------------------------------------------------------------------- /apps/plumesign/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "plumesign" 3 | edition.workspace = true 4 | version.workspace = true 5 | authors.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | 9 | [dependencies] 10 | idevice.workspace = true 11 | plist.workspace = true 12 | tokio.workspace = true 13 | futures.workspace = true 14 | env_logger.workspace = true 15 | log.workspace = true 16 | plume_core = { path = "../../crates/core", features = ["vendored-botan"] } 17 | plume_utils = { path = "../../crates/types" } 18 | plume_shared = { path = "../../crates/shared" } 19 | 20 | rustls = { version = "0.23.32", features = ["ring"] } 21 | 22 | clap = { version = "4.5", features = ["derive"] } 23 | dialoguer = "0.12.0" 24 | anyhow = "1.0" 25 | goblin = "0.9.3" 26 | 27 | [target.'cfg(target_os = "macos")'.dependencies] 28 | gestalt = { path = "../../crates/gestalt" } 29 | -------------------------------------------------------------------------------- /apps/plumeimpactor/src/pages/mod.rs: -------------------------------------------------------------------------------- 1 | mod default; 2 | pub use default::{DefaultPage, create_default_page}; 3 | 4 | mod settings; 5 | pub use settings::{LoginDialog, create_login_dialog}; 6 | pub use settings::{SettingsDialog, create_settings_dialog}; 7 | 8 | mod install; 9 | pub use install::{InstallPage, create_install_page}; 10 | 11 | mod work; 12 | pub use work::{WorkPage, create_work_page}; 13 | 14 | 15 | // TODO: investigate why github actions messes up weird sizing shit 16 | #[cfg(target_os = "linux")] 17 | pub const WINDOW_SIZE: (i32, i32) = (760, 660); 18 | #[cfg(not(target_os = "linux"))] 19 | pub const WINDOW_SIZE: (i32, i32) = (580, 440); 20 | 21 | // TODO: investigate why github actions messes up weird sizing shit 22 | #[cfg(target_os = "linux")] 23 | pub const DIALOG_SIZE: (i32, i32) = (500, 500); 24 | #[cfg(not(target_os = "linux"))] 25 | pub const DIALOG_SIZE: (i32, i32) = (400, 300); 26 | -------------------------------------------------------------------------------- /apps/plumesign/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, Subcommand}; 2 | 3 | pub mod sign; 4 | pub mod macho; 5 | pub mod account; 6 | pub mod device; 7 | 8 | #[derive(Debug, Parser)] 9 | #[command( 10 | name = "plumesign", 11 | author, 12 | version, 13 | about = "iOS code signing and inspection tool", 14 | disable_help_subcommand = true, 15 | arg_required_else_help = true 16 | )] 17 | pub struct Cli { 18 | #[command(subcommand)] 19 | pub command: Commands, 20 | } 21 | 22 | #[derive(Debug, Subcommand)] 23 | pub enum Commands { 24 | /// Sign an iOS app bundle with certificate and provisioning profile 25 | Sign(sign::SignArgs), 26 | /// Inspect Mach-O binaries 27 | MachO(macho::MachArgs), 28 | /// Manage Apple Developer account authentication 29 | Account(account::AccountArgs), 30 | /// Device management commands 31 | Device(device::DeviceArgs), 32 | } 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Create Bug Report 2 | description: Report a bug or crash in Impactor. 3 | title: '[Bug] ' 4 | labels: 5 | - bug 6 | 7 | body: 8 | - type: textarea 9 | id: text 10 | attributes: 11 | label: Report 12 | validations: 13 | required: true 14 | 15 | - type: checkboxes 16 | id: agreement 17 | attributes: 18 | label: Contributor Checks 19 | options: 20 | - label: I am willing to attempt to make a pull request to fix this bug 21 | required: false 22 | 23 | - type: checkboxes 24 | id: extras 25 | attributes: 26 | label: Additional Information 27 | options: 28 | - label: Developer mode enabled 29 | required: false 30 | - label: Using paid developer account 31 | required: false 32 | - label: I will send an ipa of the app I'm having trouble installing if any 33 | required: true 34 | - label: I'm willing to provide crash logs or debugging information if needed 35 | required: true 36 | -------------------------------------------------------------------------------- /apps/plumeimpactor/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "plumeimpactor" 3 | description = "GUI for sideloading apps onto Apple devices" 4 | version.workspace = true 5 | edition.workspace = true 6 | authors.workspace = true 7 | license.workspace = true 8 | repository.workspace = true 9 | 10 | [dependencies] 11 | idevice.workspace = true 12 | tokio.workspace = true 13 | thiserror.workspace = true 14 | uuid.workspace = true 15 | plist.workspace = true 16 | futures.workspace = true 17 | env_logger.workspace = true 18 | plume_core = { path = "../../crates/core", features = ["vendored-botan"] } 19 | plume_utils = { path = "../../crates/types" } 20 | plume_shared = { path = "../../crates/shared" } 21 | 22 | wxdragon = { git = "https://github.com/PlumeImpactor/wxDragon", rev = "7d407ce", package = "wxdragon" } 23 | rustls = { version = "0.23.32", features = ["ring"] } 24 | 25 | [target.'cfg(target_os = "windows")'.build-dependencies] 26 | embed-manifest = "1.4" 27 | winres = "0.1" 28 | 29 | [target.'cfg(target_os = "macos")'.dependencies] 30 | gestalt = { path = "../../crates/gestalt" } 31 | -------------------------------------------------------------------------------- /apps/plumeimpactor/build.rs: -------------------------------------------------------------------------------- 1 | #[cfg(windows)] 2 | fn main() { 3 | println!("cargo:rerun-if-changed=build.rs"); 4 | 5 | let pkg_name = std::env::var("CARGO_PKG_NAME").unwrap(); 6 | embed_windows_manifest(&pkg_name); 7 | } 8 | 9 | #[cfg(not(windows))] 10 | fn main() {} 11 | 12 | #[cfg(windows)] 13 | fn embed_windows_manifest(name: &str) { 14 | use embed_manifest::manifest::{ActiveCodePage, Setting, SupportedOS::*}; 15 | use embed_manifest::{embed_manifest, new_manifest}; 16 | 17 | { 18 | let manifest = new_manifest(name) 19 | .supported_os(Windows7..=Windows10) 20 | .active_code_page(ActiveCodePage::Utf8) 21 | .heap_type(embed_manifest::manifest::HeapType::SegmentHeap) 22 | .dpi_awareness(embed_manifest::manifest::DpiAwareness::Unaware) 23 | .long_path_aware(Setting::Enabled); 24 | 25 | embed_manifest(manifest).unwrap(); 26 | } 27 | 28 | { 29 | let mut res = winres::WindowsResource::new(); 30 | res.set_icon("../../package/windows/icon.ico"); 31 | res.compile().unwrap(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Samara M 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /crates/core/src/developer/v1/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod app_ids; 2 | pub mod capabilities; 3 | 4 | use serde::Deserialize; 5 | 6 | use crate::developer::{DeveloperSession, RequestType}; 7 | 8 | #[allow(dead_code)] 9 | #[derive(Deserialize, Debug)] 10 | #[serde(rename_all = "camelCase")] 11 | pub struct V1ErrorResponse { 12 | pub errors: Vec, 13 | } 14 | 15 | #[allow(dead_code)] 16 | #[derive(Deserialize, Debug)] 17 | #[serde(rename_all = "camelCase")] 18 | pub struct V1ErrorDetail { 19 | pub code: String, 20 | pub detail: Option, 21 | pub id: String, 22 | pub result_code: i64, 23 | pub status: String, 24 | pub title: Option, 25 | } 26 | 27 | impl V1ErrorDetail { 28 | pub fn to_error(&self, url: String) -> crate::Error { 29 | let message = self.detail.clone() 30 | .or(self.title.clone()) 31 | .unwrap_or_else(|| "Unknown API error".to_string()); 32 | 33 | crate::Error::DeveloperApi { 34 | url, 35 | result_code: self.result_code, 36 | http_code: self.status.parse().ok(), 37 | message, 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /package/macos/Impactor.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | English 7 | CFBundleDisplayName 8 | plumeimpactor 9 | CFBundleExecutable 10 | plumeimpactor 11 | CFBundleIdentifier 12 | bucket.plumeimpactor 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | plumeimpactor 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 0.0.1 21 | CFBundleVersion 22 | 1 23 | CSResourcesFileMapped 24 | 25 | LSRequiresCarbon 26 | 27 | NSHighResolutionCapable 28 | 29 | CFBundleIconFile 30 | AppIcon 31 | CFBundleIconName 32 | AppIcon 33 | 34 | 35 | -------------------------------------------------------------------------------- /apps/plumeimpactor/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | 3 | mod frame; 4 | mod pages; 5 | mod handlers; 6 | 7 | #[tokio::main] 8 | async fn main() { 9 | env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init(); 10 | _ = rustls::crypto::ring::default_provider().install_default().unwrap(); 11 | 12 | let _ = wxdragon::main(|_| { 13 | #[cfg(target_os = "windows")] 14 | { 15 | use wxdragon::{AppAppearance, appearance::Appearance}; 16 | if let Some(app) = wxdragon::app::get_app() { 17 | app.set_appearance(Appearance::Dark); 18 | } 19 | } 20 | 21 | frame::PlumeFrame::new().show(); 22 | }); 23 | } 24 | 25 | use thiserror::Error as ThisError; 26 | 27 | #[derive(Debug, ThisError)] 28 | pub enum Error { 29 | #[error("I/O error: {0}")] 30 | Io(#[from] std::io::Error), 31 | #[error("Plist error: {0}")] 32 | Plist(#[from] plist::Error), 33 | #[error("Idevice error: {0}")] 34 | Idevice(#[from] idevice::IdeviceError), 35 | #[error("Core error: {0}")] 36 | Core(#[from] plume_core::Error), 37 | #[error("Utils error: {0}")] 38 | Utils(#[from] plume_utils::Error), 39 | } 40 | -------------------------------------------------------------------------------- /crates/core/src/developer/qh/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account; 2 | pub mod app_groups; 3 | pub mod app_ids; 4 | pub mod certs; 5 | pub mod devices; 6 | pub mod teams; 7 | pub mod profile; 8 | 9 | use serde::Deserialize; 10 | use plist::Integer; 11 | use crate::developer::DeveloperSession; 12 | 13 | #[allow(dead_code)] 14 | #[derive(Deserialize, Debug)] 15 | #[serde(rename_all = "camelCase")] 16 | pub struct QHResponseMeta { 17 | pub creation_timestamp: String, 18 | pub user_string: Option, 19 | pub result_string: Option, 20 | pub result_code: Integer, 21 | pub http_code: Option, 22 | pub user_locale: String, 23 | pub protocol_version: String, 24 | pub request_id: Option, 25 | pub result_url: Option, 26 | pub response_id: String, 27 | pub page_number: Option, 28 | pub page_size: Option, 29 | pub total_records: Option, 30 | } 31 | 32 | impl QHResponseMeta { 33 | pub fn to_error(self, url: String) -> crate::Error { 34 | let message = self.user_string 35 | .or(self.result_string) 36 | .unwrap_or_else(|| "Unknown API error".to_string()); 37 | 38 | crate::Error::DeveloperApi { 39 | url, 40 | result_code: self.result_code.as_signed().unwrap_or(0), 41 | http_code: self.http_code.and_then(|c| c.as_signed().map(|v| v as u16)), 42 | message, 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /crates/core/src/developer/qh/account.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use plist::{Dictionary, Value}; 3 | 4 | use crate::Error; 5 | 6 | use crate::developer_endpoint; 7 | use super::{DeveloperSession, QHResponseMeta}; 8 | 9 | impl DeveloperSession { 10 | pub async fn qh_get_account_info(&self, team_id: &String) -> Result { 11 | let endpoint = developer_endpoint!("/QH65B2/viewDeveloper.action"); 12 | 13 | let mut body = Dictionary::new(); 14 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 15 | 16 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 17 | let response_data: ViewDeveloperResponse = plist::from_value(&Value::Dictionary(response))?; 18 | 19 | Ok(response_data) 20 | } 21 | } 22 | 23 | #[allow(dead_code)] 24 | #[derive(Deserialize, Debug)] 25 | #[serde(rename_all = "camelCase")] 26 | pub struct ViewDeveloperResponse { 27 | // pub teams: Vec, 28 | pub developer: Developer, 29 | #[serde(flatten)] 30 | pub meta: QHResponseMeta, 31 | } 32 | 33 | #[allow(dead_code)] 34 | #[derive(Deserialize, Debug)] 35 | #[serde(rename_all = "camelCase")] 36 | 37 | pub struct Developer { 38 | // pub developer_id: String, 39 | // pub person_id: String, 40 | pub first_name: String, 41 | pub last_name: String, 42 | pub ds_first_name: String, 43 | pub ds_last_name: String, 44 | pub email: String, 45 | pub developer_status: String, 46 | } 47 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "3" 3 | members = [ 4 | "apps/plumeimpactor", 5 | "apps/plumesign", 6 | "crates/core", 7 | "crates/gestalt", 8 | "crates/shared", 9 | "crates/types" 10 | ] 11 | 12 | [workspace.package] 13 | edition = "2024" 14 | version = "1.2.0" 15 | authors = ["khcrysalis "] 16 | license = "MIT" 17 | repository = "https://github.com/khcrysalis/plumestore" 18 | 19 | # We use `opt-level = "s"` as it significantly reduces binary size. 20 | # We could then use the `#[optimize(speed)]` attribute for spot optimizations. 21 | # Unfortunately, that attribute currently doesn't work on intrinsics such as memset. 22 | # [profile.release] 23 | # codegen-units = 1 # reduces binary size by ~2% 24 | # debug = "full" # No one needs an undebuggable release binary 25 | # lto = true # reduces binary size by ~14% 26 | # opt-level = "s" # reduces binary size by ~25% 27 | # panic = "abort" # reduces binary size by ~50% in combination with -Zbuild-std-features=panic_immediate_abort 28 | # split-debuginfo = "packed" # generates a separate *.dwp/*.dSYM so the binary can get stripped 29 | # strip = "symbols" # See split-debuginfo - allows us to drop the size by ~65% 30 | # incremental = true # Improves re-compile times 31 | 32 | [workspace.dependencies] 33 | tokio = { version = "1.43", features = ["macros", "rt-multi-thread"] } 34 | # Application 35 | idevice = { git = "https://github.com/jkcoxson/idevice", rev = "ea61459", features = ["afc", "installation_proxy", "debug_proxy", "rsd", "tunnel_tcp_stack", "heartbeat", "usbmuxd", "house_arrest"] } 36 | plist = "1.8.0" 37 | # Errors 38 | env_logger = "0.11.8" 39 | log = "0.4.28" 40 | thiserror = "2.0.16" 41 | # Utils 42 | uuid = "1.18.1" 43 | futures = "0.3.31" 44 | -------------------------------------------------------------------------------- /crates/core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "plume_core" 3 | description = "Core library for Impactor tools." 4 | edition.workspace = true 5 | version.workspace = true 6 | authors.workspace = true 7 | license = "MPL-2.0" 8 | repository.workspace = true 9 | 10 | [package.metadata.patch] 11 | crates = ["srp", "apple-codesign"] 12 | 13 | [dependencies] 14 | tokio.workspace = true 15 | plist.workspace = true 16 | uuid.workspace = true 17 | thiserror.workspace = true 18 | log.workspace = true 19 | # Core dependencies 20 | serde = { version = "1", features = ["derive"] } 21 | serde_json = { version = "1" } 22 | rustls = { version = "0.23.32", features = ["ring"] } 23 | reqwest = { version = "0.11.14", features = ["blocking", "json", "default-tls"] } 24 | regex = "1.11.2" 25 | base64 = "0.22" 26 | hex = "0.4.3" 27 | rand = "0.8.5" 28 | # Cryptography 29 | aes = "0.8.2" 30 | botan = "0.12.0" 31 | cbc = { version = "0.1.2", features = ["std"] } 32 | hmac = "0.12.1" 33 | pbkdf2 = "0.11" 34 | sha1 = "0.10.6" 35 | sha2 = "0.10.9" 36 | rsa = "0.9.8" 37 | # Certificates 38 | x509-certificate = "0.24.0" 39 | pem = "3.0.5" 40 | pem-rfc7468 = "0.7.0" 41 | rcgen = "0.9.3" 42 | p12-keystore = "0.2.0" # TODO: look into p12 crate and why it doesnt support SHA256 43 | # Apple 44 | goblin = "0.9.3" 45 | # Forks 46 | apple-codesign = { git = "https://github.com/PlumeImpactor/plume-apple-platform-rs", rev = "428b42f", package = "apple-codesign", default-features = false } 47 | omnisette = { git = "https://github.com/PlumeImpactor/omnisette", rev = "c066ac2", package = "omnisette", features = ["remote-anisette-v3"] } 48 | srp = { git = "https://github.com/PlumeImpactor/plume-PAKEs", rev = "047936a", package = "srp" } 49 | 50 | [features] 51 | default = [] 52 | vendored-botan = ["botan/vendored"] 53 | # TODO: add features lmao 54 | tweaks = [] 55 | store = [] 56 | -------------------------------------------------------------------------------- /crates/core/src/developer/qh/profile.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use plist::{Data, Date, Dictionary, Value}; 3 | 4 | use crate::Error; 5 | 6 | use crate::developer_endpoint; 7 | use super::{DeveloperSession, QHResponseMeta}; 8 | 9 | impl DeveloperSession { 10 | pub async fn qh_get_profile(&self, team_id: &String, app_id_id: &String) -> Result { 11 | let endpoint = developer_endpoint!("/QH65B2/ios/downloadTeamProvisioningProfile.action"); 12 | 13 | let mut body = Dictionary::new(); 14 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 15 | body.insert("appIdId".to_string(), Value::String(app_id_id.clone())); 16 | 17 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 18 | let response_data: ProfilesResponse = plist::from_value(&Value::Dictionary(response))?; 19 | 20 | Ok(response_data) 21 | } 22 | } 23 | 24 | #[allow(dead_code)] 25 | #[derive(Deserialize, Debug)] 26 | #[serde(rename_all = "camelCase")] 27 | pub struct ProfilesResponse { 28 | pub provisioning_profile: Profile, 29 | #[serde(flatten)] 30 | pub meta: QHResponseMeta, 31 | } 32 | 33 | #[allow(dead_code)] 34 | #[derive(Deserialize, Debug)] 35 | #[serde(rename_all = "camelCase")] 36 | pub struct Profile { 37 | provisioning_profile_id: String, 38 | name: String, 39 | status: String, 40 | #[serde(rename = "type")] 41 | _type: String, 42 | distribution_method: String, 43 | pro_pro_platorm: Option, 44 | #[serde(rename = "UUID")] 45 | uuid: String, 46 | pub date_expire: Date, 47 | managing_app: Option, 48 | // app_id: AppID, 49 | app_id_id: String, 50 | pub encoded_profile: Data, 51 | pub filename: String, 52 | is_template_profile: bool, 53 | is_team_profile: bool, 54 | is_free_provisioning_profile: Option, 55 | } 56 | -------------------------------------------------------------------------------- /crates/core/src/auth/account/mod.rs: -------------------------------------------------------------------------------- 1 | mod login; 2 | mod token; 3 | mod two_factor_auth; 4 | 5 | use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7}; 6 | use hmac::{Hmac, Mac}; 7 | use reqwest::Response; 8 | use sha2::Sha256; 9 | use srp::client::SrpClientVerifier; 10 | 11 | use crate::Error; 12 | 13 | pub async fn parse_response( 14 | res: Result, 15 | ) -> Result { 16 | let res = res?.text().await?; 17 | let res: plist::Dictionary = plist::from_bytes(res.as_bytes())?; 18 | let res: plist::Value = res.get("Response").unwrap().to_owned(); 19 | match res { 20 | plist::Value::Dictionary(dict) => Ok(dict), 21 | _ => Err(crate::Error::Parse), 22 | } 23 | } 24 | 25 | pub fn check_error(res: &plist::Dictionary) -> Result<(), Error> { 26 | let res = match res.get("Status") { 27 | Some(plist::Value::Dictionary(d)) => d, 28 | _ => &res, 29 | }; 30 | 31 | if res.get("ec").unwrap().as_signed_integer().unwrap() != 0 { 32 | return Err(Error::AuthSrpWithMessage( 33 | res.get("ec").unwrap().as_signed_integer().unwrap().into(), 34 | res.get("em").unwrap().as_string().unwrap().to_owned(), 35 | )); 36 | } 37 | 38 | Ok(()) 39 | } 40 | 41 | 42 | pub fn decrypt_cbc(usr: &SrpClientVerifier, data: &[u8]) -> Vec { 43 | let extra_data_key = create_session_key(usr, "extra data key:"); 44 | let extra_data_iv = create_session_key(usr, "extra data iv:"); 45 | let extra_data_iv = &extra_data_iv[..16]; 46 | 47 | cbc::Decryptor::::new_from_slices(&extra_data_key, extra_data_iv) 48 | .unwrap() 49 | .decrypt_padded_vec_mut::(&data) 50 | .unwrap() 51 | } 52 | 53 | pub fn create_session_key(usr: &SrpClientVerifier, name: &str) -> Vec { 54 | Hmac::::new_from_slice(&usr.key()) 55 | .unwrap() 56 | .chain_update(name.as_bytes()) 57 | .finalize() 58 | .into_bytes() 59 | .to_vec() 60 | } 61 | -------------------------------------------------------------------------------- /apps/plumesign/src/commands/macho.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use clap::Args; 3 | use anyhow::Result; 4 | use plume_core::{MachO, MachOExt}; 5 | 6 | #[derive(Debug, Args)] 7 | #[command(arg_required_else_help = true)] 8 | pub struct MachArgs { 9 | #[arg(value_name = "BINARY")] 10 | pub binary: PathBuf, 11 | #[arg(long)] 12 | pub entitlements: bool, 13 | /// List all dylib dependencies 14 | #[arg(long)] 15 | pub list_dylibs: bool, 16 | /// Add a dylib dependency (e.g., @rpath/MyLib.dylib) 17 | #[arg(long, value_name = "DYLIB_PATH")] 18 | pub add_dylib: Option, 19 | /// Replace an existing dylib dependency 20 | #[arg(long, value_names = &["OLD", "NEW"], num_args = 2)] 21 | pub replace_dylib: Option>, 22 | /// Set the SDK version (e.g., 26.0.0) 23 | #[arg(long, value_name = "SDK_VERSION")] 24 | pub sdk_version: Option 25 | } 26 | 27 | pub async fn execute(args: MachArgs) -> Result<()> { 28 | let mut macho = MachO::new(&args.binary)?; 29 | 30 | if let Some(dylib_path) = &args.add_dylib { 31 | macho.add_dylib(dylib_path)?; 32 | return Ok(()); 33 | } 34 | 35 | if let Some(replace_paths) = &args.replace_dylib { 36 | if replace_paths.len() == 2 { 37 | macho.replace_dylib(&replace_paths[0], &replace_paths[1])?; 38 | return Ok(()); 39 | } 40 | } 41 | 42 | if args.list_dylibs { 43 | // TODO: add index argument 44 | let d = macho.macho_file().nth_macho(0).unwrap().dylib_load_paths().unwrap(); 45 | for path in d { 46 | println!("{path}"); 47 | } 48 | return Ok(()); 49 | } 50 | 51 | if let Some(sdk_version) = &args.sdk_version { 52 | macho.replace_sdk_version(sdk_version)?; 53 | return Ok(()); 54 | } 55 | 56 | let entitlements = macho.entitlements(); 57 | if args.entitlements { 58 | if let Some(ent) = entitlements { 59 | let mut buf = Vec::new(); 60 | plist::Value::Dictionary(ent.clone()).to_writer_xml(&mut buf)?; 61 | let xml_str = String::from_utf8(buf)?; 62 | println!("{}", xml_str); 63 | } 64 | } 65 | 66 | Ok(()) 67 | } 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /apps/plumeimpactor/src/pages/work.rs: -------------------------------------------------------------------------------- 1 | use wxdragon::prelude::*; 2 | 3 | #[derive(Clone)] 4 | pub struct WorkPage { 5 | pub panel: Panel, 6 | status_text: StaticText, 7 | status_gauge: Gauge, 8 | back_button: Button, 9 | } 10 | 11 | pub fn create_work_page(frame: &Frame) -> WorkPage { 12 | let panel = Panel::builder(frame).build(); 13 | let sizer = BoxSizer::builder(Orientation::Vertical).build(); 14 | 15 | let warning_text = StaticText::builder(&panel) 16 | .with_label("Preparing application before installation/or export, this will take a moment. Do not\ndisconnect the device until finished.") 17 | .build(); 18 | let status_text = StaticText::builder(&panel) 19 | .with_label("Idle") 20 | .build(); 21 | let status_gauge = Gauge::builder(&panel) 22 | .with_range(100) 23 | .with_size(Size::new(-1, 30)) 24 | .build(); 25 | sizer.add(&warning_text, 0, SizerFlag::AlignLeft | SizerFlag::Expand | SizerFlag::Left | SizerFlag::Right | SizerFlag::Bottom, 16); 26 | sizer.add(&status_text, 0, SizerFlag::AlignLeft | SizerFlag::Expand | SizerFlag::Top | SizerFlag::Left | SizerFlag::Right, 16); 27 | sizer.add(&status_gauge, 0, SizerFlag::Expand | SizerFlag::All, 45); 28 | 29 | sizer.add_stretch_spacer(1); 30 | 31 | let button_sizer = BoxSizer::builder(Orientation::Horizontal).build(); 32 | let back_button = Button::builder(&panel) 33 | .with_label("Back") 34 | .build(); 35 | back_button.enable(false); 36 | button_sizer.add(&back_button, 0, SizerFlag::All, 0); 37 | button_sizer.add_stretch_spacer(1); 38 | sizer.add_sizer(&button_sizer, 0, SizerFlag::Expand | SizerFlag::Left | SizerFlag::Bottom, 13); 39 | 40 | panel.set_sizer(sizer, true); 41 | 42 | WorkPage { 43 | panel, 44 | status_text, 45 | status_gauge, 46 | back_button, 47 | } 48 | } 49 | 50 | impl WorkPage { 51 | pub fn set_status(&self, text: &str, progress: i32) { 52 | self.status_text.set_label(text); 53 | let new_value = std::cmp::min(progress, 100); 54 | self.status_gauge.set_value(new_value); 55 | } 56 | 57 | pub fn enable_back_button(&self, enable: bool) { 58 | self.back_button.enable(enable); 59 | } 60 | 61 | pub fn set_back_handler(&self, on_back: impl Fn() + 'static) { 62 | self.back_button.on_click(move |_| { 63 | on_back(); 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /crates/core/src/developer/qh/teams.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use plist::{Date, Integer, Value}; 3 | 4 | use crate::Error; 5 | 6 | use crate::developer_endpoint; 7 | use super::{DeveloperSession, QHResponseMeta}; 8 | 9 | impl DeveloperSession { 10 | pub async fn qh_list_teams(&self) -> Result { 11 | let endpoint = developer_endpoint!("/QH65B2/listTeams.action"); 12 | 13 | let response = self.qh_send_request(&endpoint, None).await?; 14 | let response_data: TeamsResponse = plist::from_value(&Value::Dictionary(response))?; 15 | 16 | Ok(response_data) 17 | } 18 | } 19 | 20 | #[allow(dead_code)] 21 | #[derive(Deserialize, Debug)] 22 | #[serde(rename_all = "camelCase")] 23 | pub struct TeamsResponse { 24 | pub teams: Vec, 25 | #[serde(flatten)] 26 | pub meta: QHResponseMeta, 27 | } 28 | 29 | #[allow(dead_code)] 30 | #[derive(Deserialize, Debug, Clone)] 31 | #[serde(rename_all = "camelCase")] 32 | pub struct Team { 33 | pub status: String, 34 | pub name: String, 35 | pub team_id: String, 36 | #[serde(rename = "type")] 37 | pub _type: String, 38 | team_agent: Option, 39 | memberships: Vec, 40 | current_team_member: TeamMember, 41 | date_created: Option, 42 | xcode_free_only: bool, 43 | team_provisioning_settings: TeamProvisionSettings, 44 | } 45 | 46 | #[allow(dead_code)] 47 | #[derive(Deserialize, Debug, Clone)] 48 | #[serde(rename_all = "camelCase")] 49 | struct Membership { 50 | membership_id: String, 51 | membership_product_id: String, 52 | status: String, 53 | in_ios_reset_window: Option, 54 | in_renewal_window: bool, 55 | date_start: Option, 56 | platform: String, 57 | delete_devices_on_expiry: bool, 58 | } 59 | 60 | #[allow(dead_code)] 61 | #[derive(Deserialize, Debug, Clone)] 62 | #[serde(rename_all = "camelCase")] 63 | struct TeamMember { 64 | team_member_id: String, 65 | person_id: Integer, 66 | first_name: String, 67 | last_name: String, 68 | email: String, 69 | developer_status: Option, 70 | // privileges: ... 71 | roles: Option>, 72 | } 73 | 74 | #[allow(dead_code)] 75 | #[derive(Deserialize, Debug, Clone)] 76 | #[serde(rename_all = "camelCase")] 77 | struct TeamProvisionSettings { 78 | can_developer_role_register_devices: bool, 79 | can_developer_role_add_app_ids: bool, 80 | can_developer_role_update_app_ids: bool, 81 | } 82 | -------------------------------------------------------------------------------- /crates/core/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | use plist::Value; 2 | 3 | mod certificate; 4 | mod provision; 5 | mod macho; 6 | 7 | pub use macho::{MachO, MachOExt}; 8 | pub use provision::MobileProvision; 9 | pub use certificate::CertificateIdentity; 10 | 11 | pub const TEAM_ID_REGEX: &str = r"^[A-Z0-9]{10}\."; 12 | 13 | pub fn merge_entitlements( 14 | base: &mut plist::Dictionary, 15 | additions: &plist::Dictionary, 16 | new_team_id: &Option, 17 | new_application_id: &Option, 18 | ) { 19 | // replaces wildcards in base entitlements with new application id 20 | // aggressive approach though, lets just hope this works :) 21 | if let Some(new_app_id) = new_application_id { 22 | fn replace_wildcard(value: &mut Value, new_app_id: &str) { 23 | match value { 24 | Value::String(s) => { 25 | if s.contains('*') { 26 | *s = s.replace('*', new_app_id); 27 | } 28 | } 29 | Value::Array(arr) => { 30 | for item in arr.iter_mut() { 31 | replace_wildcard(item, new_app_id); 32 | } 33 | } 34 | Value::Dictionary(dict) => { 35 | for v in dict.values_mut() { 36 | replace_wildcard(v, new_app_id); 37 | } 38 | } 39 | _ => {} 40 | } 41 | } 42 | for value in base.values_mut() { 43 | replace_wildcard(value, new_app_id); 44 | } 45 | } 46 | 47 | if let Some(Value::Array(groups)) = additions.get("keychain-access-groups") { 48 | base.insert("keychain-access-groups".to_string(), Value::Array(groups.clone())); 49 | } 50 | 51 | // remove anything that does not match XXXXXXXXXX. (for example, com.apple.token) 52 | // only XXXXXXXXXX.* is allowed on keychain-access-groups 53 | if let Some(Value::Array(groups)) = base.get_mut("keychain-access-groups") { 54 | let re = regex::Regex::new(TEAM_ID_REGEX).unwrap(); 55 | groups.retain(|g| matches!(g, Value::String(s) if re.is_match(s))); 56 | } 57 | 58 | if let Some(new_id) = new_team_id { 59 | if let Some(Value::Array(groups)) = base.get_mut("keychain-access-groups") { 60 | for group in groups.iter_mut() { 61 | if let Value::String(s) = group { 62 | let re = regex::Regex::new(TEAM_ID_REGEX).unwrap(); 63 | if re.is_match(s) { 64 | *s = format!("{}.{}", new_id, &s[11..]); 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /crates/types/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod options; 2 | mod package; 3 | mod bundle; 4 | mod device; 5 | mod signer; 6 | mod tweak; 7 | 8 | use std::path::Path; 9 | 10 | pub use options::{ 11 | SignerOptions, // Main 12 | SignerFeatures, // Feature support options 13 | SignerEmbedding, // Embedding options 14 | SignerMode, // Signing mode 15 | SignerInstallMode, // Installation mode 16 | SignerApp // Supported app types 17 | }; 18 | pub use package::Package; // Package helper 19 | pub use bundle::{Bundle, BundleType}; // Bundle helper 20 | pub use device::{Device, get_device_for_id}; // Device helper 21 | pub use signer::Signer; // Signer 22 | pub use tweak::Tweak; // Tweak helper 23 | 24 | use thiserror::Error as ThisError; 25 | #[derive(Debug, ThisError)] 26 | pub enum Error { 27 | #[error("Info.plist not found")] 28 | BundleInfoPlistMissing, 29 | // Device 30 | #[error("Bundle failed to rename, make sure its available: {0}")] 31 | BundleFailedToCopy(String), 32 | // Tweak 33 | #[error("Invalid tweak file path")] 34 | TweakInvalidPath, 35 | #[error("Tweak extraction failed: {0}")] 36 | TweakExtractionFailed(String), 37 | #[error("Unsupported file type: {0}")] 38 | UnsupportedFileType(String), 39 | 40 | #[error("Zip error: {0}")] 41 | Zip(#[from] zip::result::ZipError), 42 | #[error("Info.plist not found")] 43 | PackageInfoPlistMissing, 44 | #[error("I/O error: {0}")] 45 | Io(#[from] std::io::Error), 46 | #[error("Plist error: {0}")] 47 | Plist(#[from] plist::Error), 48 | #[error("Core error: {0}")] 49 | Core(#[from] plume_core::Error), 50 | #[error("Idevice error: {0}")] 51 | Idevice(#[from] idevice::IdeviceError), 52 | #[error("Codesign error: {0}")] 53 | Codesign(#[from] plume_core::AppleCodesignError), 54 | #[error("Other error: {0}")] 55 | Other(String), 56 | } 57 | 58 | pub trait PlistInfoTrait { 59 | fn get_name(&self) -> Option; 60 | fn get_executable(&self) -> Option; 61 | fn get_bundle_identifier(&self) -> Option; 62 | fn get_version(&self) -> Option; 63 | fn get_build_version(&self) -> Option; 64 | } 65 | 66 | async fn copy_dir_recursively(src: &Path, dst: &Path) -> Result<(), Error> { 67 | use tokio::fs; 68 | 69 | fs::create_dir_all(dst).await?; 70 | let mut entries = fs::read_dir(src).await?; 71 | 72 | while let Some(entry) = entries.next_entry().await? { 73 | let file_type = entry.file_type().await?; 74 | let src_path = entry.path(); 75 | let dst_path = dst.join(entry.file_name()); 76 | 77 | if file_type.is_dir() { 78 | Box::pin(copy_dir_recursively(&src_path, &dst_path)).await?; 79 | } else { 80 | fs::copy(&src_path, &dst_path).await?; 81 | } 82 | } 83 | 84 | Ok(()) 85 | } 86 | -------------------------------------------------------------------------------- /crates/gestalt/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::CStr; 2 | use std::os::raw::{c_char, c_void}; 3 | 4 | #[repr(C)] 5 | pub struct __CFString(c_void); 6 | pub type CFStringRef = *const __CFString; 7 | pub type CFTypeRef = *const c_void; 8 | 9 | const UTF8: u32 = 0x0800_0100; 10 | 11 | // ---------------------------------------------------------- 12 | // MobileGestalt + CoreFoundation bindings 13 | // ---------------------------------------------------------- 14 | 15 | unsafe extern "C" { 16 | pub fn MGCopyAnswer(key: CFStringRef) -> CFTypeRef; 17 | 18 | pub fn CFStringGetCStringPtr( 19 | string: CFStringRef, 20 | encoding: u32, 21 | ) -> *const c_char; 22 | 23 | pub fn CFStringGetCString( 24 | string: CFStringRef, 25 | buffer: *mut c_char, 26 | buffer_size: isize, 27 | encoding: u32, 28 | ) -> bool; 29 | 30 | pub fn CFStringCreateWithCString( 31 | alloc: *const c_void, 32 | c_str: *const c_char, 33 | encoding: u32, 34 | ) -> CFStringRef; 35 | 36 | pub fn CFRelease(cf: CFTypeRef); 37 | } 38 | 39 | // ---------------------------------------------------------- 40 | // Create a CFStringRef from a Rust &str 41 | // ---------------------------------------------------------- 42 | 43 | unsafe fn cfstring_from_str(s: &str) -> CFStringRef { 44 | let cstr = std::ffi::CString::new(s).unwrap(); 45 | unsafe { CFStringCreateWithCString(std::ptr::null(), cstr.as_ptr(), UTF8) } 46 | } 47 | 48 | // ---------------------------------------------------------- 49 | // Convert CFStringRef → Rust String 50 | // ---------------------------------------------------------- 51 | 52 | unsafe fn cfstring_to_string(cf: CFStringRef) -> Option { 53 | if cf.is_null() { 54 | return None; 55 | } 56 | 57 | unsafe { 58 | let ptr = CFStringGetCStringPtr(cf, UTF8); 59 | if !ptr.is_null() { 60 | return Some(CStr::from_ptr(ptr).to_string_lossy().into_owned()); 61 | } 62 | 63 | let mut buf = [0i8; 256]; 64 | if CFStringGetCString(cf, buf.as_mut_ptr(), buf.len() as isize, UTF8) { 65 | return Some(CStr::from_ptr(buf.as_ptr()).to_string_lossy().into_owned()); 66 | } 67 | } 68 | 69 | None 70 | } 71 | 72 | // ---------------------------------------------------------- 73 | // UDID 74 | // ---------------------------------------------------------- 75 | 76 | pub fn get_udid() -> Option { 77 | unsafe { 78 | let key = cfstring_from_str("UniqueDeviceID"); 79 | let cf = MGCopyAnswer(key); 80 | 81 | if cf.is_null() { 82 | CFRelease(key as CFTypeRef); 83 | return None; 84 | } 85 | 86 | let result = cfstring_to_string(cf as CFStringRef); 87 | 88 | CFRelease(cf); 89 | CFRelease(key as CFTypeRef); 90 | 91 | result 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /crates/core/src/developer/v1/capabilities.rs: -------------------------------------------------------------------------------- 1 | use plist::Dictionary; 2 | use serde::{Deserialize}; 3 | use serde_json::json; 4 | 5 | use super::{DeveloperSession, RequestType}; 6 | use crate::developer_endpoint; 7 | 8 | use crate::Error; 9 | use std::collections::HashSet; 10 | 11 | const FREE_DEVELOPER_ACCOUNT_UNALLOWED_CAPABILITIES: &[&str] = &[ 12 | "AUTOFILL_CREDENTIAL_PROVIDER", 13 | ]; 14 | 15 | impl DeveloperSession { 16 | pub async fn v1_list_capabilities(&self, team: &String) -> Result { 17 | let endpoint = developer_endpoint!("/v1/capabilities"); 18 | 19 | let body = json!({ 20 | "teamId": team, 21 | "urlEncodedQueryParams": "filter[platform]=IOS" 22 | }); 23 | 24 | let response = self.v1_send_request(&endpoint, Some(body), Some(RequestType::Get)).await?; 25 | let response_data: CapabilitiesResponse = serde_json::from_value(response)?; 26 | 27 | Ok(response_data) 28 | } 29 | 30 | pub async fn v1_request_capabilities_for_entitlements( 31 | &self, 32 | team: &String, 33 | id: &String, 34 | entitlements: &Dictionary 35 | ) -> Result<(), Error> { 36 | let capabilities = self.v1_list_capabilities(team).await?.data; 37 | let entitlement_keys: HashSet<&str> = entitlements.keys().map(|k| k.as_str()).collect(); 38 | 39 | // Collect capability IDs that match entitlement keys and are allowed for free accounts 40 | let capabilities_to_enable: Vec = capabilities 41 | .iter() 42 | .filter(|cap| !FREE_DEVELOPER_ACCOUNT_UNALLOWED_CAPABILITIES.contains(&cap.id.as_str())) 43 | .filter_map(|cap| { 44 | cap.attributes.entitlements.as_ref()?.iter() 45 | .find(|e| entitlement_keys.contains(e.profile_key.as_str())) 46 | .map(|_| cap.id.clone()) 47 | }) 48 | .collect(); 49 | 50 | self.v1_update_app_id( 51 | team, 52 | id, 53 | capabilities_to_enable.clone(), 54 | ).await?; 55 | 56 | Ok(()) 57 | } 58 | } 59 | 60 | #[allow(dead_code)] 61 | #[derive(Deserialize, Debug)] 62 | #[serde(rename_all = "camelCase")] 63 | pub struct CapabilitiesResponse { 64 | pub data: Vec, 65 | } 66 | 67 | #[allow(dead_code)] 68 | #[derive(Deserialize, Debug)] 69 | #[serde(rename_all = "camelCase")] 70 | pub struct Capability { 71 | pub id: String, 72 | pub attributes: CapabilityAttributes, 73 | } 74 | 75 | #[allow(dead_code)] 76 | #[derive(Deserialize, Debug)] 77 | #[serde(rename_all = "camelCase")] 78 | pub struct CapabilityAttributes { 79 | pub entitlements: Option>, 80 | pub supports_wildcard: bool, 81 | } 82 | 83 | #[allow(dead_code)] 84 | #[derive(Deserialize, Debug)] 85 | #[serde(rename_all = "camelCase")] 86 | pub struct CapabilityEntitlement { 87 | pub profile_key: String, 88 | } 89 | -------------------------------------------------------------------------------- /crates/core/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | pub mod developer; 3 | pub mod store; 4 | mod utils; 5 | 6 | pub use apple_codesign::{ 7 | SigningSettings, 8 | SettingsScope, 9 | UnifiedSigner, 10 | AppleCodesignError 11 | }; 12 | 13 | pub use omnisette::AnisetteConfiguration; 14 | 15 | pub use utils::{ 16 | MachO, 17 | MachOExt, 18 | MobileProvision, 19 | CertificateIdentity 20 | }; 21 | 22 | use thiserror::Error as ThisError; 23 | #[derive(Debug, ThisError)] 24 | pub enum Error { 25 | #[error("Executable not found")] 26 | BundleExecutableMissing, 27 | #[error("Entitlements not found")] 28 | ProvisioningEntitlementsUnknown, 29 | #[error("Missing certificate PEM data")] 30 | CertificatePemMissing, 31 | #[error("Certificate error: {0}")] 32 | Certificate(String), 33 | #[error("Developer API error {result_code} (HTTP {http_code:?}): {message} [URL: {url}]")] 34 | DeveloperApi { 35 | url: String, 36 | result_code: i64, 37 | http_code: Option, 38 | message: String, 39 | }, 40 | #[error("Request to developer session failed")] 41 | DeveloperSessionRequestFailed, 42 | #[error("Authentication SRP error {0}: {1}")] 43 | AuthSrpWithMessage(i64, String), 44 | #[error("Authentication extra step required: {0}")] 45 | ExtraStep(String), 46 | #[error("Bad 2FA code")] 47 | Bad2faCode, 48 | #[error("Failed to parse")] 49 | Parse, // TODO: better parsing errors 50 | #[error("I/O error: {0}")] 51 | Io(#[from] std::io::Error), 52 | #[error("Plist error: {0}")] 53 | Plist(#[from] plist::Error), 54 | #[error("Codesign error: {0}")] 55 | Codesign(#[from] apple_codesign::AppleCodesignError), 56 | #[error("CodeSignBuilder error: {0}")] 57 | CodeSignBuilder(#[from] apple_codesign::UniversalMachOError), 58 | #[error("Certificate PEM error: {0}")] 59 | Pem(#[from] pem::PemError), 60 | #[error("X509 certificate error: {0}")] 61 | X509(#[from] x509_certificate::X509CertificateError), 62 | #[error("Reqwest error: {0}")] 63 | Reqwest(#[from] reqwest::Error), 64 | #[error("Anisette error: {0}")] 65 | Anisette(#[from] omnisette::AnisetteError), 66 | #[error("Serde JSON error: {0}")] 67 | SerdeJson(#[from] serde_json::Error), 68 | #[error("RSA error: {0}")] 69 | Rsa(#[from] rsa::Error), 70 | #[error("PKCS1 RSA error: {0}")] 71 | PKCS1(#[from] rsa::pkcs1::Error), 72 | #[error("PKCS8 RSA error: {0}")] 73 | PKCS8(#[from] rsa::pkcs8::Error), 74 | #[error("RCGen error: {0}")] 75 | RcGen(#[from] rcgen::RcgenError), 76 | } 77 | 78 | pub fn client() -> Result { 79 | const APPLE_ROOT: &[u8] = include_bytes!("./apple_root.der"); 80 | let client = reqwest::ClientBuilder::new() 81 | .add_root_certificate(reqwest::Certificate::from_der(APPLE_ROOT)?) 82 | // uncomment when debugging w/ charles proxy 83 | // .danger_accept_invalid_certs(true) 84 | .http1_title_case_headers() 85 | .connection_verbose(true) 86 | .build()?; 87 | 88 | Ok(client) 89 | } 90 | -------------------------------------------------------------------------------- /crates/core/src/developer/qh/devices.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use plist::{Dictionary, Date, Value}; 3 | 4 | use crate::Error; 5 | 6 | use crate::developer_endpoint; 7 | use super::{DeveloperSession, QHResponseMeta}; 8 | 9 | impl DeveloperSession { 10 | pub async fn qh_list_devices(&self, team_id: &String) -> Result { 11 | let endpoint = developer_endpoint!("/QH65B2/ios/listDevices.action"); 12 | 13 | let mut body = Dictionary::new(); 14 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 15 | 16 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 17 | let response_data: DevicesResponse = plist::from_value(&Value::Dictionary(response))?; 18 | 19 | Ok(response_data) 20 | } 21 | 22 | pub async fn qh_add_device(&self, team_id: &String, device_name: &String, device_udid: &String) -> Result { 23 | let endpoint = developer_endpoint!("/QH65B2/ios/addDevice.action"); 24 | 25 | let mut body = Dictionary::new(); 26 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 27 | body.insert("name".to_string(), Value::String(device_name.clone())); 28 | body.insert("deviceNumber".to_string(), Value::String(device_udid.clone())); 29 | 30 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 31 | let response_data: DeviceResponse = plist::from_value(&Value::Dictionary(response))?; 32 | 33 | Ok(response_data) 34 | } 35 | 36 | pub async fn qh_get_device(&self, team_id: &String, device_udid: &String) -> Result, Error> { 37 | let response_data = self.qh_list_devices(team_id).await?; 38 | 39 | let device = response_data.devices.into_iter() 40 | .find(|dev| dev.device_number == *device_udid); 41 | 42 | Ok(device) 43 | } 44 | 45 | pub async fn qh_ensure_device(&self, team_id: &String, device_name: &String, device_udid: &String) -> Result { 46 | if let Some(device) = self.qh_get_device(team_id, device_udid).await? { 47 | Ok(device) 48 | } else { 49 | let response = self.qh_add_device(team_id, device_name, device_udid).await?; 50 | Ok(response.device) 51 | } 52 | } 53 | } 54 | 55 | #[allow(dead_code)] 56 | #[derive(Deserialize, Debug)] 57 | #[serde(rename_all = "camelCase")] 58 | pub struct DevicesResponse { 59 | pub devices: Vec, 60 | #[serde(flatten)] 61 | pub meta: QHResponseMeta, 62 | } 63 | 64 | #[allow(dead_code)] 65 | #[derive(Deserialize, Debug)] 66 | #[serde(rename_all = "camelCase")] 67 | pub struct DeviceResponse { 68 | pub device: Device, 69 | #[serde(flatten)] 70 | pub meta: QHResponseMeta, 71 | } 72 | 73 | #[allow(dead_code)] 74 | #[derive(Deserialize, Debug)] 75 | #[serde(rename_all = "camelCase")] 76 | pub struct Device { 77 | device_id: String, 78 | name: String, 79 | device_number: String, 80 | device_platform: String, 81 | status: String, 82 | device_class: String, 83 | expiration_date: Option, 84 | } 85 | -------------------------------------------------------------------------------- /crates/core/src/utils/provision.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use crate::Error; 5 | use crate::utils::TEAM_ID_REGEX; 6 | use plist::{Dictionary, Value}; 7 | 8 | use super::MachO; 9 | 10 | #[derive(Clone)] 11 | pub struct MobileProvision { 12 | pub data: Vec, 13 | entitlements: Dictionary, 14 | } 15 | 16 | impl MobileProvision { 17 | pub fn load_with_path>(path: P) -> Result { 18 | let path = path.as_ref(); 19 | let data = fs::read(path)?; 20 | 21 | Self::load_with_bytes(data) 22 | } 23 | 24 | pub fn load_with_bytes(data: Vec) -> Result { 25 | let entitlements = Self::extract_entitlements_from_prov(&data)?; 26 | 27 | Ok(Self { 28 | data, 29 | entitlements, 30 | }) 31 | } 32 | 33 | pub fn merge_entitlements(&mut self, binary_path: PathBuf, new_application_id: &str) -> Result<(), Error> { 34 | let macho = MachO::new(&binary_path)?; 35 | let binary_entitlements = macho 36 | .entitlements().clone() 37 | .ok_or(Error::ProvisioningEntitlementsUnknown)?; 38 | 39 | let new_team_id = self 40 | .entitlements 41 | .get("com.apple.developer.team-identifier") 42 | .and_then(Value::as_string) 43 | .map(|s| s.to_owned()); 44 | 45 | crate::utils::merge_entitlements( 46 | &mut self.entitlements, 47 | &binary_entitlements, 48 | &new_team_id, 49 | &Some(new_application_id.to_string()), 50 | ); 51 | 52 | Ok(()) 53 | } 54 | 55 | pub fn entitlements(&self) -> &Dictionary { 56 | &self.entitlements 57 | } 58 | 59 | pub fn entitlements_as_bytes(&self) -> Result, Error> { 60 | let mut buf = Vec::new(); 61 | Value::Dictionary(self.entitlements.clone()).to_writer_xml(&mut buf)?; 62 | Ok(buf) 63 | } 64 | 65 | pub fn bundle_id(&self) -> Option { 66 | let app_id = self 67 | .entitlements 68 | .get("application-identifier")? 69 | .as_string()?; 70 | 71 | let re = regex::Regex::new(TEAM_ID_REGEX).ok()?; 72 | let bundle_id = re.replace(app_id, "").to_string(); 73 | 74 | Some(bundle_id) 75 | } 76 | 77 | fn extract_entitlements_from_prov(data: &[u8]) -> Result { 78 | let start = data 79 | .windows(6) 80 | .position(|w| w == b"") 85 | .ok_or(Error::ProvisioningEntitlementsUnknown)? 86 | + 8; 87 | let plist_data = &data[start..end]; 88 | let plist = plist::Value::from_reader_xml(plist_data)?; 89 | 90 | plist 91 | .as_dictionary() 92 | .and_then(|d| d.get("Entitlements")) 93 | .and_then(|v| v.as_dictionary()) 94 | .cloned() 95 | .ok_or(Error::ProvisioningEntitlementsUnknown) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /package/windows/installer.nsi: -------------------------------------------------------------------------------- 1 | ;=========================================================== 2 | ; Modern NSIS Installer for a Single EXE (64-bit) 3 | ;=========================================================== 4 | 5 | !define APPNAME "PlumeImpactor" 6 | !define APPEXE "plumeimpactor.exe" 7 | !define COMPANY "Samara" 8 | 9 | Name "${APPNAME}" 10 | BrandingText "${APPNAME} Setup" 11 | 12 | OutFile "PlumeInstaller.exe" 13 | 14 | RequestExecutionLevel admin 15 | InstallDir "$PROGRAMFILES64\${APPNAME}" 16 | InstallDirRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "InstallLocation" 17 | 18 | ;----------------------------------------------------------- 19 | ; Modern UI 2 20 | ;----------------------------------------------------------- 21 | !include "MUI2.nsh" 22 | 23 | !define MUI_ABORTWARNING 24 | !define MUI_ICON "icon.ico" 25 | !define MUI_UNICON "icon.ico" 26 | 27 | ;----------------------------------------------------------- 28 | ; Installer Pages 29 | ;----------------------------------------------------------- 30 | !insertmacro MUI_PAGE_WELCOME 31 | !insertmacro MUI_PAGE_DIRECTORY 32 | !insertmacro MUI_PAGE_INSTFILES 33 | !insertmacro MUI_PAGE_FINISH 34 | 35 | ;----------------------------------------------------------- 36 | ; Uninstaller Pages 37 | ;----------------------------------------------------------- 38 | !insertmacro MUI_UNPAGE_WELCOME 39 | !insertmacro MUI_UNPAGE_CONFIRM 40 | !insertmacro MUI_UNPAGE_INSTFILES 41 | !insertmacro MUI_UNPAGE_FINISH 42 | 43 | !insertmacro MUI_LANGUAGE "English" 44 | 45 | ;=========================================================== 46 | ; Installer Section 47 | ;=========================================================== 48 | Section "Install" 49 | 50 | SetOutPath "$INSTDIR" 51 | File "${APPEXE}" 52 | 53 | ; Start Menu entries 54 | CreateDirectory "$SMPROGRAMS\${APPNAME}" 55 | CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" "$INSTDIR\${APPEXE}" 56 | 57 | CreateShortcut "$DESKTOP\${APPNAME}.lnk" "$INSTDIR\${APPEXE}" 58 | 59 | WriteUninstaller "$INSTDIR\Uninstall.exe" 60 | 61 | ; 64-bit registry uninstall entry 62 | WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "DisplayName" "${APPNAME}" 63 | WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "Publisher" "${COMPANY}" 64 | WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "InstallLocation" "$INSTDIR" 65 | WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "UninstallString" "$INSTDIR\Uninstall.exe" 66 | WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "NoModify" 1 67 | WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" "NoRepair" 1 68 | 69 | SectionEnd 70 | 71 | ;=========================================================== 72 | ; Uninstaller Section 73 | ;=========================================================== 74 | Section "Uninstall" 75 | 76 | Delete "$DESKTOP\${APPNAME}.lnk" 77 | Delete "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" 78 | RMDir "$SMPROGRAMS\${APPNAME}" 79 | 80 | Delete "$INSTDIR\${APPEXE}" 81 | Delete "$INSTDIR\Uninstall.exe" 82 | 83 | RMDir "$INSTDIR" 84 | 85 | DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" 86 | 87 | SectionEnd 88 | -------------------------------------------------------------------------------- /apps/plumeimpactor/src/pages/default.rs: -------------------------------------------------------------------------------- 1 | use wxdragon::prelude::*; 2 | 3 | #[cfg(not(target_os = "linux"))] 4 | const WELCOME_TEXT: &str = "Drag and drop your .ipa here"; 5 | #[cfg(target_os = "linux")] 6 | const WELCOME_TEXT: &str = "Press 'import' and select an .ipa to get started"; 7 | 8 | #[derive(Clone)] 9 | pub struct DefaultPage { 10 | pub panel: Panel, 11 | } 12 | 13 | impl DefaultPage { 14 | #[cfg(not(target_os = "linux"))] 15 | fn is_allowed_file(path: &str) -> bool { 16 | path.ends_with(".ipa") || path.ends_with(".tipa") 17 | } 18 | 19 | #[cfg(not(target_os = "linux"))] 20 | pub fn set_file_handlers(&self, on_drop: impl Fn(String) + 'static) { 21 | _ = FileDropTarget::builder(&self.panel) 22 | .with_on_drop_files(move |files, _, _| { 23 | if files.len() != 1 || !DefaultPage::is_allowed_file(&files[0]) { 24 | return false; 25 | } 26 | on_drop(files[0].clone()); 27 | true 28 | }) 29 | .with_on_drag_over(move |_, _, _| DragResult::Move) 30 | .with_on_enter(move |_, _, _| DragResult::Move) 31 | .build(); 32 | } 33 | } 34 | 35 | pub fn create_default_page(frame: &Frame) -> DefaultPage { 36 | let panel = Panel::builder(frame).build(); 37 | let sizer = BoxSizer::builder(Orientation::Vertical).build(); 38 | 39 | sizer.add_stretch_spacer(1); 40 | 41 | let welcome_text = StaticText::builder(&panel) 42 | .with_label(WELCOME_TEXT) 43 | .with_style(StaticTextStyle::AlignCenterHorizontal) 44 | .build(); 45 | 46 | sizer.add( 47 | &welcome_text, 48 | 0, 49 | SizerFlag::AlignCenterHorizontal | SizerFlag::All, 50 | 0, 51 | ); 52 | 53 | sizer.add_stretch_spacer(1); 54 | 55 | let love_sizer = BoxSizer::builder(Orientation::Horizontal).build(); 56 | let made_with_text = StaticText::builder(&panel) 57 | .with_label("Made with 💖 from ") 58 | .build(); 59 | let khcrysalis_link = HyperlinkCtrl::builder(&panel) 60 | .with_label("SAMSAM") 61 | .with_url("https://github.com/khcrysalis") 62 | .with_style(HyperlinkCtrlStyle::AlignLeft | HyperlinkCtrlStyle::NoUnderline) 63 | .build(); 64 | let separator1 = StaticText::builder(&panel) 65 | .with_label(" • ") 66 | .build(); 67 | let github_link = HyperlinkCtrl::builder(&panel) 68 | .with_label("GitHub") 69 | .with_url("https://github.com/khcrysalis/plumeimpactor") 70 | .with_style(HyperlinkCtrlStyle::AlignLeft | HyperlinkCtrlStyle::NoUnderline) 71 | .build(); 72 | let separator2 = StaticText::builder(&panel) 73 | .with_label(" • ") 74 | .build(); 75 | let donate_link = HyperlinkCtrl::builder(&panel) 76 | .with_label("Donate") 77 | .with_url("https://github.com/sponsors/khcrysalis") 78 | .with_style(HyperlinkCtrlStyle::AlignLeft | HyperlinkCtrlStyle::NoUnderline) 79 | .build(); 80 | love_sizer.add(&made_with_text, 0, SizerFlag::AlignCenterVertical, 0); 81 | love_sizer.add(&khcrysalis_link, 0, SizerFlag::AlignCenterVertical, 0); 82 | love_sizer.add(&separator1, 0, SizerFlag::AlignCenterVertical, 0); 83 | love_sizer.add(&github_link, 0, SizerFlag::AlignCenterVertical, 0); 84 | love_sizer.add(&separator2, 0, SizerFlag::AlignCenterVertical, 0); 85 | love_sizer.add(&donate_link, 0, SizerFlag::AlignCenterVertical, 0); 86 | 87 | sizer.add_sizer( 88 | &love_sizer, 89 | 0, 90 | SizerFlag::Left | SizerFlag::All, 91 | 15, 92 | ); 93 | 94 | panel.set_sizer(sizer, true); 95 | 96 | DefaultPage { panel } 97 | } 98 | -------------------------------------------------------------------------------- /crates/core/src/auth/anisette_data.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::time::SystemTime; 3 | 4 | use omnisette::{AnisetteConfiguration, AnisetteHeaders}; 5 | 6 | use crate::Error; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct AnisetteData { 10 | pub base_headers: HashMap, 11 | pub generated_at: SystemTime, 12 | pub config: AnisetteConfiguration, 13 | } 14 | 15 | impl AnisetteData { 16 | pub async fn new(config: AnisetteConfiguration) -> Result { 17 | let mut b = AnisetteHeaders::get_anisette_headers_provider(config.clone())?; 18 | let base_headers = b.provider.get_authentication_headers().await?; 19 | 20 | Ok(AnisetteData { 21 | base_headers, 22 | generated_at: SystemTime::now(), 23 | config 24 | }) 25 | } 26 | 27 | pub fn needs_refresh(&self) -> bool { 28 | let elapsed = self.generated_at.elapsed().unwrap(); 29 | elapsed.as_secs() > 60 30 | } 31 | 32 | pub fn is_valid(&self) -> bool { 33 | let elapsed = self.generated_at.elapsed().unwrap(); 34 | elapsed.as_secs() < 90 35 | } 36 | 37 | pub async fn refresh(&self) -> Result { 38 | Self::new(self.config.clone()).await 39 | } 40 | 41 | pub fn generate_headers( 42 | &self, 43 | cpd: bool, 44 | client_info: bool, 45 | app_info: bool, 46 | ) -> HashMap { 47 | if !self.is_valid() { 48 | panic!("Invalid data!") 49 | } 50 | 51 | let mut headers = self.base_headers.clone(); 52 | let old_client_info = headers.remove("X-Mme-Client-Info"); 53 | 54 | if client_info { 55 | let client_info = match old_client_info { 56 | Some(v) => { 57 | let temp = v.as_str(); 58 | 59 | temp.replace( 60 | temp.split('<').nth(3).unwrap().split('>').nth(0).unwrap(), 61 | "com.apple.AuthKit/1 (com.apple.dt.Xcode/3594.4.19)", 62 | ) 63 | } 64 | None => { 65 | return headers; 66 | } 67 | }; 68 | headers.insert("X-Mme-Client-Info".to_owned(), client_info.to_owned()); 69 | } 70 | 71 | if app_info { 72 | headers.insert( 73 | "X-Apple-App-Info".to_owned(), 74 | "com.apple.gs.xcode.auth".to_owned(), 75 | ); 76 | headers.insert("X-Xcode-Version".to_owned(), "11.2 (11B41)".to_owned()); 77 | } 78 | 79 | if cpd { 80 | headers.insert("bootstrap".to_owned(), "true".to_owned()); 81 | headers.insert("icscrec".to_owned(), "true".to_owned()); 82 | headers.insert("loc".to_owned(), "en_GB".to_owned()); 83 | headers.insert("pbe".to_owned(), "false".to_owned()); 84 | headers.insert("prkgen".to_owned(), "true".to_owned()); 85 | headers.insert("svct".to_owned(), "iCloud".to_owned()); 86 | } 87 | 88 | headers 89 | } 90 | 91 | pub fn to_plist(&self, cpd: bool, client_info: bool, app_info: bool) -> plist::Dictionary { 92 | let mut plist = plist::Dictionary::new(); 93 | for (key, value) in self.generate_headers(cpd, client_info, app_info).iter() { 94 | plist.insert(key.to_owned(), plist::Value::String(value.to_owned())); 95 | } 96 | 97 | plist 98 | } 99 | 100 | pub fn get_header(&self, header: &str) -> Result { 101 | let headers = self 102 | .generate_headers(true, true, true) 103 | .iter() 104 | .map(|(k, v)| (k.to_lowercase(), v.to_lowercase())) 105 | .collect::>(); 106 | 107 | match headers.get(&header.to_lowercase()) { 108 | Some(v) => Ok(v.to_string()), 109 | None => Err(Error::DeveloperSessionRequestFailed), 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /crates/core/src/developer/qh/app_groups.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use plist::{Dictionary, Value}; 3 | 4 | use crate::Error; 5 | 6 | use crate::developer::strip_invalid_chars; 7 | use crate::developer_endpoint; 8 | use super::{DeveloperSession, QHResponseMeta}; 9 | 10 | impl DeveloperSession { 11 | pub async fn qh_list_app_groups(&self, team_id: &String) -> Result { 12 | let endpoint = developer_endpoint!("/QH65B2/ios/listApplicationGroups.action"); 13 | 14 | let mut body = Dictionary::new(); 15 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 16 | 17 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 18 | let response_data: AppGroupsResponse = plist::from_value(&Value::Dictionary(response))?; 19 | 20 | Ok(response_data) 21 | } 22 | 23 | pub async fn qh_add_app_group(&self, team_id: &String, name: &String, identifier: &String) -> Result { 24 | let endpoint = developer_endpoint!("/QH65B2/ios/addApplicationGroup.action"); 25 | 26 | let mut body = Dictionary::new(); 27 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 28 | body.insert("name".to_string(), Value::String(strip_invalid_chars(name))); 29 | body.insert("identifier".to_string(), Value::String(identifier.clone())); 30 | 31 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 32 | let response_data: AppGroupResponse = plist::from_value(&Value::Dictionary(response))?; 33 | 34 | Ok(response_data) 35 | } 36 | 37 | pub async fn qh_get_app_group(&self, team_id: &String, app_group_identifier: &String) -> Result, Error> { 38 | let response_data = self.qh_list_app_groups(team_id).await?; 39 | 40 | let app_group = response_data.application_group_list.into_iter() 41 | .find(|group| group.identifier == *app_group_identifier); 42 | 43 | Ok(app_group) 44 | } 45 | 46 | pub async fn qh_ensure_app_group(&self, team_id: &String, name: &String, identifier: &String) -> Result { 47 | if let Some(app_group) = self.qh_get_app_group(team_id, identifier).await? { 48 | Ok(app_group) 49 | } else { 50 | let response = self.qh_add_app_group(team_id, name, identifier).await?; 51 | Ok(response.application_group) 52 | } 53 | } 54 | 55 | pub async fn qh_assign_app_group(&self, team_id: &String, app_id_id: &String, app_group_ids: &Vec) -> Result { 56 | let endpoint = developer_endpoint!("/QH65B2/ios/assignApplicationGroupToAppId.action"); 57 | 58 | let mut body = Dictionary::new(); 59 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 60 | body.insert("appIdId".to_string(), Value::String(app_id_id.clone())); 61 | body.insert("applicationGroups".to_string(), Value::Array(app_group_ids.iter().map(|s| Value::String(s.clone())).collect())); 62 | 63 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 64 | let response_data: QHResponseMeta = plist::from_value(&Value::Dictionary(response))?; 65 | 66 | Ok(response_data) 67 | } 68 | } 69 | 70 | #[allow(dead_code)] 71 | #[derive(Deserialize, Debug)] 72 | #[serde(rename_all = "camelCase")] 73 | pub struct AppGroupsResponse { 74 | pub application_group_list: Vec, 75 | #[serde(flatten)] 76 | pub meta: QHResponseMeta, 77 | } 78 | 79 | #[allow(dead_code)] 80 | #[derive(Deserialize, Debug)] 81 | #[serde(rename_all = "camelCase")] 82 | pub struct AppGroupResponse { 83 | pub application_group: ApplicationGroup, 84 | #[serde(flatten)] 85 | pub meta: QHResponseMeta, 86 | } 87 | 88 | #[allow(dead_code)] 89 | #[derive(Deserialize, Debug)] 90 | #[serde(rename_all = "camelCase")] 91 | pub struct ApplicationGroup { 92 | pub application_group: String, // this is the actual identifier 93 | pub name: String, 94 | pub status: String, 95 | prefix: String, 96 | pub identifier: String, // this is the group.identifier 97 | } 98 | -------------------------------------------------------------------------------- /crates/core/src/developer/v1/app_ids.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize}; 2 | use serde_json::{Value, json}; 3 | 4 | use super::{DeveloperSession, RequestType}; 5 | use crate::developer_endpoint; 6 | 7 | use crate::Error; 8 | 9 | impl DeveloperSession { 10 | pub async fn v1_list_app_ids(&self, team: &String) -> Result { 11 | let endpoint = developer_endpoint!("/v1/bundleIds"); 12 | 13 | let body = json!({ 14 | "teamId": team, 15 | "urlEncodedQueryParams": "limit=1000" 16 | }); 17 | 18 | let response = self.v1_send_request(&endpoint, Some(body), Some(RequestType::Get)).await?; 19 | let response_data: AppIDsResponse = serde_json::from_value(response)?; 20 | 21 | Ok(response_data) 22 | } 23 | 24 | pub async fn v1_get_app_id(&self, team: &String, app_id: &String) -> Result, Error> { 25 | let response_data = self.v1_list_app_ids(team).await?; 26 | 27 | let app_id = response_data.data.into_iter() 28 | .find(|app| app.attributes.identifier == *app_id); 29 | 30 | Ok(app_id) 31 | } 32 | 33 | pub async fn v1_update_app_id(&self, team: &String, app_id: &String, capabilities: Vec) -> Result { 34 | let response_data = self.v1_get_app_id(team, app_id).await?; 35 | let app_id = response_data.ok_or(Error::DeveloperSessionRequestFailed)?; 36 | 37 | let endpoint = developer_endpoint!(&format!("/v1/bundleIds/{}", app_id.id)); 38 | 39 | let bundle_id_capabilities: Vec = capabilities.into_iter().map(|capability_id| { 40 | json!({ 41 | "type": "bundleIdCapabilities", 42 | "attributes": { 43 | "enabled": true, 44 | "settings": [] 45 | }, 46 | "relationships": { 47 | "capability": { 48 | "data": { 49 | "type": "capabilities", 50 | "id": capability_id 51 | } 52 | } 53 | } 54 | }) 55 | }).collect(); 56 | 57 | let payload = json!({ 58 | "data": { 59 | "type": "bundleIds", 60 | "id": app_id.id, 61 | "attributes": { 62 | "identifier": app_id.attributes.identifier, 63 | "seedId": app_id.attributes.seed_id, 64 | "teamId": team, 65 | "name": app_id.attributes.name, 66 | "wildcard": app_id.attributes.wildcard, 67 | }, 68 | "relationships": { 69 | "bundleIdCapabilities": { 70 | "data": bundle_id_capabilities 71 | } 72 | } 73 | } 74 | }); 75 | 76 | let response = self.v1_send_request(&endpoint, Some(payload), Some(RequestType::Patch)).await?; 77 | let response_data: AppIDResponse = serde_json::from_value(response)?; 78 | 79 | Ok(response_data) 80 | } 81 | } 82 | 83 | #[allow(dead_code)] 84 | #[derive(Deserialize, Debug)] 85 | #[serde(rename_all = "camelCase")] 86 | pub struct AppIDsResponse { 87 | pub data: Vec, 88 | } 89 | 90 | #[allow(dead_code)] 91 | #[derive(Deserialize, Debug)] 92 | #[serde(rename_all = "camelCase")] 93 | pub struct AppIDResponse { 94 | pub data: AppID, 95 | } 96 | 97 | #[allow(dead_code)] 98 | #[derive(Deserialize, Debug)] 99 | #[serde(rename_all = "camelCase")] 100 | pub struct AppID { 101 | pub id: String, 102 | pub attributes: AppIDAttributes, 103 | } 104 | 105 | #[allow(dead_code)] 106 | #[derive(Deserialize, Debug)] 107 | #[serde(rename_all = "camelCase")] 108 | pub struct AppIDAttributes { 109 | pub identifier: String, 110 | pub seed_id: String, 111 | pub has_exclusive_managed_capabilities: bool, 112 | pub name: String, 113 | // pub entitlement_group_name: Option, 114 | pub bundle_type: String, 115 | // pub entitlement_types: Option, 116 | // pub platform: Option, 117 | // pub deployment_data_notice: Option, 118 | // pub response_id: Option, 119 | pub wildcard: bool, 120 | } 121 | -------------------------------------------------------------------------------- /crates/core/src/auth/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod account; 2 | pub mod anisette_data; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | use omnisette::AnisetteConfiguration; 6 | use reqwest::Client; 7 | use tokio::sync::Mutex; 8 | use std::sync::Arc; 9 | 10 | use crate::{Error, client}; 11 | use crate::auth::anisette_data::AnisetteData; 12 | 13 | const GSA_ENDPOINT: &str = "https://gsa.apple.com/grandslam/GsService2"; 14 | 15 | #[derive(Debug, Clone)] 16 | pub struct Account { 17 | pub anisette: Arc>, 18 | pub spd: Option, 19 | pub client: Client, 20 | } 21 | 22 | impl Account { 23 | pub async fn new(config: AnisetteConfiguration) -> Result { 24 | let anisette = AnisetteData::new(config).await?; 25 | Self::new_with_anisette(anisette) 26 | } 27 | 28 | pub fn new_with_anisette(anisette: AnisetteData) -> Result { 29 | let client = client()?; 30 | Ok(Account { 31 | anisette: Arc::new(Mutex::new(anisette)), 32 | spd: None, 33 | client, 34 | }) 35 | } 36 | } 37 | 38 | #[derive(Debug, Serialize, Deserialize)] 39 | pub struct InitRequestBody { 40 | #[serde(rename = "A2k")] 41 | a_pub: plist::Value, 42 | cpd: plist::Dictionary, 43 | #[serde(rename = "o")] 44 | operation: String, 45 | ps: Vec, 46 | #[serde(rename = "u")] 47 | username: String, 48 | } 49 | 50 | #[derive(Debug, Serialize, Deserialize, Clone)] 51 | pub struct RequestHeader { 52 | #[serde(rename = "Version")] 53 | version: String, 54 | } 55 | 56 | #[derive(Debug, Serialize, Deserialize)] 57 | pub struct InitRequest { 58 | #[serde(rename = "Header")] 59 | header: RequestHeader, 60 | #[serde(rename = "Request")] 61 | request: InitRequestBody, 62 | } 63 | 64 | #[derive(Debug, Serialize, Deserialize)] 65 | pub struct ChallengeRequestBody { 66 | #[serde(rename = "M1")] 67 | m: plist::Value, 68 | cpd: plist::Dictionary, 69 | c: String, 70 | #[serde(rename = "o")] 71 | operation: String, 72 | #[serde(rename = "u")] 73 | username: String, 74 | } 75 | #[derive(Debug, Serialize, Deserialize)] 76 | pub struct ChallengeRequest { 77 | #[serde(rename = "Header")] 78 | header: RequestHeader, 79 | #[serde(rename = "Request")] 80 | request: ChallengeRequestBody, 81 | } 82 | 83 | #[derive(Debug, Serialize, Deserialize)] 84 | pub struct AuthTokenRequestBody { 85 | app: Vec, 86 | c: plist::Value, 87 | cpd: plist::Dictionary, 88 | #[serde(rename = "o")] 89 | operation: String, 90 | t: String, 91 | u: String, 92 | checksum: plist::Value, 93 | } 94 | 95 | #[derive(Debug, Serialize, Deserialize)] 96 | pub struct AuthTokenRequest { 97 | #[serde(rename = "Header")] 98 | header: RequestHeader, 99 | #[serde(rename = "Request")] 100 | request: AuthTokenRequestBody, 101 | } 102 | 103 | #[derive(Clone, Debug)] 104 | pub struct AppToken { 105 | pub app_tokens: plist::Dictionary, 106 | pub auth_token: String, 107 | pub app: String, 108 | } 109 | 110 | #[repr(C)] 111 | #[derive(Debug)] 112 | pub enum LoginState { 113 | LoggedIn, 114 | NeedsDevice2FA, 115 | Needs2FAVerification, 116 | NeedsSMS2FA, 117 | NeedsSMS2FAVerification(VerifyBody), 118 | NeedsExtraStep(String), 119 | NeedsLogin, 120 | } 121 | 122 | #[derive(Serialize, Debug, Clone)] 123 | struct VerifyCode { 124 | code: String, 125 | } 126 | 127 | #[derive(Serialize, Debug, Clone)] 128 | struct PhoneNumber { 129 | id: u32, 130 | } 131 | 132 | #[derive(Serialize, Debug, Clone)] 133 | #[serde(rename_all = "camelCase")] 134 | pub struct VerifyBody { 135 | phone_number: PhoneNumber, 136 | mode: String, 137 | security_code: Option, 138 | } 139 | 140 | #[repr(C)] 141 | #[derive(Deserialize)] 142 | #[serde(rename_all = "camelCase")] 143 | pub struct TrustedPhoneNumber { 144 | pub number_with_dial_code: String, 145 | pub last_two_digits: String, 146 | pub push_mode: String, 147 | pub id: u32, 148 | } 149 | 150 | #[derive(Deserialize)] 151 | #[serde(rename_all = "camelCase")] 152 | pub struct AuthenticationExtras { 153 | pub trusted_phone_numbers: Vec, 154 | pub recovery_url: Option, 155 | pub cant_use_phone_number_url: Option, 156 | pub dont_have_access_url: Option, 157 | pub recovery_web_url: Option, 158 | pub repair_phone_number_url: Option, 159 | pub repair_phone_number_web_url: Option, 160 | #[serde(skip)] 161 | pub new_state: Option, 162 | } 163 | -------------------------------------------------------------------------------- /crates/core/src/developer/qh/certs.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use plist::{Data, Date, Dictionary, Integer, Value}; 3 | use uuid::Uuid; 4 | 5 | use crate::Error; 6 | 7 | use crate::developer_endpoint; 8 | use super::{DeveloperSession, QHResponseMeta}; 9 | 10 | impl DeveloperSession { 11 | pub async fn qh_list_certs(&self, team_id: &String) -> Result { 12 | let endpoint = developer_endpoint!("/QH65B2/ios/listAllDevelopmentCerts.action"); 13 | 14 | let mut body = Dictionary::new(); 15 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 16 | 17 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 18 | let response_data: CertsResponse = plist::from_value(&Value::Dictionary(response))?; 19 | 20 | Ok(response_data) 21 | } 22 | 23 | pub async fn qh_revoke_cert(&self, team_id: &String, serial_number: &String) -> Result { 24 | let endpoint = developer_endpoint!("/QH65B2/ios/revokeDevelopmentCert.action"); 25 | 26 | let mut body = Dictionary::new(); 27 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 28 | body.insert("serialNumber".to_string(), Value::String(serial_number.clone())); 29 | 30 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 31 | let response_data: QHResponseMeta = plist::from_value(&Value::Dictionary(response))?; 32 | 33 | Ok(response_data) 34 | } 35 | 36 | pub async fn qh_submit_cert_csr(&self, team_id: &String, csr_data: String, machine_name: &String) -> Result { 37 | let endpoint = developer_endpoint!("/QH65B2/ios/submitDevelopmentCSR.action"); 38 | 39 | let mut body = Dictionary::new(); 40 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 41 | body.insert("csrContent".to_string(), Value::String(csr_data)); 42 | body.insert("machineId".to_string(), Value::String(Uuid::new_v4().to_string().to_uppercase())); 43 | body.insert("machineName".to_string(), Value::String(machine_name.clone())); 44 | 45 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 46 | let response_data: CsrResponse = plist::from_value(&Value::Dictionary(response))?; 47 | 48 | Ok(response_data) 49 | } 50 | } 51 | 52 | #[allow(dead_code)] 53 | #[derive(Deserialize, Debug)] 54 | #[serde(rename_all = "camelCase")] 55 | pub struct CertsResponse { 56 | pub certificates: Vec, 57 | #[serde(flatten)] 58 | pub meta: QHResponseMeta, 59 | } 60 | 61 | #[allow(dead_code)] 62 | #[derive(Deserialize, Debug)] 63 | #[serde(rename_all = "camelCase")] 64 | pub struct CsrResponse { 65 | pub cert_request: Csr, 66 | #[serde(flatten)] 67 | pub meta: QHResponseMeta, 68 | } 69 | 70 | #[allow(dead_code)] 71 | #[derive(Deserialize, Debug, Clone)] 72 | #[serde(rename_all = "camelCase")] 73 | pub struct Cert { 74 | pub name: String, 75 | pub certificate_id: String, 76 | pub serial_number: String, 77 | pub status: String, 78 | status_code: Integer, 79 | pub expiration_date: Date, 80 | certificate_platform: Option, 81 | pub cert_type: Option, 82 | pub cert_content: Data, 83 | pub machine_id: Option, 84 | pub machine_name: Option, 85 | } 86 | 87 | #[allow(dead_code)] 88 | #[derive(Deserialize, Debug)] 89 | #[serde(rename_all = "camelCase")] 90 | pub struct Csr { 91 | cert_request_id: String, 92 | name: String, 93 | status_code: Integer, 94 | status_string: String, 95 | csr_platform: String, 96 | date_requested_string: String, 97 | date_requested: Date, 98 | date_created: Date, 99 | owner_type: String, 100 | owner_name: String, 101 | owner_id: String, 102 | pub certificate_id: String, 103 | certificate_status_code: Integer, 104 | cert_request_status_code: Integer, 105 | certificate_type_display_id: String, 106 | pub serial_num: String, 107 | serial_num_decimal: String, 108 | type_string: String, 109 | pub certificate_type: Option, 110 | pub machine_id: Option, 111 | pub machine_name: Option, 112 | } 113 | 114 | #[allow(dead_code)] 115 | #[derive(Deserialize, Debug, Clone)] 116 | #[serde(rename_all = "camelCase")] 117 | pub struct CertType { 118 | certificate_type_display_id: String, 119 | pub name: String, 120 | platform: String, 121 | permission_type: String, 122 | distribution_method: String, 123 | owner_type: String, 124 | days_overlap: Integer, 125 | max_active: Integer, 126 | } 127 | -------------------------------------------------------------------------------- /crates/types/src/options.rs: -------------------------------------------------------------------------------- 1 | /// Settings for the signer process. 2 | #[derive(Clone, Debug)] 3 | pub struct SignerOptions { 4 | /// Custom app name override. 5 | pub custom_name: Option, 6 | /// Custom bundle identifier override. 7 | pub custom_identifier: Option, 8 | /// Custom version override. 9 | pub custom_version: Option, 10 | /// Feature support options. 11 | pub features: SignerFeatures, 12 | /// Embedding options. 13 | pub embedding: SignerEmbedding, 14 | /// Mode. 15 | pub mode: SignerMode, 16 | /// Installation mode. 17 | pub install_mode: SignerInstallMode, 18 | /// Tweaks to apply before signing. 19 | pub tweaks: Option>, 20 | /// App type. 21 | pub app: SignerApp, 22 | } 23 | 24 | impl Default for SignerOptions { 25 | fn default() -> Self { 26 | SignerOptions { 27 | custom_name: None, 28 | custom_identifier: None, 29 | custom_version: None, 30 | features: SignerFeatures::default(), 31 | embedding: SignerEmbedding::default(), 32 | mode: SignerMode::default(), 33 | install_mode: SignerInstallMode::default(), 34 | tweaks: None, 35 | app: SignerApp::Default, 36 | } 37 | } 38 | } 39 | 40 | impl SignerOptions { 41 | pub fn new_for_app(app: SignerApp) -> Self { 42 | let mut settings = Self { 43 | app, 44 | ..Self::default() 45 | }; 46 | 47 | match app { 48 | SignerApp::LiveContainer | 49 | SignerApp::LiveContainerAndSideStore => { 50 | settings.embedding.single_profile = true; 51 | } 52 | _ => {} 53 | } 54 | 55 | settings 56 | } 57 | } 58 | 59 | #[derive(Clone, Debug, Default)] 60 | pub struct SignerFeatures { 61 | pub support_minimum_os_version: bool, 62 | pub support_file_sharing: bool, 63 | pub support_ipad_fullscreen: bool, 64 | pub support_game_mode: bool, 65 | pub support_pro_motion: bool, 66 | pub support_liquid_glass: bool, 67 | pub remove_url_schemes: bool, 68 | } 69 | 70 | /// Embedding options. 71 | #[derive(Clone, Debug, Default)] 72 | pub struct SignerEmbedding { 73 | pub single_profile: bool, 74 | } 75 | 76 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 77 | pub enum SignerInstallMode { 78 | Install, 79 | InstallMac, 80 | Export, 81 | } 82 | 83 | impl Default for SignerInstallMode { 84 | fn default() -> Self { 85 | SignerInstallMode::Export 86 | } 87 | } 88 | 89 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 90 | pub enum SignerMode { 91 | Pem, 92 | Adhoc, 93 | None, 94 | } 95 | 96 | impl Default for SignerMode { 97 | fn default() -> Self { 98 | SignerMode::Adhoc 99 | } 100 | } 101 | 102 | /// Supported app types. 103 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 104 | pub enum SignerApp { 105 | Default, 106 | Antrag, 107 | Feather, 108 | Protokolle, 109 | AltStore, 110 | SideStore, 111 | LiveContainer, 112 | LiveContainerAndSideStore, 113 | StikDebug, 114 | SparseBox 115 | } 116 | 117 | impl SignerApp { 118 | pub fn from_bundle_identifier(identifier: Option>) -> Self { 119 | match identifier.as_ref().map(|s| s.as_ref()) { 120 | Some("com.kdt.livecontainer") => SignerApp::LiveContainer, 121 | Some("thewonderofyou.syslog") => SignerApp::Protokolle, 122 | Some("thewonderofyou.antrag2") => SignerApp::Antrag, 123 | Some("thewonderofyou.Feather") => SignerApp::Feather, 124 | Some("com.SideStore.SideStore") => SignerApp::SideStore, 125 | Some("com.rileytestut.AltStore") => SignerApp::AltStore, 126 | Some("com.stik.js") => SignerApp::StikDebug, 127 | Some("com.kdt.SparseBox") => SignerApp::SparseBox, 128 | _ => SignerApp::Default, 129 | } 130 | } 131 | 132 | pub fn supports_pairing_file(&self) -> bool { 133 | !matches!(self, SignerApp::Default | SignerApp::LiveContainer | SignerApp::AltStore) 134 | } 135 | 136 | pub fn pairing_file_path(&self) -> Option<&'static str> { 137 | use SignerApp::*; 138 | match self { 139 | Antrag | Feather | Protokolle | StikDebug | SparseBox => Some("/Documents/pairingFile.plist"), 140 | SideStore => Some("/Documents/ALTPairingFile.mobiledevicepairing"), 141 | LiveContainerAndSideStore => Some("/Documents/SideStore/Documents/ALTPairingFile.mobiledevicepairing"), 142 | _ => None, 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /apps/plumesign/src/commands/device.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::{Ok, Result, Error}; 4 | use clap::Args; 5 | use dialoguer::Select; 6 | use idevice::{IdeviceService, installation_proxy::InstallationProxyClient, usbmuxd::{UsbmuxdAddr, UsbmuxdConnection}}; 7 | use plume_utils::{Device, get_device_for_id}; 8 | 9 | #[derive(Debug, Args)] 10 | #[command(arg_required_else_help = true)] 11 | pub struct DeviceArgs { 12 | /// Device UDID to target (optional, will prompt if not provided) 13 | #[arg(short = 'u', long = "udid", value_name = "UDID", conflicts_with = "mac")] 14 | pub udid: Option, 15 | /// Install app at specified path to device (.ipa, .app) 16 | #[arg(short = 'i', long = "install", value_name = "PATH")] 17 | pub install: Option, 18 | /// Install pairing record from specified path to device 19 | #[arg(short = 'p', long = "pairing", value_name = "MAC", conflicts_with = "mac", requires = "pairing_path")] 20 | pub pairing: bool, 21 | /// Path to pairing record to install (i.e. /Documents/pairingFile.plist) 22 | #[arg(long = "pairing-path", value_name = "PATH", requires = "pairing")] 23 | pub pairing_path: Option, 24 | /// App identifier for the app to use for pairing record installation (optional, will prompt if not provided) 25 | #[arg(long = "pairing-app-identifier", value_name = "IDENTIFIER")] 26 | pub pairing_app_identifier: Option, 27 | /// Install to connected Mac (arm64 only) 28 | #[cfg(all(target_os = "macos", target_arch = "aarch64"))] 29 | #[arg(short = 'm', long = "mac", value_name = "MAC", conflicts_with = "udid")] 30 | pub mac: bool, 31 | } 32 | 33 | pub async fn execute(args: DeviceArgs) -> Result<()> { 34 | let device = { 35 | #[cfg(all(target_os = "macos", target_arch = "aarch64"))] 36 | { 37 | if args.mac { 38 | Device { 39 | name: "My Mac".to_string(), 40 | udid: String::new(), 41 | device_id: 0, 42 | usbmuxd_device: None, 43 | } 44 | } else { 45 | select_device(args.udid).await? 46 | } 47 | } 48 | #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] 49 | { 50 | select_device(args.udid).await? 51 | } 52 | }; 53 | 54 | if let Some(app_path) = args.install { 55 | #[cfg(all(target_os = "macos", target_arch = "aarch64"))] 56 | if args.mac { 57 | log::info!("Installing app at {:?} to connected Mac", app_path); 58 | device.install_app_mac(&app_path).await?; 59 | return Ok(()) 60 | } 61 | 62 | log::info!("Installing app at {:?} to device {}", app_path, device.name); 63 | device.install_app(&app_path, |progress| async move { 64 | log::info!("{}", progress); 65 | }).await?; 66 | } 67 | 68 | if args.pairing { 69 | if let Some(pairing_path) = args.pairing_path { 70 | log::info!("Installing pairing record from {:?} to device {}", pairing_path, device.name); 71 | let app_identifier = if let Some(identifier) = args.pairing_app_identifier { 72 | identifier 73 | } else { 74 | apps(&device).await? 75 | }; 76 | device.install_pairing_record(&app_identifier, pairing_path.to_str().unwrap()).await?; 77 | } 78 | } 79 | 80 | Ok(()) 81 | } 82 | 83 | pub async fn select_device(device_udid: Option) -> Result { 84 | if let Some(udid) = device_udid { 85 | return Ok(get_device_for_id(&udid).await?); 86 | } 87 | 88 | let mut muxer = UsbmuxdConnection::default().await?; 89 | let devices = muxer.get_devices().await?; 90 | 91 | if devices.is_empty() { 92 | return Err(anyhow::anyhow!("No devices connected. Please connect a device or specify a UDID with --device-udid")); 93 | } 94 | 95 | let device_futures: Vec<_> = devices.into_iter() 96 | .map(|d| Device::new(d)) 97 | .collect(); 98 | 99 | let devices = futures::future::join_all(device_futures).await; 100 | 101 | let device_names: Vec = devices.iter() 102 | .map(|d| d.to_string()) 103 | .collect(); 104 | 105 | let selection = Select::new() 106 | .with_prompt("Select a device to register and install to") 107 | .items(&device_names) 108 | .default(0) 109 | .interact()?; 110 | 111 | Ok(devices[selection].clone()) 112 | } 113 | 114 | async fn apps(device: &Device) -> Result { 115 | const INSTALLATION_LABEL: &str = "App Installation"; 116 | let p = device.usbmuxd_device.clone().unwrap().to_provider( 117 | UsbmuxdAddr::from_env_var().unwrap_or_default(), 118 | INSTALLATION_LABEL, 119 | ); 120 | 121 | let mut lpc = InstallationProxyClient::connect(&p).await 122 | .map_err(|e| anyhow::anyhow!("Failed to create installation proxy client: {}", e))?; 123 | 124 | let ia = lpc.get_apps(Some("User"), None).await 125 | .map_err(|e| anyhow::anyhow!("Failed to get installed apps: {}", e))?; 126 | 127 | let app_names: Vec = ia.keys().cloned().collect(); 128 | 129 | let selection = Select::new() 130 | .items(&app_names) 131 | .default(0) 132 | .with_prompt("Select an installed app") 133 | .interact()?; 134 | 135 | Ok(app_names[selection].clone()) 136 | } 137 | -------------------------------------------------------------------------------- /crates/core/src/auth/account/token.rs: -------------------------------------------------------------------------------- 1 | use botan::Cipher; 2 | use hmac::{Hmac, Mac}; 3 | use reqwest::header::{HeaderMap, HeaderValue}; 4 | 5 | use crate::Error; 6 | use sha2::Sha256; 7 | 8 | use crate::auth::{Account, AppToken, AuthTokenRequest, AuthTokenRequestBody, GSA_ENDPOINT, RequestHeader}; 9 | use crate::auth::account::{check_error, parse_response}; 10 | 11 | 12 | impl Account { 13 | pub async fn get_app_token(&self, app_name: &str) -> Result { 14 | let spd = self.spd.as_ref().unwrap(); 15 | let dsid = spd.get("adsid").unwrap().as_string().unwrap(); 16 | let auth_token = spd.get("GsIdmsToken").unwrap().as_string().unwrap(); 17 | 18 | let valid_anisette = self.get_anisette().await; 19 | 20 | let sk = spd.get("sk").unwrap().as_data().unwrap(); 21 | let c = spd.get("c").unwrap().as_data().unwrap(); 22 | 23 | let checksum = Self::create_checksum(&sk.to_vec(), dsid, app_name); 24 | 25 | let mut gsa_headers = HeaderMap::new(); 26 | gsa_headers.insert( 27 | "Content-Type", 28 | HeaderValue::from_str("text/x-xml-plist").unwrap(), 29 | ); 30 | gsa_headers.insert("Accept", HeaderValue::from_str("*/*").unwrap()); 31 | gsa_headers.insert( 32 | "User-Agent", 33 | HeaderValue::from_str("akd/1.0 CFNetwork/978.0.7 Darwin/18.7.0").unwrap(), 34 | ); 35 | gsa_headers.insert( 36 | "X-MMe-Client-Info", 37 | HeaderValue::from_str(&valid_anisette.get_header("x-mme-client-info")?).unwrap(), 38 | ); 39 | 40 | let header = RequestHeader { 41 | version: "1.0.1".to_string(), 42 | }; 43 | let body = AuthTokenRequestBody { 44 | cpd: valid_anisette.to_plist(true, false, false), 45 | app: vec![app_name.to_string()], 46 | c: plist::Value::Data(c.to_vec()), 47 | operation: "apptokens".to_owned(), 48 | t: auth_token.to_string(), 49 | u: dsid.to_string(), 50 | checksum: plist::Value::Data(checksum), 51 | }; 52 | 53 | let packet = AuthTokenRequest { 54 | header: header.clone(), 55 | request: body, 56 | }; 57 | 58 | let mut buffer = Vec::new(); 59 | plist::to_writer_xml(&mut buffer, &packet)?; 60 | let buffer = String::from_utf8(buffer).unwrap(); 61 | 62 | let res = self 63 | .client 64 | .post(GSA_ENDPOINT) 65 | .headers(gsa_headers.clone()) 66 | .body(buffer) 67 | .send() 68 | .await; 69 | let res = parse_response(res).await?; 70 | let err_check = check_error(&res); 71 | if err_check.is_err() { 72 | return Err(err_check.err().unwrap()); 73 | } 74 | 75 | let encrypted_token = res 76 | .get("et") 77 | .ok_or(Error::Parse)? 78 | .as_data() 79 | .ok_or(Error::Parse)?; 80 | 81 | if encrypted_token.len() < 3 + 16 + 16 { 82 | return Err(Error::Parse); 83 | } 84 | let header = &encrypted_token[0..3]; 85 | if header != b"XYZ" { 86 | return Err(Error::AuthSrpWithMessage( 87 | 0, 88 | "Encrypted token is in an unknown format.".to_string(), 89 | )); 90 | } 91 | let iv = &encrypted_token[3..19]; 92 | let ciphertext_and_tag = &encrypted_token[19..]; 93 | 94 | if sk.len() != 32 { 95 | return Err(Error::Parse); 96 | } 97 | if iv.len() != 16 { 98 | return Err(Error::Parse); 99 | } 100 | // TODO: fucking botan 101 | let mut cipher = Cipher::new("AES-256/GCM", botan::CipherDirection::Decrypt) 102 | .map_err(|_| Error::Parse)?; 103 | cipher.set_key(sk).map_err(|_| Error::Parse)?; 104 | cipher 105 | .set_associated_data(header) 106 | .map_err(|_| Error::Parse)?; 107 | cipher.start(iv).map_err(|_| Error::Parse)?; 108 | 109 | let mut buf = ciphertext_and_tag.to_vec(); 110 | buf = cipher.finish(&mut buf).map_err(|_| { 111 | Error::AuthSrpWithMessage( 112 | 0, 113 | "Failed to decrypt app token (Botan AES-256/GCM).".to_string(), 114 | ) 115 | })?; 116 | 117 | let decrypted_token: plist::Dictionary = 118 | plist::from_bytes(&buf).map_err(|_| Error::Parse)?; 119 | 120 | let t_val = decrypted_token.get("t").ok_or(Error::Parse)?; 121 | let app_tokens = t_val.as_dictionary().ok_or(Error::Parse)?; 122 | let app_token_dict = app_tokens.get(app_name).ok_or(Error::Parse)?; 123 | let app_token = app_token_dict.as_dictionary().ok_or(Error::Parse)?; 124 | let token = app_token 125 | .get("token") 126 | .and_then(|v| v.as_string()) 127 | .ok_or(Error::Parse)?; 128 | 129 | Ok(AppToken { 130 | app_tokens: app_tokens.clone(), 131 | auth_token: token.to_string(), 132 | app: app_name.to_string(), 133 | }) 134 | } 135 | 136 | fn create_checksum(session_key: &Vec, dsid: &str, app_name: &str) -> Vec { 137 | Hmac::::new_from_slice(&session_key) 138 | .unwrap() 139 | .chain_update("apptokens".as_bytes()) 140 | .chain_update(dsid.as_bytes()) 141 | .chain_update(app_name.as_bytes()) 142 | .finalize() 143 | .into_bytes() 144 | .to_vec() 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /crates/core/src/auth/account/two_factor_auth.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use base64::{Engine, engine::general_purpose}; 4 | use crate::Error; 5 | use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; 6 | 7 | use crate::auth::{Account, AuthenticationExtras, LoginState, PhoneNumber, VerifyBody, VerifyCode}; 8 | 9 | impl Account { 10 | pub async fn send_2fa_to_devices(&self) -> Result { 11 | let headers = self.build_2fa_headers(false).await; 12 | 13 | let res = self 14 | .client 15 | .get("https://gsa.apple.com/auth/verify/trusteddevice") 16 | .headers(headers) 17 | .send() 18 | .await?; 19 | 20 | let status_code = res.status(); 21 | 22 | if !status_code.is_success() { 23 | return Err(Error::AuthSrpWithMessage(status_code.as_u16() as i64, "Failed to send 2FA to devices".to_string())); 24 | } 25 | 26 | return Ok(LoginState::Needs2FAVerification); 27 | } 28 | 29 | pub async fn send_sms_2fa_to_devices(&self, phone_id: u32) -> Result { 30 | let headers = self.build_2fa_headers(true).await; 31 | 32 | let body = VerifyBody { 33 | phone_number: PhoneNumber { id: phone_id }, 34 | mode: "sms".to_string(), 35 | security_code: None, 36 | }; 37 | 38 | let res = self 39 | .client 40 | .put("https://gsa.apple.com/auth/verify/phone") 41 | .headers(headers) 42 | .json(&body) 43 | .send() 44 | .await?; 45 | 46 | let status_code = res.status(); 47 | 48 | if !status_code.is_success() { 49 | return Err(Error::AuthSrpWithMessage(status_code.as_u16() as i64, "Failed to send SMS 2FA to devices".to_string())); 50 | } 51 | 52 | return Ok(LoginState::NeedsSMS2FAVerification(body)); 53 | } 54 | 55 | pub async fn get_auth_extras(&self) -> Result { 56 | let headers = self.build_2fa_headers(true); 57 | 58 | let req = self 59 | .client 60 | .get("https://gsa.apple.com/auth") 61 | .headers(headers.await) 62 | .header("Accept", "application/json") 63 | .send() 64 | .await?; 65 | let status = req.status().as_u16(); 66 | let mut new_state = req.json::().await?; 67 | if status == 201 { 68 | new_state.new_state = Some(LoginState::NeedsSMS2FAVerification(VerifyBody { 69 | phone_number: PhoneNumber { 70 | id: new_state.trusted_phone_numbers.first().unwrap().id, 71 | }, 72 | mode: "sms".to_string(), 73 | security_code: None, 74 | })); 75 | } 76 | 77 | Ok(new_state) 78 | } 79 | 80 | pub async fn verify_2fa(&self, code: String) -> Result { 81 | log::debug!("Verifying SMS 2FA with code: {}", code); 82 | 83 | let headers = self.build_2fa_headers(false); 84 | let res = self 85 | .client 86 | .get("https://gsa.apple.com/grandslam/GsService2/validate") 87 | .headers(headers.await) 88 | .header( 89 | HeaderName::from_str("security-code").unwrap(), 90 | HeaderValue::from_str(&code).unwrap(), 91 | ) 92 | .send() 93 | .await?; 94 | 95 | let res: plist::Dictionary = plist::from_bytes(res.text().await?.as_bytes())?; 96 | 97 | super::check_error(&res)?; 98 | 99 | Ok(LoginState::NeedsLogin) 100 | } 101 | 102 | pub async fn verify_sms_2fa( 103 | &self, 104 | code: String, 105 | mut body: VerifyBody, 106 | ) -> Result { 107 | log::debug!("Verifying SMS 2FA with code: {}", code); 108 | 109 | let headers = self.build_2fa_headers(true).await; 110 | body.security_code = Some(VerifyCode { code }); 111 | let res = self 112 | .client 113 | .post("https://gsa.apple.com/auth/verify/phone/securitycode") 114 | .headers(headers) 115 | .json(&body) 116 | .send() 117 | .await?; 118 | 119 | let status_code = res.status(); 120 | 121 | // TODO: 423 http code may occur, in this case we to ask for sending 122 | // last code sent (unlikely it would even work), or try again later 123 | if !status_code.is_success() { 124 | return Err(Error::Bad2faCode); 125 | } 126 | 127 | Ok(LoginState::NeedsLogin) 128 | } 129 | 130 | async fn build_2fa_headers(&self, sms: bool) -> HeaderMap { 131 | let spd = self.spd.as_ref().unwrap(); 132 | let dsid = spd.get("adsid").unwrap().as_string().unwrap(); 133 | let token = spd.get("GsIdmsToken").unwrap().as_string().unwrap(); 134 | 135 | let identity_token = general_purpose::STANDARD.encode(format!("{}:{}", dsid, token)); 136 | 137 | let mut headers = HeaderMap::new(); 138 | let valid_anisette = self.get_anisette().await; 139 | for (k, v) in valid_anisette.generate_headers(false, true, true) { 140 | headers.insert( 141 | HeaderName::from_bytes(k.as_bytes()).unwrap(), 142 | HeaderValue::from_str(&v).unwrap(), 143 | ); 144 | } 145 | if !sms { 146 | headers.insert("Content-Type", HeaderValue::from_static("text/x-xml-plist")); 147 | headers.insert("Accept", HeaderValue::from_static("text/x-xml-plist")); 148 | } else { 149 | headers.insert("Content-Type", HeaderValue::from_static("application/json")); 150 | headers.insert("Accept", HeaderValue::from_static("application/json")); 151 | } 152 | headers.insert("User-Agent", HeaderValue::from_static("Xcode")); 153 | headers.insert("Accept-Language", HeaderValue::from_static("en-us")); 154 | headers.append("X-Apple-Identity-Token", HeaderValue::from_str(&identity_token).unwrap()); 155 | 156 | if let Ok(locale) = valid_anisette.get_header("x-apple-locale") { 157 | headers.insert("Loc", HeaderValue::from_str(&locale).unwrap()); 158 | } 159 | 160 | headers 161 | } 162 | } 163 | 164 | -------------------------------------------------------------------------------- /crates/core/src/store/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::collections::HashMap; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::{Error, auth::Account, developer::DeveloperSession}; 7 | 8 | #[derive(Debug, Serialize, Deserialize, Default)] 9 | pub struct AccountStore { 10 | selected_account: Option, 11 | accounts: HashMap, 12 | path: Option, 13 | } 14 | 15 | #[derive(Debug, Serialize, Deserialize, Clone)] 16 | pub struct GsaAccount { 17 | email: String, 18 | first_name: String, 19 | adsid: String, 20 | xcode_gs_token: String, 21 | status: AccountStatus, 22 | } 23 | 24 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] 25 | pub enum AccountStatus { 26 | Valid, 27 | NeedsReauth, 28 | } 29 | 30 | impl GsaAccount { 31 | pub fn new( 32 | email: String, 33 | first_name: String, 34 | adsid: String, 35 | xcode_gs_token: String, 36 | status: AccountStatus, 37 | ) -> Self { 38 | GsaAccount { 39 | email, 40 | first_name, 41 | adsid, 42 | xcode_gs_token, 43 | status, 44 | } 45 | } 46 | pub fn email(&self) -> &String { 47 | &self.email 48 | } 49 | pub fn first_name(&self) -> &String { 50 | &self.first_name 51 | } 52 | pub fn adsid(&self) -> &String { 53 | &self.adsid 54 | } 55 | pub fn xcode_gs_token(&self) -> &String { 56 | &self.xcode_gs_token 57 | } 58 | pub fn status(&self) -> &AccountStatus { 59 | &self.status 60 | } 61 | } 62 | 63 | impl AccountStore { 64 | pub async fn load(path: &Option) -> Result { 65 | if let Some(path) = path { 66 | let mut settings = if !path.exists() { 67 | Self::default() 68 | } else { 69 | let contents = tokio::fs::read_to_string(path).await?; 70 | serde_json::from_str(&contents)? 71 | }; 72 | settings.path = Some(path.clone()); 73 | Ok(settings) 74 | } else { 75 | Ok(Self::default()) 76 | } 77 | } 78 | 79 | pub async fn save(&self) -> Result<(), Error> { 80 | if let Some(path) = &self.path { 81 | if let Some(parent) = path.parent() { 82 | tokio::fs::create_dir_all(parent).await?; 83 | } 84 | 85 | tokio::fs::write( 86 | path, 87 | serde_json::to_string_pretty(self)? 88 | ).await?; 89 | } 90 | Ok(()) 91 | } 92 | 93 | pub fn save_sync(&self) -> Result<(), Error> { 94 | if let Some(path) = &self.path { 95 | if let Some(parent) = path.parent() { 96 | std::fs::create_dir_all(parent)?; 97 | } 98 | 99 | std::fs::write( 100 | path, 101 | serde_json::to_string_pretty(self)? 102 | )?; 103 | } 104 | Ok(()) 105 | } 106 | 107 | pub fn accounts(&self) -> &HashMap { 108 | &self.accounts 109 | } 110 | 111 | pub fn get_account(&self, email: &str) -> Option<&GsaAccount> { 112 | self.accounts.get(email) 113 | } 114 | 115 | pub async fn accounts_add(&mut self, account: GsaAccount) -> Result<(), Error> { 116 | let email = account.email.clone(); 117 | self.accounts.insert(email.clone(), account); 118 | self.selected_account = Some(email); 119 | self.save().await 120 | } 121 | 122 | pub fn accounts_add_sync(&mut self, account: GsaAccount) -> Result<(), Error> { 123 | let email = account.email.clone(); 124 | self.accounts.insert(email.clone(), account); 125 | self.selected_account = Some(email); 126 | self.save_sync() 127 | } 128 | 129 | pub async fn accounts_remove(&mut self, email: &str) -> Result<(), Error> { 130 | self.accounts.remove(email); 131 | if self.selected_account.as_ref() == Some(&email.to_string()) { 132 | self.selected_account = None; 133 | } 134 | self.save().await 135 | } 136 | 137 | pub fn accounts_remove_sync(&mut self, email: &str) -> Result<(), Error> { 138 | self.accounts.remove(email); 139 | if self.selected_account.as_ref() == Some(&email.to_string()) { 140 | self.selected_account = None; 141 | } 142 | self.save_sync() 143 | } 144 | 145 | pub async fn account_select(&mut self, email: &str) -> Result<(), Error> { 146 | if self.accounts.contains_key(email) { 147 | self.selected_account = Some(email.to_string()); 148 | self.save().await 149 | } else { 150 | Err(Error::Parse) // we need better errors 151 | } 152 | } 153 | 154 | pub fn account_select_sync(&mut self, email: &str) -> Result<(), Error> { 155 | if self.accounts.contains_key(email) { 156 | self.selected_account = Some(email.to_string()); 157 | self.save_sync() 158 | } else { 159 | Err(Error::Parse) // we need better errors 160 | } 161 | } 162 | 163 | pub fn selected_account(&self) -> Option<&GsaAccount> { 164 | if let Some(email) = &self.selected_account { 165 | self.accounts.get(email) 166 | } else { 167 | None 168 | } 169 | } 170 | 171 | pub async fn accounts_add_from_session(&mut self, email: String, account: Account) -> Result<(), Error> { 172 | let first_name = account.get_name().0; 173 | let s = DeveloperSession::using_account(account).await?; 174 | s.qh_list_teams().await?; 175 | let adsid = s.adsid().clone(); 176 | let xcode_gs_token = s.xcode_gs_token().clone(); 177 | 178 | let account = GsaAccount { 179 | email, 180 | first_name, 181 | adsid, 182 | xcode_gs_token, 183 | status: AccountStatus::Valid, 184 | }; 185 | 186 | self.accounts_add(account).await?; 187 | 188 | Ok(()) 189 | } 190 | } 191 | 192 | pub async fn account_from_session(email: String, account: Account) -> Result { 193 | let first_name = account.get_name().0; 194 | let s = DeveloperSession::using_account(account).await?; 195 | s.qh_list_teams().await?; 196 | let adsid = s.adsid().clone(); 197 | let xcode_gs_token = s.xcode_gs_token().clone(); 198 | 199 | Ok(GsaAccount::new( 200 | email, 201 | first_name, 202 | adsid, 203 | xcode_gs_token, 204 | AccountStatus::Valid, 205 | )) 206 | } 207 | -------------------------------------------------------------------------------- /crates/types/src/package.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | fs, 4 | io::Read 5 | }; 6 | use std::path::PathBuf; 7 | use plist::Dictionary; 8 | use uuid::Uuid; 9 | use zip::ZipArchive; 10 | use super::{Bundle, PlistInfoTrait}; 11 | use crate::{Error, SignerApp, SignerOptions}; 12 | use zip::write::FileOptions; 13 | 14 | #[derive(Debug, Clone)] 15 | pub struct Package { 16 | package_file: PathBuf, 17 | stage_dir: PathBuf, 18 | stage_payload_dir: PathBuf, 19 | info_plist_dictionary: Dictionary, 20 | archive_entries: Vec, 21 | } 22 | 23 | impl Package { 24 | pub fn new(package_file: PathBuf) -> Result { 25 | let stage_dir = env::temp_dir().join(format!("plume_stage_{:08}", Uuid::new_v4().to_string().to_uppercase())); 26 | let out_package_file = stage_dir.join("stage.ipa"); 27 | 28 | fs::create_dir_all(&stage_dir).ok(); 29 | fs::copy(&package_file, &out_package_file)?; 30 | 31 | let file = fs::File::open(&out_package_file)?; 32 | let mut archive = ZipArchive::new(file)?; 33 | let archive_entries = (0..archive.len()) 34 | .filter_map(|i| archive.by_index(i).ok().map(|f| f.name().to_string())) 35 | .collect::>(); 36 | 37 | let info_plist_dictionary = Self::get_info_plist_from_archive(&out_package_file, &archive_entries)?; 38 | 39 | Ok(Self { 40 | package_file: out_package_file, 41 | stage_dir: stage_dir.clone(), 42 | stage_payload_dir: stage_dir.join("Payload"), 43 | info_plist_dictionary, 44 | archive_entries, 45 | }) 46 | } 47 | 48 | pub fn package_file(&self) -> &PathBuf { 49 | &self.package_file 50 | } 51 | 52 | fn get_info_plist_from_archive( 53 | archive_path: &PathBuf, 54 | archive_entries: &[String], 55 | ) -> Result { 56 | let file = fs::File::open(archive_path)?; 57 | let mut archive = ZipArchive::new(file)?; 58 | 59 | let info_plist_path = archive_entries 60 | .iter() 61 | .find(|entry| entry.starts_with("Payload/") && entry.ends_with("/Info.plist") && entry.matches('/').count() == 2) 62 | .ok_or(Error::PackageInfoPlistMissing)?; 63 | 64 | let mut plist_file = archive.by_name(info_plist_path)?; 65 | let mut plist_data = Vec::new(); 66 | plist_file.read_to_end(&mut plist_data)?; 67 | 68 | Ok(plist::from_bytes(&plist_data)?) 69 | } 70 | 71 | pub fn get_package_bundle(&self) -> Result { 72 | let file = fs::File::open(&self.package_file)?; 73 | let mut archive = ZipArchive::new(file)?; 74 | archive.extract(&self.stage_dir)?; 75 | 76 | let app_dir = fs::read_dir(&self.stage_payload_dir)? 77 | .filter_map(Result::ok) 78 | .map(|e| e.path()) 79 | .find(|p| p.is_dir() && p.extension().and_then(|e| e.to_str()) == Some("app")) 80 | .ok_or_else(|| Error::PackageInfoPlistMissing)?; 81 | 82 | Ok(Bundle::new(app_dir)?) 83 | } 84 | 85 | pub fn get_archive_based_on_path(&self, path: PathBuf) -> Result { 86 | if path.is_dir() { 87 | self.clone().archive_package_bundle() 88 | } else { 89 | Ok(self.package_file.clone()) 90 | } 91 | } 92 | 93 | fn archive_package_bundle(self) -> Result { 94 | let zip_file_path = self.stage_dir.join("resigned.ipa"); 95 | let file = fs::File::create(&zip_file_path)?; 96 | let mut zip = zip::ZipWriter::new(file); 97 | let options = FileOptions::default() 98 | .compression_method(zip::CompressionMethod::Deflated); 99 | 100 | let payload_dir = self.stage_payload_dir; 101 | 102 | fn add_dir_to_zip( 103 | zip: &mut zip::ZipWriter, 104 | path: &PathBuf, 105 | prefix: &PathBuf, 106 | options: &FileOptions<'_, zip::write::ExtendedFileOptions>, 107 | ) -> Result<(), Error> { 108 | for entry in fs::read_dir(path)? { 109 | let entry = entry?; 110 | let entry_path = entry.path(); 111 | let name = entry_path.strip_prefix(prefix) 112 | .map_err(|_| Error::PackageInfoPlistMissing)? 113 | .to_string_lossy() 114 | .to_string(); 115 | 116 | if entry_path.is_file() { 117 | zip.start_file(&name, options.clone())?; 118 | let mut f = fs::File::open(&entry_path)?; 119 | std::io::copy(&mut f, zip)?; 120 | } else if entry_path.is_dir() { 121 | zip.add_directory(&name, options.clone())?; 122 | add_dir_to_zip(zip, &entry_path, prefix, options)?; 123 | } 124 | } 125 | Ok(()) 126 | } 127 | 128 | add_dir_to_zip(&mut zip, &payload_dir, &self.stage_dir, &options)?; 129 | zip.finish()?; 130 | 131 | Ok(zip_file_path) 132 | } 133 | 134 | pub fn remove_package_stage(self) { 135 | fs::remove_dir_all(&self.stage_dir).ok(); 136 | } 137 | } 138 | 139 | // TODO: make bundle and package share a common trait for plist info access 140 | macro_rules! get_plist_dict_value { 141 | ($self:ident, $key:expr) => {{ 142 | $self.info_plist_dictionary 143 | .get($key) 144 | .and_then(|v| v.as_string()) 145 | .map(|s| s.to_string()) 146 | }}; 147 | } 148 | 149 | impl PlistInfoTrait for Package { 150 | fn get_name(&self) -> Option { 151 | get_plist_dict_value!(self, "CFBundleDisplayName") 152 | .or_else(|| get_plist_dict_value!(self, "CFBundleName")) 153 | .or_else(|| self.get_executable()) 154 | } 155 | 156 | fn get_executable(&self) -> Option { 157 | get_plist_dict_value!(self, "CFBundleExecutable") 158 | } 159 | 160 | fn get_bundle_identifier(&self) -> Option { 161 | get_plist_dict_value!(self, "CFBundleIdentifier") 162 | } 163 | 164 | fn get_version(&self) -> Option { 165 | get_plist_dict_value!(self, "CFBundleShortVersionString") 166 | } 167 | 168 | fn get_build_version(&self) -> Option { 169 | get_plist_dict_value!(self, "CFBundleVersion") 170 | } 171 | } 172 | 173 | impl Package { 174 | pub fn load_into_signer_options<'settings, 'slf: 'settings>( 175 | &'slf self, 176 | settings: &'settings mut SignerOptions, 177 | ) { 178 | let app = if self.archive_entries.iter().any(|entry| entry.contains("SideStoreApp.framework")) { 179 | SignerApp::LiveContainerAndSideStore 180 | } else { 181 | SignerApp::from_bundle_identifier(self.get_bundle_identifier().as_deref()) 182 | }; 183 | 184 | let new_settings = SignerOptions::new_for_app(app); 185 | *settings = new_settings; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /crates/core/src/developer/qh/app_ids.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use plist::{Dictionary, Integer, Value}; 3 | 4 | use crate::Error; 5 | 6 | use crate::developer::strip_invalid_chars; 7 | use crate::developer_endpoint; 8 | use super::{DeveloperSession, QHResponseMeta}; 9 | 10 | impl DeveloperSession { 11 | pub async fn qh_list_app_ids(&self, team_id: &String) -> Result { 12 | let endpoint = developer_endpoint!("/QH65B2/ios/listAppIds.action"); 13 | 14 | let mut body = Dictionary::new(); 15 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 16 | 17 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 18 | let response_data: AppIDsResponse = plist::from_value(&Value::Dictionary(response))?; 19 | 20 | Ok(response_data) 21 | } 22 | 23 | pub async fn qh_add_app_id(&self, team_id: &String, name: &String, identifier: &String) -> Result { 24 | let endpoint = developer_endpoint!("/QH65B2/ios/addAppId.action"); 25 | 26 | let mut body = Dictionary::new(); 27 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 28 | body.insert("name".to_string(), Value::String(strip_invalid_chars(name))); 29 | body.insert("identifier".to_string(), Value::String(identifier.clone())); 30 | 31 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 32 | let response_data: AppIDResponse = plist::from_value(&Value::Dictionary(response))?; 33 | 34 | Ok(response_data) 35 | } 36 | 37 | pub async fn qh_delete_app_id(&self, team_id: &String, app_id_id: &String) -> Result { 38 | let endpoint = developer_endpoint!("/QH65B2/ios/deleteAppId.action"); 39 | 40 | let mut body = Dictionary::new(); 41 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 42 | body.insert("appIdId".to_string(), Value::String(app_id_id.clone())); 43 | 44 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 45 | let response_data: QHResponseMeta = plist::from_value(&Value::Dictionary(response))?; 46 | 47 | Ok(response_data) 48 | } 49 | 50 | pub async fn qh_update_app_id(&self, team_id: &String, app_id_id: &String, features: Dictionary) -> Result { 51 | let endpoint = developer_endpoint!("/QH65B2/ios/updateAppId.action"); 52 | 53 | let mut body = Dictionary::new(); 54 | body.insert("teamId".to_string(), Value::String(team_id.clone())); 55 | body.insert("appIdId".to_string(), Value::String(app_id_id.clone())); 56 | 57 | for (key, value) in features { 58 | body.insert(key, value); 59 | } 60 | 61 | let response = self.qh_send_request(&endpoint, Some(body)).await?; 62 | let response_data: AppIDResponse = plist::from_value(&Value::Dictionary(response))?; 63 | 64 | Ok(response_data) 65 | } 66 | 67 | pub async fn qh_get_app_id(&self, team_id: &String, identifier: &String) -> Result, Error> { 68 | let response_data = self.qh_list_app_ids(team_id).await?; 69 | 70 | let app_id = response_data.app_ids.into_iter() 71 | .find(|app| app.identifier == *identifier); 72 | 73 | Ok(app_id) 74 | } 75 | 76 | pub async fn qh_ensure_app_id(&self, team_id: &String, name: &String, identifier: &String) -> Result { 77 | if let Some(app_id) = self.qh_get_app_id(team_id, identifier).await? { 78 | Ok(app_id) 79 | } else { 80 | let response = self.qh_add_app_id(team_id, name, identifier).await?; 81 | Ok(response.app_id) 82 | } 83 | } 84 | } 85 | 86 | #[allow(dead_code)] 87 | #[derive(Deserialize, Debug)] 88 | #[serde(rename_all = "camelCase")] 89 | pub struct AppIDsResponse { 90 | pub app_ids: Vec, 91 | #[serde(flatten)] 92 | pub meta: QHResponseMeta, 93 | } 94 | 95 | #[allow(dead_code)] 96 | #[derive(Deserialize, Debug)] 97 | #[serde(rename_all = "camelCase")] 98 | pub struct AppIDResponse { 99 | pub app_id: AppID, 100 | #[serde(flatten)] 101 | pub meta: QHResponseMeta, 102 | } 103 | 104 | #[allow(dead_code)] 105 | #[derive(Deserialize, Debug)] 106 | #[serde(rename_all = "camelCase")] 107 | pub struct AppID { 108 | pub app_id_id: String, 109 | name: String, 110 | app_id_platform: String, 111 | prefix: String, 112 | pub identifier: String, 113 | is_wild_card: bool, 114 | is_duplicate: bool, 115 | features: Features, 116 | enabled_features: Option>, 117 | is_dev_push_enabled: bool, 118 | is_prod_push_enabled: bool, 119 | associated_application_groups_count: Option, 120 | associated_cloud_containers_count: Option, 121 | associated_identifiers_count: Option, 122 | } 123 | 124 | #[allow(dead_code)] 125 | #[derive(Deserialize, Debug)] 126 | #[serde(rename_all = "camelCase")] 127 | struct Features { 128 | push: bool, 129 | i_cloud: bool, // com.apple.developer.icloud-container-development-container-identifiers, com.apple.developer.icloud-services, com.apple.developer.icloud-container-environment, com.apple.developer.ubiquity-kvstore-identifier, com.apple.developer.ubiquity-container-identifiers, com.apple.developer.icloud-container-identifiers 130 | in_app_purchase: bool, 131 | game_center: bool, // com.apple.developer.game-center // bro this doesnt turn off if this is on at all 132 | passbook: bool, // com.apple.developer.pass-type-identifiers 133 | // IAD53UNK2F inter-app-audio 134 | // V66P55NK2I com.apple.developer.networking.vpn.api 135 | data_protection: String, // com.apple.developer.default-data-protection // complete, unlessopen, untilfirstauth 136 | // SKC3T5S89Y com.apple.developer.associated-domains 137 | // APG3427HIY com.apple.security.application-groups 138 | // HK421J6T7P com.apple.developer.healthkit, com.apple.developer.healthkit.access, com.apple.developer.healthkit.background-delivery 139 | home_kit: bool, // com.apple.developer.homekit 140 | // WC421J6T7P com.apple.external-accessory.wireless-configuration 141 | // OM633U5T5G com.apple.developer.in-app-payments 142 | cloud_kit_version: Integer, 143 | // SI015DKUHP com.apple.developer.siri 144 | // NWEXT04537 com.apple.developer.networking.networkextension 145 | // HSC639VEI8 com.apple.developer.networking.HotspotConfiguration 146 | // MP49FN762P com.apple.developer.networking.multipath 147 | // NFCTRMAY17 com.apple.developer.nfc.readersession.formats 148 | // PKTJAN2017 com.apple.developer.ClassKit-environment 149 | // CPEQ28MX4E com.apple.developer.authentication-services.autofill-credential-provider 150 | // USER_MANAGEMENT com.apple.developer.user-management 151 | // FONT_INSTALLATION com.apple.developer.user-fonts 152 | // APPLE_ID_AUTH com.apple.developer.applesignin 153 | // NETWORK_CUSTOM_PROTOCOL com.apple.developer.networking.custom-protocol 154 | // SYSTEM_EXTENSION_INSTALL com.apple.developer.system-extension.install 155 | // AWEQ28MY3E com.apple.developer.networking.wifi-info 156 | } 157 | 158 | impl Features { 159 | // pub fn get_feature_for_entitlement(entitlement: &str) -> Option<&'static str> { 160 | 161 | // } 162 | } 163 | -------------------------------------------------------------------------------- /crates/core/src/developer/session.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use omnisette::AnisetteConfiguration; 3 | use reqwest::header::HeaderName; 4 | use tokio::sync::Mutex; 5 | 6 | use plist::{Dictionary, Value}; 7 | use reqwest::Client; 8 | use reqwest::header::HeaderMap; 9 | use reqwest::header::HeaderValue; 10 | use uuid::Uuid; 11 | 12 | use crate::Error; 13 | 14 | use crate::auth::Account; 15 | use crate::auth::anisette_data::AnisetteData; 16 | use crate::developer::qh::QHResponseMeta; 17 | use crate::developer::v1::V1ErrorResponse; 18 | 19 | pub struct DeveloperSession { 20 | anisette: Arc>, 21 | client: Client, 22 | adsid: String, // from grandslam's SPD "adsid" 23 | xcode_gs_token: String, // requested from spd initially // com.apple.gs.xcode.auth 24 | } 25 | 26 | impl DeveloperSession { 27 | pub async fn using_account(account: Account) -> Result { 28 | let adsid = account.spd.as_ref().unwrap().get("adsid").unwrap().as_string().unwrap(); 29 | let xcode_gs_token = account 30 | .get_app_token("com.apple.gs.xcode.auth") 31 | .await? 32 | .auth_token; 33 | 34 | Ok(DeveloperSession { 35 | anisette: account.anisette.clone(), 36 | client: account.client.clone(), 37 | adsid: adsid.into(), 38 | xcode_gs_token, 39 | }) 40 | } 41 | 42 | pub async fn new( 43 | adsid: String, 44 | xcode_gs_token: String, 45 | config: AnisetteConfiguration, 46 | ) -> Result { 47 | let anisette = AnisetteData::new(config).await?; 48 | Self::new_with_anisette( 49 | adsid, 50 | xcode_gs_token, 51 | Arc::new(Mutex::new(anisette)), 52 | ).await 53 | } 54 | 55 | pub async fn new_with_anisette( 56 | adsid: String, 57 | xcode_gs_token: String, 58 | anisette: Arc>, 59 | ) -> Result { 60 | let client = crate::client()?; 61 | 62 | let s = Self { 63 | anisette, 64 | client, 65 | adsid, 66 | xcode_gs_token, 67 | }; 68 | 69 | // we test the session by listing teams 70 | // if this fails, the session is invalid (obviously) 71 | s.qh_list_teams().await?; 72 | 73 | Ok(s) 74 | } 75 | 76 | pub fn adsid(&self) -> &String { 77 | &self.adsid 78 | } 79 | 80 | pub fn xcode_gs_token(&self) -> &String { 81 | &self.xcode_gs_token 82 | } 83 | } 84 | 85 | impl DeveloperSession { 86 | pub async fn qh_send_request( 87 | &self, 88 | url: &str, 89 | body: Option, 90 | ) -> Result { 91 | let mut headers = HeaderMap::new(); 92 | headers.insert("Content-Type", HeaderValue::from_static("text/x-xml-plist")); 93 | headers.insert("Accept", HeaderValue::from_static("text/x-xml-plist")); 94 | self.insert_identity_headers(&mut headers).await; 95 | self.insert_anisette_headers(&mut headers).await; 96 | 97 | let mut body = body.unwrap_or_default(); 98 | body.insert("requestId".into(), Value::String(Uuid::new_v4().to_string().to_uppercase())); 99 | 100 | let request_builder = self.client.post(url).headers(headers); 101 | 102 | let mut buffer = Vec::new(); 103 | plist::to_writer_xml(&mut buffer, &body)?; 104 | 105 | log::debug!("QH Request to {}: {:?}", url, body); 106 | 107 | let response = request_builder.body(buffer).send().await?; 108 | let response_bytes = response.bytes().await?; 109 | let response_dict: Dictionary = plist::from_bytes(&response_bytes)?; 110 | 111 | log::debug!("QH Response from {}: {:?}", url, response_dict); 112 | 113 | let response_meta: QHResponseMeta = plist::from_value(&Value::Dictionary(response_dict.clone()))?; 114 | 115 | if response_meta.result_code.as_signed().unwrap_or(0) != 0 { 116 | return Err(response_meta.to_error(url.to_string())); 117 | } 118 | 119 | Ok(response_dict) 120 | } 121 | 122 | pub async fn v1_send_request( 123 | &self, 124 | url: &str, 125 | body: Option, 126 | request_type: Option, 127 | ) -> Result { 128 | let mut headers = HeaderMap::new(); 129 | headers.insert("Content-Type", HeaderValue::from_static("application/vnd.api+json")); 130 | headers.insert("Accept", HeaderValue::from_static("application/json, text/plain, */*")); 131 | headers.insert("X-Requested-With", HeaderValue::from_static("XMLHttpRequest")); 132 | self.insert_identity_headers(&mut headers).await; 133 | if let Some(RequestType::Get) = request_type { 134 | headers.insert("X-HTTP-Method-Override", HeaderValue::from_static("GET")); 135 | } 136 | self.insert_anisette_headers(&mut headers).await; 137 | 138 | let mut request_builder = match request_type { 139 | Some(RequestType::Patch) => self.client.patch(url).headers(headers.clone()), 140 | Some(RequestType::Post) | _ if body.is_some() => self.client.post(url).headers(headers.clone()), 141 | _ => self.client.get(url).headers(headers.clone()), 142 | }; 143 | 144 | log::debug!("V1 Request to {}: {:?}", url, &body); 145 | 146 | if let Some(body) = body { 147 | request_builder = request_builder.json(&body); 148 | } 149 | 150 | let response = request_builder.send().await?; 151 | let response_text = response.text().await?; 152 | 153 | log::debug!("V1 Response from {}: {}", url, response_text); 154 | 155 | let response_json: serde_json::Value = serde_json::from_str(&response_text)?; 156 | 157 | if let Ok(errors) = serde_json::from_value::(response_json.clone()) { 158 | return Err(errors.errors[0].to_error(url.to_string())); 159 | } 160 | 161 | Ok(response_json) 162 | } 163 | 164 | // TODO: this can be deduplicated as well, for reuse in `fn build_2fa_headers` 165 | async fn insert_identity_headers(&self, headers: &mut HeaderMap) { 166 | headers.insert("Accept-Language", HeaderValue::from_static("en-us")); 167 | headers.insert("User-Agent", HeaderValue::from_static("Xcode")); 168 | headers.insert("X-Apple-I-Identity-Id", HeaderValue::from_str(&self.adsid).unwrap()); 169 | headers.insert("X-Apple-GS-Token", HeaderValue::from_str(&self.xcode_gs_token).unwrap()); 170 | } 171 | 172 | async fn insert_anisette_headers(&self, headers: &mut HeaderMap) { 173 | let valid_anisette = self.get_anisette().await; 174 | for (k, v) in valid_anisette.generate_headers(false, true, true) { 175 | headers.insert( 176 | HeaderName::from_bytes(k.as_bytes()).unwrap(), 177 | HeaderValue::from_str(&v).unwrap(), 178 | ); 179 | } 180 | if let Ok(locale) = valid_anisette.get_header("x-apple-locale") { 181 | headers.insert("X-Apple-Locale", HeaderValue::from_str(&locale).unwrap()); 182 | } 183 | } 184 | 185 | // TODO: deduplicate? 186 | pub async fn get_anisette(&self) -> AnisetteData { 187 | let mut locked = self.anisette.lock().await; 188 | if locked.needs_refresh() { 189 | *locked = locked.refresh().await.unwrap(); 190 | } 191 | locked.clone() 192 | } 193 | } 194 | 195 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 196 | pub enum RequestType { 197 | Get, 198 | Post, 199 | Patch, 200 | } 201 | -------------------------------------------------------------------------------- /apps/plumesign/src/commands/sign.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::Args; 4 | use anyhow::Result; 5 | 6 | use plume_core::{CertificateIdentity, MobileProvision}; 7 | use plume_shared::get_data_path; 8 | use plume_utils::{Bundle, Package, Signer, SignerMode, SignerOptions}; 9 | 10 | use crate::commands::{account::{get_authenticated_account, teams}, device::select_device}; 11 | 12 | #[derive(Debug, Args)] 13 | #[command(arg_required_else_help = true)] 14 | pub struct SignArgs { 15 | /// Path to the app bundle or package to sign (.app or .ipa) 16 | #[arg(long, short, value_name = "PACKAGE")] 17 | pub package: PathBuf, 18 | /// PEM files for certificate and private key 19 | #[arg(long = "pem", value_name = "PEM", num_args = 1..)] 20 | pub pem_files: Option>, 21 | /// Use Apple ID credentials for signing 22 | #[arg(long = "apple-id")] 23 | pub apple_id: bool, 24 | /// Provisioning profile files to embed 25 | #[arg(long = "provision", value_name = "PROVISION")] 26 | pub provisioning_files: Option, 27 | /// Custom bundle identifier to set 28 | #[arg(long = "custom-identifier", value_name = "BUNDLE_ID")] 29 | pub bundle_identifier: Option, 30 | /// Custom bundle name to set 31 | #[arg(long = "custom-name", value_name = "NAME")] 32 | pub name: Option, 33 | /// Custom bundle version to set 34 | #[arg(long = "custom-version", value_name = "VERSION")] 35 | pub version: Option, 36 | /// Perform ad-hoc signing (no certificate required) 37 | #[arg(long, short, num_args = 1..)] 38 | pub tweaks: Option>, 39 | /// Register device and install after signing 40 | #[arg(long)] 41 | pub register_and_install: bool, 42 | /// Device UDID to register and install to (will prompt if not provided) 43 | #[arg(long, value_name = "UDID")] 44 | pub udid: Option, 45 | /// Output path for signed .ipa (only for .ipa input) 46 | #[arg(long, short, value_name = "OUTPUT")] 47 | pub output: Option, 48 | /// Install to connected Mac (arm64 only) 49 | #[cfg(all(target_os = "macos", target_arch = "aarch64"))] 50 | #[arg(short = 'm', long = "mac", value_name = "MAC", conflicts_with = "udid")] 51 | pub mac: bool, 52 | } 53 | 54 | pub async fn execute(args: SignArgs) -> Result<()> { 55 | if !args.package.is_dir() && !args.apple_id && args.output.is_none() { 56 | return Err(anyhow::anyhow!( 57 | "-o/--output is required when signing an .ipa without --apple-id (ad-hoc mode)." 58 | )); 59 | } 60 | 61 | let mut options = SignerOptions { 62 | custom_identifier: args.bundle_identifier, 63 | custom_name: args.name, 64 | custom_version: args.version, 65 | tweaks: args.tweaks, 66 | ..Default::default() 67 | }; 68 | 69 | let (bundle, package) = if args.package.is_dir() { 70 | log::warn!("⚠️ Signing bundle in place: {}", args.package.display()); 71 | if args.output.is_some() { 72 | log::warn!("Note: -o/--output flag is ignored for .app bundles (in-place signing only)"); 73 | } 74 | (Bundle::new(&args.package)?, None) 75 | } else { 76 | let pkg = Package::new(args.package.clone())?; 77 | let bundle = pkg.get_package_bundle()?; 78 | (bundle, Some(pkg)) 79 | }; 80 | 81 | let (mut signer, team_id_opt) = if let Some(ref pem_files) = args.pem_files { 82 | let cert_identity = CertificateIdentity::new_with_paths( 83 | Some(pem_files.clone()) 84 | ).await?; 85 | 86 | options.mode = SignerMode::Pem; 87 | (Signer::new(Some(cert_identity), options), None) 88 | } else if args.apple_id { 89 | let session = get_authenticated_account().await?; 90 | let team_id = teams(&session).await?; 91 | let cert_identity = CertificateIdentity::new_with_session( 92 | &session, 93 | get_data_path(), 94 | None, 95 | &team_id, 96 | ).await?; 97 | 98 | options.mode = SignerMode::Pem; 99 | (Signer::new(Some(cert_identity), options), Some((session, team_id))) 100 | } else { 101 | options.mode = SignerMode::Adhoc; 102 | (Signer::new(None, options), None) 103 | }; 104 | 105 | if let Some(provision_path) = args.provisioning_files { 106 | let prov = MobileProvision::load_with_path(&provision_path)?; 107 | signer.provisioning_files.push(prov.clone()); 108 | } 109 | 110 | let device = if args.register_and_install { 111 | #[cfg(all(target_os = "macos", target_arch = "aarch64"))] 112 | { 113 | if args.mac { 114 | use plume_utils::Device; 115 | 116 | Some(Device { 117 | name: "My Mac".to_string(), 118 | udid: String::new(), 119 | device_id: 0, 120 | usbmuxd_device: None, 121 | }) 122 | } else { 123 | Some(select_device(args.udid).await?) 124 | } 125 | } 126 | #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] 127 | { 128 | Some(select_device(args.udid).await?) 129 | } 130 | } else { 131 | None 132 | }; 133 | 134 | if let Some((session, team_id)) = team_id_opt { 135 | signer.modify_bundle(&bundle, &Some(team_id.clone())).await?; 136 | 137 | if let Some(ref dev) = device { 138 | log::info!("Registering device: {} ({})", dev.name, dev.udid); 139 | session.qh_ensure_device(&team_id, &dev.name, &dev.udid).await?; 140 | } 141 | 142 | signer.register_bundle(&bundle, &session, &team_id).await?; 143 | signer.sign_bundle(&bundle).await?; 144 | 145 | if let Some(dev) = device { 146 | log::info!("Installing to device: {}", dev.name); 147 | #[cfg(all(target_os = "macos", target_arch = "aarch64"))] 148 | if args.mac { 149 | dev.install_app_mac(&bundle.bundle_dir()).await?; 150 | } else { 151 | dev.install_app(bundle.bundle_dir(), |progress| async move { 152 | log::info!("Installation progress: {}%", progress); 153 | }).await?; 154 | } 155 | 156 | #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] 157 | { 158 | dev.install_app(bundle.bundle_dir(), |progress| async move { 159 | log::info!("Installation progress: {}%", progress); 160 | }).await?; 161 | } 162 | 163 | log::info!("Installation complete!"); 164 | } 165 | } else { 166 | signer.modify_bundle(&bundle, &None).await?; 167 | signer.sign_bundle(&bundle).await?; 168 | 169 | if let Some(dev) = device { 170 | log::info!("Installing to device: {}", dev.name); 171 | dev.install_app(bundle.bundle_dir(), |progress| async move { 172 | log::info!("Installation progress: {}%", progress); 173 | }).await?; 174 | 175 | log::info!("Installation complete!"); 176 | } 177 | } 178 | 179 | if let Some(pkg) = package { 180 | if let Some(output_path) = args.output { 181 | let archived_path = pkg.get_archive_based_on_path(args.package.clone())?; 182 | tokio::fs::copy(&archived_path, &output_path).await?; 183 | log::info!("Saved signed package to: {}", output_path.display()); 184 | pkg.remove_package_stage(); 185 | } else { 186 | log::info!("Signed .ipa successfully (not archived, use -o to save)"); 187 | pkg.remove_package_stage(); 188 | } 189 | } 190 | 191 | Ok(()) 192 | } 193 | -------------------------------------------------------------------------------- /crates/types/src/device.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::path::{Component, Path, PathBuf}; 3 | 4 | use idevice::usbmuxd::{Connection, UsbmuxdAddr, UsbmuxdDevice}; 5 | use idevice::lockdown::LockdownClient; 6 | use idevice::IdeviceService; 7 | use idevice::utils::installation; 8 | 9 | use crate::Error; 10 | use idevice::usbmuxd::UsbmuxdConnection; 11 | use idevice::house_arrest::HouseArrestClient; 12 | use idevice::afc::opcode::AfcFopenMode; 13 | 14 | pub const CONNECTION_LABEL: &str = "plume_info"; 15 | pub const INSTALLATION_LABEL: &str = "plume_install"; 16 | pub const HOUSE_ARREST_LABEL: &str = "plume_house_arrest"; 17 | 18 | macro_rules! get_dict_string { 19 | ($dict:expr, $key:expr) => { 20 | $dict 21 | .as_dictionary() 22 | .and_then(|dict| dict.get($key)) 23 | .and_then(|v| v.as_string()) 24 | .map(|s| s.to_string()) 25 | .unwrap_or_else(|| "".to_string()) 26 | }; 27 | } 28 | 29 | #[derive(Debug, Clone)] 30 | pub struct Device { 31 | pub name: String, 32 | pub udid: String, 33 | pub device_id: u32, 34 | pub usbmuxd_device: Option, 35 | } 36 | 37 | impl Device { 38 | pub async fn new(usbmuxd_device: UsbmuxdDevice) -> Self { 39 | let name = Self::get_name_from_usbmuxd_device(&usbmuxd_device) 40 | .await 41 | .unwrap_or_default(); 42 | 43 | Device { 44 | name, 45 | udid: usbmuxd_device.udid.clone(), 46 | device_id: usbmuxd_device.device_id.clone(), 47 | usbmuxd_device: Some(usbmuxd_device), 48 | } 49 | } 50 | 51 | async fn get_name_from_usbmuxd_device( 52 | device: &UsbmuxdDevice, 53 | ) -> Result { 54 | let mut lockdown = LockdownClient::connect(&device.to_provider(UsbmuxdAddr::default(), CONNECTION_LABEL)).await?; 55 | let values = lockdown.get_value(None, None).await?; 56 | Ok(get_dict_string!(values, "DeviceName")) 57 | } 58 | 59 | pub async fn install_pairing_record(&self, identifier: &String, path: &str) -> Result<(), Error> { 60 | if self.usbmuxd_device.is_none() { 61 | return Err(Error::Other("Device is not connected via USB".to_string())); 62 | } 63 | 64 | let mut usbmuxd = UsbmuxdConnection::default().await?; 65 | let provider = self.usbmuxd_device.clone().unwrap().to_provider(UsbmuxdAddr::default(), HOUSE_ARREST_LABEL); 66 | let mut pairing_file = usbmuxd.get_pair_record(&self.udid).await?; 67 | 68 | // saving pairing record requires enabling wifi debugging 69 | // since operations are done over wifi 70 | let mut lc = LockdownClient::connect(&provider).await?; 71 | lc.start_session(&pairing_file).await.ok(); 72 | lc.set_value("EnableWifiDebugging", true.into(), Some("com.apple.mobile.wireless_lockdown")).await.ok(); 73 | 74 | pairing_file.udid = Some(self.udid.clone()); 75 | 76 | let hc = HouseArrestClient::connect(&provider).await?; 77 | let mut ac = hc.vend_documents(identifier.clone()).await?; 78 | if let Some(parent) = Path::new(path).parent() { 79 | let mut current = String::new(); 80 | let has_root = parent.has_root(); 81 | 82 | for component in parent.components() { 83 | if let Component::Normal(dir) = component { 84 | if has_root && current.is_empty() { 85 | current.push('/'); 86 | } else if !current.is_empty() && !current.ends_with('/') { 87 | current.push('/'); 88 | } 89 | 90 | current.push_str(&dir.to_string_lossy()); 91 | let _ = ac.mk_dir(¤t).await; 92 | } 93 | } 94 | } 95 | 96 | let mut f = ac.open(path, AfcFopenMode::Wr).await?; 97 | f.write(&pairing_file.serialize().unwrap()).await?; 98 | 99 | Ok(()) 100 | } 101 | 102 | pub async fn install_app(&self, app_path: &PathBuf, progress_callback: F) -> Result<(), Error> 103 | where 104 | F: FnMut(i32) -> Fut + Send + Clone + 'static, 105 | Fut: std::future::Future + Send, 106 | { 107 | if self.usbmuxd_device.is_none() { 108 | return Err(Error::Other("Device is not connected via USB".to_string())); 109 | } 110 | 111 | let provider = self.usbmuxd_device.clone().unwrap().to_provider( 112 | UsbmuxdAddr::from_env_var().unwrap_or_default(), 113 | INSTALLATION_LABEL, 114 | ); 115 | 116 | let callback = move |(progress, _): (u64, ())| { 117 | let mut cb = progress_callback.clone(); 118 | async move { 119 | cb(progress as i32).await; 120 | } 121 | }; 122 | 123 | let state = (); 124 | 125 | installation::install_package_with_callback( 126 | &provider, 127 | app_path, 128 | None, 129 | callback, 130 | state, 131 | ).await?; 132 | 133 | Ok(()) 134 | } 135 | 136 | #[cfg(all(target_os = "macos", target_arch = "aarch64"))] 137 | pub async fn install_app_mac(&self, app_path: &PathBuf) -> Result<(), Error>{ 138 | use std::env; 139 | use tokio::fs; 140 | use uuid::Uuid; 141 | use crate::copy_dir_recursively; 142 | 143 | let stage_dir = env::temp_dir().join(format!("plume_mac_stage_{}", Uuid::new_v4().to_string().to_uppercase())); 144 | let app_name = app_path.file_name().ok_or(Error::Other("Invalid app path".to_string()))?; 145 | 146 | // iOS Apps on macOS need to be wrapped in a special structure, more specifically 147 | // ``` 148 | // LiveContainer.app 149 | // ├── WrappedBundle -> Wrapper/LiveContainer.app 150 | // └── Wrapper 151 | // └── LiveContainer.app 152 | // ``` 153 | // Then install to /Applications/... 154 | 155 | let outer_app_dir = stage_dir.join(app_name); 156 | let wrapper_dir = outer_app_dir.join("Wrapper"); 157 | 158 | fs::create_dir_all(&wrapper_dir).await?; 159 | 160 | copy_dir_recursively(app_path, &wrapper_dir.join(app_name)).await?; 161 | 162 | let wrapped_bundle_path = outer_app_dir.join("WrappedBundle"); 163 | fs::symlink(PathBuf::from("Wrapper").join(app_name), &wrapped_bundle_path).await?; 164 | 165 | let applications_dir = PathBuf::from("/Applications").join(app_name); 166 | fs::rename(&outer_app_dir, &applications_dir).await 167 | .map_err(|_| Error::BundleFailedToCopy(applications_dir.to_string_lossy().into_owned()))?; 168 | 169 | Ok(()) 170 | } 171 | } 172 | 173 | impl fmt::Display for Device { 174 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 175 | write!( 176 | f, 177 | "[{}] {}", 178 | match &self.usbmuxd_device { 179 | Some(device) => match &device.connection_type { 180 | Connection::Usb => "USB", 181 | Connection::Network(_) => "WiFi", 182 | Connection::Unknown(_) => "Unknown", 183 | }, 184 | None => "LOCAL", 185 | }, 186 | self.name 187 | ) 188 | } 189 | } 190 | 191 | pub async fn get_device_for_id(device_id: &str) -> Result { 192 | let mut usbmuxd = UsbmuxdConnection::default().await?; 193 | let usbmuxd_device = usbmuxd 194 | .get_devices() 195 | .await? 196 | .into_iter() 197 | .find(|d| d.device_id.to_string() == device_id) 198 | .ok_or_else(|| Error::Other(format!("Device ID {device_id} not found")))?; 199 | 200 | Ok(Device::new(usbmuxd_device).await) 201 | } 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PlumeImpactor 2 | 3 | [![GitHub Release](https://img.shields.io/github/v/release/khcrysalis/PlumeImpactor?include_prereleases)](https://github.com/khcrysalis/PlumeImpactor/releases) 4 | [![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/khcrysalis/PlumeImpactor/total)](https://github.com/khcrysalis/PlumeImpactor/releases) 5 | [![GitHub License](https://img.shields.io/github/license/khcrysalis/PlumeImpactor?color=%23C96FAD)](https://github.com/khcrysalis/PlumeImpactor/blob/main/LICENSE) 6 | [![Sponsor Me](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/khcrysalis) 7 | 8 | Open-source, cross-platform, and feature rich iOS sideloading application. Supporting macOS, Linux[^1], and Windows[^2]. 9 | 10 | [^1]: On Linux, usbmuxd must be installed on your system. Don't worry though, it comes with most popular distributions by default already! However, due to some distributions [udev](https://man7.org/linux/man-pages/man7/udev.7.html) rules `usbmuxd` may stop running after no devices are connected causing Impactor to not detect the device after plugging it in. You can mitigate this by plugging your phone first then restarting the app. 11 | 12 | [^2]: On Windows, [iTunes](https://support.apple.com/en-us/106372) must be downloaded so Impactor is able to use the drivers for interacting with Apple devices. 13 | 14 | | ![Demo of app](demo.png) | 15 | | :--------------------------------------------------------------------------------------------------: | 16 | | Screenshot of Impactor after importing [Feather](https://github.com/khcrysalis/Feather). | 17 | 18 | ### Features 19 | 20 | - User friendly and clean UI. 21 | - Supports Linux. 22 | - Sign and sideload applications on iOS 9.0+ & Mac with your Apple ID. 23 | - Installing with AppSync is supported. 24 | - Installing with ipatool gotten ipa's is supported. 25 | - Automatically disables updates from the App Store. 26 | - Simple customization options for the app. 27 | - Tweak support for advanced users, using [ElleKit](https://github.com/tealbathingsuit/ellekit) for injection. 28 | - Supports injecting `.deb` and `.dylib` files. 29 | - Supports adding `.framework`, `.bundle`, and `.appex` directories. 30 | - Generates P12 for SideStore/AltStore to use, similar to how Altserver works. 31 | - Automatically populate pairing files for apps like SideStore, Antrag, and Protokolle. 32 | - Almost *proper* entitlement handling and can register app plugins. 33 | - Able to request entitlements like `increased-memory-limit`, for emulators like MelonX or UTM. 34 | 35 | ## Download 36 | 37 | Visit [releases](https://github.com/khcrysalis/PlumeImpactor/releases) and get the latest version for your computer. 38 | 39 | ## How it works 40 | 41 | How it works is that we try to replicate what [Xcode](https://developer.apple.com/xcode/) would do but in our own application, by using your Apple Account (which serves the purpose of being a "Developer") so we can request certificates, provisioning profiles, and register your device from Apple themselves. 42 | 43 | Apple here is the provider of these and how we'll even be able to get apps on your phone. Unfortunately, without paying for their developer program you are limited to 7-days and a limited amount of apps/components you can register. 44 | 45 | The very first thing we do when trying to sideload an app, is register your idevice to their servers, then try to create a certificate. These last 365 days, we also store the key locally so you would need to copy these keys over to other machines, if you don't, Impactor will try to make a new one. 46 | 47 | After that, we try to register your app that you're trying to sideload, and try to provision it with proper entitlements gathered from the binary. Once we do, we have to download the neccessary files when signing, that being the certificate and provisioning profile that we just created. 48 | 49 | Lastly, we do all of the necessary modifications we need to the app you're trying to sideload, can range between tweaks, name changing, etc. Though most importantly, we need to *sign* the app using [apple-codesign-rs](https://github.com/indygreg/apple-platform-rs) so we can **install it** with [idevice](https://github.com/jkcoxson/idevice)! 50 | 51 | That's the entire gist of how this works! Of course its very short and brief, however feel free to look how it works since its open source :D 52 | 53 | ## Structure 54 | 55 | The project is seperated in multiple modules, all serve single or multiple uses depending on their importance. 56 | 57 | | Module | Description | 58 | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- | 59 | | `apps/plumeimpactor` | GUI interface for the crates shown below, backend using wxWidgets (with a rust ffi wrapper, wxDragon). | 60 | | `apps/plumesign` | Simple CLI interface for signing, using `clap`. | 61 | | `crates/core`. | Handles all api request used for communicating with Apple developer services, along with providing auth for Apple's grandslam | 62 | | `crates/gestalt` | Wrapper for `libMobileGestalt.dylib`, used for obtaining your Mac's UDID for Apple Silicon sideloading. | 63 | | `crates/utils` | Shared code between GUI and CLI, contains signing and modification logic, and helpers. | 64 | | `crates/shared` | Shared code between GUI and CLI, contains keychain functionality and shared datapaths. | 65 | 66 | ## Building 67 | 68 | Building is going to be a bit convoluted for each platform, each having their own unique specifications, but the best reference for building should be looking at how [GitHub actions](./.github/workflows/build.yml) does it. 69 | 70 | 71 | You need: 72 | - [Rust](https://rustup.rs/). 73 | - [CMake](https://cmake.org/download/) (and a c++ compiler). 74 | 75 | ```sh 76 | # Applies our patches in ./patches 77 | cargo install patch-crate 78 | cargo patch-crate --force && cargo fetch --locked 79 | 80 | # Building / testing 81 | cargo run --bin plumeimpactor 82 | ``` 83 | 84 | Extra requirements are shown below for building if you don't have these already, and trust me, it is convoluted. 85 | 86 | #### Linux Requirements 87 | 88 | ```sh 89 | # Ubuntu/Debian 90 | sudo apt-get install libclang-dev pkg-config libgtk-3-dev libpng-dev libjpeg-dev libgl1-mesa-dev libglu1-mesa-dev libxkbcommon-dev libexpat1-dev libtiff-dev 91 | 92 | # Fedora/RHEL 93 | sudo dnf install clang-devel pkg-config gtk3-devel libpng-devel libjpeg-devel mesa-libGL-devel mesa-libGLU-devel libxkbcommon-devel expat-devel libtiff-devel 94 | ``` 95 | 96 | #### macOS Requirements 97 | 98 | - [Xcode](https://developer.apple.com/xcode/) or [Command Line Tools](https://developer.apple.com/download/all/). 99 | 100 | #### Windows Requirements 101 | 102 | - [Visual Studio 2022 Build Tools](https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022). 103 | - Windows 10/11 SDK. 104 | 105 | ## Sponsors 106 | 107 | | Thanks to all my [sponsors](https://github.com/sponsors/khcrysalis)!! | 108 | |:-:| 109 | | | 110 | | _**"samara is cute" - Vendicated**_ | 111 | 112 | ## Acknowledgements 113 | 114 | - [SAMSAM](https://github.com/khcrysalis) – The maker. 115 | - [SideStore](https://github.com/SideStore/apple-private-apis) – Grandslam auth & Omnisette. 116 | - [gms.py](https://gist.github.com/JJTech0130/049716196f5f1751b8944d93e73d3452) – Grandslam auth API references. 117 | - [Sideloader](https://github.com/Dadoum/Sideloader) – Apple Developer API references. 118 | - [PyDunk](https://github.com/nythepegasus/PyDunk) – `v1` Apple Developer API references. 119 | - [idevice](https://github.com/jkcoxson/idevice) – Used for communication with `installd`, specifically for sideloading the apps to your devices. 120 | - [apple-codesign-rs](https://github.com/indygreg/apple-platform-rs) – Codesign alternative, modified and extended upon to work for Impactor. 121 | 122 | ## License 123 | 124 | Project is licensed under the MIT license. You can see the full details of the license [here](https://github.com/khcrysalis/PlumeImpactor/blob/main/LICENSE). Some components may be licensed under different licenses, see their respective directories for details. 125 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "Build & Bundle" 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - 'crates/**' 8 | - 'Cargo.toml' 9 | - 'Cargo.lock' 10 | - 'apps/**' 11 | - 'package/**' 12 | - '.github/workflows/build.yml' 13 | workflow_dispatch: 14 | 15 | env: 16 | BINARY_NAME: plumeimpactor 17 | BINARY_NAME_CLI: plumesign 18 | BUNDLE_NAME: Impactor 19 | 20 | jobs: 21 | build-linux: 22 | runs-on: ${{ matrix.os }} 23 | strategy: 24 | fail-fast: true 25 | matrix: 26 | include: 27 | - os: ubuntu-22.04 28 | arch: x86_64 29 | - os: ubuntu-22.04-arm 30 | arch: aarch64 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Cache Rust dependencies 34 | uses: actions/cache@v4 35 | with: 36 | path: | 37 | ~/.cargo/registry 38 | ~/.cargo/git 39 | target 40 | key: linux-${{ matrix.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }} 41 | restore-keys: linux-${{ matrix.arch }}-cargo- 42 | 43 | - name: Install Rust stable 44 | uses: dtolnay/rust-toolchain@stable 45 | 46 | - name: Install Linux dependencies 47 | run: | 48 | sudo apt-get update 49 | sudo apt-get install -y libglib2.0-dev libsecret-1-dev libgtk-3-dev libpng-dev libjpeg-dev libgl1-mesa-dev libglu1-mesa-dev libxkbcommon-dev libexpat1-dev libtiff-dev 50 | 51 | - name: Build Binary 52 | run: | 53 | mkdir -p build/out 54 | cargo build --workspace --bins --locked --release 55 | cp target/release/${{ env.BINARY_NAME_CLI }} build/out/${{ env.BINARY_NAME_CLI }}-linux-${{ matrix.arch }} 56 | 57 | - name: Generate Runtime Metadata 58 | run: | 59 | cat > build/${{ env.BUNDLE_NAME }}.desktop <>(bundle_path: P) -> Result { 15 | let path = bundle_path.into(); 16 | let info_plist_path = path.join("Info.plist"); 17 | 18 | if !info_plist_path.exists() { 19 | return Err(Error::BundleInfoPlistMissing); 20 | } 21 | 22 | let bundle_type = path 23 | .extension() 24 | .and_then(|ext| ext.to_str()) 25 | .and_then(BundleType::from_extension) 26 | .unwrap_or(BundleType::Unknown); 27 | 28 | Ok(Self { 29 | bundle_dir: path, 30 | bundle_type, 31 | info_plist_path, 32 | }) 33 | } 34 | 35 | pub fn bundle_dir(&self) -> &PathBuf { 36 | &self.bundle_dir 37 | } 38 | 39 | pub fn bundle_type(&self) -> &BundleType { 40 | &self.bundle_type 41 | } 42 | 43 | pub fn collect_nested_bundles(&self) -> Result, Error> { 44 | collect_embeded_bundles_from_dir(&self.bundle_dir) 45 | } 46 | 47 | pub fn collect_bundles_sorted(&self) -> Result, Error> { 48 | let mut bundles = self.collect_nested_bundles()?; 49 | bundles.push(self.clone()); 50 | bundles.sort_by_key(|b| b.bundle_dir().components().count()); 51 | bundles.reverse(); 52 | 53 | Ok(bundles) 54 | } 55 | } 56 | 57 | impl Bundle { 58 | pub fn set_info_plist_key>(&self, key: &str, value: V) -> Result<(), Error> { 59 | let mut plist = Value::from_file(&self.info_plist_path)?; 60 | if let Some(dict) = plist.as_dictionary_mut() { 61 | dict.insert(key.to_string(), value.into()); 62 | } 63 | plist.to_file_xml(&self.info_plist_path)?; 64 | 65 | Ok(()) 66 | } 67 | 68 | // TODO: we need to support changing lproj infoplist strings so localized names change as well 69 | pub fn set_name(&self, new_name: &str) -> Result<(), Error> { 70 | self.set_info_plist_key("CFBundleDisplayName", new_name)?; 71 | self.set_info_plist_key("CFBundleName", new_name) 72 | } 73 | 74 | pub fn set_version(&self, new_version: &str) -> Result<(), Error> { 75 | self.set_info_plist_key("CFBundleShortVersionString", new_version)?; 76 | self.set_info_plist_key("CFBundleVersion", new_version) 77 | } 78 | 79 | pub fn set_bundle_identifier(&self, new_identifier: &str) -> Result<(), Error> { 80 | self.set_info_plist_key("CFBundleIdentifier", new_identifier) 81 | } 82 | 83 | pub fn set_matching_identifier( 84 | &self, 85 | old_identifier: &str, 86 | new_identifier: &str, 87 | ) -> Result<(), Error> { 88 | let mut did_change = false; 89 | let mut plist = Value::from_file(&self.info_plist_path)?; 90 | 91 | // CFBundleIdentifier 92 | if let Some(dict) = plist.as_dictionary_mut() { 93 | if let Some(Value::String(old_value)) = dict.get("CFBundleIdentifier") { 94 | let new_value = old_value.replace(old_identifier, new_identifier); 95 | if old_value != &new_value { 96 | dict.insert("CFBundleIdentifier".to_string(), Value::String(new_value)); 97 | did_change = true; 98 | } 99 | } 100 | 101 | // WKCompanionAppBundleIdentifier 102 | if let Some(Value::String(old_value)) = dict.get("WKCompanionAppBundleIdentifier") { 103 | let new_value = old_value.replace(old_identifier, new_identifier); 104 | if old_value != &new_value { 105 | dict.insert( 106 | "WKCompanionAppBundleIdentifier".to_string(), 107 | Value::String(new_value), 108 | ); 109 | did_change = true; 110 | } 111 | } 112 | 113 | // NSExtension → NSExtensionAttributes → WKAppBundleIdentifier 114 | if let Some(Value::Dictionary(extension_dict)) = dict.get_mut("NSExtension") { 115 | if let Some(Value::Dictionary(attributes)) = 116 | extension_dict.get_mut("NSExtensionAttributes") 117 | { 118 | if let Some(Value::String(old_value)) = attributes.get("WKAppBundleIdentifier") 119 | { 120 | let new_value = old_value.replace(old_identifier, new_identifier); 121 | if old_value != &new_value { 122 | attributes.insert( 123 | "WKAppBundleIdentifier".to_string(), 124 | Value::String(new_value), 125 | ); 126 | did_change = true; 127 | } 128 | } 129 | } 130 | } 131 | } 132 | 133 | if did_change { 134 | plist.to_file_xml(&self.info_plist_path)?; 135 | } 136 | 137 | Ok(()) 138 | } 139 | } 140 | 141 | macro_rules! get_plist_string { 142 | ($self:ident, $key:expr) => {{ 143 | let plist = Value::from_file(&$self.info_plist_path).ok()?; 144 | plist 145 | .as_dictionary() 146 | .and_then(|dict| dict.get($key)) 147 | .and_then(|v| v.as_string()) 148 | .map(|s| s.to_string()) 149 | }}; 150 | } 151 | 152 | impl PlistInfoTrait for Bundle { 153 | fn get_name(&self) -> Option { 154 | get_plist_string!(self, "CFBundleDisplayName") 155 | .or_else(|| get_plist_string!(self, "CFBundleName")) 156 | .or_else(|| self.get_executable()) 157 | } 158 | 159 | fn get_executable(&self) -> Option { 160 | get_plist_string!(self, "CFBundleExecutable") 161 | } 162 | 163 | fn get_bundle_identifier(&self) -> Option { 164 | get_plist_string!(self, "CFBundleIdentifier") 165 | } 166 | 167 | fn get_version(&self) -> Option { 168 | get_plist_string!(self, "CFBundleShortVersionString") 169 | } 170 | 171 | fn get_build_version(&self) -> Option { 172 | get_plist_string!(self, "CFBundleVersion") 173 | } 174 | } 175 | 176 | fn collect_embeded_bundles_from_dir(dir: &PathBuf) -> Result, Error> { 177 | let mut bundles = Vec::new(); 178 | 179 | fn is_bundle_dir(name: &str) -> bool { 180 | if let Some((_, ext)) = name.rsplit_once('.') { 181 | BundleType::from_extension(ext).is_some() 182 | } else { 183 | false 184 | } 185 | } 186 | 187 | fn is_dylib_file(name: &str) -> bool { 188 | name.ends_with(".dylib") 189 | } 190 | 191 | for entry in fs::read_dir(dir)? { 192 | let entry = entry.map_err(Error::Io)?; 193 | let path = entry.path(); 194 | 195 | if let Some(name) = path.file_name().and_then(|n| n.to_str()) { 196 | // Handle dylib files as bundles (even though they don't have Info.plist) 197 | if path.is_file() && is_dylib_file(name) { 198 | // Create a pseudo-bundle for dylib files 199 | bundles.push(Bundle { 200 | bundle_dir: path, 201 | bundle_type: BundleType::Dylib, 202 | info_plist_path: PathBuf::new(), // Empty for dylibs 203 | }); 204 | continue; 205 | } 206 | 207 | if is_bundle_dir(name) { 208 | if let Ok(bundle) = Bundle::new(&path) { 209 | bundles.push(bundle.clone()); 210 | 211 | if bundle.bundle_type != BundleType::App { 212 | if let Ok(embedded) = bundle.collect_nested_bundles() { 213 | bundles.extend(embedded); 214 | } 215 | } 216 | continue; 217 | } 218 | } 219 | } 220 | 221 | if path.is_dir() { 222 | if let Ok(mut sub_bundles) = collect_embeded_bundles_from_dir(&path) { 223 | bundles.append(&mut sub_bundles); 224 | } 225 | } 226 | } 227 | 228 | Ok(bundles) 229 | } 230 | 231 | #[derive(Debug, Clone, PartialEq)] 232 | pub enum BundleType { 233 | App, 234 | AppExtension, 235 | Framework, 236 | Dylib, 237 | Unknown, 238 | } 239 | 240 | impl BundleType { 241 | pub fn from_extension(ext: &str) -> Option { 242 | match ext { 243 | "app" => Some(BundleType::App), 244 | "appex" => Some(BundleType::AppExtension), 245 | "framework" => Some(BundleType::Framework), 246 | "dylib" => Some(BundleType::Dylib), 247 | _ => Some(BundleType::Unknown), 248 | } 249 | } 250 | 251 | /// Returns true if this bundle type should be signed with entitlements 252 | pub fn should_have_entitlements(&self) -> bool { 253 | matches!(self, BundleType::App | BundleType::AppExtension) 254 | } 255 | 256 | /// Returns true if this bundle type should be code signed 257 | pub fn should_be_signed(&self) -> bool { 258 | !matches!(self, BundleType::Unknown) 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /crates/core/src/auth/account/login.rs: -------------------------------------------------------------------------------- 1 | use omnisette::AnisetteConfiguration; 2 | use plist::{Dictionary, Value}; 3 | use reqwest::header::{HeaderMap, HeaderValue}; 4 | use sha2::{Digest, Sha256}; 5 | use srp::client::{SrpClient, SrpClientVerifier}; 6 | use srp::groups::G_2048; 7 | 8 | use crate::Error; 9 | 10 | use crate::auth::account::{check_error, parse_response}; 11 | use crate::auth::anisette_data::AnisetteData; 12 | use crate::auth::{Account, ChallengeRequest, ChallengeRequestBody, GSA_ENDPOINT, InitRequest, InitRequestBody,LoginState, RequestHeader}; 13 | 14 | #[macro_export] 15 | macro_rules! plist_get_string { 16 | ($base:expr, $( $path:literal )+, $final_key:literal) => {{ 17 | let mut current_val = $base; 18 | $( 19 | current_val = current_val 20 | .get($path) 21 | .expect(concat!("Missing dictionary key: ", $path)) 22 | .as_dictionary() 23 | .expect(concat!("Key value is not a dictionary: ", $path)); 24 | )+ 25 | current_val 26 | .get($final_key) 27 | .expect(concat!("Missing string key: ", $final_key)) 28 | .as_string() 29 | .expect(concat!("Value is not a string: ", $final_key)) 30 | .to_string() 31 | }}; 32 | 33 | ($base:expr, $key:literal) => {{ 34 | $base 35 | .get($key) 36 | .expect(concat!("Missing key: ", $key)) 37 | .as_string() 38 | .expect(concat!("Value is not a string: ", $key)) 39 | .to_string() 40 | }}; 41 | } 42 | 43 | impl Account { 44 | pub async fn login( 45 | appleid_closure: impl Fn() -> Result<(String, String), String>, 46 | tfa_closure: impl Fn() -> Result, 47 | config: AnisetteConfiguration, 48 | ) -> Result { 49 | let anisette = AnisetteData::new(config).await?; 50 | Account::login_with_anisette(appleid_closure, tfa_closure, anisette).await 51 | } 52 | 53 | pub async fn login_with_anisette< 54 | F: Fn() -> Result<(String, String), String>, 55 | G: Fn() -> Result, 56 | >( 57 | appleid_closure: F, 58 | tfa_closure: G, 59 | anisette: AnisetteData, 60 | ) -> Result { 61 | let mut _self = Account::new_with_anisette(anisette)?; 62 | let (username, password) = appleid_closure().map_err(|e| { 63 | Error::AuthSrpWithMessage(0, format!("Failed to get Apple ID credentials: {}", e)) 64 | })?; 65 | 66 | let mut response = _self.login_email_pass(&username, &password).await?; 67 | 68 | loop { 69 | match response { 70 | LoginState::NeedsDevice2FA => response = _self.send_2fa_to_devices().await?, 71 | LoginState::Needs2FAVerification => { 72 | response = _self 73 | .verify_2fa(tfa_closure().map_err(|e| { 74 | Error::AuthSrpWithMessage(0, format!("Failed to get 2FA code: {}", e)) 75 | })?) 76 | .await? 77 | } 78 | LoginState::NeedsSMS2FA => response = _self.send_sms_2fa_to_devices(1).await?, 79 | LoginState::NeedsSMS2FAVerification(body) => { 80 | response = _self 81 | .verify_sms_2fa( 82 | tfa_closure().map_err(|e| { 83 | Error::AuthSrpWithMessage( 84 | 0, 85 | format!("Failed to get SMS 2FA code: {}", e), 86 | ) 87 | })?, 88 | body, 89 | ) 90 | .await? 91 | } 92 | LoginState::NeedsLogin => { 93 | response = _self.login_email_pass(&username, &password).await? 94 | } 95 | LoginState::LoggedIn => return Ok(_self), 96 | LoginState::NeedsExtraStep(step) => { 97 | if _self.get_pet().is_some() { 98 | return Ok(_self); 99 | } else { 100 | return Err(Error::ExtraStep(step)); 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | pub async fn login_email_pass( 108 | &mut self, 109 | username: &str, 110 | password: &str, 111 | ) -> Result { 112 | let username_for_spd = username.to_string(); 113 | let srp_client = SrpClient::::new(&G_2048); 114 | let a: Vec = (0..32).map(|_| rand::random::()).collect(); 115 | let a_pub = srp_client.compute_public_ephemeral(&a); 116 | 117 | let anisette = self.get_anisette().await; 118 | 119 | let mut gsa_headers = HeaderMap::new(); 120 | gsa_headers.insert("Content-Type", HeaderValue::from_str("text/x-xml-plist").unwrap()); 121 | gsa_headers.insert("Accept", HeaderValue::from_str("*/*").unwrap()); 122 | gsa_headers.insert("User-Agent", HeaderValue::from_str("akd/1.0 CFNetwork/978.0.7 Darwin/18.7.0").unwrap()); 123 | gsa_headers.insert("X-MMe-Client-Info", HeaderValue::from_str(&anisette.get_header("x-mme-client-info")?).unwrap()); 124 | 125 | let header = RequestHeader { version: "1.0.1".to_string() }; 126 | let init_body = InitRequestBody { 127 | a_pub: plist::Value::Data(a_pub), 128 | cpd: anisette.to_plist(true, false, false), 129 | operation: "init".to_string(), 130 | ps: vec!["s2k".to_string(), "s2k_fo".to_string()], 131 | username: username.to_string(), 132 | }; 133 | 134 | let init_packet = InitRequest { 135 | header: header.clone(), 136 | request: init_body, 137 | }; 138 | 139 | let mut buffer = Vec::new(); 140 | plist::to_writer_xml(&mut buffer, &init_packet)?; 141 | 142 | let res = self.client 143 | .post(GSA_ENDPOINT) 144 | .headers(gsa_headers.clone()) 145 | .body(buffer) 146 | .send() 147 | .await; 148 | 149 | let res = parse_response(res).await?; 150 | check_error(&res)?; 151 | 152 | let salt = res.get("s").unwrap().as_data().unwrap(); 153 | let b_pub = res.get("B").unwrap().as_data().unwrap(); 154 | let iters = res.get("i").unwrap().as_signed_integer().unwrap(); 155 | let c = res.get("c").unwrap().as_string().unwrap(); 156 | 157 | let hashed_password = Sha256::digest(password.as_bytes()); 158 | 159 | let mut password_buf = [0u8; 32]; 160 | pbkdf2::pbkdf2::>( 161 | &hashed_password, 162 | salt, 163 | iters as u32, 164 | &mut password_buf, 165 | ); 166 | 167 | let verifier: SrpClientVerifier = srp_client 168 | .process_reply(&a, username.as_bytes(), &password_buf, salt, b_pub) 169 | .unwrap(); 170 | 171 | let challenge_body = ChallengeRequestBody { 172 | m: plist::Value::Data(verifier.proof().to_vec()), 173 | c: c.to_string(), 174 | cpd: anisette.to_plist(true, false, false), 175 | operation: "complete".to_string(), 176 | username: username.to_string(), 177 | }; 178 | 179 | let challenge_packet = ChallengeRequest { 180 | header, 181 | request: challenge_body, 182 | }; 183 | 184 | let mut buffer = Vec::new(); 185 | plist::to_writer_xml(&mut buffer, &challenge_packet)?; 186 | 187 | let res = self.client 188 | .post(GSA_ENDPOINT) 189 | .headers(gsa_headers) 190 | .body(buffer) 191 | .send() 192 | .await; 193 | 194 | let res = parse_response(res).await?; 195 | check_error(&res)?; 196 | 197 | let m2 = res.get("M2").unwrap().as_data().unwrap(); 198 | verifier.verify_server(m2).unwrap(); 199 | 200 | let spd_encrypted = res.get("spd").unwrap().as_data().unwrap(); 201 | let spd_decrypted = super::decrypt_cbc(&verifier, spd_encrypted); 202 | let mut spd: Dictionary = plist::from_bytes(&spd_decrypted).unwrap(); 203 | 204 | if !spd.contains_key("appleId") { 205 | spd.insert("appleId".to_string(), plist::Value::String(username_for_spd)); 206 | } 207 | 208 | self.spd = Some(spd); 209 | 210 | let status = res.get("Status").unwrap().as_dictionary().unwrap(); 211 | if let Some(Value::String(auth_type)) = status.get("au") { 212 | return match auth_type.as_str() { 213 | "trustedDeviceSecondaryAuth" => Ok(LoginState::NeedsDevice2FA), 214 | "secondaryAuth" => Ok(LoginState::NeedsSMS2FA), 215 | other => Ok(LoginState::NeedsExtraStep(other.to_string())), 216 | }; 217 | } 218 | 219 | Ok(LoginState::LoggedIn) 220 | } 221 | 222 | pub fn get_pet(&self) -> Option { 223 | let base = self.spd.as_ref().unwrap(); 224 | let token = base.get("t")?.as_dictionary()?; 225 | 226 | Some(plist_get_string!( 227 | token, 228 | "com.apple.gs.idms.pet", 229 | "token" 230 | )) 231 | } 232 | 233 | pub fn get_name(&self) -> (String, String) { 234 | let base = self.spd.as_ref().unwrap(); 235 | (plist_get_string!(base, "fn"), plist_get_string!(base, "ln")) 236 | } 237 | 238 | pub async fn get_anisette(&self) -> AnisetteData { 239 | let mut locked = self.anisette.lock().await; 240 | if locked.needs_refresh() { 241 | *locked = locked.refresh().await.unwrap(); 242 | } 243 | locked.clone() 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /apps/plumeimpactor/src/pages/settings.rs: -------------------------------------------------------------------------------- 1 | use wxdragon::prelude::*; 2 | 3 | use crate::frame::PlumeFrame; 4 | use super::DIALOG_SIZE; 5 | 6 | #[derive(Clone)] 7 | pub struct LoginDialog { 8 | pub dialog: Dialog, 9 | pub email_field: TextCtrl, 10 | pub password_field: TextCtrl, 11 | pub next_button: Button, 12 | } 13 | 14 | pub fn create_login_dialog(parent: &Window) -> LoginDialog { 15 | let dialog = Dialog::builder(parent, "Sign in with your Apple ID") 16 | .with_style(DialogStyle::SystemMenu | DialogStyle::Caption) 17 | .with_size(DIALOG_SIZE.0, DIALOG_SIZE.1) 18 | .build(); 19 | 20 | let sizer = BoxSizer::builder(Orientation::Vertical).build(); 21 | sizer.add_spacer(13); 22 | 23 | let email_row = BoxSizer::builder(Orientation::Horizontal).build(); 24 | let email_label = StaticText::builder(&dialog) 25 | .with_label(" Email:") 26 | .build(); 27 | let email_field = TextCtrl::builder(&dialog).build(); 28 | email_row.add(&email_label, 0, SizerFlag::AlignCenterVertical | SizerFlag::All, 4); 29 | email_row.add(&email_field, 1, SizerFlag::Expand | SizerFlag::Right, 8); 30 | sizer.add_sizer(&email_row, 0, SizerFlag::Expand | SizerFlag::All, 4); 31 | 32 | let password_row = BoxSizer::builder(Orientation::Horizontal).build(); 33 | let password_label = StaticText::builder(&dialog).with_label("Password:").build(); 34 | let password_field = TextCtrl::builder(&dialog) 35 | .with_style(TextCtrlStyle::Password) 36 | .build(); 37 | password_row.add(&password_label, 0, SizerFlag::AlignCenterVertical | SizerFlag::All, 4); 38 | password_row.add(&password_field, 1, SizerFlag::Expand | SizerFlag::Right, 8); 39 | sizer.add_sizer(&password_row, 0, SizerFlag::Expand | SizerFlag::All, 4); 40 | 41 | let button_sizer = BoxSizer::builder(Orientation::Horizontal).build(); 42 | let cancel_button = Button::builder(&dialog).with_label("Cancel").build(); 43 | let next_button = Button::builder(&dialog).with_label("Next").build(); 44 | button_sizer.add(&cancel_button, 1, SizerFlag::Expand | SizerFlag::All, 0); 45 | button_sizer.add_spacer(13); 46 | button_sizer.add(&next_button, 1, SizerFlag::Expand | SizerFlag::All, 0); 47 | 48 | sizer.add_sizer(&button_sizer, 0, SizerFlag::AlignRight | SizerFlag::All, 13); 49 | 50 | dialog.set_sizer(sizer, true); 51 | 52 | cancel_button.on_click({ 53 | let dialog = dialog.clone(); 54 | move |_| dialog.hide() 55 | }); 56 | 57 | LoginDialog { 58 | dialog, 59 | email_field, 60 | password_field, 61 | next_button, 62 | } 63 | } 64 | 65 | impl LoginDialog { 66 | pub fn get_email(&self) -> String { 67 | self.email_field.get_value().to_string() 68 | } 69 | 70 | pub fn get_password(&self) -> String { 71 | self.password_field.get_value().to_string() 72 | } 73 | 74 | pub fn clear_fields(&self) { 75 | self.email_field.set_value(""); 76 | self.password_field.set_value(""); 77 | } 78 | 79 | pub fn set_next_handler(&self, on_next: impl Fn() + 'static) { 80 | self.next_button.on_click(move |_evt| { 81 | on_next(); 82 | }); 83 | } 84 | } 85 | 86 | // MARK: - AccountDialog 87 | 88 | #[derive(Clone)] 89 | pub struct SettingsDialog { 90 | pub dialog: Dialog, 91 | pub account_list: CheckListBox, 92 | pub add_button: Button, 93 | pub remove_button: Button, 94 | } 95 | 96 | pub fn create_settings_dialog(parent: &Window) -> SettingsDialog { 97 | let dialog = Dialog::builder(parent, "Settings") 98 | .with_size(DIALOG_SIZE.0 + 50, DIALOG_SIZE.1 + 150) 99 | .build(); 100 | 101 | let main_sizer = BoxSizer::builder(Orientation::Vertical).build(); 102 | 103 | main_sizer.add_spacer(16); 104 | 105 | let accounts_label = StaticText::builder(&dialog) 106 | .with_label("Apple ID Accounts") 107 | .build(); 108 | main_sizer.add(&accounts_label, 0, SizerFlag::Left | SizerFlag::Left | SizerFlag::Right, 16); 109 | 110 | main_sizer.add_spacer(8); 111 | 112 | let account_list = CheckListBox::builder(&dialog).build(); 113 | main_sizer.add(&account_list, 1, SizerFlag::Expand | SizerFlag::Left | SizerFlag::Right, 16); 114 | 115 | main_sizer.add_spacer(12); 116 | 117 | let button_row = BoxSizer::builder(Orientation::Horizontal).build(); 118 | let add_button = Button::builder(&dialog).with_label("Add Account").build(); 119 | let remove_button = Button::builder(&dialog).with_label("Remove Account").build(); 120 | 121 | button_row.add(&add_button, 0, SizerFlag::All, 0); 122 | button_row.add_spacer(8); 123 | button_row.add(&remove_button, 0, SizerFlag::All, 0); 124 | button_row.add_stretch_spacer(1); 125 | 126 | main_sizer.add_sizer(&button_row, 0, SizerFlag::Expand | SizerFlag::Left | SizerFlag::Right, 16); 127 | 128 | main_sizer.add_spacer(16); 129 | 130 | dialog.set_sizer(main_sizer, true); 131 | 132 | SettingsDialog { 133 | dialog, 134 | account_list, 135 | add_button, 136 | remove_button, 137 | } 138 | } 139 | 140 | impl SettingsDialog { 141 | pub fn set_add_handler(&self, on_add: impl Fn() + 'static) { 142 | self.add_button.on_click(move |_| { 143 | on_add(); 144 | }); 145 | } 146 | 147 | pub fn set_remove_handler(&self, on_remove: impl Fn() + 'static) { 148 | self.remove_button.on_click(move |_| { 149 | on_remove(); 150 | }); 151 | } 152 | 153 | pub fn set_checklistbox_handler(&self, on_select: impl Fn(usize) + 'static) { 154 | let checklistbox = self.account_list.clone(); 155 | self.account_list.on_selected(move |event_data| { 156 | if let Some(selected_index) = event_data.get_selection() { 157 | let selected_index = selected_index as usize; 158 | 159 | let count = checklistbox.get_count() as usize; 160 | for i in 0..count { 161 | checklistbox.check(i as u32, false); 162 | } 163 | 164 | checklistbox.check(selected_index as u32, true); 165 | on_select(selected_index); 166 | } 167 | }); 168 | } 169 | 170 | pub fn refresh_account_list(&self, accounts: Vec<(String, String, bool)>) { 171 | self.account_list.clear(); 172 | 173 | let has_accounts = !accounts.is_empty(); 174 | 175 | for (i, (email, first_name, is_selected)) in accounts.into_iter().enumerate() { 176 | let label = format!("{} ({})", first_name, email); 177 | self.account_list.append(&label); 178 | 179 | if is_selected { 180 | self.account_list.check(i as u32, true); 181 | } 182 | } 183 | 184 | self.remove_button.enable(has_accounts); 185 | } 186 | 187 | pub fn get_checked_index(&self) -> Option { 188 | let count = self.account_list.get_count() as usize; 189 | for i in 0..count { 190 | if self.account_list.is_checked(i as u32) { 191 | return Some(i); 192 | } 193 | } 194 | None 195 | } 196 | } 197 | 198 | // MARK: - Single Field Dialog 199 | impl PlumeFrame { 200 | pub fn create_single_field_dialog(&self, title: &str, label: &str) -> Result { 201 | let dialog = Dialog::builder(&self.frame, title) 202 | .with_style(DialogStyle::SystemMenu | DialogStyle::Caption) 203 | .with_size(DIALOG_SIZE.0, DIALOG_SIZE.1) 204 | .build(); 205 | 206 | let sizer = BoxSizer::builder(Orientation::Vertical).build(); 207 | sizer.add_spacer(16); 208 | 209 | sizer.add( 210 | &StaticText::builder(&dialog).with_label(label).build(), 211 | 0, 212 | SizerFlag::All, 213 | 12, 214 | ); 215 | let text_field = TextCtrl::builder(&dialog).build(); 216 | sizer.add(&text_field, 0, SizerFlag::Expand | SizerFlag::All, 8); 217 | 218 | let button_sizer = BoxSizer::builder(Orientation::Horizontal).build(); 219 | 220 | let cancel_button = Button::builder(&dialog).with_label("Cancel").build(); 221 | let ok_button = Button::builder(&dialog).with_label("OK").build(); 222 | 223 | button_sizer.add(&cancel_button, 0, SizerFlag::All, 8); 224 | button_sizer.add_spacer(8); 225 | button_sizer.add(&ok_button, 0, SizerFlag::All, 8); 226 | 227 | sizer.add_sizer(&button_sizer, 0, SizerFlag::AlignRight | SizerFlag::All, 8); 228 | 229 | dialog.set_sizer(sizer, true); 230 | 231 | cancel_button.on_click({ 232 | let dialog = dialog.clone(); 233 | move |_| dialog.end_modal(ID_CANCEL as i32) 234 | }); 235 | ok_button.on_click({ 236 | let dialog = dialog.clone(); 237 | move |_| dialog.end_modal(ID_OK as i32) 238 | }); 239 | 240 | text_field.set_focus(); 241 | 242 | let rc = dialog.show_modal(); 243 | let result = if rc == ID_OK as i32 { 244 | Ok(text_field.get_value().to_string()) 245 | } else { 246 | Err("2FA cancelled".to_string()) 247 | }; 248 | dialog.destroy(); 249 | result 250 | } 251 | } 252 | 253 | // MARK: - Text Selection Dialog 254 | impl PlumeFrame { 255 | pub fn create_text_selection_dialog( 256 | &self, title: &str, 257 | label: &str, 258 | choices: Vec, 259 | ) -> Result { 260 | let choice_refs: Vec<&str> = choices.iter().map(|s| s.as_str()).collect(); 261 | let dialog = SingleChoiceDialog::builder(&self.frame, label, title, &choice_refs).build(); 262 | let rc = dialog.show_modal(); 263 | if rc == ID_OK as i32 { 264 | Ok(dialog.get_selection()) 265 | } else { 266 | Err("Dialog cancelled".to_string()) 267 | } 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /apps/plumesign/src/commands/account.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | use std::path::PathBuf; 3 | 4 | use clap::{Args, Subcommand}; 5 | use anyhow::{Ok, Result}; 6 | use dialoguer::Select; 7 | 8 | use plume_core::{ 9 | AnisetteConfiguration, 10 | auth::Account, 11 | developer::DeveloperSession, 12 | store::{AccountStatus, AccountStore} 13 | }; 14 | use plume_shared::get_data_path; 15 | 16 | #[derive(Debug, Args)] 17 | #[command(arg_required_else_help = true)] 18 | pub struct AccountArgs { 19 | #[command(subcommand)] 20 | pub command: AccountCommands, 21 | } 22 | 23 | #[derive(Debug, Subcommand)] 24 | #[command(arg_required_else_help = true)] 25 | pub enum AccountCommands { 26 | /// Login to Apple Developer account 27 | Login(LoginArgs), 28 | /// Logout from Apple Developer account 29 | Logout, 30 | /// List all saved accounts 31 | List, 32 | /// Switch to a different account 33 | Switch(SwitchArgs), 34 | /// List certificates for a team 35 | Certificates(CertificatesArgs), 36 | /// List devices registered to the account 37 | Devices(DevicesArgs), 38 | /// Register a new device 39 | RegisterDevice(RegisterDeviceArgs), 40 | /// List all app IDs for a team 41 | AppIds(AppIdsArgs), 42 | } 43 | 44 | #[derive(Debug, Args)] 45 | #[command(arg_required_else_help = true)] 46 | pub struct LoginArgs { 47 | /// Apple ID email 48 | #[arg(short = 'u', long = "username", value_name = "EMAIL")] 49 | pub username: Option, 50 | /// Password (will prompt if not provided) 51 | #[arg(short = 'p', long = "password", value_name = "PASSWORD")] 52 | pub password: Option, 53 | } 54 | 55 | #[derive(Debug, Args)] 56 | pub struct CertificatesArgs { 57 | /// Team ID to list certificates for 58 | #[arg(short = 't', long = "team", value_name = "TEAM_ID")] 59 | pub team_id: Option, 60 | /// Filter by certificate type (development, distribution) 61 | #[arg(long = "type", value_name = "TYPE")] 62 | pub cert_type: Option, 63 | } 64 | 65 | #[derive(Debug, Args)] 66 | pub struct DevicesArgs { 67 | /// Team ID to list devices for 68 | #[arg(short = 't', long = "team", value_name = "TEAM_ID")] 69 | pub team_id: Option, 70 | /// Filter by device platform (ios, tvos, watchos) 71 | #[arg(long = "platform", value_name = "PLATFORM")] 72 | pub platform: Option, 73 | } 74 | 75 | #[derive(Debug, Args)] 76 | pub struct RegisterDeviceArgs { 77 | /// Team ID to list devices for 78 | #[arg(short = 't', long = "team", value_name = "TEAM_ID")] 79 | pub team_id: Option, 80 | /// Device UDID 81 | #[arg(short = 'u', long = "udid", value_name = "UDID", required = true)] 82 | pub udid: String, 83 | /// Device name 84 | #[arg(short = 'n', long = "name", value_name = "NAME", required = true)] 85 | pub name: String, 86 | } 87 | 88 | #[derive(Debug, Args)] 89 | pub struct AppIdsArgs { 90 | /// Team ID to list app IDs for 91 | #[arg(short = 't', long = "team", value_name = "TEAM_ID")] 92 | pub team_id: Option, 93 | } 94 | 95 | #[derive(Debug, Args)] 96 | pub struct SwitchArgs { 97 | /// Email of the account to switch to 98 | #[arg(value_name = "EMAIL", required = true)] 99 | pub email: String, 100 | } 101 | 102 | pub async fn execute(args: AccountArgs) -> Result<()> { 103 | match args.command { 104 | AccountCommands::Login(login_args) => login(login_args).await, 105 | AccountCommands::Logout => logout().await, 106 | AccountCommands::List => list_accounts().await, 107 | AccountCommands::Switch(switch_args) => switch_account(switch_args).await, 108 | AccountCommands::Certificates(cert_args) => certificates(cert_args).await, 109 | AccountCommands::Devices(device_args) => devices(device_args).await, 110 | AccountCommands::RegisterDevice(register_args) => register_device(register_args).await, 111 | AccountCommands::AppIds(app_id_args) => app_ids(app_id_args).await, 112 | } 113 | } 114 | 115 | fn get_settings_path() -> PathBuf { 116 | get_data_path().join("accounts.json") 117 | } 118 | 119 | pub async fn get_authenticated_account() -> Result { 120 | let settings_path = get_settings_path(); 121 | let settings = AccountStore::load(&Some(settings_path.clone())).await?; 122 | 123 | let gsa_account = settings.selected_account() 124 | .ok_or_else(|| anyhow::anyhow!("No account selected. Please login first using 'plumesign account login'"))? 125 | .clone(); 126 | 127 | if *gsa_account.status() == AccountStatus::NeedsReauth { 128 | return Err(anyhow::anyhow!("Account is invalid. Please login again using 'plumesign account login'")); 129 | } 130 | 131 | let anisette_config = AnisetteConfiguration::default() 132 | .set_configuration_path(get_data_path()); 133 | 134 | log::info!("Restoring session for {}...", gsa_account.email()); 135 | 136 | let session = DeveloperSession::new( 137 | gsa_account.adsid().clone(), 138 | gsa_account.xcode_gs_token().clone(), 139 | anisette_config, 140 | ).await?; 141 | 142 | Ok(session) 143 | } 144 | 145 | async fn login(args: LoginArgs) -> Result<()> { 146 | let tfa_closure = || -> std::result::Result { 147 | log::info!("Enter 2FA code: "); 148 | let mut input = String::new(); 149 | std::io::stdin().read_line(&mut input).map_err(|e| e.to_string())?; 150 | std::result::Result::Ok(input.trim().to_string()) 151 | }; 152 | 153 | let anisette_config = AnisetteConfiguration::default() 154 | .set_configuration_path(get_data_path()); 155 | 156 | let username = if let Some(user) = args.username { 157 | user 158 | } else { 159 | log::info!("Enter Apple ID email: "); 160 | let mut input = String::new(); 161 | std::io::stdin().read_line(&mut input)?; 162 | input.trim().to_string() 163 | }; 164 | 165 | let password = if let Some(pass) = args.password { 166 | pass 167 | } else { 168 | print!("Enter password: "); 169 | std::io::stdout().flush()?; 170 | 171 | let mut input = String::new(); 172 | std::io::stdin().read_line(&mut input)?; 173 | input.trim().to_string() 174 | }; 175 | 176 | let login_closure = || -> std::result::Result<(String, String), String> { 177 | std::result::Result::Ok((username.clone(), password.clone())) 178 | }; 179 | 180 | println!("Logging in..."); 181 | let account = Account::login(login_closure, tfa_closure, anisette_config).await?; 182 | 183 | let settings_path = get_settings_path(); 184 | let mut settings = AccountStore::load(&Some(settings_path.clone())).await?; 185 | settings.accounts_add_from_session(username, account).await?; 186 | 187 | log::info!("Successfully logged in and account saved."); 188 | 189 | Ok(()) 190 | } 191 | 192 | async fn logout() -> Result<()> { 193 | let settings_path = get_settings_path(); 194 | let mut settings = AccountStore::load(&Some(settings_path.clone())).await?; 195 | 196 | let email = settings.selected_account() 197 | .ok_or_else(|| anyhow::anyhow!("No account currently logged in"))? 198 | .email() 199 | .clone(); 200 | 201 | settings.accounts_remove(&email).await?; 202 | 203 | log::info!("Successfully logged out and removed account."); 204 | 205 | Ok(()) 206 | } 207 | 208 | async fn certificates(args: CertificatesArgs) -> Result<()> { 209 | let session = get_authenticated_account().await?; 210 | 211 | let team_id = if args.team_id.is_none() { 212 | teams(&session).await? 213 | } else { 214 | args.team_id.unwrap() 215 | }; 216 | 217 | let p = session.qh_list_certs(&team_id) 218 | .await? 219 | .certificates; 220 | 221 | log::info!("{:#?}", p); 222 | 223 | Ok(()) 224 | } 225 | 226 | async fn devices(args: DevicesArgs) -> Result<()> { 227 | let session = get_authenticated_account().await?; 228 | 229 | let team_id = if args.team_id.is_none() { 230 | teams(&session).await? 231 | } else { 232 | args.team_id.unwrap() 233 | }; 234 | 235 | let p = session.qh_list_devices(&team_id) 236 | .await? 237 | .devices; 238 | 239 | log::info!("{:#?}", p); 240 | 241 | Ok(()) 242 | } 243 | 244 | async fn register_device(args: RegisterDeviceArgs) -> Result<()> { 245 | let session = get_authenticated_account().await?; 246 | 247 | let team_id = if args.team_id.is_none() { 248 | teams(&session).await? 249 | } else { 250 | args.team_id.unwrap() 251 | }; 252 | 253 | let p = session.qh_add_device(&team_id, &args.name, &args.udid) 254 | .await? 255 | .device; 256 | 257 | log::info!("{:#?}", p); 258 | 259 | Ok(()) 260 | } 261 | 262 | pub async fn teams(session: &DeveloperSession) -> Result { 263 | let teams = session.qh_list_teams().await?.teams; 264 | 265 | if teams.len() == 1 { 266 | return Ok(teams[0].team_id.clone()); 267 | } 268 | 269 | let team_names: Vec = teams.iter() 270 | .map(|t| format!("{} ({})", t.name, t.team_id)) 271 | .collect(); 272 | 273 | let selection = Select::new() 274 | .items(&team_names) 275 | .default(0) 276 | .interact()?; 277 | 278 | Ok(teams[selection].team_id.clone()) 279 | } 280 | 281 | pub async fn app_ids(args: AppIdsArgs) -> Result<()> { 282 | let session = get_authenticated_account().await?; 283 | 284 | let team_id = if args.team_id.is_none() { 285 | teams(&session).await? 286 | } else { 287 | args.team_id.unwrap() 288 | }; 289 | 290 | let p = session.v1_list_app_ids(&team_id) 291 | .await? 292 | .data; 293 | 294 | log::info!("{:#?}", p); 295 | 296 | Ok(()) 297 | } 298 | 299 | async fn list_accounts() -> Result<()> { 300 | let settings_path = get_settings_path(); 301 | let settings = AccountStore::load(&Some(settings_path)).await?; 302 | 303 | let accounts = settings.accounts(); 304 | 305 | if accounts.is_empty() { 306 | log::info!("No accounts found. Use 'account login' to add an account."); 307 | return Ok(()); 308 | } 309 | 310 | let selected_email = settings.selected_account().map(|a| a.email().clone()); 311 | 312 | log::info!("Saved accounts:"); 313 | for (email, account) in accounts { 314 | let status_str = match account.status() { 315 | AccountStatus::Valid => "Valid", 316 | AccountStatus::NeedsReauth => "Needs Re-auth", 317 | }; 318 | 319 | let selected = if Some(email) == selected_email.as_ref() { "(selected)" } else { "" }; 320 | 321 | log::info!(" [{}] {} - {} {}", 322 | status_str, 323 | account.first_name(), 324 | email, 325 | selected 326 | ); 327 | } 328 | 329 | Ok(()) 330 | } 331 | 332 | async fn switch_account(args: SwitchArgs) -> Result<()> { 333 | let settings_path = get_settings_path(); 334 | let mut settings = AccountStore::load(&Some(settings_path)).await?; 335 | 336 | if settings.get_account(&args.email).is_none() { 337 | return Err(anyhow::anyhow!("Account '{}' not found. Use 'account list' to see available accounts.", args.email)); 338 | } 339 | 340 | settings.account_select(&args.email).await?; 341 | 342 | log::info!("Switched to account: {}", args.email); 343 | 344 | Ok(()) 345 | } 346 | -------------------------------------------------------------------------------- /crates/types/src/tweak.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use plume_core::MachO; 7 | use uuid::Uuid; 8 | 9 | use crate::{Bundle, Error, PlistInfoTrait, copy_dir_recursively}; 10 | 11 | const ELLEKIT_BYTES: &[u8] = include_bytes!("./ellekit.deb"); 12 | 13 | pub struct Tweak { 14 | path: PathBuf, 15 | app_bundle: PathBuf, 16 | stage_dir: PathBuf, 17 | } 18 | 19 | impl Tweak { 20 | pub async fn install_ellekit(app_bundle: &Bundle) -> Result<(), Error> { 21 | let stage_dir = env::temp_dir().join(format!("plume_ellekit_{}", Uuid::new_v4())); 22 | tokio::fs::create_dir_all(&stage_dir).await?; 23 | 24 | let deb_path = stage_dir.join("ellekit.deb"); 25 | tokio::fs::write(&deb_path, ELLEKIT_BYTES).await?; 26 | 27 | let tweak = Tweak::new(&deb_path, app_bundle).await?; 28 | tweak.install_deb().await?; 29 | 30 | tokio::fs::remove_dir_all(&stage_dir).await.ok(); 31 | 32 | Ok(()) 33 | } 34 | 35 | pub async fn new>(tweak_path: P, app_bundle: &Bundle) -> Result { 36 | let path = tweak_path.as_ref(); 37 | if !path.exists() { 38 | return Err(Error::TweakInvalidPath); 39 | } 40 | 41 | let file_name = path.file_name() 42 | .and_then(|n| n.to_str()) 43 | .ok_or(Error::TweakInvalidPath)?; 44 | 45 | if !file_name.ends_with(".deb") 46 | && !file_name.ends_with(".dylib") 47 | && !file_name.ends_with(".framework") 48 | && !file_name.ends_with(".bundle") 49 | && !file_name.ends_with(".appex") { 50 | return Err(Error::UnsupportedFileType(file_name.to_string())); 51 | } 52 | 53 | let stage_dir = env::temp_dir().join(format!("plume_tweak_{}", Uuid::new_v4())); 54 | tokio::fs::create_dir_all(&stage_dir).await?; 55 | 56 | Ok(Self { 57 | path: path.to_path_buf(), 58 | app_bundle: app_bundle.bundle_dir().clone(), 59 | stage_dir, 60 | }) 61 | } 62 | 63 | pub async fn apply(&self) -> Result<(), Error> { 64 | let file_name = self.path.file_name() 65 | .and_then(|n| n.to_str()) 66 | .ok_or(Error::TweakInvalidPath)?; 67 | 68 | if file_name.ends_with(".deb") { 69 | self.install_deb().await?; 70 | } else if file_name.ends_with(".framework") { 71 | self.install_framework(&self.path).await?; 72 | } else if file_name.ends_with(".bundle") { 73 | self.install_bundle(&self.path).await?; 74 | } else if file_name.ends_with(".appex") { 75 | self.install_appex(&self.path).await?; 76 | } else if file_name.ends_with(".dylib") { 77 | self.install_dylib(&self.path).await?; 78 | } 79 | 80 | tokio::fs::remove_dir_all(&self.stage_dir).await.ok(); 81 | 82 | Ok(()) 83 | } 84 | 85 | async fn install_deb(&self) -> Result<(), Error> { 86 | use decompress::ExtractOpts; 87 | 88 | let extract_dir = self.stage_dir.join("deb_contents"); 89 | tokio::fs::create_dir_all(&extract_dir).await?; 90 | 91 | let ar_extract_dir = self.stage_dir.join("ar_contents"); 92 | tokio::fs::create_dir_all(&ar_extract_dir).await?; 93 | 94 | let ar_path_sync = self.path.clone(); 95 | let ar_extract_dir_sync = ar_extract_dir.clone(); 96 | 97 | tokio::task::spawn_blocking(move || { 98 | decompress::decompress( 99 | &ar_path_sync, 100 | &ar_extract_dir_sync, 101 | &ExtractOpts { 102 | strip: 0, 103 | detect_content: false, 104 | filter: Box::new(|_: &std::path::Path| true), 105 | map: Box::new(|p| std::borrow::Cow::Borrowed(p)), 106 | } 107 | ) 108 | }).await 109 | .ok() 110 | .and_then(|r| r.ok()) 111 | .ok_or_else(|| Error::TweakExtractionFailed("Failed to extract .ar archive".to_string()))?; 112 | 113 | for archive_name in ["data.tar.lzma", "data.tar.gz", "data.tar.xz", "data.tar.bz2", "data.tar"] { 114 | let data_path = ar_extract_dir.join(archive_name); 115 | if data_path.exists() { 116 | let extract_dir_sync = extract_dir.clone(); 117 | let data_path_sync = data_path.clone(); 118 | 119 | tokio::task::spawn_blocking(move || { 120 | decompress::decompress(&data_path_sync, &extract_dir_sync, &ExtractOpts { 121 | strip: 0, 122 | detect_content: false, 123 | filter: Box::new(|_: &std::path::Path| true), 124 | map: Box::new(|p| std::borrow::Cow::Borrowed(p)), 125 | } 126 | ) 127 | }).await 128 | .map_err(|e| Error::TweakExtractionFailed(format!("Failed to extract data.tar: {}", e)))? 129 | .map_err(|e| Error::TweakExtractionFailed(format!("Failed to extract data.tar: {}", e)))?; 130 | 131 | break; 132 | } 133 | } 134 | 135 | self.scan_and_install(&extract_dir).await 136 | } 137 | 138 | async fn scan_and_install(&self, root: &Path) -> Result<(), Error> { 139 | let search_paths = [ 140 | "Library/MobileSubstrate/DynamicLibraries", 141 | "usr/lib", 142 | "Library/Frameworks", 143 | "Library/Application Support", 144 | "var/jb/Library/MobileSubstrate/DynamicLibraries", 145 | "var/jb/usr/lib", 146 | "var/jb/Library/Frameworks", 147 | "var/jb/Library/Application Support", 148 | ]; 149 | 150 | for search_path in search_paths { 151 | let dir = root.join(search_path); 152 | if dir.exists() { 153 | self.scan_directory(&dir).await?; 154 | } 155 | } 156 | 157 | Ok(()) 158 | } 159 | 160 | async fn scan_directory(&self, dir: &Path) -> Result<(), Error> { 161 | use futures::future::BoxFuture; 162 | 163 | fn scan_recursive<'a>(tweak: &'a Tweak, dir: &'a Path) -> BoxFuture<'a, Result<(), Error>> { 164 | Box::pin(async move { 165 | let mut entries = tokio::fs::read_dir(dir).await?; 166 | 167 | while let Some(entry) = entries.next_entry().await? { 168 | let path = entry.path(); 169 | let metadata = tokio::fs::symlink_metadata(&path).await?; 170 | 171 | if metadata.is_symlink() { 172 | continue; 173 | } 174 | 175 | if let Some(name) = path.file_name().and_then(|n| n.to_str()) { 176 | if path.is_file() && name.ends_with(".dylib") { 177 | tweak.install_dylib(&path).await?; 178 | } else if path.is_dir() { 179 | if name.ends_with(".framework") { 180 | tweak.install_framework(&path).await?; 181 | } else if name.ends_with(".bundle") { 182 | tweak.install_bundle(&path).await?; 183 | } else if name.ends_with(".appex") { 184 | tweak.install_appex(&path).await?; 185 | } else { 186 | // Recursively scan subdirectories 187 | scan_recursive(tweak, &path).await?; 188 | } 189 | } 190 | } 191 | } 192 | 193 | Ok(()) 194 | }) 195 | } 196 | 197 | scan_recursive(self, dir).await 198 | } 199 | 200 | async fn install_dylib(&self, dylib_path: &Path) -> Result<(), Error> { 201 | let frameworks_dir = self.app_bundle.join("Frameworks"); 202 | tokio::fs::create_dir_all(&frameworks_dir).await?; 203 | 204 | let dylib_name = dylib_path.file_name().ok_or(Error::TweakInvalidPath)?; 205 | let dest = frameworks_dir.join(dylib_name); 206 | 207 | tokio::fs::copy(dylib_path, &dest).await?; 208 | 209 | Self::patch_cydiasubstrate(&dest); 210 | self.inject_dylib(&dest, false).await 211 | } 212 | 213 | async fn install_framework(&self, framework_path: &Path) -> Result<(), Error> { 214 | let frameworks_dir = self.app_bundle.join("Frameworks"); 215 | tokio::fs::create_dir_all(&frameworks_dir).await?; 216 | 217 | let framework_name = framework_path.file_name().ok_or(Error::TweakInvalidPath)?; 218 | let dest = frameworks_dir.join(framework_name); 219 | 220 | copy_dir_recursively(&framework_path, &dest).await?; 221 | 222 | if let Ok(bundle) = Bundle::new(&dest) { 223 | if let Some(exec_name) = bundle.get_executable() { 224 | let exec_path = dest.join(exec_name); 225 | if exec_path.exists() { 226 | Self::patch_cydiasubstrate(&exec_path); 227 | self.inject_dylib(&exec_path, true).await?; 228 | } 229 | } 230 | } 231 | 232 | Ok(()) 233 | } 234 | 235 | async fn install_bundle(&self, bundle_path: &Path) -> Result<(), Error> { 236 | let bundle_name = bundle_path.file_name().ok_or(Error::TweakInvalidPath)?; 237 | let dest = self.app_bundle.join(bundle_name); 238 | 239 | copy_dir_recursively(bundle_path, &dest).await 240 | } 241 | 242 | async fn install_appex(&self, appex_path: &Path) -> Result<(), Error> { 243 | let plugins_dir = self.app_bundle.join("PlugIns"); 244 | tokio::fs::create_dir_all(&plugins_dir).await?; 245 | 246 | let appex_name = appex_path.file_name().ok_or(Error::TweakInvalidPath)?; 247 | let dest = plugins_dir.join(appex_name); 248 | 249 | copy_dir_recursively(appex_path, &dest).await 250 | } 251 | 252 | async fn inject_dylib(&self, dylib_path: &Path, is_framework: bool) -> Result<(), Error> { 253 | let bundle = Bundle::new(&self.app_bundle)?; 254 | let executable_name = bundle.get_executable() 255 | .ok_or(Error::BundleInfoPlistMissing)?; 256 | 257 | let executable_path = self.app_bundle.join(&executable_name); 258 | if !executable_path.exists() { 259 | return Err(Error::BundleInfoPlistMissing); 260 | } 261 | 262 | let inject_path = if is_framework { 263 | let components: Vec<_> = dylib_path.components().rev().take(2).collect(); 264 | format!("@rpath/{}/{}", 265 | components[1].as_os_str().to_str().ok_or(Error::TweakInvalidPath)?, 266 | components[0].as_os_str().to_str().ok_or(Error::TweakInvalidPath)?) 267 | } else { 268 | let file_name = dylib_path.file_name() 269 | .and_then(|f| f.to_str()) 270 | .ok_or(Error::TweakInvalidPath)?; 271 | format!("@rpath/{}", file_name) 272 | }; 273 | 274 | let mut macho = MachO::new(&executable_path)?; 275 | macho.add_dylib(&inject_path)?; 276 | macho.write_changes()?; 277 | 278 | Ok(()) 279 | } 280 | 281 | fn patch_cydiasubstrate(binary_path: &Path) { 282 | if let Ok(mut macho) = MachO::new(binary_path) { 283 | let _ = macho.replace_dylib( 284 | "/Library/Frameworks/CydiaSubstrate.framework/CydiaSubstrate", 285 | "@rpath/CydiaSubstrate.framework/CydiaSubstrate" 286 | ); 287 | let _ = macho.write_changes(); 288 | } 289 | } 290 | } 291 | --------------------------------------------------------------------------------