Added focused-window shortcut fallback on Linux (#882)
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer 2026-07-21 14:43:33 +02:00 committed by GitHub
parent db508edc28
commit 1e5f07cb01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 350 additions and 17 deletions

View File

@ -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."

View File

@ -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!;
/// <summary>
/// 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();
}
}
}

View File

@ -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();
}
/// <summary>
/// Toggles the recording state when triggered by a global shortcut.
/// </summary>
@ -101,6 +143,48 @@ public partial class VoiceRecorder : MSGComponentBase
private string? currentRecordingPath;
private string? finalRecordingPath;
private DotNetObjectReference<VoiceRecorder>? dotNetReference;
private DotNetObjectReference<VoiceRecorder>? 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)
{

View File

@ -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."

View File

@ -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."

View File

@ -163,6 +163,7 @@ internal sealed class Program
builder.Services.AddSingleton<AIJobService>();
builder.Services.AddSingleton<AssistantSessionService>();
builder.Services.AddSingleton<VoiceRecordingAvailabilityService>();
builder.Services.AddSingleton<GlobalShortcutService>();
builder.Services.AddSingleton<MediaTranscriptionService>();
builder.Services.AddSingleton<AssistantPluginInstallService>();
builder.Services.AddSingleton<UpdatePolicy>();
@ -180,7 +181,7 @@ internal sealed class Program
builder.Services.AddHostedService<TranscriptStagingCleanupService>();
builder.Services.AddHostedService<EnterpriseEnvironmentService>();
builder.Services.AddSingleton<DatabaseClientProvider>();
builder.Services.AddHostedService<GlobalShortcutService>();
builder.Services.AddHostedService<GlobalShortcutService>(serviceProvider => serviceProvider.GetRequiredService<GlobalShortcutService>());
builder.Services.AddHostedService<RustAvailabilityMonitorService>();
// ReSharper disable AccessToDisposedClosure

View File

@ -8,4 +8,5 @@ public enum ShortcutBackend
NONE,
PORTAL,
TAURI,
LOCAL,
}

View File

@ -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<Shortcut, ShortcutState> lastSentStates = [];
private readonly Dictionary<Shortcut, string> lastNonEmptyShortcuts = [];
private readonly Dictionary<Shortcut, ShortcutRuntimeBinding> runtimeBindings = [];
private readonly ILogger<GlobalShortcutService> 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<GlobalShortcutRuntimeState, Task>? RuntimeStateChanged;
public GlobalShortcutService(
ILogger<GlobalShortcutService> logger,
@ -58,6 +64,45 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
await base.StopAsync(cancellationToken);
}
/// <summary>
/// Returns the active backend and processing state for a shortcut.
/// </summary>
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);
}
}
/// <summary>
/// Pauses native and focused-window shortcut processing.
/// </summary>
public async Task<bool> SuspendShortcutProcessing()
{
lock (this.runtimeStateLock)
this.isProcessingSuspended = true;
await this.PublishAllRuntimeStates();
return await this.rustService.SuspendShortcutProcessing();
}
/// <summary>
/// Resumes native and focused-window shortcut processing.
/// </summary>
public async Task<bool> ResumeShortcutProcessing()
{
var result = await this.rustService.ResumeShortcutProcessing();
lock (this.runtimeStateLock)
this.isProcessingSuspended = false;
await this.PublishAllRuntimeStates();
return result;
}
#region IMessageBusReceiver
public async Task ProcessMessage<T>(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<bool>(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<Func<GlobalShortcutRuntimeState, Task>>()
.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<ShortcutState> 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);
private readonly record struct ShortcutRuntimeBinding(string Shortcut, ShortcutBackend Backend);
}
public sealed record GlobalShortcutRuntimeState(
Shortcut ShortcutId,
string Shortcut,
ShortcutBackend Backend,
bool IsSuspended);

View File

@ -170,3 +170,103 @@ 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)
}
}

View File

@ -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.

View File

@ -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<ShortcutBackend>) -> bool {
/// Determines whether a failed portal attempt may safely use the focused-window fallback.
fn may_fallback_to_local(_failure: PortalFailureKind, current_backend: Option<ShortcutBackend>) -> 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));
}