mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-07-21 17:46:27 +00:00
Fixed global shortcuts on linux (#867)
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
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:
parent
45701189c8
commit
4cc9d1f9a8
@ -45,7 +45,7 @@ public partial class SettingsPanelApp : SettingsPanelBase
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
|
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED, Event.GLOBAL_SHORTCUT_CHANGED ]);
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
this.updatePolicyMode = this.UpdatePolicy.CurrentMode;
|
this.updatePolicyMode = this.UpdatePolicy.CurrentMode;
|
||||||
}
|
}
|
||||||
@ -55,6 +55,9 @@ public partial class SettingsPanelApp : SettingsPanelBase
|
|||||||
if (triggeredEvent is Event.CONFIGURATION_CHANGED)
|
if (triggeredEvent is Event.CONFIGURATION_CHANGED)
|
||||||
this.updatePolicyMode = this.UpdatePolicy.CurrentMode;
|
this.updatePolicyMode = this.UpdatePolicy.CurrentMode;
|
||||||
|
|
||||||
|
if (triggeredEvent is Event.GLOBAL_SHORTCUT_CHANGED)
|
||||||
|
this.StateHasChanged();
|
||||||
|
|
||||||
await base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data);
|
await base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -87,6 +87,11 @@ public enum Event
|
|||||||
/// Notifies receivers that voice recording availability changed.
|
/// Notifies receivers that voice recording availability changed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
VOICE_RECORDING_AVAILABILITY_CHANGED,
|
VOICE_RECORDING_AVAILABILITY_CHANGED,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notifies settings UI receivers that a portal changed the effective global shortcut label.
|
||||||
|
/// </summary>
|
||||||
|
GLOBAL_SHORTCUT_CHANGED,
|
||||||
|
|
||||||
// Update events:
|
// Update events:
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
namespace AIStudio.Tools.Rust;
|
namespace AIStudio.Tools.Rust;
|
||||||
|
|
||||||
public sealed record RegisterShortcutRequest(Shortcut Id, string Shortcut);
|
public sealed record RegisterShortcutRequest(Shortcut Id, string Shortcut, string Description, bool Reconfigure);
|
||||||
|
|||||||
11
app/MindWork AI Studio/Tools/Rust/ShortcutBackend.cs
Normal file
11
app/MindWork AI Studio/Tools/Rust/ShortcutBackend.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
namespace AIStudio.Tools.Rust;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Native backend used to register a global shortcut.
|
||||||
|
/// </summary>
|
||||||
|
public enum ShortcutBackend
|
||||||
|
{
|
||||||
|
NONE,
|
||||||
|
PORTAL,
|
||||||
|
TAURI,
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
namespace AIStudio.Tools.Rust;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed result of a global shortcut registration attempt.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@ -1,3 +1,8 @@
|
|||||||
namespace AIStudio.Tools.Rust;
|
namespace AIStudio.Tools.Rust;
|
||||||
|
|
||||||
public sealed record ShortcutResponse(bool Success, string ErrorMessage);
|
public sealed record ShortcutResponse(
|
||||||
|
bool Success,
|
||||||
|
string ErrorMessage,
|
||||||
|
ShortcutBackend Backend,
|
||||||
|
bool Cancelled,
|
||||||
|
string EffectiveDisplayName);
|
||||||
|
|||||||
@ -29,6 +29,24 @@ public readonly record struct TauriEvent(TauriEventType EventType, List<string>
|
|||||||
return TryParseSnakeCase(this.Payload[0], out shortcut);
|
return TryParseSnakeCase(this.Payload[0], out shortcut);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a portal shortcut change and its effective display name.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tries to parse a snake_case string into a ShortcutName enum value.
|
/// Tries to parse a snake_case string into a ShortcutName enum value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -42,4 +60,4 @@ public readonly record struct TauriEvent(TauriEventType EventType, List<string>
|
|||||||
// Try to match against enum names (which are in UPPER_SNAKE_CASE):
|
// Try to match against enum names (which are in UPPER_SNAKE_CASE):
|
||||||
return Enum.TryParse(upperSnakeCase, ignoreCase: false, out shortcut);
|
return Enum.TryParse(upperSnakeCase, ignoreCase: false, out shortcut);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -17,4 +17,5 @@ public enum TauriEventType
|
|||||||
FILE_DROP_CANCELED,
|
FILE_DROP_CANCELED,
|
||||||
|
|
||||||
GLOBAL_SHORTCUT_PRESSED,
|
GLOBAL_SHORTCUT_PRESSED,
|
||||||
}
|
GLOBAL_SHORTCUT_CHANGED,
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using AIStudio.Settings;
|
using AIStudio.Settings;
|
||||||
using AIStudio.Settings.DataModel;
|
using AIStudio.Settings.DataModel;
|
||||||
|
using AIStudio.Tools.PluginSystem;
|
||||||
using AIStudio.Tools.Rust;
|
using AIStudio.Tools.Rust;
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
@ -19,6 +20,8 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly SemaphoreSlim registrationSemaphore = new(1, 1);
|
private readonly SemaphoreSlim registrationSemaphore = new(1, 1);
|
||||||
|
private readonly Dictionary<Shortcut, ShortcutState> lastSentStates = [];
|
||||||
|
private readonly Dictionary<Shortcut, string> lastNonEmptyShortcuts = [];
|
||||||
private readonly ILogger<GlobalShortcutService> logger;
|
private readonly ILogger<GlobalShortcutService> logger;
|
||||||
private readonly SettingsManager settingsManager;
|
private readonly SettingsManager settingsManager;
|
||||||
private readonly MessageBus messageBus;
|
private readonly MessageBus messageBus;
|
||||||
@ -39,7 +42,7 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
|
|||||||
this.voiceRecordingAvailabilityService = voiceRecordingAvailabilityService;
|
this.voiceRecordingAvailabilityService = voiceRecordingAvailabilityService;
|
||||||
|
|
||||||
this.messageBus.RegisterComponent(this);
|
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)
|
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);
|
await this.RegisterAllShortcuts(ShortcutSyncSource.VOICE_RECORDING_AVAILABILITY_CHANGED);
|
||||||
break;
|
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 shortcutState = await this.GetShortcutState(shortcutId, source);
|
||||||
var shortcut = shortcutState.Shortcut;
|
var shortcut = shortcutState.Shortcut;
|
||||||
var isEnabled = shortcutState.IsEnabled;
|
var isEnabled = shortcutState.IsEnabled;
|
||||||
|
var requestedState = new ShortcutState(isEnabled ? shortcut : string.Empty, isEnabled, shortcutState.UsesPersistedFallback);
|
||||||
this.logger.LogInformation(
|
this.logger.LogInformation(
|
||||||
"Sync shortcut '{ShortcutId}' (source='{Source}', enabled={IsEnabled}, configured='{Shortcut}').",
|
"Sync shortcut '{ShortcutId}' (source='{Source}', enabled={IsEnabled}, configured='{Shortcut}').",
|
||||||
shortcutId,
|
shortcutId,
|
||||||
@ -123,25 +135,53 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
|
|||||||
shortcut);
|
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);
|
this.logger.LogDebug("Skipping unchanged global shortcut '{ShortcutId}'.", shortcutId);
|
||||||
if (success)
|
continue;
|
||||||
this.logger.LogInformation("Global shortcut '{ShortcutId}' ({Shortcut}) registered.", shortcutId, shortcut);
|
}
|
||||||
else
|
|
||||||
this.logger.LogWarning("Failed to register global shortcut '{ShortcutId}' ({Shortcut}).", shortcutId, shortcut);
|
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
|
else
|
||||||
{
|
{
|
||||||
this.logger.LogInformation(
|
var userMessage = result.Cancelled
|
||||||
"Disabling global shortcut '{ShortcutId}' (source='{Source}', enabled={IsEnabled}, configured='{Shortcut}').",
|
? 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,
|
shortcutId,
|
||||||
source,
|
requestedState.Shortcut,
|
||||||
isEnabled,
|
result.Backend,
|
||||||
shortcut);
|
result.Cancelled,
|
||||||
|
result.ErrorMessage);
|
||||||
|
|
||||||
// Disable the shortcut when empty or feature is disabled:
|
if (result.Cancelled)
|
||||||
await this.rustService.UpdateGlobalShortcut(shortcutId, string.Empty);
|
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,
|
_ => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private async Task<string> 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<bool>(null, Event.GLOBAL_SHORTCUT_CHANGED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(GlobalShortcutService).Namespace, nameof(GlobalShortcutService));
|
||||||
|
|
||||||
private async Task<ShortcutState> GetShortcutState(Shortcut shortcutId, ShortcutSyncSource source)
|
private async Task<ShortcutState> GetShortcutState(Shortcut shortcutId, ShortcutSyncSource source)
|
||||||
{
|
{
|
||||||
var shortcut = this.GetShortcutValue(shortcutId);
|
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);
|
private readonly record struct ShortcutState(string Shortcut, bool IsEnabled, bool UsesPersistedFallback);
|
||||||
}
|
}
|
||||||
@ -10,34 +10,36 @@ public sealed partial class RustService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="shortcutId">The identifier for the shortcut.</param>
|
/// <param name="shortcutId">The identifier for the shortcut.</param>
|
||||||
/// <param name="shortcut">The shortcut string in Tauri format (e.g., "CmdOrControl+1"). Use empty string to disable.</param>
|
/// <param name="shortcut">The shortcut string in Tauri format (e.g., "CmdOrControl+1"). Use empty string to disable.</param>
|
||||||
/// <returns>True if the shortcut was registered successfully, false otherwise.</returns>
|
/// <param name="description">Localized action description shown by the desktop portal.</param>
|
||||||
public async Task<bool> UpdateGlobalShortcut(Shortcut shortcutId, string shortcut)
|
/// <param name="reconfigure">Whether the user deliberately selected a different preferred trigger.</param>
|
||||||
|
/// <returns>A typed result including the selected backend and effective portal label.</returns>
|
||||||
|
public async Task<ShortcutRegistrationResult> UpdateGlobalShortcut(Shortcut shortcutId, string shortcut, string description, bool reconfigure)
|
||||||
{
|
{
|
||||||
try
|
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);
|
var response = await this.http.PostAsJsonAsync("/shortcuts/register", request, this.jsonRustSerializerOptions);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
this.logger?.LogError("Failed to register global shortcut '{ShortcutId}' due to network error: {StatusCode}", shortcutId, response.StatusCode);
|
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<ShortcutResponse>(this.jsonRustSerializerOptions);
|
var result = await response.Content.ReadFromJsonAsync<ShortcutRegistrationResult>(this.jsonRustSerializerOptions);
|
||||||
if (result is null || !result.Success)
|
if (result is null || !result.Success)
|
||||||
{
|
{
|
||||||
this.logger?.LogError("Failed to register global shortcut '{ShortcutId}': {Error}", shortcutId, result?.ErrorMessage ?? "Unknown error");
|
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);
|
this.logger?.LogInformation("Global shortcut '{ShortcutId}' registered successfully with key '{Shortcut}'.", shortcutId, shortcut);
|
||||||
return true;
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
this.logger?.LogError(ex, "Exception while registering global shortcut '{ShortcutId}'.", shortcutId);
|
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."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 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 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 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 voice recording and transcription on Linux.
|
||||||
- Fixed copied content from AI Studio not remaining available on the clipboard 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.
|
- Fixed being able to switch document analysis policies while an analysis or media transcription was still in progress.
|
||||||
|
|||||||
@ -72,7 +72,7 @@ windows-native-keyring-store = "1.1.0"
|
|||||||
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
|
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[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"] }
|
dbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
|
||||||
webkit2gtk = { version = "2.0.2", features = ["v2_8"] }
|
webkit2gtk = { version = "2.0.2", features = ["v2_8"] }
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@ -13,12 +12,10 @@ use log::{debug, error, info, trace, warn};
|
|||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use pdfium_render::prelude::Pdfium;
|
use pdfium_render::prelude::Pdfium;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use strum_macros::Display;
|
|
||||||
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent, generate_context};
|
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent, generate_context};
|
||||||
use tauri::path::PathResolver;
|
use tauri::path::PathResolver;
|
||||||
use tauri::WebviewWindow;
|
use tauri::WebviewWindow;
|
||||||
use tauri_plugin_updater::{UpdaterExt, Update};
|
use tauri_plugin_updater::{UpdaterExt, Update};
|
||||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
@ -31,6 +28,7 @@ use crate::environment::{
|
|||||||
use crate::log::switch_to_file_logging;
|
use crate::log::switch_to_file_logging;
|
||||||
use crate::pdfium::PDFIUM_LIB_PATH;
|
use crate::pdfium::PDFIUM_LIB_PATH;
|
||||||
use crate::qdrant_edge_database::{start_qdrant_edge_database, stop_qdrant_edge_database};
|
use crate::qdrant_edge_database::{start_qdrant_edge_database, stop_qdrant_edge_database};
|
||||||
|
use crate::global_shortcuts::{RegisterShortcutRequest, ShortcutResponse};
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
use crate::dotnet::create_startup_env_file;
|
use crate::dotnet::create_startup_env_file;
|
||||||
@ -50,20 +48,9 @@ static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<Update>>> = Lazy::new(|| Mutex::
|
|||||||
/// The event broadcast sender for Tauri events.
|
/// The event broadcast sender for Tauri events.
|
||||||
static EVENT_BROADCAST: Lazy<Mutex<Option<broadcast::Sender<Event>>>> = Lazy::new(|| Mutex::new(None));
|
static EVENT_BROADCAST: Lazy<Mutex<Option<broadcast::Sender<Event>>>> = Lazy::new(|| Mutex::new(None));
|
||||||
|
|
||||||
/// Stores the currently registered global shortcuts (name -> shortcut string).
|
|
||||||
static REGISTERED_SHORTCUTS: Lazy<Mutex<HashMap<Shortcut, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));
|
|
||||||
|
|
||||||
/// Stores the localhost origin of the Blazor app after the .NET server is ready.
|
/// Stores the localhost origin of the Blazor app after the .NET server is ready.
|
||||||
static APPROVED_APP_URL: Lazy<Mutex<Option<tauri::Url>>> = Lazy::new(|| Mutex::new(None));
|
static APPROVED_APP_URL: Lazy<Mutex<Option<tauri::Url>>> = 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.
|
/// Starts the Tauri app.
|
||||||
pub fn start_tauri() {
|
pub fn start_tauri() {
|
||||||
info!("Starting Tauri app...");
|
info!("Starting Tauri app...");
|
||||||
@ -486,6 +473,7 @@ pub enum TauriEventType {
|
|||||||
FileDropCanceled,
|
FileDropCanceled,
|
||||||
|
|
||||||
GlobalShortcutPressed,
|
GlobalShortcutPressed,
|
||||||
|
GlobalShortcutChanged,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Changes the location of the main window to the given URL.
|
/// 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
|
!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.
|
/// Response for application exit requests.
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct AppExitResponse {
|
pub struct AppExitResponse {
|
||||||
@ -701,28 +671,6 @@ pub struct AppExitResponse {
|
|||||||
error_message: String,
|
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<R: tauri::Runtime>(
|
|
||||||
app_handle: &tauri::AppHandle<R>,
|
|
||||||
shortcut: &str,
|
|
||||||
shortcut_id: Shortcut,
|
|
||||||
event_sender: broadcast::Sender<Event>,
|
|
||||||
) -> 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.
|
/// Requests a controlled shutdown of the entire desktop application.
|
||||||
pub async fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
|
pub async fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
|
||||||
let app_handle = {
|
let app_handle = {
|
||||||
@ -754,89 +702,9 @@ pub async fn exit_app(_token: APIToken) -> Json<AppExitResponse> {
|
|||||||
/// Registers or updates a global shortcut. If the shortcut string is empty,
|
/// Registers or updates a global shortcut. If the shortcut string is empty,
|
||||||
/// the existing shortcut for that name will be unregistered.
|
/// the existing shortcut for that name will be unregistered.
|
||||||
pub async fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
|
pub async fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
|
||||||
let id = payload.id;
|
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
|
||||||
let new_shortcut = payload.shortcut.clone();
|
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
|
||||||
|
Json(crate::global_shortcuts::register(app_handle, event_sender, payload.0).await)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request payload for validating a shortcut.
|
/// Request payload for validating a shortcut.
|
||||||
@ -872,8 +740,7 @@ pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutR
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the shortcut is already registered:
|
// Check if the shortcut is already registered:
|
||||||
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
|
for (name, registered_shortcut) in crate::global_shortcuts::registered_shortcuts().await {
|
||||||
for (name, registered_shortcut) in registered_shortcuts.iter() {
|
|
||||||
if registered_shortcut.eq_ignore_ascii_case(&shortcut) {
|
if registered_shortcut.eq_ignore_ascii_case(&shortcut) {
|
||||||
return Json(ShortcutValidationResponse {
|
return Json(ShortcutValidationResponse {
|
||||||
is_valid: true,
|
is_valid: true,
|
||||||
@ -884,8 +751,6 @@ pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drop(registered_shortcuts);
|
|
||||||
|
|
||||||
// Try to parse the shortcut to validate syntax.
|
// Try to parse the shortcut to validate syntax.
|
||||||
// We can't easily validate without registering in Tauri 1.x,
|
// We can't easily validate without registering in Tauri 1.x,
|
||||||
// so we do basic syntax validation here:
|
// so we do basic syntax validation here:
|
||||||
@ -908,100 +773,20 @@ pub async fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Suspends shortcut processing by unregistering all shortcuts from the OS.
|
/// Suspends shortcut processing. Portal sessions remain active and ignore activations;
|
||||||
/// The shortcuts remain in our internal map, so they can be re-registered on resume.
|
/// Tauri shortcuts are temporarily unregistered and restored on resume.
|
||||||
/// This is useful when opening a dialog to configure shortcuts, so the user can
|
/// This is useful when opening a dialog to configure shortcuts, so the user can
|
||||||
/// press the current shortcut to re-enter it without triggering the action.
|
/// press the current shortcut to re-enter it without triggering the action.
|
||||||
pub async fn suspend_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
|
pub async fn suspend_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
|
||||||
// Get the main window to access the global shortcut manager:
|
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
|
||||||
let main_window_lock = MAIN_WINDOW.lock().unwrap();
|
Json(crate::global_shortcuts::suspend(app_handle).await)
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resumes shortcut processing by re-registering all shortcuts with the OS.
|
/// Resumes shortcut processing by re-registering all shortcuts with the OS.
|
||||||
pub async fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
|
pub async fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
|
||||||
// Get the main window to access the global shortcut manager:
|
let app_handle = MAIN_WINDOW.lock().unwrap().as_ref().map(|window| window.app_handle().clone());
|
||||||
let main_window_lock = MAIN_WINDOW.lock().unwrap();
|
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
|
||||||
let main_window = match main_window_lock.as_ref() {
|
Json(crate::global_shortcuts::resume(app_handle, event_sender).await)
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates the syntax of a shortcut string.
|
/// Validates the syntax of a shortcut string.
|
||||||
|
|||||||
966
runtime/src/global_shortcuts.rs
Normal file
966
runtime/src/global_shortcuts.rs
Normal file
@ -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<Mutex<ShortcutManager>> = 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<std::sync::Mutex<HashMap<Shortcut, u64>>> = 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<Self> {
|
||||||
|
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<String>, 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<Shortcut, ActiveBinding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<GlobalShortcuts>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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<tauri::AppHandle>,
|
||||||
|
event_sender: Option<broadcast::Sender<Event>>,
|
||||||
|
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<Event>,
|
||||||
|
) -> 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<Event>, 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<tauri::AppHandle>) -> 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<tauri::AppHandle>,
|
||||||
|
event_sender: Option<broadcast::Sender<Event>>,
|
||||||
|
) -> 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<ShortcutBackend>) -> 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<String>) -> 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<PortalShortcutInfo> {
|
||||||
|
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<Vec<PortalShortcutInfo>, 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<Vec<PortalShortcutInfo>, PortalFailure>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores an approved portal shortcut or binds it when required.
|
||||||
|
async fn resolve_portal_shortcut<A: PortalAdapter>(
|
||||||
|
adapter: &mut A,
|
||||||
|
request: &RegisterShortcutRequest,
|
||||||
|
) -> Result<String, PortalFailure> {
|
||||||
|
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<GlobalShortcuts>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
impl PortalAdapter for AshpdPortalAdapter<'_> {
|
||||||
|
/// Lists and normalizes shortcuts restored by the XDG portal.
|
||||||
|
async fn list_shortcuts(&mut self) -> Result<Vec<PortalShortcutInfo>, 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<Vec<PortalShortcutInfo>, 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<Event>,
|
||||||
|
) -> Result<ActiveBinding, PortalFailure> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String> {
|
||||||
|
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<PortalShortcutInfo>,
|
||||||
|
/// Shortcuts returned by the simulated bind operation.
|
||||||
|
bound: Vec<PortalShortcutInfo>,
|
||||||
|
/// Optional failure returned instead of a successful bind result.
|
||||||
|
bind_failure: Option<PortalFailure>,
|
||||||
|
/// 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<Vec<PortalShortcutInfo>, 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<Vec<PortalShortcutInfo>, 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,4 +19,5 @@ pub mod certificate_factory;
|
|||||||
pub mod runtime_api_token;
|
pub mod runtime_api_token;
|
||||||
pub mod stale_process_cleanup;
|
pub mod stale_process_cleanup;
|
||||||
mod sidecar_types;
|
mod sidecar_types;
|
||||||
mod file_actions;
|
mod file_actions;
|
||||||
|
pub mod global_shortcuts;
|
||||||
Loading…
Reference in New Issue
Block a user