diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs index 9c6d9d9a..a467b1e7 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor.cs @@ -45,7 +45,7 @@ public partial class SettingsPanelApp : SettingsPanelBase protected override async Task OnInitializedAsync() { - this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]); + this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED, Event.GLOBAL_SHORTCUT_CHANGED ]); await base.OnInitializedAsync(); this.updatePolicyMode = this.UpdatePolicy.CurrentMode; } @@ -55,6 +55,9 @@ public partial class SettingsPanelApp : SettingsPanelBase if (triggeredEvent is Event.CONFIGURATION_CHANGED) this.updatePolicyMode = this.UpdatePolicy.CurrentMode; + if (triggeredEvent is Event.GLOBAL_SHORTCUT_CHANGED) + this.StateHasChanged(); + await base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data); } diff --git a/app/MindWork AI Studio/Tools/Event.cs b/app/MindWork AI Studio/Tools/Event.cs index dbc737e4..fd99cffc 100644 --- a/app/MindWork AI Studio/Tools/Event.cs +++ b/app/MindWork AI Studio/Tools/Event.cs @@ -87,6 +87,11 @@ public enum Event /// Notifies receivers that voice recording availability changed. /// VOICE_RECORDING_AVAILABILITY_CHANGED, + + /// + /// Notifies settings UI receivers that a portal changed the effective global shortcut label. + /// + GLOBAL_SHORTCUT_CHANGED, // Update events: /// diff --git a/app/MindWork AI Studio/Tools/Rust/RegisterShortcutRequest.cs b/app/MindWork AI Studio/Tools/Rust/RegisterShortcutRequest.cs index d6d480ca..901b0466 100644 --- a/app/MindWork AI Studio/Tools/Rust/RegisterShortcutRequest.cs +++ b/app/MindWork AI Studio/Tools/Rust/RegisterShortcutRequest.cs @@ -1,3 +1,3 @@ namespace AIStudio.Tools.Rust; -public sealed record RegisterShortcutRequest(Shortcut Id, string Shortcut); \ No newline at end of file +public sealed record RegisterShortcutRequest(Shortcut Id, string Shortcut, string Description, bool Reconfigure); diff --git a/app/MindWork AI Studio/Tools/Rust/ShortcutBackend.cs b/app/MindWork AI Studio/Tools/Rust/ShortcutBackend.cs new file mode 100644 index 00000000..ecdeee8a --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/ShortcutBackend.cs @@ -0,0 +1,11 @@ +namespace AIStudio.Tools.Rust; + +/// +/// Native backend used to register a global shortcut. +/// +public enum ShortcutBackend +{ + NONE, + PORTAL, + TAURI, +} diff --git a/app/MindWork AI Studio/Tools/Rust/ShortcutRegistrationResult.cs b/app/MindWork AI Studio/Tools/Rust/ShortcutRegistrationResult.cs new file mode 100644 index 00000000..f1b88472 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/ShortcutRegistrationResult.cs @@ -0,0 +1,15 @@ +namespace AIStudio.Tools.Rust; + +/// +/// Typed result of a global shortcut registration attempt. +/// +public sealed record ShortcutRegistrationResult( + bool Success, + string ErrorMessage, + ShortcutBackend Backend, + bool Cancelled, + string EffectiveDisplayName) +{ + public static ShortcutRegistrationResult Failed(string errorMessage) => + new(false, errorMessage, ShortcutBackend.NONE, false, string.Empty); +} diff --git a/app/MindWork AI Studio/Tools/Rust/ShortcutResponse.cs b/app/MindWork AI Studio/Tools/Rust/ShortcutResponse.cs index 1028d475..7a098706 100644 --- a/app/MindWork AI Studio/Tools/Rust/ShortcutResponse.cs +++ b/app/MindWork AI Studio/Tools/Rust/ShortcutResponse.cs @@ -1,3 +1,8 @@ namespace AIStudio.Tools.Rust; -public sealed record ShortcutResponse(bool Success, string ErrorMessage); \ No newline at end of file +public sealed record ShortcutResponse( + bool Success, + string ErrorMessage, + ShortcutBackend Backend, + bool Cancelled, + string EffectiveDisplayName); diff --git a/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs b/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs index 3e537a2d..54628930 100644 --- a/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs +++ b/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs @@ -29,6 +29,24 @@ public readonly record struct TauriEvent(TauriEventType EventType, List return TryParseSnakeCase(this.Payload[0], out shortcut); } + /// + /// Reads a portal shortcut change and its effective display name. + /// + public bool TryGetShortcutChange(out Shortcut shortcut, out string effectiveDisplayName) + { + shortcut = default; + effectiveDisplayName = string.Empty; + if (this.EventType != TauriEventType.GLOBAL_SHORTCUT_CHANGED || this.Payload.Count < 2) + return false; + + if (!Enum.TryParse(this.Payload[0], ignoreCase: true, out shortcut) + && !TryParseSnakeCase(this.Payload[0], out shortcut)) + return false; + + effectiveDisplayName = this.Payload[1]; + return true; + } + /// /// Tries to parse a snake_case string into a ShortcutName enum value. /// @@ -42,4 +60,4 @@ public readonly record struct TauriEvent(TauriEventType EventType, List // Try to match against enum names (which are in UPPER_SNAKE_CASE): return Enum.TryParse(upperSnakeCase, ignoreCase: false, out shortcut); } -}; \ No newline at end of file +}; diff --git a/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs b/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs index 52afd491..6ad50eff 100644 --- a/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs +++ b/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs @@ -17,4 +17,5 @@ public enum TauriEventType FILE_DROP_CANCELED, GLOBAL_SHORTCUT_PRESSED, -} \ No newline at end of file + GLOBAL_SHORTCUT_CHANGED, +} diff --git a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs index 9f33c68a..3be5319e 100644 --- a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs +++ b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs @@ -1,5 +1,6 @@ using AIStudio.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; using Microsoft.AspNetCore.Components; @@ -19,6 +20,8 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv } private readonly SemaphoreSlim registrationSemaphore = new(1, 1); + private readonly Dictionary lastSentStates = []; + private readonly Dictionary lastNonEmptyShortcuts = []; private readonly ILogger logger; private readonly SettingsManager settingsManager; private readonly MessageBus messageBus; @@ -39,7 +42,7 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv this.voiceRecordingAvailabilityService = voiceRecordingAvailabilityService; this.messageBus.RegisterComponent(this); - this.ApplyFilters([], [Event.CONFIGURATION_CHANGED, Event.PLUGINS_RELOADED, Event.STARTUP_COMPLETED, Event.VOICE_RECORDING_AVAILABILITY_CHANGED]); + this.ApplyFilters([], [Event.CONFIGURATION_CHANGED, Event.PLUGINS_RELOADED, Event.STARTUP_COMPLETED, Event.TAURI_EVENT_RECEIVED, Event.VOICE_RECORDING_AVAILABILITY_CHANGED]); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -86,6 +89,14 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv await this.RegisterAllShortcuts(ShortcutSyncSource.VOICE_RECORDING_AVAILABILITY_CHANGED); break; + + case Event.TAURI_EVENT_RECEIVED: + if (data is TauriEvent tauriEvent + && tauriEvent.TryGetShortcutChange(out var shortcutId, out var effectiveDisplayName)) + { + await this.UpdateEffectiveDisplayName(shortcutId, effectiveDisplayName); + } + break; } } @@ -107,6 +118,7 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv var shortcutState = await this.GetShortcutState(shortcutId, source); var shortcut = shortcutState.Shortcut; var isEnabled = shortcutState.IsEnabled; + var requestedState = new ShortcutState(isEnabled ? shortcut : string.Empty, isEnabled, shortcutState.UsesPersistedFallback); this.logger.LogInformation( "Sync shortcut '{ShortcutId}' (source='{Source}', enabled={IsEnabled}, configured='{Shortcut}').", shortcutId, @@ -123,25 +135,53 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv shortcut); } - if (isEnabled && !string.IsNullOrWhiteSpace(shortcut)) + if (this.lastSentStates.TryGetValue(shortcutId, out var lastSentState) + && lastSentState.Shortcut == requestedState.Shortcut + && lastSentState.IsEnabled == requestedState.IsEnabled) { - var success = await this.rustService.UpdateGlobalShortcut(shortcutId, shortcut); - if (success) - this.logger.LogInformation("Global shortcut '{ShortcutId}' ({Shortcut}) registered.", shortcutId, shortcut); - else - this.logger.LogWarning("Failed to register global shortcut '{ShortcutId}' ({Shortcut}).", shortcutId, shortcut); + this.logger.LogDebug("Skipping unchanged global shortcut '{ShortcutId}'.", shortcutId); + continue; + } + + var description = await this.GetShortcutDescription(shortcutId); + var reconfigure = !string.IsNullOrWhiteSpace(requestedState.Shortcut) + && this.lastNonEmptyShortcuts.TryGetValue(shortcutId, out var lastNonEmptyShortcut) + && !string.Equals(lastNonEmptyShortcut, requestedState.Shortcut, StringComparison.Ordinal); + + var result = await this.rustService.UpdateGlobalShortcut(shortcutId, requestedState.Shortcut, description, reconfigure); + this.lastSentStates[shortcutId] = requestedState; + if (!string.IsNullOrWhiteSpace(requestedState.Shortcut)) + this.lastNonEmptyShortcuts[shortcutId] = requestedState.Shortcut; + + if (result.Success) + { + this.logger.LogInformation( + "Global shortcut '{ShortcutId}' ({Shortcut}) synchronized through {Backend}.", + shortcutId, + requestedState.Shortcut, + result.Backend); + + if (result.Backend is ShortcutBackend.PORTAL) + await this.UpdateEffectiveDisplayName(shortcutId, result.EffectiveDisplayName); } else { - this.logger.LogInformation( - "Disabling global shortcut '{ShortcutId}' (source='{Source}', enabled={IsEnabled}, configured='{Shortcut}').", + var userMessage = result.Cancelled + ? TB("The global shortcut change was cancelled. The previous shortcut remains active.") + : TB("The global shortcut could not be registered. The previous shortcut remains active."); + + this.logger.LogWarning( + "Failed to synchronize global shortcut '{ShortcutId}' ({Shortcut}, backend={Backend}, cancelled={Cancelled}): {Error}", shortcutId, - source, - isEnabled, - shortcut); + requestedState.Shortcut, + result.Backend, + result.Cancelled, + result.ErrorMessage); - // Disable the shortcut when empty or feature is disabled: - await this.rustService.UpdateGlobalShortcut(shortcutId, string.Empty); + if (result.Cancelled) + await this.messageBus.SendWarning(new(Icons.Material.Filled.Keyboard, userMessage)); + else + await this.messageBus.SendError(new(Icons.Material.Filled.Keyboard, userMessage)); } } @@ -170,6 +210,34 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv _ => true, }; + private async Task GetShortcutDescription(Shortcut shortcutId) + { + var language = await this.settingsManager.GetActiveLanguagePlugin(); + return shortcutId switch + { + Shortcut.VOICE_RECORDING_TOGGLE => I18N.I.GetText(language, "Toggle voice recording", typeof(GlobalShortcutService).Namespace, nameof(GlobalShortcutService)), + _ => I18N.I.GetText(language, "Global shortcut", typeof(GlobalShortcutService).Namespace, nameof(GlobalShortcutService)), + }; + } + + private async Task UpdateEffectiveDisplayName(Shortcut shortcutId, string effectiveDisplayName) + { + if (shortcutId is not Shortcut.VOICE_RECORDING_TOGGLE || string.IsNullOrWhiteSpace(effectiveDisplayName)) + return; + + var configuredShortcut = this.settingsManager.ConfigurationData.App.ShortcutVoiceRecording; + if (this.settingsManager.ConfigurationData.App.ShortcutVoiceRecordingDisplayName == effectiveDisplayName + && this.settingsManager.ConfigurationData.App.ShortcutVoiceRecordingDisplaySource == configuredShortcut) + return; + + this.settingsManager.ConfigurationData.App.ShortcutVoiceRecordingDisplayName = effectiveDisplayName; + this.settingsManager.ConfigurationData.App.ShortcutVoiceRecordingDisplaySource = configuredShortcut; + await this.settingsManager.StoreSettings(); + await this.messageBus.SendMessage(null, Event.GLOBAL_SHORTCUT_CHANGED); + } + + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(GlobalShortcutService).Namespace, nameof(GlobalShortcutService)); + private async Task GetShortcutState(Shortcut shortcutId, ShortcutSyncSource source) { var shortcut = this.GetShortcutValue(shortcutId); @@ -194,4 +262,4 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv } private readonly record struct ShortcutState(string Shortcut, bool IsEnabled, bool UsesPersistedFallback); -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs b/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs index 69c2b41d..5273a05f 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs @@ -10,34 +10,36 @@ public sealed partial class RustService /// /// The identifier for the shortcut. /// The shortcut string in Tauri format (e.g., "CmdOrControl+1"). Use empty string to disable. - /// True if the shortcut was registered successfully, false otherwise. - public async Task UpdateGlobalShortcut(Shortcut shortcutId, string shortcut) + /// Localized action description shown by the desktop portal. + /// Whether the user deliberately selected a different preferred trigger. + /// A typed result including the selected backend and effective portal label. + public async Task UpdateGlobalShortcut(Shortcut shortcutId, string shortcut, string description, bool reconfigure) { try { - var request = new RegisterShortcutRequest(shortcutId, shortcut); + var request = new RegisterShortcutRequest(shortcutId, shortcut, description, reconfigure); var response = await this.http.PostAsJsonAsync("/shortcuts/register", request, this.jsonRustSerializerOptions); if (!response.IsSuccessStatusCode) { this.logger?.LogError("Failed to register global shortcut '{ShortcutId}' due to network error: {StatusCode}", shortcutId, response.StatusCode); - return false; + return ShortcutRegistrationResult.Failed(TB("The global shortcut could not be registered because the desktop service is unavailable.")); } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); if (result is null || !result.Success) { this.logger?.LogError("Failed to register global shortcut '{ShortcutId}': {Error}", shortcutId, result?.ErrorMessage ?? "Unknown error"); - return false; + return result ?? ShortcutRegistrationResult.Failed(TB("The desktop service returned an invalid response while registering the global shortcut.")); } this.logger?.LogInformation("Global shortcut '{ShortcutId}' registered successfully with key '{Shortcut}'.", shortcutId, shortcut); - return true; + return result; } catch (Exception ex) { this.logger?.LogError(ex, "Exception while registering global shortcut '{ShortcutId}'.", shortcutId); - return false; + return ShortcutRegistrationResult.Failed(TB("The global shortcut could not be registered because of a desktop integration error.")); } } diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md index 2400717c..643f4581 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md @@ -10,6 +10,7 @@ - Fixed an issue that could leave AI Studio unresponsive after waking the computer from sleep. Yes, we know this was an annoying bug, and we apologize for the inconvenience. - Fixed connections to internal HTTPS services and enterprise configuration servers that use organization-provided root certificates on Linux. - Fixed enterprise configuration plugins from Windows-created ZIP files may not load correctly on Linux when the ZIP contained plugin files inside a folder. +- Fixed the global voice recording shortcut on Linux so it also works outside AI Studio on supported Wayland desktops. - Fixed voice recording and transcription on Linux. - Fixed copied content from AI Studio not remaining available on the clipboard on Linux. - Fixed being able to switch document analysis policies while an analysis or media transcription was still in progress. diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 823de95d..0f507725 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -72,7 +72,7 @@ windows-native-keyring-store = "1.1.0" apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] } [target.'cfg(target_os = "linux")'.dependencies] -ashpd = { version = "0.13.12", default-features = false, features = ["tokio", "open_uri"] } +ashpd = { version = "0.13.12", default-features = false, features = ["tokio", "open_uri", "global_shortcuts"] } dbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] } webkit2gtk = { version = "2.0.2", features = ["v2_8"] } diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index ad8aad9d..274d378f 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::convert::Infallible; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -13,12 +12,10 @@ use log::{debug, error, info, trace, warn}; use once_cell::sync::Lazy; use pdfium_render::prelude::Pdfium; use serde::{Deserialize, Serialize}; -use strum_macros::Display; use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent, generate_context}; use tauri::path::PathResolver; use tauri::WebviewWindow; use tauri_plugin_updater::{UpdaterExt, Update}; -use tauri_plugin_global_shortcut::GlobalShortcutExt; use tauri_plugin_opener::OpenerExt; use tokio::sync::broadcast; use tokio::time; @@ -31,6 +28,7 @@ use crate::environment::{ use crate::log::switch_to_file_logging; use crate::pdfium::PDFIUM_LIB_PATH; use crate::qdrant_edge_database::{start_qdrant_edge_database, stop_qdrant_edge_database}; +use crate::global_shortcuts::{RegisterShortcutRequest, ShortcutResponse}; #[cfg(debug_assertions)] use crate::dotnet::create_startup_env_file; @@ -50,20 +48,9 @@ static CHECK_UPDATE_RESPONSE: Lazy>> = Lazy::new(|| Mutex:: /// The event broadcast sender for Tauri events. static EVENT_BROADCAST: Lazy>>> = Lazy::new(|| Mutex::new(None)); -/// Stores the currently registered global shortcuts (name -> shortcut string). -static REGISTERED_SHORTCUTS: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); - /// Stores the localhost origin of the Blazor app after the .NET server is ready. static APPROVED_APP_URL: Lazy>> = Lazy::new(|| Mutex::new(None)); -/// Enum identifying global keyboard shortcuts. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display)] -#[strum(serialize_all = "SCREAMING_SNAKE_CASE")] -pub enum Shortcut { - None = 0, - VoiceRecordingToggle, -} - /// Starts the Tauri app. pub fn start_tauri() { info!("Starting Tauri app..."); @@ -486,6 +473,7 @@ pub enum TauriEventType { FileDropCanceled, GlobalShortcutPressed, + GlobalShortcutChanged, } /// Changes the location of the main window to the given URL. @@ -676,24 +664,6 @@ fn self_update_allowed(development: bool, flatpak: bool) -> bool { !development && !flatpak } -/// Request payload for registering a global shortcut. -#[derive(Clone, Deserialize)] -pub struct RegisterShortcutRequest { - /// The shortcut ID to use. - id: Shortcut, - - /// The shortcut string in Tauri format (e.g., "CmdOrControl+1"). - /// Use empty string to unregister the shortcut. - shortcut: String, -} - -/// Response for shortcut registration. -#[derive(Serialize)] -pub struct ShortcutResponse { - success: bool, - error_message: String, -} - /// Response for application exit requests. #[derive(Serialize)] pub struct AppExitResponse { @@ -701,28 +671,6 @@ pub struct AppExitResponse { error_message: String, } -/// Internal helper function to register a shortcut with its callback. -/// This is used by both `register_shortcut` and `resume_shortcuts` to -/// avoid code duplication. -fn register_shortcut_with_callback( - app_handle: &tauri::AppHandle, - shortcut: &str, - shortcut_id: Shortcut, - event_sender: broadcast::Sender, -) -> Result<(), tauri_plugin_global_shortcut::Error> { - let shortcut_manager = app_handle.global_shortcut(); - shortcut_manager.on_shortcut(shortcut, move |_app, _shortcut, _event| { - info!(Source = "Tauri"; "Global shortcut triggered for '{}'.", shortcut_id); - let event = Event::new(TauriEventType::GlobalShortcutPressed, vec![shortcut_id.to_string()]); - let sender = event_sender.clone(); - tauri::async_runtime::spawn(async move { - if let Err(error) = sender.send(event) { - error!(Source = "Tauri"; "Failed to send global shortcut event: {error}"); - } - }); - }) -} - /// Requests a controlled shutdown of the entire desktop application. pub async fn exit_app(_token: APIToken) -> Json { let app_handle = { @@ -754,89 +702,9 @@ pub async fn exit_app(_token: APIToken) -> Json { /// Registers or updates a global shortcut. If the shortcut string is empty, /// the existing shortcut for that name will be unregistered. pub async fn register_shortcut(_token: APIToken, payload: Json) -> Json { - let id = payload.id; - let new_shortcut = payload.shortcut.clone(); - - if id == Shortcut::None { - error!(Source = "Tauri"; "Cannot register NONE shortcut."); - return Json(ShortcutResponse { - success: false, - error_message: "Cannot register NONE shortcut".to_string(), - }); - } - - info!(Source = "Tauri"; "Registering global shortcut '{}' with key '{new_shortcut}'.", id); - - // Get the main window to access the global shortcut manager: - let main_window_lock = MAIN_WINDOW.lock().unwrap(); - let main_window = match main_window_lock.as_ref() { - Some(window) => window, - None => { - error!(Source = "Tauri"; "Cannot register shortcut: main window not available."); - return Json(ShortcutResponse { - success: false, - error_message: "Main window not available".to_string(), - }); - } - }; - - let app_handle = main_window.app_handle(); - let shortcut_manager = app_handle.global_shortcut(); - let mut registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap(); - - // Unregister the old shortcut if one exists for this name: - if let Some(old_shortcut) = registered_shortcuts.get(&id) && !old_shortcut.is_empty() { - match shortcut_manager.unregister(old_shortcut.as_str()) { - Ok(_) => info!(Source = "Tauri"; "Unregistered old shortcut '{old_shortcut}' for '{}'.", id), - Err(error) => warn!(Source = "Tauri"; "Failed to unregister old shortcut '{old_shortcut}': {error}"), - } - } - - // When the new shortcut is empty, we're done (just unregistering): - if new_shortcut.is_empty() { - registered_shortcuts.remove(&id); - info!(Source = "Tauri"; "Shortcut '{}' has been disabled.", id); - return Json(ShortcutResponse { - success: true, - error_message: String::new(), - }); - } - - // Get the event broadcast sender for the shortcut callback: - let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap(); - let event_sender = match event_broadcast_lock.as_ref() { - Some(sender) => sender.clone(), - None => { - error!(Source = "Tauri"; "Cannot register shortcut: event broadcast not initialized."); - return Json(ShortcutResponse { - success: false, - error_message: "Event broadcast not initialized".to_string(), - }); - } - }; - - drop(event_broadcast_lock); - - // Register the new shortcut: - match register_shortcut_with_callback(app_handle, &new_shortcut, id, event_sender) { - Ok(_) => { - info!(Source = "Tauri"; "Global shortcut '{new_shortcut}' registered successfully for '{}'.", id); - registered_shortcuts.insert(id, new_shortcut); - Json(ShortcutResponse { - success: true, - error_message: String::new(), - }) - }, - - Err(error) => { - let error_msg = format!("Failed to register shortcut: {error}"); - error!(Source = "Tauri"; "{error_msg}"); - Json(ShortcutResponse { - success: false, - error_message: error_msg, - }) - } - } + let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone()); + let event_sender = EVENT_BROADCAST.lock().unwrap().clone(); + Json(crate::global_shortcuts::register(app_handle, event_sender, payload.0).await) } /// Request payload for validating a shortcut. @@ -872,8 +740,7 @@ pub async fn validate_shortcut(_token: APIToken, payload: Json Json { - // Get the main window to access the global shortcut manager: - let main_window_lock = MAIN_WINDOW.lock().unwrap(); - let main_window = match main_window_lock.as_ref() { - Some(window) => window, - None => { - error!(Source = "Tauri"; "Cannot suspend shortcuts: main window not available."); - return Json(ShortcutResponse { - success: false, - error_message: "Main window not available".to_string(), - }); - } - }; - - let app_handle = main_window.app_handle(); - let shortcut_manager = app_handle.global_shortcut(); - let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap(); - - // Unregister all shortcuts from the OS (but keep them in our map): - for (name, shortcut) in registered_shortcuts.iter() { - if !shortcut.is_empty() { - match shortcut_manager.unregister(shortcut.as_str()) { - Ok(_) => info!(Source = "Tauri"; "Temporarily unregistered shortcut '{shortcut}' for '{}'.", name), - Err(error) => warn!(Source = "Tauri"; "Failed to unregister shortcut '{shortcut}' for '{}': {error}", name), - } - } - } - - info!(Source = "Tauri"; "Shortcut processing has been suspended ({} shortcuts unregistered).", registered_shortcuts.len()); - Json(ShortcutResponse { - success: true, - error_message: String::new(), - }) + let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone()); + Json(crate::global_shortcuts::suspend(app_handle).await) } /// Resumes shortcut processing by re-registering all shortcuts with the OS. pub async fn resume_shortcuts(_token: APIToken) -> Json { - // Get the main window to access the global shortcut manager: - let main_window_lock = MAIN_WINDOW.lock().unwrap(); - let main_window = match main_window_lock.as_ref() { - Some(window) => window, - None => { - error!(Source = "Tauri"; "Cannot resume shortcuts: main window not available."); - return Json(ShortcutResponse { - success: false, - error_message: "Main window not available".to_string(), - }); - } - }; - - let app_handle = main_window.app_handle(); - let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap(); - - // Get the event broadcast sender for the shortcut callbacks: - let event_broadcast_lock = EVENT_BROADCAST.lock().unwrap(); - let event_sender = match event_broadcast_lock.as_ref() { - Some(sender) => sender.clone(), - None => { - error!(Source = "Tauri"; "Cannot resume shortcuts: event broadcast not initialized."); - return Json(ShortcutResponse { - success: false, - error_message: "Event broadcast not initialized".to_string(), - }); - } - }; - - drop(event_broadcast_lock); - - // Re-register all shortcuts with the OS: - let mut success_count = 0; - for (shortcut_id, shortcut) in registered_shortcuts.iter() { - if shortcut.is_empty() { - continue; - } - - match register_shortcut_with_callback(app_handle, shortcut, *shortcut_id, event_sender.clone()) { - Ok(_) => { - info!(Source = "Tauri"; "Re-registered shortcut '{shortcut}' for '{}'.", shortcut_id); - success_count += 1; - }, - - Err(error) => warn!(Source = "Tauri"; "Failed to re-register shortcut '{shortcut}' for '{}': {error}", shortcut_id), - } - } - - info!(Source = "Tauri"; "Shortcut processing has been resumed ({success_count} shortcuts re-registered)."); - Json(ShortcutResponse { - success: true, - error_message: String::new(), - }) + let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone()); + let event_sender = EVENT_BROADCAST.lock().unwrap().clone(); + Json(crate::global_shortcuts::resume(app_handle, event_sender).await) } /// Validates the syntax of a shortcut string. diff --git a/runtime/src/global_shortcuts.rs b/runtime/src/global_shortcuts.rs new file mode 100644 index 00000000..6b8cdc8b --- /dev/null +++ b/runtime/src/global_shortcuts.rs @@ -0,0 +1,966 @@ +#![cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))] + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(target_os = "linux")] +use std::sync::atomic::AtomicU64; + +use log::{error, info, warn}; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use strum_macros::Display; +use tauri_plugin_global_shortcut::GlobalShortcutExt; +use tokio::sync::{Mutex, broadcast}; + +use crate::app_window::{Event, TauriEventType}; + +#[cfg(target_os = "linux")] +use ashpd::desktop::{CreateSessionOptions, ResponseError}; + +#[cfg(target_os = "linux")] +use ashpd::desktop::global_shortcuts::{ + BindShortcutsOptions, GlobalShortcuts, ListShortcutsOptions, NewShortcut, Shortcut as AshpdShortcut, +}; + +#[cfg(target_os = "linux")] +use futures::StreamExt; + +/// Serializes access to the active shortcut bindings across API requests. +static SHORTCUT_MANAGER: Lazy> = Lazy::new(|| Mutex::new(ShortcutManager::default())); + +/// Indicates whether shortcut activations must currently be ignored. +static PROCESSING_SUSPENDED: AtomicBool = AtomicBool::new(false); + +#[cfg(target_os = "linux")] +/// Supplies unique generations for portal sessions so stale signal tasks can be ignored. +static NEXT_PORTAL_GENERATION: AtomicU64 = AtomicU64::new(1); + +#[cfg(target_os = "linux")] +/// Maps each shortcut to the generation of its currently active portal session. +static ACTIVE_PORTAL_GENERATIONS: Lazy>> = Lazy::new(|| std::sync::Mutex::new(HashMap::new())); + +/// Enum identifying global keyboard shortcuts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display)] +#[strum(serialize_all = "SCREAMING_SNAKE_CASE")] +pub enum Shortcut { + /// Null value used when no supported shortcut was specified. + None = 0, + + /// Toggles voice recording and transcription. + VoiceRecordingToggle, +} + +impl Shortcut { + /// Resolves an application-provided portal shortcut ID to its internal identifier. + #[cfg(target_os = "linux")] + fn from_portal_id(id: &str) -> Option { + match id { + "VOICE_RECORDING_TOGGLE" => Some(Self::VoiceRecordingToggle), + _ => None, + } + } +} + +/// Request payload for registering or disabling a global shortcut. +#[derive(Clone, Deserialize)] +pub struct RegisterShortcutRequest { + /// Identifies the action controlled by the shortcut. + pub id: Shortcut, + + /// Contains the preferred key combination in Tauri shortcut syntax. + pub shortcut: String, + + /// Contains the localized action description shown by the desktop portal. + pub description: String, + + /// Indicates that the user deliberately requested a different key combination. + pub reconfigure: bool, +} + +/// Backend used for a shortcut registration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ShortcutBackend { + /// No native shortcut backend is active. + None, + + /// The XDG Desktop Portal manages the shortcut. + Portal, + + /// The Tauri global-shortcut plugin manages the shortcut. + Tauri, +} + +/// Response for shortcut registration and processing state changes. +#[derive(Serialize)] +pub struct ShortcutResponse { + /// Indicates whether the requested operation completed successfully. + pub success: bool, + + /// Contains a technical error description when the operation failed. + pub error_message: String, + + /// Identifies the backend involved in the operation. + pub backend: ShortcutBackend, + + /// Indicates whether the user cancelled the portal request. + pub cancelled: bool, + + /// Contains the effective, user-facing shortcut label selected by the backend. + pub effective_display_name: String, +} + +impl ShortcutResponse { + /// Creates a successful shortcut response for the selected backend. + fn success(backend: ShortcutBackend, effective_display_name: String) -> Self { + Self { + success: true, + error_message: String::new(), + backend, + cancelled: false, + effective_display_name, + } + } + + /// Creates a failed shortcut response with its backend and cancellation state. + fn error(error_message: impl Into, backend: ShortcutBackend, cancelled: bool) -> Self { + Self { + success: false, + error_message: error_message.into(), + backend, + cancelled, + effective_display_name: String::new(), + } + } +} + +#[derive(Default)] +/// Owns all currently active shortcut bindings. +struct ShortcutManager { + /// Maps each logical shortcut to its active backend binding. + bindings: HashMap, +} + +/// Stores the backend-specific resources required by an active shortcut. +enum ActiveBinding { + /// Stores a shortcut registered through the Tauri plugin. + Tauri { + /// Contains the registered shortcut in Tauri syntax. + shortcut: String, + }, + + #[cfg(target_os = "linux")] + /// Stores a shortcut and its live XDG portal session. + Portal { + /// Contains the preferred shortcut in Tauri syntax. + shortcut: String, + /// Contains the effective human-readable trigger selected by the portal. + effective_display_name: String, + /// Distinguishes this session from superseded portal signal tasks. + generation: u64, + /// Keeps the portal registration active for the binding's lifetime. + session: ashpd::desktop::Session, + }, +} + +impl ActiveBinding { + /// Returns the preferred Tauri-format shortcut associated with the binding. + fn shortcut(&self) -> &str { + match self { + Self::Tauri { shortcut } => shortcut, + #[cfg(target_os = "linux")] + Self::Portal { shortcut, .. } => shortcut, + } + } + + /// Returns the native backend used by the binding. + fn backend(&self) -> ShortcutBackend { + match self { + Self::Tauri { .. } => ShortcutBackend::Tauri, + #[cfg(target_os = "linux")] + Self::Portal { .. } => ShortcutBackend::Portal, + } + } + + /// Returns the shortcut label that should be displayed in the UI. + fn effective_display_name(&self) -> String { + match self { + Self::Tauri { shortcut } => shortcut.clone(), + #[cfg(target_os = "linux")] + Self::Portal { effective_display_name, .. } => effective_display_name.clone(), + } + } +} + +/// Returns a snapshot of all registered shortcut IDs and their preferred combinations. +pub async fn registered_shortcuts() -> Vec<(Shortcut, String)> { + SHORTCUT_MANAGER + .lock() + .await + .bindings + .iter() + .map(|(id, binding)| (*id, binding.shortcut().to_string())) + .collect() +} + +/// Registers, reconfigures, or disables a global shortcut through the appropriate backend. +pub async fn register( + app_handle: Option, + event_sender: Option>, + request: RegisterShortcutRequest, +) -> ShortcutResponse { + if request.id == Shortcut::None { + return ShortcutResponse::error("Cannot register NONE shortcut", ShortcutBackend::None, false); + } + + let Some(app_handle) = app_handle else { + return ShortcutResponse::error("Main window not available", ShortcutBackend::None, false); + }; + let Some(event_sender) = event_sender else { + return ShortcutResponse::error("Event broadcast not initialized", ShortcutBackend::None, false); + }; + + let mut manager = SHORTCUT_MANAGER.lock().await; + if request.shortcut.is_empty() { + return disable_binding(&app_handle, &mut manager, request.id).await; + } + + if registration_is_unchanged(manager.bindings.get(&request.id).map(ActiveBinding::shortcut), &request.shortcut, request.reconfigure) { + info!(Source = "Global shortcuts"; "Ignoring unchanged registration for '{}'.", request.id); + let binding = manager.bindings.get(&request.id).unwrap(); + return ShortcutResponse::success(binding.backend(), binding.effective_display_name()); + } + + #[cfg(target_os = "linux")] + { + match prepare_portal_binding(&request, event_sender.clone()).await { + Ok(new_binding) => { + let effective_display_name = new_binding.effective_display_name(); + replace_portal_binding(&app_handle, &mut manager, request.id, new_binding).await; + info!(Source = "XDG portal"; "Global shortcut '{}' is active through the desktop portal.", request.id); + return ShortcutResponse::success(ShortcutBackend::Portal, effective_display_name); + }, + + Err(error) if may_fallback_to_tauri( + error.kind, + manager.bindings.get(&request.id).map(ActiveBinding::backend), + ) => { + warn!(Source = "XDG portal"; "Global shortcuts portal is unavailable; using the Tauri X11 backend: {}", error.message); + }, + + Err(error) => { + let cancelled = error.kind == PortalFailureKind::Cancelled; + if cancelled { + warn!(Source = "XDG portal"; "Global shortcut configuration was cancelled by the user."); + } else if error.kind == PortalFailureKind::Denied { + warn!(Source = "XDG portal"; "Global shortcut permission was denied: {}", error.message); + } else { + error!(Source = "XDG portal"; "Global shortcut registration failed: {}", error.message); + } + + return ShortcutResponse::error(error.message, ShortcutBackend::Portal, cancelled); + }, + } + } + + match register_tauri_binding(&app_handle, &request.shortcut, request.id, event_sender) { + Ok(()) => { + if let Some(old_binding) = manager.bindings.remove(&request.id) { + close_binding(&app_handle, request.id, old_binding).await; + } + + manager.bindings.insert(request.id, ActiveBinding::Tauri { shortcut: request.shortcut.clone() }); + ShortcutResponse::success(ShortcutBackend::Tauri, request.shortcut) + }, + + Err(error) => ShortcutResponse::error( + format!("Failed to register shortcut: {error}"), + ShortcutBackend::Tauri, + false, + ), + } +} + +/// Determines whether an existing registration already satisfies the request. +fn registration_is_unchanged(current: Option<&str>, requested: &str, reconfigure: bool) -> bool { + current.is_some_and(|current| current.eq_ignore_ascii_case(requested)) && !reconfigure +} + +/// Removes an active binding and returns a successful disabled response. +async fn disable_binding( + app_handle: &tauri::AppHandle, + manager: &mut ShortcutManager, + id: Shortcut, +) -> ShortcutResponse { + if let Some(binding) = manager.bindings.remove(&id) { + close_binding(app_handle, id, binding).await; + } + + info!(Source = "Global shortcuts"; "Shortcut '{}' has been disabled.", id); + ShortcutResponse::success(ShortcutBackend::None, String::new()) +} + +#[cfg(target_os = "linux")] +/// Activates a prepared portal binding before closing the superseded binding. +async fn replace_portal_binding( + app_handle: &tauri::AppHandle, + manager: &mut ShortcutManager, + id: Shortcut, + new_binding: ActiveBinding, +) { + #[cfg(target_os = "linux")] + if let ActiveBinding::Portal { generation, .. } = &new_binding { + ACTIVE_PORTAL_GENERATIONS.lock().unwrap().insert(id, *generation); + } + + let old_binding = manager.bindings.insert(id, new_binding); + if let Some(old_binding) = old_binding { + close_binding(app_handle, id, old_binding).await; + } +} + +/// Releases the native resources owned by an active shortcut binding. +async fn close_binding(app_handle: &tauri::AppHandle, id: Shortcut, binding: ActiveBinding) { + match binding { + ActiveBinding::Tauri { shortcut } => { + if let Err(error) = app_handle.global_shortcut().unregister(shortcut.as_str()) { + warn!(Source = "Tauri"; "Failed to unregister shortcut '{shortcut}' for '{}': {error}", id); + } + }, + + #[cfg(target_os = "linux")] + ActiveBinding::Portal { generation, session, .. } => { + let is_still_active = ACTIVE_PORTAL_GENERATIONS.lock().unwrap().get(&id) == Some(&generation); + if is_still_active { + ACTIVE_PORTAL_GENERATIONS.lock().unwrap().remove(&id); + } + if let Err(error) = session.close().await { + warn!(Source = "XDG portal"; "Failed to close portal session for '{}': {error}", id); + } + }, + } +} + +/// Registers a shortcut callback through the Tauri global-shortcut plugin. +fn register_tauri_binding( + app_handle: &tauri::AppHandle, + shortcut: &str, + shortcut_id: Shortcut, + event_sender: broadcast::Sender, +) -> Result<(), tauri_plugin_global_shortcut::Error> { + app_handle.global_shortcut().on_shortcut(shortcut, move |_app, _shortcut, _event| { + if PROCESSING_SUSPENDED.load(Ordering::Relaxed) { + return; + } + + send_shortcut_pressed(&event_sender, shortcut_id, "Tauri"); + }) +} + +/// Publishes a shortcut activation using the existing runtime event format. +fn send_shortcut_pressed(event_sender: &broadcast::Sender, shortcut_id: Shortcut, source: &str) { + info!(Source = "Global shortcuts"; "Global shortcut triggered through {source} for '{}'.", shortcut_id); + if let Err(error) = event_sender.send(Event::new( + TauriEventType::GlobalShortcutPressed, + vec![shortcut_id.to_string()], + )) { + error!(Source = "Global shortcuts"; "Failed to send global shortcut event: {error}"); + } +} + +/// Suspends shortcut processing while preserving portal sessions for later use. +pub async fn suspend(app_handle: Option) -> ShortcutResponse { + PROCESSING_SUSPENDED.store(true, Ordering::Relaxed); + let Some(app_handle) = app_handle else { + PROCESSING_SUSPENDED.store(false, Ordering::Relaxed); + return ShortcutResponse::error("Main window not available", ShortcutBackend::None, false); + }; + + let manager = SHORTCUT_MANAGER.lock().await; + for (id, binding) in &manager.bindings { + if unregister_backend_during_suspend(binding.backend()) + && let ActiveBinding::Tauri { shortcut } = binding + && let Err(error) = app_handle.global_shortcut().unregister(shortcut.as_str()) + { + warn!(Source = "Tauri"; "Failed to suspend shortcut '{shortcut}' for '{}': {error}", id); + } + } + + ShortcutResponse::success(ShortcutBackend::None, String::new()) +} + +/// Resumes shortcut processing and restores shortcuts owned by the Tauri backend. +pub async fn resume( + app_handle: Option, + event_sender: Option>, +) -> ShortcutResponse { + let Some(app_handle) = app_handle else { + return ShortcutResponse::error("Main window not available", ShortcutBackend::None, false); + }; + + let Some(event_sender) = event_sender else { + return ShortcutResponse::error("Event broadcast not initialized", ShortcutBackend::None, false); + }; + + let manager = SHORTCUT_MANAGER.lock().await; + for (id, binding) in &manager.bindings { + if let ActiveBinding::Tauri { shortcut } = binding + && let Err(error) = register_tauri_binding(&app_handle, shortcut, *id, event_sender.clone()) + { + PROCESSING_SUSPENDED.store(false, Ordering::Relaxed); + return ShortcutResponse::error( + format!("Failed to resume shortcut: {error}"), + ShortcutBackend::Tauri, + false, + ); + } + } + + PROCESSING_SUSPENDED.store(false, Ordering::Relaxed); + ShortcutResponse::success(ShortcutBackend::None, String::new()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +/// Classifies portal failures so fallback and user feedback remain intentional. +enum PortalFailureKind { + /// The portal service or GlobalShortcuts interface is not available. + Unavailable, + + /// The user cancelled the portal interaction. + Cancelled, + + /// The portal explicitly denied the shortcut request. + Denied, + + /// The portal failed for another technical reason. + Technical, +} + +/// Determines whether an unavailable portal may safely fall back to Tauri. +fn may_fallback_to_tauri(failure: PortalFailureKind, current_backend: Option) -> bool { + failure == PortalFailureKind::Unavailable && current_backend.is_none_or(|backend| backend == ShortcutBackend::Tauri) +} + +/// Determines whether a backend must unregister its shortcut during suspension. +fn unregister_backend_during_suspend(backend: ShortcutBackend) -> bool { + backend == ShortcutBackend::Tauri +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Describes how a newly created portal session should obtain its shortcut. +enum PortalBindingAction { + /// Reuse a shortcut that the portal restored from an earlier session. + Restore, + + /// Ask the portal to bind or deliberately reconfigure the shortcut. + Bind, +} + +/// Selects restore or bind based on portal state and explicit user intent. +fn portal_binding_action(was_restored: bool, reconfigure: bool) -> PortalBindingAction { + if was_restored && !reconfigure { + PortalBindingAction::Restore + } else { + PortalBindingAction::Bind + } +} + +#[derive(Debug, Clone)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +/// Carries a classified portal failure and its technical description. +struct PortalFailure { + /// Identifies the semantic failure category. + kind: PortalFailureKind, + + /// Contains the technical error text used for logging and API responses. + message: String, +} + +impl PortalFailure { + #[cfg(target_os = "linux")] + /// Converts an `ashpd` error into the application's portal failure categories. + fn from_error(error: ashpd::Error) -> Self { + let kind = match &error { + ashpd::Error::Response(ResponseError::Cancelled) => PortalFailureKind::Cancelled, + ashpd::Error::Portal(ashpd::PortalError::Cancelled(_)) => PortalFailureKind::Cancelled, + ashpd::Error::Portal(ashpd::PortalError::NotAllowed(_)) => PortalFailureKind::Denied, + ashpd::Error::PortalNotFound(_) | ashpd::Error::RequiresVersion(_, _) => PortalFailureKind::Unavailable, + _ if portal_error_is_unavailable(&error.to_string()) => PortalFailureKind::Unavailable, + _ => PortalFailureKind::Technical, + }; + + Self { kind, message: error.to_string() } + } + + /// Creates an explicit portal-permission denial. + fn denied(message: impl Into) -> Self { + Self { kind: PortalFailureKind::Denied, message: message.into() } + } +} + +#[derive(Debug, Clone)] +/// Contains the portal-facing ID and effective label of a registered shortcut. +struct PortalShortcutInfo { + /// Contains the stable application-provided shortcut ID. + id: String, + + /// Contains the human-readable trigger returned by the portal. + effective_display_name: String, +} + +#[cfg(target_os = "linux")] +/// Normalizes shortcuts returned by `ashpd` for backend-independent processing. +fn normalize_portal_shortcuts(shortcuts: &[AshpdShortcut]) -> Vec { + shortcuts + .iter() + .map(|shortcut| PortalShortcutInfo { + id: shortcut.id().to_string(), + effective_display_name: shortcut.trigger_description().to_string(), + }) + .collect() +} + +/// Abstracts portal listing and binding operations for deterministic lifecycle tests. +trait PortalAdapter { + /// Lists shortcuts restored into the current portal session. + async fn list_shortcuts(&mut self) -> Result, PortalFailure>; + + /// Binds a shortcut with a localized description and preferred XDG trigger. + async fn bind_shortcut( + &mut self, + id: &str, + description: &str, + preferred_trigger: &str, + ) -> Result, PortalFailure>; +} + +/// Restores an approved portal shortcut or binds it when required. +async fn resolve_portal_shortcut( + adapter: &mut A, + request: &RegisterShortcutRequest, +) -> Result { + let listed = adapter.list_shortcuts().await?; + let restored = listed.iter().find(|shortcut| shortcut.id == request.id.to_string()); + if portal_binding_action(restored.is_some(), request.reconfigure) == PortalBindingAction::Restore { + return Ok(restored.unwrap().effective_display_name.clone()); + } + + let preferred_trigger = tauri_shortcut_to_xdg(&request.shortcut).map_err(|message| PortalFailure { + kind: PortalFailureKind::Technical, + message, + })?; + + let bound = adapter + .bind_shortcut( + &request.id.to_string(), + &request.description, + &preferred_trigger, + ) + .await?; + + bound + .into_iter() + .find(|shortcut| shortcut.id == request.id.to_string()) + .map(|shortcut| shortcut.effective_display_name) + .ok_or_else(|| PortalFailure::denied("The desktop portal did not approve the requested shortcut.")) +} + +#[cfg(target_os = "linux")] +/// Implements portal operations through `ashpd` for one live session. +struct AshpdPortalAdapter<'a> { + /// Provides access to the GlobalShortcuts portal interface. + portal: &'a GlobalShortcuts, + + /// Identifies the session whose shortcuts are listed or bound. + session: &'a ashpd::desktop::Session, +} + +#[cfg(target_os = "linux")] +impl PortalAdapter for AshpdPortalAdapter<'_> { + /// Lists and normalizes shortcuts restored by the XDG portal. + async fn list_shortcuts(&mut self) -> Result, PortalFailure> { + let response = self + .portal + .list_shortcuts(self.session, ListShortcutsOptions::default()) + .await + .and_then(|request| request.response()) + .map_err(PortalFailure::from_error)?; + + Ok(normalize_portal_shortcuts(response.shortcuts())) + } + + /// Binds one shortcut through the XDG portal and normalizes its response. + async fn bind_shortcut( + &mut self, + id: &str, + description: &str, + preferred_trigger: &str, + ) -> Result, PortalFailure> { + let shortcut = NewShortcut::new(id, description).preferred_trigger(preferred_trigger); + let response = self + .portal + .bind_shortcuts(self.session, &[shortcut], None, BindShortcutsOptions::default()) + .await + .and_then(|request| request.response()) + .map_err(PortalFailure::from_error)?; + + Ok(normalize_portal_shortcuts(response.shortcuts())) + } +} + +#[cfg(target_os = "linux")] +/// Detects D-Bus error strings that specifically indicate an unavailable portal. +fn portal_error_is_unavailable(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + normalized.contains("unknownmethod") + || normalized.contains("unknown method") + || normalized.contains("serviceunknown") + || normalized.contains("globalshortcuts portal was not found") +} + +#[cfg(target_os = "linux")] +/// Prepares a complete portal session and its signal listeners without replacing the active binding. +async fn prepare_portal_binding( + request: &RegisterShortcutRequest, + event_sender: broadcast::Sender, +) -> Result { + let portal = GlobalShortcuts::new().await.map_err(PortalFailure::from_error)?; + if portal.version() < 1 { + return Err(PortalFailure { + kind: PortalFailureKind::Unavailable, + message: "The GlobalShortcuts portal is not supported by this desktop.".to_string(), + }); + } + + let mut activated = portal.receive_activated().await.map_err(PortalFailure::from_error)?; + let mut changed = portal.receive_shortcuts_changed().await.map_err(PortalFailure::from_error)?; + + let session = portal + .create_session(CreateSessionOptions::default()) + .await + .map_err(PortalFailure::from_error)?; + + let mut adapter = AshpdPortalAdapter { portal: &portal, session: &session }; + let effective_display_name = match resolve_portal_shortcut(&mut adapter, request).await { + Ok(effective_display_name) => effective_display_name, + Err(error) => { + let _ = session.close().await; + return Err(error); + }, + }; + + let generation = NEXT_PORTAL_GENERATION.fetch_add(1, Ordering::Relaxed); + let activation_sender = event_sender.clone(); + + tauri::async_runtime::spawn(async move { + while let Some(signal) = activated.next().await { + let Some(id) = Shortcut::from_portal_id(signal.shortcut_id()) else { + continue; + }; + + let is_active = ACTIVE_PORTAL_GENERATIONS.lock().unwrap().get(&id) == Some(&generation); + if is_active && !PROCESSING_SUSPENDED.load(Ordering::Relaxed) { + send_shortcut_pressed(&activation_sender, id, "XDG portal"); + } + } + }); + + tauri::async_runtime::spawn(async move { + while let Some(signal) = changed.next().await { + for shortcut in signal.shortcuts() { + let Some(id) = Shortcut::from_portal_id(shortcut.id()) else { + continue; + }; + + let is_active = ACTIVE_PORTAL_GENERATIONS.lock().unwrap().get(&id) == Some(&generation); + if is_active { + let _ = event_sender.send(Event::new( + TauriEventType::GlobalShortcutChanged, + vec![id.to_string(), shortcut.trigger_description().to_string()], + )); + } + } + } + }); + + Ok(ActiveBinding::Portal { + shortcut: request.shortcut.clone(), + effective_display_name, + generation, + session, + }) +} + +/// Converts a Tauri-format shortcut into the XDG shortcuts specification format. +fn tauri_shortcut_to_xdg(shortcut: &str) -> Result { + let mut converted = Vec::new(); + let parts: Vec<&str> = shortcut.split('+').collect(); + if parts.len() < 2 { + return Err(format!("Invalid global shortcut '{shortcut}'.")); + } + + for (index, part) in parts.iter().enumerate() { + let normalized = part.to_ascii_lowercase(); + let is_last = index == parts.len() - 1; + let value = if !is_last { + match normalized.as_str() { + "cmdorcontrol" | "commandorcontrol" | "ctrl" | "control" => "CTRL", + "shift" => "SHIFT", + "alt" | "option" => "ALT", + "cmd" | "command" | "meta" | "super" => "LOGO", + + _ => return Err(format!("Unsupported shortcut modifier '{part}'.")), + }.to_string() + + } else { + + match normalized.as_str() { + "enter" => "Return".to_string(), + "backspace" => "BackSpace".to_string(), + "pageup" => "Prior".to_string(), + "pagedown" => "Next".to_string(), + "arrowup" => "Up".to_string(), + "arrowdown" => "Down".to_string(), + "arrowleft" => "Left".to_string(), + "arrowright" => "Right".to_string(), + "escape" => "Escape".to_string(), + "delete" => "Delete".to_string(), + "insert" => "Insert".to_string(), + "home" => "Home".to_string(), + "end" => "End".to_string(), + "space" => "space".to_string(), + "tab" => "Tab".to_string(), + "up" => "Up".to_string(), + "down" => "Down".to_string(), + "left" => "Left".to_string(), + "right" => "Right".to_string(), + "minus" => "minus".to_string(), + "equal" => "equal".to_string(), + "bracketleft" => "bracketleft".to_string(), + "bracketright" => "bracketright".to_string(), + "backslash" => "backslash".to_string(), + "semicolon" => "semicolon".to_string(), + "quote" => "apostrophe".to_string(), + "backquote" => "grave".to_string(), + "comma" => "comma".to_string(), + "period" => "period".to_string(), + "slash" => "slash".to_string(), + + _ if normalized.starts_with("num") => numpad_key_to_xdg(&normalized).ok_or_else(|| format!("Unsupported shortcut key '{part}'."))?, + _ if part.len() == 1 && part.as_bytes()[0].is_ascii_alphabetic() => normalized, + _ if part.chars().all(|character| character.is_ascii_alphanumeric() || character == '_') => part.to_string(), + + _ => return Err(format!("Unsupported shortcut key '{part}'.")), + } + }; + + converted.push(value); + } + + Ok(converted.join("+")) +} + +/// Converts Tauri numpad key names into XKB keypad key symbols. +fn numpad_key_to_xdg(key: &str) -> Option { + let suffix = match key { + "num0" => "0", + "num1" => "1", + "num2" => "2", + "num3" => "3", + "num4" => "4", + "num5" => "5", + "num6" => "6", + "num7" => "7", + "num8" => "8", + "num9" => "9", + + "numadd" => "Add", + "numsubtract" => "Subtract", + "nummultiply" => "Multiply", + "numdivide" => "Divide", + "numdecimal" => "Decimal", + "numenter" => "Enter", + + _ => return None, + }; + + Some(format!("KP_{suffix}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default)] + /// Simulates portal listing and binding outcomes for lifecycle tests. + struct FakePortalAdapter { + /// Shortcuts returned by the simulated list operation. + listed: Vec, + /// Shortcuts returned by the simulated bind operation. + bound: Vec, + /// Optional failure returned instead of a successful bind result. + bind_failure: Option, + /// Counts bind calls so tests can detect unnecessary portal dialogs. + bind_calls: usize, + /// Records the preferred trigger supplied to the simulated portal. + last_preferred_trigger: String, + } + + impl PortalAdapter for FakePortalAdapter { + /// Returns the shortcuts configured as restored by the fake portal. + async fn list_shortcuts(&mut self) -> Result, PortalFailure> { + Ok(self.listed.clone()) + } + + /// Records the bind request and returns the configured result or failure. + async fn bind_shortcut( + &mut self, + _id: &str, + _description: &str, + preferred_trigger: &str, + ) -> Result, PortalFailure> { + self.bind_calls += 1; + self.last_preferred_trigger = preferred_trigger.to_string(); + if let Some(error) = &self.bind_failure { + return Err(error.clone()); + } + Ok(self.bound.clone()) + } + } + + /// Creates a voice-recording shortcut request for portal lifecycle tests. + fn portal_request(shortcut: &str, reconfigure: bool) -> RegisterShortcutRequest { + RegisterShortcutRequest { + id: Shortcut::VoiceRecordingToggle, + shortcut: shortcut.to_string(), + description: "Toggle voice recording".to_string(), + reconfigure, + } + } + + /// Creates normalized portal shortcut data for test responses. + fn portal_shortcut(display_name: &str) -> PortalShortcutInfo { + PortalShortcutInfo { + id: Shortcut::VoiceRecordingToggle.to_string(), + effective_display_name: display_name.to_string(), + } + } + + #[test] + /// Verifies conversion from representative Tauri combinations to XDG triggers. + fn converts_tauri_shortcut_to_xdg_trigger() { + assert_eq!(tauri_shortcut_to_xdg("CmdOrControl+Shift+1").unwrap(), "CTRL+SHIFT+1"); + assert_eq!(tauri_shortcut_to_xdg("Control+Alt+Enter").unwrap(), "CTRL+ALT+Return"); + assert_eq!(tauri_shortcut_to_xdg("Super+Space").unwrap(), "LOGO+space"); + assert_eq!(tauri_shortcut_to_xdg("Ctrl+A").unwrap(), "CTRL+a"); + assert_eq!(tauri_shortcut_to_xdg("Ctrl+Num1").unwrap(), "CTRL+KP_1"); + assert_eq!(tauri_shortcut_to_xdg("Ctrl+Quote").unwrap(), "CTRL+apostrophe"); + } + + #[test] + /// Verifies that unsupported modifiers and keys are rejected during conversion. + fn rejects_unsupported_xdg_trigger_parts() { + assert!(tauri_shortcut_to_xdg("Hyper+1").is_err()); + assert!(tauri_shortcut_to_xdg("Ctrl++").is_err()); + } + + #[test] + /// Verifies that unchanged settings do not cause duplicate native registrations. + fn identical_configuration_is_not_registered_twice() { + assert!(registration_is_unchanged(Some("CmdOrControl+Shift+1"), "cmdorcontrol+shift+1", false)); + assert!(!registration_is_unchanged(Some("CmdOrControl+Shift+1"), "CmdOrControl+Shift+2", false)); + assert!(!registration_is_unchanged(Some("CmdOrControl+Shift+1"), "CmdOrControl+Shift+1", true)); + } + + #[tokio::test] + /// Verifies that an approved shortcut is restored without another bind request. + async fn portal_adapter_restores_without_binding() { + let mut adapter = FakePortalAdapter { + listed: vec![portal_shortcut("Ctrl+Shift+1")], + ..Default::default() + }; + + let display_name = resolve_portal_shortcut(&mut adapter, &portal_request("CmdOrControl+Shift+1", false)).await.unwrap(); + + assert_eq!(display_name, "Ctrl+Shift+1"); + assert_eq!(adapter.bind_calls, 0); + } + + #[tokio::test] + /// Verifies that a missing shortcut is bound with its converted preferred trigger. + async fn portal_adapter_binds_missing_shortcut_with_preferred_trigger() { + let mut adapter = FakePortalAdapter { + bound: vec![portal_shortcut("Ctrl+Shift+1")], + ..Default::default() + }; + + let display_name = resolve_portal_shortcut(&mut adapter, &portal_request("CmdOrControl+Shift+1", false)).await.unwrap(); + + assert_eq!(display_name, "Ctrl+Shift+1"); + assert_eq!(adapter.bind_calls, 1); + assert_eq!(adapter.last_preferred_trigger, "CTRL+SHIFT+1"); + } + + #[tokio::test] + /// Verifies that deliberate changes bind again instead of restoring the old trigger. + async fn portal_adapter_rebinds_after_deliberate_change() { + let mut adapter = FakePortalAdapter { + listed: vec![portal_shortcut("Ctrl+Shift+1")], + bound: vec![portal_shortcut("Ctrl+Shift+2")], + ..Default::default() + }; + + let display_name = resolve_portal_shortcut(&mut adapter, &portal_request("CmdOrControl+Shift+2", true)).await.unwrap(); + + assert_eq!(display_name, "Ctrl+Shift+2"); + assert_eq!(adapter.bind_calls, 1); + assert_eq!(adapter.last_preferred_trigger, "CTRL+SHIFT+2"); + } + + #[tokio::test] + /// Verifies that portal cancellation remains distinguishable from technical errors. + async fn portal_adapter_preserves_cancellation() { + let mut adapter = FakePortalAdapter { + bind_failure: Some(PortalFailure { + kind: PortalFailureKind::Cancelled, + message: "cancelled".to_string(), + }), + ..Default::default() + }; + + let error = resolve_portal_shortcut(&mut adapter, &portal_request("CmdOrControl+Shift+1", false)).await.unwrap_err(); + + assert_eq!(error.kind, PortalFailureKind::Cancelled); + assert_eq!(adapter.bind_calls, 1); + } + + #[test] + /// Verifies that fallback is restricted to unavailable portals and safe active states. + fn fallback_is_limited_to_an_unavailable_portal() { + assert!(may_fallback_to_tauri(PortalFailureKind::Unavailable, None)); + assert!(may_fallback_to_tauri(PortalFailureKind::Unavailable, Some(ShortcutBackend::Tauri))); + assert!(!may_fallback_to_tauri(PortalFailureKind::Unavailable, Some(ShortcutBackend::Portal))); + assert!(!may_fallback_to_tauri(PortalFailureKind::Cancelled, None)); + assert!(!may_fallback_to_tauri(PortalFailureKind::Denied, None)); + assert!(!may_fallback_to_tauri(PortalFailureKind::Technical, None)); + } + + #[test] + /// Verifies that suspend keeps portal sessions while unregistering Tauri bindings. + fn suspend_keeps_portal_session_registered() { + assert!(!unregister_backend_during_suspend(ShortcutBackend::Portal)); + assert!(unregister_backend_during_suspend(ShortcutBackend::Tauri)); + } + + #[cfg(target_os = "linux")] + #[test] + /// Verifies recognition of unavailable-portal D-Bus errors without misclassifying rejection. + fn only_unavailable_portal_errors_allow_fallback() { + assert!(portal_error_is_unavailable("org.freedesktop.DBus.Error.UnknownMethod")); + assert!(portal_error_is_unavailable("ServiceUnknown")); + assert!(!portal_error_is_unavailable("Portal request was cancelled")); + assert!(!portal_error_is_unavailable("NotAllowed")); + } +} \ No newline at end of file diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 353c808e..def3c7b8 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -19,4 +19,5 @@ pub mod certificate_factory; pub mod runtime_api_token; pub mod stale_process_cleanup; mod sidecar_types; -mod file_actions; \ No newline at end of file +mod file_actions; +pub mod global_shortcuts; \ No newline at end of file