Manage global shortcuts (#635)
Some checks are pending
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (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) (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 deb updater) (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 updater) (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) (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 deb updater) (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer 2026-01-24 20:05:34 +01:00 committed by GitHub
parent c9f037bb2c
commit 127778df42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1654 additions and 35 deletions

View File

@ -1675,6 +1675,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T20906218
-- Use app default -- Use app default
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T3672477670"] = "Use app default" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T3672477670"] = "Use app default"
-- No shortcut configured
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T3099115336"] = "No shortcut configured"
-- Change shortcut
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T4081853237"] = "Change shortcut"
-- Configure Keyboard Shortcut
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T636303786"] = "Configure Keyboard Shortcut"
-- Yes, let the AI decide which data sources are needed. -- Yes, let the AI decide which data sources are needed.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1031370894"] = "Yes, let the AI decide which data sources are needed." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1031370894"] = "Yes, let the AI decide which data sources are needed."
@ -2017,6 +2026,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1059411425"]
-- Do you want to show preview features in the app? -- Do you want to show preview features in the app?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Do you want to show preview features in the app?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Do you want to show preview features in the app?"
-- Voice recording shortcut
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1278320412"] = "Voice recording shortcut"
-- How often should we check for app updates? -- How often should we check for app updates?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1364944735"] = "How often should we check for app updates?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1364944735"] = "How often should we check for app updates?"
@ -2047,6 +2059,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"]
-- Select the language for the app. -- Select the language for the app.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Select the language for the app." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Select the language for the app."
-- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused."
-- Disable dictation and transcription -- Disable dictation and transcription
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Disable dictation and transcription" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Disable dictation and transcription"
@ -4612,6 +4627,42 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T3832
-- Preselect one of your profiles? -- Preselect one of your profiles?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T4004501229"] = "Preselect one of your profiles?" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T4004501229"] = "Preselect one of your profiles?"
-- Save
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1294818664"] = "Save"
-- Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1464973299"] = "Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused."
-- Press a key combination...
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1468443151"] = "Press a key combination..."
-- Clear Shortcut
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1807313248"] = "Clear Shortcut"
-- Invalid shortcut: {0}
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T189893682"] = "Invalid shortcut: {0}"
-- This shortcut conflicts with: {0}
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T2633102934"] = "This shortcut conflicts with: {0}"
-- Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3060573513"] = "Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd)."
-- Shortcut is valid and available.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3159532525"] = "Shortcut is valid and available."
-- Define a shortcut
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3734850493"] = "Define a shortcut"
-- This is the shortcut you previously used.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T4167229652"] = "This is the shortcut you previously used."
-- Supported modifiers: Ctrl/Cmd, Shift, Alt.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T889258890"] = "Supported modifiers: Ctrl/Cmd, Shift, Alt."
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T900713019"] = "Cancel"
-- Please enter a value. -- Please enter a value.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SINGLEINPUTDIALOG::T3576780391"] = "Please enter a value." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SINGLEINPUTDIALOG::T3576780391"] = "Please enter a value."
@ -5050,6 +5101,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2557066213"] = "Used Open Source
-- Build time -- Build time
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build time" UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build time"
-- This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2635482790"] = "This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems."
-- To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system. -- To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2644379659"] = "To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system." UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2644379659"] = "To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system."

View File

@ -48,7 +48,7 @@
OnAdornmentClick="() => this.SendMessage()" OnAdornmentClick="() => this.SendMessage()"
Disabled="@this.IsInputForbidden()" Disabled="@this.IsInputForbidden()"
Immediate="@true" Immediate="@true"
OnKeyUp="this.InputKeyEvent" OnKeyUp="@this.InputKeyEvent"
UserAttributes="@USER_INPUT_ATTRIBUTES" UserAttributes="@USER_INPUT_ATTRIBUTES"
Class="@this.UserInputClass" Class="@this.UserInputClass"
Style="@this.UserInputStyle"/> Style="@this.UserInputStyle"/>

View File

@ -0,0 +1,26 @@
@inherits ConfigurationBaseCore
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@this.Icon" Color="@this.IconColor"/>
<MudText Typo="Typo.body1" Class="flex-grow-1">
@if (string.IsNullOrWhiteSpace(this.Shortcut()))
{
@T("No shortcut configured")
}
else
{
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Outlined">
@this.GetDisplayShortcut()
</MudChip>
}
</MudText>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Edit"
OnClick="@this.OpenDialog"
Disabled="@this.IsDisabled"
Class="mb-1">
@T("Change shortcut")
</MudButton>
</MudStack>

View File

@ -0,0 +1,109 @@
using AIStudio.Dialogs;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Components;
/// <summary>
/// A configuration component for capturing and displaying keyboard shortcuts.
/// </summary>
public partial class ConfigurationShortcut : ConfigurationBaseCore
{
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
/// <summary>
/// The current shortcut value.
/// </summary>
[Parameter]
public Func<string> Shortcut { get; set; } = () => string.Empty;
/// <summary>
/// An action which is called when the shortcut was changed.
/// </summary>
[Parameter]
public Action<string> ShortcutUpdate { get; set; } = _ => { };
/// <summary>
/// The name/identifier of the shortcut (used for conflict detection and registration).
/// </summary>
[Parameter]
public Shortcut ShortcutId { get; init; }
/// <summary>
/// The icon to display.
/// </summary>
[Parameter]
public string Icon { get; set; } = Icons.Material.Filled.Keyboard;
/// <summary>
/// The color of the icon.
/// </summary>
[Parameter]
public Color IconColor { get; set; } = Color.Default;
#region Overrides of ConfigurationBase
protected override bool Stretch => true;
protected override Variant Variant => Variant.Outlined;
protected override string Label => this.OptionDescription;
#endregion
private string GetDisplayShortcut()
{
var shortcut = this.Shortcut();
if (string.IsNullOrWhiteSpace(shortcut))
return string.Empty;
// Convert internal format to display format:
return shortcut
.Replace("CmdOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl")
.Replace("CommandOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl");
}
private async Task OpenDialog()
{
// 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();
try
{
var dialogParameters = new DialogParameters<ShortcutDialog>
{
{ x => x.InitialShortcut, this.Shortcut() },
{ x => x.ShortcutId, this.ShortcutId },
};
var dialogReference = await this.DialogService.ShowAsync<ShortcutDialog>(
this.T("Configure Keyboard Shortcut"),
dialogParameters,
DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
if (dialogResult.Data is string newShortcut)
{
this.ShortcutUpdate(newShortcut);
await this.SettingsManager.StoreSettings();
await this.InformAboutChange();
}
}
finally
{
// Resume the shortcut processing when the dialog is closed:
await this.RustService.ResumeShortcutProcessing();
}
}
}

View File

@ -1,5 +1,6 @@
@using AIStudio.Settings @using AIStudio.Settings
@using AIStudio.Settings.DataModel @using AIStudio.Settings.DataModel
@using AIStudio.Tools.Rust
@inherits SettingsPanelBase @inherits SettingsPanelBase
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Apps" HeaderText="@T("App Options")"> <ExpansionPanel HeaderIcon="@Icons.Material.Filled.Apps" HeaderText="@T("App Options")">
@ -33,5 +34,6 @@
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) @if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager))
{ {
<ConfigurationSelect OptionDescription="@T("Select a transcription provider")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider)" Data="@this.GetFilteredTranscriptionProviders()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider = selectedValue)" OptionHelp="@T("Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.UseTranscriptionProvider, out var meta) && meta.IsLocked"/> <ConfigurationSelect OptionDescription="@T("Select a transcription provider")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider)" Data="@this.GetFilteredTranscriptionProviders()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider = selectedValue)" OptionHelp="@T("Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.UseTranscriptionProvider, out var meta) && meta.IsLocked"/>
<ConfigurationShortcut ShortcutId="Shortcut.VOICE_RECORDING_TOGGLE" OptionDescription="@T("Voice recording shortcut")" Shortcut="@(() => this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecording)" ShortcutUpdate="@(shortcut => this.SettingsManager.ConfigurationData.App.ShortcutVoiceRecording = shortcut)" OptionHelp="@T("The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.ShortcutVoiceRecording, out var meta) && meta.IsLocked"/>
} }
</ExpansionPanel> </ExpansionPanel>

View File

@ -1,5 +1,6 @@
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Tools.MIME; using AIStudio.Tools.MIME;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@ -24,6 +25,9 @@ public partial class VoiceRecorder : MSGComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// Register for global shortcut events:
this.ApplyFilters([], [Event.TAURI_EVENT_RECEIVED]);
await base.OnInitializedAsync(); await base.OnInitializedAsync();
try try
@ -37,6 +41,38 @@ public partial class VoiceRecorder : MSGComponentBase
} }
} }
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
switch (triggeredEvent)
{
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.GLOBAL_SHORTCUT_PRESSED } tauriEvent:
// Check if this is the voice recording toggle shortcut:
if (tauriEvent.TryGetShortcut(out var shortcutId) && shortcutId == Shortcut.VOICE_RECORDING_TOGGLE)
{
this.Logger.LogInformation("Global shortcut triggered for voice recording toggle.");
await this.ToggleRecordingFromShortcut();
}
break;
}
}
/// <summary>
/// Toggles the recording state when triggered by a global shortcut.
/// </summary>
private async Task ToggleRecordingFromShortcut()
{
// Don't allow toggle if transcription is in progress or preparing:
if (this.isTranscribing || this.isPreparing)
{
this.Logger.LogDebug("Ignoring shortcut: transcription or preparation is in progress.");
return;
}
// Toggle the recording state:
await this.OnRecordingToggled(!this.isRecording);
}
#endregion #endregion
private uint numReceivedChunks; private uint numReceivedChunks;
@ -109,6 +145,10 @@ public partial class VoiceRecorder : MSGComponentBase
// Clean up the recording stream if starting failed: // Clean up the recording stream if starting failed:
await this.FinalizeRecordingStream(); await this.FinalizeRecordingStream();
} }
finally
{
this.StateHasChanged();
}
} }
else else
{ {

View File

@ -0,0 +1,50 @@
@inherits MSGComponentBase
<MudDialog>
<DialogContent>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused.")
</MudJustifiedText>
<MudFocusTrap DefaultFocus="DefaultFocus.FirstChild">
<MudTextField
@ref="@this.inputField"
T="string"
Text="@this.ShowText"
Variant="Variant.Outlined"
Label="@T("Define a shortcut")"
Placeholder="@T("Press a key combination...")"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Keyboard"
Immediate="@true"
TextUpdateSuppression="false"
OnKeyDown="@this.HandleKeyDown"
OnBlur="@this.HandleBlur"
UserAttributes="@USER_INPUT_ATTRIBUTES"
AutoFocus="true"
KeyDownPreventDefault="true"
KeyUpPreventDefault="true"
HelperText="@T("Supported modifiers: Ctrl/Cmd, Shift, Alt.")"
Class="me-3"/>
</MudFocusTrap>
@if (!string.IsNullOrWhiteSpace(this.validationMessage))
{
<MudAlert Severity="@this.validationSeverity" Variant="Variant.Filled" Class="mb-3">
@this.validationMessage
</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.ClearShortcut" Variant="Variant.Filled" Color="Color.Info" StartIcon="@Icons.Material.Filled.Clear">
@T("Clear Shortcut")
</MudButton>
<MudSpacer/>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">
@T("Cancel")
</MudButton>
<MudButton OnClick="@this.Confirm" Variant="Variant.Filled" Color="Color.Primary" Disabled="@this.hasValidationError">
@T("Save")
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,385 @@
using AIStudio.Components;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
namespace AIStudio.Dialogs;
/// <summary>
/// A dialog for capturing and configuring keyboard shortcuts.
/// </summary>
public partial class ShortcutDialog : MSGComponentBase
{
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
/// <summary>
/// The initial shortcut value (in internal format, e.g., "CmdOrControl+1").
/// </summary>
[Parameter]
public string InitialShortcut { get; set; } = string.Empty;
/// <summary>
/// The identifier of the shortcut for conflict detection.
/// </summary>
[Parameter]
public Shortcut ShortcutId { get; set; }
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
private string currentShortcut = string.Empty;
private string originalShortcut = string.Empty;
private string validationMessage = string.Empty;
private Severity validationSeverity = Severity.Info;
private bool hasValidationError;
//
// Current key state:
//
private bool hasCtrl;
private bool hasShift;
private bool hasAlt;
private bool hasMeta;
private string? currentKey;
private MudTextField<string>? inputField;
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Configure the spellchecking for the user input:
this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES);
this.currentShortcut = this.InitialShortcut;
this.originalShortcut = this.InitialShortcut;
this.ParseExistingShortcut();
}
#endregion
private string ShowText => string.IsNullOrWhiteSpace(this.currentShortcut)
? T("Press a key combination...")
: this.GetDisplayShortcut();
private void ParseExistingShortcut()
{
if (string.IsNullOrWhiteSpace(this.currentShortcut))
return;
// Parse the existing shortcut to set the state
var parts = this.currentShortcut.Split('+');
foreach (var part in parts)
{
switch (part.ToLowerInvariant())
{
case "cmdorcontrol":
case "commandorcontrol":
case "ctrl":
case "control":
case "cmd":
case "command":
this.hasCtrl = true;
break;
case "shift":
this.hasShift = true;
break;
case "alt":
this.hasAlt = true;
break;
case "meta":
case "super":
this.hasMeta = true;
break;
default:
this.currentKey = part;
break;
}
}
}
private async Task HandleKeyDown(KeyboardEventArgs e)
{
// Ignore pure modifier key presses:
if (IsModifierKey(e.Code))
{
this.UpdateModifiers(e);
this.currentKey = null;
this.UpdateShortcutString();
return;
}
this.UpdateModifiers(e);
// Get the key:
this.currentKey = TranslateKeyCode(e.Code);
// Validate: must have at least one modifier + a key
if (!this.hasCtrl && !this.hasShift && !this.hasAlt && !this.hasMeta)
{
this.validationMessage = T("Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd).");
this.validationSeverity = Severity.Warning;
this.hasValidationError = true;
this.StateHasChanged();
return;
}
this.UpdateShortcutString();
await this.ValidateShortcut();
this.StateHasChanged();
}
private void UpdateModifiers(KeyboardEventArgs e)
{
this.hasCtrl = e.CtrlKey || e.MetaKey; // Treat Meta (Cmd on Mac) same as Ctrl for cross-platform
this.hasShift = e.ShiftKey;
this.hasAlt = e.AltKey;
this.hasMeta = e is { MetaKey: true, CtrlKey: false }; // Only set meta if not already using ctrl
}
private void UpdateShortcutString()
{
var parts = new List<string>();
if (this.hasCtrl)
parts.Add("CmdOrControl");
if (this.hasShift)
parts.Add("Shift");
if (this.hasAlt)
parts.Add("Alt");
if (!string.IsNullOrWhiteSpace(this.currentKey))
parts.Add(this.currentKey);
this.currentShortcut = parts.Count > 0 ? string.Join("+", parts) : string.Empty;
this.StateHasChanged();
}
private async Task ValidateShortcut()
{
if (string.IsNullOrWhiteSpace(this.currentShortcut) || string.IsNullOrWhiteSpace(this.currentKey))
{
this.validationMessage = string.Empty;
this.hasValidationError = false;
return;
}
// Check if the shortcut is valid by trying to register it with Rust
var result = await this.RustService.ValidateShortcut(this.currentShortcut);
if (result.IsValid)
{
if (!string.IsNullOrWhiteSpace(this.originalShortcut)
&& this.currentShortcut.Equals(this.originalShortcut, StringComparison.OrdinalIgnoreCase))
{
this.validationMessage = T("This is the shortcut you previously used.");
this.validationSeverity = Severity.Info;
this.hasValidationError = false;
this.StateHasChanged();
return;
}
if (result.HasConflict)
{
this.validationMessage = string.Format(T("This shortcut conflicts with: {0}"), result.ConflictDescription);
this.validationSeverity = Severity.Warning;
this.hasValidationError = false; // Allow saving, but warn
}
else
{
this.validationMessage = T("Shortcut is valid and available.");
this.validationSeverity = Severity.Success;
this.hasValidationError = false;
}
}
else
{
this.validationMessage = string.Format(T("Invalid shortcut: {0}"), result.ErrorMessage);
this.validationSeverity = Severity.Error;
this.hasValidationError = true;
}
this.StateHasChanged();
}
private string GetDisplayShortcut()
{
// Convert internal format to display format:
return this.currentShortcut
.Replace("CmdOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl")
.Replace("CommandOrControl", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl");
}
private void ClearShortcut()
{
this.currentShortcut = string.Empty;
this.currentKey = null;
this.hasCtrl = false;
this.hasShift = false;
this.hasAlt = false;
this.hasMeta = false;
this.validationMessage = string.Empty;
this.hasValidationError = false;
this.StateHasChanged();
}
private void Cancel() => this.MudDialog.Cancel();
private void Confirm() => this.MudDialog.Close(DialogResult.Ok(this.currentShortcut));
/// <summary>
/// Checks if the key code represents a modifier key.
/// </summary>
private static bool IsModifierKey(string code) => code switch
{
"ShiftLeft" or "ShiftRight" => true,
"ControlLeft" or "ControlRight" => true,
"AltLeft" or "AltRight" => true,
"MetaLeft" or "MetaRight" => true,
_ => false,
};
/// <summary>
/// Translates a JavaScript KeyboardEvent.code to Tauri shortcut format.
/// </summary>
private static string TranslateKeyCode(string code) => code switch
{
// Letters
"KeyA" => "A",
"KeyB" => "B",
"KeyC" => "C",
"KeyD" => "D",
"KeyE" => "E",
"KeyF" => "F",
"KeyG" => "G",
"KeyH" => "H",
"KeyI" => "I",
"KeyJ" => "J",
"KeyK" => "K",
"KeyL" => "L",
"KeyM" => "M",
"KeyN" => "N",
"KeyO" => "O",
"KeyP" => "P",
"KeyQ" => "Q",
"KeyR" => "R",
"KeyS" => "S",
"KeyT" => "T",
"KeyU" => "U",
"KeyV" => "V",
"KeyW" => "W",
"KeyX" => "X",
"KeyY" => "Y",
"KeyZ" => "Z",
// Numbers
"Digit0" => "0",
"Digit1" => "1",
"Digit2" => "2",
"Digit3" => "3",
"Digit4" => "4",
"Digit5" => "5",
"Digit6" => "6",
"Digit7" => "7",
"Digit8" => "8",
"Digit9" => "9",
// Function keys
"F1" => "F1",
"F2" => "F2",
"F3" => "F3",
"F4" => "F4",
"F5" => "F5",
"F6" => "F6",
"F7" => "F7",
"F8" => "F8",
"F9" => "F9",
"F10" => "F10",
"F11" => "F11",
"F12" => "F12",
"F13" => "F13",
"F14" => "F14",
"F15" => "F15",
"F16" => "F16",
"F17" => "F17",
"F18" => "F18",
"F19" => "F19",
"F20" => "F20",
"F21" => "F21",
"F22" => "F22",
"F23" => "F23",
"F24" => "F24",
// Special keys
"Space" => "Space",
"Enter" => "Enter",
"Tab" => "Tab",
"Escape" => "Escape",
"Backspace" => "Backspace",
"Delete" => "Delete",
"Insert" => "Insert",
"Home" => "Home",
"End" => "End",
"PageUp" => "PageUp",
"PageDown" => "PageDown",
// Arrow keys
"ArrowUp" => "Up",
"ArrowDown" => "Down",
"ArrowLeft" => "Left",
"ArrowRight" => "Right",
// Numpad
"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",
// Punctuation
"Minus" => "Minus",
"Equal" => "Equal",
"BracketLeft" => "BracketLeft",
"BracketRight" => "BracketRight",
"Backslash" => "Backslash",
"Semicolon" => "Semicolon",
"Quote" => "Quote",
"Backquote" => "Backquote",
"Comma" => "Comma",
"Period" => "Period",
"Slash" => "Slash",
// Default: return as-is
_ => code,
};
private void HandleBlur()
{
// Re-focus the input field to keep capturing keys:
this.inputField?.FocusAsync();
}
}

View File

@ -97,6 +97,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
// Set the snackbar for the update service: // Set the snackbar for the update service:
UpdateService.SetBlazorDependencies(this.Snackbar); UpdateService.SetBlazorDependencies(this.Snackbar);
GlobalShortcutService.Initialize();
TemporaryChatService.Initialize(); TemporaryChatService.Initialize();
// Should the navigation bar be open by default? // Should the navigation bar be open by default?
@ -116,11 +117,6 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
await base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
private void LoadNavItems()
{
this.navItems = new List<NavBarItem>(this.GetNavItems());
}
#endregion #endregion
#region Implementation of ILang #region Implementation of ILang
@ -251,6 +247,11 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
#endregion #endregion
private void LoadNavItems()
{
this.navItems = new List<NavBarItem>(this.GetNavItems());
}
private IEnumerable<NavBarItem> GetNavItems() private IEnumerable<NavBarItem> GetNavItems()
{ {
var palette = this.ColorTheme.GetCurrentPalette(this.SettingsManager); var palette = this.ColorTheme.GetCurrentPalette(this.SettingsManager);

View File

@ -196,6 +196,7 @@
<ThirdPartyComponent Name="Tauri" Developer="Daniel Thompson-Yvetot, Lucas Nogueira, Tensor, Boscop, Serge Zaitsev, George Burton & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/tauri-apps/tauri/blob/dev/LICENSE_MIT" RepositoryUrl="https://github.com/tauri-apps/tauri" UseCase="@T("Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!")"/> <ThirdPartyComponent Name="Tauri" Developer="Daniel Thompson-Yvetot, Lucas Nogueira, Tensor, Boscop, Serge Zaitsev, George Burton & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/tauri-apps/tauri/blob/dev/LICENSE_MIT" RepositoryUrl="https://github.com/tauri-apps/tauri" UseCase="@T("Tauri is used to host the Blazor user interface. It is a great project that allows the creation of desktop applications using web technologies. I love Tauri!")"/>
<ThirdPartyComponent Name="Rocket" Developer="Sergio Benitez & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/rwf2/Rocket/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/rwf2/Rocket" UseCase="@T("We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust.")"/> <ThirdPartyComponent Name="Rocket" Developer="Sergio Benitez & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/rwf2/Rocket/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/rwf2/Rocket" UseCase="@T("We use Rocket to implement the runtime API. This is necessary because the runtime must be able to communicate with the user interface (IPC). Rocket is a great framework for implementing web APIs in Rust.")"/>
<ThirdPartyComponent Name="serde" Developer="Erick Tryzelaar, David Tolnay & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/serde-rs/serde/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/serde-rs/serde" UseCase="@T("Now we have multiple systems, some developed in .NET and others in Rust. The data format JSON is responsible for translating data between both worlds (called data serialization and deserialization). Serde takes on this task in the Rust world. The counterpart in the .NET world is an integral part of .NET and is located in System.Text.Json.")"/> <ThirdPartyComponent Name="serde" Developer="Erick Tryzelaar, David Tolnay & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/serde-rs/serde/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/serde-rs/serde" UseCase="@T("Now we have multiple systems, some developed in .NET and others in Rust. The data format JSON is responsible for translating data between both worlds (called data serialization and deserialization). Serde takes on this task in the Rust world. The counterpart in the .NET world is an integral part of .NET and is located in System.Text.Json.")"/>
<ThirdPartyComponent Name="strum_macros" Developer="Peter Glotfelty & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/Peternator7/strum/blob/master/LICENSE" RepositoryUrl="https://github.com/Peternator7/strum" UseCase="@T("This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems.")"/>
<ThirdPartyComponent Name="keyring" Developer="Walther Chen, Daniel Brotsky & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/hwchen/keyring-rs/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/hwchen/keyring-rs" UseCase="@T("In order to use any LLM, each user must store their so-called API key for each LLM provider. This key must be kept secure, similar to a password. The safest way to do this is offered by operating systems like macOS, Windows, and Linux: They have mechanisms to store such data, if available, on special security hardware. Since this is currently not possible in .NET, we use this Rust library.")"/> <ThirdPartyComponent Name="keyring" Developer="Walther Chen, Daniel Brotsky & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/hwchen/keyring-rs/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/hwchen/keyring-rs" UseCase="@T("In order to use any LLM, each user must store their so-called API key for each LLM provider. This key must be kept secure, similar to a password. The safest way to do this is offered by operating systems like macOS, Windows, and Linux: They have mechanisms to store such data, if available, on special security hardware. Since this is currently not possible in .NET, we use this Rust library.")"/>
<ThirdPartyComponent Name="arboard" Developer="Artur Kovacs, Avi Weinstock, 1Password & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/1Password/arboard/blob/master/LICENSE-MIT.txt" RepositoryUrl="https://github.com/1Password/arboard" UseCase="@T("To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system.")"/> <ThirdPartyComponent Name="arboard" Developer="Artur Kovacs, Avi Weinstock, 1Password & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/1Password/arboard/blob/master/LICENSE-MIT.txt" RepositoryUrl="https://github.com/1Password/arboard" UseCase="@T("To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system.")"/>
<ThirdPartyComponent Name="tokio" Developer="Alex Crichton, Carl Lerche, Alice Ryhl, Taiki Endo, Ivan Petkov, Eliza Weisman, Lucio Franco & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/tokio-rs/tokio/blob/master/LICENSE" RepositoryUrl="https://github.com/tokio-rs/tokio" UseCase="@T("Code in the Rust language can be specified as synchronous or asynchronous. Unlike .NET and the C# language, Rust cannot execute asynchronous code by itself. Rust requires support in the form of an executor for this. Tokio is one such executor.")"/> <ThirdPartyComponent Name="tokio" Developer="Alex Crichton, Carl Lerche, Alice Ryhl, Taiki Endo, Ivan Petkov, Eliza Weisman, Lucio Franco & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/tokio-rs/tokio/blob/master/LICENSE" RepositoryUrl="https://github.com/tokio-rs/tokio" UseCase="@T("Code in the Rust language can be specified as synchronous or asynchronous. Unlike .NET and the C# language, Rust cannot execute asynchronous code by itself. Rust requires support in the form of an executor for this. Tokio is one such executor.")"/>

View File

@ -153,6 +153,16 @@ CONFIG["SETTINGS"] = {}
-- I18N_ASSISTANT -- I18N_ASSISTANT
-- CONFIG["SETTINGS"]["DataApp.HiddenAssistants"] = { "ERI_ASSISTANT", "I18N_ASSISTANT" } -- CONFIG["SETTINGS"]["DataApp.HiddenAssistants"] = { "ERI_ASSISTANT", "I18N_ASSISTANT" }
-- Configure a global shortcut for starting and stopping dictation.
--
-- The format follows the Rust and Tauri conventions. Especially,
-- when you want to use the CTRL key on Windows (or the CMD key on macOS),
-- please use "CmdOrControl" as the key name. All parts of the shortcut
-- must be separated by a plus sign (+).
--
-- Examples are: "CmdOrControl+Shift+D", "Alt+F9", "F8"
-- CONFIG["SETTINGS"]["DataApp.ShortcutVoiceRecording"] = "CmdOrControl+1"
-- Example chat templates for this configuration: -- Example chat templates for this configuration:
CONFIG["CHAT_TEMPLATES"] = {} CONFIG["CHAT_TEMPLATES"] = {}

View File

@ -1677,6 +1677,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T20906218
-- Use app default -- Use app default
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T3672477670"] = "App-Standard verwenden" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T3672477670"] = "App-Standard verwenden"
-- No shortcut configured
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T3099115336"] = "Keinen Tastaturkurzbefehl konfiguriert"
-- Change shortcut
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T4081853237"] = "Tastaturkurzbefehl ändern"
-- Configure Keyboard Shortcut
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T636303786"] = "Tastaturkurzbefehl konfigurieren"
-- Yes, let the AI decide which data sources are needed. -- Yes, let the AI decide which data sources are needed.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1031370894"] = "Ja, die KI soll entscheiden, welche Datenquellen benötigt werden." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1031370894"] = "Ja, die KI soll entscheiden, welche Datenquellen benötigt werden."
@ -2019,6 +2028,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1059411425"]
-- Do you want to show preview features in the app? -- Do you want to show preview features in the app?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Möchten Sie Vorschaufunktionen in der App anzeigen lassen?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Möchten Sie Vorschaufunktionen in der App anzeigen lassen?"
-- Voice recording shortcut
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1278320412"] = "Tastaturkurzbefehl für Sprachaufnahme"
-- How often should we check for app updates? -- How often should we check for app updates?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1364944735"] = "Wie oft sollen wir nach App-Updates suchen?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1364944735"] = "Wie oft sollen wir nach App-Updates suchen?"
@ -2049,6 +2061,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"]
-- Select the language for the app. -- Select the language for the app.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Wählen Sie die Sprache für die App aus." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Wählen Sie die Sprache für die App aus."
-- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "Der globale Tastaturkurzbefehl zum Ein- und Ausschalten der Sprachaufnahme. Dieser Kurzbefehl funktioniert systemweit, auch wenn die App nicht im Vordergrund ist."
-- Disable dictation and transcription -- Disable dictation and transcription
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Diktieren und Transkribieren deaktivieren" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Diktieren und Transkribieren deaktivieren"
@ -4614,6 +4629,42 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T3832
-- Preselect one of your profiles? -- Preselect one of your profiles?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T4004501229"] = "Eines ihrer Profile vorauswählen?" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T4004501229"] = "Eines ihrer Profile vorauswählen?"
-- Save
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1294818664"] = "Speichern"
-- Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1464973299"] = "Drücken Sie die gewünschte Tastenkombination, um den Kurzbefehl festzulegen. Der Tastaturkurzbefehl wird global registriert und funktioniert auch, wenn die App nicht im Vordergrund ist."
-- Press a key combination...
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1468443151"] = "Drücken Sie eine Tastenkombination …"
-- Clear Shortcut
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1807313248"] = "Tastaturkurzbefehl löschen"
-- Invalid shortcut: {0}
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T189893682"] = "Ungültige Tastenkombination: {0}"
-- This shortcut conflicts with: {0}
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T2633102934"] = "Dieser Tastaturkurzbefehl steht in Konflikt mit: {0}"
-- Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3060573513"] = "Bitte fügen Sie mindestens einen Modifikatortaste hinzu (Strg, Umschalt, Alt oder Cmd)."
-- Shortcut is valid and available.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3159532525"] = "Der Tastaturkurzbefehl ist gültig und verfügbar."
-- Define a shortcut
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3734850493"] = "Tastaturkurzbefehl festlegen"
-- This is the shortcut you previously used.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T4167229652"] = "Dies ist der Tastaturkurzbefehl, den Sie zuvor verwendet haben."
-- Supported modifiers: Ctrl/Cmd, Shift, Alt.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T889258890"] = "Unterstützte Modifikatortasten: Strg/Cmd, Umschalt, Alt."
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T900713019"] = "Abbrechen"
-- Please enter a value. -- Please enter a value.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SINGLEINPUTDIALOG::T3576780391"] = "Bitte geben Sie einen Wert ein." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SINGLEINPUTDIALOG::T3576780391"] = "Bitte geben Sie einen Wert ein."
@ -5052,6 +5103,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2557066213"] = "Verwendete Open-
-- Build time -- Build time
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build-Zeit" UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build-Zeit"
-- This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2635482790"] = "Dieses Crate stellt Derive-Makros für Rust-Enums bereit, die wir verwenden, um Boilerplate zu reduzieren, wenn wir String-Konvertierungen und Metadaten für Laufzeittypen implementieren. Das ist hilfreich für die Kommunikation zwischen unseren Rust- und .NET-Systemen."
-- To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system. -- To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2644379659"] = "Um die Antworten des LLM in anderen Apps nutzen zu können, verwenden wir häufig die Zwischenablage des jeweiligen Betriebssystems. Leider gibt es in .NET keine Lösung, die auf allen Betriebssystemen funktioniert. Deshalb habe ich mich für diese Bibliothek in Rust entschieden. So funktioniert die Datenübertragung zu anderen Apps auf jedem System." UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2644379659"] = "Um die Antworten des LLM in anderen Apps nutzen zu können, verwenden wir häufig die Zwischenablage des jeweiligen Betriebssystems. Leider gibt es in .NET keine Lösung, die auf allen Betriebssystemen funktioniert. Deshalb habe ich mich für diese Bibliothek in Rust entschieden. So funktioniert die Datenübertragung zu anderen Apps auf jedem System."

View File

@ -1677,6 +1677,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T20906218
-- Use app default -- Use app default
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T3672477670"] = "Use app default" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T3672477670"] = "Use app default"
-- No shortcut configured
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T3099115336"] = "No shortcut configured"
-- Change shortcut
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T4081853237"] = "Change shortcut"
-- Configure Keyboard Shortcut
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONSHORTCUT::T636303786"] = "Configure Keyboard Shortcut"
-- Yes, let the AI decide which data sources are needed. -- Yes, let the AI decide which data sources are needed.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1031370894"] = "Yes, let the AI decide which data sources are needed." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1031370894"] = "Yes, let the AI decide which data sources are needed."
@ -2019,6 +2028,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1059411425"]
-- Do you want to show preview features in the app? -- Do you want to show preview features in the app?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Do you want to show preview features in the app?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1118505044"] = "Do you want to show preview features in the app?"
-- Voice recording shortcut
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1278320412"] = "Voice recording shortcut"
-- How often should we check for app updates? -- How often should we check for app updates?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1364944735"] = "How often should we check for app updates?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1364944735"] = "How often should we check for app updates?"
@ -2049,6 +2061,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1898060643"]
-- Select the language for the app. -- Select the language for the app.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Select the language for the app." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1907446663"] = "Select the language for the app."
-- The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2143741496"] = "The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused."
-- Disable dictation and transcription -- Disable dictation and transcription
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Disable dictation and transcription" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] = "Disable dictation and transcription"
@ -4614,6 +4629,42 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T3832
-- Preselect one of your profiles? -- Preselect one of your profiles?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T4004501229"] = "Preselect one of your profiles?" UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGWRITINGEMAILS::T4004501229"] = "Preselect one of your profiles?"
-- Save
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1294818664"] = "Save"
-- Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1464973299"] = "Press the desired key combination to set the shortcut. The shortcut will be registered globally and will work even when the app is not focused."
-- Press a key combination...
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1468443151"] = "Press a key combination..."
-- Clear Shortcut
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T1807313248"] = "Clear Shortcut"
-- Invalid shortcut: {0}
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T189893682"] = "Invalid shortcut: {0}"
-- This shortcut conflicts with: {0}
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T2633102934"] = "This shortcut conflicts with: {0}"
-- Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3060573513"] = "Please include at least one modifier key (Ctrl, Shift, Alt, or Cmd)."
-- Shortcut is valid and available.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3159532525"] = "Shortcut is valid and available."
-- Define a shortcut
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T3734850493"] = "Define a shortcut"
-- This is the shortcut you previously used.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T4167229652"] = "This is the shortcut you previously used."
-- Supported modifiers: Ctrl/Cmd, Shift, Alt.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T889258890"] = "Supported modifiers: Ctrl/Cmd, Shift, Alt."
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SHORTCUTDIALOG::T900713019"] = "Cancel"
-- Please enter a value. -- Please enter a value.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SINGLEINPUTDIALOG::T3576780391"] = "Please enter a value." UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SINGLEINPUTDIALOG::T3576780391"] = "Please enter a value."
@ -5052,6 +5103,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2557066213"] = "Used Open Source
-- Build time -- Build time
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build time" UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build time"
-- This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2635482790"] = "This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems."
-- To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system. -- To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2644379659"] = "To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system." UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2644379659"] = "To be able to use the responses of the LLM in other apps, we often use the clipboard of the respective operating system. Unfortunately, in .NET there is no solution that works with all operating systems. Therefore, I have opted for this library in Rust. This way, data transfer to other apps works on every system."

View File

@ -133,6 +133,7 @@ internal sealed class Program
builder.Services.AddHostedService<UpdateService>(); builder.Services.AddHostedService<UpdateService>();
builder.Services.AddHostedService<TemporaryChatService>(); builder.Services.AddHostedService<TemporaryChatService>();
builder.Services.AddHostedService<EnterpriseEnvironmentService>(); builder.Services.AddHostedService<EnterpriseEnvironmentService>();
builder.Services.AddHostedService<GlobalShortcutService>();
// ReSharper disable AccessToDisposedClosure // ReSharper disable AccessToDisposedClosure
builder.Services.AddHostedService<RustService>(_ => rust); builder.Services.AddHostedService<RustService>(_ => rust);

View File

@ -82,6 +82,13 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
/// </summary> /// </summary>
public string UseTranscriptionProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.UseTranscriptionProvider, string.Empty); public string UseTranscriptionProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.UseTranscriptionProvider, string.Empty);
/// <summary>
/// The global keyboard shortcut for toggling voice recording.
/// Uses Tauri's shortcut format, e.g., "CmdOrControl+1" (Cmd+1 on macOS, Ctrl+1 on Windows/Linux).
/// Set to empty string to disable the global shortcut.
/// </summary>
public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty);
/// <summary> /// <summary>
/// Should the user be allowed to add providers? /// Should the user be allowed to add providers?
/// </summary> /// </summary>

View File

@ -70,6 +70,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: hide some assistants? // Config: hide some assistants?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HiddenAssistants, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HiddenAssistants, this.Id, settingsTable, dryRun);
// Config: global voice recording shortcut
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShortcutVoiceRecording, this.Id, settingsTable, dryRun);
// Handle configured LLM providers: // Handle configured LLM providers:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun); PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun);

View File

@ -181,6 +181,10 @@ public static partial class PluginFactory
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HiddenAssistants, AVAILABLE_PLUGINS)) if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HiddenAssistants, AVAILABLE_PLUGINS))
wasConfigurationChanged = true; wasConfigurationChanged = true;
// Check for the voice recording shortcut:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShortcutVoiceRecording, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if (wasConfigurationChanged) if (wasConfigurationChanged)
{ {
await SETTINGS_MANAGER.StoreSettings(); await SETTINGS_MANAGER.StoreSettings();

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Rust;
public sealed record RegisterShortcutRequest(Shortcut Id, string Shortcut);

View File

@ -0,0 +1,17 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// Identifies a global keyboard shortcut.
/// </summary>
public enum Shortcut
{
/// <summary>
/// Null pattern - no shortcut assigned or unknown shortcut.
/// </summary>
NONE = 0,
/// <summary>
/// Toggles voice recording on/off.
/// </summary>
VOICE_RECORDING_TOGGLE,
}

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Rust;
public sealed record ShortcutResponse(bool Success, string ErrorMessage);

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Rust;
public sealed record ShortcutValidationResponse(bool IsValid, string ErrorMessage, bool HasConflict, string ConflictDescription);

View File

@ -0,0 +1,10 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// Result of validating a keyboard shortcut.
/// </summary>
/// <param name="IsValid">Whether the shortcut syntax is valid.</param>
/// <param name="ErrorMessage">Error message if not valid.</param>
/// <param name="HasConflict">Whether the shortcut conflicts with another registered shortcut.</param>
/// <param name="ConflictDescription">Description of the conflict, if any.</param>
public sealed record ShortcutValidationResult(bool IsValid, string ErrorMessage, bool HasConflict, string ConflictDescription);

View File

@ -5,4 +5,41 @@ namespace AIStudio.Tools.Rust;
/// </summary> /// </summary>
/// <param name="EventType">The type of the Tauri event.</param> /// <param name="EventType">The type of the Tauri event.</param>
/// <param name="Payload">The payload of the Tauri event.</param> /// <param name="Payload">The payload of the Tauri event.</param>
public readonly record struct TauriEvent(TauriEventType EventType, List<string> Payload); public readonly record struct TauriEvent(TauriEventType EventType, List<string> Payload)
{
/// <summary>
/// Attempts to parse the first payload element as a shortcut.
/// </summary>
/// <param name="shortcut">The parsed shortcut name if successful.</param>
/// <returns>True if parsing was successful, false otherwise.</returns>
public bool TryGetShortcut(out Shortcut shortcut)
{
shortcut = default;
if(this.EventType != TauriEventType.GLOBAL_SHORTCUT_PRESSED)
return false;
if (this.Payload.Count == 0)
return false;
// Try standard enum parsing (handles PascalCase and numeric values):
if (Enum.TryParse(this.Payload[0], ignoreCase: true, out shortcut))
return true;
// Try parsing snake_case format (e.g., "voice_recording_toggle"):
return TryParseSnakeCase(this.Payload[0], out shortcut);
}
/// <summary>
/// Tries to parse a snake_case string into a ShortcutName enum value.
/// </summary>
private static bool TryParseSnakeCase(string value, out Shortcut shortcut)
{
shortcut = default;
// Convert snake_case to UPPER_SNAKE_CASE for enum matching:
var upperSnakeCase = value.ToUpperInvariant();
// Try to match against enum names (which are in UPPER_SNAKE_CASE):
return Enum.TryParse(upperSnakeCase, ignoreCase: false, out shortcut);
}
};

View File

@ -15,4 +15,6 @@ public enum TauriEventType
FILE_DROP_HOVERED, FILE_DROP_HOVERED,
FILE_DROP_DROPPED, FILE_DROP_DROPPED,
FILE_DROP_CANCELED, FILE_DROP_CANCELED,
GLOBAL_SHORTCUT_PRESSED,
} }

View File

@ -0,0 +1,3 @@
namespace AIStudio.Tools.Rust;
public sealed record ValidateShortcutRequest(string Shortcut);

View File

@ -0,0 +1,111 @@
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Rust;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Tools.Services;
public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiver
{
private static bool IS_INITIALIZED;
private readonly ILogger<GlobalShortcutService> logger;
private readonly SettingsManager settingsManager;
private readonly MessageBus messageBus;
private readonly RustService rustService;
public GlobalShortcutService(
ILogger<GlobalShortcutService> logger,
SettingsManager settingsManager,
MessageBus messageBus,
RustService rustService)
{
this.logger = logger;
this.settingsManager = settingsManager;
this.messageBus = messageBus;
this.rustService = rustService;
this.messageBus.RegisterComponent(this);
this.ApplyFilters([], [Event.CONFIGURATION_CHANGED]);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Wait until the app is fully initialized:
while (!stoppingToken.IsCancellationRequested && !IS_INITIALIZED)
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
// Register shortcuts on startup:
await this.RegisterAllShortcuts();
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
this.messageBus.Unregister(this);
await base.StopAsync(cancellationToken);
}
#region IMessageBusReceiver
public async Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data)
{
switch (triggeredEvent)
{
case Event.CONFIGURATION_CHANGED:
await this.RegisterAllShortcuts();
break;
}
}
public Task<TResult?> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) => Task.FromResult<TResult?>(default);
#endregion
private async Task RegisterAllShortcuts()
{
this.logger.LogInformation("Registering global shortcuts.");
foreach (var shortcutId in Enum.GetValues<Shortcut>())
{
if(shortcutId is Shortcut.NONE)
continue;
var shortcut = this.GetShortcutValue(shortcutId);
var isEnabled = this.IsShortcutAllowed(shortcutId);
if (isEnabled && !string.IsNullOrWhiteSpace(shortcut))
{
var success = await this.rustService.UpdateGlobalShortcut(shortcutId, shortcut);
if (success)
this.logger.LogInformation("Global shortcut '{ShortcutId}' ({Shortcut}) registered.", shortcutId, shortcut);
else
this.logger.LogWarning("Failed to register global shortcut '{ShortcutId}' ({Shortcut}).", shortcutId, shortcut);
}
else
{
// Disable the shortcut when empty or feature is disabled:
await this.rustService.UpdateGlobalShortcut(shortcutId, string.Empty);
}
}
this.logger.LogInformation("Global shortcuts registration completed.");
}
private string GetShortcutValue(Shortcut name) => name switch
{
Shortcut.VOICE_RECORDING_TOGGLE => this.settingsManager.ConfigurationData.App.ShortcutVoiceRecording,
_ => string.Empty,
};
private bool IsShortcutAllowed(Shortcut name) => name switch
{
// Voice recording is a preview feature:
Shortcut.VOICE_RECORDING_TOGGLE => PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.settingsManager),
// Other shortcuts are always allowed:
_ => true,
};
public static void Initialize() => IS_INITIALIZED = true;
}

View File

@ -0,0 +1,112 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AIStudio.Tools.Services;
/// <summary>
/// Converts enum values for Rust communication.
/// </summary>
/// <remarks>
/// Rust expects PascalCase enum values (e.g., "VoiceRecordingToggle"),
/// while .NET uses UPPER_SNAKE_CASE (e.g., "VOICE_RECORDING_TOGGLE").
/// This converter handles the bidirectional conversion.
/// </remarks>
public sealed class RustEnumConverter : JsonConverter<object>
{
private static readonly ILogger<RustEnumConverter> LOG = Program.LOGGER_FACTORY.CreateLogger<RustEnumConverter>();
public override bool CanConvert(Type typeToConvert) => typeToConvert.IsEnum;
public override object? Read(ref Utf8JsonReader reader, Type enumType, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
{
var text = reader.GetString();
text = ConvertToUpperSnakeCase(text);
if (Enum.TryParse(enumType, text, out var result))
return result;
}
LOG.LogWarning($"Cannot read '{reader.GetString()}' as '{enumType.Name}' enum; token type: {reader.TokenType}");
return Activator.CreateInstance(enumType);
}
public override object ReadAsPropertyName(ref Utf8JsonReader reader, Type enumType, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.PropertyName)
{
var text = reader.GetString();
text = ConvertToUpperSnakeCase(text);
if (Enum.TryParse(enumType, text, out var result))
return result;
}
LOG.LogWarning($"Cannot read '{reader.GetString()}' as '{enumType.Name}' enum; token type: {reader.TokenType}");
return Activator.CreateInstance(enumType)!;
}
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
{
writer.WriteStringValue(ConvertToPascalCase(value.ToString()));
}
public override void WriteAsPropertyName(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
{
writer.WritePropertyName(ConvertToPascalCase(value.ToString()));
}
/// <summary>
/// Converts UPPER_SNAKE_CASE to PascalCase.
/// </summary>
/// <param name="text">The text to convert (e.g., "VOICE_RECORDING_TOGGLE").</param>
/// <returns>The converted text as PascalCase (e.g., "VoiceRecordingToggle").</returns>
private static string ConvertToPascalCase(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
var parts = text.Split('_', StringSplitOptions.RemoveEmptyEntries);
var sb = new StringBuilder();
foreach (var part in parts)
{
if (part.Length == 0)
continue;
// First character uppercase, rest lowercase:
sb.Append(char.ToUpperInvariant(part[0]));
if (part.Length > 1)
sb.Append(part[1..].ToLowerInvariant());
}
return sb.ToString();
}
/// <summary>
/// Converts a string to UPPER_SNAKE_CASE.
/// </summary>
/// <param name="text">The text to convert.</param>
/// <returns>The converted text as UPPER_SNAKE_CASE.</returns>
private static string ConvertToUpperSnakeCase(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
var sb = new StringBuilder(text.Length);
var lastCharWasLowerCase = false;
foreach (var c in text)
{
if (char.IsUpper(c) && lastCharWasLowerCase)
sb.Append('_');
sb.Append(char.ToUpperInvariant(c));
lastCharWasLowerCase = char.IsLower(c);
}
return sb.ToString();
}
}

View File

@ -0,0 +1,140 @@
// ReSharper disable NotAccessedPositionalProperty.Local
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
/// <summary>
/// Registers or updates a global keyboard shortcut.
/// </summary>
/// <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>
/// <returns>True if the shortcut was registered successfully, false otherwise.</returns>
public async Task<bool> UpdateGlobalShortcut(Shortcut shortcutId, string shortcut)
{
try
{
var request = new RegisterShortcutRequest(shortcutId, shortcut);
var response = await this.http.PostAsJsonAsync("/shortcuts/register", request, this.jsonRustSerializerOptions);
if (!response.IsSuccessStatusCode)
{
this.logger?.LogError("Failed to register global shortcut '{ShortcutId}' due to network error: {StatusCode}", shortcutId, response.StatusCode);
return false;
}
var result = await response.Content.ReadFromJsonAsync<ShortcutResponse>(this.jsonRustSerializerOptions);
if (result is null || !result.Success)
{
this.logger?.LogError("Failed to register global shortcut '{ShortcutId}': {Error}", shortcutId, result?.ErrorMessage ?? "Unknown error");
return false;
}
this.logger?.LogInformation("Global shortcut '{ShortcutId}' registered successfully with key '{Shortcut}'.", shortcutId, shortcut);
return true;
}
catch (Exception ex)
{
this.logger?.LogError(ex, "Exception while registering global shortcut '{ShortcutId}'.", shortcutId);
return false;
}
}
/// <summary>
/// Validates a shortcut string without registering it.
/// </summary>
/// <param name="shortcut">The shortcut string to validate.</param>
/// <returns>A validation result indicating if the shortcut is valid and any conflicts.</returns>
public async Task<ShortcutValidationResult> ValidateShortcut(string shortcut)
{
try
{
var request = new ValidateShortcutRequest(shortcut);
var response = await this.http.PostAsJsonAsync("/shortcuts/validate", request, this.jsonRustSerializerOptions);
if (!response.IsSuccessStatusCode)
{
this.logger?.LogError("Failed to validate shortcut due to network error: {StatusCode}", response.StatusCode);
return new ShortcutValidationResult(false, "Network error during validation", false, string.Empty);
}
var result = await response.Content.ReadFromJsonAsync<ShortcutValidationResponse>(this.jsonRustSerializerOptions);
if (result is null)
return new ShortcutValidationResult(false, "Invalid response from server", false, string.Empty);
return new ShortcutValidationResult(result.IsValid, result.ErrorMessage, result.HasConflict, result.ConflictDescription);
}
catch (Exception ex)
{
this.logger?.LogError(ex, "Exception while validating shortcut.");
return new ShortcutValidationResult(false, ex.Message, false, string.Empty);
}
}
/// <summary>
/// Suspends shortcut processing. The shortcuts remain registered, but events are not sent.
/// 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.
/// </summary>
/// <returns>True if successful, false otherwise.</returns>
public async Task<bool> SuspendShortcutProcessing()
{
try
{
var response = await this.http.PostAsync("/shortcuts/suspend", null);
if (!response.IsSuccessStatusCode)
{
this.logger?.LogError("Failed to suspend the shortcut processing due to network error: {StatusCode}.", response.StatusCode);
return false;
}
var result = await response.Content.ReadFromJsonAsync<ShortcutResponse>(this.jsonRustSerializerOptions);
if (result is null || !result.Success)
{
this.logger?.LogError("Failed to suspend shortcut processing: {Error}", result?.ErrorMessage ?? "Unknown error");
return false;
}
this.logger?.LogDebug("Shortcut processing suspended.");
return true;
}
catch (Exception ex)
{
this.logger?.LogError(ex, "Exception while suspending shortcut processing.");
return false;
}
}
/// <summary>
/// Resumes the shortcut processing after it was suspended.
/// </summary>
/// <returns>True if successful, false otherwise.</returns>
public async Task<bool> ResumeShortcutProcessing()
{
try
{
var response = await this.http.PostAsync("/shortcuts/resume", null);
if (!response.IsSuccessStatusCode)
{
this.logger?.LogError("Failed to resume shortcut processing due to network error: {StatusCode}.", response.StatusCode);
return false;
}
var result = await response.Content.ReadFromJsonAsync<ShortcutResponse>(this.jsonRustSerializerOptions);
if (result is null || !result.Success)
{
this.logger?.LogError("Failed to resume shortcut processing: {Error}", result?.ErrorMessage ?? "Unknown error");
return false;
}
this.logger?.LogDebug("Shortcut processing resumed.");
return true;
}
catch (Exception ex)
{
this.logger?.LogError(ex, "Exception while resuming shortcut processing.");
return false;
}
}
}

View File

@ -1,7 +1,6 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using Version = System.Version; using Version = System.Version;
@ -22,7 +21,10 @@ public sealed partial class RustService : BackgroundService
private readonly JsonSerializerOptions jsonRustSerializerOptions = new() private readonly JsonSerializerOptions jsonRustSerializerOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
Converters = { new TolerantEnumConverter() }, Converters =
{
new RustEnumConverter(),
},
}; };
private ILogger<RustService>? logger; private ILogger<RustService>? logger;

View File

@ -1,6 +1,7 @@
# v26.1.2, build 232 (2026-01-xx xx:xx UTC) # v26.1.2, build 232 (2026-01-xx xx:xx UTC)
- Added the option to hide specific assistants by configuration plugins. This is useful for enterprise environments in organizations. - Added the option to hide specific assistants by configuration plugins. This is useful for enterprise environments in organizations.
- Added the current date and time to the system prompt for better context in conversations. Thanks Peer `peerschuett` for the contribution. - Added the current date and time to the system prompt for better context in conversations. Thanks Peer `peerschuett` for the contribution.
- Added the ability to control the voice recording with transcription (in preview) by using a system-wide shortcut. The shortcut can be configured in the application settings or by using a configuration plugin. Thus, a uniform shortcut can be defined for an entire organization.
- Added error handling for the context window overflow, which can occur with huge file attachments in chats or the document analysis assistant. - Added error handling for the context window overflow, which can occur with huge file attachments in chats or the document analysis assistant.
- Improved error handling for model loading in provider dialogs (LLMs, embeddings, transcriptions). - Improved error handling for model loading in provider dialogs (LLMs, embeddings, transcriptions).
- Improved the microphone handling (transcription preview) so that all sound effects and the voice recording are processed without interruption. - Improved the microphone handling (transcription preview) so that all sound effects and the voice recording are processed without interruption.

13
runtime/Cargo.lock generated
View File

@ -2772,6 +2772,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"strum_macros",
"sys-locale", "sys-locale",
"tauri", "tauri",
"tauri-build", "tauri-build",
@ -4767,6 +4768,18 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.93",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"

View File

@ -9,7 +9,7 @@ authors = ["Thorsten Sommer"]
tauri-build = { version = "1.5", features = [] } tauri-build = { version = "1.5", features = [] }
[dependencies] [dependencies]
tauri = { version = "1.8", features = [ "http-all", "updater", "shell-sidecar", "shell-open", "dialog"] } tauri = { version = "1.8", features = [ "http-all", "updater", "shell-sidecar", "shell-open", "dialog", "global-shortcut"] }
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140" serde_json = "1.0.140"
@ -39,6 +39,7 @@ pdfium-render = "0.8.34"
sys-locale = "0.3.2" sys-locale = "0.3.2"
cfg-if = "1.0.1" cfg-if = "1.0.1"
pptx-to-md = "0.4.0" pptx-to-md = "0.4.0"
strum_macros = "0.27"
# Fixes security vulnerability downstream, where the upstream is not fixed yet: # Fixes security vulnerability downstream, where the upstream is not fixed yet:
url = "2.5.7" url = "2.5.7"

View File

@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration; use std::time::Duration;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
@ -7,8 +8,9 @@ use rocket::response::stream::TextStream;
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket::serde::Serialize; use rocket::serde::Serialize;
use serde::Deserialize; use serde::Deserialize;
use strum_macros::Display;
use tauri::updater::UpdateResponse; use tauri::updater::UpdateResponse;
use tauri::{FileDropEvent, UpdaterEvent, RunEvent, Manager, PathResolver, Window, WindowEvent}; use tauri::{FileDropEvent, GlobalShortcutManager, UpdaterEvent, RunEvent, Manager, PathResolver, Window, WindowEvent};
use tauri::api::dialog::blocking::FileDialogBuilder; use tauri::api::dialog::blocking::FileDialogBuilder;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tokio::time; use tokio::time;
@ -27,6 +29,17 @@ static CHECK_UPDATE_RESPONSE: Lazy<Mutex<Option<UpdateResponse<tauri::Wry>>>> =
/// 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()));
/// 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...");
@ -323,6 +336,8 @@ pub enum TauriEventType {
FileDropHovered, FileDropHovered,
FileDropDropped, FileDropDropped,
FileDropCanceled, FileDropCanceled,
GlobalShortcutPressed,
} }
/// Changes the location of the main window to the given URL. /// Changes the location of the main window to the given URL.
@ -522,13 +537,7 @@ pub fn select_file(_token: APIToken, payload: Json<SelectFileOptions>) -> Json<F
let file_dialog = file_dialog.set_title(&payload.title); let file_dialog = file_dialog.set_title(&payload.title);
// Set the file type filter if provided: // Set the file type filter if provided:
let file_dialog = match &payload.filter { let file_dialog = apply_filter(file_dialog, &payload.filter);
Some(filter) => {
file_dialog.add_filter(&filter.filter_name, &filter.filter_extensions.iter().map(|s| s.as_str()).collect::<Vec<&str>>())
},
None => file_dialog,
};
// Set the previous file path if provided: // Set the previous file path if provided:
let file_dialog = match &payload.previous_file { let file_dialog = match &payload.previous_file {
@ -572,13 +581,7 @@ pub fn select_files(_token: APIToken, payload: Json<SelectFileOptions>) -> Json<
let file_dialog = file_dialog.set_title(&payload.title); let file_dialog = file_dialog.set_title(&payload.title);
// Set the file type filter if provided: // Set the file type filter if provided:
let file_dialog = match &payload.filter { let file_dialog = apply_filter(file_dialog, &payload.filter);
Some(filter) => {
file_dialog.add_filter(&filter.filter_name, &filter.filter_extensions.iter().map(|s| s.as_str()).collect::<Vec<&str>>())
},
None => file_dialog,
};
// Set the previous file path if provided: // Set the previous file path if provided:
let file_dialog = match &payload.previous_file { let file_dialog = match &payload.previous_file {
@ -621,13 +624,7 @@ pub fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json<FileS
let file_dialog = file_dialog.set_title(&payload.title); let file_dialog = file_dialog.set_title(&payload.title);
// Set the file type filter if provided: // Set the file type filter if provided:
let file_dialog = match &payload.filter { let file_dialog = apply_filter(file_dialog, &payload.filter);
Some(filter) => {
file_dialog.add_filter(&filter.filter_name, &filter.filter_extensions.iter().map(|s| s.as_str()).collect::<Vec<&str>>())
},
None => file_dialog,
};
// Set the previous file path if provided: // Set the previous file path if provided:
let file_dialog = match &payload.name_file { let file_dialog = match &payload.name_file {
@ -665,6 +662,18 @@ pub struct PreviousFile {
file_path: String, file_path: String,
} }
/// Applies an optional file type filter to a FileDialogBuilder.
fn apply_filter(file_dialog: FileDialogBuilder, filter: &Option<FileTypeFilter>) -> FileDialogBuilder {
match filter {
Some(f) => file_dialog.add_filter(
&f.filter_name,
&f.filter_extensions.iter().map(|s| s.as_str()).collect::<Vec<&str>>(),
),
None => file_dialog,
}
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct FileSelectionResponse { pub struct FileSelectionResponse {
user_cancelled: bool, user_cancelled: bool,
@ -683,6 +692,353 @@ pub struct FileSaveResponse {
save_file_path: String, save_file_path: String,
} }
/// 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,
}
/// 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(
shortcut_manager: &mut impl GlobalShortcutManager,
shortcut: &str,
shortcut_id: Shortcut,
event_sender: broadcast::Sender<Event>,
) -> Result<(), tauri::Error> {
//
// Match the shortcut registration to transform the Tauri result into the Rust result:
//
match shortcut_manager.register(shortcut, move || {
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 {
match sender.send(event) {
Ok(_) => {}
Err(error) => error!(Source = "Tauri"; "Failed to send global shortcut event: {error}"),
}
});
}) {
Ok(_) => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Registers or updates a global shortcut. If the shortcut string is empty,
/// the existing shortcut for that name will be unregistered.
#[post("/shortcuts/register", data = "<payload>")]
pub fn register_shortcut(_token: APIToken, payload: Json<RegisterShortcutRequest>) -> Json<ShortcutResponse> {
let id = payload.id;
let new_shortcut = payload.shortcut.clone();
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 mut shortcut_manager = main_window.app_handle().global_shortcut_manager();
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) {
if !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(&mut shortcut_manager, &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.
#[derive(Clone, Deserialize)]
pub struct ValidateShortcutRequest {
/// The shortcut string to validate (e.g., "CmdOrControl+1").
shortcut: String,
}
/// Response for shortcut validation.
#[derive(Serialize)]
pub struct ShortcutValidationResponse {
is_valid: bool,
error_message: String,
has_conflict: bool,
conflict_description: String,
}
/// Validates a shortcut string without registering it.
/// Checks if the shortcut syntax is valid and if it
/// conflicts with existing shortcuts.
#[post("/shortcuts/validate", data = "<payload>")]
pub fn validate_shortcut(_token: APIToken, payload: Json<ValidateShortcutRequest>) -> Json<ShortcutValidationResponse> {
let shortcut = payload.shortcut.clone();
// Empty shortcuts are always valid (means "disabled"):
if shortcut.is_empty() {
return Json(ShortcutValidationResponse {
is_valid: true,
error_message: String::new(),
has_conflict: false,
conflict_description: String::new(),
});
}
// Check if the shortcut is already registered:
let registered_shortcuts = REGISTERED_SHORTCUTS.lock().unwrap();
for (name, registered_shortcut) in registered_shortcuts.iter() {
if registered_shortcut.eq_ignore_ascii_case(&shortcut) {
return Json(ShortcutValidationResponse {
is_valid: true,
error_message: String::new(),
has_conflict: true,
conflict_description: format!("Already used by: {}", name),
});
}
}
drop(registered_shortcuts);
// Try to parse the shortcut to validate syntax.
// We can't easily validate without registering in Tauri 1.x,
// so we do basic syntax validation here:
let is_valid = validate_shortcut_syntax(&shortcut);
if is_valid {
Json(ShortcutValidationResponse {
is_valid: true,
error_message: String::new(),
has_conflict: false,
conflict_description: String::new(),
})
} else {
Json(ShortcutValidationResponse {
is_valid: false,
error_message: format!("Invalid shortcut syntax: {}", shortcut),
has_conflict: false,
conflict_description: String::new(),
})
}
}
/// Suspends shortcut processing by unregistering all shortcuts from the OS.
/// The shortcuts remain in our internal map, so they can be re-registered on resume.
/// 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.
#[post("/shortcuts/suspend")]
pub fn suspend_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
// 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 suspend shortcuts: main window not available.");
return Json(ShortcutResponse {
success: false,
error_message: "Main window not available".to_string(),
});
}
};
let mut shortcut_manager = main_window.app_handle().global_shortcut_manager();
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.
#[post("/shortcuts/resume")]
pub fn resume_shortcuts(_token: APIToken) -> Json<ShortcutResponse> {
// 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 resume shortcuts: main window not available.");
return Json(ShortcutResponse {
success: false,
error_message: "Main window not available".to_string(),
});
}
};
let mut shortcut_manager = main_window.app_handle().global_shortcut_manager();
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(&mut shortcut_manager, 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.
fn validate_shortcut_syntax(shortcut: &str) -> bool {
let parts: Vec<&str> = shortcut.split('+').collect();
if parts.is_empty() {
return false;
}
let mut has_key = false;
for part in parts {
let part_lower = part.to_lowercase();
match part_lower.as_str() {
// Modifiers
"cmdorcontrol" | "commandorcontrol" | "ctrl" | "control" | "cmd" | "command" |
"shift" | "alt" | "meta" | "super" | "option" => continue,
// Keys - letters
"a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" |
"n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" => has_key = true,
// Keys - numbers
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" => has_key = true,
// Keys - function keys
_ if part_lower.starts_with('f') && part_lower[1..].parse::<u32>().is_ok() => has_key = true,
// Keys - special
"space" | "enter" | "tab" | "escape" | "backspace" | "delete" | "insert" |
"home" | "end" | "pageup" | "pagedown" |
"up" | "down" | "left" | "right" |
"arrowup" | "arrowdown" | "arrowleft" | "arrowright" |
"minus" | "equal" | "bracketleft" | "bracketright" | "backslash" |
"semicolon" | "quote" | "backquote" | "comma" | "period" | "slash" => has_key = true,
// Keys - numpad
_ if part_lower.starts_with("num") => has_key = true,
// Unknown
_ => return false,
}
}
has_key
}
fn set_pdfium_path(path_resolver: PathResolver) { fn set_pdfium_path(path_resolver: PathResolver) {
let pdfium_relative_source_path = String::from("resources/libraries/"); let pdfium_relative_source_path = String::from("resources/libraries/");
let pdfium_source_path = path_resolver.resolve_resource(pdfium_relative_source_path); let pdfium_source_path = path_resolver.resolve_resource(pdfium_relative_source_path);

View File

@ -87,6 +87,10 @@ pub fn start_runtime_api() {
crate::file_data::extract_data, crate::file_data::extract_data,
crate::log::get_log_paths, crate::log::get_log_paths,
crate::log::log_event, crate::log::log_event,
crate::app_window::register_shortcut,
crate::app_window::validate_shortcut,
crate::app_window::suspend_shortcuts,
crate::app_window::resume_shortcuts,
]) ])
.ignite().await.unwrap() .ignite().await.unwrap()
.launch().await.unwrap(); .launch().await.unwrap();