diff --git a/Cargo.lock b/Cargo.lock index 5f986db..67a8f23 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" @@ -3747,6 +3747,7 @@ dependencies = [ "iced", "icns", "image", + "libc", "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-application-services", diff --git a/Cargo.toml b/Cargo.toml index 386a1b5..89bc634 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 = [ 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'] 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/app/tile.rs b/src/app/tile.rs index 830abdb..f17319e 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; @@ -28,11 +27,10 @@ use objc2_app_kit::NSRunningApplication; use rayon::iter::{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)] @@ -254,7 +252,7 @@ fn handle_hot_reloading() -> impl futures::Stream { let paths = default_app_paths(); let mut total_files: usize = paths .par_iter() - .map(|dir| count_dirs_in_dir(&dir.to_owned().into())) + .map(|dir| count_dirs_in_dir(Path::new(dir))) .sum(); loop { @@ -265,7 +263,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(Path::new(dir))) .sum(); if current_content != content { @@ -281,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, diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 42a9a23..772a43d 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -10,55 +10,32 @@ 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::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::pages::clipboard::clipboard_view, platform::get_installed_apps}; use crate::{ app::{Message, Page, apps::App, default_settings, tile::Tile}, config::Config, - macos::{self, transform_process_to_ui_element}, - utils::get_installed_apps, + platform::transform_process_to_ui_element, }; -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()); 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(); })); 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 67eaf5d..a7f75a6 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -9,34 +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::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; 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::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}; +use crate::{app::RUSTCAST_DESC_NAME, platform::get_installed_apps}; pub fn handle_update(tile: &mut Tile, message: Message) -> Task { match message { @@ -182,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()); @@ -318,7 +304,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/platform/cross.rs b/src/platform/cross.rs new file mode 100644 index 0000000..3f0afe2 --- /dev/null +++ b/src/platform/cross.rs @@ -0,0 +1,151 @@ +use std::{ + fs, + path::{Path, PathBuf}, + process::exit, +}; + +use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, 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 + for<'a> IntoParallelRefIterator<'a, Item = &'a String> +{ + 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..7e4a1fd --- /dev/null +++ b/src/platform/macos/discovery.rs @@ -0,0 +1,282 @@ +//! 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::{ + env, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +use objc2_core_foundation::{CFArray, CFRetained, CFURL}; +use objc2_foundation::{NSBundle, NSNumber, 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"; + +/// 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_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 { + // 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 { + // 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; + }; + + // 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 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. + 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 +/// 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 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; + } + + // 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(|| { + 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/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 89% rename from src/macos.rs rename to src/platform/macos/mod.rs index 87f53dc..d9d0eb6 100644 --- a/src/macos.rs +++ b/src/platform/macos/mod.rs @@ -1,11 +1,15 @@ //! Macos specific logic, such as window settings, etc. -#[cfg(target_os = "macos")] +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 /// 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 +19,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 +47,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 +71,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..82442ac --- /dev/null +++ b/src/platform/mod.rs @@ -0,0 +1,57 @@ +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; + +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(_: 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..847c4f6 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) @@ -25,13 +13,14 @@ 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(); } } /// 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 +45,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 || {