diff --git a/Cargo.lock b/Cargo.lock index 6df4aeb..c54c27e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "aligned" version = "0.4.3" @@ -149,6 +158,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + [[package]] name = "arrayref" version = "0.3.9" @@ -394,6 +409,30 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "binrw" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81419ff39e6ed10a92a7f125290859776ced35d9a08a665ae40b23e7ca702f30" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "376404e55ec40d0d6f8b4b7df3f87b87954bd987f0cf9a7207ea3b6ea5c9add4" +dependencies = [ + "either", + "owo-colors", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -642,6 +681,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -681,6 +734,15 @@ dependencies = [ "x11rb", ] +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -997,6 +1059,15 @@ dependencies = [ "phf", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1456,6 +1527,18 @@ dependencies = [ "wasip2", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "gif" version = "0.14.1" @@ -1562,6 +1645,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "global-hotkey" version = "0.7.0" @@ -1797,6 +1886,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "iced" version = "0.14.0" @@ -2158,6 +2271,25 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2167,6 +2299,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "jni" version = "0.21.1" @@ -2403,6 +2541,28 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lnk" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e8628ddc5b5ce2b0411d8f389886d3bb265f91f9413662fa81fdd108a9ae33" +dependencies = [ + "binrw", + "bitflags 2.10.0", + "chrono", + "encoding_rs", + "getset", + "log", + "num-derive 0.4.2", + "num-traits", + "serde", + "serde_json", + "substring", + "thiserror 2.0.17", + "uuid", + "winstructs", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -2679,6 +2839,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -3128,6 +3299,17 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3172,6 +3354,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + [[package]] name = "pango" version = "0.18.3" @@ -3238,6 +3426,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3455,6 +3649,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -3595,7 +3811,7 @@ dependencies = [ "maybe-rayon", "new_debug_unreachable", "noop_proc_macro", - "num-derive", + "num-derive 0.4.2", "num-traits", "paste", "profiling", @@ -3697,6 +3913,35 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3742,19 +3987,24 @@ version = "0.1.0" dependencies = [ "anyhow", "arboard", + "codepage", "emojis", + "glob", "global-hotkey", "iced", "icns", "image", + "lnk", "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-application-services", "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", + "open", "rand", "rayon", + "regex", "serde", "tokio", "toml 0.9.11+spec-1.1.0", @@ -3875,6 +4125,19 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -4121,6 +4384,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + [[package]] name = "svg_fmt" version = "0.4.5" @@ -4145,6 +4417,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -5668,6 +5941,23 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winstructs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dc7406cd936173d9cc3a4fd5dc5b295bc59612439d72038e3d7ac4e5dd42de9" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "chrono", + "log", + "num-derive 0.3.3", + "num-traits", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -5929,6 +6219,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index ca9c4cc..5e3b74e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,14 @@ edition = "2024" [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.52" -windows = { version = "0.58", features = ["Win32_UI_WindowsAndMessaging", "Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_Com", "Win32_UI_Shell"] } +windows = { version = "0.58", features = [ + "Win32_UI_WindowsAndMessaging", + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_System_Com", + "Win32_UI_Shell", + "Win32_Globalization" +]} [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.3" @@ -36,6 +43,11 @@ tray-icon = "0.21.3" url = "2.5.8" tracing = "0.1.44" tracing-subscriber = "0.3.22" +glob = "0.3.3" +open = "5.3.3" +regex = "1.12.2" +lnk = "0.6.3" +codepage = "0.1.2" [package.metadata.bundle] name = "RustCast" diff --git a/src/app.rs b/src/app.rs index f54d192..4bb64b0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ //! Main logic for the app + use crate::commands::Function; use iced::window::{self, Id, Settings}; diff --git a/src/app/apps.rs b/src/app/apps.rs index a6232c8..4f0f903 100644 --- a/src/app/apps.rs +++ b/src/app/apps.rs @@ -13,8 +13,8 @@ use crate::{ app::{Message, Page, RUSTCAST_DESC_NAME}, clipboard::ClipBoardContentType, commands::Function, + cross_platform::get_img_handle, styles::{result_button_style, result_row_container_style}, - utils::handle_from_icns, }; /// This tells each "App" what to do when it is clicked, whether it is a function, a message, or a display @@ -73,7 +73,7 @@ impl App { App { open_command: AppCommand::Function(Function::Quit), desc: RUSTCAST_DESC_NAME.to_string(), - icons: handle_from_icns(Path::new( + icons: get_img_handle(Path::new( "/Applications/Rustcast.app/Contents/Resources/icon.icns", )), name: "Quit RustCast".to_string(), @@ -82,7 +82,7 @@ impl App { App { open_command: AppCommand::Function(Function::OpenPrefPane), desc: RUSTCAST_DESC_NAME.to_string(), - icons: handle_from_icns(Path::new( + icons: get_img_handle(Path::new( "/Applications/Rustcast.app/Contents/Resources/icon.icns", )), name: "Open RustCast Preferences".to_string(), @@ -91,7 +91,7 @@ impl App { App { open_command: AppCommand::Message(Message::SwitchToPage(Page::EmojiSearch)), desc: RUSTCAST_DESC_NAME.to_string(), - icons: handle_from_icns(Path::new( + icons: get_img_handle(Path::new( "/Applications/Rustcast.app/Contents/Resources/icon.icns", )), name: "Search for an Emoji".to_string(), @@ -100,7 +100,7 @@ impl App { App { open_command: AppCommand::Message(Message::SwitchToPage(Page::ClipboardHistory)), desc: RUSTCAST_DESC_NAME.to_string(), - icons: handle_from_icns(Path::new( + icons: get_img_handle(Path::new( "/Applications/Rustcast.app/Contents/Resources/icon.icns", )), name: "Clipboard History".to_string(), @@ -109,7 +109,7 @@ impl App { App { open_command: AppCommand::Message(Message::ReloadConfig), desc: RUSTCAST_DESC_NAME.to_string(), - icons: handle_from_icns(Path::new( + icons: get_img_handle(Path::new( "/Applications/Rustcast.app/Contents/Resources/icon.icns", )), name: "Reload RustCast".to_string(), @@ -118,7 +118,7 @@ impl App { App { open_command: AppCommand::Display, desc: RUSTCAST_DESC_NAME.to_string(), - icons: handle_from_icns(Path::new( + icons: get_img_handle(Path::new( "/Applications/Rustcast.app/Contents/Resources/icon.icns", )), name: format!("Current RustCast Version: {app_version}"), @@ -129,7 +129,7 @@ impl App { "/System/Library/CoreServices/Finder.app".to_string(), )), desc: "Application".to_string(), - icons: handle_from_icns(Path::new( + icons: get_img_handle(Path::new( "/System/Library/CoreServices/Finder.app/Contents/Resources/Finder.icns", )), name: "Finder".to_string(), diff --git a/src/app/menubar.rs b/src/app/menubar.rs index 5b724d3..68e1783 100644 --- a/src/app/menubar.rs +++ b/src/app/menubar.rs @@ -13,7 +13,7 @@ use tray_icon::{ use crate::{ app::{Message, tile::ExtSender}, - cross_platform::{open_settings, open_url}, + cross_platform::open_settings, }; /// This creates a new menubar icon for the app @@ -91,7 +91,9 @@ fn init_event_handler(sender: ExtSender, hotkey_id: u32) { .spawn(async move { sender.clone().try_send(Message::HideTrayIcon).unwrap() }); } "open_issue_page" => { - open_url("https://github.com/unsecretised/rustcast/issues/new"); + if let Err(e) = open::that("https://github.com/unsecretised/rustcast/issues/new") { + tracing::error!("Error opening url: {}", e) + } } "show_rustcast" => { runtime.spawn(async move { @@ -102,13 +104,19 @@ fn init_event_handler(sender: ExtSender, hotkey_id: u32) { }); } "open_help_page" => { - open_url("https://github.com/unsecretised/rustcast/discussions/new?category=q-a"); + if let Err(e) = open::that( + "https://github.com/unsecretised/rustcast/discussions/new?category=q-a", + ) { + tracing::error!("Error opening url: {}", e) + } } "open_preferences" => { open_settings(); } "open_github_page" => { - open_url("https://github.com/unsecretised/rustcast"); + if let Err(e) = open::that("https://github.com/unsecretised/rustcast") { + tracing::error!("Error opening url: {}", e) + } } _ => {} } diff --git a/src/app/tile.rs b/src/app/tile.rs index c40c587..193e28f 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -217,19 +217,30 @@ impl Tile { } // Unused, keeping it for now - pub fn capture_frontmost(&mut self) { - #[cfg(target_os = "macos")] - { - use objc2_app_kit::NSWorkspace; + // pub fn capture_frontmost(&mut self) { + // #[cfg(target_os = "macos")] + // { + // use objc2_app_kit::NSWorkspace; - let ws = NSWorkspace::sharedWorkspace(); - self.frontmost = ws.frontmostApplication(); - }; + // let ws = NSWorkspace::sharedWorkspace(); + // self.frontmost = ws.frontmostApplication(); + // }; - #[cfg(target_os = "windows")] - { - self.frontmost = Some(unsafe { GetForegroundWindow() }); - } + // #[cfg(target_os = "windows")] + // { + // use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; + + // self.frontmost = Some(unsafe { GetForegroundWindow() }); + // } + // } + + /// Gets the frontmost application to focus later. + #[cfg(target_os = "macos")] + pub fn capture_frontmost(&mut self) { + use objc2_app_kit::NSWorkspace; + + let ws = NSWorkspace::sharedWorkspace(); + self.frontmost = ws.frontmostApplication(); } /// Restores the frontmost application. diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index e11b6ab..8d611cc 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -12,9 +12,11 @@ use iced::{Length::Fill, widget::text_input}; use rayon::slice::ParallelSliceMut; +#[cfg(target_os = "windows")] +use crate::app; use crate::app::tile::AppIndex; use crate::styles::{contents_style, rustcast_text_input_style}; -use crate::utils::get_installed_apps; +use crate::utils::index_installed_apps; use crate::{ app::{Message, Page, apps::App, default_settings, tile::Tile}, config::Config, @@ -63,7 +65,7 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { let (id, open) = window::open(settings); #[cfg(target_os = "windows")] - let open: Task = open.discard(); + let open: Task = open.discard(); #[cfg(target_os = "macos")] let open = open.discard().chain(window::run(id, |handle| { @@ -72,7 +74,14 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { Message::OpenWindow })); - let mut options: Vec = get_installed_apps(config); + let options = index_installed_apps(config); + + if let Err(ref e) = options { + tracing::error!("Error indexing apps: {e}") + } + + // Still try to load the rest + let mut options = options.unwrap_or_default(); 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 e8aa98b..246d2fe 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -33,9 +33,8 @@ 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::utils::index_installed_apps; #[cfg(target_os = "macos")] use crate::{ cross_platform::macos::focus_this_app, @@ -43,7 +42,8 @@ use crate::{ }; pub fn handle_update(tile: &mut Tile, message: Message) -> Task { - tracing::debug!("Handling update (message: {:?})", message); + tracing::trace!("Handling update (message: {:?})", message); + match message { Message::OpenWindow => { #[cfg(target_os = "macos")] @@ -103,7 +103,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } tile.query_lc = input.trim().to_lowercase(); - tile.query = input; + tile.query = input.clone(); let prev_size = tile.results.len(); if tile.query_lc.is_empty() && tile.page != Page::ClipboardHistory { tile.results = vec![]; @@ -202,7 +202,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } }) .collect(); - } else if tile.results.is_empty() && is_valid_url(&tile.query) { + } else if tile.results.is_empty() && url::Url::parse(&input).is_ok() { tile.results.push(App { open_command: AppCommand::Function(Function::OpenWebsite(tile.query.clone())), desc: "Web Browsing".to_string(), @@ -336,16 +336,22 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { ) .unwrap(); - let mut new_options: Vec = get_installed_apps(&new_config); - - 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()); + match index_installed_apps(&new_config) { + Ok(mut new_options) => { + 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()); - tile.theme = new_config.theme.to_owned().into(); - tile.config = new_config; - tile.options = AppIndex::from_apps(new_options); - Task::none() + tile.theme = new_config.theme.to_owned().into(); + tile.config = new_config; + tile.options = AppIndex::from_apps(new_options); + Task::none() + } + Err(e) => { + tracing::error!("Error reindexing apps: {}", e); + Task::none() + } + } } Message::KeyPressed(hk_id) => { diff --git a/src/commands.rs b/src/commands.rs index 3b5fc7b..a90b43e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -75,7 +75,6 @@ impl Function { ); } - #[cfg(target_os = "macos")] Function::OpenWebsite(url) => { let open_url = if url.starts_with("http") { url.to_owned() @@ -83,15 +82,8 @@ impl Function { format!("https://{}", url) }; - thread::spawn(move || { - NSWorkspace::new().openURL( - &NSURL::URLWithString_relativeToURL( - &objc2_foundation::NSString::from_str(&open_url), - None, - ) - .unwrap(), - ); - }); + // Should never get here without it being validated first + open::that(open_url).unwrap(); } Function::Calculate(expr) => { @@ -110,8 +102,8 @@ impl Function { } }, + #[cfg(target_os = "macos")] Function::OpenPrefPane => { - #[cfg(target_os = "macos")] thread::spawn(move || { NSWorkspace::new().openURL(&NSURL::fileURLWithPath( &objc2_foundation::NSString::from_str( @@ -123,6 +115,10 @@ impl Function { } Function::Quit => std::process::exit(0), + f => { + // TODO: something in the UI to show this + tracing::error!("The function {:?} is unimplemented for this platform", f); + } } } } diff --git a/src/config/include_patterns.rs b/src/config/include_patterns.rs new file mode 100644 index 0000000..8331fef --- /dev/null +++ b/src/config/include_patterns.rs @@ -0,0 +1,56 @@ +//! Parser for include patterns + +use std::{path::PathBuf, str::FromStr, sync::LazyLock}; + +use regex::Regex; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +static WITH_DEPTH_REGEX: LazyLock = LazyLock::new(|| + // (.*?) Matches the path (group 0) + // (?::[..])? Optionally match the depth specifier without capturing + // (?[0-9]+) Capture the number for maximum depth in the named group depth + Regex::new("^(.*?)(?::(?[0-9]+))?$").unwrap()); + +#[derive(Debug, PartialEq, Clone)] +pub struct Pattern { + pub path: PathBuf, + pub max_depth: usize, +} + +impl FromStr for Pattern { + type Err = anyhow::Error; + + fn from_str(str: &str) -> Result { + let matched = WITH_DEPTH_REGEX.captures(str); + + if let Some(x) = matched { + Ok(Pattern { + path: PathBuf::from(&x[1]), + max_depth: x.name("depth").map_or(1, |m| m.as_str().parse().unwrap()), + }) + } else { + Err(anyhow::Error::msg("Invalid pattern syntax: \"{x}\"")) + } + } +} + +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Vec::::deserialize(deserializer)? + .iter() + .map(|x| x.parse().map_err(serde::de::Error::custom)) + .collect() +} + +pub fn serialize(patterns: &[Pattern], serializer: S) -> Result +where + S: Serializer, +{ + patterns + .iter() + .map(|x| format!("{}:{}", x.path.to_string_lossy(), x.max_depth)) + .collect::>() + .serialize(serializer) +} diff --git a/src/config.rs b/src/config/mod.rs similarity index 84% rename from src/config.rs rename to src/config/mod.rs index 98af0b1..f29b4b3 100644 --- a/src/config.rs +++ b/src/config/mod.rs @@ -1,15 +1,21 @@ //! This is the config file type definitions for rustcast -use std::{path::Path, sync::Arc}; +use std::{path::PathBuf, sync::Arc}; -use iced::{Font, font::Family, theme::Custom, widget::image::Handle}; +use iced::{Font, font::Family, theme::Custom}; use serde::{Deserialize, Serialize}; +#[cfg(target_os = "windows")] +use crate::cross_platform::windows::app_finding::get_known_paths; use crate::{ app::apps::{App, AppCommand}, commands::Function, - utils::handle_from_icns, + cross_platform::get_img_handle, }; + +mod include_patterns; +mod patterns; + /// The main config struct (effectively the config file's "schema") #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] @@ -17,17 +23,35 @@ pub struct Config { pub toggle_hotkey: String, pub buffer_rules: Buffer, pub theme: Theme, + pub placeholder: String, pub search_url: String, pub haptic_feedback: bool, pub show_trayicon: bool, pub shells: Vec, - pub index_dirs: Vec, + + #[serde(with = "include_patterns")] + pub index_dirs: Vec, + + #[serde(with = "patterns")] + pub index_exclude_patterns: Vec, + + #[serde(with = "patterns")] + pub index_include_patterns: Vec, } impl Default for Config { /// The default config fn default() -> Self { + #[cfg(target_os = "windows")] + let index_dirs = get_known_paths() + .into_iter() + .map(|path| include_patterns::Pattern { path, max_depth: 3 }) + .collect(); + + #[cfg(not(target_os = "windows"))] + let index_dirs = Vec::new(); + Self { toggle_hotkey: "ALT+SPACE".to_string(), buffer_rules: Buffer::default(), @@ -37,7 +61,9 @@ impl Default for Config { haptic_feedback: false, show_trayicon: true, shells: vec![], - index_dirs: vec![], + index_dirs, + index_exclude_patterns: vec![], + index_include_patterns: vec![], } } } @@ -177,11 +203,7 @@ impl Shelly { let self_clone = self.clone(); let icon = self_clone.icon_path.and_then(|x| { let x = x.replace("~", &std::env::var("HOME").unwrap()); - if x.ends_with(".icns") { - handle_from_icns(Path::new(&x)) - } else { - Some(Handle::from_path(Path::new(&x))) - } + get_img_handle(&PathBuf::from(x)) }); App { open_command: AppCommand::Function(Function::RunShellCommand( diff --git a/src/config/patterns.rs b/src/config/patterns.rs new file mode 100644 index 0000000..514a860 --- /dev/null +++ b/src/config/patterns.rs @@ -0,0 +1,27 @@ +//! Parser for glob patterns + +use std::str::FromStr; + +use glob::Pattern; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Vec::::deserialize(deserializer)? + .iter() + .map(|x| Pattern::from_str(x).map_err(|e| serde::de::Error::custom(e.msg))) + .collect() +} + +pub fn serialize(patterns: &[Pattern], serializer: S) -> Result +where + S: Serializer, +{ + patterns + .iter() + .map(|x| x.as_str()) + .collect::>() + .serialize(serializer) +} diff --git a/src/cross_platform/macos/mod.rs b/src/cross_platform/macos/mod.rs index 7b29f44..6ac202e 100644 --- a/src/cross_platform/macos/mod.rs +++ b/src/cross_platform/macos/mod.rs @@ -6,11 +6,13 @@ pub mod haptics; use crate::app::apps::{App, AppCommand}; use crate::commands::Function; use crate::config::Config; -use crate::utils::handle_from_icns; -use crate::utils::index_dirs_from_config; +use crate::utils::index_installed_apps; +use icns::IconFamily; +use rayon::iter::ParallelExtend; use { iced::wgpu::rwh::RawWindowHandle, iced::wgpu::rwh::WindowHandle, + iced::widget::image::Handle, objc2::MainThreadMarker, objc2::rc::Retained, objc2_app_kit::NSView, @@ -222,7 +224,7 @@ fn get_installed_apps(dir: impl AsRef, store_icons: bool) -> Vec { .collect() } -pub fn get_installed_macos_apps(config: &Config) -> Vec { +pub fn get_installed_macos_apps(config: &Config) -> anyhow::Result> { let store_icons = config.theme.show_icons; let user_local_path = std::env::var("HOME").unwrap() + "/Applications/"; let paths: Vec = vec![ @@ -232,25 +234,15 @@ pub fn get_installed_macos_apps(config: &Config) -> Vec { "/System/Applications/Utilities/".to_string(), ]; - let mut apps = paths - .par_iter() - .map(|path| get_installed_apps(path, store_icons)) - .flatten() - .collect(); - index_dirs_from_config(&mut apps); - - apps -} + let mut apps = index_installed_apps(config)?; + apps.par_extend( + paths + .par_iter() + .map(|path| get_installed_apps(path, store_icons)) + .flatten(), + ); -/// Opens a provided URL -pub fn open_url(url: &str) { - let url = url.to_owned(); - thread::spawn(move || { - NSWorkspace::new().openURL( - &NSURL::URLWithString_relativeToURL(&objc2_foundation::NSString::from_str(&url), None) - .unwrap(), - ); - }); + Ok(apps) } /// Open the settings file with the system default editor @@ -264,3 +256,25 @@ pub fn open_settings() { )); }); } + +/// Gets an iced image handle from a .icns file. +pub(crate) fn handle_from_icns(path: &Path) -> Option { + use image::RgbaImage; + + let data = std::fs::read(path).ok()?; + let family = IconFamily::read(std::io::Cursor::new(&data)).ok()?; + + let icon_type = family.available_icons(); + + let icon = family.get_icon_with_type(*icon_type.first()?).ok()?; + let image = RgbaImage::from_raw( + icon.width() as u32, + icon.height() as u32, + icon.data().to_vec(), + )?; + Some(Handle::from_rgba( + image.width(), + image.height(), + image.into_raw(), + )) +} diff --git a/src/cross_platform/mod.rs b/src/cross_platform/mod.rs index e84b658..6244cb4 100644 --- a/src/cross_platform/mod.rs +++ b/src/cross_platform/mod.rs @@ -1,15 +1,24 @@ +#![warn(missing_docs)] + +use std::path::Path; + #[cfg(target_os = "macos")] pub mod macos; #[cfg(target_os = "windows")] pub mod windows; -pub fn open_url(url: &str) { +/// Opens the settings file +pub fn open_settings() { #[cfg(target_os = "macos")] - macos::open_url(url) + macos::open_settings() } -pub fn open_settings() { +/// Gets an iced image handle +pub fn get_img_handle(path: &Path) -> Option { #[cfg(target_os = "macos")] - macos::open_settings() + return macos::handle_from_icns(path); + + #[cfg(target_os = "windows")] + return Some(iced::widget::image::Handle::from_path(path)); } diff --git a/src/cross_platform/windows.rs b/src/cross_platform/windows.rs deleted file mode 100644 index cefd369..0000000 --- a/src/cross_platform/windows.rs +++ /dev/null @@ -1,177 +0,0 @@ -use { - crate::app::apps::App, - rayon::prelude::*, - windows::{ - Win32::{ - System::Com::CoTaskMemFree, - UI::{ - Shell::{ - FOLDERID_LocalAppData, FOLDERID_ProgramFiles, FOLDERID_ProgramFilesX86, - KF_FLAG_DEFAULT, SHGetKnownFolderPath, - }, - WindowsAndMessaging::GetCursorPos, - }, - }, - core::GUID, - }, -}; - -fn get_apps_from_registry(apps: &mut Vec) { - use std::ffi::OsString; - let hkey = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE); - - let registers = [ - hkey.open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall") - .unwrap(), - hkey.open_subkey("SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall") - .unwrap(), - ]; - - // where we can find installed applications - // src: https://stackoverflow.com/questions/2864984/how-to-programatically-get-the-list-of-installed-programs/2892848#2892848 - registers.iter().for_each(|reg| { - reg.enum_keys().for_each(|key| { - // Not debug only just because it doesn't run too often - tracing::trace!("App added [reg]: {:?}", key); - - // https://learn.microsoft.com/en-us/windows/win32/msi/uninstall-registry-key - let name = key.unwrap(); - let key = reg.open_subkey(&name).unwrap(); - let display_name: OsString = key.get_value("DisplayName").unwrap_or_default(); - - // they might be useful one day ? - // let publisher = key.get_value("Publisher").unwrap_or(OsString::new()); - // let version = key.get_value("DisplayVersion").unwrap_or(OsString::new()); - - // Trick, I saw on internet to point to the exe location.. - let exe_path: OsString = key.get_value("DisplayIcon").unwrap_or_default(); - if exe_path.is_empty() { - return; - } - // if there is something, it will be in the form of - // "C:\Program Files\Microsoft Office\Office16\WINWORD.EXE",0 - let exe_path = exe_path.to_string_lossy().to_string(); - let exe = exe_path.split(",").next().unwrap().to_string(); - - // make sure it ends with .exe - if !exe.ends_with(".exe") { - return; - } - - if !display_name.is_empty() { - use crate::{app::apps::AppCommand, commands::Function}; - - apps.push(App { - open_command: AppCommand::Function(Function::OpenApp(exe)), - name: display_name.clone().into_string().unwrap(), - name_lc: display_name.clone().into_string().unwrap().to_lowercase(), - icons: None, - desc: "Application".to_string(), - }) - } - }); - }); -} -fn get_apps_from_known_folder(apps: &mut Vec) { - let paths = get_known_paths(); - use crate::{app::apps::AppCommand, commands::Function}; - use walkdir::WalkDir; - - let found_apps: Vec = paths - .par_iter() - .flat_map(|path| { - WalkDir::new(path) - .follow_links(false) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.path().extension().is_some_and(|ext| ext == "exe")) - .map(|entry| { - let path = entry.path(); - let file_name = path.file_name().unwrap().to_string_lossy(); - let name = file_name.replace(".exe", ""); - - #[cfg(debug_assertions)] - tracing::trace!("Executable loaded [kfolder]: {:?}", path.to_str()); - - App { - open_command: AppCommand::Function(Function::OpenApp( - path.to_string_lossy().to_string(), - )), - name: name.clone(), - name_lc: name.to_lowercase(), - icons: None, - desc: "Application".to_string(), - } - }) - .collect::>() - }) - .collect(); - - apps.extend(found_apps); -} -fn get_known_paths() -> Vec { - let paths = vec![ - get_windows_path(&FOLDERID_ProgramFiles).unwrap_or_default(), - get_windows_path(&FOLDERID_ProgramFilesX86).unwrap_or_default(), - (get_windows_path(&FOLDERID_LocalAppData).unwrap_or_default() + "\\Programs\\"), - ]; - paths -} -fn get_windows_path(folder_id: &GUID) -> Option { - unsafe { - let folder = SHGetKnownFolderPath(folder_id, KF_FLAG_DEFAULT, None); - if let Ok(folder) = folder { - let path = folder.to_string().ok(); - CoTaskMemFree(Some(folder.0 as *mut _)); - path - } else { - None - } - } -} -pub fn get_installed_windows_apps() -> Vec { - use crate::utils::index_dirs_from_config; - - let mut apps = Vec::new(); - - tracing::debug!("Getting apps from registry"); - get_apps_from_registry(&mut apps); - - tracing::debug!("Getting apps from known folder"); - get_apps_from_known_folder(&mut apps); - - tracing::debug!("Getting apps from config"); - index_dirs_from_config(&mut apps); - - tracing::debug!("Apps loaded ({} total count)", apps.len()); - - apps -} - -pub fn open_on_focused_monitor() -> iced::Point { - use windows::Win32::Foundation::POINT; - use windows::Win32::Graphics::Gdi::{ - GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromPoint, - }; - - use crate::app::{DEFAULT_WINDOW_HEIGHT, WINDOW_WIDTH}; - let mut point = POINT { x: 0, y: 0 }; - let mut monitor_info = MONITORINFO { - cbSize: std::mem::size_of::() as u32, - ..Default::default() - }; - - let _cursor = unsafe { GetCursorPos(&mut point) }; - let monitor = unsafe { MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST) }; - let _monitor_infos = unsafe { GetMonitorInfoW(monitor, &mut monitor_info) }; - - let monitor_width = monitor_info.rcMonitor.right - monitor_info.rcMonitor.left; - let monitor_height = monitor_info.rcMonitor.bottom - monitor_info.rcMonitor.top; - let window_width = WINDOW_WIDTH; - let window_height = DEFAULT_WINDOW_HEIGHT; - - let x = monitor_info.rcMonitor.left as f32 + (monitor_width as f32 - window_width) / 2.0; - let y = monitor_info.rcMonitor.top as f32 + (monitor_height as f32 - window_height) / 2.0; - - iced::Point { x, y } -} diff --git a/src/cross_platform/windows/app_finding.rs b/src/cross_platform/windows/app_finding.rs new file mode 100644 index 0000000..893f81f --- /dev/null +++ b/src/cross_platform/windows/app_finding.rs @@ -0,0 +1,150 @@ +use { + crate::{ + app::apps::{App, AppCommand}, + commands::Function, + cross_platform::windows::get_acp, + }, + lnk::{ + Encoding, + encoding::{UTF_16LE, WINDOWS_1252}, + }, + std::{io, path::PathBuf}, + walkdir::WalkDir, + windows::{ + Win32::{ + Globalization::GetACP, + System::Com::CoTaskMemFree, + UI::Shell::{ + FOLDERID_LocalAppData, FOLDERID_ProgramFiles, FOLDERID_ProgramFilesX86, + KF_FLAG_DEFAULT, SHGetKnownFolderPath, + }, + }, + core::GUID, + }, +}; + +/// Loads apps from the registry keys `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` and +/// `SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall`. `apps` has the relvant items +/// appended to it. +/// +/// Based on https://stackoverflow.com/questions/2864984 +pub fn get_apps_from_registry(apps: &mut Vec) { + use std::ffi::OsString; + let hkey = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE); + + let registers = [ + hkey.open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall") + .unwrap(), + hkey.open_subkey("SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall") + .unwrap(), + ]; + + registers.iter().for_each(|reg| { + reg.enum_keys().for_each(|key| { + // Not debug only just because it doesn't run too often + tracing::trace!("App added [reg]: {:?}", key); + + // https://learn.microsoft.com/en-us/windows/win32/msi/uninstall-registry-key + let name = key.unwrap(); + let key = reg.open_subkey(&name).unwrap(); + let display_name: OsString = key.get_value("DisplayName").unwrap_or_default(); + + // they might be useful one day ? + // let publisher = key.get_value("Publisher").unwrap_or(OsString::new()); + // let version = key.get_value("DisplayVersion").unwrap_or(OsString::new()); + + // Trick, I saw on internet to point to the exe location.. + let exe_path: OsString = key.get_value("DisplayIcon").unwrap_or_default(); + if exe_path.is_empty() { + return; + } + // if there is something, it will be in the form of + // "C:\Program Files\Microsoft Office\Office16\WINWORD.EXE",0 + let exe_path = exe_path.to_string_lossy().to_string(); + let exe = exe_path.split(",").next().unwrap().to_string(); + + // make sure it ends with .exe + if !exe.ends_with(".exe") { + return; + } + + if !display_name.is_empty() { + use crate::{app::apps::AppCommand, commands::Function}; + + apps.push(App { + open_command: AppCommand::Function(Function::OpenApp(exe)), + name: display_name.clone().into_string().unwrap(), + name_lc: display_name.clone().into_string().unwrap().to_lowercase(), + icons: None, + desc: "Application".to_string(), + }) + } + }); + }); +} + +/// Returns the set of known paths +pub fn get_known_paths() -> Vec { + let paths = vec![ + get_windows_path(&FOLDERID_ProgramFiles).unwrap_or_default(), + get_windows_path(&FOLDERID_ProgramFilesX86).unwrap_or_default(), + (get_windows_path(&FOLDERID_LocalAppData) + .unwrap_or_default() + .join("Programs")), + ]; + paths +} + +/// Wrapper around `SHGetKnownFolderPath` to get paths to known folders +fn get_windows_path(folder_id: &GUID) -> Option { + unsafe { + let folder = SHGetKnownFolderPath(folder_id, KF_FLAG_DEFAULT, None); + if let Ok(folder) = folder { + let path = folder.to_string().ok()?; + CoTaskMemFree(Some(folder.0 as *mut _)); + Some(path.into()) + } else { + None + } + } +} + +pub fn index_start_menu() -> Vec { + WalkDir::new(r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs") + .into_iter() + .filter_map(|x| x.ok()) + .filter_map(|path| { + let lnk = lnk::ShellLink::open(path.path(), get_acp()); + + match lnk { + Ok(x) => { + let target = x.link_target(); + + match target { + Some(target) => Some(App { + open_command: AppCommand::Function(Function::OpenApp(target.clone())), + desc: "".to_string(), + icons: None, + name: path.file_name().to_string_lossy().to_string(), + name_lc: path.file_name().to_string_lossy().to_string(), + }), + None => { + tracing::debug!( + "Link at {} has no target, skipped", + path.path().to_string_lossy() + ); + None + } + } + } + Err(e) => { + tracing::debug!( + "Error opening link {} ({e}), skipped", + path.path().to_string_lossy() + ); + None + } + } + }) + .collect() +} diff --git a/src/cross_platform/windows/mod.rs b/src/cross_platform/windows/mod.rs new file mode 100644 index 0000000..53794cc --- /dev/null +++ b/src/cross_platform/windows/mod.rs @@ -0,0 +1,42 @@ +use lnk::{Encoding, encoding::WINDOWS_1252}; +use windows::Win32::{Globalization::GetACP, UI::WindowsAndMessaging::GetCursorPos}; + +pub mod app_finding; + +pub fn open_on_focused_monitor() -> iced::Point { + use windows::Win32::Foundation::POINT; + use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromPoint, + }; + + use crate::app::{DEFAULT_WINDOW_HEIGHT, WINDOW_WIDTH}; + let mut point = POINT { x: 0, y: 0 }; + let mut monitor_info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + let _cursor = unsafe { GetCursorPos(&mut point) }; + let monitor = unsafe { MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST) }; + let _monitor_infos = unsafe { GetMonitorInfoW(monitor, &mut monitor_info) }; + + let monitor_width = monitor_info.rcMonitor.right - monitor_info.rcMonitor.left; + let monitor_height = monitor_info.rcMonitor.bottom - monitor_info.rcMonitor.top; + let window_width = WINDOW_WIDTH; + let window_height = DEFAULT_WINDOW_HEIGHT; + + let x = monitor_info.rcMonitor.left as f32 + (monitor_width as f32 - window_width) / 2.0; + let y = monitor_info.rcMonitor.top as f32 + (monitor_height as f32 - window_height) / 2.0; + + iced::Point { x, y } +} + +/// Wrapper over GetACP that defaults to WINDOWS_1252 if the ACP isn't found +pub fn get_acp() -> Encoding { + unsafe { codepage::to_encoding(GetACP() as u16) }.unwrap_or_else(|| { + tracing::warn!( + "ACP not found, falling back to WINDOWS_1252 as the default shortcut encoding" + ); + WINDOWS_1252 + }) +} diff --git a/src/main.rs b/src/main.rs index 73fe500..4b37288 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,10 +10,11 @@ mod utils; mod cross_platform; use std::env::temp_dir; -use std::fs::File; +use std::fs::{File, create_dir}; +use std::io; // import from utils -use crate::utils::{create_config_file_if_not_exists, get_config_file_path, read_config_file}; +use crate::utils::{get_config_file_path, get_config_installation_dir, read_config_file}; use crate::app::tile::{self, Tile}; @@ -26,16 +27,34 @@ fn main() -> iced::Result { #[cfg(target_os = "macos")] cross_platform::macos::set_activation_policy_accessory(); + if let Err(e) = std::fs::metadata(get_config_installation_dir().join("rustcast/")) { + if e.kind() == io::ErrorKind::NotFound { + let result = create_dir(get_config_installation_dir().join("rustcast/")); + + if let Err(e) = result { + eprintln!("{}", e); + std::process::exit(1); + } + } else { + eprintln!("{}", e); + std::process::exit(1); + } + } + let file_path = get_config_file_path(); - let config = read_config_file(&file_path).unwrap(); - create_config_file_if_not_exists(&file_path, &config).unwrap(); + let config = read_config_file(&file_path); + if let Err(e) = config { + // Tracing isn't inited yet + eprintln!("Error parsing config: {}", e); + std::process::exit(1); + } + + let config = config.unwrap(); { let log_path = temp_dir().join("rustcast/log.log"); let vv_log_path = temp_dir().join("rustcast/vv_log.log"); - create_config_file_if_not_exists(&log_path, &config).unwrap(); - let file = File::create(&log_path).expect("Failed to create logfile"); let vv_file = File::create(&vv_log_path).expect("Failed to create logfile"); @@ -82,8 +101,6 @@ fn main() -> iced::Result { tracing::error!("{}", e.to_string()); } - tracing::info!("Starting."); - iced::daemon( move || tile::elm::new(show_hide, &config), tile::update::handle_update, diff --git a/src/unit_conversion.rs b/src/unit_conversion/defs.rs similarity index 54% rename from src/unit_conversion.rs rename to src/unit_conversion/defs.rs index 3bc6f7f..f467bcb 100644 --- a/src/unit_conversion.rs +++ b/src/unit_conversion/defs.rs @@ -1,12 +1,4 @@ -//! Unit conversion parsing and calculation. - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UnitCategory { - Length, - Mass, - Volume, - Temperature, -} +use crate::unit_conversion::UnitCategory; #[derive(Debug, Clone, Copy)] pub struct UnitDef { @@ -17,22 +9,7 @@ pub struct UnitDef { pub offset: f64, } -#[derive(Debug, Clone)] -pub struct ConversionResult { - pub source_value: f64, - pub source_unit: &'static UnitDef, - pub target_value: f64, - pub target_unit: &'static UnitDef, -} - -#[derive(Debug, Clone)] -struct ParsedQuery { - value: f64, - source_unit: &'static UnitDef, - target_unit: Option<&'static UnitDef>, -} - -const UNITS: &[UnitDef] = &[ +pub const UNITS: &[UnitDef] = &[ // Length (base: meter) UnitDef { name: "mm", @@ -231,152 +208,3 @@ const UNITS: &[UnitDef] = &[ offset: -273.15, }, ]; - -pub fn convert_query(query: &str) -> Option> { - let parsed = parse_query(query)?; - let base_value = to_base(parsed.value, parsed.source_unit); - - let mut results = Vec::new(); - let targets: Vec<&UnitDef> = match parsed.target_unit { - Some(target) => vec![target], - None => UNITS - .iter() - .filter(|unit| unit.category == parsed.source_unit.category) - .collect(), - }; - - for target_unit in targets { - if target_unit.name == parsed.source_unit.name { - continue; - } - let target_value = from_base(base_value, target_unit); - results.push(ConversionResult { - source_value: parsed.value, - source_unit: parsed.source_unit, - target_value, - target_unit, - }); - } - - if results.is_empty() { - None - } else { - Some(results) - } -} - -pub fn format_number(value: f64) -> String { - let value = if value.abs() < 1e-9 { 0.0 } else { value }; - let mut s = format!("{value:.6}"); - if let Some(dot_pos) = s.find('.') { - while s.ends_with('0') { - s.pop(); - } - if s.ends_with('.') && dot_pos == s.len() - 1 { - s.pop(); - } - } - s -} - -fn parse_query(query: &str) -> Option { - let (value_str, rest) = parse_number_prefix(query)?; - let value: f64 = value_str.parse().ok()?; - let rest = rest.trim_start(); - if rest.is_empty() { - return None; - } - - let rest_lc = rest.to_lowercase(); - let tokens: Vec<&str> = rest_lc.split_whitespace().collect(); - if tokens.is_empty() { - return None; - } - - let source_unit = find_unit(tokens[0])?; - match tokens.len() { - 1 => Some(ParsedQuery { - value, - source_unit, - target_unit: None, - }), - 2 => { - let target_unit = find_unit(tokens[1])?; - if target_unit.category != source_unit.category { - return None; - } - Some(ParsedQuery { - value, - source_unit, - target_unit: Some(target_unit), - }) - } - 3 if tokens[1] == "to" || tokens[1] == "in" => { - let target_unit = find_unit(tokens[2])?; - if target_unit.category != source_unit.category { - return None; - } - Some(ParsedQuery { - value, - source_unit, - target_unit: Some(target_unit), - }) - } - _ => None, - } -} - -fn parse_number_prefix(s: &str) -> Option<(&str, &str)> { - let s = s.trim_start(); - if s.is_empty() { - return None; - } - - let mut chars = s.char_indices().peekable(); - if let Some((_, c)) = chars.peek() - && (*c == '+' || *c == '-') - { - chars.next(); - } - - let mut end = 0; - let mut has_digit = false; - while let Some((idx, c)) = chars.peek().cloned() { - if c.is_ascii_digit() { - has_digit = true; - end = idx + c.len_utf8(); - chars.next(); - } else if c == '.' { - end = idx + c.len_utf8(); - chars.next(); - } else { - break; - } - } - - if !has_digit || end == 0 { - return None; - } - - let (num, rest) = s.split_at(end); - Some((num, rest)) -} - -fn find_unit(token: &str) -> Option<&'static UnitDef> { - let token = token.trim(); - if token.is_empty() { - return None; - } - - UNITS - .iter() - .find(|unit| unit.name == token || unit.aliases.contains(&token)) -} - -fn to_base(value: f64, unit: &UnitDef) -> f64 { - (value + unit.offset) * unit.scale -} - -fn from_base(value: f64, unit: &UnitDef) -> f64 { - value / unit.scale - unit.offset -} diff --git a/src/unit_conversion/mod.rs b/src/unit_conversion/mod.rs new file mode 100644 index 0000000..0d6da02 --- /dev/null +++ b/src/unit_conversion/mod.rs @@ -0,0 +1,177 @@ +//! Unit conversion parsing and calculation. + +use crate::unit_conversion::defs::{UNITS, UnitDef}; + +mod defs; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnitCategory { + Length, + Mass, + Volume, + Temperature, +} + +#[derive(Debug, Clone)] +pub struct ConversionResult { + pub source_value: f64, + pub source_unit: &'static UnitDef, + pub target_value: f64, + pub target_unit: &'static UnitDef, +} + +#[derive(Debug, Clone)] +struct ParsedQuery { + value: f64, + source_unit: &'static UnitDef, + target_unit: Option<&'static UnitDef>, +} + +pub fn convert_query(query: &str) -> Option> { + let parsed = parse_query(query)?; + let base_value = to_base(parsed.value, parsed.source_unit); + + let mut results = Vec::new(); + let targets: Vec<&UnitDef> = match parsed.target_unit { + Some(target) => vec![target], + None => UNITS + .iter() + .filter(|unit| unit.category == parsed.source_unit.category) + .collect(), + }; + + for target_unit in targets { + if target_unit.name == parsed.source_unit.name { + continue; + } + let target_value = from_base(base_value, target_unit); + results.push(ConversionResult { + source_value: parsed.value, + source_unit: parsed.source_unit, + target_value, + target_unit, + }); + } + + if results.is_empty() { + None + } else { + Some(results) + } +} + +pub fn format_number(value: f64) -> String { + let value = if value.abs() < 1e-9 { 0.0 } else { value }; + let mut s = format!("{value:.6}"); + if let Some(dot_pos) = s.find('.') { + while s.ends_with('0') { + s.pop(); + } + if s.ends_with('.') && dot_pos == s.len() - 1 { + s.pop(); + } + } + s +} + +fn parse_query(query: &str) -> Option { + let (value_str, rest) = parse_number_prefix(query)?; + let value: f64 = value_str.parse().ok()?; + let rest = rest.trim_start(); + if rest.is_empty() { + return None; + } + + let rest_lc = rest.to_lowercase(); + let tokens: Vec<&str> = rest_lc.split_whitespace().collect(); + if tokens.is_empty() { + return None; + } + + let source_unit = find_unit(tokens[0])?; + match tokens.len() { + 1 => Some(ParsedQuery { + value, + source_unit, + target_unit: None, + }), + 2 => { + let target_unit = find_unit(tokens[1])?; + if target_unit.category != source_unit.category { + return None; + } + Some(ParsedQuery { + value, + source_unit, + target_unit: Some(target_unit), + }) + } + 3 if tokens[1] == "to" || tokens[1] == "in" => { + let target_unit = find_unit(tokens[2])?; + if target_unit.category != source_unit.category { + return None; + } + Some(ParsedQuery { + value, + source_unit, + target_unit: Some(target_unit), + }) + } + _ => None, + } +} + +fn parse_number_prefix(s: &str) -> Option<(&str, &str)> { + let s = s.trim_start(); + if s.is_empty() { + return None; + } + + let mut chars = s.char_indices().peekable(); + if let Some((_, c)) = chars.peek() + && (*c == '+' || *c == '-') + { + chars.next(); + } + + let mut end = 0; + let mut has_digit = false; + while let Some((idx, c)) = chars.peek().cloned() { + if c.is_ascii_digit() { + has_digit = true; + end = idx + c.len_utf8(); + chars.next(); + } else if c == '.' { + end = idx + c.len_utf8(); + chars.next(); + } else { + break; + } + } + + if !has_digit || end == 0 { + return None; + } + + let (num, rest) = s.split_at(end); + Some((num, rest)) +} + +fn find_unit(token: &str) -> Option<&'static UnitDef> { + let token = token.trim(); + if token.is_empty() { + return None; + } + + UNITS + .iter() + .find(|unit| unit.name == token || unit.aliases.contains(&token)) +} + +fn to_base(value: f64, unit: &UnitDef) -> f64 { + (value + unit.offset) * unit.scale +} + +fn from_base(value: f64, unit: &UnitDef) -> f64 { + value / unit.scale - unit.offset +} diff --git a/src/utils.rs b/src/utils.rs index ed1f5c1..5d86cf5 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,50 +1,23 @@ //! This has all the utility functions that rustcast uses use std::{ - fs::{self}, + io, path::{Path, PathBuf}, thread, + time::Instant, }; -use iced::widget::image::Handle; -#[cfg(target_os = "macos")] -use icns::IconFamily; +use rayon::prelude::*; #[cfg(target_os = "macos")] use { - crate::cross_platform::macos::get_installed_macos_apps, objc2_app_kit::NSWorkspace, - objc2_foundation::NSURL, std::os::unix::fs::PermissionsExt, + objc2_app_kit::NSWorkspace, + objc2_foundation::NSURL, }; #[cfg(target_os = "windows")] -use {crate::cross_platform::windows::get_installed_windows_apps, std::process::Command}; - -use crate::{ - app::apps::{App, AppCommand}, - commands::Function, -}; +use std::process::Command; -/// This converts an icns file to an iced image handle -#[cfg(target_os = "macos")] -pub(crate) fn handle_from_icns(path: &Path) -> Option { - use image::RgbaImage; - - let data = std::fs::read(path).ok()?; - let family = IconFamily::read(std::io::Cursor::new(&data)).ok()?; - - let icon_type = family.available_icons(); - - let icon = family.get_icon_with_type(*icon_type.first()?).ok()?; - let image = RgbaImage::from_raw( - icon.width() as u32, - icon.height() as u32, - icon.data().to_vec(), - )?; - return Some(Handle::from_rgba( - image.width(), - image.height(), - image.into_raw(), - )); -} +use crate::app::apps::App; pub fn get_config_installation_dir() -> PathBuf { if cfg!(target_os = "windows") { @@ -63,39 +36,72 @@ pub fn get_config_file_path() -> PathBuf { home.join(".config/rustcast/config.toml") } } -use crate::config::Config; -pub fn read_config_file(file_path: &Path) -> Result { - let config: Config = match std::fs::read_to_string(file_path) { - Ok(a) => toml::from_str(&a).unwrap(), - Err(_) => Config::default(), - }; +/// Recursively loads apps from a set of folders. +/// +/// [`exclude_patterns`] is a set of glob patterns to include, while [`include_patterns`] is a set of +/// patterns to include ignoring [`exclude_patterns`]. +fn search_dir( + path: impl AsRef, + exclude_patterns: &[glob::Pattern], + include_patterns: &[glob::Pattern], + max_depth: usize, +) -> impl ParallelIterator { + use crate::{app::apps::AppCommand, commands::Function}; + use walkdir::WalkDir; + + WalkDir::new(path.as_ref()) + .follow_links(false) + .max_depth(max_depth) + .into_iter() + .par_bridge() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "exe")) + .filter_map(|entry| { + let path = entry.path(); + + if exclude_patterns.iter().any(|x| x.matches_path(path)) + && !include_patterns.iter().any(|x| x.matches_path(path)) + { + #[cfg(debug_assertions)] + tracing::trace!("Executable skipped [kfolder]: {:?}", path.to_str()); + + return None; + } - Ok(config) + let file_name = path.file_name().unwrap().to_string_lossy(); + let name = file_name.replace(".exe", ""); + + #[cfg(debug_assertions)] + tracing::trace!("Executable loaded [kfolder]: {:?}", path.to_str()); + + Some(App { + open_command: AppCommand::Function(Function::OpenApp( + path.to_string_lossy().to_string(), + )), + name: name.clone(), + name_lc: name.to_lowercase(), + icons: None, + desc: "Application".to_string(), + }) + }) } -pub fn create_config_file_if_not_exists( - file_path: &Path, - config: &Config, -) -> Result<(), std::io::Error> { - // check if file exists - if let Ok(exists) = std::fs::metadata(file_path) - && exists.is_file() - { - return Ok(()); - } +use crate::config::Config; - if let Some(parent) = file_path.parent() { - std::fs::create_dir_all(parent).unwrap(); +pub fn read_config_file(file_path: &Path) -> anyhow::Result { + match std::fs::read_to_string(file_path) { + Ok(a) => Ok(toml::from_str(&a)?), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + let cfg = Config::default(); + std::fs::write( + file_path, + toml::to_string(&cfg).unwrap_or_else(|x| x.to_string()), + )?; + Ok(cfg) + } + Err(e) => Err(e.into()), } - - std::fs::write( - file_path, - toml::to_string(&config).unwrap_or_else(|x| x.to_string()), - ) - .unwrap(); - - Ok(()) } pub fn open_application(path: &str) { @@ -126,90 +132,74 @@ pub fn open_application(path: &str) { }); } -#[allow(unused)] -pub fn index_dirs_from_config(apps: &mut Vec) -> bool { +pub fn index_installed_apps(config: &Config) -> anyhow::Result> { + tracing::debug!("Indexing installed apps"); + tracing::debug!("Exclude patterns: {:?}", &config.index_exclude_patterns); + tracing::debug!("Include patterns: {:?}", &config.index_include_patterns); + let path = get_config_file_path(); - let config = read_config_file(path.as_path()); - - // if config is not valid return false otherwise unwrap config so it is usable - let config = match config { - Ok(config) => config, - Err(err) => { - println!("Error reading config file: {}", err); - return false; - } - }; + let config = read_config_file(path.as_path())?; if config.index_dirs.is_empty() { - return false; + tracing::debug!("No extra index dirs provided") } - config.index_dirs.clone().iter().for_each(|dir| { - // check if dir exists - if !Path::new(dir).exists() { - println!("Directory {} does not exist", dir); - return; - } - - let paths = fs::read_dir(dir).unwrap(); - - for path in paths { - let path = path.unwrap().path(); - let metadata = fs::metadata(&path).unwrap(); - - #[cfg(target_os = "windows")] - let is_executable = - metadata.is_file() && path.extension().and_then(|s| s.to_str()) == Some("exe"); - - #[cfg(target_os = "macos")] - let is_executable = { - (metadata.is_file() && (metadata.permissions().mode() & 0o111 != 0)) - || path.extension().and_then(|s| s.to_str()) == Some("app") - }; - - if is_executable { - let display_name = path.file_name().unwrap().to_string_lossy().to_string(); - apps.push(App { - open_command: AppCommand::Function(Function::OpenApp( - path.to_string_lossy().to_string(), - )), - name: display_name.clone(), - desc: "Application".to_string(), - name_lc: display_name.clone().to_lowercase(), - icons: None, - }); - } - } - }); - - true -} - -/// Use this to get installed apps -pub fn get_installed_apps(config: &Config) -> Vec { - tracing::debug!("Indexing installed apps"); - - #[cfg(target_os = "macos")] + #[cfg(target_os = "windows")] { - get_installed_macos_apps(config) + use crate::cross_platform::windows::app_finding::get_apps_from_registry; + use crate::cross_platform::windows::app_finding::index_start_menu; + + let start = Instant::now(); + + let mut other_apps = index_start_menu(); + get_apps_from_registry(&mut other_apps); + + let res = config + .index_dirs + .par_iter() + .flat_map(|x| { + search_dir( + &x.path, + &config.index_exclude_patterns, + &config.index_include_patterns, + x.max_depth, + ) + }) + .chain(other_apps.into_par_iter()) + .collect(); + + let end = Instant::now(); + tracing::info!( + "Finished indexing apps (t = {}s)", + (end - start).as_secs_f32() + ); + + Ok(res) } - #[cfg(target_os = "windows")] + #[cfg(not(target_os = "windows"))] { - get_installed_windows_apps() + let start = Instant::now(); + + let res = config + .index_dirs + .par_iter() + .flat_map(|x| { + search_dir( + &x.path, + &config.index_exclude_patterns, + &config.index_include_patterns, + x.max_depth, + ) + }) + .collect(); + + let end = Instant::now(); + tracing::info!( + "Finished indexing apps (t = {}s)", + (end - start).as_secs_f32() + ); + + Ok(res) } } - -/// Check if the provided string is a valid url -pub fn is_valid_url(s: &str) -> bool { - s.ends_with(".com") - || s.ends_with(".net") - || s.ends_with(".org") - || s.ends_with(".edu") - || s.ends_with(".gov") - || s.ends_with(".io") - || s.ends_with(".co") - || s.ends_with(".me") - || s.ends_with(".app") - || s.ends_with(".dev") -}