diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 383d19e0..d098c3fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,16 +55,24 @@ jobs: path: dist/* build-windows: - runs-on: windows-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - uses: ilammy/msvc-dev-cmd@v1 + with: + targets: x86_64-pc-windows-gnu - - name: Build binaries + - name: Install mingw-w64 run: | - choco install nsis.portable - make windows PROFILE=release NSIS=1 + sudo apt-get update + sudo apt-get install -y mingw-w64 + + - name: Build binaries (cross-compile) + run: | + cargo build --bins --workspace --release --target x86_64-pc-windows-gnu + mkdir -p dist + cp target/x86_64-pc-windows-gnu/release/plumeimpactor.exe dist/Impactor-windows-x86_64-portable.exe + cp target/x86_64-pc-windows-gnu/release/plumesign.exe dist/plumesign-windows-x86_64.exe - name: Upload Bundles uses: actions/upload-artifact@v4 @@ -72,24 +80,95 @@ jobs: name: ${{ env.BINARY_NAME }}-windows path: dist/*.exe + package-windows: + runs-on: windows-latest + needs: [build-windows] + steps: + - uses: actions/checkout@v4 + + - name: Download Windows Artifacts + uses: actions/download-artifact@v4 + with: + name: ${{ env.BINARY_NAME }}-windows + path: dist + + - name: Install NSIS + run: choco install nsis.portable + + - name: Build NSIS Installer + run: | + mkdir dist\nsis + copy package\windows\* dist\nsis\ + copy dist\Impactor-windows-x86_64-portable.exe dist\nsis\plumeimpactor.exe + makensis dist\nsis\installer.nsi + move dist\nsis\installer.exe dist\Impactor-windows-x86_64-setup.exe + + - name: Upload Installer + uses: actions/upload-artifact@v4 + with: + name: ${{ env.BINARY_NAME }}-windows-installer + path: dist/Impactor-windows-x86_64-setup.exe + build-macos-slices: - runs-on: ${{ matrix.os }} + runs-on: ubuntu-22.04 strategy: matrix: include: - - os: macos-latest - target: aarch64-apple-darwin + - target: aarch64-apple-darwin arch: arm - - os: macos-15-intel - target: x86_64-apple-darwin + suffix: arm64 + osxcross_prefix: arm64-apple-darwin21.4 + - target: x86_64-apple-darwin arch: intel + suffix: x86_64 + osxcross_prefix: x86_64-apple-darwin21.4 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Setup osxcross + uses: Timmmm/setup-osxcross@v3 + with: + osx-version: "12.3" - name: Build binaries (${{ matrix.arch }}) run: | - make macos PROFILE=release + target_env=$(echo "${{ matrix.target }}" | tr '-' '_') + target_env_upper=$(echo "${target_env}" | tr '[:lower:]' '[:upper:]') + prefix="${{ matrix.osxcross_prefix }}" + osxcross_bin="$(dirname "$(command -v "${prefix}-clang")")" + osxcross_root="$(cd "${osxcross_bin}/.." && pwd)" + sdk_dir="${osxcross_root}/SDK" + if [ -d "${sdk_dir}" ]; then + sdkroot="$(ls -d "${sdk_dir}"/MacOSX*.sdk 2>/dev/null | sort -V | tail -n1)" + if [ -n "${sdkroot}" ]; then + export SDKROOT="${sdkroot}" + fi + fi + export MACOSX_DEPLOYMENT_TARGET="11.0" + ld_path="" + for name in "${prefix}-ld" "${prefix}-ld64" "ld64" "ld"; do + if [ -x "${osxcross_bin}/${name}" ]; then + ld_path="${osxcross_bin}/${name}" + break + fi + done + export "CC_${target_env}=${osxcross_bin}/${prefix}-clang" + export "CXX_${target_env}=${osxcross_bin}/${prefix}-clang++" + export "AR_${target_env}=${osxcross_bin}/${prefix}-ar" + export "RANLIB_${target_env}=${osxcross_bin}/${prefix}-ranlib" + export "CARGO_TARGET_${target_env_upper}_LINKER=${osxcross_bin}/${prefix}-clang" + if [ -n "${ld_path}" ]; then + export "CFLAGS_${target_env}=-fuse-ld=${ld_path}" + export "CXXFLAGS_${target_env}=-fuse-ld=${ld_path}" + export "LDFLAGS_${target_env}=-fuse-ld=${ld_path}" + export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-C link-arg=-fuse-ld=${ld_path}" + fi + cargo build --bins --workspace --release --target ${{ matrix.target }} + mkdir -p dist + cp target/${{ matrix.target }}/release/plumeimpactor dist/plumeimpactor-macos-${{ matrix.suffix }} + cp target/${{ matrix.target }}/release/plumesign dist/plumesign-macos-${{ matrix.suffix }} - name: Upload ${{ matrix.arch }} Slice uses: actions/upload-artifact@v4 diff --git a/Cargo.lock b/Cargo.lock index 8c674ed7..a633a456 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2440,12 +2440,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "float_next_after" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" - [[package]] name = "fnv" version = "1.0.7" @@ -3425,22 +3419,6 @@ dependencies = [ "thiserror 2.0.17", ] -[[package]] -name = "iced_aw" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cc84cc77dcb1c384c60792de025fb4a72e23c3d8c65c4a34691684875fc5403" -dependencies = [ - "cfg-if", - "chrono", - "iced_core", - "iced_fonts", - "iced_widget", - "num-format", - "num-traits", - "web-time", -] - [[package]] name = "iced_core" version = "0.14.0" @@ -3470,29 +3448,6 @@ dependencies = [ "log", ] -[[package]] -name = "iced_fonts" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "214cff7c8499e328774216690e58e315a1a5f8f6fdd1035aed6298e62ffc4c1d" -dependencies = [ - "iced_core", - "iced_fonts_macros", - "iced_widget", -] - -[[package]] -name = "iced_fonts_macros" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef5125e110cb19cd1910a28298661c98c5d9ab02eef43594968352940e8752e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", - "ttf-parser", -] - [[package]] name = "iced_futures" version = "0.14.0" @@ -3522,7 +3477,6 @@ dependencies = [ "image", "kamadak-exif", "log", - "lyon_path", "raw-window-handle", "rustc-hash 2.1.1", "thiserror 2.0.17", @@ -3597,7 +3551,6 @@ dependencies = [ "iced_debug", "iced_graphics", "log", - "lyon", "rustc-hash 2.1.1", "thiserror 2.0.17", "wgpu", @@ -3736,15 +3689,21 @@ dependencies = [ "async-stream", "async_zip", "base64 0.22.1", + "byteorder", + "bytes", "chrono", "crossfire", "futures", "indexmap", + "json", + "ns-keyed-archive", "plist", "rand 0.9.2", + "reqwest 0.12.23", "rsa", "rustls 0.23.32", "serde", + "serde_json", "sha2", "thiserror 2.0.17", "tokio", @@ -4014,6 +3973,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd" + [[package]] name = "kamadak-exif" version = "0.6.1" @@ -4257,58 +4222,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lyon" -version = "1.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbcb7d54d54c8937364c9d41902d066656817dce1e03a44e5533afebd1ef4352" -dependencies = [ - "lyon_algorithms", - "lyon_tessellation", -] - -[[package]] -name = "lyon_algorithms" -version = "1.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c0829e28c4f336396f250d850c3987e16ce6db057ffe047ce0dd54aab6b647" -dependencies = [ - "lyon_path", - "num-traits", -] - -[[package]] -name = "lyon_geom" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e260b6de923e6e47adfedf6243013a7a874684165a6a277594ee3906021b2343" -dependencies = [ - "arrayvec", - "euclid", - "num-traits", -] - -[[package]] -name = "lyon_path" -version = "1.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aeca86bcfd632a15984ba029b539ffb811e0a70bf55e814ef8b0f54f506fdeb" -dependencies = [ - "lyon_geom", - "num-traits", -] - -[[package]] -name = "lyon_tessellation" -version = "1.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3f586142e1280335b1bc89539f7c97dd80f08fc43e9ab1b74ef0a42b04aa353" -dependencies = [ - "float_next_after", - "lyon_path", - "num-traits", -] - [[package]] name = "lzma-rs" version = "0.3.0" @@ -4647,6 +4560,26 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "ns-keyed-archive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc340e0b7a5bb4b06338cafa693d0b585d597b024fff876c5dad385a00d83e7" +dependencies = [ + "nskeyedarchiver_converter", + "plist", +] + +[[package]] +name = "nskeyedarchiver_converter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c53158d1bf37bbbdd165f5220fda8bb5757c89eb700107992152c64c6cad7e" +dependencies = [ + "plist", + "thiserror 2.0.17", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -4691,16 +4624,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "num-format" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" -dependencies = [ - "arrayvec", - "itoa", -] - [[package]] name = "num-integer" version = "0.1.46" @@ -5706,6 +5629,7 @@ version = "1.4.1" name = "plume_store" version = "1.4.1" dependencies = [ + "chrono", "plume_core", "serde", "serde_json", @@ -5723,6 +5647,7 @@ dependencies = [ "log", "plist", "plume_core", + "plume_store", "thiserror 2.0.17", "tokio", "uuid", @@ -5733,11 +5658,11 @@ dependencies = [ name = "plumeimpactor" version = "1.4.1" dependencies = [ + "chrono", "env_logger", "futures", "gtk", "iced", - "iced_aw", "idevice", "image", "open", diff --git a/Cargo.toml b/Cargo.toml index 7f70349c..fe14c300 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,15 +32,7 @@ incremental = true # Improves re-compile times tokio = { version = "1.43", features = ["full"] } # Application idevice = { git = "https://github.com/PlumeImpactor/plume-idevice", rev = "5e350a", features = [ - "afc", - "installation_proxy", - "debug_proxy", - "rsd", - "tunnel_tcp_stack", - "heartbeat", - "usbmuxd", - "house_arrest", - "pair", + "full" ] } plist = "1.8.0" diff --git a/apps/plumeimpactor/Cargo.toml b/apps/plumeimpactor/Cargo.toml index bb947a17..76de9383 100644 --- a/apps/plumeimpactor/Cargo.toml +++ b/apps/plumeimpactor/Cargo.toml @@ -18,13 +18,15 @@ plume_core = { path = "../../crates/plume_core", features = ["tweaks"] } plume_utils = { path = "../../crates/plume_types" } plume_store = { path = "../../crates/plume_store" } +# TODO: move to workspace +chrono = { version = "0.4.42", features = ["serde"] } + rustls = { version = "0.23.32", features = ["ring"] } iced = { version = "0.14.0", features = ["image", "advanced"] } tray-icon = { version = "0.21.2", default-features = false } image = "0.25" rfd = "0.16.0" -iced_aw = { version = "0.13.0", features = ["selection_list"] } open = "5.3.3" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/apps/plumeimpactor/src/appearance/fonts.rs b/apps/plumeimpactor/src/appearance/fonts.rs new file mode 100644 index 00000000..71fab36e --- /dev/null +++ b/apps/plumeimpactor/src/appearance/fonts.rs @@ -0,0 +1,64 @@ +use iced::widget::{Row, Text}; +use iced::{Alignment::Center, Font, Length::Fixed, font}; +use iced::{Color, Element}; + +use super::THEME_ICON_SIZE; + +pub(crate) fn load_fonts() -> Vec> { + vec![include_bytes!("./plume_icons.ttf").as_slice().into()] +} + +pub(crate) const GEAR: &str = "\u{e800}"; +pub(crate) const CHEVRON_BACK: &str = "\u{e801}"; +pub(crate) const DOWNLOAD: &str = "\u{e802}"; +pub(crate) const STAR: &str = "\u{e803}"; +pub(crate) const WRENCH: &str = "\u{e804}"; +pub(crate) const PLUS: &str = "\u{e805}"; +pub(crate) const MINUS: &str = "\u{e806}"; +pub(crate) const SHARE: &str = "\u{e807}"; +pub(crate) const FILE: &str = "\u{f15b}"; + +pub(crate) fn icon_text( + icon: &'static str, + label: &'static str, + color: Option, +) -> Element<'static, M> { + let icon_font = Font { + family: iced::font::Family::Name("plume_icons".into()), + weight: iced::font::Weight::Normal, + stretch: iced::font::Stretch::Normal, + style: iced::font::Style::Normal, + }; + + let mut row = Row::new().spacing(10).align_y(Center); + + let mut icon_text_widget = Text::new(icon) + .font(icon_font) + .width(Fixed(THEME_ICON_SIZE)); + if let Some(c) = color { + icon_text_widget = icon_text_widget.color(c); + } + row = row.push(icon_text_widget); + + let mut label_widget = Text::new(label); + if let Some(c) = color { + label_widget = label_widget.color(c); + } + row = row.push(label_widget); + + row.into() +} + +pub(crate) fn icon(icon: &'static str) -> Text<'static> { + let icon_font = Font { + family: font::Family::Name("plume_icons".into()), + weight: font::Weight::Normal, + stretch: font::Stretch::Normal, + style: font::Style::Normal, + }; + + Text::new(icon) + .font(icon_font) + .align_x(Center) + .width(Fixed(THEME_ICON_SIZE)) +} diff --git a/apps/plumeimpactor/src/appearance/mod.rs b/apps/plumeimpactor/src/appearance/mod.rs index 47d1a127..f0c4b7d2 100644 --- a/apps/plumeimpactor/src/appearance/mod.rs +++ b/apps/plumeimpactor/src/appearance/mod.rs @@ -1,14 +1,21 @@ use iced::{Color, Theme, color}; mod button; +mod fonts; mod picklist; pub(crate) use button::{p_button, s_button}; +#[allow(unused)] +pub(crate) use fonts::{ + CHEVRON_BACK, DOWNLOAD, FILE, GEAR, MINUS, PLUS, SHARE, STAR, WRENCH, icon, icon_text, + load_fonts, +}; pub(crate) use picklist::s_pick_list; pub(crate) const THEME_CORNER_RADIUS: f32 = 4.0; pub(crate) const THEME_FONT_SIZE: f32 = 12.0; pub(crate) const THEME_PADDING: f32 = 9.0; +pub(crate) const THEME_ICON_SIZE: f32 = 12.0; pub(crate) fn p_font() -> iced::Font { iced::Font { diff --git a/apps/plumeimpactor/src/appearance/plume_icons.ttf b/apps/plumeimpactor/src/appearance/plume_icons.ttf new file mode 100755 index 00000000..2efa7ac8 Binary files /dev/null and b/apps/plumeimpactor/src/appearance/plume_icons.ttf differ diff --git a/apps/plumeimpactor/src/defaults.rs b/apps/plumeimpactor/src/defaults.rs index 4db6528f..b07e7be8 100644 --- a/apps/plumeimpactor/src/defaults.rs +++ b/apps/plumeimpactor/src/defaults.rs @@ -11,6 +11,7 @@ pub(crate) fn default_settings() -> iced::Settings { iced::Settings { default_font: appearance::p_font(), default_text_size: appearance::THEME_FONT_SIZE.into(), + fonts: appearance::load_fonts(), ..Default::default() } } @@ -28,7 +29,7 @@ pub(crate) fn default_window_settings() -> window::Settings { let platform_specific = window::settings::PlatformSpecific::default(); window::Settings { - size: iced::Size::new(555.0, 300.0), + size: iced::Size::new(575.0, 410.0), position: window::Position::Centered, exit_on_close_request: false, resizable: false, diff --git a/apps/plumeimpactor/src/main.rs b/apps/plumeimpactor/src/main.rs index 68c9a85c..c93fc46d 100644 --- a/apps/plumeimpactor/src/main.rs +++ b/apps/plumeimpactor/src/main.rs @@ -1,7 +1,10 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use crate::refresh::spawn_refresh_daemon; + mod appearance; mod defaults; +mod refresh; mod screen; mod subscriptions; mod tray; @@ -18,6 +21,9 @@ fn main() -> iced::Result { gtk::init().expect("GTK init failed"); } + let (_daemon_handle, connected_devices) = spawn_refresh_daemon(); + screen::set_refresh_daemon_devices(connected_devices); + iced::daemon( screen::Impactor::new, screen::Impactor::update, diff --git a/apps/plumeimpactor/src/refresh.rs b/apps/plumeimpactor/src/refresh.rs new file mode 100644 index 00000000..9746ba7f --- /dev/null +++ b/apps/plumeimpactor/src/refresh.rs @@ -0,0 +1,306 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use chrono::Utc; +use plume_core::{ + AnisetteConfiguration, CertificateIdentity, MobileProvision, developer::DeveloperSession, +}; +use plume_store::{AccountStore, RefreshDevice}; +use plume_utils::{Bundle, Device, Signer, SignerMode, SignerOptions}; + +use crate::defaults::get_data_path; + +pub type ConnectedDevices = Arc>>; + +pub struct RefreshDaemon { + store_path: std::path::PathBuf, + connected_devices: ConnectedDevices, + check_interval: Duration, +} + +impl RefreshDaemon { + pub fn new() -> Self { + Self { + store_path: get_data_path().join("accounts.json"), + connected_devices: Arc::new(Mutex::new(HashMap::new())), + check_interval: Duration::from_secs(60 * 30), // Check every 30 minutes + } + } + + pub fn connected_devices(&self) -> ConnectedDevices { + self.connected_devices.clone() + } + + pub fn spawn(self) -> thread::JoinHandle<()> { + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + loop { + if let Err(e) = rt.block_on(self.check_and_refresh()) { + eprintln!("Refresh daemon error: {}", e); + } + + thread::sleep(self.check_interval); + } + }) + } + + async fn check_and_refresh(&self) -> Result<(), String> { + let store = AccountStore::load(&Some(self.store_path.clone())) + .await + .map_err(|e| format!("Failed to load account store: {}", e))?; + + let now = Utc::now(); + + for (udid, refresh_device) in store.refreshes() { + for app in &refresh_device.apps { + if app.scheduled_refresh <= now { + println!("App at {:?} needs refresh for device {}", app.path, udid); + + let device = self.wait_for_device(udid).await?; + + self.refresh_app(&store, refresh_device, app, &device) + .await?; + } + } + } + + Ok(()) + } + + async fn wait_for_device(&self, udid: &str) -> Result { + println!("Waiting for device {} to connect...", udid); + + if let Ok(devices) = self.connected_devices.lock() { + if let Some(device) = devices.get(udid) { + println!("Device {} is already connected", udid); + return Ok(device.clone()); + } + } + + let timeout = Duration::from_secs(60 * 60); // 1 hour timeout + let start = std::time::Instant::now(); + + loop { + if start.elapsed() > timeout { + return Err(format!("Timeout waiting for device {} to connect", udid)); + } + + if let Ok(devices) = self.connected_devices.lock() { + if let Some(device) = devices.get(udid) { + println!("Device {} connected", udid); + return Ok(device.clone()); + } + } + + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + + pub async fn refresh_app( + &self, + store: &AccountStore, + refresh_device: &RefreshDevice, + app: &plume_store::RefreshApp, + device: &Device, + ) -> Result<(), String> { + println!("Starting refresh for app at {:?}", app.path); + + let account = store + .get_account(&refresh_device.account) + .ok_or_else(|| format!("Account {} not found", refresh_device.account))?; + + let session = DeveloperSession::new( + account.adsid().clone(), + account.xcode_gs_token().clone(), + AnisetteConfiguration::default().set_configuration_path(get_data_path()), + ) + .await + .map_err(|e| format!("Failed to create session: {}", e))?; + + let teams_response = session + .qh_list_teams() + .await + .map_err(|e| format!("Failed to list teams: {}", e))?; + + if teams_response.teams.is_empty() { + return Err("No teams available for this account".to_string()); + } + + let team_id = if account.team_id().is_empty() { + &teams_response.teams[0].team_id + } else { + account.team_id() + }; + + let identity_is_new = { + let identity = + CertificateIdentity::new_with_session(&session, get_data_path(), None, team_id) + .await + .map_err(|e| format!("Failed to create identity: {}", e))?; + identity.new + }; + + let is_installed = if let Some(bundle_id) = app.bundle_id.as_deref() { + device + .is_app_installed(bundle_id) + .await + .map_err(|e| format!("Failed to check if app is installed: {}", e))? + } else { + false + }; + + let needs_reinstall = device.is_mac || identity_is_new || !is_installed; + + if needs_reinstall { + println!("Resigning and reinstalling app..."); + self.resign_and_reinstall(app, device, &session, team_id) + .await?; + } else { + println!("Certificate exists and app is installed, updating provisioning profiles..."); + self.update_provisioning_profiles(app, device, &session, team_id) + .await?; + } + + self.update_refresh_schedule(store, refresh_device, app) + .await?; + + println!("Successfully refreshed app at {:?}", app.path); + + Ok(()) + } + + async fn resign_and_reinstall( + &self, + app: &plume_store::RefreshApp, + device: &Device, + session: &DeveloperSession, + team_id: &str, + ) -> Result<(), String> { + let team_id_string = team_id.to_string(); + session + .qh_ensure_device(&team_id_string, &device.name, &device.udid) + .await + .map_err(|e| format!("Failed to ensure device: {}", e))?; + + let bundle = + Bundle::new(app.path.clone()).map_err(|e| format!("Failed to create bundle: {}", e))?; + + let options = SignerOptions { + mode: SignerMode::Pem, + ..Default::default() + }; + + let team_id_string = team_id.to_string(); + let signing_identity = + CertificateIdentity::new_with_session(session, get_data_path(), None, &team_id_string) + .await + .map_err(|e| format!("Failed to create signing identity: {}", e))?; + + let mut signer = Signer::new(Some(signing_identity), options); + + signer + .register_bundle(&bundle, session, &team_id.to_string(), true) + .await + .map_err(|e| format!("Failed to register bundle: {}", e))?; + + signer + .sign_bundle(&bundle) + .await + .map_err(|e| format!("Failed to sign bundle: {}", e))?; + + if !device.is_mac { + device + .install_app(&app.path, |_| async {}) + .await + .map_err(|e| format!("Failed to install app: {}", e))?; + } else { + plume_utils::install_app_mac(&app.path) + .await + .map_err(|e| format!("Failed to install app on Mac: {}", e))?; + } + + Ok(()) + } + + async fn update_provisioning_profiles( + &self, + app: &plume_store::RefreshApp, + device: &Device, + session: &DeveloperSession, + team_id: &str, + ) -> Result<(), String> { + let bundle = + Bundle::new(app.path.clone()).map_err(|e| format!("Failed to create bundle: {}", e))?; + + let options = SignerOptions { + mode: SignerMode::Pem, + ..Default::default() + }; + + let mut signer = Signer::new(None, options); + + signer + .register_bundle(&bundle, session, &team_id.to_string(), true) + .await + .map_err(|e| format!("Failed to register bundle: {}", e))?; + + for provision in &signer.provisioning_files { + device + .install_profile(provision) + .await + .map_err(|e| format!("Failed to install profile: {}", e))?; + } + + Ok(()) + } + + async fn update_refresh_schedule( + &self, + store: &AccountStore, + refresh_device: &RefreshDevice, + app: &plume_store::RefreshApp, + ) -> Result<(), String> { + let embedded_prov_path = app.path.join("embedded.mobileprovision"); + if !embedded_prov_path.exists() { + return Err("embedded.mobileprovision not found".to_string()); + } + + let provision = MobileProvision::load_with_path(&embedded_prov_path) + .map_err(|e| format!("Failed to load mobile provision: {}", e))?; + + let expiration_date = provision.expiration_date().clone(); + let scheduled_refresh = expiration_date + .to_xml_format() + .parse::>() + .unwrap_or_else(|_| Utc::now() + chrono::Duration::days(6)); + let scheduled_refresh = scheduled_refresh - chrono::Duration::days(1); + + let mut store = store.clone(); + let mut updated_device = refresh_device.clone(); + + if let Some(existing_app) = updated_device.apps.iter_mut().find(|a| a.path == app.path) { + existing_app.scheduled_refresh = scheduled_refresh; + } + + store + .add_or_update_refresh_device_sync(updated_device) + .map_err(|e| format!("Failed to update refresh schedule: {}", e))?; + + println!("Next refresh scheduled for: {}", scheduled_refresh); + + Ok(()) + } +} + +pub fn spawn_refresh_daemon() -> (thread::JoinHandle<()>, ConnectedDevices) { + let daemon = RefreshDaemon::new(); + let devices = daemon.connected_devices(); + let handle = daemon.spawn(); + (handle, devices) +} diff --git a/apps/plumeimpactor/src/screen/general.rs b/apps/plumeimpactor/src/screen/general.rs index 2df66dbf..f5dbe78a 100644 --- a/apps/plumeimpactor/src/screen/general.rs +++ b/apps/plumeimpactor/src/screen/general.rs @@ -6,7 +6,7 @@ use crate::appearance; use std::sync::OnceLock; const INSTALL_IMAGE: &[u8] = include_bytes!("./general.png"); -const INSTALL_IMAGE_HEIGHT: f32 = 100.0; +const INSTALL_IMAGE_HEIGHT: f32 = 130.0; #[derive(Debug, Clone)] pub enum Message { @@ -31,11 +31,17 @@ impl GeneralScreen { pub fn update(&mut self, message: Message) -> Task { match message { Message::OpenFileDialog => { - let path = rfd::FileDialog::new() - .add_filter("iOS App Package", &["ipa", "tipa"]) - .set_title("Select IPA/TIPA file") - .pick_file(); - Task::done(Message::FileSelected(path)) + return Task::perform( + async { + rfd::AsyncFileDialog::new() + .add_filter("iOS App Package", &["ipa", "tipa"]) + .set_title("Select IPA/TIPA file") + .pick_file() + .await + .map(|file| file.path().to_path_buf()) + }, + Message::FileSelected, + ); } Message::FileSelected(path) => { if let Some(path) = path { @@ -70,12 +76,23 @@ impl GeneralScreen { let image_handle = INSTALL_IMAGE_HANDLE.get_or_init(|| image::Handle::from_bytes(INSTALL_IMAGE)); - let screen_content = image(image_handle.clone()).height(INSTALL_IMAGE_HEIGHT); + let screen_content = column![ + container(text("")).height(appearance::THEME_PADDING * 2.0), + image(image_handle.clone()).height(INSTALL_IMAGE_HEIGHT), + text("Drag & drop an IPA here") + .size(appearance::THEME_FONT_SIZE + 7.0) + .color(Color::from_rgba(1.0, 1.0, 1.0, 0.3)) + ] + .spacing(10) + .align_x(Center); - let footer_links = - button(text("Give me a ⭐ star :3").color(Color::from_rgb(1.0, 0.75, 0.8))) - .on_press(Message::OpenGitHub) - .style(iced::widget::button::text); + let footer_links = button(appearance::icon_text( + appearance::STAR, + "Star us on GitHub!", + Some(Color::from_rgb(1.0, 0.75, 0.8)), + )) + .on_press(Message::OpenGitHub) + .style(iced::widget::button::text); column![ container(screen_content).center(Fill).height(Fill), @@ -88,14 +105,18 @@ impl GeneralScreen { fn view_buttons(&self) -> Element<'_, Message> { container( row![ - button(text("Device Utilities").align_x(Center)) + button(appearance::icon_text(appearance::WRENCH, "Utilities", None)) .on_press(Message::NavigateToUtilities) .width(Fill) .style(appearance::s_button), - button(text("Import .ipa / .tipa").align_x(Center)) - .on_press(Message::OpenFileDialog) - .width(Fill) - .style(appearance::s_button) + button(appearance::icon_text( + appearance::DOWNLOAD, + "Import .ipa / .tipa", + None + )) + .on_press(Message::OpenFileDialog) + .width(Fill) + .style(appearance::s_button) ] .spacing(appearance::THEME_PADDING), ) diff --git a/apps/plumeimpactor/src/screen/mod.rs b/apps/plumeimpactor/src/screen/mod.rs index 10c7c285..916c09a2 100644 --- a/apps/plumeimpactor/src/screen/mod.rs +++ b/apps/plumeimpactor/src/screen/mod.rs @@ -1,11 +1,10 @@ pub(crate) mod general; mod package; mod progress; -mod settings; +pub(crate) mod settings; mod utilties; mod windows; -use iced::Alignment::Center; use iced::Length::Fill; use iced::widget::{button, container, pick_list, row, text}; use iced::window; @@ -17,7 +16,14 @@ use plume_utils::{Device, SignerOptions}; use crate::subscriptions; use crate::tray::ImpactorTray; use crate::{appearance, defaults}; -use windows::{login_window, team_selection_window}; +use windows::login_window; + +static REFRESH_DAEMON_DEVICES: std::sync::OnceLock = + std::sync::OnceLock::new(); + +pub fn set_refresh_daemon_devices(devices: crate::refresh::ConnectedDevices) { + let _ = REFRESH_DAEMON_DEVICES.set(devices); +} #[derive(Debug, Clone)] #[allow(dead_code)] @@ -38,6 +44,17 @@ pub enum Message { #[cfg(target_os = "linux")] GtkTick, + // Refresh operations + RefreshAppNow { + udid: String, + app_path: String, + }, + ForgetApp { + udid: String, + app_path: String, + }, + UpdateTrayMenu, + // Window management ShowWindow, HideWindow, @@ -46,10 +63,6 @@ pub enum Message { // Login window LoginWindowMessage(window::Id, login_window::Message), - // Team selection window - TeamSelectionWindowMessage(window::Id, team_selection_window::Message), - TeamSelectionRequested(Vec), - // Screen-specific messages MainScreen(general::Message), UtilitiesScreen(utilties::Message), @@ -70,11 +83,6 @@ pub struct Impactor { main_window: Option, account_store: Option, login_windows: std::collections::HashMap, - team_selection_windows: - std::collections::HashMap, - team_selection_listener: - Option>>>>, - team_response_sender: Option>>, pending_installation: bool, } @@ -97,7 +105,9 @@ enum ImpactorScreen { impl Impactor { pub fn new() -> (Self, Task) { - let tray = ImpactorTray::new(); + let mut tray = ImpactorTray::new(); + let store = Self::init_account_store_sync(); + tray.update_refresh_apps(&store); let (id, open_task) = window::open(defaults::default_window_settings()); ( @@ -108,11 +118,8 @@ impl Impactor { selected_device: None, tray: Some(tray), main_window: Some(id), - account_store: Some(Self::init_account_store_sync()), + account_store: Some(store), login_windows: std::collections::HashMap::new(), - team_selection_windows: std::collections::HashMap::new(), - team_selection_listener: None, - team_response_sender: None, pending_installation: false, }, open_task.discard(), @@ -121,9 +128,7 @@ impl Impactor { fn init_account_store_sync() -> AccountStore { let path = defaults::get_data_path().join("accounts.json"); - tokio::runtime::Runtime::new() - .unwrap() - .block_on(async { AccountStore::load(&Some(path)).await.unwrap_or_default() }) + AccountStore::load_sync(&Some(path)).unwrap_or_default() } pub fn update(&mut self, message: Message) -> Task { @@ -153,6 +158,12 @@ impl Impactor { } } + if let Some(daemon_devices) = REFRESH_DAEMON_DEVICES.get() { + if let Ok(mut devices) = daemon_devices.lock() { + devices.insert(device.udid.clone(), device.clone()); + } + } + if let ImpactorScreen::Utilities(_) = self.current_screen { self.current_screen = ImpactorScreen::Utilities( utilties::UtilitiesScreen::new(self.selected_device.clone()), @@ -163,12 +174,24 @@ impl Impactor { Task::none() } Message::DeviceDisconnected(id) => { + let udid = self + .devices + .iter() + .find(|d| d.device_id == id) + .map(|d| d.udid.clone()); + self.devices.retain(|d| d.device_id != id); if self.selected_device.as_ref().map(|d| d.device_id) == Some(id) { self.selected_device = self.devices.first().cloned(); } + if let (Some(udid), Some(daemon_devices)) = (udid, REFRESH_DAEMON_DEVICES.get()) { + if let Ok(mut devices) = daemon_devices.lock() { + devices.remove(&udid); + } + } + if let ImpactorScreen::Utilities(_) = self.current_screen { self.current_screen = ImpactorScreen::Utilities( utilties::UtilitiesScreen::new(self.selected_device.clone()), @@ -234,10 +257,23 @@ impl Impactor { Message::TrayIconClicked => Task::done(Message::ShowWindow), Message::TrayMenuClicked(id) => { if let Some(tray) = &self.tray { - if tray.is_quit_clicked(&id) { - Task::done(Message::Quit) - } else if tray.is_show_clicked(&id) { - Task::done(Message::ShowWindow) + if let Some(action) = tray.get_action(&id) { + match action { + crate::tray::TrayAction::Show => Task::done(Message::ShowWindow), + crate::tray::TrayAction::Quit => Task::done(Message::Quit), + crate::tray::TrayAction::RefreshApp { udid, app_path } => { + Task::done(Message::RefreshAppNow { + udid: udid.clone(), + app_path: app_path.clone(), + }) + } + crate::tray::TrayAction::ForgetApp { udid, app_path } => { + Task::done(Message::ForgetApp { + udid: udid.clone(), + app_path: app_path.clone(), + }) + } + } } else { Task::none() } @@ -285,10 +321,8 @@ impl Impactor { self.account_store = Some(Self::init_account_store_sync()); if let ImpactorScreen::Settings(_) = self.current_screen { - let account_store = Some(Self::init_account_store_sync()); - self.current_screen = ImpactorScreen::Settings( - settings::SettingsScreen::new(account_store), - ); + self.current_screen = + ImpactorScreen::Settings(settings::SettingsScreen::new()); } if self.pending_installation { @@ -311,51 +345,13 @@ impl Impactor { Task::none() } } - Message::TeamSelectionWindowMessage(id, msg) => { - if let Some(team_window) = self.team_selection_windows.get_mut(&id) { - let task = team_window.update(msg.clone()); - - match msg { - team_selection_window::Message::Confirm => { - if let Some(selected_index) = team_window.selected_index { - self.team_selection_windows.remove(&id); - - if let Some(ref sender) = self.team_response_sender { - let _ = sender.send(Ok(selected_index)); - } - - return window::close(id); - } - task.map(move |msg| Message::TeamSelectionWindowMessage(id, msg)) - } - team_selection_window::Message::Cancel => { - self.team_selection_windows.remove(&id); - - if let Some(ref sender) = self.team_response_sender { - let _ = sender.send(Err("Team selection cancelled".to_string())); - } - - window::close(id) - } - _ => task.map(move |msg| Message::TeamSelectionWindowMessage(id, msg)), - } - } else { - Task::none() - } - } - Message::TeamSelectionRequested(team_names) => { - let (window_id, open_window) = - window::open(team_selection_window::TeamSelectionWindow::settings()); - let team_window = team_selection_window::TeamSelectionWindow::new(team_names); - self.team_selection_windows.insert(window_id, team_window); - open_window.discard() - } Message::MainScreen(msg) => { if let ImpactorScreen::Main(ref mut screen) = self.current_screen { let task = screen.update(msg.clone()).map(Message::MainScreen); if let general::Message::NavigateToInstaller(package) = msg { - let options = SignerOptions::default(); + let mut options = SignerOptions::default(); + package.load_into_signer_options(&mut options); self.current_screen = ImpactorScreen::Installer( package::PackageScreen::new(Some(package), options), ); @@ -389,11 +385,96 @@ impl Impactor { self.login_windows.insert(id, login_window); task.map(move |msg| Message::LoginWindowMessage(id, msg)) } - _ => { - let task = screen.update(msg); - self.account_store = Some(Self::init_account_store_sync()); - task.map(Message::SettingsScreen) + settings::Message::SelectAccount(index) => { + if let Some(store) = &mut self.account_store { + let mut emails: Vec<_> = store.accounts().keys().cloned().collect(); + emails.sort(); + if let Some(email) = emails.get(index) { + let _ = store.account_select_sync(email); + } + } + Task::none() + } + settings::Message::RemoveAccount(index) => { + if let Some(store) = &mut self.account_store { + let mut emails: Vec<_> = store.accounts().keys().cloned().collect(); + emails.sort(); + if let Some(email) = emails.get(index) { + let _ = store.accounts_remove_sync(email); + } + } + Task::none() } + settings::Message::ExportP12 => { + if let Some(account) = self + .account_store + .as_ref() + .and_then(|s| s.selected_account().cloned()) + { + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let _ = rt.block_on(async move { + crate::subscriptions::export_certificate(account).await + }); + }); + } + Task::none() + } + settings::Message::FetchTeams(ref email) => { + if let Some(account_store) = &self.account_store { + if let Some(account) = account_store.accounts().get(email) { + let account_clone = account.clone(); + let email_clone = email.clone(); + + return Task::perform( + async move { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async move { + crate::subscriptions::fetch_teams( + &account_clone, + ) + .await + .unwrap_or_else(|e| { + eprintln!("Failed to fetch teams: {}", e); + Vec::new() + }) + }); + let _ = tx.send(result); + }); + + rx.recv().unwrap_or_default() + }, + move |teams| { + Message::SettingsScreen(settings::Message::TeamsLoaded( + email_clone, + teams, + )) + }, + ); + } + } + screen.update(msg).map(Message::SettingsScreen) + } + settings::Message::SelectTeam(ref email, ref team_id) => { + if let Some(store) = &mut self.account_store { + if let Err(e) = + store.update_account_team_sync(email, team_id.clone()) + { + eprintln!("Failed to update team: {:?}", e); + } else { + self.account_store = Some(Self::init_account_store_sync()); + } + } + screen.update(msg).map(Message::SettingsScreen) + } + _ => screen.update(msg).map(Message::SettingsScreen), } } else { Task::none() @@ -438,12 +519,129 @@ impl Impactor { if let ImpactorScreen::Progress(ref mut screen) = self.current_screen { match msg { progress::Message::Back => Task::done(Message::PreviousScreen), + progress::Message::InstallationFinished => { + Task::done(Message::UpdateTrayMenu) + } _ => screen.update(msg).map(Message::ProgressScreen), } } else { Task::none() } } + Message::RefreshAppNow { udid, app_path } => { + if let Some(daemon_devices) = REFRESH_DAEMON_DEVICES.get() { + let daemon_devices = daemon_devices.clone(); + let store_opt = self.account_store.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async move { + if let Some(store) = store_opt { + if let Some(refresh_device) = store.get_refresh_device(&udid) { + if let Some(app) = refresh_device + .apps + .iter() + .find(|a| a.path.to_string_lossy() == app_path) + { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(60); + + let device_opt = loop { + if start.elapsed() > timeout { + eprintln!("Timeout waiting for device {}", udid); + break None; + } + + if let Ok(devices) = daemon_devices.lock() { + if let Some(dev) = devices.get(&udid) { + break Some(dev.clone()); + } + } + tokio::time::sleep(std::time::Duration::from_secs(1)) + .await; + }; + + if let Some(device) = device_opt { + let daemon = crate::refresh::RefreshDaemon::new(); + if let Err(e) = daemon + .refresh_app(&store, refresh_device, app, &device) + .await + { + eprintln!("Failed to refresh app: {}", e); + } else { + println!( + "Successfully refreshed app at {:?}", + app.path + ); + } + } + } + } + } + }); + }); + } + Task::done(Message::UpdateTrayMenu) + } + Message::ForgetApp { udid, app_path } => { + if let Some(store) = &mut self.account_store { + if let Some(mut refresh_device) = store.get_refresh_device(&udid).cloned() { + if let Some(app) = refresh_device + .apps + .iter() + .find(|a| a.path.to_string_lossy() == app_path) + { + let app_path_buf = app.path.clone(); + std::thread::spawn(move || { + if app_path_buf.exists() { + if let Err(e) = std::fs::remove_dir_all(&app_path_buf) { + eprintln!( + "Failed to delete app at {:?}: {}", + app_path_buf, e + ); + } else { + println!("Deleted app at {:?}", app_path_buf); + } + } + }); + } + + refresh_device + .apps + .retain(|a| a.path.to_string_lossy() != app_path); + + if refresh_device.apps.is_empty() { + let _ = store.remove_refresh_device_sync(&udid); + } else { + let _ = store.add_or_update_refresh_device_sync(refresh_device); + } + + self.account_store = Some(Self::init_account_store_sync()); + } + } + Task::done(Message::UpdateTrayMenu) + } + Message::UpdateTrayMenu => { + self.account_store = Some(Self::init_account_store_sync()); + + if let Some(store) = &self.account_store { + match &mut self.tray { + Some(existing_tray) => { + existing_tray.update_refresh_apps(&store); + } + None => { + let mut new_tray = ImpactorTray::new(); + new_tray.update_refresh_apps(&store); + self.tray = Some(new_tray); + } + } + } + Task::none() + } Message::StartInstallation => Task::none(), } } @@ -473,13 +671,6 @@ impl Impactor { Subscription::none() }; - let team_selection_subscription = if let Some(ref listener) = self.team_selection_listener { - subscriptions::team_selection_listener(listener.clone()) - .map(Message::TeamSelectionRequested) - } else { - Subscription::none() - }; - let close_subscription = iced::event::listen_with(|event, _status, _id| { if let iced::Event::Window(window::Event::CloseRequested) = event { return Some(Message::HideWindow); @@ -492,7 +683,6 @@ impl Impactor { tray_subscription, hover_subscription, progress_subscription, - team_selection_subscription, close_subscription, ]) } @@ -506,12 +696,6 @@ impl Impactor { .map(move |msg| Message::LoginWindowMessage(window_id, msg)); } - if let Some(team_window) = self.team_selection_windows.get(&window_id) { - return team_window - .view() - .map(move |msg| Message::TeamSelectionWindowMessage(window_id, msg)); - } - let has_device = self.selected_device.is_some(); let screen_content = self.view_current_screen(has_device); let top_bar = self.view_top_bar(); @@ -525,7 +709,9 @@ impl Impactor { match &self.current_screen { ImpactorScreen::Main(screen) => screen.view().map(Message::MainScreen), ImpactorScreen::Utilities(screen) => screen.view().map(Message::UtilitiesScreen), - ImpactorScreen::Settings(screen) => screen.view().map(Message::SettingsScreen), + ImpactorScreen::Settings(screen) => screen + .view(&self.account_store) + .map(Message::SettingsScreen), ImpactorScreen::Installer(screen) => { screen.view(has_device).map(Message::InstallerScreen) } @@ -542,15 +728,15 @@ impl Impactor { .unwrap_or("No Device"); let right_button = if matches!(self.current_screen, ImpactorScreen::Settings(_)) { - button(text("←").align_x(Center)) + button(appearance::icon(appearance::CHEVRON_BACK)) .on_press(Message::PreviousScreen) .style(appearance::s_button) } else if matches!(self.current_screen, ImpactorScreen::Utilities(_)) { - button(text("←").align_x(Center)) + button(appearance::icon(appearance::CHEVRON_BACK)) .on_press(Message::PreviousScreen) .style(appearance::s_button) } else { - button(text("≡").align_x(Center)) + button(appearance::icon(appearance::GEAR)) .style(appearance::s_button) .on_press(Message::NavigateToScreen(ImpactorScreenType::Settings)) }; @@ -585,9 +771,7 @@ impl Impactor { )); } ImpactorScreenType::Settings => { - let account_store = Some(Self::init_account_store_sync()); - self.current_screen = - ImpactorScreen::Settings(settings::SettingsScreen::new(account_store)); + self.current_screen = ImpactorScreen::Settings(settings::SettingsScreen::new()); } ImpactorScreenType::Progress => { self.current_screen = ImpactorScreen::Progress(progress::ProgressScreen::new()); @@ -608,18 +792,11 @@ impl Impactor { .account_store .as_ref() .and_then(|s| s.selected_account().cloned()); + let mut store = self.account_store.clone(); let (tx, rx) = std::sync::mpsc::channel(); let progress_rx = std::sync::Arc::new(std::sync::Mutex::new(rx)); - let (team_tx, team_rx) = std::sync::mpsc::channel::>(); - let (team_response_tx, team_response_rx) = - std::sync::mpsc::channel::>(); - - self.team_selection_listener = - Some(std::sync::Arc::new(std::sync::Mutex::new(team_rx))); - self.team_response_sender = Some(team_response_tx); - let mut progress_screen = progress::ProgressScreen::new(); progress_screen.start_installation(progress_rx.clone()); self.current_screen = ImpactorScreen::Progress(progress_screen); @@ -633,9 +810,8 @@ impl Impactor { device.as_ref(), &options, account.as_ref(), + store.as_mut(), &tx, - Some(team_tx), - Some(team_response_rx), ) .await { diff --git a/apps/plumeimpactor/src/screen/package.rs b/apps/plumeimpactor/src/screen/package.rs index 11d84e85..95b8932b 100644 --- a/apps/plumeimpactor/src/screen/package.rs +++ b/apps/plumeimpactor/src/screen/package.rs @@ -1,7 +1,7 @@ use iced::widget::{ button, checkbox, column, container, pick_list, row, scrollable, text, text_input, }; -use iced::{Alignment, Center, Element, Fill, Length, Task}; +use iced::{Alignment, Center, Element, Fill, Task}; use plume_utils::{Package, PlistInfoTrait, SignerInstallMode, SignerMode, SignerOptions}; use crate::appearance; @@ -18,6 +18,8 @@ pub enum Message { ToggleProMotion(bool), ToggleSingleProfile(bool), ToggleLiquidGlass(bool), + ToggleRefresh(bool), + ToggleElleKit(bool), UpdateSignerMode(SignerMode), UpdateInstallMode(SignerInstallMode), AddTweak, @@ -113,6 +115,14 @@ impl PackageScreen { self.options.features.support_liquid_glass = value; Task::none() } + Message::ToggleRefresh(value) => { + self.options.refresh = value; + Task::none() + } + Message::ToggleElleKit(value) => { + self.options.features.support_ellekit = value; + Task::none() + } Message::UpdateSignerMode(mode) => { self.options.mode = mode; Task::none() @@ -227,12 +237,12 @@ impl PackageScreen { text("Tweaks:").size(12), self.view_tweaks(), row![ - button(text("Add Tweak").align_x(Center)) + button(appearance::icon_text(appearance::PLUS, "Add Tweak", None)) .on_press(Message::AddTweak) - .style(appearance::p_button), - button(text("Add Bundle").align_x(Center)) + .style(appearance::s_button), + button(appearance::icon_text(appearance::PLUS, "Add Bundle", None)) .on_press(Message::AddBundle) - .style(appearance::p_button), + .style(appearance::s_button), ] .spacing(8), ] @@ -266,6 +276,12 @@ impl PackageScreen { checkbox(self.options.features.support_liquid_glass) .label("Force Liquid Glass (26+)") .on_toggle(Message::ToggleLiquidGlass), + checkbox(self.options.features.support_ellekit) + .label("Replace Substrate with ElleKit") + .on_toggle(Message::ToggleElleKit), + checkbox(self.options.refresh) + .label("Auto Refresh [BETA]") + .on_toggle(Message::ToggleRefresh), text("Mode:").size(12), pick_list( &[SignerInstallMode::Install, SignerInstallMode::Export][..], @@ -296,14 +312,22 @@ impl PackageScreen { container( row![ - button(text("Back").align_x(Center)) - .on_press(Message::Back) - .style(appearance::s_button) - .width(Fill), - button(text(button_label).align_x(Center)) - .on_press_maybe(button_enabled.then_some(Message::RequestInstallation)) - .style(appearance::p_button) - .width(Fill), + button(appearance::icon_text( + appearance::CHEVRON_BACK, + "Back", + None + )) + .on_press(Message::Back) + .style(appearance::s_button) + .width(Fill), + button(appearance::icon_text( + appearance::DOWNLOAD, + button_label, + None + )) + .on_press_maybe(button_enabled.then_some(Message::RequestInstallation)) + .style(appearance::p_button) + .width(Fill), ] .spacing(appearance::THEME_PADDING), ) @@ -326,9 +350,9 @@ impl PackageScreen { text(tweak.file_name().and_then(|n| n.to_str()).unwrap_or("???")) .size(12) .width(Fill), - button(text("Remove").align_x(Center)) + button(appearance::icon(appearance::MINUS)) .on_press(Message::RemoveTweak(i)) - .style(appearance::p_button) + .style(appearance::s_button) .padding(6) ] .spacing(8) @@ -337,7 +361,7 @@ impl PackageScreen { tweak_list = tweak_list.push(tweak_row); } - scrollable(tweak_list).height(Length::Fixed(100.0)).into() + scrollable(tweak_list).into() } else { text("No tweaks added").size(12).into() } diff --git a/apps/plumeimpactor/src/screen/progress.rs b/apps/plumeimpactor/src/screen/progress.rs index 1a0a2447..7d00003a 100644 --- a/apps/plumeimpactor/src/screen/progress.rs +++ b/apps/plumeimpactor/src/screen/progress.rs @@ -46,6 +46,14 @@ impl ProgressScreen { pub fn update(&mut self, message: Message) -> Task { match message { Message::InstallationProgress(status, progress) => { + let mut status = status; + let mut progress = progress; + + if progress >= 100 { + progress = 100; + status = "Finished!".to_string(); + } + self.status = status.clone(); self.progress = progress; @@ -64,6 +72,8 @@ impl ProgressScreen { } else if progress >= 100 { self.progress_rx = None; self.is_installing = false; + + return Task::done(Message::InstallationFinished); } Task::none() @@ -115,9 +125,14 @@ impl ProgressScreen { fn view_buttons(&self) -> Element<'_, Message> { container(row![ - button(text("Back")) - .on_press_maybe((!self.is_installing).then_some(Message::Back)) - .style(appearance::s_button) + button(appearance::icon_text( + appearance::CHEVRON_BACK, + "Back", + None + )) + .on_press_maybe((!self.is_installing).then_some(Message::Back)) + .width(Fill) + .style(appearance::s_button) ]) .width(Fill) .into() diff --git a/apps/plumeimpactor/src/screen/settings.rs b/apps/plumeimpactor/src/screen/settings.rs index 0d691e1d..fdfcbd79 100644 --- a/apps/plumeimpactor/src/screen/settings.rs +++ b/apps/plumeimpactor/src/screen/settings.rs @@ -1,75 +1,66 @@ -use iced::widget::{button, column, container, row, text}; -use iced::{Alignment, Center, Element, Fill, Task}; -use iced_aw::SelectionList; +use std::collections::HashMap; + +use iced::widget::{button, column, container, pick_list, row, scrollable, text}; +use iced::{Alignment, Element, Fill, Task}; use plume_store::AccountStore; use crate::appearance; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Team { + pub name: String, + pub id: String, +} + +impl std::fmt::Display for Team { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.name, self.id) + } +} + #[derive(Debug, Clone)] pub enum Message { ShowLogin, SelectAccount(usize), RemoveAccount(usize), ExportP12, + SelectTeam(String, String), + FetchTeams(String), + TeamsLoaded(String, Vec), } #[derive(Debug)] pub struct SettingsScreen { - pub account_store: Option, + teams: HashMap>, + loading_teams: Option, } impl SettingsScreen { - pub fn new(account_store: Option) -> Self { - Self { account_store } + pub fn new() -> Self { + Self { + teams: HashMap::new(), + loading_teams: None, + } } pub fn update(&mut self, message: Message) -> Task { match message { - Message::SelectAccount(index) => { - if let Some(store) = &mut self.account_store { - let mut emails: Vec<_> = store.accounts().keys().cloned().collect(); - emails.sort(); - if let Some(email) = emails.get(index) { - let _ = store.account_select_sync(email); - } - } + Message::FetchTeams(ref email) => { + self.loading_teams = Some(email.clone()); Task::none() } - Message::RemoveAccount(index) => { - if let Some(store) = &mut self.account_store { - let mut emails: Vec<_> = store.accounts().keys().cloned().collect(); - emails.sort(); - if let Some(email) = emails.get(index) { - let _ = store.accounts_remove_sync(email); - } - } - Task::none() - } - Message::ExportP12 => { - if let Some(account) = self - .account_store - .as_ref() - .and_then(|s| s.selected_account().cloned()) - { - std::thread::spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - - let _ = rt.block_on(async move { - crate::subscriptions::export_certificate(account).await - }); - }); - } + Message::TeamsLoaded(email, teams) => { + self.teams.insert(email, teams); + self.loading_teams = None; Task::none() } - Message::ShowLogin => Task::none(), + Message::SelectTeam(_, _) => Task::none(), + _ => Task::none(), } } - pub fn view(&self) -> Element<'_, Message> { - let Some(store) = &self.account_store else { + pub fn view<'a>(&'a self, account_store: &'a Option) -> Element<'a, Message> { + let Some(store) = account_store else { return column![text("Loading accounts...")] .spacing(appearance::THEME_PADDING) .padding(appearance::THEME_PADDING) @@ -86,67 +77,88 @@ impl SettingsScreen { let mut content = column![].spacing(appearance::THEME_PADDING); if !accounts.is_empty() { - content = content.push(self.view_account_list(&accounts, selected_index)); + let account_list = accounts.iter().enumerate().fold( + column![].spacing(appearance::THEME_PADDING), + |content, (index, (email, account))| { + let marker = if Some(index) == selected_index { + "[✓] " + } else { + "[ ] " + }; + let style = if Some(index) == selected_index { + appearance::p_button + } else { + appearance::s_button + }; + + let account_button = button( + text(format!("{}{}", marker, account.email())) + .size(appearance::THEME_FONT_SIZE) + .align_x(Alignment::Start), + ) + .on_press(Message::SelectAccount(index)) + .style(style) + .width(Fill); + + let mut account_row = row![account_button].spacing(appearance::THEME_PADDING); + + if Some(index) == selected_index { + let team_id = account.team_id(); + let is_loading = self.loading_teams.as_ref() == Some(email); + let teams = self.teams.get(*email).cloned().unwrap_or_default(); + + let current_team = if !team_id.is_empty() { + teams.iter().find(|t| t.id == *team_id).cloned() + } else { + None + }; + + let placeholder = if is_loading { + "Loading teams...".to_string() + } else if !team_id.is_empty() { + team_id.to_string() + } else { + "Select team...".to_string() + }; + + let email_owned = email.to_string(); + + let team_pick = pick_list(teams, current_team, move |selected: Team| { + Message::SelectTeam(email_owned.clone(), selected.id) + }) + .placeholder(placeholder) + .on_open(Message::FetchTeams(email.to_string())) + .style(appearance::s_pick_list); + + account_row = account_row.push(team_pick); + } + + content.push(account_row) + }, + ); + + content = content.push(container(scrollable(account_list)).height(Fill).style( + |theme: &iced::Theme| container::Style { + border: iced::Border { + width: 1.0, + color: theme.palette().background.scale_alpha(0.5), + radius: appearance::THEME_CORNER_RADIUS.into(), + }, + ..Default::default() + }, + )); } else { content = content.push(text("No accounts added yet")); } content = content.push(self.view_account_buttons(selected_index)); - content.into() - } - fn view_account_list( - &self, - accounts: &[(&String, &plume_store::GsaAccount)], - selected_index: Option, - ) -> Element<'_, Message> { - let account_labels: &'static [String] = Box::leak( - accounts - .iter() - .enumerate() - .map(|(i, (_, account))| { - let name = if !account.first_name().is_empty() { - format!("{} ({})", account.first_name(), account.email()) - } else { - account.email().to_string() - }; - let marker = if Some(i) == selected_index { - " [✓] " - } else { - " [ ] " - }; - format!("{}{}", marker, name) - }) - .collect::>() - .into_boxed_slice(), - ); - - let selection_list = SelectionList::new_with( - account_labels, - |index, _| Message::SelectAccount(index), - appearance::THEME_FONT_SIZE.into(), - 5.0, - iced_aw::style::selection_list::primary, - selected_index, - appearance::p_font(), - ); - - container(selection_list) - .height(Fill) - .style(|theme: &iced::Theme| container::Style { - border: iced::Border { - width: 1.0, - color: theme.palette().background.scale_alpha(0.5), - radius: appearance::THEME_CORNER_RADIUS.into(), - }, - ..Default::default() - }) - .into() + content.into() } fn view_account_buttons(&self, selected_index: Option) -> Element<'_, Message> { let mut buttons = row![ - button(text("Add Account").align_x(Center)) + button(appearance::icon_text(appearance::PLUS, "Add Account", None)) .on_press(Message::ShowLogin) .style(appearance::s_button) ] @@ -155,12 +167,16 @@ impl SettingsScreen { if let Some(index) = selected_index { buttons = buttons .push( - button(text("Remove Selected").align_x(Center)) - .on_press(Message::RemoveAccount(index)) - .style(appearance::s_button), + button(appearance::icon_text( + appearance::MINUS, + "Remove Account", + None, + )) + .on_press(Message::RemoveAccount(index)) + .style(appearance::s_button), ) .push( - button(text("Export P12").align_x(Center)) + button(appearance::icon_text(appearance::SHARE, "Export P12", None)) .on_press(Message::ExportP12) .style(appearance::s_button), ); diff --git a/apps/plumeimpactor/src/screen/utilties.rs b/apps/plumeimpactor/src/screen/utilties.rs index b382f919..44f4f6ad 100644 --- a/apps/plumeimpactor/src/screen/utilties.rs +++ b/apps/plumeimpactor/src/screen/utilties.rs @@ -3,6 +3,37 @@ use iced::{Center, Color, Element, Task}; use crate::appearance; use plume_utils::{Device, SignerAppReal}; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +struct StatusMessage { + text: String, + is_error: bool, +} + +impl StatusMessage { + fn success(text: impl Into) -> Self { + Self { + text: text.into(), + is_error: false, + } + } + + fn error(text: impl Into) -> Self { + Self { + text: text.into(), + is_error: true, + } + } + + fn color(&self) -> Color { + if self.is_error { + Color::from_rgb(0.9, 0.2, 0.2) + } else { + Color::from_rgb(0.2, 0.8, 0.4) + } + } +} #[derive(Debug, Clone)] pub enum Message { @@ -11,14 +42,15 @@ pub enum Message { InstallPairingFile(SignerAppReal), Trust, PairResult(Result<(), String>), - InstallPairingResult(Result<(), String>), + InstallPairingResult(String, Result<(), String>), } #[derive(Debug, Clone)] pub struct UtilitiesScreen { device: Option, installed_apps: Vec, - error_message: Option, + status_message: Option, + app_statuses: HashMap, loading: bool, trust_loading: bool, } @@ -28,13 +60,14 @@ impl UtilitiesScreen { let mut screen = Self { device, installed_apps: Vec::new(), - error_message: None, + status_message: None, + app_statuses: HashMap::new(), loading: false, trust_loading: false, }; if screen.device.as_ref().map(|d| d.is_mac).unwrap_or(false) { - screen.error_message = Some("macOS devices are not supported".to_string()); + screen.status_message = Some(StatusMessage::error("macOS devices are not supported")); } screen @@ -44,7 +77,8 @@ impl UtilitiesScreen { match message { Message::RefreshApps => { self.loading = true; - self.error_message = None; + self.status_message = None; + self.app_statuses.clear(); if let Some(device) = &self.device { if device.is_mac { return Task::none(); @@ -84,10 +118,10 @@ impl UtilitiesScreen { match result { Ok(apps) => { self.installed_apps = apps; - self.error_message = None; + self.status_message = None; } Err(e) => { - self.error_message = Some(e); + self.status_message = Some(StatusMessage::error(e)); self.installed_apps.clear(); } } @@ -98,6 +132,7 @@ impl UtilitiesScreen { let device = device.clone(); let bundle_id = app.bundle_id.clone().unwrap_or_default(); let pairing_path = app.app.pairing_file_path().unwrap_or_default(); + let app_key = Self::app_key(&app); let (tx, rx) = std::sync::mpsc::sync_channel(1); std::thread::spawn(move || { @@ -120,7 +155,7 @@ impl UtilitiesScreen { .join() .unwrap() }, - Message::InstallPairingResult, + move |result| Message::InstallPairingResult(app_key, result), ) } else { Task::none() @@ -128,7 +163,7 @@ impl UtilitiesScreen { } Message::Trust => { self.trust_loading = true; - self.error_message = None; + self.status_message = None; if let Some(device) = &self.device { let device = device.clone(); let (tx, rx) = std::sync::mpsc::sync_channel(1); @@ -163,24 +198,21 @@ impl UtilitiesScreen { self.trust_loading = false; match result { Ok(_) => { - self.error_message = Some("Device paired successfully!".to_string()); + self.status_message = + Some(StatusMessage::success("Device paired successfully!")); } Err(e) => { - self.error_message = Some(e); + self.status_message = Some(StatusMessage::error(e)); } } Task::none() } - Message::InstallPairingResult(result) => { - match result { - Ok(_) => { - self.error_message = - Some("Pairing file installed successfully!".to_string()); - } - Err(e) => { - self.error_message = Some(e); - } - } + Message::InstallPairingResult(app_key, result) => { + let status = match result { + Ok(_) => StatusMessage::success("Pairing file installed successfully!"), + Err(e) => StatusMessage::error(e), + }; + self.app_statuses.insert(app_key, status); Task::none() } } @@ -202,8 +234,8 @@ impl UtilitiesScreen { content.push(text("No device connected").color(Color::from_rgb(0.7, 0.7, 0.7))); } - if let Some(ref error) = self.error_message { - content = content.push(text(error).size(14).color(Color::from_rgb(0.9, 0.2, 0.2))); + if let Some(ref status) = self.status_message { + content = content.push(text(&status.text).size(14).color(status.color())); } if self.device.is_some() && !self.device.as_ref().unwrap().is_mac { @@ -249,7 +281,8 @@ impl UtilitiesScreen { let mut apps_list = column![].spacing(4); for app in &self.installed_apps { - apps_list = apps_list.push( + let app_key = Self::app_key(app); + let mut app_row = column![ row![ text(format!( "{} ({})", @@ -263,8 +296,15 @@ impl UtilitiesScreen { .style(appearance::s_button) ] .spacing(appearance::THEME_PADDING) - .align_y(Center), - ); + .align_y(Center) + ] + .spacing(4); + + if let Some(status) = self.app_statuses.get(&app_key) { + app_row = app_row.push(text(&status.text).size(13).color(status.color())); + } + + apps_list = apps_list.push(app_row); } content = content.push(apps_list); @@ -272,4 +312,10 @@ impl UtilitiesScreen { container(scrollable(content)).into() } + + fn app_key(app: &SignerAppReal) -> String { + app.bundle_id + .clone() + .unwrap_or_else(|| app.app.to_string()) + } } diff --git a/apps/plumeimpactor/src/screen/windows/login_window.rs b/apps/plumeimpactor/src/screen/windows/login_window.rs index 82f06419..fbc70f20 100644 --- a/apps/plumeimpactor/src/screen/windows/login_window.rs +++ b/apps/plumeimpactor/src/screen/windows/login_window.rs @@ -273,7 +273,8 @@ impl LoginWindow { } else { Some(Message::TwoFactorSubmit) }) - .style(appearance::p_button), + .style(appearance::p_button) + .padding(8), ] .spacing(appearance::THEME_PADDING); diff --git a/apps/plumeimpactor/src/screen/windows/mod.rs b/apps/plumeimpactor/src/screen/windows/mod.rs index 96f931fb..ea6248e5 100644 --- a/apps/plumeimpactor/src/screen/windows/mod.rs +++ b/apps/plumeimpactor/src/screen/windows/mod.rs @@ -1,2 +1 @@ pub mod login_window; -pub mod team_selection_window; diff --git a/apps/plumeimpactor/src/screen/windows/team_selection_window.rs b/apps/plumeimpactor/src/screen/windows/team_selection_window.rs deleted file mode 100644 index 96f3353c..00000000 --- a/apps/plumeimpactor/src/screen/windows/team_selection_window.rs +++ /dev/null @@ -1,101 +0,0 @@ -use iced::widget::{button, column, container, scrollable, text}; -use iced::{Alignment, Element, Length, Task, window}; -use iced_aw::SelectionList; - -use crate::appearance; - -#[derive(Debug, Clone)] -pub enum Message { - SelectTeam(usize), - Confirm, - Cancel, -} - -pub struct TeamSelectionWindow { - teams: Vec, - pub selected_index: Option, -} - -impl TeamSelectionWindow { - pub fn settings() -> window::Settings { - window::Settings { - size: iced::Size::new(500.0, 400.0), - position: window::Position::Centered, - resizable: false, - decorations: true, - ..Default::default() - } - } - - pub fn new(teams: Vec) -> Self { - Self { - teams, - selected_index: None, - } - } - - pub fn update(&mut self, message: Message) -> Task { - match message { - Message::SelectTeam(index) => { - self.selected_index = Some(index); - Task::none() - } - Message::Confirm | Message::Cancel => Task::none(), - } - } - - pub fn view(&self) -> Element<'_, Message> { - let title = text("Select Developer Team") - .size(24) - .width(Length::Fill) - .align_x(Alignment::Center); - - let description = text("Multiple developer teams are available. Please select one:") - .size(14) - .width(Length::Fill); - - let team_labels: &'static [String] = Box::leak(self.teams.clone().into_boxed_slice()); - - let selection_list = SelectionList::new_with( - team_labels, - |index, _| Message::SelectTeam(index), - appearance::THEME_FONT_SIZE.into(), - 5.0, - iced_aw::style::selection_list::primary, - self.selected_index, - appearance::p_font(), - ); - - let list_container = container(scrollable(selection_list)) - .height(Length::Fill) - .style(|theme: &iced::Theme| container::Style { - border: iced::Border { - width: 1.0, - color: theme.palette().background.scale_alpha(0.5), - radius: appearance::THEME_CORNER_RADIUS.into(), - }, - ..Default::default() - }); - - let buttons = iced::widget::row![ - button(text("Cancel").align_x(Alignment::Center)) - .on_press(Message::Cancel) - .style(appearance::s_button) - .width(Length::Fill), - button(text("Confirm").align_x(Alignment::Center)) - .on_press_maybe(self.selected_index.map(|_| Message::Confirm)) - .style(appearance::p_button) - .width(Length::Fill), - ] - .spacing(appearance::THEME_PADDING); - - container( - column![title, description, list_container, buttons] - .spacing(appearance::THEME_PADDING) - .padding(appearance::THEME_PADDING), - ) - .width(Length::Fill) - .height(Length::Fill) - .into() - } -} diff --git a/apps/plumeimpactor/src/subscriptions.rs b/apps/plumeimpactor/src/subscriptions.rs index ffc8d3db..5a65db7f 100644 --- a/apps/plumeimpactor/src/subscriptions.rs +++ b/apps/plumeimpactor/src/subscriptions.rs @@ -3,8 +3,11 @@ use idevice::usbmuxd::{UsbmuxdConnection, UsbmuxdListenEvent}; use std::sync::Arc; use tray_icon::{TrayIconEvent, menu::MenuEvent}; -use crate::screen::{Message, general}; -use plume_utils::Device; +use crate::{ + defaults::get_data_path, + screen::{Message, general}, +}; +use plume_utils::{Bundle, Device, PlistInfoTrait}; pub(crate) fn device_listener() -> Subscription { Subscription::run(|| { @@ -190,59 +193,18 @@ pub(crate) fn installation_progress_listener( } } -pub(crate) fn team_selection_listener( - team_rx: Arc>>>, -) -> Subscription> { - struct State { - rx: Arc>>>, - } - - impl std::hash::Hash for State { - fn hash(&self, state: &mut H) { - Arc::as_ptr(&self.rx).hash(state); - } - } - - let state = State { rx: team_rx }; - Subscription::run_with(state, |state| { - let rx = state.rx.clone(); - iced::stream::channel( - 10, - move |mut output: iced::futures::channel::mpsc::Sender>| async move { - use iced::futures::{SinkExt, StreamExt}; - - let (tx, mut rx_stream) = iced::futures::channel::mpsc::unbounded::>(); - - let rx_thread = rx.clone(); - std::thread::spawn(move || { - if let Ok(guard) = rx_thread.lock() { - if let Ok(teams) = guard.recv() { - let _ = tx.unbounded_send(teams); - } - } - }); - - while let Some(teams) = rx_stream.next().await { - let _ = output.send(teams).await; - } - }, - ) - }) -} - pub(crate) async fn run_installation( package: &plume_utils::Package, device: Option<&Device>, options: &plume_utils::SignerOptions, account: Option<&plume_store::GsaAccount>, + mut store: Option<&mut plume_store::AccountStore>, tx: &std::sync::mpsc::Sender<(String, i32)>, - team_selection_tx: Option>>, - team_selection_rx: Option>>, ) -> Result<(), String> { use plume_core::{AnisetteConfiguration, CertificateIdentity, developer::DeveloperSession}; use plume_utils::{Signer, SignerInstallMode, SignerMode}; - let package_file: std::path::PathBuf; + let package_file: Bundle; let mut options = options.clone(); let send = |msg: String, progress: i32| { let _ = tx.send((msg, progress)); @@ -273,28 +235,19 @@ pub(crate) async fn run_installation( return Err("No teams available for this account".to_string()); } - let team_id = if teams_response.teams.len() == 1 { + let team_id = account.team_id(); + + if !team_id.is_empty() && !teams_response.teams.iter().any(|t| &t.team_id == team_id) { + return Err(format!( + "Stored team ID '{}' not found in available teams. Please update your team selection in Settings.", + team_id + )); + } + + let team_id = if team_id.is_empty() { &teams_response.teams[0].team_id } else { - let team_names: Vec = teams_response - .teams - .iter() - .map(|t| format!("{} ({})", t.name, t.team_id)) - .collect(); - - if let (Some(tx), Some(rx)) = (team_selection_tx, team_selection_rx) { - tx.send(team_names) - .map_err(|_| "Failed to send team selection request".to_string())?; - - let selected_index = rx - .recv() - .map_err(|_| "Team selection channel closed".to_string())? - .map_err(|e| format!("Team selection error: {}", e))?; - - &teams_response.teams[selected_index].team_id - } else { - &teams_response.teams[0].team_id - } + team_id }; let identity = CertificateIdentity::new_with_session( @@ -328,7 +281,7 @@ pub(crate) async fn run_installation( .await .map_err(|e| e.to_string())?; signer - .register_bundle(&bundle, &session, team_id) + .register_bundle(&bundle, &session, team_id, false) .await .map_err(|e| e.to_string())?; signer @@ -337,7 +290,7 @@ pub(crate) async fn run_installation( .map_err(|e| e.to_string())?; options = signer.options.clone(); - package_file = bundle.bundle_dir().to_path_buf(); + package_file = bundle; } SignerMode::Adhoc => { send("Extracting package...".to_string(), 50); @@ -358,14 +311,93 @@ pub(crate) async fn run_installation( .map_err(|e| e.to_string())?; options = signer.options.clone(); - package_file = bundle.bundle_dir().to_path_buf(); + package_file = bundle; } _ => { send("Extracting package...".to_string(), 50); let bundle = package.get_package_bundle().map_err(|e| e.to_string())?; - package_file = bundle.bundle_dir().to_path_buf(); + package_file = bundle; + } + } + + if options.refresh && options.mode == SignerMode::Pem { + send("Saving for refresh...".to_string(), 75); + let path = get_data_path().join("refresh_store"); + tokio::fs::create_dir_all(&path) + .await + .map_err(|e| e.to_string())?; + + let original_name = package_file + .bundle_dir() + .file_name() + .unwrap() + .to_string_lossy(); + let uuid = uuid::Uuid::new_v4(); + let dest_name = if let Some(dot_pos) = original_name.rfind('.') { + let (name, ext) = original_name.split_at(dot_pos); + format!("{}-{}{}", name, uuid, ext) + } else { + format!("{}-{}", original_name, uuid) + }; + let dest_path = path.join(dest_name); + + plume_utils::copy_dir_recursively(&package_file.bundle_dir(), &dest_path) + .await + .map_err(|e| e.to_string())?; + + if let (Some(dev), Some(account), Some(store)) = (&device, &account, store.as_mut()) { + let embedded_prov_path = dest_path.join("embedded.mobileprovision"); + + let provision_path = if embedded_prov_path.exists() { + Some(embedded_prov_path) + } else { + None + }; + + if let Some(prov_path) = provision_path { + use plume_core::MobileProvision; + + if let Ok(provision) = MobileProvision::load_with_path(&prov_path) { + let expiration_date = provision.expiration_date().clone(); + let scheduled_refresh = expiration_date + .to_xml_format() + .parse::>() + .unwrap_or_else(|_| chrono::Utc::now() + chrono::Duration::days(6)); + let scheduled_refresh = scheduled_refresh - chrono::Duration::days(1); + + let refresh_app = plume_store::RefreshApp { + name: package_file.get_name(), + bundle_id: package_file.get_bundle_identifier(), + path: dest_path.clone(), + scheduled_refresh, + }; + + let mut refresh_device = store + .get_refresh_device(&dev.udid) + .cloned() + .unwrap_or_else(|| plume_store::RefreshDevice { + udid: dev.udid.clone(), + name: dev.name.clone(), + account: account.email().clone(), + apps: Vec::new(), + is_mac: dev.is_mac, + }); + + if let Some(existing_app) = + refresh_device.apps.iter_mut().find(|a| a.path == dest_path) + { + *existing_app = refresh_app; + } else { + refresh_device.apps.push(refresh_app); + } + + store + .add_or_update_refresh_device_sync(refresh_device) + .map_err(|e| e.to_string())?; + } + } } } @@ -376,7 +408,7 @@ pub(crate) async fn run_installation( send("Installing...".to_string(), 80); let tx_clone = tx.clone(); - dev.install_app(&package_file, move |progress: i32| { + dev.install_app(&package_file.bundle_dir(), move |progress: i32| { let tx = tx_clone.clone(); async move { let _ = tx.send(("Installing...".to_string(), 80 + (progress / 5))); @@ -401,7 +433,7 @@ pub(crate) async fn run_installation( } else { send("Installing...".to_string(), 90); - plume_utils::install_app_mac(&package_file) + plume_utils::install_app_mac(&package_file.bundle_dir()) .await .map_err(|e| e.to_string())?; } @@ -413,7 +445,7 @@ pub(crate) async fn run_installation( send("Exporting...".to_string(), 90); let archive_path = package - .get_archive_based_on_path(package_file) + .get_archive_based_on_path(&package_file.bundle_dir()) .map_err(|e| e.to_string())?; let file = rfd::AsyncFileDialog::new() @@ -458,12 +490,19 @@ pub(crate) async fn export_certificate(account: plume_store::GsaAccount) -> Resu return Err("No teams available for this account".to_string()); } - let team_id = if teams_response.teams.len() == 1 { + let team_id = account.team_id(); + + if !team_id.is_empty() && !teams_response.teams.iter().any(|t| &t.team_id == team_id) { + return Err(format!( + "Stored team ID '{}' not found in available teams. Please update your team selection in Settings.", + team_id + )); + } + + let team_id = if team_id.is_empty() { &teams_response.teams[0].team_id } else { - // Multiple teams - for export_certificate, just use the first one for now - // TODO: Add team selection support for export_certificate - &teams_response.teams[0].team_id + team_id }; let identity = CertificateIdentity::new_with_session( @@ -504,3 +543,28 @@ pub(crate) async fn export_certificate(account: plume_store::GsaAccount) -> Resu Ok(()) } + +pub(crate) async fn fetch_teams( + account: &plume_store::GsaAccount, +) -> Result, String> { + use plume_core::{AnisetteConfiguration, developer::DeveloperSession}; + + let session = DeveloperSession::new( + account.adsid().clone(), + account.xcode_gs_token().clone(), + AnisetteConfiguration::default().set_configuration_path(crate::defaults::get_data_path()), + ) + .await + .map_err(|e| e.to_string())?; + + let teams_response = session.qh_list_teams().await.map_err(|e| e.to_string())?; + + Ok(teams_response + .teams + .into_iter() + .map(|t| crate::screen::settings::Team { + name: t.name, + id: t.team_id, + }) + .collect()) +} diff --git a/apps/plumeimpactor/src/tray.rs b/apps/plumeimpactor/src/tray.rs index 85e8d334..07ce35ca 100644 --- a/apps/plumeimpactor/src/tray.rs +++ b/apps/plumeimpactor/src/tray.rs @@ -1,6 +1,7 @@ +use std::collections::HashMap; use tray_icon::{ Icon, TrayIcon, TrayIconBuilder, - menu::{Menu, MenuId, MenuItem, PredefinedMenuItem}, + menu::{Menu, MenuId, MenuItem, PredefinedMenuItem, Submenu}, }; pub(crate) fn build_tray_icon(menu: &Menu) -> TrayIcon { @@ -22,11 +23,20 @@ fn load_icon() -> Icon { Icon::from_rgba(image.into_raw(), width, height).unwrap() } +#[derive(Debug, Clone)] +pub enum TrayAction { + Show, + Quit, + RefreshApp { udid: String, app_path: String }, + ForgetApp { udid: String, app_path: String }, +} + pub(crate) struct ImpactorTray { - #[allow(dead_code)] - icon: TrayIcon, + icon: Option, + menu: Menu, show_item_id: MenuId, quit_item_id: MenuId, + action_map: HashMap, } impl ImpactorTray { @@ -38,22 +48,108 @@ impl ImpactorTray { let show_item_id = show_item.id().clone(); let quit_item_id = quit_item.id().clone(); - let _ = tray_menu.append_items(&[&show_item, &PredefinedMenuItem::separator(), &quit_item]); + let mut action_map = HashMap::new(); + action_map.insert(show_item_id.clone(), TrayAction::Show); + action_map.insert(quit_item_id.clone(), TrayAction::Quit); - let icon = build_tray_icon(&tray_menu); + let _ = tray_menu.append_items(&[&show_item, &PredefinedMenuItem::separator(), &quit_item]); Self { - icon, + icon: Some(build_tray_icon(&tray_menu)), + menu: tray_menu, show_item_id, quit_item_id, + action_map, } } - pub(crate) fn is_show_clicked(&self, id: &MenuId) -> bool { - *id == self.show_item_id + pub(crate) fn update_refresh_apps(&mut self, store: &plume_store::AccountStore) { + let new_menu = Menu::new(); + let show_item = MenuItem::new("Open", true, None); + + let mut action_map = HashMap::new(); + action_map.insert(show_item.id().clone(), TrayAction::Show); + + let _ = new_menu.append(&show_item); + let _ = new_menu.append(&PredefinedMenuItem::separator()); + + let has_apps = store.refreshes().values().any(|d| !d.apps.is_empty()); + + if has_apps { + let refresh_submenu = Submenu::new("Auto-Refresh Apps", true); + + for (udid, refresh_device) in store.refreshes() { + if refresh_device.apps.is_empty() { + continue; + } + + let device_label = MenuItem::with_id( + MenuId::new(format!("header-{}", udid)), + &refresh_device.name, + false, + None, + ); + let _ = refresh_submenu.append(&device_label); + + for app in &refresh_device.apps { + let scheduled = app.scheduled_refresh.format("%H:%M %b %d").to_string(); + + let app_submenu = Submenu::new( + &format!( + "{} (Next: {})", + app.name.clone().unwrap_or("???".to_string()), + scheduled + ), + true, + ); + + let refresh_item = MenuItem::new("Refresh Now", true, None); + let forget_item = MenuItem::new("Forget App", true, None); + + action_map.insert( + refresh_item.id().clone(), + TrayAction::RefreshApp { + udid: udid.clone(), + app_path: app.path.to_string_lossy().to_string(), + }, + ); + action_map.insert( + forget_item.id().clone(), + TrayAction::ForgetApp { + udid: udid.clone(), + app_path: app.path.to_string_lossy().to_string(), + }, + ); + + let _ = app_submenu.append(&refresh_item); + let _ = app_submenu.append(&forget_item); + + let _ = refresh_submenu.append(&app_submenu); + } + + let _ = refresh_submenu.append(&PredefinedMenuItem::separator()); + } + + let _ = new_menu.append(&refresh_submenu); + let _ = new_menu.append(&PredefinedMenuItem::separator()); + } + + let quit_item = MenuItem::new(format!("Quit {}", crate::APP_NAME), true, None); + action_map.insert(quit_item.id().clone(), TrayAction::Quit); + let _ = new_menu.append(&quit_item); + + self.show_item_id = show_item.id().clone(); + self.quit_item_id = quit_item.id().clone(); + + self.menu = new_menu; + self.action_map = action_map; + + if let Some(tray_icon) = &mut self.icon { + let _ = tray_icon.set_menu(Some(Box::new(self.menu.clone()))); + } } - pub(crate) fn is_quit_clicked(&self, id: &MenuId) -> bool { - *id == self.quit_item_id + pub(crate) fn get_action(&self, id: &MenuId) -> Option<&TrayAction> { + self.action_map.get(id) } } diff --git a/apps/plumesign/src/commands/sign.rs b/apps/plumesign/src/commands/sign.rs index 6a5e76d4..1e8cf946 100644 --- a/apps/plumesign/src/commands/sign.rs +++ b/apps/plumesign/src/commands/sign.rs @@ -149,7 +149,9 @@ pub async fn execute(args: SignArgs) -> Result<()> { .await?; } - signer.register_bundle(&bundle, &session, &team_id).await?; + signer + .register_bundle(&bundle, &session, &team_id, false) + .await?; signer.sign_bundle(&bundle).await?; if let Some(dev) = device { @@ -191,7 +193,7 @@ pub async fn execute(args: SignArgs) -> Result<()> { if let Some(pkg) = package { if let Some(output_path) = args.output { - let archived_path = pkg.get_archive_based_on_path(args.package.clone())?; + let archived_path = pkg.get_archive_based_on_path(&args.package.clone())?; tokio::fs::copy(&archived_path, &output_path).await?; log::info!("Saved signed package to: {}", output_path.display()); pkg.remove_package_stage(); diff --git a/crates/plume_core/src/utils/certificate.rs b/crates/plume_core/src/utils/certificate.rs index 424b2594..fb508a5d 100644 --- a/crates/plume_core/src/utils/certificate.rs +++ b/crates/plume_core/src/utils/certificate.rs @@ -28,6 +28,7 @@ pub struct CertificateIdentity { pub machine_id: Option, pub serial_number: Option, pub p12_data: Option>, + pub new: bool, } impl CertificateIdentity { @@ -39,6 +40,7 @@ impl CertificateIdentity { machine_id: None, p12_data: None, serial_number: None, + new: false, }; if let Some(paths) = paths { @@ -61,12 +63,13 @@ impl CertificateIdentity { let key_path = Self::key_dir(config_path, &team_id)?.join("key.pem"); - let mut cert = Self { + let mut identity = Self { cert: None, key: None, machine_id: None, p12_data: None, serial_number: None, + new: false, }; // To same some unnecessary requests, we're going to list our certificates first here @@ -80,30 +83,37 @@ impl CertificateIdentity { let key_string = fs::read_to_string(&key_path)?; let priv_key = RsaPrivateKey::from_pkcs8_pem(&key_string)?; - if let Some(cert) = cert + if let Some(certificate) = identity .find_certificate(certs.clone(), &priv_key, &machine_name) .await? { - let cert_pem = - encode_string("CERTIFICATE", LineEnding::LF, cert.cert_content.as_ref()) - .unwrap(); + let cert_pem = encode_string( + "CERTIFICATE", + LineEnding::LF, + certificate.cert_content.as_ref(), + ) + .unwrap(); let key_pem = priv_key.to_pkcs8_pem(Default::default())?.to_string(); [cert_pem.into_bytes(), key_pem.into_bytes()] } else { - let (cert, priv_key) = cert + let (certificate, priv_key) = identity .request_new_certificate(session, team_id, &machine_name, certs) .await?; - let cert_pem = - encode_string("CERTIFICATE", LineEnding::LF, cert.cert_content.as_ref()) - .unwrap(); + let cert_pem = encode_string( + "CERTIFICATE", + LineEnding::LF, + certificate.cert_content.as_ref(), + ) + .unwrap(); let key_pem = priv_key.to_pkcs8_pem(Default::default())?.to_string(); fs::write(&key_path, &key_pem)?; + identity.new = true; [cert_pem.into_bytes(), key_pem.into_bytes()] } } else { - let (cert, priv_key) = cert + let (cert, priv_key) = identity .request_new_certificate(session, team_id, &machine_name, certs) .await?; let cert_pem = @@ -111,19 +121,20 @@ impl CertificateIdentity { let key_pem = priv_key.to_pkcs8_pem(Default::default())?.to_string(); fs::write(&key_path, &key_pem)?; + identity.new = true; [cert_pem.into_bytes(), key_pem.into_bytes()] }; // TODO: this may be horrendious - if let Some(p12_data) = cert.create_pkcs12(&key_pair) { - cert.p12_data = Some(p12_data); + if let Some(p12_data) = identity.create_pkcs12(&key_pair) { + identity.p12_data = Some(p12_data); } for pem in key_pair { - cert.resolve_certificate_from_contents(pem)?; + identity.resolve_certificate_from_contents(pem)?; } - Ok(cert) + Ok(identity) } // /keys/ diff --git a/crates/plume_core/src/utils/provision.rs b/crates/plume_core/src/utils/provision.rs index e273b88f..b9b2bc4f 100644 --- a/crates/plume_core/src/utils/provision.rs +++ b/crates/plume_core/src/utils/provision.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use crate::Error; use crate::utils::TEAM_ID_REGEX; -use plist::{Dictionary, Value}; +use plist::{Date, Dictionary, Value}; use super::MachO; @@ -11,6 +11,7 @@ use super::MachO; pub struct MobileProvision { pub data: Vec, entitlements: Dictionary, + expiration_date: Date, } impl MobileProvision { @@ -22,9 +23,13 @@ impl MobileProvision { } pub fn load_with_bytes(data: Vec) -> Result { - let entitlements = Self::extract_entitlements_from_prov(&data)?; + let (entitlements, expiration_date) = Self::extract_entitlements_from_prov(&data)?; - Ok(Self { data, entitlements }) + Ok(Self { + data, + entitlements, + expiration_date, + }) } pub fn merge_entitlements( @@ -58,6 +63,10 @@ impl MobileProvision { &self.entitlements } + pub fn expiration_date(&self) -> &Date { + &self.expiration_date + } + pub fn entitlements_as_bytes(&self) -> Result, Error> { let mut buf = Vec::new(); Value::Dictionary(self.entitlements.clone()).to_writer_xml(&mut buf)?; @@ -76,7 +85,7 @@ impl MobileProvision { Some(bundle_id) } - fn extract_entitlements_from_prov(data: &[u8]) -> Result { + fn extract_entitlements_from_prov(data: &[u8]) -> Result<(Dictionary, Date), Error> { let start = data .windows(6) .position(|w| w == b" Self { + pub fn new( + email: String, + first_name: String, + adsid: String, + xcode_gs_token: String, + team_id: String, + ) -> Self { GsaAccount { email, first_name, adsid, xcode_gs_token, + team_id, } } pub fn email(&self) -> &String { @@ -29,6 +38,12 @@ impl GsaAccount { pub fn xcode_gs_token(&self) -> &String { &self.xcode_gs_token } + pub fn team_id(&self) -> &String { + &self.team_id + } + pub fn set_team_id(&mut self, team_id: String) { + self.team_id = team_id; + } } pub async fn account_from_session( @@ -37,9 +52,21 @@ pub async fn account_from_session( ) -> Result { let first_name = account.get_name().0; let s = plume_core::developer::DeveloperSession::using_account(account).await?; - s.qh_list_teams().await?; + let teams_response = s.qh_list_teams().await?; let adsid = s.adsid().clone(); let xcode_gs_token = s.xcode_gs_token().clone(); - Ok(GsaAccount::new(email, first_name, adsid, xcode_gs_token)) + let team_id = if teams_response.teams.is_empty() { + "".to_string() + } else { + teams_response.teams[0].team_id.clone() + }; + + Ok(GsaAccount::new( + email, + first_name, + adsid, + xcode_gs_token, + team_id, + )) } diff --git a/crates/plume_store/src/lib.rs b/crates/plume_store/src/lib.rs index fd1482f9..a33427c5 100644 --- a/crates/plume_store/src/lib.rs +++ b/crates/plume_store/src/lib.rs @@ -1,4 +1,6 @@ mod gsa_account; +mod refresh; mod store; pub use gsa_account::{GsaAccount, account_from_session}; +pub use refresh::{RefreshApp, RefreshDevice}; pub use store::AccountStore; diff --git a/crates/plume_store/src/refresh.rs b/crates/plume_store/src/refresh.rs new file mode 100644 index 00000000..3ff99b39 --- /dev/null +++ b/crates/plume_store/src/refresh.rs @@ -0,0 +1,34 @@ +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RefreshDevice { + pub udid: String, // Device UDID + pub name: String, // Device name + pub account: String, // Email + pub apps: Vec, // Device apps to refresh + pub is_mac: bool, // m1 sideloading +} + +// custom entitlements not supported +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RefreshApp { + pub path: PathBuf, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub bundle_id: Option, + pub scheduled_refresh: DateTime, // the scheduled refresh time will happen a day before expiration +} + +// to support autorefreshing of apps we need to store a modified copy of the app first +// MISAGENT: +// for this, we can just reregister the bundle and collect the provisioning profiles, +// +// MANUAL (CERTIFICATE REVOKED): +// we have a modified copy of the app already, we can just resign and register the bundle and attempt to install it + +// TODO: replace substrate with ellekit +// TODO: maybe some 26.0 macho fixes. diff --git a/crates/plume_store/src/store.rs b/crates/plume_store/src/store.rs index 984f5707..1ff28b54 100644 --- a/crates/plume_store/src/store.rs +++ b/crates/plume_store/src/store.rs @@ -5,12 +5,15 @@ use serde::{Deserialize, Serialize}; use plume_core::Error; -use crate::gsa_account::GsaAccount; +use crate::{GsaAccount, RefreshDevice}; -#[derive(Debug, Serialize, Deserialize, Default)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] pub struct AccountStore { - selected_account: Option, - accounts: HashMap, + selected_account: Option, // Email + accounts: HashMap, // Email -> GsaAccount + #[serde(default)] + refreshes: HashMap, // UDID -> RefreshDevice (apps?) + #[serde(skip)] path: Option, } @@ -30,6 +33,21 @@ impl AccountStore { } } + pub fn load_sync(path: &Option) -> Result { + if let Some(path) = path { + let mut settings = if !path.exists() { + Self::default() + } else { + let contents = std::fs::read_to_string(path)?; + serde_json::from_str(&contents)? + }; + settings.path = Some(path.clone()); + Ok(settings) + } else { + Ok(Self::default()) + } + } + pub async fn save(&self) -> Result<(), Error> { if let Some(path) = &self.path { if let Some(parent) = path.parent() { @@ -56,6 +74,10 @@ impl AccountStore { &self.accounts } + pub fn path(&self) -> Option { + self.path.clone() + } + pub fn get_account(&self, email: &str) -> Option<&GsaAccount> { self.accounts.get(email) } @@ -123,14 +145,72 @@ impl AccountStore { ) -> Result<(), Error> { let first_name = account.get_name().0; let s = plume_core::developer::DeveloperSession::using_account(account).await?; - s.qh_list_teams().await?; + let teams_response = s.qh_list_teams().await?; let adsid = s.adsid().clone(); let xcode_gs_token = s.xcode_gs_token().clone(); - let account = GsaAccount::new(email, first_name, adsid, xcode_gs_token); + let team_id = if teams_response.teams.is_empty() { + "".to_string() + } else { + teams_response.teams[0].team_id.clone() + }; + + let account = GsaAccount::new(email, first_name, adsid, xcode_gs_token, team_id); self.accounts_add(account).await?; Ok(()) } + + pub async fn update_account_team(&mut self, email: &str, team_id: String) -> Result<(), Error> { + if let Some(account) = self.accounts.get_mut(email) { + account.set_team_id(team_id); + self.save().await + } else { + Err(Error::Parse) + } + } + + pub fn update_account_team_sync(&mut self, email: &str, team_id: String) -> Result<(), Error> { + if let Some(account) = self.accounts.get_mut(email) { + account.set_team_id(team_id); + self.save_sync() + } else { + Err(Error::Parse) + } + } + + pub fn refreshes(&self) -> &HashMap { + &self.refreshes + } + + pub fn get_refresh_device(&self, udid: &str) -> Option<&RefreshDevice> { + self.refreshes.get(udid) + } + + pub async fn add_or_update_refresh_device( + &mut self, + device: RefreshDevice, + ) -> Result<(), Error> { + self.refreshes.insert(device.udid.clone(), device); + self.save().await + } + + pub fn add_or_update_refresh_device_sync( + &mut self, + device: RefreshDevice, + ) -> Result<(), Error> { + self.refreshes.insert(device.udid.clone(), device); + self.save_sync() + } + + pub async fn remove_refresh_device(&mut self, udid: &str) -> Result<(), Error> { + self.refreshes.remove(udid); + self.save().await + } + + pub fn remove_refresh_device_sync(&mut self, udid: &str) -> Result<(), Error> { + self.refreshes.remove(udid); + self.save_sync() + } } diff --git a/crates/plume_types/Cargo.toml b/crates/plume_types/Cargo.toml index d7bf2cfc..67c45c44 100644 --- a/crates/plume_types/Cargo.toml +++ b/crates/plume_types/Cargo.toml @@ -16,6 +16,7 @@ tokio.workspace = true futures.workspace = true log.workspace = true plume_core = { path = "../plume_core", features = ["tweaks"] } +plume_store = { path = "../plume_store" } # TODO: replace zip with decompress zip = { version = "4.3", default-features = false, features = ["deflate"] } diff --git a/crates/plume_types/src/device.rs b/crates/plume_types/src/device.rs index c4c752c2..2bfaa4dd 100644 --- a/crates/plume_types/src/device.rs +++ b/crates/plume_types/src/device.rs @@ -4,14 +4,17 @@ use std::path::{Component, Path, PathBuf}; use idevice::IdeviceService; use idevice::installation_proxy::InstallationProxyClient; use idevice::lockdown::LockdownClient; +use idevice::misagent::MisagentClient; use idevice::usbmuxd::{Connection, UsbmuxdAddr, UsbmuxdDevice}; use idevice::utils::installation; +use plume_core::MobileProvision; use crate::Error; use crate::options::SignerAppReal; use idevice::afc::opcode::AfcFopenMode; use idevice::house_arrest::HouseArrestClient; use idevice::usbmuxd::UsbmuxdConnection; +use plist::Value; pub const CONNECTION_LABEL: &str = "plume_info"; pub const INSTALLATION_LABEL: &str = "plume_install"; @@ -78,8 +81,12 @@ impl Device { let mut found_apps = Vec::new(); - for (bundle_id, _) in apps { - let signer_app = SignerAppReal::from_bundle_identifier(Some(bundle_id.as_str())); + for (bundle_id, info) in apps { + let app_name = get_app_name_from_info(&info); + let signer_app = SignerAppReal::from_bundle_identifier_and_name( + Some(bundle_id.as_str()), + app_name.as_deref(), + ); if signer_app.app.supports_pairing_file_alt() { found_apps.push(signer_app); @@ -89,6 +96,39 @@ impl Device { Ok(found_apps) } + pub async fn is_app_installed(&self, bundle_id: &str) -> Result { + let device = match &self.usbmuxd_device { + Some(dev) => dev, + None => return Err(Error::Other("Device is not connected via USB".to_string())), + }; + + let provider = device.to_provider( + UsbmuxdAddr::from_env_var().unwrap_or_default(), + INSTALLATION_LABEL, + ); + + let mut ic = InstallationProxyClient::connect(&provider).await?; + let apps = ic.get_apps(Some("User"), None).await?; + + Ok(apps.contains_key(bundle_id)) + } + + pub async fn install_profile(&self, profile: &MobileProvision) -> Result<(), Error> { + if self.usbmuxd_device.is_none() { + return Err(Error::Other("Device is not connected via USB".to_string())); + } + + let provider = self.usbmuxd_device.clone().unwrap().to_provider( + UsbmuxdAddr::from_env_var().unwrap_or_default(), + INSTALLATION_LABEL, + ); + + let mut mc = MisagentClient::connect(&provider).await?; + mc.install(profile.data.clone()).await?; + + Ok(()) + } + pub async fn pair(&self) -> Result<(), Error> { if self.usbmuxd_device.is_none() { return Err(Error::Other("Device is not connected via USB".to_string())); @@ -204,6 +244,18 @@ impl Device { } } +fn get_app_name_from_info(info: &Value) -> Option { + let dict = info.as_dictionary()?; + dict.get("CFBundleDisplayName") + .and_then(|value| value.as_string()) + .or_else(|| dict.get("CFBundleName").and_then(|value| value.as_string())) + .or_else(|| { + dict.get("CFBundleExecutable") + .and_then(|value| value.as_string()) + }) + .map(|value| value.to_string()) +} + impl fmt::Display for Device { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( @@ -272,7 +324,13 @@ pub async fn install_app_mac(app_path: &PathBuf) -> Result<(), Error> { ) .await?; - let applications_dir = PathBuf::from("/Applications").join(app_name); + let applications_dir = PathBuf::from("/Applications/iOS"); + fs::create_dir_all(&applications_dir).await?; + + let applications_dir = applications_dir.join(app_name); + + fs::remove_dir_all(&applications_dir).await.ok(); + fs::rename(&outer_app_dir, &applications_dir) .await .map_err(|_| Error::BundleFailedToCopy(applications_dir.to_string_lossy().into_owned()))?; diff --git a/crates/plume_types/src/lib.rs b/crates/plume_types/src/lib.rs index 382336c5..9d5b5806 100644 --- a/crates/plume_types/src/lib.rs +++ b/crates/plume_types/src/lib.rs @@ -64,7 +64,7 @@ pub trait PlistInfoTrait { fn get_build_version(&self) -> Option; } -async fn copy_dir_recursively(src: &Path, dst: &Path) -> Result<(), Error> { +pub async fn copy_dir_recursively(src: &Path, dst: &Path) -> Result<(), Error> { use tokio::fs; fs::create_dir_all(dst).await?; diff --git a/crates/plume_types/src/options.rs b/crates/plume_types/src/options.rs index cac16a4c..0e25763b 100644 --- a/crates/plume_types/src/options.rs +++ b/crates/plume_types/src/options.rs @@ -19,6 +19,8 @@ pub struct SignerOptions { pub tweaks: Option>, /// App type. pub app: SignerApp, + /// Apply autorefresh + pub refresh: bool, } impl Default for SignerOptions { @@ -33,6 +35,7 @@ impl Default for SignerOptions { install_mode: SignerInstallMode::default(), tweaks: None, app: SignerApp::Default, + refresh: false, } } } @@ -63,6 +66,7 @@ pub struct SignerFeatures { pub support_game_mode: bool, pub support_pro_motion: bool, pub support_liquid_glass: bool, + pub support_ellekit: bool, pub remove_url_schemes: bool, } @@ -130,6 +134,14 @@ impl SignerAppReal { bundle_id: identifier.map(|s| s.to_string()), } } + + pub fn from_bundle_identifier_and_name(identifier: Option<&str>, name: Option<&str>) -> Self { + let app = SignerApp::from_bundle_identifier_or_name(identifier, name); + Self { + app, + bundle_id: identifier.map(|s| s.to_string()), + } + } } /// Supported app types. @@ -192,6 +204,46 @@ impl SignerApp { SignerApp::Default } + pub fn from_bundle_identifier_or_name( + identifier: Option>, + name: Option>, + ) -> Self { + let app = Self::from_bundle_identifier(identifier); + if app != SignerApp::Default { + return app; + } + + let name = match name { + Some(name) => name.as_ref().to_owned(), + None => return SignerApp::Default, + }; + + let normalized = name + .to_ascii_lowercase() + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::(); + + const KNOWN_APP_NAMES: &[(&str, SignerApp)] = &[ + ("livecontainer", SignerApp::LiveContainer), + ("sidestore", SignerApp::SideStore), + ("altstore", SignerApp::AltStore), + ("feather", SignerApp::Feather), + ("antrag", SignerApp::Antrag), + ("protokolle", SignerApp::Protokolle), + ("stikdebug", SignerApp::StikDebug), + ("sparsebox", SignerApp::SparseBox), + ]; + + for &(needle, app) in KNOWN_APP_NAMES { + if normalized.contains(needle) { + return app; + } + } + + SignerApp::Default + } + pub fn supports_pairing_file(&self) -> bool { use SignerApp::*; !matches!(self, Default | LiveContainer | AltStore) diff --git a/crates/plume_types/src/package.rs b/crates/plume_types/src/package.rs index d1109387..c16e75b8 100644 --- a/crates/plume_types/src/package.rs +++ b/crates/plume_types/src/package.rs @@ -86,7 +86,7 @@ impl Package { Ok(Bundle::new(app_dir)?) } - pub fn get_archive_based_on_path(&self, path: PathBuf) -> Result { + pub fn get_archive_based_on_path(&self, path: &PathBuf) -> Result { if path.is_dir() { self.clone().archive_package_bundle() } else { diff --git a/crates/plume_types/src/signer.rs b/crates/plume_types/src/signer.rs index 8f2e8589..648fcd7b 100644 --- a/crates/plume_types/src/signer.rs +++ b/crates/plume_types/src/signer.rs @@ -124,9 +124,13 @@ impl Signer { } } - if let Some(tweak_files) = self.options.tweaks.as_ref() { + let has_tweaks = self.options.tweaks.as_ref().is_some_and(|t| !t.is_empty()); + + if self.options.features.support_ellekit || has_tweaks { crate::Tweak::install_ellekit(&bundle).await?; + } + if let Some(tweak_files) = self.options.tweaks.as_ref() { for tweak_file in tweak_files { let tweak = crate::Tweak::new(tweak_file, bundle).await?; tweak.apply().await?; @@ -157,6 +161,7 @@ impl Signer { bundle: &Bundle, session: &DeveloperSession, team_id: &String, + is_refresh: bool, ) -> Result<(), Error> { if self.options.mode != SignerMode::Pem { return Ok(()); @@ -221,25 +226,30 @@ impl Signer { if let Some(app_groups) = macho.app_groups_for_entitlements() { let mut app_group_ids: Vec = Vec::new(); for group in &app_groups { - let group = format!("{group}.{team_id}"); + let mut group_name = format!("{group}.{team_id}"); + + if is_refresh { + group_name = group.clone(); + } let group_id = session - .qh_ensure_app_group(&team_id, &group, &group) + .qh_ensure_app_group(&team_id, &group_name, &group_name) .await?; app_group_ids.push(group_id.application_group); } - - if signer_settings.app == SignerApp::SideStore - || signer_settings.app == SignerApp::AltStore - { - bundle.set_info_plist_key( - "ALTAppGroups", - Value::Array( - app_groups - .iter() - .map(|s| Value::String(format!("{s}.{team_id}"))) - .collect(), - ), - )?; + if !is_refresh { + if signer_settings.app == SignerApp::SideStore + || signer_settings.app == SignerApp::AltStore + { + bundle.set_info_plist_key( + "ALTAppGroups", + Value::Array( + app_groups + .iter() + .map(|s| Value::String(format!("{s}.{team_id}"))) + .collect(), + ), + )?; + } } session