Skip to content

Instantly share code, notes, and snippets.

@dsebastien
Created October 27, 2025 09:22
Show Gist options
  • Select an option

  • Save dsebastien/539126349f0ba0d4633f2b16bfa4ed2d to your computer and use it in GitHub Desktop.

Select an option

Save dsebastien/539126349f0ba0d4633f2b16bfa4ed2d to your computer and use it in GitHub Desktop.
use crate::audio_feedback::{play_feedback_sound, SoundType};
use crate::managers::audio::AudioRecordingManager;
use crate::managers::transcription::TranscriptionManager;
use crate::overlay::{show_recording_overlay, show_transcribing_overlay};
use crate::settings::get_settings;
use crate::time_utils::format_timestamp;
use crate::tray::{change_tray_icon, TrayIconState};
use crate::utils;
use crate::ManagedToggleState;
use log::debug;
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tauri::AppHandle;
use tauri::Manager;
// Shortcut Action Trait
pub trait ShortcutAction: Send + Sync {
fn start(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str);
fn stop(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str);
}
// Transcribe Action
struct TranscribeAction;
impl ShortcutAction for TranscribeAction {
fn start(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) {
let start_time = Instant::now();
eprintln!("{} [ACTION] TranscribeAction::start called for binding: {}", format_timestamp(), binding_id);
// Load model in the background
let tm = app.state::<Arc<TranscriptionManager>>();
tm.initiate_model_load();
let binding_id = binding_id.to_string();
change_tray_icon(app, TrayIconState::Recording);
show_recording_overlay(app);
let rm = app.state::<Arc<AudioRecordingManager>>();
// Get the microphone mode to determine audio feedback timing
let settings = get_settings(app);
let is_always_on = settings.always_on_microphone;
eprintln!("{} [ACTION] Microphone mode - always_on: {}", format_timestamp(), is_always_on);
let recording_started = if is_always_on {
// Always-on mode: Play audio feedback immediately
eprintln!("{} [ACTION] Always-on mode: Playing audio feedback immediately", format_timestamp());
play_feedback_sound(app, SoundType::Start);
let started = rm.try_start_recording(&binding_id);
eprintln!("{} [ACTION] Recording started: {}", format_timestamp(), started);
started
} else {
// On-demand mode: Start recording first, then play audio feedback
// This allows the microphone to be activated before playing the sound
eprintln!("{} [ACTION] On-demand mode: Starting recording first, then audio feedback", format_timestamp());
let recording_start_time = Instant::now();
let started = rm.try_start_recording(&binding_id);
if started {
eprintln!("{} [ACTION] Recording started in {:?}", format_timestamp(), recording_start_time.elapsed());
// Small delay to ensure microphone stream is active
let app_clone = app.clone();
let ts = format_timestamp();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(100));
eprintln!("{} [ACTION] Playing delayed audio feedback", ts);
play_feedback_sound(&app_clone, SoundType::Start);
});
} else {
eprintln!("{} [ACTION] Failed to start recording", format_timestamp());
}
started
};
// Clear busy flag for toggle mode (PTT mode doesn't set it on start)
let settings = get_settings(app);
if !settings.push_to_talk {
// Toggle mode: clear busy flag after start completes
if let Some(toggle_state) = app.try_state::<ManagedToggleState>() {
if let Ok(mut states) = toggle_state.lock() {
states.busy_states.insert(binding_id.clone(), false);
debug!("Busy flag cleared for binding: {} (toggle mode)", binding_id);
}
}
}
if !recording_started {
// If recording failed to start, reset UI state and clear busy flag
utils::hide_recording_overlay(app);
change_tray_icon(app, TrayIconState::Idle);
// Always clear busy flag on failure, regardless of mode
if let Some(toggle_state) = app.try_state::<ManagedToggleState>() {
if let Ok(mut states) = toggle_state.lock() {
states.busy_states.insert(binding_id.clone(), false);
debug!("Busy flag cleared due to failed start for binding: {}", binding_id);
}
}
}
debug!(
"TranscribeAction::start completed in {:?}",
start_time.elapsed()
);
}
fn stop(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) {
let stop_time = Instant::now();
eprintln!("{} [ACTION] TranscribeAction::stop called for binding: {}", format_timestamp(), binding_id);
let ah = app.clone();
let rm = Arc::clone(&app.state::<Arc<AudioRecordingManager>>());
let tm = Arc::clone(&app.state::<Arc<TranscriptionManager>>());
change_tray_icon(app, TrayIconState::Transcribing);
show_transcribing_overlay(app);
// Play audio feedback for recording stop
play_feedback_sound(app, SoundType::Stop);
let binding_id = binding_id.to_string(); // Clone binding_id for the async task
tauri::async_runtime::spawn(async move {
let binding_id_clone = binding_id.clone(); // Clone for the inner async task
eprintln!(
"{} [ACTION] Starting async transcription task for binding: {}",
format_timestamp(), binding_id_clone
);
// Helper to clear busy flag
let clear_busy_flag = || {
if let Some(toggle_state) = ah.try_state::<ManagedToggleState>() {
if let Ok(mut states) = toggle_state.lock() {
states.busy_states.insert(binding_id.clone(), false);
eprintln!("{} [ACTION] Busy flag cleared for binding: {}", format_timestamp(), binding_id);
}
}
};
let stop_recording_time = Instant::now();
if let Some(samples) = rm.stop_recording(&binding_id_clone) {
eprintln!(
"{} [ACTION] Recording stopped and samples retrieved in {:?}, sample count: {}",
format_timestamp(),
stop_recording_time.elapsed(),
samples.len()
);
let transcription_time = Instant::now();
// Note: We don't clone samples for history since HistoryManager isn't implemented yet
match tm.transcribe(samples) {
Ok(transcription) => {
eprintln!(
"{} [ACTION] Transcription completed in {:?}: '{}'",
format_timestamp(),
transcription_time.elapsed(),
transcription
);
if !transcription.is_empty() {
// TODO: Save to history when HistoryManager is implemented
// let hm = Arc::clone(&app.state::<Arc<HistoryManager>>());
// if let Err(e) = hm.save_transcription(samples_clone, transcription_for_history).await {
// error!("Failed to save transcription to history: {}", e);
// }
let transcription_clone = transcription.clone();
let ah_clone = ah.clone();
let paste_time = Instant::now();
ah.run_on_main_thread(move || {
match utils::paste(transcription_clone, ah_clone.clone()) {
Ok(()) => debug!(
"Text pasted successfully in {:?}",
paste_time.elapsed()
),
Err(e) => eprintln!("Failed to paste transcription: {}", e),
}
// Hide the overlay after transcription is complete
utils::hide_recording_overlay(&ah_clone);
change_tray_icon(&ah_clone, TrayIconState::Idle);
})
.unwrap_or_else(|e| {
eprintln!("Failed to run paste on main thread: {:?}", e);
utils::hide_recording_overlay(&ah);
change_tray_icon(&ah, TrayIconState::Idle);
});
} else {
utils::hide_recording_overlay(&ah);
change_tray_icon(&ah, TrayIconState::Idle);
}
}
Err(err) => {
debug!("Global Shortcut Transcription error: {}", err);
utils::hide_recording_overlay(&ah);
change_tray_icon(&ah, TrayIconState::Idle);
}
}
} else {
debug!("No samples retrieved from recording stop");
utils::hide_recording_overlay(&ah);
change_tray_icon(&ah, TrayIconState::Idle);
}
// Clear busy flag after all operations complete
clear_busy_flag();
});
debug!(
"TranscribeAction::stop completed in {:?}",
stop_time.elapsed()
);
}
}
// Test Action
struct TestAction;
impl ShortcutAction for TestAction {
fn start(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str) {
println!(
"Shortcut ID '{}': Started - {} (App: {})",
binding_id,
shortcut_str,
app.package_info().name
);
}
fn stop(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str) {
println!(
"Shortcut ID '{}': Stopped - {} (App: {})",
binding_id,
shortcut_str,
app.package_info().name
);
}
}
// Static Action Map
pub static ACTION_MAP: Lazy<HashMap<String, Arc<dyn ShortcutAction>>> = Lazy::new(|| {
let mut map = HashMap::new();
map.insert(
"transcribe".to_string(),
Arc::new(TranscribeAction) as Arc<dyn ShortcutAction>,
);
map.insert(
"test".to_string(),
Arc::new(TestAction) as Arc<dyn ShortcutAction>,
);
map
});
//! Hyprland native shortcuts implementation using hyprctl
//!
//! This module provides global shortcut functionality specifically for Hyprland
//! by directly managing keybinds via the `hyprctl` command. This works around
//! limitations in xdg-desktop-portal-hyprland's GlobalShortcuts implementation.
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use tauri::AppHandle;
use tokio::sync::Mutex;
use crate::settings::ShortcutBinding;
/// Manages Hyprland shortcuts via hyprctl keyword bind
pub struct HyprlandShortcutManager {
app: AppHandle,
shortcuts: Arc<Mutex<HashMap<String, ShortcutBinding>>>,
app_path: PathBuf,
}
impl HyprlandShortcutManager {
/// Create a new Hyprland shortcut manager
pub fn new(app: AppHandle) -> Result<Self> {
// Get the path to the current executable
let app_path = std::env::current_exe()
.context("Failed to get current executable path")?;
Ok(Self {
app,
shortcuts: Arc::new(Mutex::new(HashMap::new())),
app_path,
})
}
/// Check if Hyprland is running
pub fn is_hyprland_running() -> bool {
std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok()
}
/// Convert shortcut string to hyprctl format
///
/// Our format: "ctrl+shift+g"
/// hyprctl format: "CTRL SHIFT, G" (space-separated modifiers, comma before key)
fn convert_to_hyprctl_format(shortcut: &str) -> Result<String> {
eprintln!(" [convert_to_hyprctl_format] Input: '{}'", shortcut);
let parts: Vec<&str> = shortcut.split('+').collect();
eprintln!(" [convert_to_hyprctl_format] Parts: {:?}", parts);
if parts.is_empty() {
return Err(anyhow::anyhow!("Empty shortcut string"));
}
let mut modifiers = Vec::new();
let mut key_str = String::new();
for (idx, part) in parts.iter().enumerate() {
let part_lower = part.to_lowercase();
eprintln!(" [convert_to_hyprctl_format] Processing part {} of {}: '{}' (lowercase: '{}')",
idx, parts.len() - 1, part, part_lower);
if idx == parts.len() - 1 {
// Last part is the key
key_str = match part_lower.as_str() {
"space" => "space".to_string(),
"return" | "enter" => "return".to_string(),
"tab" => "tab".to_string(),
"backspace" => "backspace".to_string(),
"escape" | "esc" => "escape".to_string(),
other => other.to_string(),
};
eprintln!(" [convert_to_hyprctl_format] Key identified: '{}'", key_str);
} else {
// Earlier parts are modifiers
let modifier = match part_lower.as_str() {
"ctrl" | "control" => "CTRL",
"alt" => "ALT",
"shift" => "SHIFT",
"super" | "meta" | "win" | "cmd" => "SUPER",
other => return Err(anyhow::anyhow!("Unknown modifier: {}", other)),
};
eprintln!(" [convert_to_hyprctl_format] Modifier identified: '{}'", modifier);
modifiers.push(modifier);
}
}
if key_str.is_empty() {
return Err(anyhow::anyhow!("No key specified"));
}
let result = if modifiers.is_empty() {
// Just a key, no modifiers
format!(", {}", key_str)
} else {
// Modifiers + key
format!("{}, {}", modifiers.join(" "), key_str)
};
eprintln!(" [convert_to_hyprctl_format] Output: '{}'", result);
Ok(result)
}
/// Execute hyprctl command
fn exec_hyprctl(args: &[&str]) -> Result<String> {
let output = Command::new("hyprctl")
.args(args)
.output()
.context("Failed to execute hyprctl")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("hyprctl failed: {}", stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
/// Bind a shortcut in Hyprland
///
/// Binds both press and release events to support PTT mode:
/// - bindl (locked bind on press) -> triggers action start
/// - bindr (bind on release) -> triggers action stop
pub async fn bind_shortcut(
&self,
id: &str,
trigger: &str,
description: &str,
) -> Result<()> {
eprintln!("=== Binding Hyprland Shortcut ===");
eprintln!(" ID: {}", id);
eprintln!(" Trigger: {}", trigger);
eprintln!(" Description: {}", description);
// Convert trigger format
let hyprctl_format = Self::convert_to_hyprctl_format(trigger)?;
eprintln!(" hyprctl format: '{}'", hyprctl_format);
// Build the exec commands
let app_path_str = self.app_path.to_str()
.ok_or_else(|| anyhow::anyhow!("Invalid app path"))?;
// Press event (start action)
let exec_cmd_press = format!("{} --trigger-press {}", app_path_str, id);
eprintln!(" Exec command (press): {}", exec_cmd_press);
// Release event (stop action)
let exec_cmd_release = format!("{} --trigger-release {}", app_path_str, id);
eprintln!(" Exec command (release): {}", exec_cmd_release);
// Bind press event (bindl = locked bind, triggers on press even if locked)
let bind_press_cmd = format!("bindl = {}, exec, {}", hyprctl_format, exec_cmd_press);
eprintln!(" Full bind command (press): hyprctl keyword {}", bind_press_cmd);
let output_press = Self::exec_hyprctl(&["keyword", &bind_press_cmd])?;
eprintln!(" hyprctl output (press): {}", output_press.trim());
// Bind release event
let bind_release_cmd = format!("bindr = {}, exec, {}", hyprctl_format, exec_cmd_release);
eprintln!(" Full bind command (release): hyprctl keyword {}", bind_release_cmd);
let output_release = Self::exec_hyprctl(&["keyword", &bind_release_cmd])?;
eprintln!(" hyprctl output (release): {}", output_release.trim());
eprintln!("βœ“ Shortcut bound successfully (press + release)");
Ok(())
}
/// Unbind a shortcut in Hyprland
///
/// Unbinds both press (bindl) and release (bindr) events
pub async fn unbind_shortcut(&self, trigger: &str) -> Result<()> {
eprintln!("=== Unbinding Hyprland Shortcut ===");
eprintln!(" Trigger: {}", trigger);
// Convert trigger format
let hyprctl_format = Self::convert_to_hyprctl_format(trigger)?;
eprintln!(" hyprctl format: '{}'", hyprctl_format);
// Unbind press event (bindl)
let unbind_press_cmd = format!("unbindl = {}", hyprctl_format);
eprintln!(" Full unbind command (press): hyprctl keyword {}", unbind_press_cmd);
let output_press = Self::exec_hyprctl(&["keyword", &unbind_press_cmd])?;
eprintln!(" hyprctl output (press): {}", output_press.trim());
// Unbind release event (bindr)
let unbind_release_cmd = format!("unbindr = {}", hyprctl_format);
eprintln!(" Full unbind command (release): hyprctl keyword {}", unbind_release_cmd);
let output_release = Self::exec_hyprctl(&["keyword", &unbind_release_cmd])?;
eprintln!(" hyprctl output (release): {}", output_release.trim());
eprintln!("βœ“ Shortcut unbound successfully (press + release)");
Ok(())
}
/// Initialize all shortcuts from settings
pub async fn init_shortcuts(&self) -> Result<()> {
eprintln!("=== Hyprland Shortcut Initialization ===");
// Load shortcuts from settings
let settings = crate::settings::load_or_create_app_settings(&self.app);
let bindings: Vec<(String, ShortcutBinding)> = settings.bindings.into_iter().collect();
eprintln!("βœ“ Loaded {} shortcut bindings from settings", bindings.len());
// Store shortcuts
let mut shortcuts_map = self.shortcuts.lock().await;
for (id, binding) in bindings.iter() {
eprintln!(" Binding: {} -> {}", id, binding.current_binding);
shortcuts_map.insert(id.clone(), binding.clone());
}
drop(shortcuts_map);
// Bind each shortcut
for (id, binding) in bindings {
if let Err(e) = self.bind_shortcut(&id, &binding.current_binding, &binding.description).await {
eprintln!("βœ— Failed to bind shortcut '{}': {}", id, e);
eprintln!(" Continuing with remaining shortcuts...");
}
}
eprintln!("=== Hyprland Shortcut Initialization Complete ===");
Ok(())
}
/// Update a shortcut binding
pub async fn update_binding(
&self,
id: &str,
old_trigger: &str,
new_trigger: &str,
description: &str,
) -> Result<()> {
eprintln!("=== Updating Hyprland Shortcut ===");
eprintln!(" ID: {}", id);
eprintln!(" Old trigger: {}", old_trigger);
eprintln!(" New trigger: {}", new_trigger);
// Unbind old shortcut
if let Err(e) = self.unbind_shortcut(old_trigger).await {
eprintln!(" Warning: Failed to unbind old shortcut: {}", e);
eprintln!(" This might be OK if it wasn't bound yet");
}
// Bind new shortcut
self.bind_shortcut(id, new_trigger, description).await?;
// Update local storage
let mut shortcuts_lock = self.shortcuts.lock().await;
if let Some(binding) = shortcuts_lock.get_mut(id) {
binding.current_binding = new_trigger.to_string();
eprintln!("βœ“ Local binding updated");
}
eprintln!("=== Shortcut Update Complete ===");
Ok(())
}
/// Cleanup: unbind all shortcuts
pub async fn cleanup(&self) -> Result<()> {
eprintln!("=== Cleaning up Hyprland shortcuts ===");
let shortcuts_lock = self.shortcuts.lock().await;
for (id, binding) in shortcuts_lock.iter() {
eprintln!(" Unbinding: {}", id);
if let Err(e) = self.unbind_shortcut(&binding.current_binding).await {
eprintln!(" Warning: Failed to unbind '{}': {}", id, e);
}
}
eprintln!("βœ“ Cleanup complete");
Ok(())
}
}
mod actions;
mod audio_feedback;
pub mod audio_toolkit;
mod clipboard;
mod commands;
mod managers;
mod overlay;
mod settings;
mod shortcut;
mod shortcut_protection;
mod time_utils;
mod tray;
mod utils;
#[cfg(target_os = "linux")]
mod wayland_shortcut;
#[cfg(target_os = "linux")]
mod hyprland_shortcut;
use managers::audio::AudioRecordingManager;
use managers::model::ModelManager;
use managers::transcription::TranscriptionManager;
use std::sync::Arc;
use tauri::image::Image;
use tauri::tray::TrayIconBuilder;
use tauri::Emitter;
use tauri::{AppHandle, Manager};
use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
// Re-export state types for use in other modules
pub use shortcut_protection::{ManagedDebounceState, ManagedToggleState};
use shortcut_protection::{
check_event_allowed, log_event_check_result, EventCheckResult, ShortcutToggleStates,
TriggerDebounceState,
};
use std::sync::Mutex;
use time_utils::format_timestamp;
fn show_main_window(app: &AppHandle) {
if let Some(main_window) = app.get_webview_window("main") {
// First, ensure the window is visible
if let Err(e) = main_window.show() {
eprintln!("Failed to show window: {}", e);
}
// Then, bring it to the front and give it focus
if let Err(e) = main_window.set_focus() {
eprintln!("Failed to focus window: {}", e);
}
// Optional: On macOS, ensure the app becomes active if it was an accessory
#[cfg(target_os = "macos")]
{
if let Err(e) = app.set_activation_policy(tauri::ActivationPolicy::Regular) {
eprintln!("Failed to set activation policy to Regular: {}", e);
}
}
} else {
eprintln!("Main window not found.");
}
}
fn initialize_core_logic(app_handle: &AppHandle) {
// First, initialize the managers
let recording_manager = Arc::new(
AudioRecordingManager::new(app_handle).expect("Failed to initialize recording manager"),
);
let model_manager =
Arc::new(ModelManager::new(app_handle, None).expect("Failed to initialize model manager"));
let transcription_manager = Arc::new(
TranscriptionManager::new(app_handle, model_manager.clone())
.expect("Failed to initialize transcription manager"),
);
// Add managers to Tauri's managed state
app_handle.manage(recording_manager.clone());
app_handle.manage(model_manager.clone());
app_handle.manage(transcription_manager.clone());
// Initialize the shortcuts
shortcut::init_shortcuts(app_handle);
// Apply macOS Accessory policy if starting hidden
#[cfg(target_os = "macos")]
{
let settings = settings::get_settings(app_handle);
if settings.start_hidden {
let _ = app_handle.set_activation_policy(tauri::ActivationPolicy::Accessory);
}
}
// Get the current theme to set the appropriate initial icon
let initial_theme = tray::get_current_theme(app_handle);
// Choose the appropriate initial icon based on theme
let initial_icon_path = tray::get_icon_path(initial_theme, tray::TrayIconState::Idle);
let tray = TrayIconBuilder::new()
.icon(
Image::from_path(
app_handle
.path()
.resolve(initial_icon_path, tauri::path::BaseDirectory::Resource)
.unwrap(),
)
.unwrap(),
)
.show_menu_on_left_click(true)
.icon_as_template(true)
.on_menu_event(|app, event| match event.id.as_ref() {
"settings" => {
show_main_window(app);
}
"check_updates" => {
show_main_window(app);
let _ = app.emit("check-for-updates", ());
}
"cancel" => {
use crate::utils::cancel_current_operation;
// Use centralized cancellation that handles all operations
cancel_current_operation(app);
}
"quit" => {
app.exit(0);
}
_ => {}
})
.build(app_handle)
.unwrap();
app_handle.manage(tray);
// Initialize tray menu with idle state
utils::update_tray_menu(app_handle, &utils::TrayIconState::Idle);
// Get the autostart manager and configure based on user setting
let autostart_manager = app_handle.autolaunch();
let settings = settings::get_settings(&app_handle);
if settings.autostart_enabled {
// Enable autostart if user has opted in
let _ = autostart_manager.enable();
} else {
// Disable autostart if user has opted out
let _ = autostart_manager.disable();
}
// Create the recording overlay window (hidden by default)
utils::create_recording_overlay(app_handle);
}
#[tauri::command]
fn trigger_update_check(app: AppHandle) -> Result<(), String> {
app.emit("check-for-updates", ())
.map_err(|e| e.to_string())?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
env_logger::init();
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
eprintln!("{} === Single Instance Callback ===", format_timestamp());
eprintln!("{} Args received: {:?}", format_timestamp(), args);
eprintln!("{} Args length: {}", format_timestamp(), args.len());
// Check if this is a trigger command (press or release)
// Note: args[0] is the executable path, so we check args[1] for the flag
if args.len() >= 3 {
let is_press = args[1] == "--trigger-press";
let is_release = args[1] == "--trigger-release";
if is_press || is_release {
let trigger_id = &args[2];
let event_type = if is_press { "PRESS" } else { "RELEASE" };
eprintln!("{} βœ“ Detected trigger {} event: '{}'", format_timestamp(), event_type, trigger_id);
// Check debouncing and busy state using centralized protection system
let check_result = check_event_allowed(app, trigger_id, is_press);
match check_result {
EventCheckResult::Allowed => {
// Event is allowed, proceed with action
}
EventCheckResult::Debounced { .. } | EventCheckResult::Busy => {
// Event is blocked, log and return
log_event_check_result(trigger_id, is_press, &check_result);
if matches!(check_result, EventCheckResult::Debounced { .. }) {
eprintln!("{} === Event debounced, ignoring ===\n", format_timestamp());
}
return;
}
}
// Execute the action associated with this trigger
if let Some(action) = actions::ACTION_MAP.get(trigger_id) {
eprintln!("{} βœ“ Found action in ACTION_MAP for '{}'", format_timestamp(), trigger_id);
let settings = settings::get_settings(app);
eprintln!("{} βœ“ Loaded settings (PTT mode: {})", format_timestamp(), settings.push_to_talk);
// Get the shortcut binding to pass the correct string
if let Some(binding) = settings.bindings.get(trigger_id) {
eprintln!("{} βœ“ Found binding for trigger: '{}'", format_timestamp(), trigger_id);
eprintln!("{} Current binding: {}", format_timestamp(), binding.current_binding);
// Get toggle state manager for setting busy flags
let toggle_state_manager = app.state::<ManagedToggleState>();
if is_press {
// Press event
if settings.push_to_talk {
// PTT mode: start recording on press
// Don't set busy flag - only release (transcription) needs it
eprintln!("{} β†’ PTT mode: Executing action.start()...", format_timestamp());
action.start(app, trigger_id, &binding.current_binding);
eprintln!("{} βœ“ action.start() called", format_timestamp());
} else {
// Toggle mode: check current state
eprintln!("{} β†’ Toggle mode: Checking current state...", format_timestamp());
let mut states = toggle_state_manager
.lock()
.expect("Failed to lock toggle state manager");
let is_currently_active = states
.active_toggles
.entry(trigger_id.to_string())
.or_insert(false);
if *is_currently_active {
eprintln!("{} Currently active, stopping...", format_timestamp());
// Set busy flag before stopping
states.busy_states.insert(trigger_id.to_string(), true);
drop(states);
action.stop(app, trigger_id, &binding.current_binding);
// Update toggle state
let mut states = toggle_state_manager
.lock()
.expect("Failed to lock toggle state manager");
*states.active_toggles.get_mut(trigger_id).unwrap() = false;
} else {
eprintln!("{} Currently inactive, starting...", format_timestamp());
// Set busy flag before starting
states.busy_states.insert(trigger_id.to_string(), true);
drop(states);
action.start(app, trigger_id, &binding.current_binding);
// Update toggle state
let mut states = toggle_state_manager
.lock()
.expect("Failed to lock toggle state manager");
*states.active_toggles.get_mut(trigger_id).unwrap() = true;
}
}
} else {
// Release event
if settings.push_to_talk {
// PTT mode: stop recording on release
eprintln!("{} β†’ PTT mode: Executing action.stop()...", format_timestamp());
// Set busy flag before stopping to prevent multiple concurrent stops
let mut states = toggle_state_manager
.lock()
.expect("Failed to lock toggle state manager");
states.busy_states.insert(trigger_id.to_string(), true);
drop(states);
action.stop(app, trigger_id, &binding.current_binding);
eprintln!("{} βœ“ action.stop() called", format_timestamp());
} else {
// Toggle mode: ignore release events
eprintln!("{} β†’ Toggle mode: Ignoring release event", format_timestamp());
}
}
} else {
eprintln!("{} βœ— Warning: Trigger '{}' not found in bindings", format_timestamp(), trigger_id);
eprintln!("{} Available bindings: {:?}", format_timestamp(), settings.bindings.keys().collect::<Vec<_>>());
}
} else {
eprintln!("{} βœ— Warning: No action defined for trigger '{}'", format_timestamp(), trigger_id);
eprintln!("{} Available actions: {:?}", format_timestamp(), actions::ACTION_MAP.keys().collect::<Vec<_>>());
}
} else {
eprintln!("{} β†’ No trigger detected, showing main window", format_timestamp());
// Default behavior: show window
show_main_window(app);
}
} else {
eprintln!("{} β†’ No args or insufficient args, showing main window", format_timestamp());
show_main_window(app);
}
eprintln!("{} === Single Instance Callback Complete ===\n", format_timestamp());
}))
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
Some(vec![]),
))
.manage(Mutex::new(ShortcutToggleStates::default()))
.manage(Mutex::new(TriggerDebounceState::default()))
.setup(move |app| {
let settings = settings::get_settings(&app.handle());
let app_handle = app.handle().clone();
initialize_core_logic(&app_handle);
// Show main window only if not starting hidden
if !settings.start_hidden {
if let Some(main_window) = app_handle.get_webview_window("main") {
main_window.show().unwrap();
main_window.set_focus().unwrap();
}
}
Ok(())
})
.on_window_event(|window, event| match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
api.prevent_close();
let _res = window.hide();
#[cfg(target_os = "macos")]
{
let res = window
.app_handle()
.set_activation_policy(tauri::ActivationPolicy::Accessory);
if let Err(e) = res {
println!("Failed to set activation policy: {}", e);
}
}
}
tauri::WindowEvent::ThemeChanged(theme) => {
println!("Theme changed to: {:?}", theme);
// Update tray icon to match new theme, maintaining idle state
utils::change_tray_icon(&window.app_handle(), utils::TrayIconState::Idle);
}
_ => {}
})
.invoke_handler(tauri::generate_handler![
shortcut::change_binding,
shortcut::reset_binding,
shortcut::change_ptt_setting,
shortcut::change_audio_feedback_setting,
shortcut::change_audio_feedback_volume_setting,
shortcut::change_sound_theme_setting,
shortcut::change_start_hidden_setting,
shortcut::change_autostart_setting,
shortcut::change_translate_to_english_setting,
shortcut::change_selected_language_setting,
shortcut::change_overlay_position_setting,
shortcut::change_debug_mode_setting,
shortcut::change_word_correction_threshold_setting,
shortcut::change_paste_method_setting,
shortcut::change_clipboard_handling_setting,
shortcut::update_custom_words,
shortcut::suspend_binding,
shortcut::resume_binding,
trigger_update_check,
commands::cancel_operation,
commands::get_app_dir_path,
commands::models::get_available_models,
commands::models::get_model_info,
commands::models::download_model,
commands::models::delete_model,
commands::models::cancel_download,
commands::models::pause_model_download,
commands::models::resume_model_download,
commands::models::cancel_model_download,
commands::models::set_active_model,
commands::models::get_current_model,
commands::models::is_model_loaded,
commands::models::get_transcription_model_status,
commands::models::is_model_loading,
commands::models::has_any_models_available,
commands::models::has_any_models_or_downloads,
commands::models::get_recommended_first_model,
commands::audio::update_microphone_mode,
commands::audio::get_microphone_mode,
commands::audio::get_available_microphones,
commands::audio::set_selected_microphone,
commands::audio::get_selected_microphone,
commands::audio::get_available_output_devices,
commands::audio::set_selected_output_device,
commands::audio::get_selected_output_device,
commands::audio::play_test_sound,
commands::audio::check_custom_sounds,
commands::transcription::set_model_unload_timeout,
commands::transcription::get_model_load_status,
commands::transcription::unload_model_manually,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
match event {
tauri::RunEvent::ExitRequested { .. } => {
eprintln!("[App] Exit requested, cleaning up...");
// Clean up Hyprland shortcuts on Linux
#[cfg(target_os = "linux")]
{
if let Some(manager) = app_handle.try_state::<std::sync::Arc<hyprland_shortcut::HyprlandShortcutManager>>() {
eprintln!("[App] Unbinding Hyprland shortcuts...");
let manager_clone = manager.inner().clone();
tauri::async_runtime::block_on(async move {
if let Err(e) = manager_clone.cleanup().await {
eprintln!("[App] Cleanup error: {}", e);
} else {
eprintln!("[App] βœ“ Hyprland shortcuts cleaned up");
}
});
}
}
// Don't prevent exit, allow app to close after cleanup
}
_ => {}
}
});
}
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_autostart::ManagerExt;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use crate::actions::ACTION_MAP;
use crate::settings::ShortcutBinding;
use crate::settings::{self, get_settings, ClipboardHandling, OverlayPosition, PasteMethod, SoundTheme};
use crate::shortcut_protection::{check_event_allowed, log_event_check_result, EventCheckResult};
use crate::ManagedToggleState;
#[cfg(target_os = "linux")]
use crate::wayland_shortcut::WaylandShortcutManager;
#[cfg(target_os = "linux")]
use crate::hyprland_shortcut::HyprlandShortcutManager;
/// Check if we're running on Wayland (runtime check)
#[cfg(target_os = "linux")]
fn is_using_wayland() -> bool {
is_wayland()
}
/// Detect if we're running on Wayland or X11
#[cfg(target_os = "linux")]
fn is_wayland() -> bool {
// Check XDG_SESSION_TYPE first (more reliable)
if let Ok(session_type) = std::env::var("XDG_SESSION_TYPE") {
if session_type.to_lowercase() == "wayland" {
return true;
}
}
// Fallback: check if WAYLAND_DISPLAY is set
if let Ok(wayland_display) = std::env::var("WAYLAND_DISPLAY") {
if !wayland_display.is_empty() {
return true;
}
}
false
}
/// Check if Hyprland is running
#[cfg(target_os = "linux")]
fn is_hyprland() -> bool {
HyprlandShortcutManager::is_hyprland_running()
}
pub fn init_shortcuts(app: &AppHandle) {
#[cfg(target_os = "linux")]
{
if is_wayland() {
// Check if Hyprland is running
if is_hyprland() {
eprintln!("Detected Hyprland compositor, using native hyprctl bindings");
// Use Hyprland-specific implementation
match HyprlandShortcutManager::new(app.clone()) {
Ok(hyprland_manager) => {
let hyprland_manager = std::sync::Arc::new(hyprland_manager);
// Store the manager in Tauri state to keep it alive
app.manage(hyprland_manager.clone());
// Spawn async initialization
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = hyprland_manager.init_shortcuts().await {
eprintln!("Failed to initialize Hyprland shortcuts: {}", e);
eprintln!("Falling back to X11 compatibility layer...");
// Fallback to X11 implementation
init_x11_shortcuts(&app_clone);
}
});
return;
}
Err(e) => {
eprintln!("Failed to create Hyprland manager: {}", e);
eprintln!("Falling back to portal/X11...");
}
}
}
eprintln!("Detected Wayland session (non-Hyprland), using XDG Desktop Portal for global shortcuts");
// Use Wayland/XDG Portal implementation
let wayland_manager = std::sync::Arc::new(WaylandShortcutManager::new(app.clone()));
// Store the manager in Tauri state to keep it alive
app.manage(wayland_manager.clone());
// Spawn async initialization
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = wayland_manager.init_shortcuts().await {
eprintln!("Failed to initialize Wayland shortcuts: {}", e);
eprintln!("Falling back to X11 compatibility layer...");
// Fallback to X11 implementation
init_x11_shortcuts(&app_clone);
}
});
return;
} else {
eprintln!("Detected X11 session, using tauri-plugin-global-shortcut");
}
}
// Use X11/default implementation for non-Linux or X11 on Linux
init_x11_shortcuts(app);
}
/// Initialize shortcuts using the X11-compatible tauri-plugin-global-shortcut
fn init_x11_shortcuts(app: &AppHandle) {
let settings = settings::load_or_create_app_settings(app);
// Register shortcuts with the bindings from settings
for (_id, binding) in settings.bindings {
if let Err(e) = _register_shortcut(app, binding) {
eprintln!("Failed to register shortcut {} during init: {}", _id, e);
}
}
}
#[derive(Serialize)]
pub struct BindingResponse {
success: bool,
binding: Option<ShortcutBinding>,
error: Option<String>,
}
#[tauri::command]
pub fn change_binding(
app: AppHandle,
id: String,
binding: String,
) -> Result<BindingResponse, String> {
eprintln!("=== change_binding called ===");
eprintln!(" ID: {}", id);
eprintln!(" New binding: '{}'", binding);
let mut settings = settings::get_settings(&app);
// Get the binding to modify
let binding_to_modify = match settings.bindings.get(&id) {
Some(binding) => binding.clone(),
None => {
let error_msg = format!("Binding with id '{}' not found", id);
eprintln!("change_binding error: {}", error_msg);
return Ok(BindingResponse {
success: false,
binding: None,
error: Some(error_msg),
});
}
};
// Validate the new shortcut before we touch the current registration
if let Err(e) = validate_shortcut_string(&binding) {
eprintln!("change_binding validation error: {}", e);
return Err(e);
}
// Create an updated binding
let mut updated_binding = binding_to_modify.clone();
updated_binding.current_binding = binding.clone();
// Update the shortcut based on the backend
#[cfg(target_os = "linux")]
{
if is_using_wayland() {
// Check if Hyprland manager is available first
if let Some(manager) = app.try_state::<std::sync::Arc<HyprlandShortcutManager>>() {
eprintln!("Updating Hyprland shortcut via hyprctl...");
// Spawn async task to update the binding
let manager_clone = manager.inner().clone();
let id_clone = id.clone();
let old_binding = binding_to_modify.current_binding.clone();
let new_binding = binding.clone();
let description = updated_binding.description.clone();
eprintln!(" Old binding: '{}'", old_binding);
eprintln!(" New binding: '{}'", new_binding);
eprintln!(" Description: '{}'", description);
tauri::async_runtime::spawn(async move {
eprintln!("β†’ Calling update_binding async...");
match manager_clone.update_binding(&id_clone, &old_binding, &new_binding, &description).await {
Ok(_) => {
eprintln!("βœ“ Hyprland shortcut updated successfully");
}
Err(e) => {
eprintln!("βœ— Failed to update Hyprland shortcut: {}", e);
}
}
});
} else if let Some(manager) = app.try_state::<std::sync::Arc<WaylandShortcutManager>>() {
eprintln!("Updating Wayland shortcut via portal...");
// Spawn async task to update the binding
let manager_clone = manager.inner().clone();
let id_clone = id.clone();
let binding_clone = binding.clone();
let description = updated_binding.description.clone();
tauri::async_runtime::spawn(async move {
match manager_clone.update_binding(&id_clone, &binding_clone, &description).await {
Ok(_) => {
eprintln!("βœ“ Wayland shortcut updated successfully");
}
Err(e) => {
eprintln!("βœ— Failed to update Wayland shortcut: {}", e);
}
}
});
} else {
eprintln!("Warning: No Wayland/Hyprland manager found in state, falling back to X11");
// Fallback to X11 method
if let Err(e) = _unregister_shortcut(&app, binding_to_modify.clone()) {
eprintln!("Failed to unregister shortcut: {}", e);
}
if let Err(e) = _register_shortcut(&app, updated_binding.clone()) {
let error_msg = format!("Failed to register shortcut: {}", e);
return Ok(BindingResponse {
success: false,
binding: None,
error: Some(error_msg),
});
}
}
} else {
// X11 path
if let Err(e) = _unregister_shortcut(&app, binding_to_modify.clone()) {
eprintln!("Failed to unregister shortcut: {}", e);
}
if let Err(e) = _register_shortcut(&app, updated_binding.clone()) {
let error_msg = format!("Failed to register shortcut: {}", e);
return Ok(BindingResponse {
success: false,
binding: None,
error: Some(error_msg),
});
}
}
}
#[cfg(not(target_os = "linux"))]
{
// Non-Linux platforms use X11-style registration
if let Err(e) = _unregister_shortcut(&app, binding_to_modify.clone()) {
eprintln!("Failed to unregister shortcut: {}", e);
}
if let Err(e) = _register_shortcut(&app, updated_binding.clone()) {
let error_msg = format!("Failed to register shortcut: {}", e);
return Ok(BindingResponse {
success: false,
binding: None,
error: Some(error_msg),
});
}
}
// Update the binding in the settings
settings.bindings.insert(id, updated_binding.clone());
// Save the settings
settings::write_settings(&app, settings);
// Return the updated binding
Ok(BindingResponse {
success: true,
binding: Some(updated_binding),
error: None,
})
}
#[tauri::command]
pub fn reset_binding(app: AppHandle, id: String) -> Result<BindingResponse, String> {
let binding = settings::get_stored_binding(&app, &id);
return change_binding(app, id, binding.default_binding);
}
#[tauri::command]
pub fn change_ptt_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
// TODO if the setting is currently false, we probably want to
// cancel any ongoing recordings or actions
settings.push_to_talk = enabled;
settings::write_settings(&app, settings);
Ok(())
}
#[tauri::command]
pub fn change_audio_feedback_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.audio_feedback = enabled;
settings::write_settings(&app, settings);
Ok(())
}
#[tauri::command]
pub fn change_audio_feedback_volume_setting(app: AppHandle, volume: f32) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.audio_feedback_volume = volume;
settings::write_settings(&app, settings);
Ok(())
}
#[tauri::command]
pub fn change_sound_theme_setting(app: AppHandle, theme: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
let parsed = match theme.as_str() {
"marimba" => SoundTheme::Marimba,
"pop" => SoundTheme::Pop,
"custom" => SoundTheme::Custom,
other => {
eprintln!("Invalid sound theme '{}', defaulting to marimba", other);
SoundTheme::Marimba
}
};
settings.sound_theme = parsed;
settings::write_settings(&app, settings);
Ok(())
}
#[tauri::command]
pub fn change_translate_to_english_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.translate_to_english = enabled;
settings::write_settings(&app, settings);
Ok(())
}
#[tauri::command]
pub fn change_selected_language_setting(app: AppHandle, language: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.selected_language = language;
settings::write_settings(&app, settings);
Ok(())
}
#[tauri::command]
pub fn change_overlay_position_setting(app: AppHandle, position: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
let parsed = match position.as_str() {
"none" => OverlayPosition::None,
"top" => OverlayPosition::Top,
"bottom" => OverlayPosition::Bottom,
other => {
eprintln!("Invalid overlay position '{}', defaulting to bottom", other);
OverlayPosition::Bottom
}
};
settings.overlay_position = parsed;
settings::write_settings(&app, settings);
// Update overlay position without recreating window
crate::utils::update_overlay_position(&app);
Ok(())
}
#[tauri::command]
pub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.debug_mode = enabled;
settings::write_settings(&app, settings);
// Emit event to notify frontend of debug mode change
let _ = app.emit(
"settings-changed",
serde_json::json!({
"setting": "debug_mode",
"value": enabled
}),
);
Ok(())
}
#[tauri::command]
pub fn change_start_hidden_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.start_hidden = enabled;
settings::write_settings(&app, settings);
// Notify frontend
let _ = app.emit(
"settings-changed",
serde_json::json!({
"setting": "start_hidden",
"value": enabled
}),
);
Ok(())
}
#[tauri::command]
pub fn change_autostart_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.autostart_enabled = enabled;
settings::write_settings(&app, settings);
// Apply the autostart setting immediately
let autostart_manager = app.autolaunch();
if enabled {
let _ = autostart_manager.enable();
} else {
let _ = autostart_manager.disable();
}
// Notify frontend
let _ = app.emit(
"settings-changed",
serde_json::json!({
"setting": "autostart_enabled",
"value": enabled
}),
);
Ok(())
}
#[tauri::command]
pub fn update_custom_words(app: AppHandle, words: Vec<String>) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.custom_words = words;
settings::write_settings(&app, settings);
Ok(())
}
#[tauri::command]
pub fn change_word_correction_threshold_setting(
app: AppHandle,
threshold: f64,
) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
settings.word_correction_threshold = threshold;
settings::write_settings(&app, settings);
Ok(())
}
#[tauri::command]
pub fn change_paste_method_setting(app: AppHandle, method: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
let parsed = match method.as_str() {
"ctrl_v" => PasteMethod::CtrlV,
"direct" => PasteMethod::Direct,
other => {
eprintln!("Invalid paste method '{}', defaulting to ctrl_v", other);
PasteMethod::CtrlV
}
};
settings.paste_method = parsed;
settings::write_settings(&app, settings);
Ok(())
}
#[tauri::command]
pub fn change_clipboard_handling_setting(app: AppHandle, handling: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
let parsed = match handling.as_str() {
"dont_modify" => ClipboardHandling::DontModify,
"copy_to_clipboard" => ClipboardHandling::CopyToClipboard,
other => {
eprintln!("Invalid clipboard handling '{}', defaulting to dont_modify", other);
ClipboardHandling::DontModify
}
};
settings.clipboard_handling = parsed;
settings::write_settings(&app, settings);
Ok(())
}
/// Determine whether a shortcut string contains at least one non-modifier key.
/// We allow single non-modifier keys (e.g. "f5" or "space") but disallow
/// modifier-only combos (e.g. "ctrl" or "ctrl+shift").
fn validate_shortcut_string(raw: &str) -> Result<(), String> {
let modifiers = [
"ctrl", "control", "shift", "alt", "option", "meta", "command", "cmd", "super", "win",
"windows",
];
let has_non_modifier = raw
.split('+')
.any(|part| !modifiers.contains(&part.trim().to_lowercase().as_str()));
if has_non_modifier {
Ok(())
} else {
Err("Shortcut must contain at least one non-modifier key".into())
}
}
/// Temporarily unregister a binding while the user is editing it in the UI.
/// This avoids firing the action while keys are being recorded.
#[tauri::command]
pub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> {
if let Some(b) = settings::get_bindings(&app).get(&id).cloned() {
if let Err(e) = _unregister_shortcut(&app, b) {
eprintln!("suspend_binding error for id '{}': {}", id, e);
return Err(e);
}
}
Ok(())
}
/// Re-register the binding after the user has finished editing.
#[tauri::command]
pub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> {
if let Some(b) = settings::get_bindings(&app).get(&id).cloned() {
if let Err(e) = _register_shortcut(&app, b) {
eprintln!("resume_binding error for id '{}': {}", id, e);
return Err(e);
}
}
Ok(())
}
fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
// Validate human-level rules first
if let Err(e) = validate_shortcut_string(&binding.current_binding) {
eprintln!(
"_register_shortcut validation error for binding '{}': {}",
binding.current_binding, e
);
return Err(e);
}
// Parse shortcut and return error if it fails
let shortcut = match binding.current_binding.parse::<Shortcut>() {
Ok(s) => s,
Err(e) => {
let error_msg = format!(
"Failed to parse shortcut '{}': {}",
binding.current_binding, e
);
eprintln!("_register_shortcut parse error: {}", error_msg);
return Err(error_msg);
}
};
// Prevent duplicate registrations that would silently shadow one another
if app.global_shortcut().is_registered(shortcut) {
let error_msg = format!("Shortcut '{}' is already in use", binding.current_binding);
eprintln!("_register_shortcut duplicate error: {}", error_msg);
return Err(error_msg);
}
// Clone binding.id for use in the closure
let binding_id_for_closure = binding.id.clone();
app.global_shortcut()
.on_shortcut(shortcut, move |ah, scut, event| {
if scut == &shortcut {
let shortcut_string = scut.into_string();
let is_press = event.state == ShortcutState::Pressed;
// Check debouncing and busy state using centralized protection system
let check_result = check_event_allowed(ah, &binding_id_for_closure, is_press);
match check_result {
EventCheckResult::Allowed => {
// Event is allowed, proceed with action
}
EventCheckResult::Debounced { .. } | EventCheckResult::Busy => {
// Event is blocked, log and return
log_event_check_result(&binding_id_for_closure, is_press, &check_result);
return;
}
}
let settings = get_settings(ah);
if let Some(action) = ACTION_MAP.get(&binding_id_for_closure) {
if settings.push_to_talk {
if event.state == ShortcutState::Pressed {
action.start(ah, &binding_id_for_closure, &shortcut_string);
} else if event.state == ShortcutState::Released {
action.stop(ah, &binding_id_for_closure, &shortcut_string);
}
} else {
if event.state == ShortcutState::Pressed {
let toggle_state_manager = ah.state::<ManagedToggleState>();
let mut states = toggle_state_manager.lock().expect("Failed to lock toggle state manager");
let is_currently_active = states.active_toggles
.entry(binding_id_for_closure.clone())
.or_insert(false);
if *is_currently_active {
action.stop(
ah,
&binding_id_for_closure,
&shortcut_string,
);
*is_currently_active = false; // Update state to inactive
} else {
action.start(ah, &binding_id_for_closure, &shortcut_string);
*is_currently_active = true; // Update state to active
}
}
}
} else {
println!(
"Warning: No action defined in ACTION_MAP for shortcut ID '{}'. Shortcut: '{}', State: {:?}",
binding_id_for_closure, shortcut_string, event.state
);
}
}
})
.map_err(|e| {
let error_msg = format!("Couldn't register shortcut '{}': {}", binding.current_binding, e);
eprintln!("_register_shortcut registration error: {}", error_msg);
error_msg
})?;
Ok(())
}
fn _unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
let shortcut = match binding.current_binding.parse::<Shortcut>() {
Ok(s) => s,
Err(e) => {
let error_msg = format!(
"Failed to parse shortcut '{}' for unregistration: {}",
binding.current_binding, e
);
eprintln!("_unregister_shortcut parse error: {}", error_msg);
return Err(error_msg);
}
};
app.global_shortcut().unregister(shortcut).map_err(|e| {
let error_msg = format!(
"Failed to unregister shortcut '{}': {}",
binding.current_binding, e
);
eprintln!("_unregister_shortcut error: {}", error_msg);
error_msg
})?;
Ok(())
}
//! Wayland global shortcuts implementation using XDG Desktop Portal.
//!
//! This module provides global shortcut functionality for Wayland compositors
//! (including Hyprland) that support the org.freedesktop.portal.GlobalShortcuts
//! portal interface.
use anyhow::{Context, Result};
use ashpd::desktop::global_shortcuts::{GlobalShortcuts, NewShortcut};
use ashpd::desktop::Session;
use ashpd::WindowIdentifier;
use futures_util::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use tokio::sync::Mutex;
use crate::actions::ACTION_MAP;
use crate::settings::{get_settings, ShortcutBinding};
/// Convert shortcut string from our format to XDG Portal format
///
/// Our format: "ctrl+shift+g" (lowercase)
/// Portal format: "CTRL+SHIFT+g" (uppercase modifiers, xkbcommon keysyms)
fn convert_to_portal_format(shortcut: &str) -> String {
shortcut
.split('+')
.map(|part| {
let part_lower = part.to_lowercase();
match part_lower.as_str() {
// Convert modifier names to uppercase as per XDG spec
"ctrl" | "control" => "CTRL".to_string(),
"alt" => "ALT".to_string(),
"shift" => "SHIFT".to_string(),
"super" | "meta" | "win" | "cmd" => "LOGO".to_string(),
"num" => "NUM".to_string(),
// For keys, keep the original case (usually lowercase)
// but handle some special cases
"space" => "space".to_string(),
"return" | "enter" => "Return".to_string(),
"tab" => "Tab".to_string(),
"backspace" => "BackSpace".to_string(),
"escape" | "esc" => "Escape".to_string(),
// Default: keep as-is (handles letter keys like 'g', 'a', etc.)
_ => part.to_string(),
}
})
.collect::<Vec<_>>()
.join("+")
}
/// Manages Wayland global shortcuts via XDG Desktop Portal
pub struct WaylandShortcutManager {
app: AppHandle,
session: Arc<Mutex<Option<Session<'static, GlobalShortcuts<'static>>>>>,
shortcuts: Arc<Mutex<HashMap<String, ShortcutBinding>>>,
}
impl WaylandShortcutManager {
/// Create a new Wayland shortcut manager
pub fn new(app: AppHandle) -> Self {
Self {
app,
session: Arc::new(Mutex::new(None)),
shortcuts: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Initialize the GlobalShortcuts portal and register all shortcuts from settings
pub async fn init_shortcuts(&self) -> Result<()> {
eprintln!("=== Wayland GlobalShortcuts Initialization ===");
eprintln!("Step 1: Creating GlobalShortcuts portal proxy...");
// Create GlobalShortcuts proxy
let global_shortcuts = match GlobalShortcuts::new().await {
Ok(proxy) => {
eprintln!("βœ“ GlobalShortcuts proxy created successfully");
proxy
}
Err(e) => {
eprintln!("βœ— Failed to create GlobalShortcuts proxy: {}", e);
return Err(e).context("Failed to create GlobalShortcuts portal proxy");
}
};
eprintln!("Step 2: Creating portal session...");
// Create a session
let session = match global_shortcuts.create_session().await {
Ok(s) => {
eprintln!("βœ“ GlobalShortcuts session created successfully");
s
}
Err(e) => {
eprintln!("βœ— Failed to create session: {}", e);
return Err(e).context("Failed to create GlobalShortcuts session");
}
};
eprintln!("Step 3: Loading shortcuts from settings...");
// Load shortcuts from settings
let settings = crate::settings::load_or_create_app_settings(&self.app);
let bindings: Vec<(String, ShortcutBinding)> = settings.bindings.into_iter().collect();
eprintln!("βœ“ Loaded {} shortcut bindings from settings", bindings.len());
// Prepare shortcuts for binding
let mut new_shortcuts = Vec::new();
for (id, binding) in bindings.iter() {
let portal_format = convert_to_portal_format(&binding.current_binding);
eprintln!(
" Preparing: {} -> {} ({}) [portal format: {}]",
id, binding.current_binding, binding.description, portal_format
);
let new_shortcut = NewShortcut::new(id.clone(), binding.description.clone())
.preferred_trigger(portal_format.as_str());
new_shortcuts.push(new_shortcut);
}
eprintln!("Step 4: Binding {} shortcuts to portal session...", new_shortcuts.len());
// Bind shortcuts to the session
let bind_result = match global_shortcuts
.bind_shortcuts(&session, &new_shortcuts, None::<&WindowIdentifier>)
.await {
Ok(result) => {
eprintln!("βœ“ Bind request sent successfully");
result
}
Err(e) => {
eprintln!("βœ— Failed to bind shortcuts: {}", e);
return Err(e).context("Failed to bind shortcuts");
}
};
eprintln!("Step 5: Getting bind response...");
let response = match bind_result.response() {
Ok(r) => {
eprintln!("βœ“ Bind response received");
r
}
Err(e) => {
eprintln!("βœ— Failed to get bind response: {}", e);
return Err(e).context("Failed to get bind response");
}
};
eprintln!("βœ“ Successfully bound {} shortcuts", response.shortcuts().len());
eprintln!("Step 6: Registered shortcuts (from portal response):");
for shortcut in response.shortcuts() {
let trigger = shortcut.trigger_description();
eprintln!(
" βœ“ ID: '{}' | Description: {} | Trigger: '{}'",
shortcut.id(),
shortcut.description(),
trigger
);
if trigger.is_empty() {
eprintln!(" ⚠️ WARNING: Portal returned EMPTY trigger!");
eprintln!(" This means the portal accepted but didn't bind the shortcut.");
eprintln!(" Possible causes:");
eprintln!(" - User confirmation required (check for permission dialog)");
eprintln!(" - Portal doesn't support BindShortcuts (only ListShortcuts)");
eprintln!(" - Compositor doesn't implement the feature");
eprintln!(" - Invalid trigger format still");
}
}
// CRITICAL DEBUG: Check if portal changed the IDs
eprintln!("Step 6.5: Comparing requested IDs vs portal-returned IDs:");
for (idx, (requested_id, _)) in bindings.iter().enumerate() {
if let Some(returned_shortcut) = response.shortcuts().get(idx) {
let returned_id = returned_shortcut.id();
if requested_id != returned_id {
eprintln!(" ⚠️ ID MISMATCH!");
eprintln!(" Requested: '{}'", requested_id);
eprintln!(" Portal returned: '{}'", returned_id);
eprintln!(" This will cause signal lookup to fail!");
} else {
eprintln!(" βœ“ ID match: '{}'", requested_id);
}
}
}
eprintln!("Step 7: Storing shortcuts in manager...");
// Store the shortcuts
let mut shortcuts_map = self.shortcuts.lock().await;
eprintln!("Storing shortcuts in local map:");
for (id, binding) in bindings {
eprintln!(" Storing ID: '{}' -> {}", id, binding.current_binding);
shortcuts_map.insert(id, binding);
}
drop(shortcuts_map);
eprintln!("βœ“ Shortcuts stored");
eprintln!("Step 8: Storing session...");
// Store the session
*self.session.lock().await = Some(session);
eprintln!("βœ“ Session stored");
eprintln!("Step 9: Setting up signal listeners...");
// Start listening for shortcut activation events
match self.listen_for_activations(global_shortcuts).await {
Ok(_) => {
eprintln!("βœ“ Signal listeners started");
}
Err(e) => {
eprintln!("βœ— Failed to setup listeners: {}", e);
return Err(e);
}
}
eprintln!("=== Wayland GlobalShortcuts Initialization Complete ===");
Ok(())
}
/// Listen for shortcut activation/deactivation signals
async fn listen_for_activations(&self, global_shortcuts: GlobalShortcuts<'static>) -> Result<()> {
let app = self.app.clone();
let shortcuts = self.shortcuts.clone();
// Get the signal streams before spawning tasks
let activated_stream = global_shortcuts
.receive_activated()
.await
.context("Failed to create activated signal receiver")?;
let deactivated_stream = global_shortcuts
.receive_deactivated()
.await
.context("Failed to create deactivated signal receiver")?;
// Spawn a task to listen for activated signals
tauri::async_runtime::spawn(async move {
let mut stream = activated_stream;
eprintln!("=== SIGNAL LISTENER: Activated stream ready ===");
eprintln!("Waiting for shortcut activation signals...");
while let Some(activated) = stream.next().await {
let shortcut_id = activated.shortcut_id();
let timestamp = activated.timestamp();
eprintln!(""); // Blank line for visibility
eprintln!("πŸ”₯ SIGNAL RECEIVED: Shortcut activated!");
eprintln!(" Shortcut ID: '{}'", shortcut_id);
eprintln!(" Timestamp: {:?}", timestamp);
// Debug: Show what shortcuts we have stored
let shortcuts_lock = shortcuts.lock().await;
eprintln!(" Available shortcuts in map:");
for (stored_id, _) in shortcuts_lock.iter() {
eprintln!(" - '{}'", stored_id);
}
// Try to find the binding
if let Some(binding) = shortcuts_lock.get(shortcut_id) {
eprintln!(" βœ“ Found matching binding!");
eprintln!(" Binding ID: {}", binding.id);
eprintln!(" Shortcut: {}", binding.current_binding);
let binding_id = binding.id.clone();
let shortcut_str = binding.current_binding.clone();
drop(shortcuts_lock);
// Get the action from the ACTION_MAP
if let Some(action) = ACTION_MAP.get(&binding_id) {
let settings = get_settings(&app);
if settings.push_to_talk {
// PTT mode: start on activation
action.start(&app, &binding_id, &shortcut_str);
} else {
// Toggle mode: check current state
let toggle_state_manager = app.state::<crate::ManagedToggleState>();
let mut states = toggle_state_manager
.lock()
.expect("Failed to lock toggle state manager");
let is_currently_active = states
.active_toggles
.entry(binding_id.clone())
.or_insert(false);
if *is_currently_active {
action.stop(&app, &binding_id, &shortcut_str);
*is_currently_active = false;
} else {
action.start(&app, &binding_id, &shortcut_str);
*is_currently_active = true;
}
}
} else {
eprintln!(
"Warning: No action defined in ACTION_MAP for shortcut ID '{}'",
binding_id
);
}
} else {
eprintln!(" βœ— NO MATCH FOUND!");
eprintln!(" Portal sent ID: '{}'", shortcut_id);
eprintln!(" This doesn't match any stored shortcut IDs");
eprintln!(" Possible ID mismatch or prefix/suffix issue");
}
}
eprintln!("=== SIGNAL LISTENER: Activated stream ended ===");
});
// Spawn a separate task to listen for deactivated signals (for PTT mode)
let app_deactivated = self.app.clone();
let shortcuts_deactivated = self.shortcuts.clone();
tauri::async_runtime::spawn(async move {
let mut stream = deactivated_stream;
eprintln!("Listening for GlobalShortcuts deactivated signals...");
while let Some(deactivated) = stream.next().await {
let shortcut_id = deactivated.shortcut_id();
let timestamp = deactivated.timestamp();
eprintln!(
"GlobalShortcut deactivated: {} at timestamp {:?}",
shortcut_id, timestamp
);
// Get the binding for this shortcut
let shortcuts_lock = shortcuts_deactivated.lock().await;
if let Some(binding) = shortcuts_lock.get(shortcut_id) {
let binding_id = binding.id.clone();
let shortcut_str = binding.current_binding.clone();
drop(shortcuts_lock);
// Get the action from the ACTION_MAP
if let Some(action) = ACTION_MAP.get(&binding_id) {
let settings = get_settings(&app_deactivated);
if settings.push_to_talk {
// PTT mode: stop on deactivation
action.stop(&app_deactivated, &binding_id, &shortcut_str);
}
// In toggle mode, deactivation events are ignored
}
}
}
eprintln!("GlobalShortcuts deactivated signal stream ended");
});
Ok(())
}
/// Update a single shortcut binding (rebind with new trigger)
pub async fn update_binding(&self, id: &str, new_trigger: &str, description: &str) -> Result<()> {
eprintln!("=== Updating Wayland Shortcut: {} ===", id);
// Get the session
let session_lock = self.session.lock().await;
let session = match session_lock.as_ref() {
Some(s) => s,
None => {
return Err(anyhow::anyhow!("No active GlobalShortcuts session"));
}
};
// Create GlobalShortcuts proxy
let global_shortcuts = GlobalShortcuts::new()
.await
.context("Failed to create GlobalShortcuts proxy for update")?;
// Convert trigger to portal format
let portal_format = convert_to_portal_format(new_trigger);
eprintln!("Binding updated shortcut: {} -> {} [portal format: {}]", id, new_trigger, portal_format);
// Create the new shortcut
let new_shortcut = NewShortcut::new(id.to_string(), description.to_string())
.preferred_trigger(portal_format.as_str());
// Rebind (this will replace the existing one with the same ID)
let bind_result = global_shortcuts
.bind_shortcuts(session, &[new_shortcut], None::<&WindowIdentifier>)
.await
.context("Failed to rebind shortcut")?;
let response = bind_result
.response()
.context("Failed to get rebind response")?;
eprintln!("βœ“ Shortcut rebound successfully");
for shortcut in response.shortcuts() {
eprintln!(
" Updated: {} -> {} (trigger: {})",
shortcut.id(),
shortcut.description(),
shortcut.trigger_description()
);
}
// Update local storage
let mut shortcuts_lock = self.shortcuts.lock().await;
if let Some(binding) = shortcuts_lock.get_mut(id) {
binding.current_binding = new_trigger.to_string();
eprintln!("βœ“ Local binding updated");
}
eprintln!("=== Shortcut Update Complete ===");
Ok(())
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment