From ff8c6dcdf239e0b5b69eb138bb5650bb855192d9 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Thu, 26 Mar 2026 15:53:35 +0800 Subject: [PATCH 01/51] fix links --- CONTRIBUTING.md | 2 +- Cargo.toml | 2 +- README.md | 36 ++++++++++++++++++------------------ docs/index.html | 26 +++++++++++++------------- src/app/menubar.rs | 8 ++++---- src/app/pages/settings.rs | 2 +- src/app/tile.rs | 2 +- 7 files changed, 39 insertions(+), 39 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d10dddcd..c321d5e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ There are 2 areas you can work on: 1. Help people in solving their github issues For bug fixes, and helping people to solve their github issues: see -[https://github.com/unsecretised/rustcast/issues] For features, see +[https://github.com/RustCastLabs/rustcast/issues] For features, see [The Planned Features in the README](README.md) or [The existing feature list](FEATURES.md) diff --git a/Cargo.toml b/Cargo.toml index 65fbdbdb..89afae40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" license = "MIT" description = "RustCast - Productivity app for macOS" homepage = "https://rustcast.app" -repository = "https://github.com/unsecretised/rustcast" +repository = "https://github.com/RustCastLabs/rustcast" [dependencies] anyhow = "1.0.100" diff --git a/README.md b/README.md index b9757316..5c5ba3d9 100644 --- a/README.md +++ b/README.md @@ -10,35 +10,35 @@

- - Latest release + + Latest release - - Downloads + + Downloads Discord - - Stars + + Stars - - License + + License

-> [Those who sponsor me also get a personal easter egg inside RustCast](https://github.com/sponsors/unsecretised) +> [Those who sponsor me also get a personal easter egg inside RustCast](https://github.com/sponsors/RustCastLabs) -**Config docs:** https://github.com/unsecretised/rustcast/wiki +**Config docs:** https://github.com/RustCastLabs/rustcast/wiki **Community:** https://discord.gg/bDfNYPbnC5 **Plugins**: -[RustCast Library for shell scripts](https://github.com/unsecretised/rustcast-library) +[RustCast Library for shell scripts](https://github.com/RustCastLabs/rustcast-library) > For support use github discussions / issues / the discord > -> You can also contact unsecretised / secretised at +> You can also contact RustCastLabs / secretised at > [admin@rustcast.app](mailto:admin+gh@rustcast.app) ![RustCast Demo](./docs/rustcast-latest-demo.png) @@ -48,18 +48,18 @@ ### Via Homebrew: ``` -brew install --cask unsecretised/tap/rustcast +brew install --cask RustCastLabs/tap/rustcast ``` ### Via github releases 1. Download the dmg from this link - [https://github.com/unsecretised/rustcast/releases/latest/download/rustcast.dmg](https://github.com/unsecretised/rustcast/releases/latest/download/rustcast.dmg) + [https://github.com/RustCastLabs/rustcast/releases/latest/download/rustcast.dmg](https://github.com/RustCastLabs/rustcast/releases/latest/download/rustcast.dmg) ## Config: Full config docs can be found -[here](https://github.com/unsecretised/rustcast/wiki) +[here](https://github.com/RustCastLabs/rustcast/wiki) The config file should be located at: `~/.config/rustcast/config.toml` RustCast creates the default configuration for you, but it does use its @@ -135,8 +135,8 @@ And ofc, all the people who starred my repo!! And the updated list of contributors to the macos version: - - + + ### Easter egg list: @@ -148,7 +148,7 @@ And the updated list of contributors to the macos version: ## If you like rustcast, consider starring it on github :) -[![Star History Chart](https://api.star-history.com/svg?repos=unsecretised/rustcast&type=date&legend=top-left)](https://www.star-history.com/#unsecretised/rustcast&type=date&legend=top-left) +[![Star History Chart](https://api.star-history.com/svg?repos=RustCastLabs/rustcast&type=date&legend=top-left)](https://www.star-history.com/#RustCastLabs/rustcast&type=date&legend=top-left) ## Motivations: diff --git a/docs/index.html b/docs/index.html index 00ce1c22..d81c8f71 100644 --- a/docs/index.html +++ b/docs/index.html @@ -22,19 +22,19 @@
  • Features
  • Install
  • - GitHub
  • ♥ Get your easter egg
  • Download
  • @@ -61,13 +61,13 @@

    The fastest launcher
    you'll ever use

    ↓ Download for macOS @@ -277,7 +277,7 @@

    Your launcher,
    your rules.


    Up and running
    in 30 seconds
    # Tap the cask and install
    - brew tap unsecretised/tap
    + brew tap RustCastLabs/tap
    brew install --cask rustcast
    @@ -370,7 +370,7 @@

    Up and running
    in 30 seconds

    Use the .dmg from Github Releases
    @@ -381,7 +381,7 @@

    Up and running
    in 30 seconds

    git clone - https://github.com/unsecretised/rustcast.git
    + https://github.com/RustCastLabs/rustcast.git
    cd rustcast
    cargo install --path .
    @@ -481,7 +481,7 @@

    Built by the community

    # Tap the cask and install
    - brew tap RustCastLabs/tap
    + brew tap unsecretised/tap
    brew install --cask rustcast
    From 08086a0bb987bf5b18fe2ee0ae807852b757955f Mon Sep 17 00:00:00 2001 From: unsecretised Date: Sat, 28 Mar 2026 10:21:50 +0800 Subject: [PATCH 26/51] add ability to opt out of clipboard history --- src/app.rs | 1 + src/app/pages/settings.rs | 18 ++++++++++++++++++ src/app/tile/update.rs | 24 ++++++++++++++++++++---- src/config.rs | 2 ++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/app.rs b/src/app.rs index dc900711..160707b1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -124,6 +124,7 @@ pub enum SetConfigFields { ClipboardHotkey(String), PlaceHolder(String), SearchUrl(String), + ClipboardHistory(bool), HapticFeedback(bool), ShowMenubarIcon(bool), SetPage(MainPage), diff --git a/src/app/pages/settings.rs b/src/app/pages/settings.rs index 71742ba4..5a040713 100644 --- a/src/app/pages/settings.rs +++ b/src/app/pages/settings.rs @@ -83,6 +83,23 @@ pub fn settings_page(config: Config) -> Element<'static, Message> { notice_item(theme.clone(), "Which search engine to use (%s = query)"), ]); + let theme_clone = theme.clone(); + let clipboard_history = Row::from_iter([ + settings_hint_text(theme.clone(), "Enable Clipboard history"), + checkbox(config.clone().cbhist) + .style(move |_, _| settings_checkbox_style(&theme_clone)) + .on_toggle(|input| Message::SetConfig(SetConfigFields::ClipboardHistory(input))) + .into(), + notice_item( + theme.clone(), + "If you want your clipboard history to be stored", + ), + ]) + .align_y(Alignment::Center) + .spacing(SETTINGS_ITEM_COL_SPACING * 2) + .padding(SETTINGS_ITEM_PADDING) + .height(SETTINGS_ITEM_HEIGHT); + let theme_clone = theme.clone(); let current_delay = config.debounce_delay; let debounce = settings_item_column([ @@ -396,6 +413,7 @@ pub fn settings_page(config: Config) -> Element<'static, Message> { debounce.into(), haptic.into(), tray_icon.into(), + clipboard_history.into(), auto_suggest.into(), show_scrollbar.into(), clear_on_hide.into(), diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 8cac396b..c9d8b895 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -345,9 +345,20 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } Message::SwitchToPage(page) => { - tile.page = page; - let task = match tile.page { - Page::ClipboardHistory | Page::Settings => window::latest().map(|x| { + let task = match &page { + Page::ClipboardHistory => { + if !tile.config.cbhist { + return Task::none(); + } + window::latest().map(|x| { + let id = x.unwrap(); + Message::ResizeWindow( + id, + ((7 * 55) + 35 + DEFAULT_WINDOW_HEIGHT as usize) as f32, + ) + }) + } + Page::Settings => window::latest().map(|x| { let id = x.unwrap(); Message::ResizeWindow( id, @@ -357,6 +368,8 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { _ => Task::none(), }; + tile.page = page; + let refresh_empty_main_query = if tile.page == Page::Main { window::latest() .map(|x| x.unwrap()) @@ -477,6 +490,9 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } Message::EditClipboardHistory(action) => { + if !tile.config.cbhist { + return Task::none(); + } match action { Editable::Create(content) => { if !tile.clipboard_content.contains(&content) { @@ -606,6 +622,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { match config { SetConfigFields::ToggleHotkey(hk) => final_config.toggle_hotkey = hk, SetConfigFields::ClipboardHotkey(hk) => final_config.clipboard_hotkey = hk, + SetConfigFields::ClipboardHistory(cbhist) => final_config.cbhist = cbhist, SetConfigFields::Modes(Editable::Create((key, value))) => { final_config.modes.insert(key, value); } @@ -935,7 +952,6 @@ fn execute_query(tile: &mut Tile, id: Id) -> Task { } "cbhist" => { task = task.chain(Task::done(Message::SwitchToPage(Page::ClipboardHistory))); - tile.page = Page::ClipboardHistory; } "main" => { if tile.page != Page::Main { diff --git a/src/config.rs b/src/config.rs index 667fa76e..cddb8395 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,6 +25,7 @@ pub struct Config { pub placeholder: String, pub search_url: String, pub haptic_feedback: bool, + pub cbhist: bool, pub show_trayicon: bool, pub shells: Vec, pub modes: HashMap, @@ -44,6 +45,7 @@ impl Default for Config { theme: Theme::default(), placeholder: String::from("Time to be productive!"), search_url: "https://duckduckgo.com/search?q=%s".to_string(), + cbhist: true, haptic_feedback: false, show_trayicon: true, main_page: MainPage::default(), From 358149e7e7050aa4b18d2bd821c2b9bfefb27f0b Mon Sep 17 00:00:00 2001 From: unsecretised Date: Sat, 28 Mar 2026 14:44:56 +0800 Subject: [PATCH 27/51] update styling --- src/styles.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/styles.rs b/src/styles.rs index 869a77d2..dd0d386b 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -27,14 +27,14 @@ pub fn rustcast_text_input_style(theme: &ConfigTheme) -> text_input::Style { text_input::Style { background: Background::Color(surface), border: Border { - color: glass_border(theme.text_color(1.0), focused), + color: glass_border(theme.text_color(0.), focused), width: 0., - radius: Radius::new(15.).bottom(0.), + radius: Radius::new(10.).bottom(0.), }, - icon: theme.text_color(0.75), - placeholder: theme.text_color(0.50), - value: theme.text_color(1.0), - selection: with_alpha(theme.text_color(1.0), 0.20), + icon: theme.text_color(0.), + placeholder: theme.text_color(0.2), + value: theme.text_color(0.9), + selection: theme.text_color(0.2), } } @@ -44,7 +44,7 @@ pub fn contents_style(theme: &ConfigTheme) -> container::Style { background: None, text_color: None, border: iced::Border { - color: theme.text_color(0.7), + color: theme.text_color(0.9), width: 0.4, radius: Radius::new(14.0), }, @@ -260,8 +260,8 @@ pub fn settings_slider_style(theme: &ConfigTheme) -> slider::Style { /// Helper fn for making a color look like its glassy pub fn glass_surface(base: Color, focused: bool) -> Color { - let t = if focused { 0.3 } else { 0.06 }; - let a = if focused { 0.3 } else { 0.22 }; + let t = if focused { 0.2 } else { 0.06 }; + let a = if focused { 0.9 } else { 0.58 }; with_alpha(tint(base, t), a) } From ff1cf0cd3c59d0de2e7ad799d95f040bb52772dc Mon Sep 17 00:00:00 2001 From: unsecretised Date: Sun, 29 Mar 2026 17:36:21 +0800 Subject: [PATCH 28/51] Switch to NSEvent handling for hotkeys --- src/app.rs | 3 +- src/app/menubar.rs | 34 ++--- src/app/pages/settings.rs | 4 +- src/app/tile.rs | 32 +--- src/app/tile/elm.rs | 23 +-- src/app/tile/update.rs | 49 ++++++- src/main.rs | 39 ++--- src/platform/macos/launching.rs | 250 ++++++++++++++++++++++++++++++++ src/platform/macos/mod.rs | 1 + 9 files changed, 339 insertions(+), 96 deletions(-) create mode 100644 src/platform/macos/launching.rs diff --git a/src/app.rs b/src/app.rs index 2be7378c..68b19e41 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use crate::app::apps::{App, AppCommand, ICNS_ICON}; use crate::commands::Function; use crate::config::{Config, MainPage, Shelly}; use crate::debounce::DebouncePolicy; +use crate::platform::macos::launching::Shortcut; use crate::utils::icns_data_to_handle; use crate::{app::tile::ExtSender, clipboard::ClipBoardContentType}; use iced::time::Duration; @@ -90,7 +91,7 @@ pub enum Message { OpenResult(u32), OpenToSettings, SearchQueryChanged(String, Id), - KeyPressed(u32), + KeyPressed(Shortcut), FocusTextInput(Move), HideWindow(Id), RunFunction(Function), diff --git a/src/app/menubar.rs b/src/app/menubar.rs index b9a566f6..817a6e1d 100644 --- a/src/app/menubar.rs +++ b/src/app/menubar.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, io::Cursor}; -use global_hotkey::hotkey::{Code, HotKey, Modifiers}; +use global_hotkey::hotkey::{Code, Modifiers}; use image::{DynamicImage, ImageReader}; use log::info; use tray_icon::{ @@ -16,6 +16,7 @@ use tray_icon::{ use crate::{ app::{Message, tile::ExtSender}, config::Config, + platform::macos::launching::Shortcut, utils::open_url, }; @@ -39,14 +40,15 @@ pub fn menu_icon(config: Config, sender: ExtSender) -> TrayIcon { } pub fn menu_builder(config: Config, sender: ExtSender, update_item: bool) -> Menu { - let hotkey = config.toggle_hotkey.parse::().ok(); + let shortcut = + Shortcut::parse(&config.toggle_hotkey).unwrap_or(Shortcut::parse("opt+space").unwrap()); let mut modes = config.modes; if !modes.contains_key("default") { modes.insert("Default".to_string(), "default".to_string()); } - init_event_handler(sender, hotkey.map(|x| x.id)); + init_event_handler(sender, shortcut); Menu::with_items(&[ &MenuItem::with_id( @@ -64,7 +66,7 @@ pub fn menu_builder(config: Config, sender: ExtSender, update_item: bool) -> Men &open_github_item(), &PredefinedMenuItem::separator(), &refresh_item(), - &open_item(hotkey), + &open_item(), &mode_item(modes), &PredefinedMenuItem::separator(), &open_issue_item(), @@ -86,10 +88,12 @@ fn get_image() -> DynamicImage { .unwrap() } -fn init_event_handler(sender: ExtSender, hotkey_id: Option) { +fn init_event_handler(sender: ExtSender, shortcut: Shortcut) { let runtime = Runtime::new().unwrap(); + let shortcut = shortcut.clone(); MenuEvent::set_event_handler(Some(move |x: MenuEvent| { + let shortcut = shortcut.clone(); let sender = sender.clone(); let sender = sender.0.clone(); info!("Menubar event called: {}", x.id.0); @@ -107,11 +111,12 @@ fn init_event_handler(sender: ExtSender, hotkey_id: Option) { open_url("https://github.com/RustCastLabs/rustcast/issues/new"); } "show_rustcast" => { - if let Some(hk) = hotkey_id { - runtime.spawn(async move { - sender.clone().try_send(Message::KeyPressed(hk)).unwrap(); - }); - } + runtime.spawn(async move { + sender + .clone() + .try_send(Message::KeyPressed(shortcut.clone())) + .unwrap(); + }); } "update" => { open_url("https://github.com/RustCastLabs/rustcast/releases/latest"); @@ -178,13 +183,8 @@ fn mode_item(modes: HashMap) -> Submenu { Submenu::with_items("Modes", true, &items).unwrap() } -fn open_item(hotkey: Option) -> MenuItem { - MenuItem::with_id( - "show_rustcast", - "Toggle View", - true, - hotkey.map(|hk| Accelerator::new(Some(hk.mods), hk.key)), - ) +fn open_item() -> MenuItem { + MenuItem::with_id("show_rustcast", "Toggle View", true, None) } fn open_github_item() -> MenuItem { diff --git a/src/app/pages/settings.rs b/src/app/pages/settings.rs index 6a20ab0d..2c59db10 100644 --- a/src/app/pages/settings.rs +++ b/src/app/pages/settings.rs @@ -44,7 +44,7 @@ pub fn settings_page(config: Config) -> Element<'static, Message> { .width(Length::Fill) .style(move |_, _| settings_text_input_item_style(&hotkey_theme)) .into(), - notice_item(theme.clone(), "Requires a restart"), + notice_item(theme.clone(), "Use \"+\" as a seperator"), ]); let cb_theme = theme.clone(); @@ -56,7 +56,7 @@ pub fn settings_page(config: Config) -> Element<'static, Message> { .width(Length::Fill) .style(move |_, _| settings_text_input_item_style(&cb_theme)) .into(), - notice_item(theme.clone(), "Requires a restart"), + notice_item(theme.clone(), "Use \"+\" as a seperator"), ]); let placeholder_theme = theme.clone(); diff --git a/src/app/tile.rs b/src/app/tile.rs index f842a1c0..a3c7b960 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -5,13 +5,12 @@ pub mod update; use crate::app::apps::App; use crate::app::{ArrowKey, Message, Move, Page}; use crate::clipboard::ClipBoardContentType; -use crate::config::Config; +use crate::config::{Config, Shelly}; use crate::debounce::Debouncer; use crate::platform::default_app_paths; +use crate::platform::macos::launching::Shortcut; use arboard::Clipboard; -use global_hotkey::hotkey::HotKey; -use global_hotkey::{GlobalHotKeyEvent, HotKeyState}; use iced::futures::SinkExt; use iced::futures::channel::mpsc::{Sender, channel}; @@ -190,9 +189,9 @@ pub struct Tile { /// Stores the toggle [`HotKey`] and the Clipboard [`HotKey`] #[derive(Clone, Debug)] pub struct Hotkeys { - pub toggle: HotKey, - pub clipboard_hotkey: HotKey, - pub shells: HashMap, + pub toggle: Shortcut, + pub clipboard_hotkey: Shortcut, + pub shells: HashMap, } impl Tile { @@ -229,7 +228,6 @@ impl Tile { _ => None, }); Subscription::batch([ - Subscription::run(handle_hotkeys), Subscription::run(handle_hot_reloading), keyboard, Subscription::run(handle_recipient), @@ -269,9 +267,8 @@ impl Tile { keyboard::Key::Named(Named::Backspace) => { return Some(Message::FocusTextInput(Move::Back)); } - _ => {} + _ => None, } - None } else { None } @@ -337,22 +334,6 @@ impl Tile { } } -/// This is the subscription function that handles hotkeys, e.g. for hiding / showing the window -fn handle_hotkeys() -> impl futures::Stream { - stream::channel(100, async |mut output| { - let receiver = GlobalHotKeyEvent::receiver(); - loop { - info!("Hotkey received"); - if let Ok(event) = receiver.recv() - && event.state == HotKeyState::Pressed - { - output.try_send(Message::KeyPressed(event.id)).unwrap(); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) -} - /// This is the subscription function that handles the change in clipboard history fn handle_clipboard_history() -> impl futures::Stream { stream::channel(100, async |mut output| { @@ -602,7 +583,6 @@ fn handle_recipient() -> impl futures::Stream { let abcd = recipient .try_recv() .map(async |msg| { - info!("Sending a message"); output.send(msg).await.unwrap(); }) .ok(); diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 03eca1d4..87fadf9c 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -4,7 +4,6 @@ use std::collections::HashMap; use std::fs; -use global_hotkey::hotkey::HotKey; use iced::border::Radius; use iced::widget::scrollable::{Anchor, Direction, Scrollbar}; use iced::widget::text::LineHeight; @@ -35,7 +34,7 @@ use crate::{ }; /// Initialise the base window -pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { +pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { let (id, open) = window::open(default_settings()); info!("Opening window"); @@ -60,24 +59,6 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { options.par_sort_by_key(|x| x.display_name.len()); let options = AppIndex::from_apps(options); - let mut shells_map = HashMap::new(); - for shell in &config.shells { - if let Some(hk_str) = &shell.hotkey - && let Ok(hk) = hk_str.parse::() - { - shells_map.insert(hk.id, shell.command.clone()); - } - } - - let hotkeys = Hotkeys { - toggle: hotkey, - clipboard_hotkey: config - .clipboard_hotkey - .parse() - .unwrap_or("SUPER+SHIFT+C".parse().unwrap()), - shells: shells_map, - }; - let home = std::env::var("HOME").unwrap_or("/".to_string()); let ranking = toml::from_str( @@ -94,8 +75,8 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { focus_id: 0, results: vec![], options, - emoji_apps: AppIndex::from_apps(App::emoji_apps()), hotkeys, + emoji_apps: AppIndex::from_apps(App::emoji_apps()), visible: true, frontmost: None, focused: false, diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 4bca0409..356e0ea9 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -1,5 +1,6 @@ //! This handles the update logic for the tile (AKA rustcast's main window) use std::cmp::min; +use std::collections::HashMap; use std::fs; use std::io::Cursor; use std::thread; @@ -34,6 +35,8 @@ use crate::commands::Function; use crate::config::Config; use crate::config::MainPage; use crate::debounce::DebouncePolicy; +use crate::platform::macos::launching::Shortcut; +use crate::platform::macos::launching::global_handler; use crate::platform::macos::{start_at_login, stop_at_login}; use crate::quit::get_open_apps; use crate::unit_conversion; @@ -91,6 +94,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Message::SetSender(sender) => { tile.sender = Some(sender.clone()); + global_handler(sender.clone()); if tile.config.show_trayicon { tile.tray_icon = Some(menu_icon(tile.config.clone(), sender)); } @@ -274,6 +278,24 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Err(_) => return Task::none(), }; + if let Some(hotkey) = Shortcut::parse(&new_config.clipboard_hotkey).ok() { + tile.hotkeys.clipboard_hotkey = hotkey + } + + if let Some(hotkey) = Shortcut::parse(&new_config.toggle_hotkey).ok() { + tile.hotkeys.toggle = hotkey + } + + let mut shell_map = HashMap::new(); + + for shell in &new_config.shells { + if let Some(hotkey) = shell.hotkey.clone().and_then(|x| Shortcut::parse(&x).ok()) { + shell_map.insert(hotkey, shell.clone()); + } + } + + tile.hotkeys.shells = shell_map; + let update_apps_task = if tile.config.shells != new_config.shells { info!("App Update required"); Task::done(Message::UpdateApps) @@ -303,17 +325,20 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Task::batch([Task::done(Message::LoadRanking), update_apps_task]) } - Message::KeyPressed(hk_id) => { - if let Some(cmd) = tile.hotkeys.shells.get(&hk_id) { - return Task::done(Message::RunFunction(Function::RunShellCommand(cmd.clone()))); + Message::KeyPressed(shortcut) => { + if let Some(cmd) = tile.hotkeys.shells.get(&shortcut) { + return Task::done(Message::RunFunction(Function::RunShellCommand( + cmd.command.clone(), + ))); } - - let is_clipboard_hotkey = tile.hotkeys.clipboard_hotkey.id == hk_id; - let is_open_hotkey = hk_id == tile.hotkeys.toggle.id; + let is_clipboard_hotkey = shortcut == tile.hotkeys.clipboard_hotkey; + let is_open_hotkey = shortcut == tile.hotkeys.toggle; let clipboard_page_task = if is_clipboard_hotkey { + info!("Switching to clipboard page"); Task::done(Message::SwitchToPage(Page::ClipboardHistory)) } else if is_open_hotkey { + info!("Switching to main page"); Task::done(Message::SwitchToPage(Page::Main)) } else { Task::none() @@ -485,6 +510,18 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { new_options.par_sort_by_key(|x| x.display_name.len()); tile.options = AppIndex::from_apps(new_options); + let mut shell_map = HashMap::new(); + + for shell in &tile.config.shells { + if let Some(has_hk) = &shell.hotkey + && let Some(hotkey) = Shortcut::parse(has_hk).ok() + { + shell_map.insert(hotkey, shell.clone()); + } + } + + tile.hotkeys.shells = shell_map; + Task::none() } diff --git a/src/main.rs b/src/main.rs index 6b06a7c4..82c88636 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,18 +12,14 @@ mod styles; mod unit_conversion; mod utils; -use std::{fs::OpenOptions, path::Path}; +use std::{collections::HashMap, fs::OpenOptions, path::Path}; use crate::{ - app::tile::{self, Tile}, + app::tile::{self, Hotkeys, Tile}, config::Config, - platform::macos::get_autostart_status, + platform::macos::{get_autostart_status, launching::Shortcut}, }; -use global_hotkey::{ - GlobalHotKeyManager, - hotkey::{Code, HotKey, Modifiers}, -}; use log::info; use tracing_subscriber::{EnvFilter, Layer, util::SubscriberInitExt}; @@ -67,36 +63,33 @@ fn main() -> iced::Result { info!("Config loaded"); - let manager = GlobalHotKeyManager::new().unwrap(); + let show_hide = + Shortcut::parse(&config.toggle_hotkey).unwrap_or(Shortcut::parse("option+space").unwrap()); - let show_hide = config - .toggle_hotkey - .parse() - .unwrap_or(HotKey::new(Some(Modifiers::ALT), Code::Space)); + let cbhist = Shortcut::parse(&config.clipboard_hotkey.to_lowercase()) + .unwrap_or_else(|_| Shortcut::parse("cmd+shift+c").unwrap()); - let cbhist = config - .clipboard_hotkey - .parse() - .unwrap_or("SUPER+SHIFT+C".parse().unwrap()); + let mut shell_map = HashMap::new(); - let mut hotkeys = vec![show_hide, cbhist]; for shell in &config.shells { if let Some(hk_str) = &shell.hotkey - && let Ok(hk) = hk_str.parse::() + && let Ok(hk) = Shortcut::parse(hk_str) { - hotkeys.push(hk); + shell_map.insert(hk, shell.clone()); } } - manager - .register_all(&hotkeys) - .expect("Unable to register hotkeys"); + let hotkeys = Hotkeys { + toggle: show_hide, + clipboard_hotkey: cbhist, + shells: shell_map, + }; info!("Hotkeys loaded"); info!("Starting rustcast"); iced::daemon( - move || tile::elm::new(show_hide, &config), + move || tile::elm::new(hotkeys.clone(), &config), tile::update::handle_update, tile::elm::view, ) diff --git a/src/platform/macos/launching.rs b/src/platform/macos/launching.rs new file mode 100644 index 00000000..fd8c55b8 --- /dev/null +++ b/src/platform/macos/launching.rs @@ -0,0 +1,250 @@ +use std::sync::{Arc, Mutex}; + +use block2::RcBlock; +use objc2_app_kit::{NSEvent, NSEventMask, NSEventModifierFlags, NSEventType}; + +use crate::app::{Message, tile::ExtSender}; + +pub fn global_handler(sender: ExtSender) { + local_handler(sender.clone()); + let mask = NSEventMask::KeyDown | NSEventMask::FlagsChanged; + let sender = Arc::new(Mutex::new(sender.0.clone())); + + let block = RcBlock::new({ + move |event: std::ptr::NonNull| { + let event = unsafe { event.as_ref() }; + let event_type = event.r#type(); + + let key_code = event.keyCode(); + let mods = event.modifierFlags() + & (NSEventModifierFlags::Command + | NSEventModifierFlags::Option + | NSEventModifierFlags::Control + | NSEventModifierFlags::Function + | NSEventModifierFlags::CapsLock + | NSEventModifierFlags::Shift); + + let shortcut = match event_type { + NSEventType::KeyDown => Shortcut { + key_code: Some(key_code), + mods: if mods.0 != 0 { + Some(mods.0 as usize) + } else { + None + }, + }, + NSEventType::FlagsChanged => Shortcut { + key_code: None, + mods: if mods.0 != 0 { + Some(mods.0 as usize) + } else { + None + }, + }, + _ => return, + }; + + let mut s = sender.lock().unwrap(); + let _ = s.try_send(Message::KeyPressed(shortcut)); + } + }); + + NSEvent::addGlobalMonitorForEventsMatchingMask_handler(mask, &block); +} + +pub fn local_handler(sender: ExtSender) { + let mask = NSEventMask::KeyDown | NSEventMask::FlagsChanged; + let sender = Arc::new(Mutex::new(sender.0.clone())); + + let block = RcBlock::new({ + move |event: std::ptr::NonNull| -> *mut NSEvent { + let event_ref = unsafe { event.as_ref() }; + let event_type = event_ref.r#type(); + + let key_code = event_ref.keyCode(); + let mods = event_ref.modifierFlags() + & (NSEventModifierFlags::Command + | NSEventModifierFlags::Option + | NSEventModifierFlags::Control + | NSEventModifierFlags::Function + | NSEventModifierFlags::CapsLock + | NSEventModifierFlags::Shift); + + let shortcut = match event_type { + NSEventType::KeyDown => Shortcut { + key_code: Some(key_code), + mods: if mods.0 != 0 { + Some(mods.0 as usize) + } else { + None + }, + }, + NSEventType::FlagsChanged => Shortcut { + key_code: None, + mods: if mods.0 != 0 { + Some(mods.0 as usize) + } else { + None + }, + }, + _ => return event.as_ptr(), // pass through unhandled events + }; + + let mut s = sender.lock().unwrap(); + let _ = s.try_send(Message::KeyPressed(shortcut)); + + event.as_ptr() + } + }); + + unsafe { + NSEvent::addLocalMonitorForEventsMatchingMask_handler(mask, &block); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Shortcut { + pub key_code: Option, + pub mods: Option, +} + +impl Shortcut { + pub fn new(key_code: Option, mods: Option) -> Self { + Self { key_code, mods } + } + + pub fn parse(s: &str) -> Result { + let parts: Vec<&str> = s.split('+').map(|p| p.trim()).collect(); + + let mut mods: usize = 0; + let mut key_code: Option = None; + let mut has_mods = false; + + for part in &parts { + match part.to_lowercase().as_str() { + "cmd" | "command" | "super" => { + mods |= NSEventModifierFlags::Command.0; + has_mods = true; + } + "opt" | "option" | "alt" => { + mods |= NSEventModifierFlags::Option.0; + has_mods = true; + } + "capslock" | "caps" | "caps lock" => mods |= NSEventModifierFlags::CapsLock.0, + "ctrl" | "control" => { + mods |= NSEventModifierFlags::Control.0; + has_mods = true; + } + "shift" => { + mods |= NSEventModifierFlags::Shift.0; + has_mods = true; + } + "fn" | "function" => { + mods |= NSEventModifierFlags::Function.0; + has_mods = true; + } + key => { + if key_code.is_some() { + return Err(format!("Multiple keys specified: '{}'", s)); + } + key_code = Some(str_to_keycode(key)?); + } + } + } + + Ok(Shortcut::new( + key_code, + if has_mods { Some(mods) } else { None }, + )) + } +} + +fn str_to_keycode(s: &str) -> Result { + let code = match s.to_lowercase().as_str() { + // Letters + "a" => 0x00, + "s" => 0x01, + "d" => 0x02, + "f" => 0x03, + "h" => 0x04, + "g" => 0x05, + "z" => 0x06, + "x" => 0x07, + "c" => 0x08, + "v" => 0x09, + "b" => 0x0b, + "q" => 0x0c, + "w" => 0x0d, + "e" => 0x0e, + "r" => 0x0f, + "y" => 0x10, + "t" => 0x11, + "o" => 0x1f, + "u" => 0x20, + "i" => 0x22, + "p" => 0x23, + "l" => 0x25, + "j" => 0x26, + "k" => 0x28, + "n" => 0x2d, + "m" => 0x2e, + + // Numbers + "1" => 0x12, + "2" => 0x13, + "3" => 0x14, + "4" => 0x15, + "5" => 0x17, + "6" => 0x16, + "7" => 0x1a, + "8" => 0x1c, + "9" => 0x19, + "0" => 0x1d, + + // Special keys + "return" | "enter" => 0x24, + "tab" => 0x30, + "space" => 0x31, + "delete" | "backspace" => 0x33, + "escape" | "esc" => 0x35, + "left" | "arrowleft" => 0x7b, + "right" | "arrowright" => 0x7c, + "down" | "arrowdown" => 0x7d, + "up" | "arrowup" => 0x7e, + "home" => 0x73, + "end" => 0x77, + "pageup" => 0x74, + "pagedown" => 0x79, + + // Function keys + "f1" => 0x7a, + "f2" => 0x78, + "f3" => 0x63, + "f4" => 0x76, + "f5" => 0x60, + "f6" => 0x61, + "f7" => 0x62, + "f8" => 0x64, + "f9" => 0x65, + "f10" => 0x6d, + "f11" => 0x67, + "f12" => 0x6f, + + // Symbols + "-" | "minus" => 0x1b, + "=" | "equal" => 0x18, + "[" | "bracketleft" => 0x21, + "]" | "bracketright" => 0x1e, + "\\" | "backslash" => 0x2a, + ";" | "semicolon" => 0x29, + "'" | "quote" => 0x27, + "`" | "backquote" | "grave" => 0x32, + "," | "comma" => 0x2b, + "." | "period" => 0x2f, + "/" | "slash" => 0x2c, + + _ => return Err(format!("Unknown key: '{}'", s)), + }; + + Ok(code) +} diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index ab7c05c0..eeab1289 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -1,6 +1,7 @@ //! Macos specific logic, such as window settings, etc. pub mod discovery; pub mod haptics; +pub mod launching; use iced::wgpu::rwh::WindowHandle; From 66cb6c70825a8e0b74701b95550dc7668a569e9f Mon Sep 17 00:00:00 2001 From: unsecretised Date: Sun, 29 Mar 2026 17:36:55 +0800 Subject: [PATCH 29/51] clippy & format -_- --- src/app/tile.rs | 22 ++++++++++------------ src/app/tile/update.rs | 4 ++-- src/platform/macos/launching.rs | 24 ++++-------------------- 3 files changed, 16 insertions(+), 34 deletions(-) diff --git a/src/app/tile.rs b/src/app/tile.rs index a3c7b960..02e029a9 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -239,33 +239,31 @@ impl Tile { if let keyboard::Event::KeyPressed { key, modifiers, .. } = event { match key { keyboard::Key::Named(Named::ArrowUp) => { - return Some(Message::ChangeFocus(ArrowKey::Up, 1)); + Some(Message::ChangeFocus(ArrowKey::Up, 1)) } keyboard::Key::Named(Named::ArrowLeft) => { - return Some(Message::ChangeFocus(ArrowKey::Left, 1)); + Some(Message::ChangeFocus(ArrowKey::Left, 1)) } keyboard::Key::Named(Named::ArrowRight) => { - return Some(Message::ChangeFocus(ArrowKey::Right, 1)); + Some(Message::ChangeFocus(ArrowKey::Right, 1)) } keyboard::Key::Named(Named::ArrowDown) => { - return Some(Message::ChangeFocus(ArrowKey::Down, 1)); + Some(Message::ChangeFocus(ArrowKey::Down, 1)) } keyboard::Key::Character(chr) => { if modifiers.command() && chr.to_string() == "r" { - return Some(Message::ReloadConfig); + Some(Message::ReloadConfig) } else if chr.to_string() == "p" && modifiers.control() { - return Some(Message::ChangeFocus(ArrowKey::Up, 1)); + Some(Message::ChangeFocus(ArrowKey::Up, 1)) } else if chr.to_string() == "n" && modifiers.control() { - return Some(Message::ChangeFocus(ArrowKey::Down, 1)); + Some(Message::ChangeFocus(ArrowKey::Down, 1)) } else { - return Some(Message::FocusTextInput(Move::Forwards( - chr.to_string(), - ))); + Some(Message::FocusTextInput(Move::Forwards(chr.to_string()))) } } - keyboard::Key::Named(Named::Enter) => return Some(Message::OpenFocused), + keyboard::Key::Named(Named::Enter) => Some(Message::OpenFocused), keyboard::Key::Named(Named::Backspace) => { - return Some(Message::FocusTextInput(Move::Back)); + Some(Message::FocusTextInput(Move::Back)) } _ => None, } diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 356e0ea9..9ae79519 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -278,11 +278,11 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Err(_) => return Task::none(), }; - if let Some(hotkey) = Shortcut::parse(&new_config.clipboard_hotkey).ok() { + if let Ok(hotkey) = Shortcut::parse(&new_config.clipboard_hotkey) { tile.hotkeys.clipboard_hotkey = hotkey } - if let Some(hotkey) = Shortcut::parse(&new_config.toggle_hotkey).ok() { + if let Ok(hotkey) = Shortcut::parse(&new_config.toggle_hotkey) { tile.hotkeys.toggle = hotkey } diff --git a/src/platform/macos/launching.rs b/src/platform/macos/launching.rs index fd8c55b8..5ef49b4a 100644 --- a/src/platform/macos/launching.rs +++ b/src/platform/macos/launching.rs @@ -27,19 +27,11 @@ pub fn global_handler(sender: ExtSender) { let shortcut = match event_type { NSEventType::KeyDown => Shortcut { key_code: Some(key_code), - mods: if mods.0 != 0 { - Some(mods.0 as usize) - } else { - None - }, + mods: if mods.0 != 0 { Some(mods.0) } else { None }, }, NSEventType::FlagsChanged => Shortcut { key_code: None, - mods: if mods.0 != 0 { - Some(mods.0 as usize) - } else { - None - }, + mods: if mods.0 != 0 { Some(mods.0) } else { None }, }, _ => return, }; @@ -73,19 +65,11 @@ pub fn local_handler(sender: ExtSender) { let shortcut = match event_type { NSEventType::KeyDown => Shortcut { key_code: Some(key_code), - mods: if mods.0 != 0 { - Some(mods.0 as usize) - } else { - None - }, + mods: if mods.0 != 0 { Some(mods.0) } else { None }, }, NSEventType::FlagsChanged => Shortcut { key_code: None, - mods: if mods.0 != 0 { - Some(mods.0 as usize) - } else { - None - }, + mods: if mods.0 != 0 { Some(mods.0) } else { None }, }, _ => return event.as_ptr(), // pass through unhandled events }; From ebf2520d05d48a926798a500f8ac128e0e9d930f Mon Sep 17 00:00:00 2001 From: niklasmarderx Date: Wed, 1 Apr 2026 09:41:55 +0200 Subject: [PATCH 30/51] fix: highlight favourite icon and sort favourites alphabetically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heart icon now shows ❤️ for favourites and 🤍 for non-favourites, with brighter text when the app is marked as a favourite. Previously the icon looked the same regardless of favourite status. Favourites are now sorted alphabetically by display name. Closes #254 --- src/app/apps.rs | 6 ++++-- src/app/tile.rs | 16 +++++++--------- src/styles.rs | 23 +++++++++++++++++------ 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/app/apps.rs b/src/app/apps.rs index 7b6624d5..17227773 100644 --- a/src/app/apps.rs +++ b/src/app/apps.rs @@ -213,11 +213,13 @@ impl App { let name = self.search_name.clone(); let theme_clone = theme.clone(); + let is_favourite = self.ranking == -1; + let heart = if is_favourite { "❤️" } else { "🤍" }; row = row.push( - Button::new(Text::new("♥️").width(Length::Fill).align_x(Alignment::End)) + Button::new(Text::new(heart).width(Length::Fill).align_x(Alignment::End)) .on_press_with(move || Message::ToggleFavouriteApp(name.clone())) .width(Length::Fill) - .style(move |_, status| favourite_button_style(&theme_clone, status)), + .style(move |_, status| favourite_button_style(&theme_clone, status, is_favourite)), ); let msg = on_press.or(match self.open_command.clone() { diff --git a/src/app/tile.rs b/src/app/tile.rs index 02e029a9..05c41668 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -109,16 +109,14 @@ impl AppIndex { } fn get_favourites(&self) -> Vec { - self.by_name + let mut favs: Vec = self + .by_name .values() - .filter_map(|x| { - if x.ranking == -1 { - Some(x.to_owned()) - } else { - None - } - }) - .collect() + .filter(|x| x.ranking == -1) + .cloned() + .collect(); + favs.sort_by(|a, b| a.display_name.cmp(&b.display_name)); + favs } fn empty() -> AppIndex { diff --git a/src/styles.rs b/src/styles.rs index 3972b9a3..a978adbc 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -75,12 +75,23 @@ pub fn result_button_style(theme: &ConfigTheme) -> button::Style { } } -pub fn favourite_button_style(theme: &ConfigTheme, status: button::Status) -> button::Style { - let text_color = match status { - button::Status::Pressed => theme.text_color(1.), - button::Status::Hovered => theme.text_color(0.5), - button::Status::Active => theme.text_color(0.1), - button::Status::Disabled => theme.text_color(0.1), +pub fn favourite_button_style( + theme: &ConfigTheme, + status: button::Status, + is_favourite: bool, +) -> button::Style { + let text_color = if is_favourite { + match status { + button::Status::Pressed => theme.text_color(0.8), + button::Status::Hovered => theme.text_color(0.9), + _ => theme.text_color(1.), + } + } else { + match status { + button::Status::Pressed => theme.text_color(1.), + button::Status::Hovered => theme.text_color(0.5), + _ => theme.text_color(0.1), + } }; button::Style { From 5a0a12379a4268145bf990568646c6d34b06b8dd Mon Sep 17 00:00:00 2001 From: niklasmarderx Date: Wed, 1 Apr 2026 10:54:03 +0200 Subject: [PATCH 31/51] fix: address review feedback - simplify style, drop emoji swap --- src/app/apps.rs | 3 +-- src/styles.rs | 20 +++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/app/apps.rs b/src/app/apps.rs index 17227773..90d615cc 100644 --- a/src/app/apps.rs +++ b/src/app/apps.rs @@ -214,9 +214,8 @@ impl App { let name = self.search_name.clone(); let theme_clone = theme.clone(); let is_favourite = self.ranking == -1; - let heart = if is_favourite { "❤️" } else { "🤍" }; row = row.push( - Button::new(Text::new(heart).width(Length::Fill).align_x(Alignment::End)) + Button::new(Text::new("♥️").width(Length::Fill).align_x(Alignment::End)) .on_press_with(move || Message::ToggleFavouriteApp(name.clone())) .width(Length::Fill) .style(move |_, status| favourite_button_style(&theme_clone, status, is_favourite)), diff --git a/src/styles.rs b/src/styles.rs index a978adbc..91861f88 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -80,18 +80,16 @@ pub fn favourite_button_style( status: button::Status, is_favourite: bool, ) -> button::Style { - let text_color = if is_favourite { - match status { - button::Status::Pressed => theme.text_color(0.8), - button::Status::Hovered => theme.text_color(0.9), - _ => theme.text_color(1.), - } + let (base, pressed, hovered) = if is_favourite { + (1.0, 0.8, 0.9) } else { - match status { - button::Status::Pressed => theme.text_color(1.), - button::Status::Hovered => theme.text_color(0.5), - _ => theme.text_color(0.1), - } + (0.1, 1.0, 0.5) + }; + + let text_color = match status { + button::Status::Pressed => theme.text_color(pressed), + button::Status::Hovered => theme.text_color(hovered), + _ => theme.text_color(base), }; button::Style { From 050933178e5a0e6fa3bbb0cdb27e5fcd5c2f78fd Mon Sep 17 00:00:00 2001 From: unsecretised Date: Thu, 2 Apr 2026 15:04:19 +0800 Subject: [PATCH 32/51] Remove manual input handling --- Cargo.lock | 18 --- Cargo.toml | 6 +- src/app.rs | 3 +- src/app/menubar.rs | 52 +++---- src/app/pages/settings.rs | 4 +- src/app/tile.rs | 54 +++----- src/app/tile/elm.rs | 23 +--- src/app/tile/update.rs | 48 ++++++- src/main.rs | 39 +++--- src/platform/macos/launching.rs | 234 ++++++++++++++++++++++++++++++++ src/platform/macos/mod.rs | 1 + 11 files changed, 336 insertions(+), 146 deletions(-) create mode 100644 src/platform/macos/launching.rs diff --git a/Cargo.lock b/Cargo.lock index c059a04c..98cf30de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1571,23 +1571,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "global-hotkey" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" -dependencies = [ - "crossbeam-channel", - "keyboard-types", - "objc2 0.6.3", - "objc2-app-kit 0.3.2", - "once_cell", - "thiserror 2.0.18", - "windows-sys 0.59.0", - "x11rb", - "xkeysym", -] - [[package]] name = "glow" version = "0.16.0" @@ -3856,7 +3839,6 @@ dependencies = [ "arboard", "block2 0.6.2", "emojis", - "global-hotkey", "iced", "icns", "image", diff --git a/Cargo.toml b/Cargo.toml index 82cb2404..43950c10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,6 @@ repository = "https://github.com/RustCastLabs/rustcast" arboard = "3.6.1" block2 = "0.6.2" emojis = "0.8.0" -global-hotkey = "0.7.0" iced = { version = "0.14.0", features = ["image", "tokio"] } icns = "0.3.1" image = { version = "0.25.9", features = ["tiff"] } @@ -20,10 +19,7 @@ log = "0.4.29" minreq = { version = "2.14.1", features = ["https"] } objc2 = "0.6.3" objc2-app-kit = { version = "0.3.2", features = ["NSImage"] } -objc2-application-services = { version = "0.3.2", default-features = false, features = [ - "HIServices", - "Processes", -] } +objc2-application-services = { version = "0.3.2", default-features = false, features = ["HIServices", "Processes"] } objc2-core-foundation = "0.3.2" objc2-foundation = { version = "0.3.2", features = ["NSString"] } objc2-service-management = "0.3.2" diff --git a/src/app.rs b/src/app.rs index 2be7378c..68b19e41 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use crate::app::apps::{App, AppCommand, ICNS_ICON}; use crate::commands::Function; use crate::config::{Config, MainPage, Shelly}; use crate::debounce::DebouncePolicy; +use crate::platform::macos::launching::Shortcut; use crate::utils::icns_data_to_handle; use crate::{app::tile::ExtSender, clipboard::ClipBoardContentType}; use iced::time::Duration; @@ -90,7 +91,7 @@ pub enum Message { OpenResult(u32), OpenToSettings, SearchQueryChanged(String, Id), - KeyPressed(u32), + KeyPressed(Shortcut), FocusTextInput(Move), HideWindow(Id), RunFunction(Function), diff --git a/src/app/menubar.rs b/src/app/menubar.rs index b9a566f6..7b82e887 100644 --- a/src/app/menubar.rs +++ b/src/app/menubar.rs @@ -2,20 +2,20 @@ use std::{collections::HashMap, io::Cursor}; -use global_hotkey::hotkey::{Code, HotKey, Modifiers}; use image::{DynamicImage, ImageReader}; use log::info; use tray_icon::{ Icon, TrayIcon, TrayIconBuilder, menu::{ AboutMetadataBuilder, Icon as Ico, IsMenuItem, Menu, MenuEvent, MenuItem, - PredefinedMenuItem, Submenu, accelerator::Accelerator, + PredefinedMenuItem, Submenu, }, }; use crate::{ app::{Message, tile::ExtSender}, config::Config, + platform::macos::launching::Shortcut, utils::open_url, }; @@ -39,14 +39,15 @@ pub fn menu_icon(config: Config, sender: ExtSender) -> TrayIcon { } pub fn menu_builder(config: Config, sender: ExtSender, update_item: bool) -> Menu { - let hotkey = config.toggle_hotkey.parse::().ok(); + let shortcut = + Shortcut::parse(&config.toggle_hotkey).unwrap_or(Shortcut::parse("opt+space").unwrap()); let mut modes = config.modes; if !modes.contains_key("default") { modes.insert("Default".to_string(), "default".to_string()); } - init_event_handler(sender, hotkey.map(|x| x.id)); + init_event_handler(sender, shortcut); Menu::with_items(&[ &MenuItem::with_id( @@ -64,7 +65,7 @@ pub fn menu_builder(config: Config, sender: ExtSender, update_item: bool) -> Men &open_github_item(), &PredefinedMenuItem::separator(), &refresh_item(), - &open_item(hotkey), + &open_item(), &mode_item(modes), &PredefinedMenuItem::separator(), &open_issue_item(), @@ -86,10 +87,12 @@ fn get_image() -> DynamicImage { .unwrap() } -fn init_event_handler(sender: ExtSender, hotkey_id: Option) { +fn init_event_handler(sender: ExtSender, shortcut: Shortcut) { let runtime = Runtime::new().unwrap(); + let shortcut = shortcut.clone(); MenuEvent::set_event_handler(Some(move |x: MenuEvent| { + let shortcut = shortcut.clone(); let sender = sender.clone(); let sender = sender.0.clone(); info!("Menubar event called: {}", x.id.0); @@ -107,11 +110,12 @@ fn init_event_handler(sender: ExtSender, hotkey_id: Option) { open_url("https://github.com/RustCastLabs/rustcast/issues/new"); } "show_rustcast" => { - if let Some(hk) = hotkey_id { - runtime.spawn(async move { - sender.clone().try_send(Message::KeyPressed(hk)).unwrap(); - }); - } + runtime.spawn(async move { + sender + .clone() + .try_send(Message::KeyPressed(shortcut.clone())) + .unwrap(); + }); } "update" => { open_url("https://github.com/RustCastLabs/rustcast/releases/latest"); @@ -178,13 +182,8 @@ fn mode_item(modes: HashMap) -> Submenu { Submenu::with_items("Modes", true, &items).unwrap() } -fn open_item(hotkey: Option) -> MenuItem { - MenuItem::with_id( - "show_rustcast", - "Toggle View", - true, - hotkey.map(|hk| Accelerator::new(Some(hk.mods), hk.key)), - ) +fn open_item() -> MenuItem { + MenuItem::with_id("show_rustcast", "Toggle View", true, None) } fn open_github_item() -> MenuItem { @@ -196,24 +195,11 @@ fn open_issue_item() -> MenuItem { } fn refresh_item() -> MenuItem { - MenuItem::with_id( - "refresh_rustcast", - "Refresh", - true, - Some(Accelerator::new( - Some(Modifiers::SUPER), - global_hotkey::hotkey::Code::KeyR, - )), - ) + MenuItem::with_id("refresh_rustcast", "Refresh", true, None) } fn open_settings_item() -> MenuItem { - MenuItem::with_id( - "open_preferences", - "Open Preferences", - true, - Some(Accelerator::new(Some(Modifiers::SUPER), Code::Comma)), - ) + MenuItem::with_id("open_preferences", "Open Preferences", true, None) } fn get_help_item() -> MenuItem { diff --git a/src/app/pages/settings.rs b/src/app/pages/settings.rs index 6a20ab0d..2c59db10 100644 --- a/src/app/pages/settings.rs +++ b/src/app/pages/settings.rs @@ -44,7 +44,7 @@ pub fn settings_page(config: Config) -> Element<'static, Message> { .width(Length::Fill) .style(move |_, _| settings_text_input_item_style(&hotkey_theme)) .into(), - notice_item(theme.clone(), "Requires a restart"), + notice_item(theme.clone(), "Use \"+\" as a seperator"), ]); let cb_theme = theme.clone(); @@ -56,7 +56,7 @@ pub fn settings_page(config: Config) -> Element<'static, Message> { .width(Length::Fill) .style(move |_, _| settings_text_input_item_style(&cb_theme)) .into(), - notice_item(theme.clone(), "Requires a restart"), + notice_item(theme.clone(), "Use \"+\" as a seperator"), ]); let placeholder_theme = theme.clone(); diff --git a/src/app/tile.rs b/src/app/tile.rs index f842a1c0..02e029a9 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -5,13 +5,12 @@ pub mod update; use crate::app::apps::App; use crate::app::{ArrowKey, Message, Move, Page}; use crate::clipboard::ClipBoardContentType; -use crate::config::Config; +use crate::config::{Config, Shelly}; use crate::debounce::Debouncer; use crate::platform::default_app_paths; +use crate::platform::macos::launching::Shortcut; use arboard::Clipboard; -use global_hotkey::hotkey::HotKey; -use global_hotkey::{GlobalHotKeyEvent, HotKeyState}; use iced::futures::SinkExt; use iced::futures::channel::mpsc::{Sender, channel}; @@ -190,9 +189,9 @@ pub struct Tile { /// Stores the toggle [`HotKey`] and the Clipboard [`HotKey`] #[derive(Clone, Debug)] pub struct Hotkeys { - pub toggle: HotKey, - pub clipboard_hotkey: HotKey, - pub shells: HashMap, + pub toggle: Shortcut, + pub clipboard_hotkey: Shortcut, + pub shells: HashMap, } impl Tile { @@ -229,7 +228,6 @@ impl Tile { _ => None, }); Subscription::batch([ - Subscription::run(handle_hotkeys), Subscription::run(handle_hot_reloading), keyboard, Subscription::run(handle_recipient), @@ -241,37 +239,34 @@ impl Tile { if let keyboard::Event::KeyPressed { key, modifiers, .. } = event { match key { keyboard::Key::Named(Named::ArrowUp) => { - return Some(Message::ChangeFocus(ArrowKey::Up, 1)); + Some(Message::ChangeFocus(ArrowKey::Up, 1)) } keyboard::Key::Named(Named::ArrowLeft) => { - return Some(Message::ChangeFocus(ArrowKey::Left, 1)); + Some(Message::ChangeFocus(ArrowKey::Left, 1)) } keyboard::Key::Named(Named::ArrowRight) => { - return Some(Message::ChangeFocus(ArrowKey::Right, 1)); + Some(Message::ChangeFocus(ArrowKey::Right, 1)) } keyboard::Key::Named(Named::ArrowDown) => { - return Some(Message::ChangeFocus(ArrowKey::Down, 1)); + Some(Message::ChangeFocus(ArrowKey::Down, 1)) } keyboard::Key::Character(chr) => { if modifiers.command() && chr.to_string() == "r" { - return Some(Message::ReloadConfig); + Some(Message::ReloadConfig) } else if chr.to_string() == "p" && modifiers.control() { - return Some(Message::ChangeFocus(ArrowKey::Up, 1)); + Some(Message::ChangeFocus(ArrowKey::Up, 1)) } else if chr.to_string() == "n" && modifiers.control() { - return Some(Message::ChangeFocus(ArrowKey::Down, 1)); + Some(Message::ChangeFocus(ArrowKey::Down, 1)) } else { - return Some(Message::FocusTextInput(Move::Forwards( - chr.to_string(), - ))); + Some(Message::FocusTextInput(Move::Forwards(chr.to_string()))) } } - keyboard::Key::Named(Named::Enter) => return Some(Message::OpenFocused), + keyboard::Key::Named(Named::Enter) => Some(Message::OpenFocused), keyboard::Key::Named(Named::Backspace) => { - return Some(Message::FocusTextInput(Move::Back)); + Some(Message::FocusTextInput(Move::Back)) } - _ => {} + _ => None, } - None } else { None } @@ -337,22 +332,6 @@ impl Tile { } } -/// This is the subscription function that handles hotkeys, e.g. for hiding / showing the window -fn handle_hotkeys() -> impl futures::Stream { - stream::channel(100, async |mut output| { - let receiver = GlobalHotKeyEvent::receiver(); - loop { - info!("Hotkey received"); - if let Ok(event) = receiver.recv() - && event.state == HotKeyState::Pressed - { - output.try_send(Message::KeyPressed(event.id)).unwrap(); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) -} - /// This is the subscription function that handles the change in clipboard history fn handle_clipboard_history() -> impl futures::Stream { stream::channel(100, async |mut output| { @@ -602,7 +581,6 @@ fn handle_recipient() -> impl futures::Stream { let abcd = recipient .try_recv() .map(async |msg| { - info!("Sending a message"); output.send(msg).await.unwrap(); }) .ok(); diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 03eca1d4..87fadf9c 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -4,7 +4,6 @@ use std::collections::HashMap; use std::fs; -use global_hotkey::hotkey::HotKey; use iced::border::Radius; use iced::widget::scrollable::{Anchor, Direction, Scrollbar}; use iced::widget::text::LineHeight; @@ -35,7 +34,7 @@ use crate::{ }; /// Initialise the base window -pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { +pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { let (id, open) = window::open(default_settings()); info!("Opening window"); @@ -60,24 +59,6 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { options.par_sort_by_key(|x| x.display_name.len()); let options = AppIndex::from_apps(options); - let mut shells_map = HashMap::new(); - for shell in &config.shells { - if let Some(hk_str) = &shell.hotkey - && let Ok(hk) = hk_str.parse::() - { - shells_map.insert(hk.id, shell.command.clone()); - } - } - - let hotkeys = Hotkeys { - toggle: hotkey, - clipboard_hotkey: config - .clipboard_hotkey - .parse() - .unwrap_or("SUPER+SHIFT+C".parse().unwrap()), - shells: shells_map, - }; - let home = std::env::var("HOME").unwrap_or("/".to_string()); let ranking = toml::from_str( @@ -94,8 +75,8 @@ pub fn new(hotkey: HotKey, config: &Config) -> (Tile, Task) { focus_id: 0, results: vec![], options, - emoji_apps: AppIndex::from_apps(App::emoji_apps()), hotkeys, + emoji_apps: AppIndex::from_apps(App::emoji_apps()), visible: true, frontmost: None, focused: false, diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 4bca0409..f3aa95b2 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -1,5 +1,6 @@ //! This handles the update logic for the tile (AKA rustcast's main window) use std::cmp::min; +use std::collections::HashMap; use std::fs; use std::io::Cursor; use std::thread; @@ -34,6 +35,8 @@ use crate::commands::Function; use crate::config::Config; use crate::config::MainPage; use crate::debounce::DebouncePolicy; +use crate::platform::macos::launching::Shortcut; +use crate::platform::macos::launching::global_handler; use crate::platform::macos::{start_at_login, stop_at_login}; use crate::quit::get_open_apps; use crate::unit_conversion; @@ -91,6 +94,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Message::SetSender(sender) => { tile.sender = Some(sender.clone()); + global_handler(sender.clone()); if tile.config.show_trayicon { tile.tray_icon = Some(menu_icon(tile.config.clone(), sender)); } @@ -274,6 +278,24 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Err(_) => return Task::none(), }; + if let Ok(hotkey) = Shortcut::parse(&new_config.clipboard_hotkey) { + tile.hotkeys.clipboard_hotkey = hotkey + } + + if let Ok(hotkey) = Shortcut::parse(&new_config.toggle_hotkey) { + tile.hotkeys.toggle = hotkey + } + + let mut shell_map = HashMap::new(); + + for shell in &new_config.shells { + if let Some(hotkey) = shell.hotkey.clone().and_then(|x| Shortcut::parse(&x).ok()) { + shell_map.insert(hotkey, shell.clone()); + } + } + + tile.hotkeys.shells = shell_map; + let update_apps_task = if tile.config.shells != new_config.shells { info!("App Update required"); Task::done(Message::UpdateApps) @@ -303,17 +325,21 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Task::batch([Task::done(Message::LoadRanking), update_apps_task]) } - Message::KeyPressed(hk_id) => { - if let Some(cmd) = tile.hotkeys.shells.get(&hk_id) { - return Task::done(Message::RunFunction(Function::RunShellCommand(cmd.clone()))); + Message::KeyPressed(shortcut) => { + if let Some(cmd) = tile.hotkeys.shells.get(&shortcut) { + return Task::done(Message::RunFunction(Function::RunShellCommand( + cmd.command.clone(), + ))); } - let is_clipboard_hotkey = tile.hotkeys.clipboard_hotkey.id == hk_id; - let is_open_hotkey = hk_id == tile.hotkeys.toggle.id; + let is_clipboard_hotkey = shortcut == tile.hotkeys.clipboard_hotkey; + let is_open_hotkey = shortcut == tile.hotkeys.toggle; let clipboard_page_task = if is_clipboard_hotkey { + info!("Switching to clipboard page"); Task::done(Message::SwitchToPage(Page::ClipboardHistory)) } else if is_open_hotkey { + info!("Switching to main page"); Task::done(Message::SwitchToPage(Page::Main)) } else { Task::none() @@ -485,6 +511,18 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { new_options.par_sort_by_key(|x| x.display_name.len()); tile.options = AppIndex::from_apps(new_options); + let mut shell_map = HashMap::new(); + + for shell in &tile.config.shells { + if let Some(has_hk) = &shell.hotkey + && let Some(hotkey) = Shortcut::parse(has_hk).ok() + { + shell_map.insert(hotkey, shell.clone()); + } + } + + tile.hotkeys.shells = shell_map; + Task::none() } diff --git a/src/main.rs b/src/main.rs index 6b06a7c4..82c88636 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,18 +12,14 @@ mod styles; mod unit_conversion; mod utils; -use std::{fs::OpenOptions, path::Path}; +use std::{collections::HashMap, fs::OpenOptions, path::Path}; use crate::{ - app::tile::{self, Tile}, + app::tile::{self, Hotkeys, Tile}, config::Config, - platform::macos::get_autostart_status, + platform::macos::{get_autostart_status, launching::Shortcut}, }; -use global_hotkey::{ - GlobalHotKeyManager, - hotkey::{Code, HotKey, Modifiers}, -}; use log::info; use tracing_subscriber::{EnvFilter, Layer, util::SubscriberInitExt}; @@ -67,36 +63,33 @@ fn main() -> iced::Result { info!("Config loaded"); - let manager = GlobalHotKeyManager::new().unwrap(); + let show_hide = + Shortcut::parse(&config.toggle_hotkey).unwrap_or(Shortcut::parse("option+space").unwrap()); - let show_hide = config - .toggle_hotkey - .parse() - .unwrap_or(HotKey::new(Some(Modifiers::ALT), Code::Space)); + let cbhist = Shortcut::parse(&config.clipboard_hotkey.to_lowercase()) + .unwrap_or_else(|_| Shortcut::parse("cmd+shift+c").unwrap()); - let cbhist = config - .clipboard_hotkey - .parse() - .unwrap_or("SUPER+SHIFT+C".parse().unwrap()); + let mut shell_map = HashMap::new(); - let mut hotkeys = vec![show_hide, cbhist]; for shell in &config.shells { if let Some(hk_str) = &shell.hotkey - && let Ok(hk) = hk_str.parse::() + && let Ok(hk) = Shortcut::parse(hk_str) { - hotkeys.push(hk); + shell_map.insert(hk, shell.clone()); } } - manager - .register_all(&hotkeys) - .expect("Unable to register hotkeys"); + let hotkeys = Hotkeys { + toggle: show_hide, + clipboard_hotkey: cbhist, + shells: shell_map, + }; info!("Hotkeys loaded"); info!("Starting rustcast"); iced::daemon( - move || tile::elm::new(show_hide, &config), + move || tile::elm::new(hotkeys.clone(), &config), tile::update::handle_update, tile::elm::view, ) diff --git a/src/platform/macos/launching.rs b/src/platform/macos/launching.rs new file mode 100644 index 00000000..5ef49b4a --- /dev/null +++ b/src/platform/macos/launching.rs @@ -0,0 +1,234 @@ +use std::sync::{Arc, Mutex}; + +use block2::RcBlock; +use objc2_app_kit::{NSEvent, NSEventMask, NSEventModifierFlags, NSEventType}; + +use crate::app::{Message, tile::ExtSender}; + +pub fn global_handler(sender: ExtSender) { + local_handler(sender.clone()); + let mask = NSEventMask::KeyDown | NSEventMask::FlagsChanged; + let sender = Arc::new(Mutex::new(sender.0.clone())); + + let block = RcBlock::new({ + move |event: std::ptr::NonNull| { + let event = unsafe { event.as_ref() }; + let event_type = event.r#type(); + + let key_code = event.keyCode(); + let mods = event.modifierFlags() + & (NSEventModifierFlags::Command + | NSEventModifierFlags::Option + | NSEventModifierFlags::Control + | NSEventModifierFlags::Function + | NSEventModifierFlags::CapsLock + | NSEventModifierFlags::Shift); + + let shortcut = match event_type { + NSEventType::KeyDown => Shortcut { + key_code: Some(key_code), + mods: if mods.0 != 0 { Some(mods.0) } else { None }, + }, + NSEventType::FlagsChanged => Shortcut { + key_code: None, + mods: if mods.0 != 0 { Some(mods.0) } else { None }, + }, + _ => return, + }; + + let mut s = sender.lock().unwrap(); + let _ = s.try_send(Message::KeyPressed(shortcut)); + } + }); + + NSEvent::addGlobalMonitorForEventsMatchingMask_handler(mask, &block); +} + +pub fn local_handler(sender: ExtSender) { + let mask = NSEventMask::KeyDown | NSEventMask::FlagsChanged; + let sender = Arc::new(Mutex::new(sender.0.clone())); + + let block = RcBlock::new({ + move |event: std::ptr::NonNull| -> *mut NSEvent { + let event_ref = unsafe { event.as_ref() }; + let event_type = event_ref.r#type(); + + let key_code = event_ref.keyCode(); + let mods = event_ref.modifierFlags() + & (NSEventModifierFlags::Command + | NSEventModifierFlags::Option + | NSEventModifierFlags::Control + | NSEventModifierFlags::Function + | NSEventModifierFlags::CapsLock + | NSEventModifierFlags::Shift); + + let shortcut = match event_type { + NSEventType::KeyDown => Shortcut { + key_code: Some(key_code), + mods: if mods.0 != 0 { Some(mods.0) } else { None }, + }, + NSEventType::FlagsChanged => Shortcut { + key_code: None, + mods: if mods.0 != 0 { Some(mods.0) } else { None }, + }, + _ => return event.as_ptr(), // pass through unhandled events + }; + + let mut s = sender.lock().unwrap(); + let _ = s.try_send(Message::KeyPressed(shortcut)); + + event.as_ptr() + } + }); + + unsafe { + NSEvent::addLocalMonitorForEventsMatchingMask_handler(mask, &block); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Shortcut { + pub key_code: Option, + pub mods: Option, +} + +impl Shortcut { + pub fn new(key_code: Option, mods: Option) -> Self { + Self { key_code, mods } + } + + pub fn parse(s: &str) -> Result { + let parts: Vec<&str> = s.split('+').map(|p| p.trim()).collect(); + + let mut mods: usize = 0; + let mut key_code: Option = None; + let mut has_mods = false; + + for part in &parts { + match part.to_lowercase().as_str() { + "cmd" | "command" | "super" => { + mods |= NSEventModifierFlags::Command.0; + has_mods = true; + } + "opt" | "option" | "alt" => { + mods |= NSEventModifierFlags::Option.0; + has_mods = true; + } + "capslock" | "caps" | "caps lock" => mods |= NSEventModifierFlags::CapsLock.0, + "ctrl" | "control" => { + mods |= NSEventModifierFlags::Control.0; + has_mods = true; + } + "shift" => { + mods |= NSEventModifierFlags::Shift.0; + has_mods = true; + } + "fn" | "function" => { + mods |= NSEventModifierFlags::Function.0; + has_mods = true; + } + key => { + if key_code.is_some() { + return Err(format!("Multiple keys specified: '{}'", s)); + } + key_code = Some(str_to_keycode(key)?); + } + } + } + + Ok(Shortcut::new( + key_code, + if has_mods { Some(mods) } else { None }, + )) + } +} + +fn str_to_keycode(s: &str) -> Result { + let code = match s.to_lowercase().as_str() { + // Letters + "a" => 0x00, + "s" => 0x01, + "d" => 0x02, + "f" => 0x03, + "h" => 0x04, + "g" => 0x05, + "z" => 0x06, + "x" => 0x07, + "c" => 0x08, + "v" => 0x09, + "b" => 0x0b, + "q" => 0x0c, + "w" => 0x0d, + "e" => 0x0e, + "r" => 0x0f, + "y" => 0x10, + "t" => 0x11, + "o" => 0x1f, + "u" => 0x20, + "i" => 0x22, + "p" => 0x23, + "l" => 0x25, + "j" => 0x26, + "k" => 0x28, + "n" => 0x2d, + "m" => 0x2e, + + // Numbers + "1" => 0x12, + "2" => 0x13, + "3" => 0x14, + "4" => 0x15, + "5" => 0x17, + "6" => 0x16, + "7" => 0x1a, + "8" => 0x1c, + "9" => 0x19, + "0" => 0x1d, + + // Special keys + "return" | "enter" => 0x24, + "tab" => 0x30, + "space" => 0x31, + "delete" | "backspace" => 0x33, + "escape" | "esc" => 0x35, + "left" | "arrowleft" => 0x7b, + "right" | "arrowright" => 0x7c, + "down" | "arrowdown" => 0x7d, + "up" | "arrowup" => 0x7e, + "home" => 0x73, + "end" => 0x77, + "pageup" => 0x74, + "pagedown" => 0x79, + + // Function keys + "f1" => 0x7a, + "f2" => 0x78, + "f3" => 0x63, + "f4" => 0x76, + "f5" => 0x60, + "f6" => 0x61, + "f7" => 0x62, + "f8" => 0x64, + "f9" => 0x65, + "f10" => 0x6d, + "f11" => 0x67, + "f12" => 0x6f, + + // Symbols + "-" | "minus" => 0x1b, + "=" | "equal" => 0x18, + "[" | "bracketleft" => 0x21, + "]" | "bracketright" => 0x1e, + "\\" | "backslash" => 0x2a, + ";" | "semicolon" => 0x29, + "'" | "quote" => 0x27, + "`" | "backquote" | "grave" => 0x32, + "," | "comma" => 0x2b, + "." | "period" => 0x2f, + "/" | "slash" => 0x2c, + + _ => return Err(format!("Unknown key: '{}'", s)), + }; + + Ok(code) +} diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index ab7c05c0..eeab1289 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -1,6 +1,7 @@ //! Macos specific logic, such as window settings, etc. pub mod discovery; pub mod haptics; +pub mod launching; use iced::wgpu::rwh::WindowHandle; From f07a5d5fc473bc93dab7e6bb58363b3a60f57051 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Thu, 2 Apr 2026 15:36:01 +0800 Subject: [PATCH 33/51] remove unused imports --- src/app/menubar.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/menubar.rs b/src/app/menubar.rs index 2d1c87a0..7b82e887 100644 --- a/src/app/menubar.rs +++ b/src/app/menubar.rs @@ -2,7 +2,6 @@ use std::{collections::HashMap, io::Cursor}; -use global_hotkey::hotkey::{Code, Modifiers}; use image::{DynamicImage, ImageReader}; use log::info; use tray_icon::{ From ea00fe31991a87c38d6fec85c341a55c5f1e3ac9 Mon Sep 17 00:00:00 2001 From: Secretised <132474703+unsecretised@users.noreply.github.com> Date: Sat, 11 Apr 2026 16:30:40 +0800 Subject: [PATCH 34/51] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 32 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 21 +++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..5537e972 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Unexpected behaviour / crashes +title: "[bug]" +labels: bug, not assigned +assignees: '' +type: Bug + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Which macos version? (please complete the following information):** + - OS: [e.g. MacOS Sequoia 15.5] + - Rustcast Version [e.g. v0.7.3] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..c7a35a7f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: Feature request +about: Request a feature into Rustcast +title: "[feat]" +labels: enhancement, not assigned +assignees: '' +type: Feature + +--- + +**Why do you think this is a good feature?** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**What have you been using currently to solve this?** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From f4b1c1ad73b4623984f2f8839c827d1688b8e1e2 Mon Sep 17 00:00:00 2001 From: Ansel <238927804+asong56@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:29:35 -0400 Subject: [PATCH 35/51] Enhance search_prefix to include prefix with hyphen --- src/app/tile.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/tile.rs b/src/app/tile.rs index 05c41668..0ccbb4e2 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -54,7 +54,10 @@ impl AppIndex { /// Search for an element in the index that starts with the provided prefix fn search_prefix<'a>(&'a self, prefix: &'a str) -> impl ParallelIterator + 'a { self.by_name.par_iter().filter_map(move |(name, app)| { - if name.starts_with(prefix) || name.contains(format!(" {prefix}").as_str()) { + if name.starts_with(prefix) + || name.contains(format!(" {prefix}").as_str()) + || name.contains(format!("-{prefix}").as_str()) + { Some(app) } else { None From a5f2c76f8190ce12cd2c12ec371e5f762027512e Mon Sep 17 00:00:00 2001 From: unsecretised Date: Sun, 19 Apr 2026 18:53:59 +0800 Subject: [PATCH 36/51] Update website --- docs/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.html b/docs/index.html index 1f17b1d8..63dcd3ea 100644 --- a/docs/index.html +++ b/docs/index.html @@ -75,8 +75,8 @@

    The fastest launcher
    you'll ever use

    -
    500+ stars
    -
    1.2k+ downloads
    +
    550+ stars
    +
    1.9k+ downloads
    100% free forever
    MIT license
    From d1420a2be2ac8072a190537365fa80511347a521 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Wed, 22 Apr 2026 12:34:38 +0800 Subject: [PATCH 37/51] add deeplinks support --- Cargo.lock | 1 + Cargo.toml | 5 + assets/macos/RustCast.app/Contents/Info.plist | 67 ++++--- src/app.rs | 1 + src/app/tile.rs | 1 + src/app/tile/elm.rs | 2 + src/app/tile/update.rs | 30 +++ src/platform/macos/mod.rs | 1 + src/platform/macos/urlscheme.rs | 186 ++++++++++++++++++ 9 files changed, 266 insertions(+), 28 deletions(-) create mode 100644 src/platform/macos/urlscheme.rs diff --git a/Cargo.lock b/Cargo.lock index c059a04c..59d8cc85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3855,6 +3855,7 @@ version = "0.7.2" dependencies = [ "arboard", "block2 0.6.2", + "crossbeam-channel", "emojis", "global-hotkey", "iced", diff --git a/Cargo.toml b/Cargo.toml index 812a7cbb..d69ba843 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ repository = "https://github.com/RustCastLabs/rustcast" [dependencies] arboard = "3.6.1" block2 = "0.6.2" +crossbeam-channel = "0.5.15" emojis = "0.8.0" global-hotkey = "0.7.0" iced = { version = "0.14.0", features = ["image", "tokio"] } @@ -35,3 +36,7 @@ toml = "0.9.8" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } tray-icon = "0.21.3" url = { version = "2.5.8", default-features = false } + +[package.metadata.bundle] +info_plist = "assets/macos/RustCast.app/Contents/Resources/Info.plist" +identifier = "com.umangsurana.rustcast" diff --git a/assets/macos/RustCast.app/Contents/Info.plist b/assets/macos/RustCast.app/Contents/Info.plist index 298774ff..9f50001f 100644 --- a/assets/macos/RustCast.app/Contents/Info.plist +++ b/assets/macos/RustCast.app/Contents/Info.plist @@ -1,32 +1,43 @@ - - CFBundleDevelopmentRegion - en - CFBundleExecutable - rustcast - CFBundleIdentifier - com.umangsurana.rustcast - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - RustCast - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSUIElement - - NSHighResolutionCapable - - NSHumanReadableCopyright - Copyright © 2025 Umang Surana. All rights reserved. - NSInputMonitoringUsageDescription - RustCast needs to monitor keyboard input to detect global shortcuts and control casting. - CFBundleIconFile - icon - + + CFBundleDevelopmentRegion + en + CFBundleExecutable + rustcast + CFBundleIdentifier + com.umangsurana.rustcast + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + RustCast + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSUIElement + + NSHighResolutionCapable + + CFBundleURLTypes + + + CFBundleURLSchemes + + rustcast + + CFBundleURLName + com.umangsurana.rustcast + + + NSHumanReadableCopyright + Copyright © 2025 Umang Surana. All rights reserved. + NSInputMonitoringUsageDescription + RustCast needs to monitor keyboard input to detect global shortcuts and control casting. + CFBundleIconFile + icon + diff --git a/src/app.rs b/src/app.rs index 68b19e41..6892bdb5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -80,6 +80,7 @@ pub enum Editable { /// The message type that iced uses for actions that can do something #[derive(Debug, Clone)] pub enum Message { + UriReceived(String), WriteConfig(bool), SaveRanking, ToggleAutoStartup(bool), diff --git a/src/app/tile.rs b/src/app/tile.rs index 0ccbb4e2..7b131bee 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -231,6 +231,7 @@ impl Tile { Subscription::batch([ Subscription::run(handle_hot_reloading), keyboard, + Subscription::run(crate::platform::macos::urlscheme::url_stream), Subscription::run(handle_recipient), Subscription::run(handle_version_and_rankings), Subscription::run(handle_clipboard_history), diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 87fadf9c..7fbe2ab9 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -66,6 +66,8 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { ) .unwrap_or(HashMap::new()); + crate::platform::macos::urlscheme::install(); + ( Tile { update_available: false, diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index f3aa95b2..1684476d 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -15,6 +15,7 @@ use log::info; use rayon::iter::IntoParallelRefIterator; use rayon::iter::ParallelIterator; use rayon::slice::ParallelSliceMut; +use url::Url; use crate::app::Editable; use crate::app::SetConfigBufferFields; @@ -46,6 +47,12 @@ 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}; +fn extract_target(url: &Url) -> Option { + url.query_pairs() + .find(|(key, _)| key == "target") + .map(|(_, value)| value.into_owned()) +} + /// Handle the "elm" update pub fn handle_update(tile: &mut Tile, message: Message) -> Task { match message { @@ -64,6 +71,29 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } } + Message::UriReceived(uri) => { + let Ok(url) = Url::parse(&uri) else { + return Task::none(); + }; + + match url.host_str().unwrap_or("") { + "open" => extract_target(&url) + .and_then(|x| tile.options.by_name.get(&x).map(|x| x.to_owned())) + .map(|app| match app.open_command { + AppCommand::Function(a) => Task::done(Message::RunFunction(a)), + AppCommand::Display => Task::none(), + AppCommand::Message(msg) => Task::done(msg), + }) + .unwrap_or(Task::none()), + + "show" => open_window(DEFAULT_WINDOW_HEIGHT), + + "quit" => Task::done(Message::RunFunction(Function::Quit)), + + _ => Task::none(), + } + } + Message::UpdateAvailable => { tile.update_available = true; Task::done(Message::ReloadConfig) diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index eeab1289..0441f05d 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -2,6 +2,7 @@ pub mod discovery; pub mod haptics; pub mod launching; +pub mod urlscheme; use iced::wgpu::rwh::WindowHandle; diff --git a/src/platform/macos/urlscheme.rs b/src/platform/macos/urlscheme.rs new file mode 100644 index 00000000..abf08737 --- /dev/null +++ b/src/platform/macos/urlscheme.rs @@ -0,0 +1,186 @@ +//! macOS URL scheme handler for `nexus://` deep links +//! +//! On macOS, clicking a `nexus://` link delivers the URL via Apple Events +//! (`kInternetEventClass` / `kAEGetURL`), not as a command-line argument. +//! +//! This module registers a handler with `NSAppleEventManager` to receive +//! those events and forwards URLs through a crossbeam channel consumed by +//! an async stream subscription. +//! +//! **Why NSAppleEventManager instead of NSApplicationDelegate?** +//! Iced/winit owns the `NSApplication` delegate for window and input event +//! handling. Replacing it with our own delegate breaks the entire event +//! chain and causes crashes. `NSAppleEventManager` hooks into URL delivery +//! at a lower level without touching the delegate. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use crossbeam_channel::{Receiver, Sender}; +use objc2::rc::Retained; +use objc2::runtime::AnyObject; +use objc2::{MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; +use objc2_foundation::{NSObject, NSObjectProtocol}; +use once_cell::sync::Lazy; + +use crate::app::Message; + +/// Channel for forwarding URLs from the Apple Event handler to the Iced event loop. +/// +/// `crossbeam_channel` is used because both `Sender` and `Receiver` are +/// `Send + Sync`, which is required for use in a `static`. The standard +/// library's `mpsc::Receiver` is not `Sync` and would fail to compile. +static URL_CHANNEL: Lazy<(Sender, Receiver)> = + Lazy::new(crossbeam_channel::unbounded); + +/// Flag set during app shutdown so the `spawn_blocking` recv loop can exit. +static SHUTTING_DOWN: AtomicBool = AtomicBool::new(false); + +/// Apple Event FourCharCode for `kInternetEventClass` and `kAEGetURL` (both `'GURL'`). +const K_AE_GET_URL: u32 = u32::from_be_bytes(*b"GURL"); + +/// Apple Event FourCharCode for `keyDirectObject` (`'----'`), the parameter +/// key that contains the URL string in a "get URL" event. +const KEY_DIRECT_OBJECT: u32 = u32::from_be_bytes(*b"----"); + +define_class!( + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "NexusURLHandler"] + struct UrlHandler; + + unsafe impl NSObjectProtocol for UrlHandler {} + + /// Handler method registered with `NSAppleEventManager`. The selector + /// `handleGetURLEvent:withReplyEvent:` matches what AppKit expects for + /// Apple Event callbacks. + impl UrlHandler { + #[unsafe(method(handleGetURLEvent:withReplyEvent:))] + fn handle_get_url_event(&self, event: &AnyObject, _reply: &AnyObject) { + // event is an NSAppleEventDescriptor. Extract the direct object + // parameter which contains the URL as an NSAppleEventDescriptor, + // then get its stringValue (an NSString). + let descriptor: *mut AnyObject = + unsafe { msg_send![event, paramDescriptorForKeyword: KEY_DIRECT_OBJECT] }; + if descriptor.is_null() { + return; + } + let ns_string: *mut AnyObject = unsafe { msg_send![&*descriptor, stringValue] }; + if ns_string.is_null() { + return; + } + let utf8: *const std::ffi::c_char = unsafe { msg_send![&*ns_string, UTF8String] }; + if utf8.is_null() { + return; + } + let url_str = unsafe { std::ffi::CStr::from_ptr(utf8) } + .to_string_lossy() + .to_string(); + if url_str.to_lowercase().starts_with("rustcast://") { + let _ = URL_CHANNEL.0.send(url_str); + } + } + } +); + +impl UrlHandler { + fn new(mtm: MainThreadMarker) -> Retained { + // SAFETY: `Self::alloc(mtm)` returns a valid allocated instance of our + // NSObject subclass. Sending `init` to a freshly allocated NSObject + // subclass with no custom ivars is the standard Objective-C + // initialisation pattern and always succeeds. + unsafe { msg_send![Self::alloc(mtm), init] } + } +} + +/// Install the macOS URL scheme handler via `NSAppleEventManager`. +/// +/// Must be called **after** the Iced/winit event loop has been created +/// (i.e. from `NexusApp::new()`), so that AppKit is fully initialized. +/// +/// Registers for `kInternetEventClass` / `kAEGetURL` events, which macOS +/// sends when a `nexus://` URL is opened (clicked in browser, Finder, etc.). +pub fn install() { + let Some(mtm) = MainThreadMarker::new() else { + eprintln!("macos_url: not on main thread, skipping URL handler install"); + return; + }; + + let handler = UrlHandler::new(mtm); + + // Get [NSAppleEventManager sharedAppleEventManager] + let mgr: *mut AnyObject = unsafe { + msg_send![ + objc2::runtime::AnyClass::get(c"NSAppleEventManager") + .expect("NSAppleEventManager class not found"), + sharedAppleEventManager + ] + }; + assert!( + !mgr.is_null(), + "macos_url: sharedAppleEventManager returned nil" + ); + + // Register: [mgr setEventHandler:handler + // andSelector:@selector(handleGetURLEvent:withReplyEvent:) + // forEventClass:kInternetEventClass + // andEventID:kAEGetURL] + let handler_sel = sel!(handleGetURLEvent:withReplyEvent:); + unsafe { + let _: () = msg_send![ + &*mgr, + setEventHandler: &*handler, + andSelector: handler_sel, + forEventClass: K_AE_GET_URL, + andEventID: K_AE_GET_URL + ]; + } + + // Leak the handler so it lives for the entire process. + // + // `UrlHandler` is `MainThreadOnly` (`!Send + !Sync`), so + // `Retained` cannot be stored in a `static`. Leaking + // is the standard pattern for process-lifetime Objective-C objects. + // + // Unlike `NSApplication.delegate` (which is weak), the Apple Event + // Manager retains a strong reference — but leaking is still correct + // because we never want to unregister the handler. + std::mem::forget(handler); +} + +/// Signal the URL stream to stop so the `spawn_blocking` task can exit +/// and tokio's runtime drop won't hang. +/// +/// Must be called before `iced::window::close()` on macOS. +#[allow(unused)] +pub fn shutdown() { + SHUTTING_DOWN.store(true, Ordering::Relaxed); +} + +/// Async stream that yields URLs received via Apple Events. +/// +/// Uses `recv_timeout` inside `spawn_blocking` so the blocking thread +/// wakes periodically and can exit when the tokio runtime shuts down +/// (e.g., on app quit). Without this, `recv()` blocks indefinitely and +/// causes a hang on macOS during quit. +pub fn url_stream() -> impl iced::futures::Stream { + iced::futures::stream::unfold((), |()| async { + let url = tokio::task::spawn_blocking(|| { + loop { + if SHUTTING_DOWN.load(Ordering::Relaxed) { + return None; + } + match URL_CHANNEL.1.recv_timeout(Duration::from_millis(500)) { + Ok(url) => return Some(url), + Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue, + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => return None, + } + } + }) + .await + .ok() + .flatten()?; + + Some((Message::UriReceived(url), ())) + }) +} From e7ebdea8d43ac77b186c4f9c2d6e497c7e878ad3 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Wed, 22 Apr 2026 12:43:12 +0800 Subject: [PATCH 38/51] remove cargo bundle stuff --- Cargo.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d69ba843..7cb8af64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,3 @@ toml = "0.9.8" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } tray-icon = "0.21.3" url = { version = "2.5.8", default-features = false } - -[package.metadata.bundle] -info_plist = "assets/macos/RustCast.app/Contents/Resources/Info.plist" -identifier = "com.umangsurana.rustcast" From c53aacf37f8b8afdae03d298d1d6b44a6f0fb33a Mon Sep 17 00:00:00 2001 From: unsecretised Date: Wed, 22 Apr 2026 12:43:49 +0800 Subject: [PATCH 39/51] Fix attempt --- assets/entitlements.plist | 18 ++++++++++++++++++ scripts/sign-macos.sh | 3 +++ 2 files changed, 21 insertions(+) create mode 100644 assets/entitlements.plist diff --git a/assets/entitlements.plist b/assets/entitlements.plist new file mode 100644 index 00000000..a56b2511 --- /dev/null +++ b/assets/entitlements.plist @@ -0,0 +1,18 @@ + + + + + + com.apple.security.temporary-exception.accessibility + + + + com.apple.security.app-sandbox + + + + com.apple.security.device.input-monitoring + + + diff --git a/scripts/sign-macos.sh b/scripts/sign-macos.sh index 881f50fc..6b99c8e2 100755 --- a/scripts/sign-macos.sh +++ b/scripts/sign-macos.sh @@ -1,5 +1,7 @@ #!/usr/bin/env -S bash -e +ENTITLEMENTS_PATH="assets/macos/entitlements.plist" + APP_BUNDLE_PATH="${APP_BUNDLE_PATH:?APP_BUNDLE_PATH not set}" # 1. Create a temporary keychain and import certificate @@ -29,6 +31,7 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: \ # 2. Sign app bundle codesign --deep --force --options runtime --timestamp \ + --entitlements $ENTITLEMENTS_PATH \ --sign "$MACOS_CERTIFICATE_NAME" \ "$APP_BUNDLE_PATH" From b8f006fc0cc7959773d5185be9fc5828b19b10c5 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Wed, 22 Apr 2026 13:09:04 +0800 Subject: [PATCH 40/51] update path --- assets/entitlements.plist | 9 ++------- scripts/sign-macos.sh | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/assets/entitlements.plist b/assets/entitlements.plist index a56b2511..cf673fc9 100644 --- a/assets/entitlements.plist +++ b/assets/entitlements.plist @@ -3,16 +3,11 @@ "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> - - com.apple.security.temporary-exception.accessibility - - - com.apple.security.app-sandbox - - com.apple.security.device.input-monitoring + com.apple.security.temporary-exception.accessibility + diff --git a/scripts/sign-macos.sh b/scripts/sign-macos.sh index 6b99c8e2..3b1c1620 100755 --- a/scripts/sign-macos.sh +++ b/scripts/sign-macos.sh @@ -1,6 +1,6 @@ #!/usr/bin/env -S bash -e -ENTITLEMENTS_PATH="assets/macos/entitlements.plist" +ENTITLEMENTS_PATH="assets/entitlements.plist" APP_BUNDLE_PATH="${APP_BUNDLE_PATH:?APP_BUNDLE_PATH not set}" From b182c9e9b1ec7ac2812edd979327ca4af75fe5f4 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Wed, 22 Apr 2026 13:17:55 +0800 Subject: [PATCH 41/51] ensure accessibility --- src/platform/macos/accessibility.rs | 89 +++++++++++++++++++++++++++++ src/platform/macos/launching.rs | 3 +- src/platform/macos/login.rs | 0 src/platform/macos/mod.rs | 1 + 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/platform/macos/accessibility.rs delete mode 100644 src/platform/macos/login.rs diff --git a/src/platform/macos/accessibility.rs b/src/platform/macos/accessibility.rs new file mode 100644 index 00000000..f994f843 --- /dev/null +++ b/src/platform/macos/accessibility.rs @@ -0,0 +1,89 @@ +use std::ffi::c_void; +use std::thread; +use std::time::{Duration, Instant}; + +use objc2::rc::autoreleasepool; +use objc2::runtime::AnyObject; +use objc2::{class, msg_send}; +use log::info; + +#[link(name = "ApplicationServices", kind = "framework")] +unsafe extern "C" { + fn AXIsProcessTrustedWithOptions(options: *const c_void) -> bool; + + static kAXTrustedCheckOptionPrompt: *const c_void; +} + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + static kCFBooleanTrue: *const c_void; + static kCFBooleanFalse: *const c_void; +} + +const AX_POLL_INTERVAL: Duration = Duration::from_millis(250); +const AX_POLL_TIMEOUT: Duration = Duration::from_secs(30); + +#[inline] +fn ax_is_trusted() -> bool { + unsafe { + autoreleasepool(|_| { + let keys: [*mut AnyObject; 1] = [kAXTrustedCheckOptionPrompt as *mut AnyObject]; + let vals: [*mut AnyObject; 1] = [kCFBooleanFalse as *mut AnyObject]; + let dict: *mut AnyObject = msg_send![ + class!(NSDictionary), + dictionaryWithObjects: vals.as_ptr(), + forKeys: keys.as_ptr(), + count: 1usize + ]; + + AXIsProcessTrustedWithOptions(dict.cast()) + }) + } +} + +#[allow(unsafe_op_in_unsafe_fn)] +unsafe fn prompt_ax_trust_dialog() { + autoreleasepool(|_| { + let keys: [*mut AnyObject; 1] = [kAXTrustedCheckOptionPrompt as *mut AnyObject]; + let vals: [*mut AnyObject; 1] = [kCFBooleanTrue as *mut AnyObject]; + + let dict: *mut AnyObject = msg_send![ + class!(NSDictionary), + dictionaryWithObjects: vals.as_ptr(), + forKeys: keys.as_ptr(), + count: 1usize + ]; + + let _ = AXIsProcessTrustedWithOptions(dict.cast()); + }); +} + +pub fn ensure_accessibility_permission() { + if ax_is_trusted() { + return; + } + + info!("Accessibility permission is not granted; prompting user for permission now."); + + unsafe { prompt_ax_trust_dialog() }; + + let start = Instant::now(); + loop { + if ax_is_trusted() { + info!("Accessibility permission granted"); + return; + } + + if start.elapsed() >= AX_POLL_TIMEOUT { + break; + } + + thread::sleep(AX_POLL_INTERVAL); + } + + println!( + "Rift still does not have accessibility permission. Enable it in System Settings > Privacy & Security > Accessibility, then restart Rift." + ); + + std::process::exit(1); +} diff --git a/src/platform/macos/launching.rs b/src/platform/macos/launching.rs index 5ef49b4a..087bb026 100644 --- a/src/platform/macos/launching.rs +++ b/src/platform/macos/launching.rs @@ -3,9 +3,10 @@ use std::sync::{Arc, Mutex}; use block2::RcBlock; use objc2_app_kit::{NSEvent, NSEventMask, NSEventModifierFlags, NSEventType}; -use crate::app::{Message, tile::ExtSender}; +use crate::{app::{Message, tile::ExtSender}, platform::macos::accessibility::ensure_accessibility_permission}; pub fn global_handler(sender: ExtSender) { + ensure_accessibility_permission(); local_handler(sender.clone()); let mask = NSEventMask::KeyDown | NSEventMask::FlagsChanged; let sender = Arc::new(Mutex::new(sender.0.clone())); diff --git a/src/platform/macos/login.rs b/src/platform/macos/login.rs deleted file mode 100644 index e69de29b..00000000 diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index eeab1289..39cb9c41 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -1,4 +1,5 @@ //! Macos specific logic, such as window settings, etc. +pub mod accessibility; pub mod discovery; pub mod haptics; pub mod launching; From 4a6cbf700bda635ae2b32a6f4b2ecfc2507251d3 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Wed, 22 Apr 2026 13:18:04 +0800 Subject: [PATCH 42/51] format -_- --- src/platform/macos/accessibility.rs | 2 +- src/platform/macos/launching.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/platform/macos/accessibility.rs b/src/platform/macos/accessibility.rs index f994f843..2617a412 100644 --- a/src/platform/macos/accessibility.rs +++ b/src/platform/macos/accessibility.rs @@ -2,10 +2,10 @@ use std::ffi::c_void; use std::thread; use std::time::{Duration, Instant}; +use log::info; use objc2::rc::autoreleasepool; use objc2::runtime::AnyObject; use objc2::{class, msg_send}; -use log::info; #[link(name = "ApplicationServices", kind = "framework")] unsafe extern "C" { diff --git a/src/platform/macos/launching.rs b/src/platform/macos/launching.rs index 087bb026..e6d22a98 100644 --- a/src/platform/macos/launching.rs +++ b/src/platform/macos/launching.rs @@ -3,7 +3,10 @@ use std::sync::{Arc, Mutex}; use block2::RcBlock; use objc2_app_kit::{NSEvent, NSEventMask, NSEventModifierFlags, NSEventType}; -use crate::{app::{Message, tile::ExtSender}, platform::macos::accessibility::ensure_accessibility_permission}; +use crate::{ + app::{Message, tile::ExtSender}, + platform::macos::accessibility::ensure_accessibility_permission, +}; pub fn global_handler(sender: ExtSender) { ensure_accessibility_permission(); From 04927ded43a1b944ca20d1578c73335250b43292 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Wed, 22 Apr 2026 13:18:41 +0800 Subject: [PATCH 43/51] change note and add credits --- src/platform/macos/accessibility.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/platform/macos/accessibility.rs b/src/platform/macos/accessibility.rs index 2617a412..38b4b73e 100644 --- a/src/platform/macos/accessibility.rs +++ b/src/platform/macos/accessibility.rs @@ -1,3 +1,4 @@ +/// Taken from: use std::ffi::c_void; use std::thread; use std::time::{Duration, Instant}; @@ -82,7 +83,7 @@ pub fn ensure_accessibility_permission() { } println!( - "Rift still does not have accessibility permission. Enable it in System Settings > Privacy & Security > Accessibility, then restart Rift." + "Rustcast still does not have accessibility permission. Enable it in System Settings > Privacy & Security > Accessibility, then restart Rustcast." ); std::process::exit(1); From 04ef73aabe386810ffc219c7cf317baf592bb623 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Wed, 22 Apr 2026 14:16:19 +0800 Subject: [PATCH 44/51] format + deeplinks --- .github/ISSUE_TEMPLATE/bug_report.md | 23 +++---- .github/ISSUE_TEMPLATE/feature_request.md | 17 +++-- docs/index.html | 77 +++++++++++++++++++++++ 3 files changed, 95 insertions(+), 22 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 5537e972..e3968f0d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -5,28 +5,25 @@ title: "[bug]" labels: bug, not assigned assignees: '' type: Bug - --- -**Describe the bug** -A clear and concise description of what the bug is. +**Describe the bug** A clear and concise description of what the bug is. + +**To Reproduce** Steps to reproduce the behavior: -**To Reproduce** -Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error -**Expected behavior** -A clear and concise description of what you expected to happen. +**Expected behavior** A clear and concise description of what you expected to +happen. -**Screenshots** -If applicable, add screenshots to help explain your problem. +**Screenshots** If applicable, add screenshots to help explain your problem. **Which macos version? (please complete the following information):** - - OS: [e.g. MacOS Sequoia 15.5] - - Rustcast Version [e.g. v0.7.3] -**Additional context** -Add any other context about the problem here. +- OS: [e.g. MacOS Sequoia 15.5] +- Rustcast Version [e.g. v0.7.3] + +**Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index c7a35a7f..76d6f7a1 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -5,17 +5,16 @@ title: "[feat]" labels: enhancement, not assigned assignees: '' type: Feature - --- -**Why do you think this is a good feature?** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +**Why do you think this is a good feature?** A clear and concise description of +what the problem is. Ex. I'm always frustrated when [...] -**Describe the solution you'd like** -A clear and concise description of what you want to happen. +**Describe the solution you'd like** A clear and concise description of what you +want to happen. -**What have you been using currently to solve this?** -A clear and concise description of any alternative solutions or features you've considered. +**What have you been using currently to solve this?** A clear and concise +description of any alternative solutions or features you've considered. -**Additional context** -Add any other context or screenshots about the feature request here. +**Additional context** Add any other context or screenshots about the feature +request here. diff --git a/docs/index.html b/docs/index.html index 63dcd3ea..f1b56293 100644 --- a/docs/index.html +++ b/docs/index.html @@ -260,6 +260,83 @@

    Script your workflow.
    Define your modes.

    +
    + + + +
    From 9b77e1aca9d2f0a5163c5794c2c7c34e2bffd8b7 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Wed, 22 Apr 2026 22:52:31 +0800 Subject: [PATCH 45/51] attempt fix from orvar --- scripts/sign-macos-broken.sh | 68 -------------------- scripts/sign-macos-old.sh | 39 ++++++++++++ scripts/sign-macos.sh | 118 ++++++++++++++++++++++++++--------- 3 files changed, 126 insertions(+), 99 deletions(-) delete mode 100755 scripts/sign-macos-broken.sh create mode 100755 scripts/sign-macos-old.sh diff --git a/scripts/sign-macos-broken.sh b/scripts/sign-macos-broken.sh deleted file mode 100755 index eeafe4fa..00000000 --- a/scripts/sign-macos-broken.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/bin/bash -set -euo pipefail - -RELEASE_DIR="target/release" -APP_DIR="$RELEASE_DIR/macos" -APP_NAME="Rustcast.app" -APP_PATH="$APP_DIR/$APP_NAME" - -# --- Required env vars (using the names you provided) --- -environment=( - "MACOS_CERTIFICATE" - "MACOS_CERTIFICATE_PWD" - "MACOS_CI_KEYCHAIN_PWD" - "MACOS_CERTIFICATE_NAME" - "MACOS_NOTARIZATION_PWD" - "MACOS_NOTARY_TEAM_ID" - "MACOS_NOTARY_KEY_ID" - "MACOS_NOTARY_KEY" -) - -for var in "${environment[@]}"; do - if [[ -z "${!var:-}" ]]; then - echo "Error: $var is not set" - exit 1 - fi -done - -# Optional: only needed if you still want to keep this around -: "${MACOS_NOTARISATION_APPLE_ID:=}" - -echo "Decoding certificate" -echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12 - -echo "Installing cert in a new keychain" -security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain -security default-keychain -s build.keychain -security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain -security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign -security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain - -echo "Signing..." -/usr/bin/codesign --force -s "$MACOS_CERTIFICATE_NAME" --options runtime --timestamp "$APP_PATH" -v - -echo "Creating temp notarization archive" -ditto -c -k --keepParent "$APP_PATH" "notarization.zip" - -echo "Notarize app (API key auth)" -# MACOS_NOTARY_KEY can be either: -# - the *contents* of the .p8 key, or -# - base64 of the .p8 key (recommended for CI) -# -# If it's base64, decode it first. -NOTARY_KEY_FILE="AuthKey.p8" -if printf '%s' "$MACOS_NOTARY_KEY" | grep -q "BEGIN PRIVATE KEY"; then - printf '%s' "$MACOS_NOTARY_KEY" > "$NOTARY_KEY_FILE" -else - printf '%s' "$MACOS_NOTARY_KEY" | base64 --decode > "$NOTARY_KEY_FILE" -fi - -# xcrun notarytool submit "notarization.zip" \ -# --team-id "$MACOS_NOTARY_TEAM_ID" \ -# --issuer "$MACOS_NOTARY_ISSUER_ID" \ -# --key-id "$MACOS_NOTARY_KEY_ID" \ -# --key "$NOTARY_KEY_FILE" \ -# --wait - -echo "Attach staple" -xcrun stapler staple "$APP_PATH" diff --git a/scripts/sign-macos-old.sh b/scripts/sign-macos-old.sh new file mode 100755 index 00000000..3b1c1620 --- /dev/null +++ b/scripts/sign-macos-old.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env -S bash -e + +ENTITLEMENTS_PATH="assets/entitlements.plist" + +APP_BUNDLE_PATH="${APP_BUNDLE_PATH:?APP_BUNDLE_PATH not set}" + +# 1. Create a temporary keychain and import certificate +KEYCHAIN=build.keychain-db + +if security list-keychains | grep -q "$KEYCHAIN"; then + echo "Keychain $KEYCHAIN already exists, using existing keychain." +else + security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" +fi + +security default-keychain -s "$KEYCHAIN" +security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" +security set-keychain-settings "$KEYCHAIN" +security default-keychain -s "$KEYCHAIN" +security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" +security set-keychain-settings "$KEYCHAIN" + +echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12 +security import certificate.p12 \ + -k "$KEYCHAIN" \ + -P "$MACOS_CERTIFICATE_PWD" \ + -T /usr/bin/codesign + +security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + +# 2. Sign app bundle +codesign --deep --force --options runtime --timestamp \ + --entitlements $ENTITLEMENTS_PATH \ + --sign "$MACOS_CERTIFICATE_NAME" \ + "$APP_BUNDLE_PATH" + +codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE_PATH" +echo "Signed app at $APP_BUNDLE_PATH" diff --git a/scripts/sign-macos.sh b/scripts/sign-macos.sh index 3b1c1620..0cd5d814 100755 --- a/scripts/sign-macos.sh +++ b/scripts/sign-macos.sh @@ -1,39 +1,95 @@ -#!/usr/bin/env -S bash -e +#!/bin/bash +set -euo pipefail -ENTITLEMENTS_PATH="assets/entitlements.plist" +RELEASE_DIR="target/release" +APP_DIR="$RELEASE_DIR/macos" +APP_NAME="Rustcast.app" +APP_PATH="$APP_DIR/$APP_NAME" -APP_BUNDLE_PATH="${APP_BUNDLE_PATH:?APP_BUNDLE_PATH not set}" +# --- Required env vars (using the names you provided) --- +environment=( + "MACOS_CERTIFICATE" + "MACOS_CERTIFICATE_PWD" + "MACOS_CI_KEYCHAIN_PWD" + "MACOS_CERTIFICATE_NAME" + "MACOS_NOTARIZATION_PWD" + "MACOS_NOTARY_TEAM_ID" + "MACOS_NOTARY_KEY_ID" + "MACOS_NOTARY_KEY" +) -# 1. Create a temporary keychain and import certificate -KEYCHAIN=build.keychain-db +for var in "${environment[@]}"; do + if [[ -z "${!var:-}" ]]; then + echo "Error: $var is not set" + exit 1 + fi +done -if security list-keychains | grep -q "$KEYCHAIN"; then - echo "Keychain $KEYCHAIN already exists, using existing keychain." +# Optional: only needed if you still want to keep this around +: "${MACOS_NOTARISATION_APPLE_ID:=}" + +echo "Decoding certificate" +echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12 + +echo "Installing cert in a new keychain" +security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain +security default-keychain -s build.keychain +security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain +security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain + +echo "Signing..." +/usr/bin/codesign --force -s "$MACOS_CERTIFICATE_NAME" --options runtime --timestamp "$APP_PATH" -v + +/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" +spctl --assess --type execute --verbose "$APP_PATH" + +echo "Creating temp notarization archive" +ditto -c -k --keepParent "$APP_PATH" "notarization.zip" + +SUBMIT_JSON=$(xcrun notarytool submit "notarization.zip" \ + --key "$NOTARY_KEY_FILE" \ + --key-id "$MACOS_NOTARY_KEY_ID" \ + --issuer "$MACOS_NOTARY_ISSUER_ID" \ + --output-format json) + +echo "$SUBMIT_JSON" + +SUBMIT_ID=$(echo "$SUBMIT_JSON" | jq -r .id) + +WAIT_STATUS=0 + +xcrun notarytool wait "$SUBMIT_ID" \ + --key "$NOTARY_KEY_FILE" --key-id "$MACOS_NOTARY_KEY_ID" --issuer "$MACOS_NOTARY_ISSUER_ID" \ + --timeout 30m || WAIT_STATUS=$? +xcrun notarytool log "$SUBMIT_ID" \ + --key "$NOTARY_KEY_FILE" --key-id "$MACOS_NOTARY_KEY_ID" --issuer "$MACOS_NOTARY_ISSUER_ID" \ + notarization-log.json || true +cat notarization-log.json || true +if [[ $WAIT_STATUS -ne 0 ]]; then + echo "Notarization did not succeed (wait exit $WAIT_STATUS)" + exit $WAIT_STATUS +fi + +echo "Notarize app (API key auth)" +# MACOS_NOTARY_KEY can be either: +# - the *contents* of the .p8 key, or +# - base64 of the .p8 key (recommended for CI) +# +# If it's base64, decode it first. +NOTARY_KEY_FILE="AuthKey.p8" +if printf '%s' "$MACOS_NOTARY_KEY" | grep -q "BEGIN PRIVATE KEY"; then + printf '%s' "$MACOS_NOTARY_KEY" > "$NOTARY_KEY_FILE" else - security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" + printf '%s' "$MACOS_NOTARY_KEY" | base64 --decode > "$NOTARY_KEY_FILE" fi -security default-keychain -s "$KEYCHAIN" -security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" -security set-keychain-settings "$KEYCHAIN" -security default-keychain -s "$KEYCHAIN" -security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" -security set-keychain-settings "$KEYCHAIN" +# xcrun notarytool submit "notarization.zip" \ +# --team-id "$MACOS_NOTARY_TEAM_ID" \ +# --issuer "$MACOS_NOTARY_ISSUER_ID" \ +# --key-id "$MACOS_NOTARY_KEY_ID" \ +# --key "$NOTARY_KEY_FILE" \ +# --wait -echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12 -security import certificate.p12 \ - -k "$KEYCHAIN" \ - -P "$MACOS_CERTIFICATE_PWD" \ - -T /usr/bin/codesign - -security set-key-partition-list -S apple-tool:,apple:,codesign: \ - -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN" - -# 2. Sign app bundle -codesign --deep --force --options runtime --timestamp \ - --entitlements $ENTITLEMENTS_PATH \ - --sign "$MACOS_CERTIFICATE_NAME" \ - "$APP_BUNDLE_PATH" - -codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE_PATH" -echo "Signed app at $APP_BUNDLE_PATH" +echo "Attach staple" +xcrun stapler staple "$APP_PATH" From 4b8a6dbf0efdb773d45649bbdab99364529d8c6d Mon Sep 17 00:00:00 2001 From: unsecretised Date: Wed, 22 Apr 2026 23:06:11 +0800 Subject: [PATCH 46/51] turns out order was wrong --- scripts/sign-macos.sh | 93 ++++++++++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 36 deletions(-) diff --git a/scripts/sign-macos.sh b/scripts/sign-macos.sh index 0cd5d814..774d7559 100755 --- a/scripts/sign-macos.sh +++ b/scripts/sign-macos.sh @@ -6,16 +6,16 @@ APP_DIR="$RELEASE_DIR/macos" APP_NAME="Rustcast.app" APP_PATH="$APP_DIR/$APP_NAME" -# --- Required env vars (using the names you provided) --- +# --- Required env vars --- environment=( "MACOS_CERTIFICATE" "MACOS_CERTIFICATE_PWD" "MACOS_CI_KEYCHAIN_PWD" "MACOS_CERTIFICATE_NAME" - "MACOS_NOTARIZATION_PWD" "MACOS_NOTARY_TEAM_ID" "MACOS_NOTARY_KEY_ID" "MACOS_NOTARY_KEY" + "MACOS_NOTARY_ISSUER_ID" ) for var in "${environment[@]}"; do @@ -25,28 +25,47 @@ for var in "${environment[@]}"; do fi done -# Optional: only needed if you still want to keep this around -: "${MACOS_NOTARISATION_APPLE_ID:=}" +# --- Step 1: Decode the notarization API key FIRST --- +echo "Preparing notarization API key..." +NOTARY_KEY_FILE="AuthKey.p8" +if printf '%s' "$MACOS_NOTARY_KEY" | grep -q "BEGIN PRIVATE KEY"; then + printf '%s' "$MACOS_NOTARY_KEY" > "$NOTARY_KEY_FILE" +else + printf '%s' "$MACOS_NOTARY_KEY" | base64 --decode > "$NOTARY_KEY_FILE" +fi -echo "Decoding certificate" +# --- Step 2: Decode and install the signing certificate --- +echo "Decoding certificate..." echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12 -echo "Installing cert in a new keychain" +echo "Installing cert in a new keychain..." security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain security default-keychain -s build.keychain security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain -echo "Signing..." -/usr/bin/codesign --force -s "$MACOS_CERTIFICATE_NAME" --options runtime --timestamp "$APP_PATH" -v - +# --- Step 3: Sign the app --- +echo "Signing app..." +/usr/bin/codesign \ + --force \ + --deep \ + --options runtime \ + --timestamp \ + -s "$MACOS_CERTIFICATE_NAME" \ + -v \ + "$APP_PATH" + +# --- Step 4: Verify the signature (not notarization yet) --- +echo "Verifying signature..." /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" -spctl --assess --type execute --verbose "$APP_PATH" -echo "Creating temp notarization archive" +# --- Step 5: Create notarization zip --- +echo "Creating notarization archive..." ditto -c -k --keepParent "$APP_PATH" "notarization.zip" +# --- Step 6: Submit for notarization --- +echo "Submitting for notarization..." SUBMIT_JSON=$(xcrun notarytool submit "notarization.zip" \ --key "$NOTARY_KEY_FILE" \ --key-id "$MACOS_NOTARY_KEY_ID" \ @@ -54,42 +73,44 @@ SUBMIT_JSON=$(xcrun notarytool submit "notarization.zip" \ --output-format json) echo "$SUBMIT_JSON" - SUBMIT_ID=$(echo "$SUBMIT_JSON" | jq -r .id) -WAIT_STATUS=0 +if [[ -z "$SUBMIT_ID" || "$SUBMIT_ID" == "null" ]]; then + echo "Error: Failed to get submission ID from notarytool" + exit 1 +fi + +echo "Submission ID: $SUBMIT_ID" +# --- Step 7: Wait for notarization to complete --- +echo "Waiting for notarization result..." +WAIT_STATUS=0 xcrun notarytool wait "$SUBMIT_ID" \ - --key "$NOTARY_KEY_FILE" --key-id "$MACOS_NOTARY_KEY_ID" --issuer "$MACOS_NOTARY_ISSUER_ID" \ + --key "$NOTARY_KEY_FILE" \ + --key-id "$MACOS_NOTARY_KEY_ID" \ + --issuer "$MACOS_NOTARY_ISSUER_ID" \ --timeout 30m || WAIT_STATUS=$? + +# --- Step 8: Fetch and print the notarization log --- +echo "Fetching notarization log..." xcrun notarytool log "$SUBMIT_ID" \ - --key "$NOTARY_KEY_FILE" --key-id "$MACOS_NOTARY_KEY_ID" --issuer "$MACOS_NOTARY_ISSUER_ID" \ + --key "$NOTARY_KEY_FILE" \ + --key-id "$MACOS_NOTARY_KEY_ID" \ + --issuer "$MACOS_NOTARY_ISSUER_ID" \ notarization-log.json || true cat notarization-log.json || true + if [[ $WAIT_STATUS -ne 0 ]]; then - echo "Notarization did not succeed (wait exit $WAIT_STATUS)" + echo "Notarization did not succeed (wait exit code: $WAIT_STATUS)" exit $WAIT_STATUS fi -echo "Notarize app (API key auth)" -# MACOS_NOTARY_KEY can be either: -# - the *contents* of the .p8 key, or -# - base64 of the .p8 key (recommended for CI) -# -# If it's base64, decode it first. -NOTARY_KEY_FILE="AuthKey.p8" -if printf '%s' "$MACOS_NOTARY_KEY" | grep -q "BEGIN PRIVATE KEY"; then - printf '%s' "$MACOS_NOTARY_KEY" > "$NOTARY_KEY_FILE" -else - printf '%s' "$MACOS_NOTARY_KEY" | base64 --decode > "$NOTARY_KEY_FILE" -fi +# --- Step 9: Staple the notarization ticket --- +echo "Stapling notarization ticket..." +xcrun stapler staple "$APP_PATH" -# xcrun notarytool submit "notarization.zip" \ -# --team-id "$MACOS_NOTARY_TEAM_ID" \ -# --issuer "$MACOS_NOTARY_ISSUER_ID" \ -# --key-id "$MACOS_NOTARY_KEY_ID" \ -# --key "$NOTARY_KEY_FILE" \ -# --wait +# --- Step 10: Final Gatekeeper check (AFTER stapling) --- +echo "Running Gatekeeper assessment..." +spctl --assess --type execute --verbose "$APP_PATH" -echo "Attach staple" -xcrun stapler staple "$APP_PATH" +echo "Done! App is signed, notarized, and stapled." From 385efed8f885bb3d034cce77562580be5c2cce57 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Wed, 22 Apr 2026 23:55:35 +0800 Subject: [PATCH 47/51] add easter egg for orvar --- src/app/tile/update.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 1684476d..f9dd999b 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -1008,6 +1008,19 @@ fn execute_query(tile: &mut Tile, id: Id) -> Task { }]; return single_item_resize_task(id); } + "zombo" => { + tile.results = vec![App { + ranking: 0, + open_command: AppCommand::Function(Function::OpenWebsite( + "https://zombo.com".to_string(), + )), + desc: "Easter Egg".to_string(), + icons: None, + display_name: "🫳 🌱".to_string(), + search_name: "".to_string(), + }]; + return single_item_resize_task(id); + } "lemon" => { tile.results = vec![App { ranking: 0, From 63bff8546cae43c25d8c3434f0591f0afc7f635f Mon Sep 17 00:00:00 2001 From: unsecretised Date: Fri, 24 Apr 2026 14:34:24 +0800 Subject: [PATCH 48/51] fix: resizing of window when using unit conversion --- src/app/tile/update.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index f9dd999b..7f1ba3fc 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -1132,7 +1132,7 @@ fn execute_query(tile: &mut Tile, id: Id) -> Task { .into_iter() .map(|conversion| conversion.to_app()) .collect(); - return single_item_resize_task(id); + return resize_task(id, tile.results.len() as u32); } else if let Ok(res) = Expr::from_str(&tile.query) { tile.results.push(App { ranking: 0, From a888b2cdcc61c5f6690d05765d0e274600a5bd89 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Fri, 24 Apr 2026 23:19:38 +0800 Subject: [PATCH 49/51] add events opening --- Cargo.lock | 43 +++++++++++++++++-- Cargo.toml | 3 +- src/app.rs | 2 + src/app/apps.rs | 10 +++++ src/app/pages/settings.rs | 28 ++++++++++++- src/app/tile.rs | 12 ++++++ src/app/tile/elm.rs | 4 ++ src/app/tile/update.rs | 16 ++++++++ src/commands.rs | 14 +++++++ src/config.rs | 6 ++- src/platform/macos/events.rs | 80 ++++++++++++++++++++++++++++++++++++ src/platform/macos/mod.rs | 1 + 12 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 src/platform/macos/events.rs diff --git a/Cargo.lock b/Cargo.lock index 59d8cc85..ad2b8da1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2866,7 +2866,7 @@ dependencies = [ "bitflags 2.11.0", "block2 0.5.1", "objc2 0.5.2", - "objc2-core-location", + "objc2-core-location 0.2.2", "objc2-foundation 0.2.2", ] @@ -2975,6 +2975,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -3006,6 +3016,22 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-event-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bc9ad7325642172110196bacd6af64027ec5549ded7fc6589ea03e0f792bf8" +dependencies = [ + "bitflags 2.11.0", + "block2 0.6.2", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-graphics", + "objc2-core-location 0.3.2", + "objc2-foundation 0.3.2", + "objc2-map-kit", +] + [[package]] name = "objc2-foundation" version = "0.2.2" @@ -3055,6 +3081,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-map-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579edede1c244621cd8229b70ea6e20e6ec3bab5a74afdfd494b446b681e1e64" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -3138,7 +3174,7 @@ dependencies = [ "objc2-cloud-kit 0.2.2", "objc2-core-data 0.2.2", "objc2-core-image 0.2.2", - "objc2-core-location", + "objc2-core-location 0.2.2", "objc2-foundation 0.2.2", "objc2-link-presentation", "objc2-quartz-core 0.2.2", @@ -3167,7 +3203,7 @@ dependencies = [ "bitflags 2.11.0", "block2 0.5.1", "objc2 0.5.2", - "objc2-core-location", + "objc2-core-location 0.2.2", "objc2-foundation 0.2.2", ] @@ -3868,6 +3904,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-application-services", "objc2-core-foundation", + "objc2-event-kit", "objc2-foundation 0.3.2", "objc2-service-management", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 7cb8af64..bd6362b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,8 @@ objc2 = "0.6.3" objc2-app-kit = { version = "0.3.2", features = ["NSImage"] } objc2-application-services = { version = "0.3.2", default-features = false, features = ["HIServices", "Processes"] } objc2-core-foundation = "0.3.2" -objc2-foundation = { version = "0.3.2", features = ["NSString"] } +objc2-event-kit = "0.3.2" +objc2-foundation = { version = "0.3.2", features = ["NSDateFormatter", "NSFormatter", "NSString"] } objc2-service-management = "0.3.2" once_cell = "1.21.3" rand = "0.9.2" diff --git a/src/app.rs b/src/app.rs index 6892bdb5..99bd510a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -101,6 +101,7 @@ pub enum Message { OpenFileDialogue(String), ReturnFocus, EscKeyPressed(Id), + UpdateEvents, ClearSearchResults, WindowFocusChanged(Id, bool), ClearSearchQuery, @@ -131,6 +132,7 @@ pub enum SetConfigFields { HapticFeedback(bool), ShowMenubarIcon(bool), SetPage(MainPage), + SetEventDuration(String), Modes(Editable<(String, String)>), Aliases(Editable<(String, String)>), SearchDirs(Editable), diff --git a/src/app/apps.rs b/src/app/apps.rs index 90d615cc..9a176816 100644 --- a/src/app/apps.rs +++ b/src/app/apps.rs @@ -59,6 +59,16 @@ impl PartialEq for App { } impl App { + pub fn new(name: String, icon: Option, desc: String, command: AppCommand) -> Self { + Self { + ranking: 0, + open_command: command, + icons: icon, + search_name: name.to_lowercase(), + display_name: name, + desc, + } + } /// A vec of all the emojis as App structs pub fn emoji_apps() -> Vec { emojis::iter() diff --git a/src/app/pages/settings.rs b/src/app/pages/settings.rs index 2c59db10..a0b77d26 100644 --- a/src/app/pages/settings.rs +++ b/src/app/pages/settings.rs @@ -175,7 +175,7 @@ pub fn settings_page(config: Config) -> Element<'static, Message> { }) .into(), radio( - "Frequently Used", + "Frequents", MainPage::FrequentlyUsed, Some(config.main_page), |page| Message::SetConfig(SetConfigFields::SetPage(page)), @@ -185,6 +185,14 @@ pub fn settings_page(config: Config) -> Element<'static, Message> { move |_, _| settings_radio_button_style(&theme_clone.clone()) }) .into(), + radio("Events", MainPage::Events, Some(config.main_page), |page| { + Message::SetConfig(SetConfigFields::SetPage(page)) + }) + .style({ + let theme_clone = theme_clone.clone(); + move |_, _| settings_radio_button_style(&theme_clone.clone()) + }) + .into(), radio("Nothing", MainPage::Blank, Some(config.main_page), |page| { Message::SetConfig(SetConfigFields::SetPage(page)) }) @@ -277,6 +285,23 @@ pub fn settings_page(config: Config) -> Element<'static, Message> { notice_item(theme.clone(), "What font rustcast should use"), ]); + let theme_clone = theme.clone(); + let event_duration = settings_item_column([ + settings_hint_text(theme.clone(), "Set Event duration"), + text_input("Event duration", &config.event_duration.to_string()) + .on_input(move |input: String| { + Message::SetConfig(SetConfigFields::SetEventDuration(input)) + }) + .on_submit(Message::WriteConfig(false)) + .width(Length::Fill) + .style(move |_, _| settings_text_input_item_style(&theme_clone)) + .into(), + notice_item( + theme.clone(), + "How many minutes from now the events should be displayed", + ), + ]); + let theme_clone = theme.clone(); let theme_clone_1 = theme.clone(); let theme_clone_2 = theme.clone(); @@ -431,6 +456,7 @@ pub fn settings_page(config: Config) -> Element<'static, Message> { clear_on_enter.into(), show_icons.into(), font_family.into(), + event_duration.into(), text_clr.into(), bg_clr.into(), settings_hint_text(theme.clone(), "Aliases"), diff --git a/src/app/tile.rs b/src/app/tile.rs index 7b131bee..62759d10 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -8,6 +8,7 @@ use crate::clipboard::ClipBoardContentType; use crate::config::{Config, Shelly}; use crate::debounce::Debouncer; use crate::platform::default_app_paths; +use crate::platform::macos::events::Event; use crate::platform::macos::launching::Shortcut; use arboard::Clipboard; @@ -173,6 +174,7 @@ pub struct Tile { emoji_apps: AppIndex, visible: bool, focused: bool, + pub events: Vec, frontmost: Option>, pub config: Config, hotkeys: Hotkeys, @@ -233,6 +235,7 @@ impl Tile { keyboard, Subscription::run(crate::platform::macos::urlscheme::url_stream), Subscription::run(handle_recipient), + Subscription::run(reload_events), Subscription::run(handle_version_and_rankings), Subscription::run(handle_clipboard_history), Subscription::run(handle_file_search), @@ -595,6 +598,15 @@ fn handle_recipient() -> impl futures::Stream { }) } +fn reload_events() -> impl futures::Stream { + stream::channel(100, async |mut output| { + loop { + output.send(Message::UpdateEvents).await.ok(); + tokio::time::sleep(Duration::from_mins(2)).await; + } + }) +} + fn handle_version_and_rankings() -> impl futures::Stream { stream::channel(100, async |mut output| { let current_version = format!("\"{}\"", option_env!("APP_VERSION").unwrap_or("")); diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 7fbe2ab9..0e1db6b2 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -22,6 +22,7 @@ use crate::app::tile::{AppIndex, Hotkeys}; use crate::app::{DEFAULT_WINDOW_HEIGHT, ToApp, ToApps}; use crate::config::Theme; use crate::debounce::Debouncer; +use crate::platform::macos::events::Event; use crate::styles::{ contents_style, glass_border, glass_surface, results_scrollbar_style, rustcast_text_input_style, }; @@ -38,6 +39,8 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { let (id, open) = window::open(default_settings()); info!("Opening window"); + let events = Event::get_events(config.event_duration); + let open = open.discard().chain(window::run(id, |handle| { platform::window_config(&handle.window_handle().expect("Unable to get window handle")); transform_process_to_ui_element(); @@ -78,6 +81,7 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { results: vec![], options, hotkeys, + events, emoji_apps: AppIndex::from_apps(App::emoji_apps()), visible: true, frontmost: None, diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index f9dd999b..41c1db1d 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -36,6 +36,7 @@ use crate::commands::Function; use crate::config::Config; use crate::config::MainPage; use crate::debounce::DebouncePolicy; +use crate::platform::macos::events::Event; use crate::platform::macos::launching::Shortcut; use crate::platform::macos::launching::global_handler; use crate::platform::macos::{start_at_login, stop_at_login}; @@ -71,6 +72,11 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } } + Message::UpdateEvents => { + tile.events = Event::get_events(tile.config.event_duration); + Task::none() + } + Message::UriReceived(uri) => { let Ok(url) = Url::parse(&uri) else { return Task::none(); @@ -706,6 +712,15 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { SetConfigFields::Modes(Editable::Create((key, value))) => { final_config.modes.insert(key, value); } + SetConfigFields::SetEventDuration(duration) => { + if duration.trim().is_empty() { + final_config.event_duration = 0; + } else if let Ok(duration) = duration.parse::() { + final_config.event_duration = duration; + } + + tile.events = Event::get_events(final_config.event_duration); + } SetConfigFields::Modes(Editable::Delete((key, _))) => { final_config.modes.remove(&key); } @@ -976,6 +991,7 @@ fn execute_query(tile: &mut Tile, id: Id) -> Task { if tile.page == Page::Main && tile.query_lc.is_empty() { tile.results = match tile.config.main_page { MainPage::FrequentlyUsed => tile.frequent_results(), + MainPage::Events => tile.events.iter().map(|x| x.to_app()).collect(), MainPage::Blank => vec![], MainPage::Favourites => tile.options.get_favourites(), }; diff --git a/src/commands.rs b/src/commands.rs index a4d6d29a..285ef368 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -19,6 +19,7 @@ use crate::{ pub enum Function { OpenApp(String), QuitApp(String), + OpenRawUrl(String), QuitAllApps, RunShellCommand(String), OpenWebsite(String), @@ -41,6 +42,19 @@ impl Function { )); }); } + + Function::OpenRawUrl(url) => { + let url = url.to_owned(); + thread::spawn(move || { + NSWorkspace::new().openURL( + &NSURL::URLWithString_relativeToURL( + &objc2_foundation::NSString::from_str(&url), + None, + ) + .unwrap(), + ); + }); + } Function::RunShellCommand(command) => { Command::new("sh").arg("-c").arg(command).spawn().ok(); } diff --git a/src/config.rs b/src/config.rs index 41de2496..7aa46f52 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,7 @@ pub struct Config { pub toggle_hotkey: String, pub clipboard_hotkey: String, pub buffer_rules: Buffer, + pub event_duration: u32, pub main_page: MainPage, pub start_at_login: bool, pub theme: Theme, @@ -45,6 +46,7 @@ impl Default for Config { buffer_rules: Buffer::default(), theme: Theme::default(), start_at_login: true, + event_duration: 60, placeholder: String::from("Time to be productive!"), search_url: "https://duckduckgo.com/search?q=%s".to_string(), cbhist: true, @@ -66,6 +68,7 @@ impl Default for Config { pub enum MainPage { Favourites, FrequentlyUsed, + Events, #[default] Blank, } @@ -73,9 +76,10 @@ pub enum MainPage { impl std::fmt::Display for MainPage { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { - MainPage::Blank => "♥️ Rustcast", + MainPage::Blank => "Rustcast", MainPage::Favourites => "Favourites", MainPage::FrequentlyUsed => "Frequently Used", + MainPage::Events => "Events", }) } } diff --git a/src/platform/macos/events.rs b/src/platform/macos/events.rs new file mode 100644 index 00000000..a91373b3 --- /dev/null +++ b/src/platform/macos/events.rs @@ -0,0 +1,80 @@ +use block2::RcBlock; +use objc2::runtime::Bool; +use objc2_event_kit::{EKEntityType, EKEventStore}; +use objc2_foundation::{NSDate, NSDateFormatter, NSDateFormatterStyle, NSError}; + +use crate::{ + app::{ + ToApp, + apps::{App, AppCommand, ICNS_ICON}, + }, + commands::Function, + utils::icns_data_to_handle, +}; + +#[derive(Debug, Clone, PartialEq)] +pub struct Event { + pub event_name: String, + pub event_url: Option, + pub time: String, +} + +impl ToApp for Event { + fn to_app(&self) -> App { + let icons = icns_data_to_handle(ICNS_ICON.to_vec()); + let appcmd = if let Some(url) = &self.event_url { + AppCommand::Function(Function::OpenRawUrl(url.clone())) + } else { + AppCommand::Display + }; + App::new(self.event_name.clone(), icons, self.time.clone(), appcmd) + } +} + +impl Event { + pub fn get_events(duration_in_min: u32) -> Vec { + unsafe { + let store = EKEventStore::new(); + + let (tx, rx) = std::sync::mpsc::channel::<()>(); + + let block = block2::RcBlock::new(move |_: Bool, _: *mut NSError| { + let _ = tx.send(()); + }); + + store.requestFullAccessToEventsWithCompletion(RcBlock::< + dyn std::ops::Fn(objc2::runtime::Bool, *mut NSError), + >::as_ptr(&block)); + + rx.recv().unwrap(); + + let start = NSDate::now(); + let end = NSDate::dateWithTimeIntervalSinceNow((duration_in_min * 60) as f64); + + let calendars = store.calendarsForEntityType(EKEntityType::Event); + + let predicate = store.predicateForEventsWithStartDate_endDate_calendars( + &start, + &end, + Some(&calendars), + ); + + let formatter = NSDateFormatter::new(); + + formatter.setDateStyle(NSDateFormatterStyle::MediumStyle); + formatter.setTimeStyle(NSDateFormatterStyle::ShortStyle); + + store + .eventsMatchingPredicate(&predicate) + .iter() + .map(|x| Event { + event_name: x.title().to_string(), + event_url: x + .URL() + .and_then(|url| url.absoluteString().map(|x| x.to_string())), + time: formatter.stringFromDate(&x.startDate()).to_string(), + }) + .collect() + } + } +} diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index 4a6a340e..ff8cc138 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -1,6 +1,7 @@ //! Macos specific logic, such as window settings, etc. pub mod accessibility; pub mod discovery; +pub mod events; pub mod haptics; pub mod launching; pub mod urlscheme; From 1093f6d2f3abcc4831c188d45e5c3cb9902d8aad Mon Sep 17 00:00:00 2001 From: unsecretised Date: Sat, 25 Apr 2026 02:26:25 +0800 Subject: [PATCH 50/51] add auto updating --- Cargo.lock | 547 +++++++++++++++++++++++++++++++++++++- Cargo.toml | 4 + src/app.rs | 1 + src/app/pages/settings.rs | 14 + src/app/tile.rs | 43 +-- src/app/tile/update.rs | 12 + src/autoupdate.rs | 222 ++++++++++++++++ src/config.rs | 2 + src/main.rs | 1 + 9 files changed, 805 insertions(+), 41 deletions(-) create mode 100644 src/autoupdate.rs diff --git a/Cargo.lock b/Cargo.lock index 59d8cc85..63fbcaf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,6 +30,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.8.12" @@ -454,6 +465,24 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -535,6 +564,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -651,6 +689,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -726,6 +774,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation" version = "0.9.4" @@ -829,6 +889,24 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -891,6 +969,25 @@ dependencies = [ "wgpu", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctor-lite" version = "0.1.2" @@ -913,6 +1010,43 @@ dependencies = [ "byteorder", ] +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1191,6 +1325,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide 0.8.9", + "zlib-rs", ] [[package]] @@ -1432,6 +1567,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1461,10 +1606,25 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + [[package]] name = "gif" version = "0.14.1" @@ -1806,6 +1966,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + [[package]] name = "iced" version = "0.14.0" @@ -2085,6 +2263,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -2154,6 +2338,17 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", ] [[package]] @@ -2277,6 +2472,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lebe" version = "0.5.3" @@ -2307,6 +2508,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" + [[package]] name = "libc" version = "0.2.182" @@ -2448,6 +2655,15 @@ version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +[[package]] +name = "lzma-rust2" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" +dependencies = [ + "sha2 0.10.9", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2714,6 +2930,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-derive" version = "0.4.2" @@ -3287,6 +3509,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3442,6 +3674,18 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3457,6 +3701,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3586,6 +3840,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.9.2" @@ -3858,6 +4118,7 @@ dependencies = [ "crossbeam-channel", "emojis", "global-hotkey", + "hex", "iced", "icns", "image", @@ -3876,11 +4137,14 @@ dependencies = [ "rfd", "serde", "serde_json", + "sha2 0.11.0", + "tempfile", "tokio", "toml 0.9.12+spec-1.1.0", "tracing-subscriber", "tray-icon", "url", + "zip", ] [[package]] @@ -4065,6 +4329,39 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -4282,6 +4579,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svg_fmt" version = "0.4.5" @@ -4444,6 +4747,26 @@ dependencies = [ "zune-jpeg 0.4.21", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "js-sys", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tiny-skia" version = "0.11.4" @@ -4723,6 +5046,18 @@ dependencies = [ "core_maths", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "uds_windows" version = "1.2.1" @@ -4770,6 +5105,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -4858,6 +5199,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.113" @@ -4917,6 +5267,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "wasmtimer" version = "0.4.3" @@ -5774,6 +6158,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -5997,6 +6463,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" @@ -6030,12 +6502,85 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "8.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59" +dependencies = [ + "aes", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.4.2", + "hmac", + "indexmap", + "lzma-rust2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 7cb8af64..367e842a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ block2 = "0.6.2" crossbeam-channel = "0.5.15" emojis = "0.8.0" global-hotkey = "0.7.0" +hex = "0.4.3" iced = { version = "0.14.0", features = ["image", "tokio"] } icns = "0.3.1" image = { version = "0.25.9", features = ["tiff"] } @@ -31,8 +32,11 @@ rayon = "1.11.0" rfd = "0.17.2" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +sha2 = "0.11.0" +tempfile = "3.27.0" tokio = { version = "1.48.0", features = ["full"] } toml = "0.9.8" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } tray-icon = "0.21.3" url = { version = "2.5.8", default-features = false } +zip = "8.5.1" diff --git a/src/app.rs b/src/app.rs index 6892bdb5..9f683f06 100644 --- a/src/app.rs +++ b/src/app.rs @@ -128,6 +128,7 @@ pub enum SetConfigFields { PlaceHolder(String), SearchUrl(String), ClipboardHistory(bool), + SetAutoUpdate(bool), HapticFeedback(bool), ShowMenubarIcon(bool), SetPage(MainPage), diff --git a/src/app/pages/settings.rs b/src/app/pages/settings.rs index 2c59db10..01d81493 100644 --- a/src/app/pages/settings.rs +++ b/src/app/pages/settings.rs @@ -129,6 +129,19 @@ pub fn settings_page(config: Config) -> Element<'static, Message> { notice_item(theme.clone(), "If you want rustcast to start on login"), ]); + let theme_clone = theme.clone(); + let auto_update = settings_item_row([ + settings_hint_text(theme.clone(), "Auto update"), + checkbox(config.clone().auto_update) + .style(move |_, _| settings_checkbox_style(&theme_clone)) + .on_toggle(move |input| Message::SetConfig(SetConfigFields::SetAutoUpdate(input))) + .into(), + notice_item( + theme.clone(), + "If rustcast should automatically update itself", + ), + ]); + let theme_clone = theme.clone(); let haptic = Row::from_iter([ settings_hint_text(theme.clone(), "Haptic feedback"), @@ -422,6 +435,7 @@ pub fn settings_page(config: Config) -> Element<'static, Message> { search.into(), debounce.into(), start_at_login.into(), + auto_update.into(), haptic.into(), tray_icon.into(), clipboard_history.into(), diff --git a/src/app/tile.rs b/src/app/tile.rs index 7b131bee..ce248e30 100644 --- a/src/app/tile.rs +++ b/src/app/tile.rs @@ -4,6 +4,7 @@ pub mod update; use crate::app::apps::App; use crate::app::{ArrowKey, Message, Move, Page}; +use crate::autoupdate::new_version_available; use crate::clipboard::ClipBoardContentType; use crate::config::{Config, Shelly}; use crate::debounce::Debouncer; @@ -32,7 +33,6 @@ use tray_icon::TrayIcon; use std::collections::HashMap; use std::fmt::Debug; -use std::str::FromStr; use std::time::Duration; /// This is a wrapper around the sender to disable dropping @@ -597,46 +597,9 @@ fn handle_recipient() -> impl futures::Stream { fn handle_version_and_rankings() -> impl futures::Stream { stream::channel(100, async |mut output| { - let current_version = format!("\"{}\"", option_env!("APP_VERSION").unwrap_or("")); - - if current_version.is_empty() { - println!("empty version"); - return; - } - - let req = minreq::Request::new( - minreq::Method::Get, - "https://api.github.com/repos/RustCastLabs/rustcast/releases/latest", - ) - .with_header("User-Agent", "rustcast-update-checker") - .with_header("Accept", "application/vnd.github+json") - .with_header("X-GitHub-Api-Version", "2022-11-28"); - loop { - let resp = req - .clone() - .send() - .and_then(|x| x.as_str().map(serde_json::Value::from_str)); - - info!("Made a req for latest version"); - - if let Ok(Ok(val)) = resp { - let new_ver = val - .get("name") - .map(|x| x.to_string()) - .unwrap_or("".to_string()); - - // new_ver is in the format "\"v0.0.0\"" - // note that it is encapsulated in double quotes - if new_ver.trim() != current_version - && !new_ver.is_empty() - && new_ver.starts_with("\"v") - { - info!("new version available: {new_ver}"); - output.send(Message::UpdateAvailable).await.ok(); - } - } else { - warn!("Error getting resp"); + if new_version_available().is_some() { + output.send(Message::UpdateAvailable).await.ok(); } tokio::time::sleep(Duration::from_secs(30)).await; output.send(Message::SaveRanking).await.ok(); diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index f9dd999b..7b3407ba 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -31,6 +31,8 @@ use crate::app::menubar::menu_builder; use crate::app::menubar::menu_icon; use crate::app::tile::AppIndex; use crate::app::{Message, Page, tile::Tile}; +use crate::autoupdate::download_latest_app; +use crate::autoupdate::relaunch_app; use crate::calculator::Expr; use crate::commands::Function; use crate::config::Config; @@ -96,6 +98,13 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { Message::UpdateAvailable => { tile.update_available = true; + + if tile.config.auto_update { + thread::spawn(|| { + download_latest_app().ok(); + relaunch_app(); + }); + } Task::done(Message::ReloadConfig) } @@ -796,6 +805,9 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { SetConfigFields::HapticFeedback(haptic_feedback) => { final_config.haptic_feedback = haptic_feedback } + SetConfigFields::SetAutoUpdate(au) => { + final_config.auto_update = au; + } SetConfigFields::ShowMenubarIcon(show) => final_config.show_trayicon = show, SetConfigFields::SetThemeFields(SetConfigThemeFields::Font(fnt)) => { final_config.theme.font = Some(fnt) diff --git a/src/autoupdate.rs b/src/autoupdate.rs new file mode 100644 index 00000000..de34d7b8 --- /dev/null +++ b/src/autoupdate.rs @@ -0,0 +1,222 @@ +use std::str::FromStr; + +use log::{error, info}; +use sha2::{Digest, Sha256}; + +pub struct ReleaseInfo { + pub version: String, + pub zip_url: String, + pub sha256: String, +} + +pub fn get_latest_release() -> Option { + let req = minreq::Request::new( + minreq::Method::Get, + "https://api.github.com/repos/RustCastLabs/rustcast/releases/latest", + ) + .with_header("User-Agent", "rustcast-update-checker") + .with_header("Accept", "application/vnd.github+json") + .with_header("X-GitHub-Api-Version", "2022-11-28"); + + let resp = req + .send() + .and_then(|x| x.as_str().map(serde_json::Value::from_str)); + + if let Ok(Ok(val)) = resp { + let version = val.get("name")?.as_str()?.to_string(); + + let assets = val.get("assets")?.as_array()?; + + let mut zip_url = None; + let mut sha256 = None; + + for asset in assets { + let name = asset.get("name")?.as_str()?; + let url = asset.get("browser_download_url")?.as_str()?.to_string(); + + if name == "Rustcast-universal-macos.app.zip" { + zip_url = Some(url); + + sha256 = asset + .get("digest") + .and_then(|d| d.as_str()) + .and_then(|d| d.strip_prefix("sha256:")) + .map(|d| d.to_string()); + } + } + + Some(ReleaseInfo { + version, + zip_url: zip_url?, + sha256: sha256?, + }) + } else { + None + } +} + +pub fn new_version_available() -> Option { + info!("Checking for new version"); + let info = get_latest_release()?; + info!("Got latest info"); + let current = option_env!("APP_VERSION").unwrap_or(""); + + if info.version != current { + Some(info) + } else { + None + } +} + +pub fn verify_sha256(file_path: &std::path::Path, expected_hex: &str) -> std::io::Result { + let bytes = std::fs::read(file_path)?; + let digest = Sha256::digest(&bytes); + let actual_hex = hex::encode(digest); + Ok(actual_hex == expected_hex) +} + +pub fn download_latest_app() -> Result { + let info = get_latest_release().ok_or_else(|| { + error!("Could not get latest release info"); + })?; + + info!("got latest release"); + + let tmp = tempfile::tempdir().map_err(|e| { + error!("Could not create temporary directory: {e}"); + })?; + + info!("created temp dir"); + + let zip_path = tmp.path().join("Rustcast-universal-macos.app.zip"); + + info!("zip path: {:?}", zip_path); + let resp = minreq::get(&info.zip_url) + .with_header("User-Agent", "rustcast-update-checker") + .send() + .map_err(|e| { + error!("Could not download update: {e}"); + })?; + + info!("downloaded zip"); + + std::fs::write(&zip_path, resp.as_bytes()).map_err(|e| { + error!("Could not write zip to disk: {e}"); + })?; + + info!("wrote zip to disk"); + + let ok = verify_sha256(&zip_path, &info.sha256).map_err(|e| { + error!("Could not verify sha256: {e}"); + })?; + + info!("verified sha256"); + + if !ok { + error!("SHA256 mismatch — aborting update"); + return Err(()); + } + + let zip_file = std::fs::File::open(&zip_path).map_err(|e| { + error!("Could not open zip: {e}"); + })?; + + info!("opened zip"); + + let mut archive = zip::ZipArchive::new(zip_file).map_err(|e| { + error!("Could not read zip archive: {e}"); + })?; + + info!("read zip archive. contents:"); + + archive.extract(tmp.path()).map_err(|e| { + error!("Could not extract zip: {e}"); + })?; + + if let Ok(entries) = std::fs::read_dir(tmp.path()) { + for entry in entries.flatten() { + info!(" extracted entry: {:?}", entry.file_name()); + } + } + + let extracted_app = tmp.path().join("target/release/macos/Rustcast.app"); + + info!("found extracted app at: {:?}", extracted_app); + + let dest = get_app_path().ok_or_else(|| { + error!("Could not determine current app path"); + })?; + + info!("Installing update over {:?}", dest); + + if dest.exists() { + std::fs::remove_dir_all(&dest).map_err(|e| { + error!("Could not remove existing app: {e}"); + })?; + } + + move_or_copy(&extracted_app, &dest).map_err(|e| { + error!("Could not move app into place: {e}"); + })?; + + info!("Successful update"); + + Ok(dest) +} + +fn move_or_copy(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + match std::fs::rename(src, dst) { + Ok(()) => Ok(()), + Err(_) => { + copy_dir_recursive(src, dst)?; + std::fs::remove_dir_all(src) + } + } +} + +fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let dst_path = dst.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir_recursive(&entry.path(), &dst_path)?; + } else { + std::fs::copy(entry.path(), dst_path)?; + } + } + Ok(()) +} + +pub fn relaunch_app() { + let app_path = match get_app_path() { + Some(p) => p, + None => { + error!("Could not determine current app path for relaunch"); + return; + } + }; + + match std::process::Command::new("open").arg(&app_path).spawn() { + Ok(_) => { + info!("Relaunching app at {:?}", app_path); + std::thread::sleep(std::time::Duration::from_millis(500)); + std::process::exit(0); + } + Err(e) => { + error!("Could not relaunch app: {e}"); + } + } +} + +pub fn get_app_path() -> Option { + let exe = std::env::current_exe().ok()?; + + let mut path = exe.as_path(); + loop { + if path.extension().and_then(|e| e.to_str()) == Some("app") { + return Some(path.to_path_buf()); + } + path = path.parent()?; + } +} diff --git a/src/config.rs b/src/config.rs index 41de2496..d038e093 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,6 +34,7 @@ pub struct Config { pub search_dirs: Vec, pub log_path: String, pub debounce_delay: u64, + pub auto_update: bool, } impl Default for Config { @@ -49,6 +50,7 @@ impl Default for Config { search_url: "https://duckduckgo.com/search?q=%s".to_string(), cbhist: true, haptic_feedback: false, + auto_update: true, show_trayicon: true, main_page: MainPage::default(), search_dirs: vec!["~".to_string()], diff --git a/src/main.rs b/src/main.rs index 82c88636..c10192cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ #![deny(clippy::dbg_macro)] mod app; +mod autoupdate; mod calculator; mod clipboard; mod commands; From d814b5741a42df65f1d340f1eed41659d086d276 Mon Sep 17 00:00:00 2001 From: unsecretised Date: Mon, 27 Apr 2026 02:48:39 +0800 Subject: [PATCH 51/51] Update stats --- docs/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.html b/docs/index.html index f1b56293..997fce82 100644 --- a/docs/index.html +++ b/docs/index.html @@ -75,8 +75,8 @@

    The fastest launcher
    you'll ever use

    -
    550+ stars
    -
    1.9k+ downloads
    +
    590+ stars
    +
    2.3k+ downloads
    100% free forever
    MIT license