From 6e4352bd7c3c6a26dbaabb7a99e6dbc4bcc45cfa Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud Date: Mon, 26 Jan 2026 09:41:13 +0100 Subject: [PATCH 1/9] feat: toolchain --- rust-toolchain.toml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 rust-toolchain.toml diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..61f0c38 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.93.0" +components = ['rustfmt', 'clippy', 'rust-src', 'rust-analyzer'] From 7049b74ce4821d86db62fa1ded4393513115f4c2 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud Date: Mon, 26 Jan 2026 10:08:55 +0100 Subject: [PATCH 2/9] feat: abstract over platform --- src/app/tile/elm.rs | 6 +-- src/app/tile/update.rs | 15 +++----- src/main.rs | 10 ++--- src/{ => platform/macos}/haptics.rs | 49 ++++++++++++------------- src/{macos.rs => platform/macos/mod.rs} | 16 ++++---- src/platform/mod.rs | 43 ++++++++++++++++++++++ 6 files changed, 86 insertions(+), 53 deletions(-) rename src/{ => platform/macos}/haptics.rs (84%) rename src/{macos.rs => platform/macos/mod.rs} (90%) create mode 100644 src/platform/mod.rs diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 42a9a23..1007b1c 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -15,16 +15,16 @@ use rayon::{ slice::ParallelSliceMut, }; -use crate::app::WINDOW_WIDTH; use crate::app::pages::clipboard::clipboard_view; use crate::app::pages::emoji::emoji_page; use crate::app::tile::AppIndex; use crate::config::Theme; use crate::styles::{contents_style, rustcast_text_input_style, tint, with_alpha}; +use crate::{app::WINDOW_WIDTH, platform}; use crate::{ app::{Message, Page, apps::App, default_settings, tile::Tile}, config::Config, - macos::{self, transform_process_to_ui_element}, + platform::transform_process_to_ui_element, utils::get_installed_apps, }; @@ -46,7 +46,7 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { let (id, open) = window::open(default_settings()); let open = open.discard().chain(window::run(id, |handle| { - macos::macos_window_config(&handle.window_handle().expect("Unable to get window handle")); + platform::window_config(&handle.window_handle().expect("Unable to get window handle")); transform_process_to_ui_element(); })); diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 67eaf5d..54cf7e4 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -13,9 +13,6 @@ use rayon::iter::IntoParallelRefIterator; use rayon::iter::ParallelIterator; use rayon::slice::ParallelSliceMut; -use crate::app::ArrowKey; -use crate::app::DEFAULT_WINDOW_HEIGHT; -use crate::app::Move; use crate::app::RUSTCAST_DESC_NAME; use crate::app::WINDOW_WIDTH; use crate::app::apps::App; @@ -24,19 +21,17 @@ use crate::app::default_settings; use crate::app::menubar::menu_icon; use crate::app::tile::AppIndex; use crate::app::tile::elm::default_app_paths; +use crate::app::{Message, Page, tile::Tile}; use crate::calculator::Expr; use crate::clipboard::ClipBoardContentType; use crate::commands::Function; use crate::config::Config; -use crate::haptics::HapticPattern; -use crate::haptics::perform_haptic; use crate::unit_conversion; use crate::utils::get_installed_apps; use crate::utils::is_valid_url; -use crate::{ - app::{Message, Page, tile::Tile}, - macos::focus_this_app, -}; +use crate::{app::ArrowKey, platform::focus_this_app}; +use crate::{app::DEFAULT_WINDOW_HEIGHT, platform::perform_haptic}; +use crate::{app::Move, platform::HapticPattern}; pub fn handle_update(tile: &mut Tile, message: Message) -> Task { match message { @@ -318,7 +313,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Message::SearchQueryChanged(input, id) => { tile.focus_id = 0; - #[cfg(target_os = "macos")] + if tile.config.haptic_feedback { perform_haptic(HapticPattern::Alignment); } diff --git a/src/main.rs b/src/main.rs index 792367f..beb8895 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,8 +3,7 @@ mod calculator; mod clipboard; mod commands; mod config; -mod haptics; -mod macos; +mod platform; mod styles; mod unit_conversion; mod utils; @@ -18,11 +17,10 @@ use crate::{ use global_hotkey::GlobalHotKeyManager; +use self::platform::set_activation_policy_accessory; + fn main() -> iced::Result { - #[cfg(target_os = "macos")] - { - macos::set_activation_policy_accessory(); - } + set_activation_policy_accessory(); let home = std::env::var("HOME").unwrap(); diff --git a/src/haptics.rs b/src/platform/macos/haptics.rs similarity index 84% rename from src/haptics.rs rename to src/platform/macos/haptics.rs index 50ee2f8..83935a0 100644 --- a/src/haptics.rs +++ b/src/platform/macos/haptics.rs @@ -5,17 +5,12 @@ #![allow(non_camel_case_types)] use objc2_core_foundation::{CFNumber, CFNumberType, CFRetained, CFString, CFType}; -use once_cell::sync::OnceCell; -use std::ffi::{c_char, c_void}; - -/// The kinds of haptic patterns that can be performed -#[allow(dead_code)] -#[derive(Copy, Clone, Debug)] -pub enum HapticPattern { - Generic, - Alignment, - LevelChange, -} +use std::{ + ffi::{c_char, c_void}, + sync::LazyLock, +}; + +use crate::platform::HapticPattern; unsafe extern "C" { unsafe fn CFRelease(cf: *mut CFType); @@ -143,28 +138,30 @@ impl MtsState { } } -static MTS: OnceCell> = OnceCell::new(); +static MTS: LazyLock> = LazyLock::new(MtsState::open_default_or_all); fn mts_state() -> Option<&'static MtsState> { - MTS.get_or_init(MtsState::open_default_or_all).as_ref() + MTS.as_ref() } /// Perform a haptic feedback - Just use this function to perform haptic feedback... please don't /// remake this function unless you're a genius or absolutely have to -pub fn perform_haptic(pattern: HapticPattern) -> bool { - if let Some(state) = mts_state() { - let pat = pattern_index(pattern); - let mut any_ok = false; - unsafe { - for &act in &state.actuators { - if !act.is_null() && MTActuatorIsOpen(act) { - let kr = MTActuatorActuate(act, pat, 0, 0.0, 0.0); - any_ok |= kr == 0; - } +pub(crate) fn perform_haptic(pattern: HapticPattern) -> bool { + let Some(state) = mts_state() else { + return false; + }; + + let pat = pattern_index(pattern); + let mut any_ok = false; + + unsafe { + for &act in &state.actuators { + if !act.is_null() && MTActuatorIsOpen(act) { + let kr = MTActuatorActuate(act, pat, 0, 0.0, 0.0); + any_ok |= kr == 0; } } - any_ok - } else { - false } + + any_ok } diff --git a/src/macos.rs b/src/platform/macos/mod.rs similarity index 90% rename from src/macos.rs rename to src/platform/macos/mod.rs index 87f53dc..f0d9770 100644 --- a/src/macos.rs +++ b/src/platform/macos/mod.rs @@ -1,11 +1,13 @@ //! Macos specific logic, such as window settings, etc. -#[cfg(target_os = "macos")] +mod haptics; + use iced::wgpu::rwh::WindowHandle; +pub(super) use self::haptics::perform_haptic; + /// This sets the activation policy of the app to Accessory, allowing rustcast to be visible ontop /// of fullscreen apps -#[cfg(target_os = "macos")] -pub fn set_activation_policy_accessory() { +pub(super) fn set_activation_policy_accessory() { use objc2::MainThreadMarker; use objc2_app_kit::{NSApp, NSApplicationActivationPolicy}; @@ -15,8 +17,7 @@ pub fn set_activation_policy_accessory() { } /// This carries out the window configuration for the macos window (only things that are macos specific) -#[cfg(target_os = "macos")] -pub fn macos_window_config(handle: &WindowHandle) { +pub(super) fn macos_window_config(handle: &WindowHandle) { use iced::wgpu::rwh::RawWindowHandle; use objc2::rc::Retained; use objc2_app_kit::NSView; @@ -44,8 +45,7 @@ pub fn macos_window_config(handle: &WindowHandle) { /// This is the function that forces focus onto rustcast #[allow(deprecated)] -#[cfg(target_os = "macos")] -pub fn focus_this_app() { +pub(super) fn focus_this_app() { use objc2::MainThreadMarker; use objc2_app_kit::NSApp; @@ -69,7 +69,7 @@ struct ProcessSerialNumber { /// returns ApplicationServices OSStatus (u32) /// /// doesn't seem to do anything if you haven't opened a window yet, so wait to call it until after that. -pub fn transform_process_to_ui_element() -> u32 { +pub(super) fn transform_process_to_ui_element() -> u32 { use objc2_application_services::{ TransformProcessType, kCurrentProcess, kProcessTransformToUIElementApplication, }; diff --git a/src/platform/mod.rs b/src/platform/mod.rs new file mode 100644 index 0000000..454e637 --- /dev/null +++ b/src/platform/mod.rs @@ -0,0 +1,43 @@ +use iced::wgpu::rwh::WindowHandle; + +#[cfg(target_os = "macos")] +mod macos; + +pub fn set_activation_policy_accessory() { + #[cfg(target_os = "macos")] + self::macos::set_activation_policy_accessory(); +} + +pub fn window_config(handle: &WindowHandle) { + #[cfg(target_os = "macos")] + self::macos::macos_window_config(handle); +} + +pub fn focus_this_app() { + #[cfg(target_os = "macos")] + self::macos::focus_this_app(); +} + +pub fn transform_process_to_ui_element() { + #[cfg(target_os = "macos")] + self::macos::transform_process_to_ui_element(); +} + +/// The kinds of haptic patterns that can be performed +#[allow(dead_code)] +#[derive(Copy, Clone, Debug)] +pub enum HapticPattern { + Generic, + Alignment, + LevelChange, +} + +#[cfg(target_os = "macos")] +pub fn perform_haptic(pattern: HapticPattern) -> bool { + self::macos::perform_haptic(pattern) +} + +#[cfg(not(target_os = "macos"))] +pub fn perform_haptic(_pattern: HapticPattern) -> bool { + false +} From 6d6f19f221d605310bd1c7f7405e782cd912b319 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud Date: Mon, 26 Jan 2026 12:01:23 +0100 Subject: [PATCH 3/9] feat: OS based discovery --- Cargo.lock | 32 +++++- Cargo.toml | 2 + build.rs | 2 + src/app/tile.rs | 11 +- src/app/tile/elm.rs | 29 +---- src/app/tile/update.rs | 13 +-- src/platform/cross.rs | 149 +++++++++++++++++++++++++ src/platform/macos/discovery.rs | 188 ++++++++++++++++++++++++++++++++ src/platform/macos/mod.rs | 2 + src/platform/mod.rs | 16 ++- src/utils.rs | 137 +---------------------- 11 files changed, 400 insertions(+), 181 deletions(-) create mode 100644 src/platform/cross.rs create mode 100644 src/platform/macos/discovery.rs diff --git a/Cargo.lock b/Cargo.lock index 5f986db..96e7895 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2301,9 +2301,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.178" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libfuzzer-sys" @@ -2961,6 +2961,21 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "bitflags 2.10.0", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -3077,6 +3092,17 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", +] + [[package]] name = "objc2-symbols" version = "0.2.2" @@ -3747,10 +3773,12 @@ dependencies = [ "iced", "icns", "image", + "libc", "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-application-services", "objc2-core-foundation", + "objc2-core-services", "objc2-foundation 0.3.2", "once_cell", "rand", diff --git a/Cargo.toml b/Cargo.toml index 386a1b5..40ce129 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ global-hotkey = "0.7.0" iced = { version = "0.14.0", features = ["image", "tokio"] } icns = "0.3.1" image = "0.25.9" +libc = "0.2.180" objc2 = "0.6.3" objc2-app-kit = { version = "0.3.2", features = ["NSImage"] } objc2-application-services = { version = "0.3.2", default-features = false, features = [ @@ -18,6 +19,7 @@ objc2-application-services = { version = "0.3.2", default-features = false, feat "Processes", ] } objc2-core-foundation = "0.3.2" +objc2-core-services = { version = "0.3.2", features = ["LSInfo"] } objc2-foundation = { version = "0.3.2", features = ["NSString"] } once_cell = "1.21.3" rand = "0.9.2" diff --git a/build.rs b/build.rs index a4b0b05..5d16e45 100644 --- a/build.rs +++ b/build.rs @@ -2,4 +2,6 @@ fn main() { println!("cargo:rustc-link-search=framework=/System/Library/PrivateFrameworks"); println!("cargo:rustc-link-lib=framework=IOKit"); println!("cargo:rustc-link-lib=framework=MultitouchSupport"); + println!("cargo:rustc-link-lib=framework=CoreServices"); + println!("cargo:rustc-link-lib=framework=ApplicationServices"); } diff --git a/src/app/tile.rs b/src/app/tile.rs index 830abdb..98cc5dc 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -2,12 +2,11 @@ pub mod elm; pub mod update; -use crate::app::apps::App; -use crate::app::tile::elm::default_app_paths; use crate::app::{ArrowKey, Message, Move, Page}; use crate::clipboard::ClipBoardContentType; use crate::config::Config; use crate::utils::open_settings; +use crate::{app::apps::App, platform::default_app_paths}; use arboard::Clipboard; use global_hotkey::hotkey::HotKey; @@ -25,7 +24,7 @@ use iced::{event, window}; use objc2::rc::Retained; use objc2_app_kit::NSRunningApplication; -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}; use tray_icon::TrayIcon; use std::collections::BTreeMap; @@ -251,10 +250,10 @@ fn handle_hot_reloading() -> impl futures::Stream { ) .unwrap_or("".to_string()); - let paths = default_app_paths(); + let paths: Vec = default_app_paths().into_par_iter().collect(); let mut total_files: usize = paths .par_iter() - .map(|dir| count_dirs_in_dir(&dir.to_owned().into())) + .map(|dir| count_dirs_in_dir(&PathBuf::from(dir))) .sum(); loop { @@ -265,7 +264,7 @@ fn handle_hot_reloading() -> impl futures::Stream { let current_total_files: usize = paths .par_iter() - .map(|dir| count_dirs_in_dir(&dir.to_owned().into())) + .map(|dir| count_dirs_in_dir(&PathBuf::from(dir))) .sum(); if current_content != content { diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 1007b1c..772a43d 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -10,37 +10,20 @@ use iced::{Alignment, Color, Length, Vector, window}; use iced::{Element, Task}; use iced::{Length::Fill, widget::text_input}; -use rayon::{ - iter::{IntoParallelRefIterator, ParallelIterator}, - slice::ParallelSliceMut, -}; +use rayon::slice::ParallelSliceMut; -use crate::app::pages::clipboard::clipboard_view; use crate::app::pages::emoji::emoji_page; use crate::app::tile::AppIndex; use crate::config::Theme; use crate::styles::{contents_style, rustcast_text_input_style, tint, with_alpha}; use crate::{app::WINDOW_WIDTH, platform}; +use crate::{app::pages::clipboard::clipboard_view, platform::get_installed_apps}; use crate::{ app::{Message, Page, apps::App, default_settings, tile::Tile}, config::Config, platform::transform_process_to_ui_element, - utils::get_installed_apps, }; -pub fn default_app_paths() -> Vec { - let user_local_path = std::env::var("HOME").unwrap() + "/Applications/"; - - let paths = vec![ - "/Applications/".to_string(), - user_local_path, - "/System/Applications/".to_string(), - "/System/Applications/Utilities/".to_string(), - ]; - - paths -} - /// Initialise the base window pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { let (id, open) = window::open(default_settings()); @@ -52,13 +35,7 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { let store_icons = config.theme.show_icons; - let paths = default_app_paths(); - - let mut options: Vec = paths - .par_iter() - .map(|path| get_installed_apps(path, store_icons)) - .flatten() - .collect(); + let mut options = get_installed_apps(store_icons); options.extend(config.shells.iter().map(|x| x.to_app())); options.extend(App::basic_apps()); diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 54cf7e4..a7f75a6 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -9,29 +9,25 @@ use iced::widget::image::Handle; use iced::widget::operation; use iced::widget::operation::AbsoluteOffset; use iced::window; -use rayon::iter::IntoParallelRefIterator; -use rayon::iter::ParallelIterator; use rayon::slice::ParallelSliceMut; -use crate::app::RUSTCAST_DESC_NAME; use crate::app::WINDOW_WIDTH; use crate::app::apps::App; use crate::app::apps::AppCommand; use crate::app::default_settings; use crate::app::menubar::menu_icon; use crate::app::tile::AppIndex; -use crate::app::tile::elm::default_app_paths; use crate::app::{Message, Page, tile::Tile}; use crate::calculator::Expr; use crate::clipboard::ClipBoardContentType; use crate::commands::Function; use crate::config::Config; use crate::unit_conversion; -use crate::utils::get_installed_apps; use crate::utils::is_valid_url; use crate::{app::ArrowKey, platform::focus_this_app}; use crate::{app::DEFAULT_WINDOW_HEIGHT, platform::perform_haptic}; use crate::{app::Move, platform::HapticPattern}; +use crate::{app::RUSTCAST_DESC_NAME, platform::get_installed_apps}; pub fn handle_update(tile: &mut Tile, message: Message) -> Task { match message { @@ -177,12 +173,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Err(_) => return Task::none(), }; - let mut new_options: Vec = default_app_paths() - .par_iter() - .map(|path| get_installed_apps(path, new_config.theme.show_icons)) - .flatten() - .collect(); - + let mut new_options = get_installed_apps(new_config.theme.show_icons); new_options.extend(new_config.shells.iter().map(|x| x.to_app())); new_options.extend(App::basic_apps()); new_options.par_sort_by_key(|x| x.name.len()); diff --git a/src/platform/cross.rs b/src/platform/cross.rs new file mode 100644 index 0000000..a9d3e60 --- /dev/null +++ b/src/platform/cross.rs @@ -0,0 +1,149 @@ +use std::{ + fs, + path::{Path, PathBuf}, + process::exit, +}; + +use rayon::iter::{IntoParallelIterator, ParallelIterator as _}; + +use crate::{ + app::apps::{App, AppCommand}, + commands::Function, + utils::{handle_from_icns, log_error, log_error_and_exit}, +}; + +pub fn default_app_paths() -> impl IntoParallelIterator { + let user_local_path = std::env::var("HOME").unwrap() + "/Applications/"; + + [ + "/Applications/".to_string(), + user_local_path, + "/System/Applications/".to_string(), + "/System/Applications/Utilities/".to_string(), + ] +} + +pub(crate) fn get_installed_apps(store_icons: bool) -> Vec { + default_app_paths() + .into_par_iter() + .flat_map(|path| discover_apps(path, store_icons)) + .collect() +} + +/// This gets all the installed apps in the given directory +/// +/// the directories are defined in [`crate::app::tile::Tile::new`] +fn discover_apps( + dir: impl AsRef, + store_icons: bool, +) -> impl IntoParallelIterator { + let entries: Vec<_> = fs::read_dir(dir.as_ref()) + .unwrap_or_else(|x| { + log_error_and_exit(&x.to_string()); + }) + .filter_map(|x| x.ok()) + .collect(); + + entries.into_par_iter().filter_map(move |x| { + let file_type = x.file_type().unwrap_or_else(|e| { + log_error(&e.to_string()); + exit(-1) + }); + if !file_type.is_dir() { + return None; + } + + let file_name_os = x.file_name(); + let file_name = file_name_os.into_string().unwrap_or_else(|e| { + log_error(e.to_str().unwrap_or("")); + exit(-1) + }); + if !file_name.ends_with(".app") { + return None; + } + + let path = x.path(); + let path_str = path.to_str().map(|x| x.to_string()).unwrap_or_else(|| { + log_error("Unable to get file_name"); + exit(-1) + }); + + let icons = if store_icons { + match fs::read_to_string(format!("{}/Contents/Info.plist", path_str)).map(|content| { + let icon_line = content + .lines() + .scan(false, |expect_next, line| { + if *expect_next { + *expect_next = false; + // Return this line to the iterator + return Some(Some(line)); + } + + if line.trim() == "CFBundleIconFile" { + *expect_next = true; + } + + // For lines that are not the one after the key, return None to skip + Some(None) + }) + .flatten() // remove the Nones + .next() + .map(|x| { + x.trim() + .strip_prefix("") + .unwrap_or("") + .strip_suffix("") + .unwrap_or("") + }); + + handle_from_icns(Path::new(&format!( + "{}/Contents/Resources/{}", + path_str, + icon_line.unwrap_or("AppIcon.icns") + ))) + }) { + Ok(Some(a)) => Some(a), + _ => { + // Fallback method + let direntry = fs::read_dir(format!("{}/Contents/Resources", path_str)) + .into_iter() + .flatten() + .filter_map(|x| { + let file = x.ok()?; + let name = file.file_name(); + let file_name = name.to_str()?; + if file_name.ends_with(".icns") { + Some(file.path()) + } else { + None + } + }) + .collect::>(); + + if direntry.len() > 1 { + let icns_vec = direntry + .iter() + .filter(|x| x.ends_with("AppIcon.icns")) + .collect::>(); + handle_from_icns(icns_vec.first().unwrap_or(&&PathBuf::new())) + } else if !direntry.is_empty() { + handle_from_icns(direntry.first().unwrap_or(&PathBuf::new())) + } else { + None + } + } + } + } else { + None + }; + + let name = file_name.strip_suffix(".app").unwrap().to_string(); + Some(App { + open_command: AppCommand::Function(Function::OpenApp(path_str)), + desc: "Application".to_string(), + icons, + name_lc: name.to_lowercase(), + name, + }) + }) +} diff --git a/src/platform/macos/discovery.rs b/src/platform/macos/discovery.rs new file mode 100644 index 0000000..a1f1f32 --- /dev/null +++ b/src/platform/macos/discovery.rs @@ -0,0 +1,188 @@ +//! macOS application discovery using Launch Services. +//! +//! This module uses the undocumented `LSCopyAllApplicationURLs` API to enumerate +//! all registered applications on the system. This private API has been stable +//! since macOS 10.5 and is widely used by launcher applications (Alfred, Raycast, etc.). +//! +//! Since the symbol is not exported in Apple's `.tbd` stub files (which only list +//! documented APIs), we load it at runtime via `dlsym` from the LaunchServices +//! framework. If loading fails, we fall back to the cross-platform directory +//! scanning approach. + +use core::{ + ffi::{CStr, c_void}, + mem, + ptr::{self, NonNull}, +}; +use std::sync::LazyLock; + +use objc2_core_foundation::{CFArray, CFRetained, CFURL}; +use objc2_foundation::{NSBundle, NSString, NSURL, ns_string}; +use rayon::iter::{IntoParallelIterator, ParallelIterator as _}; + +use crate::{ + app::apps::{App, AppCommand}, + commands::Function, + utils::{handle_from_icns, log_error}, +}; + +use super::super::cross; + +/// Function signature for `LSCopyAllApplicationURLs`. +/// +/// This undocumented Launch Services function retrieves URLs for all applications +/// registered with the system. It follows Core Foundation's "Copy Rule" - the +/// caller owns the returned `CFArray` and is responsible for releasing it. +/// +/// # Parameters +/// - `out`: Pointer to receive the `CFArray` of application URLs +/// +/// # Returns +/// - `0` (`noErr`) on success +/// - Non-zero `OSStatus` error code on failure +type LSCopyAllApplicationURLsFn = unsafe extern "C" fn(out: *mut *const CFArray) -> i32; + +/// Path to the LaunchServices framework binary within CoreServices. +const LAUNCHSERVICES_PATH: &CStr = + c"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/LaunchServices"; + +/// Dynamically loads `LSCopyAllApplicationURLs` from the LaunchServices framework. +/// +/// This function is called once and cached via `LazyLock`. We use dynamic loading +/// because the symbol is undocumented and not present in Apple's `.tbd` stub files, +/// which prevents static linking on modern macOS. +/// +/// # Returns +/// The function pointer if successfully loaded, `None` otherwise. +fn load_symbol() -> Option { + // SAFETY: We pass a valid null-terminated path string to dlopen. + // RTLD_LAZY defers symbol resolution until first use. + let lib = unsafe { libc::dlopen(LAUNCHSERVICES_PATH.as_ptr(), libc::RTLD_LAZY) }; + let Some(lib) = NonNull::new(lib) else { + log_error("failed to load LaunchServices framework"); + return None; + }; + + // SAFETY: We pass a valid library handle and null-terminated symbol name. + let sym = unsafe { libc::dlsym(lib.as_ptr(), c"_LSCopyAllApplicationURLs".as_ptr()) }; + let Some(sym) = NonNull::new(sym) else { + log_error("failed to find symbol `LSCopyAllApplicationURLs`"); + return None; + }; + + // SAFETY: We've verified the symbol exists. The function signature matches + // the known (though undocumented) API based on reverse engineering and + // widespread usage in other applications. + Some(unsafe { mem::transmute::<*mut c_void, LSCopyAllApplicationURLsFn>(sym.as_ptr()) }) +} + +/// Retrieves URLs for all applications registered with Launch Services. +/// +/// Uses the cached function pointer from [`load_symbol`] to call the +/// undocumented `LSCopyAllApplicationURLs` API. +/// +/// # Returns +/// +/// `Some(CFRetained>)` containing application URLs on success, +/// `None` if the symbol couldn't be loaded or the API call failed. +fn registered_app_urls() -> Option>> { + static SYM: LazyLock> = LazyLock::new(load_symbol); + + let sym = (*SYM)?; + let mut urls_ptr = ptr::null(); + + // SAFETY: We've verified `sym` is a valid function pointer. We pass a valid + // mutable pointer to receive the output. The function follows the "Copy Rule" + // so we take ownership of the returned CFArray. + let err = unsafe { sym(&mut urls_ptr) }; + + if err != 0 { + log_error(&format!( + "LSCopyAllApplicationURLs failed with error code: {err}" + )); + return None; + } + + let url_ptr = NonNull::new(urls_ptr.cast_mut())?; + + // SAFETY: LSCopyAllApplicationURLs returns a +1 retained CFArray on success. + // We transfer ownership to CFRetained which will call CFRelease when dropped. + Some(unsafe { CFRetained::from_raw(url_ptr) }) +} + +/// Extracts application metadata from a bundle URL. +/// +/// Queries the bundle's `Info.plist` for display name and icon, with the +/// following fallback chain for the app name: +/// 1. `CFBundleDisplayName` - localized display name +/// 2. `CFBundleName` - short bundle name +/// 3. File stem from path (e.g., "Safari" from "Safari.app") +/// +/// # Returns +/// +/// `Some(App)` if the bundle is valid and has a determinable name, `None` otherwise. +fn query_app(url: impl AsRef, store_icons: bool) -> Option { + let url = url.as_ref(); + let path = url.to_file_path()?; + + let bundle = NSBundle::bundleWithURL(url)?; + let info = bundle.infoDictionary()?; + + let get_string = |key: &NSString| -> Option { + info.objectForKey(key)? + .downcast::() + .ok() + .map(|s| s.to_string()) + }; + + let name = get_string(ns_string!("CFBundleDisplayName")) + .or_else(|| get_string(ns_string!("CFBundleName"))) + .or_else(|| { + path.file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + })?; + + let icons = store_icons + .then(|| { + get_string(ns_string!("CFBundleIconFile")).and_then(|icon| { + let mut path = path.join("Contents/Resources").join(&icon); + if path.extension().is_none() { + path.set_extension("icns"); + } + + handle_from_icns(&path) + }) + }) + .flatten(); + + Some(App { + name: name.clone(), + name_lc: name.to_lowercase(), + desc: "Application".to_string(), + icons, + open_command: AppCommand::Function(Function::OpenApp(path.to_string_lossy().into_owned())), + }) +} + +/// Returns all installed applications discovered via Launch Services. +/// +/// Attempts to use the native `LSCopyAllApplicationURLs` API for comprehensive +/// app discovery. If the API is unavailable (symbol not found or call fails), +/// falls back to the cross-platform directory scanning approach. +/// +/// # Arguments +/// +/// * `store_icons` - Whether to load application icons (slower but needed for display) +pub(crate) fn get_installed_apps(store_icons: bool) -> Vec { + let Some(registered_app_urls) = registered_app_urls() else { + log_error("native app discovery unavailable, falling back to directory scan"); + return cross::get_installed_apps(store_icons); + }; + + // Intermediate allocation into a vec allows us to parallelize the iteration, speeding up discovery by ~5x. + let urls: Vec<_> = registered_app_urls.into_iter().collect(); + + urls.into_par_iter() + .filter_map(|url| query_app(url, store_icons)) + .collect() +} diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index f0d9770..d9d0eb6 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -1,8 +1,10 @@ //! Macos specific logic, such as window settings, etc. +mod discovery; mod haptics; use iced::wgpu::rwh::WindowHandle; +pub(super) use self::discovery::get_installed_apps; pub(super) use self::haptics::perform_haptic; /// This sets the activation policy of the app to Accessory, allowing rustcast to be visible ontop diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 454e637..82442ac 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,5 +1,9 @@ use iced::wgpu::rwh::WindowHandle; +pub use self::cross::default_app_paths; +use crate::app::apps::App; + +mod cross; #[cfg(target_os = "macos")] mod macos; @@ -38,6 +42,16 @@ pub fn perform_haptic(pattern: HapticPattern) -> bool { } #[cfg(not(target_os = "macos"))] -pub fn perform_haptic(_pattern: HapticPattern) -> bool { +pub fn perform_haptic(_: HapticPattern) -> bool { false } + +#[cfg(target_os = "macos")] +pub fn get_installed_apps(store_icons: bool) -> Vec { + self::macos::get_installed_apps(store_icons) +} + +#[cfg(not(target_os = "macos"))] +pub fn get_installed_apps(store_icons: bool) -> Vec { + self::cross::get_installed_apps(store_icons) +} diff --git a/src/utils.rs b/src/utils.rs index 263ca11..d6d82a8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,23 +1,11 @@ //! This has all the utility functions that rustcast uses -use std::{ - fs::{self, File}, - io::Write, - path::{Path, PathBuf}, - process::exit, - thread, -}; +use std::{fs::File, io::Write, path::Path, process::exit, thread}; use iced::widget::image::Handle; use icns::IconFamily; use image::RgbaImage; use objc2_app_kit::NSWorkspace; use objc2_foundation::NSURL; -use rayon::iter::{IntoParallelIterator, ParallelIterator}; - -use crate::{ - app::apps::{App, AppCommand}, - commands::Function, -}; /// The default error log path (works only on unix systems, and must be changed for windows /// support) @@ -31,7 +19,7 @@ pub(crate) fn log_error(msg: &str) { } /// This logs an error to the error log file, and exits the program -pub(crate) fn log_error_and_exit(msg: &str) { +pub(crate) fn log_error_and_exit(msg: &str) -> ! { log_error(msg); exit(-1) } @@ -56,127 +44,6 @@ pub(crate) fn handle_from_icns(path: &Path) -> Option { )) } -/// This gets all the installed apps in the given directory -/// -/// the directories are defined in [`crate::app::tile::Tile::new`] -pub(crate) fn get_installed_apps(dir: impl AsRef, store_icons: bool) -> Vec { - let entries: Vec<_> = fs::read_dir(dir.as_ref()) - .unwrap_or_else(|x| { - log_error_and_exit(&x.to_string()); - exit(-1) - }) - .filter_map(|x| x.ok()) - .collect(); - - entries - .into_par_iter() - .filter_map(|x| { - let file_type = x.file_type().unwrap_or_else(|e| { - log_error(&e.to_string()); - exit(-1) - }); - if !file_type.is_dir() { - return None; - } - - let file_name_os = x.file_name(); - let file_name = file_name_os.into_string().unwrap_or_else(|e| { - log_error(e.to_str().unwrap_or("")); - exit(-1) - }); - if !file_name.ends_with(".app") { - return None; - } - - let path = x.path(); - let path_str = path.to_str().map(|x| x.to_string()).unwrap_or_else(|| { - log_error("Unable to get file_name"); - exit(-1) - }); - - let icons = if store_icons { - match fs::read_to_string(format!("{}/Contents/Info.plist", path_str)).map( - |content| { - let icon_line = content - .lines() - .scan(false, |expect_next, line| { - if *expect_next { - *expect_next = false; - // Return this line to the iterator - return Some(Some(line)); - } - - if line.trim() == "CFBundleIconFile" { - *expect_next = true; - } - - // For lines that are not the one after the key, return None to skip - Some(None) - }) - .flatten() // remove the Nones - .next() - .map(|x| { - x.trim() - .strip_prefix("") - .unwrap_or("") - .strip_suffix("") - .unwrap_or("") - }); - - handle_from_icns(Path::new(&format!( - "{}/Contents/Resources/{}", - path_str, - icon_line.unwrap_or("AppIcon.icns") - ))) - }, - ) { - Ok(Some(a)) => Some(a), - _ => { - // Fallback method - let direntry = fs::read_dir(format!("{}/Contents/Resources", path_str)) - .into_iter() - .flatten() - .filter_map(|x| { - let file = x.ok()?; - let name = file.file_name(); - let file_name = name.to_str()?; - if file_name.ends_with(".icns") { - Some(file.path()) - } else { - None - } - }) - .collect::>(); - - if direntry.len() > 1 { - let icns_vec = direntry - .iter() - .filter(|x| x.ends_with("AppIcon.icns")) - .collect::>(); - handle_from_icns(icns_vec.first().unwrap_or(&&PathBuf::new())) - } else if !direntry.is_empty() { - handle_from_icns(direntry.first().unwrap_or(&PathBuf::new())) - } else { - None - } - } - } - } else { - None - }; - - let name = file_name.strip_suffix(".app").unwrap().to_string(); - Some(App { - open_command: AppCommand::Function(Function::OpenApp(path_str)), - desc: "Application".to_string(), - icons, - name_lc: name.to_lowercase(), - name, - }) - }) - .collect() -} - /// Open the settings file with the system default editor pub fn open_settings() { thread::spawn(move || { From cacf331cdd57faf9d6a57060e36920c0ca3d4d09 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud Date: Mon, 26 Jan 2026 12:08:30 +0100 Subject: [PATCH 4/9] feat: logging --- src/platform/macos/discovery.rs | 48 +++++++++++++++++++++++++++++---- src/utils.rs | 1 + 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/platform/macos/discovery.rs b/src/platform/macos/discovery.rs index a1f1f32..6520f4c 100644 --- a/src/platform/macos/discovery.rs +++ b/src/platform/macos/discovery.rs @@ -46,27 +46,62 @@ type LSCopyAllApplicationURLsFn = unsafe extern "C" fn(out: *mut *const CFArray< const LAUNCHSERVICES_PATH: &CStr = c"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/LaunchServices"; +/// Logs the last `dlerror` message with a prefix. +/// +/// # Safety +/// +/// Must be called immediately after a failed `dlopen`/`dlsym` call, +/// before any other dl* functions are invoked. +unsafe fn log_dlerror(prefix: &str) { + let error = unsafe { libc::dlerror() }; + let message = if error.is_null() { + "unknown error".into() + } else { + unsafe { CStr::from_ptr(error) }.to_string_lossy() + }; + + log_error(&format!("{prefix}: {message}")); +} + /// Dynamically loads `LSCopyAllApplicationURLs` from the LaunchServices framework. /// /// This function is called once and cached via `LazyLock`. We use dynamic loading /// because the symbol is undocumented and not present in Apple's `.tbd` stub files, /// which prevents static linking on modern macOS. /// +/// The library handle is intentionally kept open for the process lifetime since +/// we cache the function pointer. +/// /// # Returns +/// /// The function pointer if successfully loaded, `None` otherwise. fn load_symbol() -> Option { // SAFETY: We pass a valid null-terminated path string to dlopen. - // RTLD_LAZY defers symbol resolution until first use. - let lib = unsafe { libc::dlopen(LAUNCHSERVICES_PATH.as_ptr(), libc::RTLD_LAZY) }; + // RTLD_NOW resolves symbols immediately; RTLD_LOCAL keeps them private. + let lib = unsafe { + libc::dlopen( + LAUNCHSERVICES_PATH.as_ptr(), + libc::RTLD_NOW | libc::RTLD_LOCAL, + ) + }; + let Some(lib) = NonNull::new(lib) else { - log_error("failed to load LaunchServices framework"); + // SAFETY: dlopen has returned a null pointer, indicating failure. + unsafe { log_dlerror("failed to load LaunchServices framework") }; return None; }; + // Clear any prior error before checking dlsym result. + unsafe { libc::dlerror() }; + // SAFETY: We pass a valid library handle and null-terminated symbol name. let sym = unsafe { libc::dlsym(lib.as_ptr(), c"_LSCopyAllApplicationURLs".as_ptr()) }; let Some(sym) = NonNull::new(sym) else { - log_error("failed to find symbol `LSCopyAllApplicationURLs`"); + // SAFETY: dlsym has returned a null pointer, indicating failure. + unsafe { log_dlerror("failed to find symbol `LSCopyAllApplicationURLs`") }; + + // SAFETY: lib is a valid handle from successful dlopen. + unsafe { libc::dlclose(lib.as_ptr()) }; return None; }; @@ -103,7 +138,10 @@ fn registered_app_urls() -> Option>> { return None; } - let url_ptr = NonNull::new(urls_ptr.cast_mut())?; + let Some(url_ptr) = NonNull::new(urls_ptr.cast_mut()) else { + log_error("LSCopyAllApplicationURLs returned null on success"); + return None; + }; // SAFETY: LSCopyAllApplicationURLs returns a +1 retained CFArray on success. // We transfer ownership to CFRetained which will call CFRelease when dropped. diff --git a/src/utils.rs b/src/utils.rs index d6d82a8..847c4f6 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -13,6 +13,7 @@ const ERR_LOG_PATH: &str = "/tmp/rustscan-err.log"; /// This logs an error to the error log file pub(crate) fn log_error(msg: &str) { + eprintln!("{msg}"); if let Ok(mut file) = File::options().create(true).append(true).open(ERR_LOG_PATH) { let _ = file.write_all(msg.as_bytes()).ok(); } From 388d0b2f25dbde221757ddadae174443c77529d9 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud Date: Mon, 26 Jan 2026 12:11:14 +0100 Subject: [PATCH 5/9] chore: remove unnecessary allocation --- src/app/tile.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/tile.rs b/src/app/tile.rs index 98cc5dc..ee546ee 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -27,11 +27,10 @@ use objc2_app_kit::NSRunningApplication; use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}; use tray_icon::TrayIcon; -use std::collections::BTreeMap; use std::fs; use std::ops::Bound; -use std::path::PathBuf; use std::time::Duration; +use std::{collections::BTreeMap, path::Path}; /// This is a wrapper around the sender to disable dropping #[derive(Clone, Debug)] @@ -253,7 +252,7 @@ fn handle_hot_reloading() -> impl futures::Stream { let paths: Vec = default_app_paths().into_par_iter().collect(); let mut total_files: usize = paths .par_iter() - .map(|dir| count_dirs_in_dir(&PathBuf::from(dir))) + .map(|dir| count_dirs_in_dir(Path::new(dir))) .sum(); loop { @@ -264,7 +263,7 @@ fn handle_hot_reloading() -> impl futures::Stream { let current_total_files: usize = paths .par_iter() - .map(|dir| count_dirs_in_dir(&PathBuf::from(dir))) + .map(|dir| count_dirs_in_dir(Path::new(dir))) .sum(); if current_content != content { @@ -280,7 +279,7 @@ fn handle_hot_reloading() -> impl futures::Stream { }) } -fn count_dirs_in_dir(dir: &PathBuf) -> usize { +fn count_dirs_in_dir(dir: impl AsRef) -> usize { // Read the directory; if it fails, treat as empty let entries = match fs::read_dir(dir) { Ok(e) => e, From ac30f7121d88c62a5a6fc4c8b623c7e044cc7fdb Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud Date: Mon, 26 Jan 2026 12:13:00 +0100 Subject: [PATCH 6/9] chore: remove prior experimentation --- Cargo.lock | 27 --------------------------- Cargo.toml | 1 - build.rs | 2 -- 3 files changed, 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96e7895..67a8f23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2961,21 +2961,6 @@ dependencies = [ "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-core-services" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" -dependencies = [ - "bitflags 2.10.0", - "block2 0.6.2", - "dispatch2", - "libc", - "objc2 0.6.3", - "objc2-core-foundation", - "objc2-security", -] - [[package]] name = "objc2-core-text" version = "0.3.2" @@ -3092,17 +3077,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", - "objc2-core-foundation", -] - [[package]] name = "objc2-symbols" version = "0.2.2" @@ -3778,7 +3752,6 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-application-services", "objc2-core-foundation", - "objc2-core-services", "objc2-foundation 0.3.2", "once_cell", "rand", diff --git a/Cargo.toml b/Cargo.toml index 40ce129..89bc634 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ objc2-application-services = { version = "0.3.2", default-features = false, feat "Processes", ] } objc2-core-foundation = "0.3.2" -objc2-core-services = { version = "0.3.2", features = ["LSInfo"] } objc2-foundation = { version = "0.3.2", features = ["NSString"] } once_cell = "1.21.3" rand = "0.9.2" diff --git a/build.rs b/build.rs index 5d16e45..a4b0b05 100644 --- a/build.rs +++ b/build.rs @@ -2,6 +2,4 @@ fn main() { println!("cargo:rustc-link-search=framework=/System/Library/PrivateFrameworks"); println!("cargo:rustc-link-lib=framework=IOKit"); println!("cargo:rustc-link-lib=framework=MultitouchSupport"); - println!("cargo:rustc-link-lib=framework=CoreServices"); - println!("cargo:rustc-link-lib=framework=ApplicationServices"); } From 17a3fb2a0c336c1126cd69f2f7c55262c7a92ced Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud Date: Mon, 26 Jan 2026 12:14:40 +0100 Subject: [PATCH 7/9] chpore: cleanup --- src/app/tile.rs | 4 ++-- src/platform/cross.rs | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/tile.rs b/src/app/tile.rs index ee546ee..f17319e 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -24,7 +24,7 @@ use iced::{event, window}; use objc2::rc::Retained; use objc2_app_kit::NSRunningApplication; -use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use tray_icon::TrayIcon; use std::fs; @@ -249,7 +249,7 @@ fn handle_hot_reloading() -> impl futures::Stream { ) .unwrap_or("".to_string()); - let paths: Vec = default_app_paths().into_par_iter().collect(); + let paths = default_app_paths(); let mut total_files: usize = paths .par_iter() .map(|dir| count_dirs_in_dir(Path::new(dir))) diff --git a/src/platform/cross.rs b/src/platform/cross.rs index a9d3e60..3f0afe2 100644 --- a/src/platform/cross.rs +++ b/src/platform/cross.rs @@ -4,7 +4,7 @@ use std::{ process::exit, }; -use rayon::iter::{IntoParallelIterator, ParallelIterator as _}; +use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator as _}; use crate::{ app::apps::{App, AppCommand}, @@ -12,7 +12,9 @@ use crate::{ utils::{handle_from_icns, log_error, log_error_and_exit}, }; -pub fn default_app_paths() -> impl IntoParallelIterator { +pub fn default_app_paths() +-> impl IntoParallelIterator + for<'a> IntoParallelRefIterator<'a, Item = &'a String> +{ let user_local_path = std::env::var("HOME").unwrap() + "/Applications/"; [ From 35436bb21eca311128e634d76ce9f486ac0c241d Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud Date: Mon, 26 Jan 2026 12:30:01 +0100 Subject: [PATCH 8/9] feat: do not include discovery --- src/platform/macos/discovery.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/platform/macos/discovery.rs b/src/platform/macos/discovery.rs index 6520f4c..2cdc4c5 100644 --- a/src/platform/macos/discovery.rs +++ b/src/platform/macos/discovery.rs @@ -17,7 +17,7 @@ use core::{ use std::sync::LazyLock; use objc2_core_foundation::{CFArray, CFRetained, CFURL}; -use objc2_foundation::{NSBundle, NSString, NSURL, ns_string}; +use objc2_foundation::{NSBundle, NSNumber, NSString, NSURL, ns_string}; use rayon::iter::{IntoParallelIterator, ParallelIterator as _}; use crate::{ @@ -173,6 +173,23 @@ fn query_app(url: impl AsRef, store_icons: bool) -> Option { .map(|s| s.to_string()) }; + let is_truthy = |key: &NSString| -> bool { + info.objectForKey(key) + .map(|v| { + // Check for boolean true or string "1"/"YES" + v.downcast_ref::().is_some_and(|n| n.boolValue()) + || v.downcast_ref::().is_some_and(|s| { + s.to_string() == "1" || s.to_string().eq_ignore_ascii_case("YES") + }) + }) + .unwrap_or(false) + }; + + // Filter out background-only apps (daemons, agents, internal system apps) + if is_truthy(ns_string!("LSUIElement")) || is_truthy(ns_string!("LSBackgroundOnly")) { + return None; + } + let name = get_string(ns_string!("CFBundleDisplayName")) .or_else(|| get_string(ns_string!("CFBundleName"))) .or_else(|| { From 45ab65cafcc82fec22e1059f06bf99adb756a7d6 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud Date: Mon, 26 Jan 2026 12:39:16 +0100 Subject: [PATCH 9/9] fix: filter out system apps --- src/app/apps.rs | 11 --------- src/platform/macos/discovery.rs | 41 ++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/app/apps.rs b/src/app/apps.rs index d55c2d0..29f7c75 100644 --- a/src/app/apps.rs +++ b/src/app/apps.rs @@ -124,17 +124,6 @@ impl App { name: format!("Current RustCast Version: {app_version}"), name_lc: "version".to_string(), }, - App { - open_command: AppCommand::Function(Function::OpenApp( - "/System/Library/CoreServices/Finder.app".to_string(), - )), - desc: "Application".to_string(), - icons: handle_from_icns(Path::new( - "/System/Library/CoreServices/Finder.app/Contents/Resources/Finder.icns", - )), - name: "Finder".to_string(), - name_lc: "finder".to_string(), - }, ] } diff --git a/src/platform/macos/discovery.rs b/src/platform/macos/discovery.rs index 2cdc4c5..7e4a1fd 100644 --- a/src/platform/macos/discovery.rs +++ b/src/platform/macos/discovery.rs @@ -14,7 +14,11 @@ use core::{ mem, ptr::{self, NonNull}, }; -use std::sync::LazyLock; +use std::{ + env, + path::{Path, PathBuf}, + sync::LazyLock, +}; use objc2_core_foundation::{CFArray, CFRetained, CFURL}; use objc2_foundation::{NSBundle, NSNumber, NSString, NSURL, ns_string}; @@ -148,6 +152,32 @@ fn registered_app_urls() -> Option>> { Some(unsafe { CFRetained::from_raw(url_ptr) }) } +/// Directories that contain user-facing applications. +/// Apps in these directories are included by default (after LSUIElement check). +static USER_APP_DIRECTORIES: LazyLock<&'static [&'static Path]> = LazyLock::new(|| { + // These strings live for the lifetime of the program, so are safe to leak. + let items = [ + Path::new("/Applications/"), + Path::new("/System/Applications/"), + ]; + + let Some(home) = env::var_os("HOME") else { + return Box::leak(Box::new(items)); + }; + + let home_apps = Path::new(&home).join("Applications/"); + let home_apps = PathBuf::leak(home_apps); + + Box::leak(Box::new([items[0], items[1], home_apps])) +}); + +/// Checks if an app path is in a trusted user-facing application directory. +fn is_in_user_app_directory(path: &Path) -> bool { + USER_APP_DIRECTORIES + .iter() + .any(|directory| path.starts_with(directory)) +} + /// Extracts application metadata from a bundle URL. /// /// Queries the bundle's `Info.plist` for display name and icon, with the @@ -190,6 +220,15 @@ fn query_app(url: impl AsRef, store_icons: bool) -> Option { return None; } + // For apps outside trusted directories, require LSApplicationCategoryType to be set. + // This filters out internal system apps (SCIM, ShortcutsActions, etc.) while keeping + // user-facing apps like Finder that happen to live in /System/Library/CoreServices/. + if !is_in_user_app_directory(&path) + && get_string(ns_string!("LSApplicationCategoryType")).is_none() + { + return None; + } + let name = get_string(ns_string!("CFBundleDisplayName")) .or_else(|| get_string(ns_string!("CFBundleName"))) .or_else(|| {