From 127778df4290d1967b5ba1bce60b06f7105e6ae7 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 24 Jan 2026 20:05:34 +0100 Subject: [PATCH] Manage global shortcuts (#635) --- .../Assistants/I18N/allTexts.lua | 54 +++ .../Components/ChatComponent.razor | 2 +- .../Components/ConfigurationShortcut.razor | 26 ++ .../Components/ConfigurationShortcut.razor.cs | 109 +++++ .../Settings/SettingsPanelApp.razor | 2 + .../Components/VoiceRecorder.razor.cs | 40 ++ .../Dialogs/ShortcutDialog.razor | 50 +++ .../Dialogs/ShortcutDialog.razor.cs | 385 +++++++++++++++++ .../Layout/MainLayout.razor.cs | 11 +- .../Pages/Information.razor | 3 +- .../Plugins/configuration/plugin.lua | 10 + .../plugin.lua | 54 +++ .../plugin.lua | 54 +++ app/MindWork AI Studio/Program.cs | 1 + .../Settings/DataModel/DataApp.cs | 7 + .../Tools/PluginSystem/PluginConfiguration.cs | 3 + .../PluginSystem/PluginFactory.Loading.cs | 4 + .../Tools/Rust/RegisterShortcutRequest.cs | 3 + app/MindWork AI Studio/Tools/Rust/Shortcut.cs | 17 + .../Tools/Rust/ShortcutResponse.cs | 3 + .../Tools/Rust/ShortcutValidationResponse.cs | 3 + .../Tools/Rust/ShortcutValidationResult.cs | 10 + .../Tools/Rust/TauriEvent.cs | 39 +- .../Tools/Rust/TauriEventType.cs | 6 +- .../Tools/Rust/ValidateShortcutRequest.cs | 3 + .../Tools/Services/GlobalShortcutService.cs | 111 +++++ .../Tools/Services/RustEnumConverter.cs | 112 +++++ .../Tools/Services/RustService.Shortcuts.cs | 140 ++++++ .../Tools/Services/RustService.cs | 6 +- .../wwwroot/changelog/v26.1.2.md | 1 + runtime/Cargo.lock | 13 + runtime/Cargo.toml | 3 +- runtime/src/app_window.rs | 400 +++++++++++++++++- runtime/src/runtime_api.rs | 4 + 34 files changed, 1654 insertions(+), 35 deletions(-) create mode 100644 app/MindWork AI Studio/Components/ConfigurationShortcut.razor create mode 100644 app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs create mode 100644 app/MindWork AI Studio/Dialogs/ShortcutDialog.razor create mode 100644 app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/RegisterShortcutRequest.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/Shortcut.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/ShortcutResponse.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/ShortcutValidationResponse.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/ShortcutValidationResult.cs create mode 100644 app/MindWork AI Studio/Tools/Rust/ValidateShortcutRequest.cs create mode 100644 app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs create mode 100644 app/MindWork AI Studio/Tools/Services/RustEnumConverter.cs create mode 100644 app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 5f0e15d8..598e7f52 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -1675,6 +1675,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T20906218 -- 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. 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? 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? 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. 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 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? 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. 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 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. 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." diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor index 9d8f0072..38409675 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor +++ b/app/MindWork AI Studio/Components/ChatComponent.razor @@ -48,7 +48,7 @@ OnAdornmentClick="() => this.SendMessage()" Disabled="@this.IsInputForbidden()" Immediate="@true" - OnKeyUp="this.InputKeyEvent" + OnKeyUp="@this.InputKeyEvent" UserAttributes="@USER_INPUT_ATTRIBUTES" Class="@this.UserInputClass" Style="@this.UserInputStyle"/> diff --git a/app/MindWork AI Studio/Components/ConfigurationShortcut.razor b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor new file mode 100644 index 00000000..41f3b9a3 --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor @@ -0,0 +1,26 @@ +@inherits ConfigurationBaseCore + + + + + @if (string.IsNullOrWhiteSpace(this.Shortcut())) + { + @T("No shortcut configured") + } + else + { + + @this.GetDisplayShortcut() + + } + + + @T("Change shortcut") + + diff --git a/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs new file mode 100644 index 00000000..aaa600b7 --- /dev/null +++ b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs @@ -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; + +/// +/// A configuration component for capturing and displaying keyboard shortcuts. +/// +public partial class ConfigurationShortcut : ConfigurationBaseCore +{ + [Inject] + private IDialogService DialogService { get; init; } = null!; + + [Inject] + private RustService RustService { get; init; } = null!; + + /// + /// The current shortcut value. + /// + [Parameter] + public Func Shortcut { get; set; } = () => string.Empty; + + /// + /// An action which is called when the shortcut was changed. + /// + [Parameter] + public Action ShortcutUpdate { get; set; } = _ => { }; + + /// + /// The name/identifier of the shortcut (used for conflict detection and registration). + /// + [Parameter] + public Shortcut ShortcutId { get; init; } + + /// + /// The icon to display. + /// + [Parameter] + public string Icon { get; set; } = Icons.Material.Filled.Keyboard; + + /// + /// The color of the icon. + /// + [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 + { + { x => x.InitialShortcut, this.Shortcut() }, + { x => x.ShortcutId, this.ShortcutId }, + }; + + var dialogReference = await this.DialogService.ShowAsync( + 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(); + } + } +} diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor index 0a5c89c8..62b996d0 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor @@ -1,5 +1,6 @@ @using AIStudio.Settings @using AIStudio.Settings.DataModel +@using AIStudio.Tools.Rust @inherits SettingsPanelBase @@ -33,5 +34,6 @@ @if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) { + } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs index 8b58035b..73a95e8d 100644 --- a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs @@ -1,5 +1,6 @@ using AIStudio.Provider; using AIStudio.Tools.MIME; +using AIStudio.Tools.Rust; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -24,6 +25,9 @@ public partial class VoiceRecorder : MSGComponentBase protected override async Task OnInitializedAsync() { + // Register for global shortcut events: + this.ApplyFilters([], [Event.TAURI_EVENT_RECEIVED]); + await base.OnInitializedAsync(); try @@ -37,6 +41,38 @@ public partial class VoiceRecorder : MSGComponentBase } } + protected override async Task ProcessIncomingMessage(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; + } + } + + /// + /// Toggles the recording state when triggered by a global shortcut. + /// + 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 private uint numReceivedChunks; @@ -109,6 +145,10 @@ public partial class VoiceRecorder : MSGComponentBase // Clean up the recording stream if starting failed: await this.FinalizeRecordingStream(); } + finally + { + this.StateHasChanged(); + } } else { diff --git a/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor b/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor new file mode 100644 index 00000000..eb90ed79 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor @@ -0,0 +1,50 @@ +@inherits MSGComponentBase + + + + + @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.") + + + + + + + @if (!string.IsNullOrWhiteSpace(this.validationMessage)) + { + + @this.validationMessage + + } + + + + @T("Clear Shortcut") + + + + @T("Cancel") + + + @T("Save") + + + diff --git a/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs new file mode 100644 index 00000000..9809b818 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs @@ -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; + +/// +/// A dialog for capturing and configuring keyboard shortcuts. +/// +public partial class ShortcutDialog : MSGComponentBase +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + + [Inject] + private RustService RustService { get; init; } = null!; + + /// + /// The initial shortcut value (in internal format, e.g., "CmdOrControl+1"). + /// + [Parameter] + public string InitialShortcut { get; set; } = string.Empty; + + /// + /// The identifier of the shortcut for conflict detection. + /// + [Parameter] + public Shortcut ShortcutId { get; set; } + + private static readonly Dictionary 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? 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(); + + 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)); + + /// + /// Checks if the key code represents a modifier key. + /// + 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, + }; + + /// + /// Translates a JavaScript KeyboardEvent.code to Tauri shortcut format. + /// + 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(); + } +} diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index fc89f248..4516b81b 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -97,6 +97,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan // Set the snackbar for the update service: UpdateService.SetBlazorDependencies(this.Snackbar); + GlobalShortcutService.Initialize(); TemporaryChatService.Initialize(); // Should the navigation bar be open by default? @@ -116,11 +117,6 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan await base.OnInitializedAsync(); } - private void LoadNavItems() - { - this.navItems = new List(this.GetNavItems()); - } - #endregion #region Implementation of ILang @@ -251,6 +247,11 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan #endregion + private void LoadNavItems() + { + this.navItems = new List(this.GetNavItems()); + } + private IEnumerable GetNavItems() { var palette = this.ColorTheme.GetCurrentPalette(this.SettingsManager); diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index a348283f..13b7598d 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -196,6 +196,7 @@ + @@ -224,4 +225,4 @@ - \ No newline at end of file + diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index 9708c666..ff421ff7 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -153,6 +153,16 @@ CONFIG["SETTINGS"] = {} -- 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: CONFIG["CHAT_TEMPLATES"] = {} diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index a8906b2a..e7300566 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -1677,6 +1677,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T20906218 -- Use app default 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. 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? 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? 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. 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 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? 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. 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 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. 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." diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index ca447cfe..591f9860 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -1677,6 +1677,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONPROVIDERSELECTION::T20906218 -- 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. 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? 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? 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. 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 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? 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. 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 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. 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." diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index e87dc0ca..c577b2f4 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -133,6 +133,7 @@ internal sealed class Program builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); // ReSharper disable AccessToDisposedClosure builder.Services.AddHostedService(_ => rust); diff --git a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs index d2ac4bb7..fe4409de 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs @@ -82,6 +82,13 @@ public sealed class DataApp(Expression>? configSelection = n /// public string UseTranscriptionProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.UseTranscriptionProvider, string.Empty); + /// + /// 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. + /// + public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty); + /// /// Should the user be allowed to add providers? /// diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 814aa971..e85c8eba 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -70,6 +70,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Config: hide some assistants? 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: PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs index 63eb3df5..bdfdba81 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Loading.cs @@ -181,6 +181,10 @@ public static partial class PluginFactory if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HiddenAssistants, AVAILABLE_PLUGINS)) wasConfigurationChanged = true; + // Check for the voice recording shortcut: + if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShortcutVoiceRecording, AVAILABLE_PLUGINS)) + wasConfigurationChanged = true; + if (wasConfigurationChanged) { await SETTINGS_MANAGER.StoreSettings(); diff --git a/app/MindWork AI Studio/Tools/Rust/RegisterShortcutRequest.cs b/app/MindWork AI Studio/Tools/Rust/RegisterShortcutRequest.cs new file mode 100644 index 00000000..d6d480ca --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/RegisterShortcutRequest.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools.Rust; + +public sealed record RegisterShortcutRequest(Shortcut Id, string Shortcut); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/Shortcut.cs b/app/MindWork AI Studio/Tools/Rust/Shortcut.cs new file mode 100644 index 00000000..f8f783b3 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/Shortcut.cs @@ -0,0 +1,17 @@ +namespace AIStudio.Tools.Rust; + +/// +/// Identifies a global keyboard shortcut. +/// +public enum Shortcut +{ + /// + /// Null pattern - no shortcut assigned or unknown shortcut. + /// + NONE = 0, + + /// + /// Toggles voice recording on/off. + /// + VOICE_RECORDING_TOGGLE, +} diff --git a/app/MindWork AI Studio/Tools/Rust/ShortcutResponse.cs b/app/MindWork AI Studio/Tools/Rust/ShortcutResponse.cs new file mode 100644 index 00000000..1028d475 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/ShortcutResponse.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools.Rust; + +public sealed record ShortcutResponse(bool Success, string ErrorMessage); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/ShortcutValidationResponse.cs b/app/MindWork AI Studio/Tools/Rust/ShortcutValidationResponse.cs new file mode 100644 index 00000000..3a4a3270 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/ShortcutValidationResponse.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools.Rust; + +public sealed record ShortcutValidationResponse(bool IsValid, string ErrorMessage, bool HasConflict, string ConflictDescription); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/ShortcutValidationResult.cs b/app/MindWork AI Studio/Tools/Rust/ShortcutValidationResult.cs new file mode 100644 index 00000000..7b482276 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/ShortcutValidationResult.cs @@ -0,0 +1,10 @@ +namespace AIStudio.Tools.Rust; + +/// +/// Result of validating a keyboard shortcut. +/// +/// Whether the shortcut syntax is valid. +/// Error message if not valid. +/// Whether the shortcut conflicts with another registered shortcut. +/// Description of the conflict, if any. +public sealed record ShortcutValidationResult(bool IsValid, string ErrorMessage, bool HasConflict, string ConflictDescription); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs b/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs index c060e63a..3e537a2d 100644 --- a/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs +++ b/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs @@ -5,4 +5,41 @@ namespace AIStudio.Tools.Rust; /// /// The type of the Tauri event. /// The payload of the Tauri event. -public readonly record struct TauriEvent(TauriEventType EventType, List Payload); \ No newline at end of file +public readonly record struct TauriEvent(TauriEventType EventType, List Payload) +{ + /// + /// Attempts to parse the first payload element as a shortcut. + /// + /// The parsed shortcut name if successful. + /// True if parsing was successful, false otherwise. + 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); + } + + /// + /// Tries to parse a snake_case string into a ShortcutName enum value. + /// + 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); + } +}; \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs b/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs index 2cd1c792..52afd491 100644 --- a/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs +++ b/app/MindWork AI Studio/Tools/Rust/TauriEventType.cs @@ -8,11 +8,13 @@ public enum TauriEventType NONE, PING, UNKNOWN, - + WINDOW_FOCUSED, WINDOW_NOT_FOCUSED, - + FILE_DROP_HOVERED, FILE_DROP_DROPPED, FILE_DROP_CANCELED, + + GLOBAL_SHORTCUT_PRESSED, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/ValidateShortcutRequest.cs b/app/MindWork AI Studio/Tools/Rust/ValidateShortcutRequest.cs new file mode 100644 index 00000000..2c045c67 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/ValidateShortcutRequest.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools.Rust; + +public sealed record ValidateShortcutRequest(string Shortcut); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs new file mode 100644 index 00000000..4515b78b --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs @@ -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 logger; + private readonly SettingsManager settingsManager; + private readonly MessageBus messageBus; + private readonly RustService rustService; + + public GlobalShortcutService( + ILogger 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(ComponentBase? sendingComponent, Event triggeredEvent, T? data) + { + switch (triggeredEvent) + { + case Event.CONFIGURATION_CHANGED: + await this.RegisterAllShortcuts(); + break; + } + } + + public Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) => Task.FromResult(default); + + #endregion + + private async Task RegisterAllShortcuts() + { + this.logger.LogInformation("Registering global shortcuts."); + foreach (var shortcutId in Enum.GetValues()) + { + 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; +} diff --git a/app/MindWork AI Studio/Tools/Services/RustEnumConverter.cs b/app/MindWork AI Studio/Tools/Services/RustEnumConverter.cs new file mode 100644 index 00000000..deb14ec6 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/RustEnumConverter.cs @@ -0,0 +1,112 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AIStudio.Tools.Services; + +/// +/// Converts enum values for Rust communication. +/// +/// +/// 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. +/// +public sealed class RustEnumConverter : JsonConverter +{ + private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(); + + 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())); + } + + /// + /// Converts UPPER_SNAKE_CASE to PascalCase. + /// + /// The text to convert (e.g., "VOICE_RECORDING_TOGGLE"). + /// The converted text as PascalCase (e.g., "VoiceRecordingToggle"). + 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(); + } + + /// + /// Converts a string to UPPER_SNAKE_CASE. + /// + /// The text to convert. + /// The converted text as UPPER_SNAKE_CASE. + 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(); + } +} diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs b/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs new file mode 100644 index 00000000..69c2b41d --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs @@ -0,0 +1,140 @@ +// ReSharper disable NotAccessedPositionalProperty.Local +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools.Services; + +public sealed partial class RustService +{ + /// + /// Registers or updates a global keyboard shortcut. + /// + /// The identifier for the shortcut. + /// The shortcut string in Tauri format (e.g., "CmdOrControl+1"). Use empty string to disable. + /// True if the shortcut was registered successfully, false otherwise. + public async Task 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(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; + } + } + + /// + /// Validates a shortcut string without registering it. + /// + /// The shortcut string to validate. + /// A validation result indicating if the shortcut is valid and any conflicts. + public async Task 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(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); + } + } + + /// + /// 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. + /// + /// True if successful, false otherwise. + public async Task 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(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; + } + } + + /// + /// Resumes the shortcut processing after it was suspended. + /// + /// True if successful, false otherwise. + public async Task 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(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; + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index c1b90e2f..6272378c 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -1,7 +1,6 @@ using System.Security.Cryptography; using System.Text.Json; -using AIStudio.Settings; using AIStudio.Tools.PluginSystem; using Version = System.Version; @@ -22,7 +21,10 @@ public sealed partial class RustService : BackgroundService private readonly JsonSerializerOptions jsonRustSerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - Converters = { new TolerantEnumConverter() }, + Converters = + { + new RustEnumConverter(), + }, }; private ILogger? logger; diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md index 2fa37abb..7b4d4528 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md @@ -1,6 +1,7 @@ # 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 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. - 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. diff --git a/runtime/Cargo.lock b/runtime/Cargo.lock index 7da20043..3ed52f10 100644 --- a/runtime/Cargo.lock +++ b/runtime/Cargo.lock @@ -2772,6 +2772,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "strum_macros", "sys-locale", "tauri", "tauri-build", @@ -4767,6 +4768,18 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "subtle" version = "2.6.1" diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 251ee1ce..47a0bc0a 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -9,7 +9,7 @@ authors = ["Thorsten Sommer"] tauri-build = { version = "1.5", features = [] } [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" } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" @@ -39,6 +39,7 @@ pdfium-render = "0.8.34" sys-locale = "0.3.2" cfg-if = "1.0.1" pptx-to-md = "0.4.0" +strum_macros = "0.27" # Fixes security vulnerability downstream, where the upstream is not fixed yet: url = "2.5.7" diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index 87d58fda..c69c5c08 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Mutex; use std::time::Duration; use log::{debug, error, info, trace, warn}; @@ -7,8 +8,9 @@ use rocket::response::stream::TextStream; use rocket::serde::json::Json; use rocket::serde::Serialize; use serde::Deserialize; +use strum_macros::Display; 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 tokio::sync::broadcast; use tokio::time; @@ -27,6 +29,17 @@ static CHECK_UPDATE_RESPONSE: Lazy>>> = /// The event broadcast sender for Tauri events. static EVENT_BROADCAST: Lazy>>> = Lazy::new(|| Mutex::new(None)); +/// Stores the currently registered global shortcuts (name -> shortcut string). +static REGISTERED_SHORTCUTS: Lazy>> = 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. pub fn start_tauri() { info!("Starting Tauri app..."); @@ -323,6 +336,8 @@ pub enum TauriEventType { FileDropHovered, FileDropDropped, FileDropCanceled, + + GlobalShortcutPressed, } /// Changes the location of the main window to the given URL. @@ -522,13 +537,7 @@ pub fn select_file(_token: APIToken, payload: Json) -> Json { - file_dialog.add_filter(&filter.filter_name, &filter.filter_extensions.iter().map(|s| s.as_str()).collect::>()) - }, - - None => file_dialog, - }; + let file_dialog = apply_filter(file_dialog, &payload.filter); // Set the previous file path if provided: let file_dialog = match &payload.previous_file { @@ -572,13 +581,7 @@ pub fn select_files(_token: APIToken, payload: Json) -> Json< let file_dialog = file_dialog.set_title(&payload.title); // Set the file type filter if provided: - let file_dialog = match &payload.filter { - Some(filter) => { - file_dialog.add_filter(&filter.filter_name, &filter.filter_extensions.iter().map(|s| s.as_str()).collect::>()) - }, - - None => file_dialog, - }; + let file_dialog = apply_filter(file_dialog, &payload.filter); // Set the previous file path if provided: let file_dialog = match &payload.previous_file { @@ -621,13 +624,7 @@ pub fn save_file(_token: APIToken, payload: Json) -> Json { - file_dialog.add_filter(&filter.filter_name, &filter.filter_extensions.iter().map(|s| s.as_str()).collect::>()) - }, - - None => file_dialog, - }; + let file_dialog = apply_filter(file_dialog, &payload.filter); // Set the previous file path if provided: let file_dialog = match &payload.name_file { @@ -665,6 +662,18 @@ pub struct PreviousFile { file_path: String, } +/// Applies an optional file type filter to a FileDialogBuilder. +fn apply_filter(file_dialog: FileDialogBuilder, filter: &Option) -> FileDialogBuilder { + match filter { + Some(f) => file_dialog.add_filter( + &f.filter_name, + &f.filter_extensions.iter().map(|s| s.as_str()).collect::>(), + ), + + None => file_dialog, + } +} + #[derive(Serialize)] pub struct FileSelectionResponse { user_cancelled: bool, @@ -683,6 +692,353 @@ pub struct FileSaveResponse { 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, +) -> 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 = "")] +pub fn register_shortcut(_token: APIToken, payload: Json) -> Json { + 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 = "")] +pub fn validate_shortcut(_token: APIToken, payload: Json) -> Json { + 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 { + // 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 { + // 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::().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) { let pdfium_relative_source_path = String::from("resources/libraries/"); let pdfium_source_path = path_resolver.resolve_resource(pdfium_relative_source_path); diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index 0b1cc8c1..de81cc18 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -87,6 +87,10 @@ pub fn start_runtime_api() { crate::file_data::extract_data, crate::log::get_log_paths, 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() .launch().await.unwrap();