mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-07-22 20:26:27 +00:00
Added focused-window shortcut fallback on Linux (#882)
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
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
db508edc28
commit
1e5f07cb01
@ -8944,6 +8944,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T8163
|
||||
-- The generated assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T939708112"] = "The generated assistant plugin is invalid. Issue: {0}"
|
||||
|
||||
-- The voice recording shortcut currently works only while AI Studio is focused.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T1204510649"] = "The voice recording shortcut currently works only while AI Studio is focused."
|
||||
|
||||
-- The global shortcut could not be registered. The previous shortcut remains active.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T2266307101"] = "The global shortcut could not be registered. The previous shortcut remains active."
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
private GlobalShortcutService GlobalShortcutService { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The shortcut binding data.
|
||||
@ -69,7 +69,7 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore
|
||||
{
|
||||
// Suspend shortcut processing while the dialog is open, so the user can
|
||||
// press the current shortcut to re-enter it without triggering the action:
|
||||
await this.RustService.SuspendShortcutProcessing();
|
||||
await this.GlobalShortcutService.SuspendShortcutProcessing();
|
||||
|
||||
try
|
||||
{
|
||||
@ -106,7 +106,7 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore
|
||||
finally
|
||||
{
|
||||
// Resume the shortcut processing when the dialog is closed:
|
||||
await this.RustService.ResumeShortcutProcessing();
|
||||
await this.GlobalShortcutService.ResumeShortcutProcessing();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,9 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private GlobalShortcutService GlobalShortcutService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ISnackbar Snackbar { get; init; } = null!;
|
||||
|
||||
@ -35,6 +38,8 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.GlobalShortcutService.RuntimeStateChanged += this.OnShortcutRuntimeStateChanged;
|
||||
|
||||
// Register for global shortcut events:
|
||||
this.ApplyFilters([], [Event.TAURI_EVENT_RECEIVED, Event.VOICE_RECORDING_AVAILABILITY_CHANGED]);
|
||||
|
||||
@ -43,8 +48,15 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && this.ShouldRenderVoiceRecording)
|
||||
await this.EnsureSoundEffectsAvailableAsync("during the first interactive render");
|
||||
if (firstRender)
|
||||
{
|
||||
this.localShortcutDotNetReference = DotNetObjectReference.Create(this);
|
||||
this.localShortcutInteropReady = true;
|
||||
await this.ApplyLocalShortcutState(this.GlobalShortcutService.GetRuntimeState(Shortcut.VOICE_RECORDING_TOGGLE));
|
||||
|
||||
if (this.ShouldRenderVoiceRecording)
|
||||
await this.EnsureSoundEffectsAvailableAsync("during the first interactive render");
|
||||
}
|
||||
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
@ -69,6 +81,36 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnShortcutRuntimeStateChanged(GlobalShortcutRuntimeState runtimeState)
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.InvokeAsync(() => this.ApplyLocalShortcutState(runtimeState));
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
this.Logger.LogDebug("Ignoring a shortcut state change after the voice recorder was disposed.");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
this.Logger.LogDebug(ex, "The focused-window shortcut listener could not be updated because the component dispatcher is unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnLocalShortcutPressed()
|
||||
{
|
||||
var runtimeState = this.GlobalShortcutService.GetRuntimeState(Shortcut.VOICE_RECORDING_TOGGLE);
|
||||
if (runtimeState.Backend is not ShortcutBackend.LOCAL || runtimeState.IsSuspended)
|
||||
{
|
||||
this.Logger.LogDebug("Ignoring a stale focused-window shortcut event.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.Logger.LogInformation("Focused-window shortcut triggered for voice recording toggle.");
|
||||
await this.ToggleRecordingFromShortcut();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the recording state when triggered by a global shortcut.
|
||||
/// </summary>
|
||||
@ -101,6 +143,48 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
private string? currentRecordingPath;
|
||||
private string? finalRecordingPath;
|
||||
private DotNetObjectReference<VoiceRecorder>? dotNetReference;
|
||||
private DotNetObjectReference<VoiceRecorder>? localShortcutDotNetReference;
|
||||
private bool localShortcutInteropReady;
|
||||
|
||||
private async Task ApplyLocalShortcutState(GlobalShortcutRuntimeState runtimeState)
|
||||
{
|
||||
if (!this.localShortcutInteropReady
|
||||
|| this.localShortcutDotNetReference is null
|
||||
|| runtimeState.ShortcutId is not Shortcut.VOICE_RECORDING_TOGGLE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (runtimeState.Backend is ShortcutBackend.LOCAL
|
||||
&& !runtimeState.IsSuspended
|
||||
&& !string.IsNullOrWhiteSpace(runtimeState.Shortcut))
|
||||
{
|
||||
await this.JsRuntime.InvokeVoidAsync(
|
||||
"localShortcut.register",
|
||||
"voice-recording-toggle",
|
||||
runtimeState.Shortcut,
|
||||
this.localShortcutDotNetReference);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.JsRuntime.InvokeVoidAsync("localShortcut.unregister", "voice-recording-toggle");
|
||||
}
|
||||
}
|
||||
catch (JSDisconnectedException)
|
||||
{
|
||||
this.Logger.LogDebug("The focused-window shortcut listener could not be updated because the JS runtime disconnected.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this.Logger.LogDebug("Updating the focused-window shortcut listener was canceled.");
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
this.Logger.LogWarning(ex, "Failed to update the focused-window shortcut listener.");
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldRenderVoiceRecording => PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)
|
||||
&& !string.IsNullOrWhiteSpace(this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider);
|
||||
@ -482,6 +566,15 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.GlobalShortcutService.RuntimeStateChanged -= this.OnShortcutRuntimeStateChanged;
|
||||
|
||||
if (this.localShortcutInteropReady)
|
||||
_ = this.JsRuntime.InvokeVoidAsync("localShortcut.unregister", "voice-recording-toggle");
|
||||
|
||||
this.localShortcutDotNetReference?.Dispose();
|
||||
this.localShortcutDotNetReference = null;
|
||||
this.localShortcutInteropReady = false;
|
||||
|
||||
// Clean up recording resources if still active:
|
||||
if (this.currentRecordingStream is not null)
|
||||
{
|
||||
|
||||
@ -8946,6 +8946,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T8163
|
||||
-- The generated assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T939708112"] = "Das generierte Assistenten-Plugin ist ungültig. Problem: {0}"
|
||||
|
||||
-- The voice recording shortcut currently works only while AI Studio is focused.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T1204510649"] = "Die Tastenkombination für Sprachaufnahmen funktioniert derzeit nur, wenn AI Studio im Vordergrund aktiv ist."
|
||||
|
||||
-- The global shortcut could not be registered. The previous shortcut remains active.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T2266307101"] = "Die globale Tastenkombination konnte nicht registriert werden. Die vorherige Tastenkombination bleibt aktiv."
|
||||
|
||||
|
||||
@ -8946,6 +8946,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T8163
|
||||
-- The generated assistant plugin is invalid. Issue: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T939708112"] = "The generated assistant plugin is invalid. Issue: {0}"
|
||||
|
||||
-- The voice recording shortcut currently works only while AI Studio is focused.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T1204510649"] = "The voice recording shortcut currently works only while AI Studio is focused."
|
||||
|
||||
-- The global shortcut could not be registered. The previous shortcut remains active.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T2266307101"] = "The global shortcut could not be registered. The previous shortcut remains active."
|
||||
|
||||
|
||||
@ -163,6 +163,7 @@ internal sealed class Program
|
||||
builder.Services.AddSingleton<AIJobService>();
|
||||
builder.Services.AddSingleton<AssistantSessionService>();
|
||||
builder.Services.AddSingleton<VoiceRecordingAvailabilityService>();
|
||||
builder.Services.AddSingleton<GlobalShortcutService>();
|
||||
builder.Services.AddSingleton<MediaTranscriptionService>();
|
||||
builder.Services.AddSingleton<AssistantPluginInstallService>();
|
||||
builder.Services.AddSingleton<UpdatePolicy>();
|
||||
@ -180,7 +181,7 @@ internal sealed class Program
|
||||
builder.Services.AddHostedService<TranscriptStagingCleanupService>();
|
||||
builder.Services.AddHostedService<EnterpriseEnvironmentService>();
|
||||
builder.Services.AddSingleton<DatabaseClientProvider>();
|
||||
builder.Services.AddHostedService<GlobalShortcutService>();
|
||||
builder.Services.AddHostedService<GlobalShortcutService>(serviceProvider => serviceProvider.GetRequiredService<GlobalShortcutService>());
|
||||
builder.Services.AddHostedService<RustAvailabilityMonitorService>();
|
||||
|
||||
// ReSharper disable AccessToDisposedClosure
|
||||
|
||||
@ -8,4 +8,5 @@ public enum ShortcutBackend
|
||||
NONE,
|
||||
PORTAL,
|
||||
TAURI,
|
||||
LOCAL,
|
||||
}
|
||||
|
||||
@ -20,13 +20,19 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
|
||||
}
|
||||
|
||||
private readonly SemaphoreSlim registrationSemaphore = new(1, 1);
|
||||
private readonly object runtimeStateLock = new();
|
||||
private readonly Dictionary<Shortcut, ShortcutState> lastSentStates = [];
|
||||
private readonly Dictionary<Shortcut, string> lastNonEmptyShortcuts = [];
|
||||
private readonly Dictionary<Shortcut, ShortcutRuntimeBinding> runtimeBindings = [];
|
||||
private readonly ILogger<GlobalShortcutService> logger;
|
||||
private readonly SettingsManager settingsManager;
|
||||
private readonly MessageBus messageBus;
|
||||
private readonly RustService rustService;
|
||||
private readonly VoiceRecordingAvailabilityService voiceRecordingAvailabilityService;
|
||||
private bool isProcessingSuspended;
|
||||
private bool localFallbackWarningShown;
|
||||
|
||||
public event Func<GlobalShortcutRuntimeState, Task>? RuntimeStateChanged;
|
||||
|
||||
public GlobalShortcutService(
|
||||
ILogger<GlobalShortcutService> logger,
|
||||
@ -58,6 +64,45 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the active backend and processing state for a shortcut.
|
||||
/// </summary>
|
||||
public GlobalShortcutRuntimeState GetRuntimeState(Shortcut shortcutId)
|
||||
{
|
||||
lock (this.runtimeStateLock)
|
||||
{
|
||||
if (this.runtimeBindings.TryGetValue(shortcutId, out var binding))
|
||||
return new(shortcutId, binding.Shortcut, binding.Backend, this.isProcessingSuspended);
|
||||
|
||||
return new(shortcutId, string.Empty, ShortcutBackend.NONE, this.isProcessingSuspended);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pauses native and focused-window shortcut processing.
|
||||
/// </summary>
|
||||
public async Task<bool> SuspendShortcutProcessing()
|
||||
{
|
||||
lock (this.runtimeStateLock)
|
||||
this.isProcessingSuspended = true;
|
||||
|
||||
await this.PublishAllRuntimeStates();
|
||||
return await this.rustService.SuspendShortcutProcessing();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes native and focused-window shortcut processing.
|
||||
/// </summary>
|
||||
public async Task<bool> ResumeShortcutProcessing()
|
||||
{
|
||||
var result = await this.rustService.ResumeShortcutProcessing();
|
||||
lock (this.runtimeStateLock)
|
||||
this.isProcessingSuspended = false;
|
||||
|
||||
await this.PublishAllRuntimeStates();
|
||||
return result;
|
||||
}
|
||||
|
||||
#region IMessageBusReceiver
|
||||
|
||||
public async Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data)
|
||||
@ -155,6 +200,9 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
|
||||
if (!string.IsNullOrWhiteSpace(requestedState.Shortcut))
|
||||
this.lastNonEmptyShortcuts[shortcutId] = requestedState.Shortcut;
|
||||
|
||||
lock (this.runtimeStateLock)
|
||||
this.runtimeBindings[shortcutId] = new(requestedState.Shortcut, result.Backend);
|
||||
|
||||
this.logger.LogInformation(
|
||||
"Global shortcut '{ShortcutId}' ({Shortcut}) synchronized through {Backend}.",
|
||||
shortcutId,
|
||||
@ -163,6 +211,15 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
|
||||
|
||||
if (result.Backend is ShortcutBackend.PORTAL)
|
||||
await this.UpdateEffectiveDisplayName(shortcutId, result.EffectiveDisplayName);
|
||||
|
||||
await this.PublishRuntimeState(shortcutId);
|
||||
if (result.Backend is ShortcutBackend.LOCAL && !this.localFallbackWarningShown)
|
||||
{
|
||||
this.localFallbackWarningShown = true;
|
||||
await this.messageBus.SendWarning(new(
|
||||
Icons.Material.Filled.Keyboard,
|
||||
TB("The voice recording shortcut currently works only while AI Studio is focused.")));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -236,6 +293,31 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
|
||||
await this.messageBus.SendMessage<bool>(null, Event.GLOBAL_SHORTCUT_CHANGED);
|
||||
}
|
||||
|
||||
private async Task PublishAllRuntimeStates()
|
||||
{
|
||||
Shortcut[] shortcutIds;
|
||||
lock (this.runtimeStateLock)
|
||||
shortcutIds = this.runtimeBindings.Keys.ToArray();
|
||||
|
||||
foreach (var shortcutId in shortcutIds)
|
||||
await this.PublishRuntimeState(shortcutId);
|
||||
}
|
||||
|
||||
private async Task PublishRuntimeState(Shortcut shortcutId)
|
||||
{
|
||||
var subscribers = this.RuntimeStateChanged;
|
||||
if (subscribers is null)
|
||||
return;
|
||||
|
||||
var handlers = subscribers.GetInvocationList()
|
||||
.Cast<Func<GlobalShortcutRuntimeState, Task>>()
|
||||
.ToArray();
|
||||
var runtimeState = this.GetRuntimeState(shortcutId);
|
||||
|
||||
foreach (var handler in handlers)
|
||||
await handler(runtimeState);
|
||||
}
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(GlobalShortcutService).Namespace, nameof(GlobalShortcutService));
|
||||
|
||||
private async Task<ShortcutState> GetShortcutState(Shortcut shortcutId, ShortcutSyncSource source)
|
||||
@ -262,4 +344,12 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
|
||||
}
|
||||
|
||||
private readonly record struct ShortcutState(string Shortcut, bool IsEnabled, bool UsesPersistedFallback);
|
||||
}
|
||||
|
||||
private readonly record struct ShortcutRuntimeBinding(string Shortcut, ShortcutBackend Backend);
|
||||
}
|
||||
|
||||
public sealed record GlobalShortcutRuntimeState(
|
||||
Shortcut ShortcutId,
|
||||
string Shortcut,
|
||||
ShortcutBackend Backend,
|
||||
bool IsSuspended);
|
||||
@ -169,4 +169,104 @@ window.unregisterEscapeHandler = function (id) {
|
||||
|
||||
document.removeEventListener('keydown', handler, true)
|
||||
escapeHandlers.delete(id)
|
||||
}
|
||||
|
||||
const localShortcutHandlers = new Map()
|
||||
|
||||
function tauriKeyFromKeyboardCode(code) {
|
||||
if (/^Key[A-Z]$/.test(code))
|
||||
return code.substring(3)
|
||||
|
||||
if (/^Digit[0-9]$/.test(code))
|
||||
return code.substring(5)
|
||||
|
||||
if (/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(code))
|
||||
return code
|
||||
|
||||
const keys = {
|
||||
Space: 'Space', Enter: 'Enter', Tab: 'Tab', Escape: 'Escape', Backspace: 'Backspace',
|
||||
Delete: 'Delete', Insert: 'Insert', Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown',
|
||||
ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right',
|
||||
Numpad0: 'Num0', Numpad1: 'Num1', Numpad2: 'Num2', Numpad3: 'Num3', Numpad4: 'Num4',
|
||||
Numpad5: 'Num5', Numpad6: 'Num6', Numpad7: 'Num7', Numpad8: 'Num8', Numpad9: 'Num9',
|
||||
NumpadAdd: 'NumAdd', NumpadSubtract: 'NumSubtract', NumpadMultiply: 'NumMultiply',
|
||||
NumpadDivide: 'NumDivide', NumpadDecimal: 'NumDecimal', NumpadEnter: 'NumEnter',
|
||||
Minus: 'Minus', Equal: 'Equal', BracketLeft: 'BracketLeft', BracketRight: 'BracketRight',
|
||||
Backslash: 'Backslash', Semicolon: 'Semicolon', Quote: 'Quote', Backquote: 'Backquote',
|
||||
Comma: 'Comma', Period: 'Period', Slash: 'Slash'
|
||||
}
|
||||
|
||||
return keys[code] ?? code
|
||||
}
|
||||
|
||||
function parseTauriShortcut(shortcut) {
|
||||
const expected = { ctrl: false, shift: false, alt: false, meta: false, key: '' }
|
||||
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform)
|
||||
|
||||
for (const rawPart of shortcut.split('+')) {
|
||||
const part = rawPart.trim().toLowerCase()
|
||||
switch (part) {
|
||||
case 'cmdorcontrol':
|
||||
case 'commandorcontrol':
|
||||
expected[isMac ? 'meta' : 'ctrl'] = true
|
||||
break
|
||||
case 'ctrl':
|
||||
case 'control':
|
||||
expected.ctrl = true
|
||||
break
|
||||
case 'cmd':
|
||||
case 'command':
|
||||
case 'meta':
|
||||
case 'super':
|
||||
expected.meta = true
|
||||
break
|
||||
case 'shift':
|
||||
expected.shift = true
|
||||
break
|
||||
case 'alt':
|
||||
case 'option':
|
||||
expected.alt = true
|
||||
break
|
||||
default:
|
||||
expected.key = rawPart.trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return expected
|
||||
}
|
||||
|
||||
window.localShortcut = {
|
||||
register: function (id, shortcut, dotNetReference) {
|
||||
this.unregister(id)
|
||||
const expected = parseTauriShortcut(shortcut)
|
||||
if (!expected.key)
|
||||
return
|
||||
|
||||
const handler = function (event) {
|
||||
if (event.repeat
|
||||
|| event.ctrlKey !== expected.ctrl
|
||||
|| event.shiftKey !== expected.shift
|
||||
|| event.altKey !== expected.alt
|
||||
|| event.metaKey !== expected.meta
|
||||
|| tauriKeyFromKeyboardCode(event.code).toLowerCase() !== expected.key.toLowerCase())
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dotNetReference.invokeMethodAsync('OnLocalShortcutPressed').catch(() => {})
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handler, true)
|
||||
localShortcutHandlers.set(id, handler)
|
||||
},
|
||||
|
||||
unregister: function (id) {
|
||||
const handler = localShortcutHandlers.get(id)
|
||||
if (!handler)
|
||||
return
|
||||
|
||||
document.removeEventListener('keydown', handler, true)
|
||||
localShortcutHandlers.delete(id)
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,7 @@
|
||||
- Fixed an issue that could leave AI Studio unresponsive after waking the computer from sleep. Yes, we know this was an annoying bug, and we apologize for the inconvenience.
|
||||
- Fixed connections to internal HTTPS services and enterprise configuration servers that use organization-provided root certificates on Linux.
|
||||
- Fixed enterprise configuration plugins from Windows-created ZIP files may not load correctly on Linux when the ZIP contained plugin files inside a folder.
|
||||
- Fixed the global voice recording shortcut on Linux so it also works outside AI Studio on supported Wayland desktops.
|
||||
- Fixed the voice recording shortcut on Linux so it works globally on supported desktops and while AI Studio is focused on other Linux desktops.
|
||||
- Fixed voice recording and transcription on Linux.
|
||||
- Fixed copied content from AI Studio not remaining available on the clipboard on Linux.
|
||||
- Fixed dragging and dropping files from the home folder into the Linux Flatpak version.
|
||||
|
||||
@ -91,6 +91,9 @@ pub enum ShortcutBackend {
|
||||
|
||||
/// The Tauri global-shortcut plugin manages the shortcut.
|
||||
Tauri,
|
||||
|
||||
/// The focused application window handles the shortcut.
|
||||
Local,
|
||||
}
|
||||
|
||||
/// Response for shortcut registration and processing state changes.
|
||||
@ -151,6 +154,12 @@ enum ActiveBinding {
|
||||
shortcut: String,
|
||||
},
|
||||
|
||||
/// Stores a shortcut handled within the focused application window.
|
||||
Local {
|
||||
/// Contains the registered shortcut in Tauri syntax.
|
||||
shortcut: String,
|
||||
},
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
/// Stores a shortcut and its live XDG portal session.
|
||||
Portal {
|
||||
@ -170,6 +179,7 @@ impl ActiveBinding {
|
||||
fn shortcut(&self) -> &str {
|
||||
match self {
|
||||
Self::Tauri { shortcut } => shortcut,
|
||||
Self::Local { shortcut } => shortcut,
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Portal { shortcut, .. } => shortcut,
|
||||
}
|
||||
@ -179,6 +189,7 @@ impl ActiveBinding {
|
||||
fn backend(&self) -> ShortcutBackend {
|
||||
match self {
|
||||
Self::Tauri { .. } => ShortcutBackend::Tauri,
|
||||
Self::Local { .. } => ShortcutBackend::Local,
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Portal { .. } => ShortcutBackend::Portal,
|
||||
}
|
||||
@ -188,6 +199,7 @@ impl ActiveBinding {
|
||||
fn effective_display_name(&self) -> String {
|
||||
match self {
|
||||
Self::Tauri { shortcut } => shortcut.clone(),
|
||||
Self::Local { shortcut } => shortcut.clone(),
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Portal { effective_display_name, .. } => effective_display_name.clone(),
|
||||
}
|
||||
@ -246,8 +258,15 @@ pub async fn register(
|
||||
|
||||
Err(error) => {
|
||||
let current_backend = manager.bindings.get(&request.id).map(ActiveBinding::backend);
|
||||
if may_fallback_to_tauri(error.kind, current_backend) {
|
||||
warn!(Source = "XDG portal"; "Global shortcut registration failed; using the Tauri X11 backend: {}", error.message);
|
||||
if may_fallback_to_local(error.kind, current_backend) {
|
||||
warn!(Source = "XDG portal"; "Global shortcut registration failed; using the focused-window fallback: {}", error.message);
|
||||
|
||||
if let Some(old_binding) = manager.bindings.remove(&request.id) {
|
||||
close_binding(&app_handle, request.id, old_binding).await;
|
||||
}
|
||||
|
||||
manager.bindings.insert(request.id, ActiveBinding::Local { shortcut: request.shortcut.clone() });
|
||||
return ShortcutResponse::success(ShortcutBackend::Local, request.shortcut);
|
||||
} else {
|
||||
let cancelled = error.kind == PortalFailureKind::Cancelled;
|
||||
if cancelled {
|
||||
@ -264,6 +283,7 @@ pub async fn register(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
match register_tauri_binding(&app_handle, &request.shortcut, request.id, event_sender) {
|
||||
Ok(()) => {
|
||||
if let Some(old_binding) = manager.bindings.remove(&request.id) {
|
||||
@ -329,6 +349,8 @@ async fn close_binding(app_handle: &tauri::AppHandle, id: Shortcut, binding: Act
|
||||
}
|
||||
},
|
||||
|
||||
ActiveBinding::Local { .. } => {},
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
ActiveBinding::Portal { generation, session, .. } => {
|
||||
let is_still_active = ACTIVE_PORTAL_GENERATIONS.lock().unwrap().get(&id) == Some(&generation);
|
||||
@ -447,8 +469,8 @@ enum PortalFailureKind {
|
||||
Technical,
|
||||
}
|
||||
|
||||
/// Determines whether a failed portal attempt may safely fall back to Tauri.
|
||||
fn may_fallback_to_tauri(_failure: PortalFailureKind, current_backend: Option<ShortcutBackend>) -> bool {
|
||||
/// Determines whether a failed portal attempt may safely use the focused-window fallback.
|
||||
fn may_fallback_to_local(_failure: PortalFailureKind, current_backend: Option<ShortcutBackend>) -> bool {
|
||||
current_backend != Some(ShortcutBackend::Portal)
|
||||
}
|
||||
|
||||
@ -946,29 +968,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that all initial portal failures use the Tauri fallback.
|
||||
fn all_initial_portal_failures_use_tauri_fallback() {
|
||||
/// Verifies that all initial portal failures use the focused-window fallback.
|
||||
fn all_initial_portal_failures_use_local_fallback() {
|
||||
for failure in [
|
||||
PortalFailureKind::Unavailable,
|
||||
PortalFailureKind::Cancelled,
|
||||
PortalFailureKind::Denied,
|
||||
PortalFailureKind::Technical,
|
||||
] {
|
||||
assert!(may_fallback_to_tauri(failure, None));
|
||||
assert!(may_fallback_to_local(failure, None));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that a failed reconfiguration never replaces an active portal binding.
|
||||
fn failed_reconfiguration_preserves_portal_binding() {
|
||||
assert!(!may_fallback_to_tauri(PortalFailureKind::Cancelled, Some(ShortcutBackend::Portal)));
|
||||
assert!(!may_fallback_to_tauri(PortalFailureKind::Denied, Some(ShortcutBackend::Portal)));
|
||||
for failure in [
|
||||
PortalFailureKind::Unavailable,
|
||||
PortalFailureKind::Cancelled,
|
||||
PortalFailureKind::Denied,
|
||||
PortalFailureKind::Technical,
|
||||
] {
|
||||
assert!(!may_fallback_to_local(failure, Some(ShortcutBackend::Portal)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that focused-window bindings expose their shortcut and backend consistently.
|
||||
fn local_binding_reports_runtime_state() {
|
||||
let binding = ActiveBinding::Local { shortcut: "CmdOrControl+3".to_string() };
|
||||
|
||||
assert_eq!(binding.shortcut(), "CmdOrControl+3");
|
||||
assert_eq!(binding.backend(), ShortcutBackend::Local);
|
||||
assert_eq!(binding.effective_display_name(), "CmdOrControl+3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Verifies that suspend keeps portal sessions while unregistering Tauri bindings.
|
||||
fn suspend_keeps_portal_session_registered() {
|
||||
assert!(!unregister_backend_during_suspend(ShortcutBackend::Portal));
|
||||
assert!(!unregister_backend_during_suspend(ShortcutBackend::Local));
|
||||
assert!(unregister_backend_during_suspend(ShortcutBackend::Tauri));
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user