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