diff --git a/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs index c83369af..aaa600b7 100644 --- a/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs +++ b/app/MindWork AI Studio/Components/ConfigurationShortcut.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Dialogs; +using AIStudio.Tools.Rust; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -33,7 +34,7 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore /// The name/identifier of the shortcut (used for conflict detection and registration). /// [Parameter] - public string ShortcutName { get; set; } = string.Empty; + public Shortcut ShortcutId { get; init; } /// /// The icon to display. @@ -80,7 +81,7 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore var dialogParameters = new DialogParameters { { x => x.InitialShortcut, this.Shortcut() }, - { x => x.ShortcutName, this.ShortcutName }, + { x => x.ShortcutId, this.ShortcutId }, }; var dialogReference = await this.DialogService.ShowAsync( diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor index 45fe4eea..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,6 +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 d829c177..73a95e8d 100644 --- a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs @@ -47,11 +47,12 @@ public partial class VoiceRecorder : MSGComponentBase { 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.Payload.Count > 0 && tauriEvent.Payload[0] == "voice_recording_toggle") + 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; } } diff --git a/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs index 588140bb..2f772721 100644 --- a/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/ShortcutDialog.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Components; +using AIStudio.Tools.Rust; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -24,10 +25,10 @@ public partial class ShortcutDialog : MSGComponentBase public string InitialShortcut { get; set; } = string.Empty; /// - /// The name/identifier of the shortcut for conflict detection. + /// The identifier of the shortcut for conflict detection. /// [Parameter] - public string ShortcutName { get; set; } = string.Empty; + public Shortcut ShortcutId { get; set; } private static readonly Dictionary USER_INPUT_ATTRIBUTES = new(); 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/TauriEvent.cs b/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs index c060e63a..c4cba654 100644 --- a/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs +++ b/app/MindWork AI Studio/Tools/Rust/TauriEvent.cs @@ -5,4 +5,38 @@ 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.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/Services/GlobalShortcutService.cs b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs index 23ddb553..e0865fbe 100644 --- a/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs +++ b/app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs @@ -1,5 +1,6 @@ using AIStudio.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.Rust; using Microsoft.AspNetCore.Components; @@ -57,44 +58,51 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv } } - public Task ProcessMessageWithResult( - ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) - => Task.FromResult(default); + public Task ProcessMessageWithResult(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) => Task.FromResult(default); #endregion private async Task RegisterAllShortcuts() { this.logger.LogInformation("Registering global shortcuts."); - - // - // Voice recording shortcut (preview feature) - // - if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.settingsManager)) + foreach (var shortcutId in Enum.GetValues()) { - var shortcut = this.settingsManager.ConfigurationData.App.ShortcutVoiceRecording; - if (!string.IsNullOrWhiteSpace(shortcut)) + var shortcut = this.GetShortcutValue(shortcutId); + var isEnabled = this.IsShortcutAllowed(shortcutId); + + if (isEnabled && !string.IsNullOrWhiteSpace(shortcut)) { - var success = await this.rustService.UpdateGlobalShortcut("voice_recording_toggle", shortcut); + var success = await this.rustService.UpdateGlobalShortcut(shortcutId, shortcut); if (success) - this.logger.LogInformation("Global shortcut 'voice_recording_toggle' ({Shortcut}) registered.", shortcut); + this.logger.LogInformation("Global shortcut '{ShortcutId}' ({Shortcut}) registered.", shortcutId, shortcut); else - this.logger.LogWarning("Failed to register global shortcut 'voice_recording_toggle' ({Shortcut}).", shortcut); + this.logger.LogWarning("Failed to register global shortcut '{ShortcutId}' ({Shortcut}).", shortcutId, shortcut); } else { - // Disable shortcut when empty - await this.rustService.UpdateGlobalShortcut("voice_recording_toggle", string.Empty); + // Disable the shortcut when empty or feature is disabled: + await this.rustService.UpdateGlobalShortcut(shortcutId, string.Empty); } } - else - { - // Disable the shortcut when the preview feature is disabled: - await this.rustService.UpdateGlobalShortcut("voice_recording_toggle", 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/RustService.Shortcuts.cs b/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs index 5da8d6ac..53cebaf7 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs @@ -1,4 +1,6 @@ // ReSharper disable NotAccessedPositionalProperty.Local +using AIStudio.Tools.Rust; + namespace AIStudio.Tools.Services; public sealed partial class RustService @@ -6,35 +8,35 @@ public sealed partial class RustService /// /// Registers or updates a global keyboard shortcut. /// - /// The name/identifier for the shortcut (e.g., "voice_recording_toggle"). + /// 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(string name, string shortcut) + public async Task UpdateGlobalShortcut(Shortcut shortcutId, string shortcut) { try { - var request = new RegisterShortcutRequest(name, shortcut); + 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 '{Name}' due to network error: {StatusCode}", name, response.StatusCode); + 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 '{Name}': {Error}", name, result?.ErrorMessage ?? "Unknown error"); + this.logger?.LogError("Failed to register global shortcut '{ShortcutId}': {Error}", shortcutId, result?.ErrorMessage ?? "Unknown error"); return false; } - this.logger?.LogInformation("Global shortcut '{Name}' registered successfully with key '{Shortcut}'.", name, shortcut); + 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 '{Name}'.", name); + this.logger?.LogError(ex, "Exception while registering global shortcut '{ShortcutId}'.", shortcutId); return false; } } @@ -136,7 +138,7 @@ public sealed partial class RustService } } - private sealed record RegisterShortcutRequest(string Name, string Shortcut); + private sealed record RegisterShortcutRequest(Shortcut ShortcutId, string Shortcut); private sealed record ShortcutResponse(bool Success, string ErrorMessage); @@ -151,5 +153,5 @@ public sealed partial class RustService /// 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. +/// Description of the conflict, if any. public sealed record ShortcutValidationResult(bool IsValid, string ErrorMessage, bool HasConflict, string ConflictDescription); diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index fe832090..b45ff00f 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -29,7 +29,24 @@ static CHECK_UPDATE_RESPONSE: Lazy>>> = 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())); +static REGISTERED_SHORTCUTS: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); + +/// Enum identifying global keyboard shortcuts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Shortcut { + None = 0, + VoiceRecordingToggle, +} + +impl Shortcut { + /// Returns the display name for logging. + pub fn display_name(&self) -> &'static str { + match self { + Shortcut::None => "none", + Shortcut::VoiceRecordingToggle => "voice_recording_toggle", + } + } +} /// Starts the Tauri app. pub fn start_tauri() { @@ -686,8 +703,8 @@ pub struct FileSaveResponse { /// Request payload for registering a global shortcut. #[derive(Clone, Deserialize)] pub struct RegisterShortcutRequest { - /// The name/identifier for the shortcut (e.g., "voice_recording_toggle"). - name: String, + /// The shortcut ID to use. + id: Shortcut, /// The shortcut string in Tauri format (e.g., "CmdOrControl+1"). /// Use empty string to unregister the shortcut. @@ -707,17 +724,15 @@ pub struct ShortcutResponse { fn register_shortcut_with_callback( shortcut_manager: &mut impl tauri::GlobalShortcutManager, shortcut: &str, - name: &str, + shortcut_id: Shortcut, event_sender: broadcast::Sender, ) -> Result<(), tauri::Error> { - let shortcut_name = name.to_string(); - // // 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_name}'."); - let event = Event::new(TauriEventType::GlobalShortcutPressed, vec![shortcut_name.clone()]); + info!(Source = "Tauri"; "Global shortcut triggered for '{}'.", shortcut_id.display_name()); + 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) { @@ -735,10 +750,10 @@ fn register_shortcut_with_callback( /// the existing shortcut for that name will be unregistered. #[post("/shortcuts/register", data = "")] pub fn register_shortcut(_token: APIToken, payload: Json) -> Json { - let name = payload.name.clone(); + let id = payload.id; let new_shortcut = payload.shortcut.clone(); - info!(Source = "Tauri"; "Registering global shortcut '{name}' with key '{new_shortcut}'."); + info!(Source = "Tauri"; "Registering global shortcut '{}' with key '{new_shortcut}'.", id.display_name()); // Get the main window to access the global shortcut manager: let main_window_lock = MAIN_WINDOW.lock().unwrap(); @@ -757,10 +772,10 @@ pub fn register_shortcut(_token: APIToken, payload: Json info!(Source = "Tauri"; "Unregistered old shortcut '{old_shortcut}' for '{name}'."), + Ok(_) => info!(Source = "Tauri"; "Unregistered old shortcut '{old_shortcut}' for '{}'.", id.display_name()), Err(error) => warn!(Source = "Tauri"; "Failed to unregister old shortcut '{old_shortcut}': {error}"), } } @@ -768,8 +783,8 @@ pub fn register_shortcut(_token: APIToken, payload: Json { - info!(Source = "Tauri"; "Global shortcut '{new_shortcut}' registered successfully for '{name}'."); - registered_shortcuts.insert(name, new_shortcut); + info!(Source = "Tauri"; "Global shortcut '{new_shortcut}' registered successfully for '{}'.", id.display_name()); + registered_shortcuts.insert(id, new_shortcut); Json(ShortcutResponse { success: true, error_message: String::new(), @@ -854,7 +869,7 @@ pub fn validate_shortcut(_token: APIToken, payload: Json Json { 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 '{name}': {error}"), + Ok(_) => info!(Source = "Tauri"; "Temporarily unregistered shortcut '{shortcut}' for '{}'.", name.display_name()), + Err(error) => warn!(Source = "Tauri"; "Failed to unregister shortcut '{shortcut}' for '{}': {error}", name.display_name()), } } } @@ -958,18 +973,18 @@ pub fn resume_shortcuts(_token: APIToken) -> Json { // Re-register all shortcuts with the OS: let mut success_count = 0; - for (name, shortcut) in registered_shortcuts.iter() { + for (shortcut_id, shortcut) in registered_shortcuts.iter() { if shortcut.is_empty() { continue; } - match register_shortcut_with_callback(&mut shortcut_manager, shortcut, name, event_sender.clone()) { + match register_shortcut_with_callback(&mut shortcut_manager, shortcut, *shortcut_id, event_sender.clone()) { Ok(_) => { - info!(Source = "Tauri"; "Re-registered shortcut '{shortcut}' for '{name}'."); + info!(Source = "Tauri"; "Re-registered shortcut '{shortcut}' for '{}'.", shortcut_id.display_name()); success_count += 1; }, - Err(error) => warn!(Source = "Tauri"; "Failed to re-register shortcut '{shortcut}' for '{name}': {error}"), + Err(error) => warn!(Source = "Tauri"; "Failed to re-register shortcut '{shortcut}' for '{}': {error}", shortcut_id.display_name()), } }