From ccea2baf9a430e96b6e8eee517af105c15d8c2cd Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Tue, 13 Jan 2026 03:35:53 -0800 Subject: [PATCH 01/23] make window bigger + add text --- apps/plumeimpactor/src/defaults.rs | 2 +- apps/plumeimpactor/src/screen/general.rs | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/plumeimpactor/src/defaults.rs b/apps/plumeimpactor/src/defaults.rs index 4db6528..fdfbf2b 100644 --- a/apps/plumeimpactor/src/defaults.rs +++ b/apps/plumeimpactor/src/defaults.rs @@ -28,7 +28,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/screen/general.rs b/apps/plumeimpactor/src/screen/general.rs index 2df66db..f216666 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 { @@ -70,7 +70,14 @@ 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![ + 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))) From c78cf58f9d69665feeab0c4dd60cf31f4c931a54 Mon Sep 17 00:00:00 2001 From: se2crid <151872490+se2crid@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:40:13 +0100 Subject: [PATCH 02/23] remove iced_aw dependency --- Cargo.lock | 110 ------------------ apps/plumeimpactor/Cargo.toml | 1 - apps/plumeimpactor/src/screen/settings.rs | 64 +++++----- .../screen/windows/team_selection_window.rs | 36 ++++-- 4 files changed, 57 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c674ed..72ad257 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", @@ -4257,58 +4210,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" @@ -4691,16 +4592,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" @@ -5737,7 +5628,6 @@ dependencies = [ "futures", "gtk", "iced", - "iced_aw", "idevice", "image", "open", diff --git a/apps/plumeimpactor/Cargo.toml b/apps/plumeimpactor/Cargo.toml index bb947a1..cfdb367 100644 --- a/apps/plumeimpactor/Cargo.toml +++ b/apps/plumeimpactor/Cargo.toml @@ -24,7 +24,6 @@ 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/screen/settings.rs b/apps/plumeimpactor/src/screen/settings.rs index 0d691e1..5b0d2db 100644 --- a/apps/plumeimpactor/src/screen/settings.rs +++ b/apps/plumeimpactor/src/screen/settings.rs @@ -1,6 +1,5 @@ -use iced::widget::{button, column, container, row, text}; +use iced::widget::{button, column, container, row, scrollable, text}; use iced::{Alignment, Center, Element, Fill, Task}; -use iced_aw::SelectionList; use plume_store::AccountStore; use crate::appearance; @@ -100,38 +99,39 @@ impl SettingsScreen { 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(), + let account_list = accounts.iter().enumerate().fold( + column![].spacing(5.0), + |content, (index, (_, account))| { + let name = if !account.first_name().is_empty() { + format!("{} ({})", account.first_name(), account.email()) + } else { + account.email().to_string() + }; + let marker = if Some(index) == selected_index { + " [✓] " + } else { + " [ ] " + }; + let style = if Some(index) == selected_index { + appearance::p_button + } else { + appearance::s_button + }; + + content.push( + button( + text(format!("{}{}", marker, name)) + .size(appearance::THEME_FONT_SIZE) + .align_x(Alignment::Start), + ) + .on_press(Message::SelectAccount(index)) + .style(style) + .width(Fill), + ) + }, ); - container(selection_list) + container(scrollable(account_list)) .height(Fill) .style(|theme: &iced::Theme| container::Style { border: iced::Border { diff --git a/apps/plumeimpactor/src/screen/windows/team_selection_window.rs b/apps/plumeimpactor/src/screen/windows/team_selection_window.rs index 96f3353..853732f 100644 --- a/apps/plumeimpactor/src/screen/windows/team_selection_window.rs +++ b/apps/plumeimpactor/src/screen/windows/team_selection_window.rs @@ -1,6 +1,5 @@ use iced::widget::{button, column, container, scrollable, text}; use iced::{Alignment, Element, Length, Task, window}; -use iced_aw::SelectionList; use crate::appearance; @@ -54,19 +53,34 @@ impl TeamSelectionWindow { .size(14) .width(Length::Fill); - let team_labels: &'static [String] = Box::leak(self.teams.clone().into_boxed_slice()); + let team_list = self.teams.iter().enumerate().fold( + column![].spacing(5.0), + |content, (index, team)| { + let marker = if Some(index) == self.selected_index { + " [✓] " + } else { + " [ ] " + }; + let style = if Some(index) == self.selected_index { + appearance::p_button + } else { + appearance::s_button + }; - 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(), + content.push( + button( + text(format!("{}{}", marker, team)) + .size(appearance::THEME_FONT_SIZE) + .align_x(Alignment::Start), + ) + .on_press(Message::SelectTeam(index)) + .style(style) + .width(Length::Fill), + ) + }, ); - let list_container = container(scrollable(selection_list)) + let list_container = container(scrollable(team_list)) .height(Length::Fill) .style(|theme: &iced::Theme| container::Style { border: iced::Border { From 1f29ebfc7ee6e6035b2d3c3b798380c5d110601d Mon Sep 17 00:00:00 2001 From: se2crid <151872490+se2crid@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:20:19 +0100 Subject: [PATCH 03/23] adjust pair file management --- apps/plumeimpactor/src/screen/utilties.rs | 98 +++++++++++++++++------ crates/plume_types/src/device.rs | 18 ++++- crates/plume_types/src/options.rs | 51 ++++++++++++ 3 files changed, 139 insertions(+), 28 deletions(-) diff --git a/apps/plumeimpactor/src/screen/utilties.rs b/apps/plumeimpactor/src/screen/utilties.rs index b382f91..44f4f6a 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/crates/plume_types/src/device.rs b/crates/plume_types/src/device.rs index c4c752c..9e12eae 100644 --- a/crates/plume_types/src/device.rs +++ b/crates/plume_types/src/device.rs @@ -12,6 +12,7 @@ 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 +79,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); @@ -204,6 +209,15 @@ 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!( diff --git a/crates/plume_types/src/options.rs b/crates/plume_types/src/options.rs index cac16a4..c0ceadc 100644 --- a/crates/plume_types/src/options.rs +++ b/crates/plume_types/src/options.rs @@ -130,6 +130,17 @@ 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 +203,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) From f41b99042f6c6abb94fbfcacaf66761d586b8bea Mon Sep 17 00:00:00 2001 From: se2crid <151872490+se2crid@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:35:51 +0100 Subject: [PATCH 04/23] makes github action cross compile all targets --- .github/workflows/build.yml | 101 ++++++++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 383d19e..d098c3f 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 From 1ee726e4f230c0daba28493f90c4012802ebc824 Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Wed, 14 Jan 2026 05:40:15 -0800 Subject: [PATCH 05/23] chore: make it a tiny bit better --- apps/plumeimpactor/src/screen/general.rs | 1 + apps/plumeimpactor/src/screen/package.rs | 10 ++-- apps/plumeimpactor/src/screen/settings.rs | 59 ++++++++++++----------- 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/apps/plumeimpactor/src/screen/general.rs b/apps/plumeimpactor/src/screen/general.rs index f216666..a1a292e 100644 --- a/apps/plumeimpactor/src/screen/general.rs +++ b/apps/plumeimpactor/src/screen/general.rs @@ -71,6 +71,7 @@ impl GeneralScreen { INSTALL_IMAGE_HANDLE.get_or_init(|| image::Handle::from_bytes(INSTALL_IMAGE)); 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) diff --git a/apps/plumeimpactor/src/screen/package.rs b/apps/plumeimpactor/src/screen/package.rs index 11d84e8..94381ea 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; @@ -229,10 +229,10 @@ impl PackageScreen { row![ button(text("Add Tweak").align_x(Center)) .on_press(Message::AddTweak) - .style(appearance::p_button), + .style(appearance::s_button), button(text("Add Bundle").align_x(Center)) .on_press(Message::AddBundle) - .style(appearance::p_button), + .style(appearance::s_button), ] .spacing(8), ] @@ -328,7 +328,7 @@ impl PackageScreen { .width(Fill), button(text("Remove").align_x(Center)) .on_press(Message::RemoveTweak(i)) - .style(appearance::p_button) + .style(appearance::s_button) .padding(6) ] .spacing(8) @@ -337,7 +337,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/settings.rs b/apps/plumeimpactor/src/screen/settings.rs index 5b0d2db..e582ea4 100644 --- a/apps/plumeimpactor/src/screen/settings.rs +++ b/apps/plumeimpactor/src/screen/settings.rs @@ -99,37 +99,38 @@ impl SettingsScreen { accounts: &[(&String, &plume_store::GsaAccount)], selected_index: Option, ) -> Element<'_, Message> { - let account_list = accounts.iter().enumerate().fold( - column![].spacing(5.0), - |content, (index, (_, account))| { - let name = if !account.first_name().is_empty() { - format!("{} ({})", account.first_name(), account.email()) - } else { - account.email().to_string() - }; - let marker = if Some(index) == selected_index { - " [✓] " - } else { - " [ ] " - }; - let style = if Some(index) == selected_index { - appearance::p_button - } else { - appearance::s_button - }; - - content.push( - button( - text(format!("{}{}", marker, name)) - .size(appearance::THEME_FONT_SIZE) - .align_x(Alignment::Start), - ) + let account_list = + accounts + .iter() + .enumerate() + .fold(column![], |content, (index, (_, account))| { + let name = if !account.first_name().is_empty() { + format!("{} ({})", account.first_name(), account.email()) + } else { + account.email().to_string() + }; + let marker = if Some(index) == selected_index { + " [✓] " + } else { + " [ ] " + }; + let style = if Some(index) == selected_index { + appearance::p_button + } else { + appearance::s_button + }; + + content.push( + button( + text(format!("{}{}", marker, name)) + .size(appearance::THEME_FONT_SIZE) + .align_x(Alignment::Start), + ) .on_press(Message::SelectAccount(index)) .style(style) .width(Fill), - ) - }, - ); + ) + }); container(scrollable(account_list)) .height(Fill) @@ -155,7 +156,7 @@ impl SettingsScreen { if let Some(index) = selected_index { buttons = buttons .push( - button(text("Remove Selected").align_x(Center)) + button(text("Remove Account").align_x(Center)) .on_press(Message::RemoveAccount(index)) .style(appearance::s_button), ) From e6922dda90ab73807516780dd5bce845c4038617 Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Wed, 14 Jan 2026 09:30:37 -0800 Subject: [PATCH 06/23] feat: icons, improved team picker, etc --- apps/plumeimpactor/src/appearance/fonts.rs | 64 ++++++ apps/plumeimpactor/src/appearance/mod.rs | 7 + .../src/appearance/plume_icons.ttf | Bin 0 -> 7720 bytes apps/plumeimpactor/src/defaults.rs | 1 + apps/plumeimpactor/src/screen/general.rs | 25 ++- apps/plumeimpactor/src/screen/mod.rs | 193 +++++++++-------- apps/plumeimpactor/src/screen/package.rs | 30 ++- apps/plumeimpactor/src/screen/progress.rs | 11 +- apps/plumeimpactor/src/screen/settings.rs | 205 ++++++++++-------- apps/plumeimpactor/src/screen/windows/mod.rs | 1 - .../screen/windows/team_selection_window.rs | 115 ---------- apps/plumeimpactor/src/subscriptions.rs | 119 +++++----- crates/plume_store/src/gsa_account.rs | 33 ++- crates/plume_store/src/store.rs | 31 ++- 14 files changed, 436 insertions(+), 399 deletions(-) create mode 100644 apps/plumeimpactor/src/appearance/fonts.rs create mode 100755 apps/plumeimpactor/src/appearance/plume_icons.ttf delete mode 100644 apps/plumeimpactor/src/screen/windows/team_selection_window.rs diff --git a/apps/plumeimpactor/src/appearance/fonts.rs b/apps/plumeimpactor/src/appearance/fonts.rs new file mode 100644 index 0000000..71fab36 --- /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 47d1a12..f0c4b7d 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 0000000000000000000000000000000000000000..2efa7ac83adce162e890c262748e05e805f4e07c GIT binary patch literal 7720 zcmd^DTWlLwdOl}{oZ(F*MNvapl;xqulw|QDS(YWol5K_5C0@yES(Y8rS=*#3O0uon zO0wf*y;*M(rKxg79(qHO?xNX6Q55K*+|4F!QKSU|6xoO7A;-59?sZnKUpoI>G$2#g%zh&m5rBL*u(KpS?O=WG;JzV~j>Q zD4Z$V{PJb~I&)(GrzMi|Wvk`?#13)^ROXaHeZVs|H8{!M+hCtPA&+leV;AH`z7ye*e`i)!+t4NHtZMn zW*hcPk+osJ@B#c}z2ptrW2+)xkiFo##s>Q{<^1TQ2*6&C#93EsH0Y4IJj$F7=c>X% zUnLP&8JGDglO=hTNz%>lJaP8S$;T3HO?pj8Rq7JFtGUU~wa)H%ZCD9#Mb#o&SoQOk zrgn7jm*7p!hg+IkRHX^u=B}ROyr;RHr+LfauI>}OyT>LT>fDD8{(5<*wvTI(p64p4wCIRvp3HE?+PjIjsbhAY2&KZ|K61R9=r|Rn6F1h-)>f?ci$Z2OleJt#7xGiQ6ztlT1adTqg7L5lRYonct zU#T7A&b}(wXhYDWPP=`5PNl!m=~sN6fre;+`&5&6PraT}eQJ1Y7pu3|>C86Rto1=o zkpO>hy(j&aJix*%(jVrWaXtzvOw8ELNJN57-I5lDDVth2%*DH57%eQly7^0fz~i~- zZSZbh@p}22o<`3_kMxI|f4BKd6MFdXq2+ISy%#-=6gOL6$Um0H;p=MF!cMT0{X>0R zb-Ce1CXF(e>RNSkRaRG-B0FToaR+lGN8Sl{Bh_Vgl{p;JWss!PCywopYfYi}BXuE< zvM&+uN^|%ej@$uoz8Y(4CZ8UPMYPTnJl3hn8fnqao5`sd={eM?skMhXr4Pc5VX3aZ z?v=0}lC*}pAzk~`FCvXx*SI_qJ0Bb6vZlZ73EhK2_p05V$ZMMaHNVDRtD6j%6Qyuq z8@zm1!^GOVnm(%IA8(BW?|HrVf|1&LfiTzn_bi)#09*94eQa-keJJ2@$WA7~?y$rv zv}HH-*1pKF!BFe&OH8 z>z?uIzVGuooqysByubO0&l}|G^UtdxkHf_y?+1Lf&eqLHWV6*tdA)U0{*jq~VU}ix z`a7DrLvC#e$r9=-XGlJ{VwE{0V?>1y^%jl4Tt39gq*4Fad+64dYs_3()XWn>YMjzVacK`mY)HV_e zc=q}=DWr!S^`CD(7Y-i__iFrh&%{4y@sUgXjSpX!!a*gZUiyBYv?mp&a$?5v7jg*t z80ZRpx{P3IeCTx>K(t^-i#=S2O)bowVYRnv)`XOQ(tEj|Ihw7XrPs@)p(w@%Q(`V0|8i~gn4j+ggaw~gH5e$fMxZ2K%uw5t> zwK}XtVx8T`co&sItwvQ0l+9z@Xi}93(h1p=mRq`xt7Na9ZYr8uSPosvZgylZUCO>n zWi7GK={O$m;@$iChw&~?FyM+j|FI{as#^YM{h2*dmFjl-6umCQ9lp^1*8b6{-n^!& ze(%R#-u5~z!#~Vi;yxuH2Wq6Q_-2Q>*pptT>UVp%=VPzzuDgEn)Sp!;T&;HZb$)wq zw4*DXY;CA_N!RP#viD;YAUv@D*1t&~NiVSdtg%1Zv?r*d#PCrWw-`I`!VTfDL#a>1 z&4OoU9qL8b367J3B6uG+Gmm0AqDdbGMuW-VTW{er8l)D@c0Uk!>#e||790%(e)5yR zHyHzIVle%AP0ph(Dy)tr`wvjsp%TbUUUeXL>AaIrjB(WAdZ<4(SPDM-bYV%xj zl8dVCdPg3NNxfCBe|EVxE2v-Xjgc=yW;F4?kh-|b<@y)3DuihL@~%mhZ=Z{24Y z3$cIP+50d?EIByyvcoo(kPrPfmO&r3u>*MA#!ldiHdcUVZ0unPwrpcBt6^`#L)?KI z4Q?(K8*`@dsEsA&=Lc;pgMPxs4&ZY(b^>3wF>bB=hc@=G^ZYk$>}AdT)9k|HQ%hH; zXIAvq>;b*AqqAGT@|3;+($)FmtX{maGPAI>toQ3v3-c@G+1Z73X<=@0_QqWK^3~G9 z{PK8t`o?T=X%}S|JYQZ~zPd24A53@bq7Ikm%S*+T@+9rFd~>>UWo1gAT3VRXhwMG{ z#ifO7<7Xl|$wZ?&K(p+{NA8!@aom z$)$y9_iTA;CAF|vo_9?yJUKtRP@HruuN0S*Czs0er5Oi8Vc9u%b^gY(Qogmgu(YC- ziu0xNtU9?+N?n~tF`^9P_|+A-Z%=dtOX`B1%MIpredHbHKQkhfvlnurD=J#^g(-dQ zTuw;w;=3-Cky6RH5{<bAt&|8yK=aeWwN3s zs}~BHiWJUfDsee0r0m2kUHA-uY;jUJ#%`@kl7wYMtlSVI>bk#{XBu=sBePz^YcOUA zHkK>r*ELR@Dd9*8IU#DZIoeNX*{sbF)hBiF-k5MSUs!MDRoTJPpil;LF(JqE<4@$k z99_%lVr&eg{(MvyM+hCs=kcSB(LMJslmJ_Ox5iTPG4`50mB^1-#u!KHYQLkil8YFe2miY7)tT0#- z&bAl^v-+C8hP^5s&NwV|CRZ4X7RU2BBOlA_qW^3TV^Ok;ElNtLN#V{W)=|JstGm!O zG6urM$P|TiWlHc81QBXmO1P6cN$ZDN4t51Bkhi~(Czis1DXk~D?)J0nV5Tj$6FA=F z{ek4OR^bVVkcH|6eQ?bvl2c7%vnV-4=urr{EhSuQ6bGy=sva_3?8huj-cfD$BtON! zk-1xiR2zhgqDCy=h9C|kE0Q!QCW`|p5llh_T^E7uw@5Ppct`}PH;!J=^j9?&3YtdI zVS^F`7uDH{VKvVs>gqB=n-htSGY0`v*uBe(`%$nmjUh4K*Q%B;b|*f!V}Gcau%BOYj+rXZ7P zG!IRNA^$L__dDw$F03$CW5CD=sN?ILb2A=llQ>MJ!HJv*85w<0_z`|p@LVCI7k*w> z$MMs#hGjAtvOpMPyjTgl65^#qv>&t~Hq2HQ2l} zxMU6LNhSh`hw2HXerBmkdhXUQ2K{u%)dc;hHJ~50`=KATap*^FGxVdj1^Q9j3jL@( z0R5Brt{wLV>sMPgi6X zPgtjtBU_j>gcFA?}d*|vqz2QwYp84?~86I~xNhG|ps z;_>Y!sX|m#wh-7nPQJkrIUp`&w;9=zLqxX%DN5}3P&D=M-J$LtdU!Rdp zild3$4BL~oHylWc-ozThk78^Mnf@@@;Lx<_fQW;p$`r=UmgS4Mpk*x8DVz;tS{j*W zDKwO|$%PV}g?~L9LbcxSb%wYto_{S^lku&_}Qw57&qu@QfzKBWO;M6$fE%eF%r}wza;U zq<|KuVCK`wb;gDO#sD~hGs$&sQpN#H%30zW#?S=u5I9FX1kMu=fya|~Q46y`IUt6fdZqY6PS+pmJkJIRPh|fe9iO)orh|ffqY0p8h6=+WaMcR|V721-M*)|}=)@*6=q=-{1tgvo3lh%_lZOU~=O!i}+gbHVvuDn@WkH(plm&_XX{>Y1 zTJn1qB!=4-B!=&U_xR3w&zL=Py&qVR*zZ`7*nfzb$F23AwIDG(XF+0k9=s=Z)>}1u z=6Ww!kl26Cg2etJW}dLt`*jNv!;Z(zlyWY`pLIgVQL1~zc#D&+}FN~IP{tn3%!f~2Vn!1{rtr3^{_VRB-Co(l7~y~DRwh2s?*#{+krlL{Lc F_%Bs9gi` iced::Settings { iced::Settings { default_font: appearance::p_font(), default_text_size: appearance::THEME_FONT_SIZE.into(), + fonts: appearance::load_fonts(), ..Default::default() } } diff --git a/apps/plumeimpactor/src/screen/general.rs b/apps/plumeimpactor/src/screen/general.rs index a1a292e..046bb36 100644 --- a/apps/plumeimpactor/src/screen/general.rs +++ b/apps/plumeimpactor/src/screen/general.rs @@ -80,10 +80,13 @@ impl GeneralScreen { .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), @@ -96,14 +99,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 10c7c28..fe71d1e 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,7 @@ 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; #[derive(Debug, Clone)] #[allow(dead_code)] @@ -46,10 +45,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 +65,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, } @@ -110,9 +100,6 @@ impl Impactor { main_window: Some(id), account_store: Some(Self::init_account_store_sync()), 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(), @@ -285,10 +272,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,45 +296,6 @@ 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); @@ -389,11 +335,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() @@ -473,13 +504,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 +516,6 @@ impl Impactor { tray_subscription, hover_subscription, progress_subscription, - team_selection_subscription, close_subscription, ]) } @@ -506,12 +529,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 +542,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 +561,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 +604,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()); @@ -612,14 +629,6 @@ impl Impactor { 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); @@ -634,8 +643,6 @@ impl Impactor { &options, account.as_ref(), &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 94381ea..70a3c19 100644 --- a/apps/plumeimpactor/src/screen/package.rs +++ b/apps/plumeimpactor/src/screen/package.rs @@ -227,10 +227,10 @@ 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::s_button), - button(text("Add Bundle").align_x(Center)) + button(appearance::icon_text(appearance::PLUS, "Add Bundle", None)) .on_press(Message::AddBundle) .style(appearance::s_button), ] @@ -296,14 +296,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,7 +334,7 @@ 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::s_button) .padding(6) diff --git a/apps/plumeimpactor/src/screen/progress.rs b/apps/plumeimpactor/src/screen/progress.rs index 1a0a244..45ac4ab 100644 --- a/apps/plumeimpactor/src/screen/progress.rs +++ b/apps/plumeimpactor/src/screen/progress.rs @@ -115,9 +115,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 e582ea4..fdfcbd7 100644 --- a/apps/plumeimpactor/src/screen/settings.rs +++ b/apps/plumeimpactor/src/screen/settings.rs @@ -1,74 +1,66 @@ -use iced::widget::{button, column, container, row, scrollable, text}; -use iced::{Alignment, Center, Element, Fill, Task}; +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) @@ -85,34 +77,13 @@ impl SettingsScreen { let mut content = column![].spacing(appearance::THEME_PADDING); if !accounts.is_empty() { - content = content.push(self.view_account_list(&accounts, selected_index)); - } 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_list = - accounts - .iter() - .enumerate() - .fold(column![], |content, (index, (_, account))| { - let name = if !account.first_name().is_empty() { - format!("{} ({})", account.first_name(), account.email()) - } else { - account.email().to_string() - }; + 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 @@ -120,34 +91,74 @@ impl SettingsScreen { appearance::s_button }; - content.push( - button( - text(format!("{}{}", marker, name)) - .size(appearance::THEME_FONT_SIZE) - .align_x(Alignment::Start), - ) - .on_press(Message::SelectAccount(index)) - .style(style) - .width(Fill), + let account_button = button( + text(format!("{}{}", marker, account.email())) + .size(appearance::THEME_FONT_SIZE) + .align_x(Alignment::Start), ) - }); - - 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(), + .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() }, - ..Default::default() - }) - .into() + )); + } else { + content = content.push(text("No accounts added yet")); + } + + content = content.push(self.view_account_buttons(selected_index)); + + 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) ] @@ -156,12 +167,16 @@ impl SettingsScreen { if let Some(index) = selected_index { buttons = buttons .push( - button(text("Remove Account").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/windows/mod.rs b/apps/plumeimpactor/src/screen/windows/mod.rs index 96f931f..ea6248e 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 853732f..0000000 --- a/apps/plumeimpactor/src/screen/windows/team_selection_window.rs +++ /dev/null @@ -1,115 +0,0 @@ -use iced::widget::{button, column, container, scrollable, text}; -use iced::{Alignment, Element, Length, Task, window}; - -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_list = self.teams.iter().enumerate().fold( - column![].spacing(5.0), - |content, (index, team)| { - let marker = if Some(index) == self.selected_index { - " [✓] " - } else { - " [ ] " - }; - let style = if Some(index) == self.selected_index { - appearance::p_button - } else { - appearance::s_button - }; - - content.push( - button( - text(format!("{}{}", marker, team)) - .size(appearance::THEME_FONT_SIZE) - .align_x(Alignment::Start), - ) - .on_press(Message::SelectTeam(index)) - .style(style) - .width(Length::Fill), - ) - }, - ); - - let list_container = container(scrollable(team_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 ffc8d3d..b454f69 100644 --- a/apps/plumeimpactor/src/subscriptions.rs +++ b/apps/plumeimpactor/src/subscriptions.rs @@ -190,54 +190,12 @@ 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>, 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}; @@ -273,28 +231,22 @@ 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 { + // Use the stored team_id from the account + let team_id = account.team_id(); + + // Verify the team_id exists in the available teams + 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 + )); + } + + // If team_id is empty (old accounts), use the first team + 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( @@ -458,12 +410,22 @@ 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 { + // Use the stored team_id from the account + let team_id = account.team_id(); + + // Verify the team_id exists in the available teams + 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 + )); + } + + // If team_id is empty (old accounts), use the first team + 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 +466,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/crates/plume_store/src/gsa_account.rs b/crates/plume_store/src/gsa_account.rs index f03cc6c..ed2ea6a 100644 --- a/crates/plume_store/src/gsa_account.rs +++ b/crates/plume_store/src/gsa_account.rs @@ -6,15 +6,24 @@ pub struct GsaAccount { first_name: String, adsid: String, xcode_gs_token: String, + #[serde(default)] + team_id: String, } impl GsaAccount { - pub fn new(email: String, first_name: String, adsid: String, xcode_gs_token: String) -> 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/store.rs b/crates/plume_store/src/store.rs index 984f570..8f6fa61 100644 --- a/crates/plume_store/src/store.rs +++ b/crates/plume_store/src/store.rs @@ -7,10 +7,11 @@ use plume_core::Error; use crate::gsa_account::GsaAccount; -#[derive(Debug, Serialize, Deserialize, Default)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] pub struct AccountStore { selected_account: Option, accounts: HashMap, + #[serde(skip)] path: Option, } @@ -123,14 +124,38 @@ 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) + } + } } From 5d2ea4d1885fac04648483c1ee0746fffdd91d01 Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Wed, 14 Jan 2026 09:41:45 -0800 Subject: [PATCH 07/23] chore: remove claude comments --- apps/plumeimpactor/src/subscriptions.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/plumeimpactor/src/subscriptions.rs b/apps/plumeimpactor/src/subscriptions.rs index b454f69..ec58a98 100644 --- a/apps/plumeimpactor/src/subscriptions.rs +++ b/apps/plumeimpactor/src/subscriptions.rs @@ -231,10 +231,8 @@ pub(crate) async fn run_installation( return Err("No teams available for this account".to_string()); } - // Use the stored team_id from the account let team_id = account.team_id(); - // Verify the team_id exists in the available teams 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.", @@ -242,7 +240,6 @@ pub(crate) async fn run_installation( )); } - // If team_id is empty (old accounts), use the first team let team_id = if team_id.is_empty() { &teams_response.teams[0].team_id } else { @@ -410,10 +407,8 @@ pub(crate) async fn export_certificate(account: plume_store::GsaAccount) -> Resu return Err("No teams available for this account".to_string()); } - // Use the stored team_id from the account let team_id = account.team_id(); - // Verify the team_id exists in the available teams 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.", @@ -421,7 +416,6 @@ pub(crate) async fn export_certificate(account: plume_store::GsaAccount) -> Resu )); } - // If team_id is empty (old accounts), use the first team let team_id = if team_id.is_empty() { &teams_response.teams[0].team_id } else { From 38ffbcb938cae1190dffe27aee3728a9c41bb047 Mon Sep 17 00:00:00 2001 From: se2crid <151872490+se2crid@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:07:13 +0100 Subject: [PATCH 08/23] fix: ipa/tipa picker on windows --- apps/plumeimpactor/src/screen/general.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/plumeimpactor/src/screen/general.rs b/apps/plumeimpactor/src/screen/general.rs index 046bb36..f5dbe78 100644 --- a/apps/plumeimpactor/src/screen/general.rs +++ b/apps/plumeimpactor/src/screen/general.rs @@ -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 { From 6ecb6b7bf811536328caa9935f6b944af9a13354 Mon Sep 17 00:00:00 2001 From: se2crid <151872490+se2crid@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:21:49 +0100 Subject: [PATCH 09/23] fix padding --- apps/plumeimpactor/src/screen/windows/login_window.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/plumeimpactor/src/screen/windows/login_window.rs b/apps/plumeimpactor/src/screen/windows/login_window.rs index 82f0641..fbc70f2 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); From 7428da0bdb20c000886eb8f361e8acf306533984 Mon Sep 17 00:00:00 2001 From: se2crid <151872490+se2crid@users.noreply.github.com> Date: Wed, 14 Jan 2026 18:34:06 +0100 Subject: [PATCH 10/23] shows finnished when reaching 100% installed --- apps/plumeimpactor/src/screen/progress.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/plumeimpactor/src/screen/progress.rs b/apps/plumeimpactor/src/screen/progress.rs index 45ac4ab..61cc776 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; From 38451540d95b1c74ec3303726471ce588416d3a7 Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Wed, 14 Jan 2026 12:11:20 -0800 Subject: [PATCH 11/23] chore: renames some variables + some misc stuff --- Cargo.lock | 34 +++++++++++++++++++ Cargo.toml | 10 +----- crates/plume_core/src/utils/certificate.rs | 39 ++++++++++++++-------- crates/plume_store/Cargo.toml | 1 + 4 files changed, 61 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72ad257..d08d439 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3689,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", @@ -3967,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" @@ -4548,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" @@ -5597,6 +5629,7 @@ version = "1.4.1" name = "plume_store" version = "1.4.1" dependencies = [ + "chrono", "plume_core", "serde", "serde_json", @@ -5614,6 +5647,7 @@ dependencies = [ "log", "plist", "plume_core", + "plume_store", "thiserror 2.0.17", "tokio", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 7f70349..fe14c30 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/crates/plume_core/src/utils/certificate.rs b/crates/plume_core/src/utils/certificate.rs index 424b259..fb508a5 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_store/Cargo.toml b/crates/plume_store/Cargo.toml index 6b208c0..55e26ce 100644 --- a/crates/plume_store/Cargo.toml +++ b/crates/plume_store/Cargo.toml @@ -13,3 +13,4 @@ plume_core = { path = "../plume_core", features = ["tweaks"] } # TODO: move this to workspace serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } +chrono = { version = "0.4.42", features = ["serde"] } From cf2e3d6b1d228b5738c4fe44db2cf81d7d9ce3b2 Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Wed, 14 Jan 2026 12:11:39 -0800 Subject: [PATCH 12/23] feat: misagent profile installer for device object --- crates/plume_types/src/device.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/plume_types/src/device.rs b/crates/plume_types/src/device.rs index 9e12eae..a017149 100644 --- a/crates/plume_types/src/device.rs +++ b/crates/plume_types/src/device.rs @@ -4,8 +4,10 @@ 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; @@ -94,6 +96,22 @@ impl Device { Ok(found_apps) } + 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())); @@ -214,7 +232,10 @@ fn get_app_name_from_info(info: &Value) -> Option { 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())) + .or_else(|| { + dict.get("CFBundleExecutable") + .and_then(|value| value.as_string()) + }) .map(|value| value.to_string()) } From 90a16e7819618d5474f170a05e6eb8cc335207ea Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Thu, 15 Jan 2026 08:11:03 -0800 Subject: [PATCH 13/23] horrible auto refresh impl --- Cargo.lock | 2 + apps/plumeimpactor/Cargo.toml | 4 + apps/plumeimpactor/src/main.rs | 9 + apps/plumeimpactor/src/refresh.rs | 316 ++++++++++++++++++++++ apps/plumeimpactor/src/screen/mod.rs | 189 ++++++++++++- apps/plumeimpactor/src/screen/package.rs | 8 + apps/plumeimpactor/src/screen/progress.rs | 4 + apps/plumeimpactor/src/subscriptions.rs | 74 ++++- apps/plumeimpactor/src/tray.rs | 114 +++++++- crates/plume_core/src/utils/provision.rs | 31 ++- crates/plume_store/src/lib.rs | 2 + crates/plume_store/src/refresh.rs | 29 ++ crates/plume_store/src/store.rs | 61 ++++- crates/plume_types/Cargo.toml | 1 + crates/plume_types/src/device.rs | 8 +- crates/plume_types/src/lib.rs | 2 +- crates/plume_types/src/options.rs | 9 +- crates/plume_types/src/package.rs | 2 +- crates/plume_types/src/signer.rs | 36 ++- 19 files changed, 851 insertions(+), 50 deletions(-) create mode 100644 apps/plumeimpactor/src/refresh.rs create mode 100644 crates/plume_store/src/refresh.rs diff --git a/Cargo.lock b/Cargo.lock index d08d439..1bfa58d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5658,12 +5658,14 @@ dependencies = [ name = "plumeimpactor" version = "1.4.1" dependencies = [ + "chrono", "env_logger", "futures", "gtk", "iced", "idevice", "image", + "log", "open", "plist", "plume_core", diff --git a/apps/plumeimpactor/Cargo.toml b/apps/plumeimpactor/Cargo.toml index cfdb367..8f92b0a 100644 --- a/apps/plumeimpactor/Cargo.toml +++ b/apps/plumeimpactor/Cargo.toml @@ -14,10 +14,14 @@ uuid.workspace = true plist.workspace = true futures.workspace = true env_logger.workspace = true +log.workspace = true 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"] } diff --git a/apps/plumeimpactor/src/main.rs b/apps/plumeimpactor/src/main.rs index 68c9a85..4b948f9 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,12 @@ fn main() -> iced::Result { gtk::init().expect("GTK init failed"); } + // Spawn refresh daemon in background + let (_daemon_handle, connected_devices) = spawn_refresh_daemon(); + + // Store the connected_devices reference for the application to use + 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 0000000..01dd100 --- /dev/null +++ b/apps/plumeimpactor/src/refresh.rs @@ -0,0 +1,316 @@ +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 with_check_interval(mut self, interval: Duration) -> Self { + self.check_interval = interval; + self + } + + pub fn connected_devices(&self) -> ConnectedDevices { + self.connected_devices.clone() + } + + pub fn update_device(&self, device: Device) { + if let Ok(mut devices) = self.connected_devices.lock() { + devices.insert(device.udid.clone(), device); + } + } + + pub fn remove_device(&self, udid: &str) { + if let Ok(mut devices) = self.connected_devices.lock() { + devices.remove(udid); + } + } + + 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 + }; + + if device.is_mac { + println!("Mac device - updating provisioning profiles only..."); + self.update_provisioning_profiles(app, device, &session, team_id) + .await?; + } else if identity_is_new { + println!("Certificate is new, resigning and reinstalling app..."); + self.resign_and_reinstall(app, device, &session, team_id) + .await?; + } else { + println!("Certificate exists, 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/mod.rs b/apps/plumeimpactor/src/screen/mod.rs index fe71d1e..dc0c326 100644 --- a/apps/plumeimpactor/src/screen/mod.rs +++ b/apps/plumeimpactor/src/screen/mod.rs @@ -18,6 +18,13 @@ use crate::tray::ImpactorTray; use crate::{appearance, defaults}; 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)] pub enum Message { @@ -37,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, @@ -87,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()); ( @@ -98,7 +118,7 @@ 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(), pending_installation: false, }, @@ -108,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 { @@ -140,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()), @@ -150,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()), @@ -221,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() } @@ -469,12 +518,130 @@ impl Impactor { if let ImpactorScreen::Progress(ref mut screen) = self.current_screen { match msg { progress::Message::Back => Task::done(Message::PreviousScreen), + progress::Message::InstallationFinished => { + log::info!("Received InstallationFinished, triggering UpdateTrayMenu"); + 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 => { + log::info!("UpdateTrayMenu: Reloading account store"); + self.account_store = Some(Self::init_account_store_sync()); + + if let Some(store) = &self.account_store { + log::info!( + "UpdateTrayMenu: Recreating tray with {} refresh devices", + store.refreshes().len() + ); + let mut new_tray = crate::tray::ImpactorTray::new(); + new_tray.update_refresh_apps(store); + self.tray = Some(new_tray); + } else { + log::warn!("UpdateTrayMenu: Store not available"); + } + Task::none() + } Message::StartInstallation => Task::none(), } } @@ -625,6 +792,7 @@ 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)); @@ -642,6 +810,7 @@ impl Impactor { device.as_ref(), &options, account.as_ref(), + store.as_mut(), &tx, ) .await @@ -656,7 +825,7 @@ impl Impactor { }); }); - Task::none() + Task::done(Message::UpdateTrayMenu) } else { Task::none() } diff --git a/apps/plumeimpactor/src/screen/package.rs b/apps/plumeimpactor/src/screen/package.rs index 70a3c19..4a48b61 100644 --- a/apps/plumeimpactor/src/screen/package.rs +++ b/apps/plumeimpactor/src/screen/package.rs @@ -18,6 +18,7 @@ pub enum Message { ToggleProMotion(bool), ToggleSingleProfile(bool), ToggleLiquidGlass(bool), + ToggleRefresh(bool), UpdateSignerMode(SignerMode), UpdateInstallMode(SignerInstallMode), AddTweak, @@ -113,6 +114,10 @@ impl PackageScreen { self.options.features.support_liquid_glass = value; Task::none() } + Message::ToggleRefresh(value) => { + self.options.refresh = value; + Task::none() + } Message::UpdateSignerMode(mode) => { self.options.mode = mode; Task::none() @@ -266,6 +271,9 @@ impl PackageScreen { checkbox(self.options.features.support_liquid_glass) .label("Force Liquid Glass (26+)") .on_toggle(Message::ToggleLiquidGlass), + checkbox(self.options.refresh) + .label("Auto Refresh") + .on_toggle(Message::ToggleRefresh), text("Mode:").size(12), pick_list( &[SignerInstallMode::Install, SignerInstallMode::Export][..], diff --git a/apps/plumeimpactor/src/screen/progress.rs b/apps/plumeimpactor/src/screen/progress.rs index 61cc776..e5f8f67 100644 --- a/apps/plumeimpactor/src/screen/progress.rs +++ b/apps/plumeimpactor/src/screen/progress.rs @@ -72,6 +72,10 @@ impl ProgressScreen { } else if progress >= 100 { self.progress_rx = None; self.is_installing = false; + + // Update tray menu after successful installation + log::info!("Installation finished, sending InstallationFinished message"); + return Task::done(Message::InstallationFinished); } Task::none() diff --git a/apps/plumeimpactor/src/subscriptions.rs b/apps/plumeimpactor/src/subscriptions.rs index ec58a98..2c91759 100644 --- a/apps/plumeimpactor/src/subscriptions.rs +++ b/apps/plumeimpactor/src/subscriptions.rs @@ -3,7 +3,10 @@ use idevice::usbmuxd::{UsbmuxdConnection, UsbmuxdListenEvent}; use std::sync::Arc; use tray_icon::{TrayIconEvent, menu::MenuEvent}; -use crate::screen::{Message, general}; +use crate::{ + defaults::get_data_path, + screen::{Message, general}, +}; use plume_utils::Device; pub(crate) fn device_listener() -> Subscription { @@ -195,6 +198,7 @@ pub(crate) async fn run_installation( 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)>, ) -> Result<(), String> { use plume_core::{AnisetteConfiguration, CertificateIdentity, developer::DeveloperSession}; @@ -277,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 @@ -362,7 +366,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) .map_err(|e| e.to_string())?; let file = rfd::AsyncFileDialog::new() @@ -384,6 +388,70 @@ pub(crate) async fn run_installation( } } + if options.refresh && options.mode == SignerMode::Pem { + 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.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, &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"); + if embedded_prov_path.exists() { + use plume_core::MobileProvision; + + if let Ok(provision) = MobileProvision::load_with_path(&embedded_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 { + 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(), + 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())?; + } + } + } + } + send("Finished!".to_string(), 100); Ok(()) diff --git a/apps/plumeimpactor/src/tray.rs b/apps/plumeimpactor/src/tray.rs index 85e8d33..1430f37 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, + menu: Menu, show_item_id: MenuId, quit_item_id: MenuId, + action_map: HashMap, } impl ImpactorTray { @@ -38,22 +48,114 @@ impl ImpactorTray { let show_item_id = show_item.id().clone(); let quit_item_id = quit_item.id().clone(); + 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 _ = tray_menu.append_items(&[&show_item, &PredefinedMenuItem::separator(), &quit_item]); let icon = build_tray_icon(&tray_menu); Self { icon, + 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) { + log::info!( + "Updating tray menu with {} refresh devices", + store.refreshes().len() + ); + + 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()); + + if !store.refreshes().is_empty() { + let refresh_submenu = Submenu::new("Auto-Refresh Apps", true); + + for (udid, refresh_device) in store.refreshes() { + let device_name = if refresh_device.is_mac { + "This Mac".to_string() + } else { + format!("Device {}", &udid[..8.min(udid.len())]) + }; + + if !refresh_device.apps.is_empty() { + let device_submenu = Submenu::new(&device_name, true); + + for app in &refresh_device.apps { + let app_name = app + .path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("Unknown App"); + + let scheduled = app.scheduled_refresh.format("%Y-%m-%d %H:%M").to_string(); + + let app_submenu = + Submenu::new(&format!("{} ({})", app_name, scheduled), true); + + let refresh_item = MenuItem::new("Refresh Now", true, None); + let forget_item = MenuItem::new("Forget App", true, None); + + let refresh_id = refresh_item.id().clone(); + let forget_id = forget_item.id().clone(); + + action_map.insert( + refresh_id, + TrayAction::RefreshApp { + udid: udid.clone(), + app_path: app.path.to_string_lossy().to_string(), + }, + ); + action_map.insert( + forget_id, + 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 _ = device_submenu.append(&app_submenu); + } + + let _ = refresh_submenu.append(&device_submenu); + } + } + + 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(); + + log::info!("Rebuilding tray icon with new menu"); + let new_icon = build_tray_icon(&new_menu); + self.icon = new_icon; + + self.menu = new_menu; + self.action_map = action_map; + log::info!("Tray menu updated successfully"); } - 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/crates/plume_core/src/utils/provision.rs b/crates/plume_core/src/utils/provision.rs index e273b88..b9b2bc4 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", // 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, + 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 8f6fa61..1ff28b5 100644 --- a/crates/plume_store/src/store.rs +++ b/crates/plume_store/src/store.rs @@ -5,12 +5,14 @@ use serde::{Deserialize, Serialize}; use plume_core::Error; -use crate::gsa_account::GsaAccount; +use crate::{GsaAccount, RefreshDevice}; #[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, } @@ -31,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() { @@ -57,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) } @@ -158,4 +179,38 @@ impl AccountStore { 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 d7bf2cf..67c45c4 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 a017149..30fd1de 100644 --- a/crates/plume_types/src/device.rs +++ b/crates/plume_types/src/device.rs @@ -307,7 +307,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 382336c..9d5b580 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 c0ceadc..0e25763 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, } @@ -131,10 +135,7 @@ impl SignerAppReal { } } - pub fn from_bundle_identifier_and_name( - identifier: Option<&str>, - name: Option<&str>, - ) -> Self { + 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, diff --git a/crates/plume_types/src/package.rs b/crates/plume_types/src/package.rs index d110938..c16e75b 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 8f2e858..ab26858 100644 --- a/crates/plume_types/src/signer.rs +++ b/crates/plume_types/src/signer.rs @@ -157,6 +157,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 +222,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 From 0e29843873689dedc6cef6496ac45f4fb0c164a5 Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Thu, 15 Jan 2026 09:20:48 -0800 Subject: [PATCH 14/23] move this up --- apps/plumeimpactor/src/refresh.rs | 17 --- apps/plumeimpactor/src/screen/mod.rs | 2 +- apps/plumeimpactor/src/subscriptions.rs | 140 +++++++++++++----------- 3 files changed, 77 insertions(+), 82 deletions(-) diff --git a/apps/plumeimpactor/src/refresh.rs b/apps/plumeimpactor/src/refresh.rs index 01dd100..b3c280f 100644 --- a/apps/plumeimpactor/src/refresh.rs +++ b/apps/plumeimpactor/src/refresh.rs @@ -29,27 +29,10 @@ impl RefreshDaemon { } } - pub fn with_check_interval(mut self, interval: Duration) -> Self { - self.check_interval = interval; - self - } - pub fn connected_devices(&self) -> ConnectedDevices { self.connected_devices.clone() } - pub fn update_device(&self, device: Device) { - if let Ok(mut devices) = self.connected_devices.lock() { - devices.insert(device.udid.clone(), device); - } - } - - pub fn remove_device(&self, udid: &str) { - if let Ok(mut devices) = self.connected_devices.lock() { - devices.remove(udid); - } - } - pub fn spawn(self) -> thread::JoinHandle<()> { thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() diff --git a/apps/plumeimpactor/src/screen/mod.rs b/apps/plumeimpactor/src/screen/mod.rs index dc0c326..bfee19c 100644 --- a/apps/plumeimpactor/src/screen/mod.rs +++ b/apps/plumeimpactor/src/screen/mod.rs @@ -825,7 +825,7 @@ impl Impactor { }); }); - Task::done(Message::UpdateTrayMenu) + Task::none() } else { Task::none() } diff --git a/apps/plumeimpactor/src/subscriptions.rs b/apps/plumeimpactor/src/subscriptions.rs index 2c91759..38f6661 100644 --- a/apps/plumeimpactor/src/subscriptions.rs +++ b/apps/plumeimpactor/src/subscriptions.rs @@ -322,6 +322,82 @@ pub(crate) async fn run_installation( } } + 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.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, &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 { + 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(), + 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())?; + } else { + eprintln!("Failed to load mobile provision from {:?}", prov_path); + } + } else { + eprintln!("No embedded provision found in {:?}", dest_path); + } + } + } + match options.install_mode { SignerInstallMode::Install => { if let Some(dev) = &device { @@ -388,70 +464,6 @@ pub(crate) async fn run_installation( } } - if options.refresh && options.mode == SignerMode::Pem { - 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.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, &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"); - if embedded_prov_path.exists() { - use plume_core::MobileProvision; - - if let Ok(provision) = MobileProvision::load_with_path(&embedded_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 { - 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(), - 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())?; - } - } - } - } - send("Finished!".to_string(), 100); Ok(()) From ecfd6a920c33941889e1154df0d319da3875de9c Mon Sep 17 00:00:00 2001 From: SAMSAM Date: Fri, 16 Jan 2026 08:01:34 -0800 Subject: [PATCH 15/23] fix loonix tray --- apps/plumeimpactor/src/tray.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/plumeimpactor/src/tray.rs b/apps/plumeimpactor/src/tray.rs index 1430f37..0f80efe 100644 --- a/apps/plumeimpactor/src/tray.rs +++ b/apps/plumeimpactor/src/tray.rs @@ -32,7 +32,7 @@ pub enum TrayAction { } pub(crate) struct ImpactorTray { - icon: TrayIcon, + icon: Option, menu: Menu, show_item_id: MenuId, quit_item_id: MenuId, @@ -54,10 +54,10 @@ impl ImpactorTray { let _ = tray_menu.append_items(&[&show_item, &PredefinedMenuItem::separator(), &quit_item]); - let icon = build_tray_icon(&tray_menu); - + // Do not build the tray icon here to avoid a double registration + // during startup: `update_refresh_apps` will create the icon once. Self { - icon, + icon: None, menu: tray_menu, show_item_id, quit_item_id, @@ -147,8 +147,13 @@ impl ImpactorTray { self.quit_item_id = quit_item.id().clone(); log::info!("Rebuilding tray icon with new menu"); + + if let Some(old_icon) = self.icon.take() { + drop(old_icon); + } + let new_icon = build_tray_icon(&new_menu); - self.icon = new_icon; + self.icon = Some(new_icon); self.menu = new_menu; self.action_map = action_map; From 2ca8460d20db0dd7ff616fe057e2b9785df6894c Mon Sep 17 00:00:00 2001 From: SAMSAM Date: Fri, 16 Jan 2026 08:27:58 -0800 Subject: [PATCH 16/23] fix compiling plumesign --- apps/plumesign/src/commands/sign.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/plumesign/src/commands/sign.rs b/apps/plumesign/src/commands/sign.rs index 6a5e76d..60e2e8a 100644 --- a/apps/plumesign/src/commands/sign.rs +++ b/apps/plumesign/src/commands/sign.rs @@ -149,7 +149,7 @@ 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 +191,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(); From 00a332005666e95f3a91c5d4f1fb0152c786f908 Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Fri, 16 Jan 2026 09:06:33 -0800 Subject: [PATCH 17/23] lint --- apps/plumesign/src/commands/sign.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/plumesign/src/commands/sign.rs b/apps/plumesign/src/commands/sign.rs index 60e2e8a..1e8cf94 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, false).await?; + signer + .register_bundle(&bundle, &session, &team_id, false) + .await?; signer.sign_bundle(&bundle).await?; if let Some(dev) = device { From 0ea44739ef5d68d0e00255091221a21d046d4a3f Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Fri, 16 Jan 2026 09:26:27 -0800 Subject: [PATCH 18/23] potential fix --- apps/plumeimpactor/src/main.rs | 3 --- apps/plumeimpactor/src/tray.rs | 15 ++++----------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/apps/plumeimpactor/src/main.rs b/apps/plumeimpactor/src/main.rs index 4b948f9..c93fc46 100644 --- a/apps/plumeimpactor/src/main.rs +++ b/apps/plumeimpactor/src/main.rs @@ -21,10 +21,7 @@ fn main() -> iced::Result { gtk::init().expect("GTK init failed"); } - // Spawn refresh daemon in background let (_daemon_handle, connected_devices) = spawn_refresh_daemon(); - - // Store the connected_devices reference for the application to use screen::set_refresh_daemon_devices(connected_devices); iced::daemon( diff --git a/apps/plumeimpactor/src/tray.rs b/apps/plumeimpactor/src/tray.rs index 0f80efe..6c10c7e 100644 --- a/apps/plumeimpactor/src/tray.rs +++ b/apps/plumeimpactor/src/tray.rs @@ -54,10 +54,8 @@ impl ImpactorTray { let _ = tray_menu.append_items(&[&show_item, &PredefinedMenuItem::separator(), &quit_item]); - // Do not build the tray icon here to avoid a double registration - // during startup: `update_refresh_apps` will create the icon once. Self { - icon: None, + icon: Some(build_tray_icon(&tray_menu)), menu: tray_menu, show_item_id, quit_item_id, @@ -148,16 +146,11 @@ impl ImpactorTray { log::info!("Rebuilding tray icon with new menu"); - if let Some(old_icon) = self.icon.take() { - drop(old_icon); - } - - let new_icon = build_tray_icon(&new_menu); - self.icon = Some(new_icon); - self.menu = new_menu; self.action_map = action_map; - log::info!("Tray menu updated successfully"); + if let Some(tray_icon) = &mut self.icon { + let _ = tray_icon.set_menu(Some(Box::new(self.menu.clone()))); + } } pub(crate) fn get_action(&self, id: &MenuId) -> Option<&TrayAction> { From 7e688e38264c98aeb344f76ec0aaf05a47ff3d9f Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Fri, 16 Jan 2026 09:36:37 -0800 Subject: [PATCH 19/23] another fix --- apps/plumeimpactor/src/screen/mod.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/plumeimpactor/src/screen/mod.rs b/apps/plumeimpactor/src/screen/mod.rs index bfee19c..8fd53b9 100644 --- a/apps/plumeimpactor/src/screen/mod.rs +++ b/apps/plumeimpactor/src/screen/mod.rs @@ -634,9 +634,16 @@ impl Impactor { "UpdateTrayMenu: Recreating tray with {} refresh devices", store.refreshes().len() ); - let mut new_tray = crate::tray::ImpactorTray::new(); - new_tray.update_refresh_apps(store); - self.tray = Some(new_tray); + 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); + } + } } else { log::warn!("UpdateTrayMenu: Store not available"); } From 367d283107bdf092c439e9437eab58019700c7eb Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Fri, 16 Jan 2026 10:05:48 -0800 Subject: [PATCH 20/23] improve readability of tray --- Cargo.lock | 1 - apps/plumeimpactor/Cargo.toml | 1 - apps/plumeimpactor/src/screen/mod.rs | 8 -- apps/plumeimpactor/src/screen/progress.rs | 2 - apps/plumeimpactor/src/subscriptions.rs | 30 +++--- apps/plumeimpactor/src/tray.rs | 108 +++++++++++----------- crates/plume_store/src/refresh.rs | 2 + 7 files changed, 70 insertions(+), 82 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1bfa58d..a633a45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5665,7 +5665,6 @@ dependencies = [ "iced", "idevice", "image", - "log", "open", "plist", "plume_core", diff --git a/apps/plumeimpactor/Cargo.toml b/apps/plumeimpactor/Cargo.toml index 8f92b0a..76de938 100644 --- a/apps/plumeimpactor/Cargo.toml +++ b/apps/plumeimpactor/Cargo.toml @@ -14,7 +14,6 @@ uuid.workspace = true plist.workspace = true futures.workspace = true env_logger.workspace = true -log.workspace = true plume_core = { path = "../../crates/plume_core", features = ["tweaks"] } plume_utils = { path = "../../crates/plume_types" } plume_store = { path = "../../crates/plume_store" } diff --git a/apps/plumeimpactor/src/screen/mod.rs b/apps/plumeimpactor/src/screen/mod.rs index 8fd53b9..d392adc 100644 --- a/apps/plumeimpactor/src/screen/mod.rs +++ b/apps/plumeimpactor/src/screen/mod.rs @@ -519,7 +519,6 @@ impl Impactor { match msg { progress::Message::Back => Task::done(Message::PreviousScreen), progress::Message::InstallationFinished => { - log::info!("Received InstallationFinished, triggering UpdateTrayMenu"); Task::done(Message::UpdateTrayMenu) } _ => screen.update(msg).map(Message::ProgressScreen), @@ -626,14 +625,9 @@ impl Impactor { Task::done(Message::UpdateTrayMenu) } Message::UpdateTrayMenu => { - log::info!("UpdateTrayMenu: Reloading account store"); self.account_store = Some(Self::init_account_store_sync()); if let Some(store) = &self.account_store { - log::info!( - "UpdateTrayMenu: Recreating tray with {} refresh devices", - store.refreshes().len() - ); match &mut self.tray { Some(existing_tray) => { existing_tray.update_refresh_apps(&store); @@ -644,8 +638,6 @@ impl Impactor { self.tray = Some(new_tray); } } - } else { - log::warn!("UpdateTrayMenu: Store not available"); } Task::none() } diff --git a/apps/plumeimpactor/src/screen/progress.rs b/apps/plumeimpactor/src/screen/progress.rs index e5f8f67..7d00003 100644 --- a/apps/plumeimpactor/src/screen/progress.rs +++ b/apps/plumeimpactor/src/screen/progress.rs @@ -73,8 +73,6 @@ impl ProgressScreen { self.progress_rx = None; self.is_installing = false; - // Update tray menu after successful installation - log::info!("Installation finished, sending InstallationFinished message"); return Task::done(Message::InstallationFinished); } diff --git a/apps/plumeimpactor/src/subscriptions.rs b/apps/plumeimpactor/src/subscriptions.rs index 38f6661..5a4021b 100644 --- a/apps/plumeimpactor/src/subscriptions.rs +++ b/apps/plumeimpactor/src/subscriptions.rs @@ -7,7 +7,7 @@ use crate::{ defaults::get_data_path, screen::{Message, general}, }; -use plume_utils::Device; +use plume_utils::{Bundle, Device, PlistInfoTrait}; pub(crate) fn device_listener() -> Subscription { Subscription::run(|| { @@ -204,7 +204,7 @@ pub(crate) async fn run_installation( 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)); @@ -290,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); @@ -311,14 +311,14 @@ 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; } } @@ -329,7 +329,11 @@ pub(crate) async fn run_installation( .await .map_err(|e| e.to_string())?; - let original_name = package_file.file_name().unwrap().to_string_lossy(); + 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); @@ -339,7 +343,7 @@ pub(crate) async fn run_installation( }; let dest_path = path.join(dest_name); - plume_utils::copy_dir_recursively(&package_file, &dest_path) + plume_utils::copy_dir_recursively(&package_file.bundle_dir(), &dest_path) .await .map_err(|e| e.to_string())?; @@ -364,6 +368,7 @@ pub(crate) async fn run_installation( let scheduled_refresh = scheduled_refresh - chrono::Duration::days(1); let refresh_app = plume_store::RefreshApp { + name: package_file.get_name(), path: dest_path.clone(), scheduled_refresh, }; @@ -373,6 +378,7 @@ pub(crate) async fn run_installation( .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, @@ -389,11 +395,7 @@ pub(crate) async fn run_installation( store .add_or_update_refresh_device_sync(refresh_device) .map_err(|e| e.to_string())?; - } else { - eprintln!("Failed to load mobile provision from {:?}", prov_path); } - } else { - eprintln!("No embedded provision found in {:?}", dest_path); } } } @@ -405,7 +407,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))); @@ -430,7 +432,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())?; } @@ -442,7 +444,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() diff --git a/apps/plumeimpactor/src/tray.rs b/apps/plumeimpactor/src/tray.rs index 6c10c7e..07ce35c 100644 --- a/apps/plumeimpactor/src/tray.rs +++ b/apps/plumeimpactor/src/tray.rs @@ -64,11 +64,6 @@ impl ImpactorTray { } pub(crate) fn update_refresh_apps(&mut self, store: &plume_store::AccountStore) { - log::info!( - "Updating tray menu with {} refresh devices", - store.refreshes().len() - ); - let new_menu = Menu::new(); let show_item = MenuItem::new("Open", true, None); @@ -78,59 +73,61 @@ impl ImpactorTray { let _ = new_menu.append(&show_item); let _ = new_menu.append(&PredefinedMenuItem::separator()); - if !store.refreshes().is_empty() { + 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() { - let device_name = if refresh_device.is_mac { - "This Mac".to_string() - } else { - format!("Device {}", &udid[..8.min(udid.len())]) - }; - - if !refresh_device.apps.is_empty() { - let device_submenu = Submenu::new(&device_name, true); - - for app in &refresh_device.apps { - let app_name = app - .path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("Unknown App"); - - let scheduled = app.scheduled_refresh.format("%Y-%m-%d %H:%M").to_string(); - - let app_submenu = - Submenu::new(&format!("{} ({})", app_name, scheduled), true); - - let refresh_item = MenuItem::new("Refresh Now", true, None); - let forget_item = MenuItem::new("Forget App", true, None); - - let refresh_id = refresh_item.id().clone(); - let forget_id = forget_item.id().clone(); - - action_map.insert( - refresh_id, - TrayAction::RefreshApp { - udid: udid.clone(), - app_path: app.path.to_string_lossy().to_string(), - }, - ); - action_map.insert( - forget_id, - 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 _ = device_submenu.append(&app_submenu); - } - - let _ = refresh_submenu.append(&device_submenu); + 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); @@ -144,10 +141,9 @@ impl ImpactorTray { self.show_item_id = show_item.id().clone(); self.quit_item_id = quit_item.id().clone(); - log::info!("Rebuilding tray icon with new menu"); - 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()))); } diff --git a/crates/plume_store/src/refresh.rs b/crates/plume_store/src/refresh.rs index f8984a5..5b54c6f 100644 --- a/crates/plume_store/src/refresh.rs +++ b/crates/plume_store/src/refresh.rs @@ -6,6 +6,7 @@ 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 @@ -15,6 +16,7 @@ pub struct RefreshDevice { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct RefreshApp { pub path: PathBuf, + pub name: Option, pub scheduled_refresh: DateTime, // the scheduled refresh time will happen a day before expiration } From ff854c2384def50500c521b8a8729f58411be959 Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Fri, 16 Jan 2026 10:55:48 -0800 Subject: [PATCH 21/23] fix cert imports --- apps/plumeimpactor/src/screen/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/plumeimpactor/src/screen/mod.rs b/apps/plumeimpactor/src/screen/mod.rs index d392adc..916c09a 100644 --- a/apps/plumeimpactor/src/screen/mod.rs +++ b/apps/plumeimpactor/src/screen/mod.rs @@ -350,7 +350,8 @@ impl Impactor { 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), ); From d767a0eeaa2099d48a4f3692d92b7d7a0f5086dc Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Fri, 16 Jan 2026 15:57:26 -0800 Subject: [PATCH 22/23] improved logic for handling refreshes --- apps/plumeimpactor/src/refresh.rs | 21 ++++++++++++++------- apps/plumeimpactor/src/subscriptions.rs | 1 + crates/plume_store/src/refresh.rs | 3 +++ crates/plume_types/src/device.rs | 17 +++++++++++++++++ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/plumeimpactor/src/refresh.rs b/apps/plumeimpactor/src/refresh.rs index b3c280f..9746ba7 100644 --- a/apps/plumeimpactor/src/refresh.rs +++ b/apps/plumeimpactor/src/refresh.rs @@ -146,16 +146,23 @@ impl RefreshDaemon { identity.new }; - if device.is_mac { - println!("Mac device - updating provisioning profiles only..."); - self.update_provisioning_profiles(app, device, &session, team_id) - .await?; - } else if identity_is_new { - println!("Certificate is new, resigning and reinstalling app..."); + 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, updating provisioning profiles..."); + println!("Certificate exists and app is installed, updating provisioning profiles..."); self.update_provisioning_profiles(app, device, &session, team_id) .await?; } diff --git a/apps/plumeimpactor/src/subscriptions.rs b/apps/plumeimpactor/src/subscriptions.rs index 5a4021b..5a65db7 100644 --- a/apps/plumeimpactor/src/subscriptions.rs +++ b/apps/plumeimpactor/src/subscriptions.rs @@ -369,6 +369,7 @@ pub(crate) async fn run_installation( let refresh_app = plume_store::RefreshApp { name: package_file.get_name(), + bundle_id: package_file.get_bundle_identifier(), path: dest_path.clone(), scheduled_refresh, }; diff --git a/crates/plume_store/src/refresh.rs b/crates/plume_store/src/refresh.rs index 5b54c6f..3ff99b3 100644 --- a/crates/plume_store/src/refresh.rs +++ b/crates/plume_store/src/refresh.rs @@ -16,7 +16,10 @@ pub struct RefreshDevice { #[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 } diff --git a/crates/plume_types/src/device.rs b/crates/plume_types/src/device.rs index 30fd1de..2bfaa4d 100644 --- a/crates/plume_types/src/device.rs +++ b/crates/plume_types/src/device.rs @@ -96,6 +96,23 @@ 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())); From 973caf74ef4ec65e1e9aa41478cc7e16ff477843 Mon Sep 17 00:00:00 2001 From: khcrysalis Date: Fri, 16 Jan 2026 16:23:51 -0800 Subject: [PATCH 23/23] adds option to replace substrate with ellekit --- apps/plumeimpactor/src/screen/package.rs | 10 +++++++++- crates/plume_types/src/signer.rs | 6 +++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/plumeimpactor/src/screen/package.rs b/apps/plumeimpactor/src/screen/package.rs index 4a48b61..95b8932 100644 --- a/apps/plumeimpactor/src/screen/package.rs +++ b/apps/plumeimpactor/src/screen/package.rs @@ -19,6 +19,7 @@ pub enum Message { ToggleSingleProfile(bool), ToggleLiquidGlass(bool), ToggleRefresh(bool), + ToggleElleKit(bool), UpdateSignerMode(SignerMode), UpdateInstallMode(SignerInstallMode), AddTweak, @@ -118,6 +119,10 @@ impl PackageScreen { 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() @@ -271,8 +276,11 @@ 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") + .label("Auto Refresh [BETA]") .on_toggle(Message::ToggleRefresh), text("Mode:").size(12), pick_list( diff --git a/crates/plume_types/src/signer.rs b/crates/plume_types/src/signer.rs index ab26858..648fcd7 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?;