From 1e5f07cb010bd64ac9eec7a350df0314d44612c1 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 21 Jul 2026 14:43:33 +0200 Subject: [PATCH] Added focused-window shortcut fallback on Linux (#882) --- .../Assistants/I18N/allTexts.lua | 3 + .../Components/ConfigurationShortcut.razor.cs | 6 +- .../Components/VoiceRecorder.razor.cs | 97 ++++++++++++++++- .../plugin.lua | 3 + .../plugin.lua | 3 + app/MindWork AI Studio/Program.cs | 3 +- .../Tools/Rust/ShortcutBackend.cs | 1 + .../Tools/Services/GlobalShortcutService.cs | 92 +++++++++++++++- app/MindWork AI Studio/wwwroot/app.js | 100 ++++++++++++++++++ .../wwwroot/changelog/v26.7.3.md | 2 +- runtime/src/global_shortcuts.rs | 57 ++++++++-- 11 files changed, 350 insertions(+), 17 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index c4945835..d0213f55 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -8944,6 +8944,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T8163 -- The generated assistant plugin is invalid. Issue: {0} UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T939708112"] = "The generated assistant plugin is invalid. Issue: {0}" +-- The voice recording shortcut currently works only while AI Studio is focused. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T1204510649"] = "The voice recording shortcut currently works only while AI Studio is focused." + -- The global shortcut could not be registered. The previous shortcut remains active. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T2266307101"] = "The global shortcut could not be registered. The previous shortcut remains active." diff --git a/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs index e717787c..3cb641a2 100644 --- a/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs @@ -15,7 +15,7 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore private IDialogService DialogService { get; init; } = null!; [Inject] - private RustService RustService { get; init; } = null!; + private GlobalShortcutService GlobalShortcutService { get; init; } = null!; /// /// The shortcut binding data. @@ -69,7 +69,7 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore { // Suspend shortcut processing while the dialog is open, so the user can // press the current shortcut to re-enter it without triggering the action: - await this.RustService.SuspendShortcutProcessing(); + await this.GlobalShortcutService.SuspendShortcutProcessing(); try { @@ -106,7 +106,7 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore finally { // Resume the shortcut processing when the dialog is closed: - await this.RustService.ResumeShortcutProcessing(); + await this.GlobalShortcutService.ResumeShortcutProcessing(); } } } diff --git a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs index f754695f..1cd1e9fb 100644 --- a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs @@ -22,6 +22,9 @@ public partial class VoiceRecorder : MSGComponentBase [Inject] private RustService RustService { get; init; } = null!; + [Inject] + private GlobalShortcutService GlobalShortcutService { get; init; } = null!; + [Inject] private ISnackbar Snackbar { get; init; } = null!; @@ -35,6 +38,8 @@ public partial class VoiceRecorder : MSGComponentBase protected override async Task OnInitializedAsync() { + this.GlobalShortcutService.RuntimeStateChanged += this.OnShortcutRuntimeStateChanged; + // Register for global shortcut events: this.ApplyFilters([], [Event.TAURI_EVENT_RECEIVED, Event.VOICE_RECORDING_AVAILABILITY_CHANGED]); @@ -43,8 +48,15 @@ public partial class VoiceRecorder : MSGComponentBase protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender && this.ShouldRenderVoiceRecording) - await this.EnsureSoundEffectsAvailableAsync("during the first interactive render"); + if (firstRender) + { + this.localShortcutDotNetReference = DotNetObjectReference.Create(this); + this.localShortcutInteropReady = true; + await this.ApplyLocalShortcutState(this.GlobalShortcutService.GetRuntimeState(Shortcut.VOICE_RECORDING_TOGGLE)); + + if (this.ShouldRenderVoiceRecording) + await this.EnsureSoundEffectsAvailableAsync("during the first interactive render"); + } await base.OnAfterRenderAsync(firstRender); } @@ -69,6 +81,36 @@ public partial class VoiceRecorder : MSGComponentBase } } + private async Task OnShortcutRuntimeStateChanged(GlobalShortcutRuntimeState runtimeState) + { + try + { + await this.InvokeAsync(() => this.ApplyLocalShortcutState(runtimeState)); + } + catch (ObjectDisposedException) + { + this.Logger.LogDebug("Ignoring a shortcut state change after the voice recorder was disposed."); + } + catch (InvalidOperationException ex) + { + this.Logger.LogDebug(ex, "The focused-window shortcut listener could not be updated because the component dispatcher is unavailable."); + } + } + + [JSInvokable] + public async Task OnLocalShortcutPressed() + { + var runtimeState = this.GlobalShortcutService.GetRuntimeState(Shortcut.VOICE_RECORDING_TOGGLE); + if (runtimeState.Backend is not ShortcutBackend.LOCAL || runtimeState.IsSuspended) + { + this.Logger.LogDebug("Ignoring a stale focused-window shortcut event."); + return; + } + + this.Logger.LogInformation("Focused-window shortcut triggered for voice recording toggle."); + await this.ToggleRecordingFromShortcut(); + } + /// /// Toggles the recording state when triggered by a global shortcut. /// @@ -101,6 +143,48 @@ public partial class VoiceRecorder : MSGComponentBase private string? currentRecordingPath; private string? finalRecordingPath; private DotNetObjectReference? dotNetReference; + private DotNetObjectReference? localShortcutDotNetReference; + private bool localShortcutInteropReady; + + private async Task ApplyLocalShortcutState(GlobalShortcutRuntimeState runtimeState) + { + if (!this.localShortcutInteropReady + || this.localShortcutDotNetReference is null + || runtimeState.ShortcutId is not Shortcut.VOICE_RECORDING_TOGGLE) + { + return; + } + + try + { + if (runtimeState.Backend is ShortcutBackend.LOCAL + && !runtimeState.IsSuspended + && !string.IsNullOrWhiteSpace(runtimeState.Shortcut)) + { + await this.JsRuntime.InvokeVoidAsync( + "localShortcut.register", + "voice-recording-toggle", + runtimeState.Shortcut, + this.localShortcutDotNetReference); + } + else + { + await this.JsRuntime.InvokeVoidAsync("localShortcut.unregister", "voice-recording-toggle"); + } + } + catch (JSDisconnectedException) + { + this.Logger.LogDebug("The focused-window shortcut listener could not be updated because the JS runtime disconnected."); + } + catch (OperationCanceledException) + { + this.Logger.LogDebug("Updating the focused-window shortcut listener was canceled."); + } + catch (JSException ex) + { + this.Logger.LogWarning(ex, "Failed to update the focused-window shortcut listener."); + } + } private bool ShouldRenderVoiceRecording => PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager) && !string.IsNullOrWhiteSpace(this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider); @@ -482,6 +566,15 @@ public partial class VoiceRecorder : MSGComponentBase protected override void DisposeResources() { + this.GlobalShortcutService.RuntimeStateChanged -= this.OnShortcutRuntimeStateChanged; + + if (this.localShortcutInteropReady) + _ = this.JsRuntime.InvokeVoidAsync("localShortcut.unregister", "voice-recording-toggle"); + + this.localShortcutDotNetReference?.Dispose(); + this.localShortcutDotNetReference = null; + this.localShortcutInteropReady = false; + // Clean up recording resources if still active: if (this.currentRecordingStream is not null) { diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index e06c4386..01d85b7a 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -8946,6 +8946,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T8163 -- The generated assistant plugin is invalid. Issue: {0} UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T939708112"] = "Das generierte Assistenten-Plugin ist ungültig. Problem: {0}" +-- The voice recording shortcut currently works only while AI Studio is focused. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T1204510649"] = "Die Tastenkombination für Sprachaufnahmen funktioniert derzeit nur, wenn AI Studio im Vordergrund aktiv ist." + -- The global shortcut could not be registered. The previous shortcut remains active. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T2266307101"] = "Die globale Tastenkombination konnte nicht registriert werden. Die vorherige Tastenkombination bleibt aktiv." diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 4eaaf657..bb5e3610 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -8946,6 +8946,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T8163 -- The generated assistant plugin is invalid. Issue: {0} UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T939708112"] = "The generated assistant plugin is invalid. Issue: {0}" +-- The voice recording shortcut currently works only while AI Studio is focused. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T1204510649"] = "The voice recording shortcut currently works only while AI Studio is focused." + -- The global shortcut could not be registered. The previous shortcut remains active. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T2266307101"] = "The global shortcut could not be registered. The previous shortcut remains active." diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 3e775326..483600f2 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -163,6 +163,7 @@ internal sealed class Program builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -180,7 +181,7 @@ internal sealed class Program builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddSingleton(); - builder.Services.AddHostedService(); + builder.Services.AddHostedService(serviceProvider => serviceProvider.GetRequiredService()); builder.Services.AddHostedService(); // ReSharper disable AccessToDisposedClosure diff --git a/app/MindWork AI Studio/Tools/Rust/ShortcutBackend.cs b/app/MindWork AI Studio/Tools/Rust/ShortcutBackend.cs index ecdeee8a..49fe65d9 100644 --- a/app/MindWork AI Studio/Tools/Rust/ShortcutBackend.cs +++ b/app/MindWork AI Studio/Tools/Rust/ShortcutBackend.cs @@ -8,4 +8,5 @@ public enum ShortcutBackend NONE, PORTAL, TAURI, + LOCAL, } diff --git a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs index 403d0fc2..fd04a0d5 100644 --- a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs +++ b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs @@ -20,13 +20,19 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv } private readonly SemaphoreSlim registrationSemaphore = new(1, 1); + private readonly object runtimeStateLock = new(); private readonly Dictionary lastSentStates = []; private readonly Dictionary lastNonEmptyShortcuts = []; + private readonly Dictionary runtimeBindings = []; private readonly ILogger logger; private readonly SettingsManager settingsManager; private readonly MessageBus messageBus; private readonly RustService rustService; private readonly VoiceRecordingAvailabilityService voiceRecordingAvailabilityService; + private bool isProcessingSuspended; + private bool localFallbackWarningShown; + + public event Func? RuntimeStateChanged; public GlobalShortcutService( ILogger logger, @@ -58,6 +64,45 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv await base.StopAsync(cancellationToken); } + /// + /// Returns the active backend and processing state for a shortcut. + /// + public GlobalShortcutRuntimeState GetRuntimeState(Shortcut shortcutId) + { + lock (this.runtimeStateLock) + { + if (this.runtimeBindings.TryGetValue(shortcutId, out var binding)) + return new(shortcutId, binding.Shortcut, binding.Backend, this.isProcessingSuspended); + + return new(shortcutId, string.Empty, ShortcutBackend.NONE, this.isProcessingSuspended); + } + } + + /// + /// Pauses native and focused-window shortcut processing. + /// + public async Task SuspendShortcutProcessing() + { + lock (this.runtimeStateLock) + this.isProcessingSuspended = true; + + await this.PublishAllRuntimeStates(); + return await this.rustService.SuspendShortcutProcessing(); + } + + /// + /// Resumes native and focused-window shortcut processing. + /// + public async Task ResumeShortcutProcessing() + { + var result = await this.rustService.ResumeShortcutProcessing(); + lock (this.runtimeStateLock) + this.isProcessingSuspended = false; + + await this.PublishAllRuntimeStates(); + return result; + } + #region IMessageBusReceiver public async Task ProcessMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) @@ -155,6 +200,9 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv if (!string.IsNullOrWhiteSpace(requestedState.Shortcut)) this.lastNonEmptyShortcuts[shortcutId] = requestedState.Shortcut; + lock (this.runtimeStateLock) + this.runtimeBindings[shortcutId] = new(requestedState.Shortcut, result.Backend); + this.logger.LogInformation( "Global shortcut '{ShortcutId}' ({Shortcut}) synchronized through {Backend}.", shortcutId, @@ -163,6 +211,15 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv if (result.Backend is ShortcutBackend.PORTAL) await this.UpdateEffectiveDisplayName(shortcutId, result.EffectiveDisplayName); + + await this.PublishRuntimeState(shortcutId); + if (result.Backend is ShortcutBackend.LOCAL && !this.localFallbackWarningShown) + { + this.localFallbackWarningShown = true; + await this.messageBus.SendWarning(new( + Icons.Material.Filled.Keyboard, + TB("The voice recording shortcut currently works only while AI Studio is focused."))); + } } else { @@ -236,6 +293,31 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv await this.messageBus.SendMessage(null, Event.GLOBAL_SHORTCUT_CHANGED); } + private async Task PublishAllRuntimeStates() + { + Shortcut[] shortcutIds; + lock (this.runtimeStateLock) + shortcutIds = this.runtimeBindings.Keys.ToArray(); + + foreach (var shortcutId in shortcutIds) + await this.PublishRuntimeState(shortcutId); + } + + private async Task PublishRuntimeState(Shortcut shortcutId) + { + var subscribers = this.RuntimeStateChanged; + if (subscribers is null) + return; + + var handlers = subscribers.GetInvocationList() + .Cast>() + .ToArray(); + var runtimeState = this.GetRuntimeState(shortcutId); + + foreach (var handler in handlers) + await handler(runtimeState); + } + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(GlobalShortcutService).Namespace, nameof(GlobalShortcutService)); private async Task GetShortcutState(Shortcut shortcutId, ShortcutSyncSource source) @@ -262,4 +344,12 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv } private readonly record struct ShortcutState(string Shortcut, bool IsEnabled, bool UsesPersistedFallback); -} \ No newline at end of file + + private readonly record struct ShortcutRuntimeBinding(string Shortcut, ShortcutBackend Backend); +} + +public sealed record GlobalShortcutRuntimeState( + Shortcut ShortcutId, + string Shortcut, + ShortcutBackend Backend, + bool IsSuspended); \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/app.js b/app/MindWork AI Studio/wwwroot/app.js index 0f2a49ec..160b5227 100644 --- a/app/MindWork AI Studio/wwwroot/app.js +++ b/app/MindWork AI Studio/wwwroot/app.js @@ -169,4 +169,104 @@ window.unregisterEscapeHandler = function (id) { document.removeEventListener('keydown', handler, true) escapeHandlers.delete(id) +} + +const localShortcutHandlers = new Map() + +function tauriKeyFromKeyboardCode(code) { + if (/^Key[A-Z]$/.test(code)) + return code.substring(3) + + if (/^Digit[0-9]$/.test(code)) + return code.substring(5) + + if (/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(code)) + return code + + const keys = { + Space: 'Space', Enter: 'Enter', Tab: 'Tab', Escape: 'Escape', Backspace: 'Backspace', + Delete: 'Delete', Insert: 'Insert', Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown', + ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', + Numpad0: 'Num0', Numpad1: 'Num1', Numpad2: 'Num2', Numpad3: 'Num3', Numpad4: 'Num4', + Numpad5: 'Num5', Numpad6: 'Num6', Numpad7: 'Num7', Numpad8: 'Num8', Numpad9: 'Num9', + NumpadAdd: 'NumAdd', NumpadSubtract: 'NumSubtract', NumpadMultiply: 'NumMultiply', + NumpadDivide: 'NumDivide', NumpadDecimal: 'NumDecimal', NumpadEnter: 'NumEnter', + Minus: 'Minus', Equal: 'Equal', BracketLeft: 'BracketLeft', BracketRight: 'BracketRight', + Backslash: 'Backslash', Semicolon: 'Semicolon', Quote: 'Quote', Backquote: 'Backquote', + Comma: 'Comma', Period: 'Period', Slash: 'Slash' + } + + return keys[code] ?? code +} + +function parseTauriShortcut(shortcut) { + const expected = { ctrl: false, shift: false, alt: false, meta: false, key: '' } + const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform) + + for (const rawPart of shortcut.split('+')) { + const part = rawPart.trim().toLowerCase() + switch (part) { + case 'cmdorcontrol': + case 'commandorcontrol': + expected[isMac ? 'meta' : 'ctrl'] = true + break + case 'ctrl': + case 'control': + expected.ctrl = true + break + case 'cmd': + case 'command': + case 'meta': + case 'super': + expected.meta = true + break + case 'shift': + expected.shift = true + break + case 'alt': + case 'option': + expected.alt = true + break + default: + expected.key = rawPart.trim() + break + } + } + + return expected +} + +window.localShortcut = { + register: function (id, shortcut, dotNetReference) { + this.unregister(id) + const expected = parseTauriShortcut(shortcut) + if (!expected.key) + return + + const handler = function (event) { + if (event.repeat + || event.ctrlKey !== expected.ctrl + || event.shiftKey !== expected.shift + || event.altKey !== expected.alt + || event.metaKey !== expected.meta + || tauriKeyFromKeyboardCode(event.code).toLowerCase() !== expected.key.toLowerCase()) + return + + event.preventDefault() + event.stopPropagation() + dotNetReference.invokeMethodAsync('OnLocalShortcutPressed').catch(() => {}) + } + + document.addEventListener('keydown', handler, true) + localShortcutHandlers.set(id, handler) + }, + + unregister: function (id) { + const handler = localShortcutHandlers.get(id) + if (!handler) + return + + document.removeEventListener('keydown', handler, true) + localShortcutHandlers.delete(id) + } } \ No newline at end of file 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 4da9f648..526cb489 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md @@ -16,7 +16,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 the voice recording shortcut on Linux so it works globally on supported desktops and while AI Studio is focused on other Linux desktops. - Fixed voice recording and transcription on Linux. - Fixed copied content from AI Studio not remaining available on the clipboard on Linux. - Fixed dragging and dropping files from the home folder into the Linux Flatpak version. diff --git a/runtime/src/global_shortcuts.rs b/runtime/src/global_shortcuts.rs index c6927b8a..bb62e993 100644 --- a/runtime/src/global_shortcuts.rs +++ b/runtime/src/global_shortcuts.rs @@ -91,6 +91,9 @@ pub enum ShortcutBackend { /// The Tauri global-shortcut plugin manages the shortcut. Tauri, + + /// The focused application window handles the shortcut. + Local, } /// Response for shortcut registration and processing state changes. @@ -151,6 +154,12 @@ enum ActiveBinding { shortcut: String, }, + /// Stores a shortcut handled within the focused application window. + Local { + /// Contains the registered shortcut in Tauri syntax. + shortcut: String, + }, + #[cfg(target_os = "linux")] /// Stores a shortcut and its live XDG portal session. Portal { @@ -170,6 +179,7 @@ impl ActiveBinding { fn shortcut(&self) -> &str { match self { Self::Tauri { shortcut } => shortcut, + Self::Local { shortcut } => shortcut, #[cfg(target_os = "linux")] Self::Portal { shortcut, .. } => shortcut, } @@ -179,6 +189,7 @@ impl ActiveBinding { fn backend(&self) -> ShortcutBackend { match self { Self::Tauri { .. } => ShortcutBackend::Tauri, + Self::Local { .. } => ShortcutBackend::Local, #[cfg(target_os = "linux")] Self::Portal { .. } => ShortcutBackend::Portal, } @@ -188,6 +199,7 @@ impl ActiveBinding { fn effective_display_name(&self) -> String { match self { Self::Tauri { shortcut } => shortcut.clone(), + Self::Local { shortcut } => shortcut.clone(), #[cfg(target_os = "linux")] Self::Portal { effective_display_name, .. } => effective_display_name.clone(), } @@ -246,8 +258,15 @@ pub async fn register( Err(error) => { let current_backend = manager.bindings.get(&request.id).map(ActiveBinding::backend); - if may_fallback_to_tauri(error.kind, current_backend) { - warn!(Source = "XDG portal"; "Global shortcut registration failed; using the Tauri X11 backend: {}", error.message); + if may_fallback_to_local(error.kind, current_backend) { + warn!(Source = "XDG portal"; "Global shortcut registration failed; using the focused-window fallback: {}", error.message); + + 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::Local { shortcut: request.shortcut.clone() }); + return ShortcutResponse::success(ShortcutBackend::Local, request.shortcut); } else { let cancelled = error.kind == PortalFailureKind::Cancelled; if cancelled { @@ -264,6 +283,7 @@ pub async fn register( } } + #[cfg(not(target_os = "linux"))] match register_tauri_binding(&app_handle, &request.shortcut, request.id, event_sender) { Ok(()) => { if let Some(old_binding) = manager.bindings.remove(&request.id) { @@ -329,6 +349,8 @@ async fn close_binding(app_handle: &tauri::AppHandle, id: Shortcut, binding: Act } }, + ActiveBinding::Local { .. } => {}, + #[cfg(target_os = "linux")] ActiveBinding::Portal { generation, session, .. } => { let is_still_active = ACTIVE_PORTAL_GENERATIONS.lock().unwrap().get(&id) == Some(&generation); @@ -447,8 +469,8 @@ enum PortalFailureKind { Technical, } -/// Determines whether a failed portal attempt may safely fall back to Tauri. -fn may_fallback_to_tauri(_failure: PortalFailureKind, current_backend: Option) -> bool { +/// Determines whether a failed portal attempt may safely use the focused-window fallback. +fn may_fallback_to_local(_failure: PortalFailureKind, current_backend: Option) -> bool { current_backend != Some(ShortcutBackend::Portal) } @@ -946,29 +968,46 @@ mod tests { } #[test] - /// Verifies that all initial portal failures use the Tauri fallback. - fn all_initial_portal_failures_use_tauri_fallback() { + /// Verifies that all initial portal failures use the focused-window fallback. + fn all_initial_portal_failures_use_local_fallback() { for failure in [ PortalFailureKind::Unavailable, PortalFailureKind::Cancelled, PortalFailureKind::Denied, PortalFailureKind::Technical, ] { - assert!(may_fallback_to_tauri(failure, None)); + assert!(may_fallback_to_local(failure, None)); } } #[test] /// Verifies that a failed reconfiguration never replaces an active portal binding. fn failed_reconfiguration_preserves_portal_binding() { - assert!(!may_fallback_to_tauri(PortalFailureKind::Cancelled, Some(ShortcutBackend::Portal))); - assert!(!may_fallback_to_tauri(PortalFailureKind::Denied, Some(ShortcutBackend::Portal))); + for failure in [ + PortalFailureKind::Unavailable, + PortalFailureKind::Cancelled, + PortalFailureKind::Denied, + PortalFailureKind::Technical, + ] { + assert!(!may_fallback_to_local(failure, Some(ShortcutBackend::Portal))); + } + } + + #[test] + /// Verifies that focused-window bindings expose their shortcut and backend consistently. + fn local_binding_reports_runtime_state() { + let binding = ActiveBinding::Local { shortcut: "CmdOrControl+3".to_string() }; + + assert_eq!(binding.shortcut(), "CmdOrControl+3"); + assert_eq!(binding.backend(), ShortcutBackend::Local); + assert_eq!(binding.effective_display_name(), "CmdOrControl+3"); } #[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::Local)); assert!(unregister_backend_during_suspend(ShortcutBackend::Tauri)); }