From b36b8000286f8b0317d623604a931581c73b8833 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 21 Jul 2026 08:11:34 +0200 Subject: [PATCH 1/2] Refactor global shortcut handling to improve fallback logic on linux (#879) --- .../Tools/Services/GlobalShortcutService.cs | 8 +- runtime/src/global_shortcuts.rs | 85 ++++++++++++------- 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs index 3be5319e..403d0fc2 100644 --- a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs +++ b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs @@ -149,12 +149,12 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv && !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.lastSentStates[shortcutId] = requestedState; + if (!string.IsNullOrWhiteSpace(requestedState.Shortcut)) + this.lastNonEmptyShortcuts[shortcutId] = requestedState.Shortcut; + this.logger.LogInformation( "Global shortcut '{ShortcutId}' ({Shortcut}) synchronized through {Backend}.", shortcutId, diff --git a/runtime/src/global_shortcuts.rs b/runtime/src/global_shortcuts.rs index 6b8cdc8b..c6927b8a 100644 --- a/runtime/src/global_shortcuts.rs +++ b/runtime/src/global_shortcuts.rs @@ -11,6 +11,7 @@ use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use strum_macros::Display; use tauri_plugin_global_shortcut::GlobalShortcutExt; +use tauri_plugin_global_shortcut::ShortcutState; use tokio::sync::{Mutex, broadcast}; use crate::app_window::{Event, TauriEventType}; @@ -217,6 +218,7 @@ pub async fn register( 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); }; @@ -242,24 +244,22 @@ pub async fn register( 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); + 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); } else { - error!(Source = "XDG portal"; "Global shortcut registration failed: {}", error.message); - } + let cancelled = error.kind == PortalFailureKind::Cancelled; + if cancelled { + warn!(Source = "XDG portal"; "Global shortcut configuration was cancelled by the user; preserving the active portal binding."); + } else if error.kind == PortalFailureKind::Denied { + warn!(Source = "XDG portal"; "Global shortcut permission was denied; preserving the active portal binding: {}", error.message); + } else { + error!(Source = "XDG portal"; "Global shortcut registration failed; preserving the active portal binding: {}", error.message); + } - return ShortcutResponse::error(error.message, ShortcutBackend::Portal, cancelled); + return ShortcutResponse::error(error.message, ShortcutBackend::Portal, cancelled); + } }, } } @@ -349,15 +349,24 @@ fn register_tauri_binding( 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) { + app_handle.global_shortcut().on_shortcut(shortcut, move |_app, _shortcut, event| { + if !should_forward_tauri_event(event.state) || PROCESSING_SUSPENDED.load(Ordering::Relaxed) { return; } - send_shortcut_pressed(&event_sender, shortcut_id, "Tauri"); + info!(Source = "Tauri"; "Tauri shortcut callback received for '{}'.", shortcut_id); + let sender = event_sender.clone(); + tauri::async_runtime::spawn(async move { + send_shortcut_pressed(&sender, shortcut_id, "Tauri"); + }); }) } +/// Returns whether a native shortcut event represents the single actionable key press. +fn should_forward_tauri_event(state: ShortcutState) -> bool { + state == ShortcutState::Pressed +} + /// 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); @@ -438,9 +447,9 @@ enum PortalFailureKind { 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 failed portal attempt may safely fall back to Tauri. +fn may_fallback_to_tauri(_failure: PortalFailureKind, current_backend: Option) -> bool { + current_backend != Some(ShortcutBackend::Portal) } /// Determines whether a backend must unregister its shortcut during suspension. @@ -937,14 +946,23 @@ mod tests { } #[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)); + /// Verifies that all initial portal failures use the Tauri fallback. + fn all_initial_portal_failures_use_tauri_fallback() { + for failure in [ + PortalFailureKind::Unavailable, + PortalFailureKind::Cancelled, + PortalFailureKind::Denied, + PortalFailureKind::Technical, + ] { + assert!(may_fallback_to_tauri(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))); } #[test] @@ -954,10 +972,17 @@ mod tests { assert!(unregister_backend_during_suspend(ShortcutBackend::Tauri)); } + #[test] + /// Verifies that Tauri key releases cannot trigger a second shortcut event. + fn tauri_only_forwards_pressed_events() { + assert!(should_forward_tauri_event(ShortcutState::Pressed)); + assert!(!should_forward_tauri_event(ShortcutState::Released)); + } + #[cfg(target_os = "linux")] #[test] /// Verifies recognition of unavailable-portal D-Bus errors without misclassifying rejection. - fn only_unavailable_portal_errors_allow_fallback() { + fn recognizes_unavailable_portal_errors() { 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")); From f13c35d814906ac9bffca651d0dbc2cf4b89d264 Mon Sep 17 00:00:00 2001 From: nilskruthoff <69095224+nilskruthoff@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:17:13 +0200 Subject: [PATCH 2/2] Security audit provider fallback (#876) --- .../Agents/AssistantAudit/AssistantAuditAgent.cs | 11 ++++++++--- .../Assistants/Builder/AssistantBuilder.razor.cs | 2 +- .../Assistants/AssistantPluginAuditService.cs | 7 +++++-- app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md | 1 + 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/MindWork AI Studio/Agents/AssistantAudit/AssistantAuditAgent.cs b/app/MindWork AI Studio/Agents/AssistantAudit/AssistantAuditAgent.cs index bc306978..e116a134 100644 --- a/app/MindWork AI Studio/Agents/AssistantAudit/AssistantAuditAgent.cs +++ b/app/MindWork AI Studio/Agents/AssistantAudit/AssistantAuditAgent.cs @@ -117,10 +117,14 @@ public sealed class AssistantAuditAgent(ILogger logger, ILo /// /// Resolves and stores the provider configuration used for assistant plugin audits. /// + /// The provider to use when no provider is configured for the audit agent. /// The configured provider, or when no audit provider is configured. - public AIStudio.Settings.Provider ResolveProvider() + public AIStudio.Settings.Provider ResolveProvider(AIStudio.Settings.Provider? fallbackProvider = null) { var provider = this.SettingsManager.GetPreselectedProvider(Tools.Components.AGENT_ASSISTANT_PLUGIN_AUDIT, null, true); + if (provider == AIStudio.Settings.Provider.NONE && fallbackProvider is not null) + provider = fallbackProvider; + this.ProviderSettings = provider; return provider; } @@ -130,12 +134,13 @@ public sealed class AssistantAuditAgent(ILogger logger, ILo /// /// The assistant plugin to audit. /// A cancellation token for prompt generation and the audit request. + /// The provider to use when no provider is configured for the audit agent. /// /// The parsed audit result, or an UNKNOWN result when no provider is configured or the model response cannot be used. /// - public async Task AuditAsync(PluginAssistants plugin, CancellationToken token = default) + public async Task AuditAsync(PluginAssistants plugin, CancellationToken token = default, AIStudio.Settings.Provider? fallbackProvider = null) { - var provider = this.ResolveProvider(); + var provider = this.ResolveProvider(fallbackProvider); if (provider == AIStudio.Settings.Provider.NONE) { await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.SettingsSuggest, string.Format(TB("No provider is configured for the Security Audit Agent.")))); diff --git a/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor.cs b/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor.cs index 24cb7296..ce97c548 100644 --- a/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor.cs +++ b/app/MindWork AI Studio/Assistants/Builder/AssistantBuilder.razor.cs @@ -570,7 +570,7 @@ public partial class AssistantBuilder : AssistantBaseCore this.isAuditingPlugin = true; try { - this.pluginAudit = await this.AssistantPluginAuditService.RunAuditAsync(this.installedAssistantPlugin); + this.pluginAudit = await this.AssistantPluginAuditService.RunAuditAsync(this.installedAssistantPlugin, fallbackProvider: this.ProviderSettings); if (this.pluginAudit.Level is AssistantAuditLevel.UNKNOWN) { this.FailInstallStep(BuilderInstallStep.SECURITY_CHECK, T("The security check could not determine a result.")); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/AssistantPluginAuditService.cs b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/AssistantPluginAuditService.cs index 3bd282dd..0ede62d6 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/Assistants/AssistantPluginAuditService.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/Assistants/AssistantPluginAuditService.cs @@ -7,9 +7,12 @@ namespace AIStudio.Tools.PluginSystem.Assistants; /// public sealed class AssistantPluginAuditService(AssistantAuditAgent auditAgent) { - public async Task RunAuditAsync(PluginAssistants plugin, CancellationToken token = default) + /// + /// Runs an assistant plugin audit, optionally falling back to the supplied provider when no audit provider is configured. + /// + public async Task RunAuditAsync(PluginAssistants plugin, CancellationToken token = default, Settings.Provider? fallbackProvider = null) { - var result = await auditAgent.AuditAsync(plugin, token); + var result = await auditAgent.AuditAsync(plugin, token, fallbackProvider); var provider = auditAgent.ProviderSettings; var promptPreview = await plugin.BuildAuditPromptPreviewAsync(token); 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 d1be164e..64581b68 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md @@ -9,6 +9,7 @@ - Improved update guidance for Flatpak installations and added an enterprise option that lets organizations manage updates entirely through their IT department. - Improved secure API-key storage diagnostics on Linux. AI Studio now provides specific guidance when the default password collection is missing or locked, a password-manager prompt is dismissed, or no compatible Secret Service is available. - Improved the file dialogs to prevent opening multiple times when you click "Open" or "Save" multiple times in a row. +- Improved the Assistant Builder security check so it can use the selected provider when no dedicated security audit agent provider is configured. - 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.