Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/MindWork AI Studio/Assistants/I18N/allTexts.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8944,6 +8944,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T8163
-- The generated assistant plugin is invalid. Issue: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T939708112"] = "The generated assistant plugin is invalid. Issue: {0}"

-- The voice recording shortcut currently works only while AI Studio is focused.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T1204510649"] = "The voice recording shortcut currently works only while AI Studio is focused."

-- The global shortcut could not be registered. The previous shortcut remains active.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T2266307101"] = "The global shortcut could not be registered. The previous shortcut remains active."

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public partial class ConfigurationShortcut : ConfigurationBaseCore
private IDialogService DialogService { get; init; } = null!;

[Inject]
private RustService RustService { get; init; } = null!;
private GlobalShortcutService GlobalShortcutService { get; init; } = null!;

/// <summary>
/// The shortcut binding data.
Expand Down Expand Up @@ -69,7 +69,7 @@ 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();
await this.GlobalShortcutService.SuspendShortcutProcessing();

try
{
Expand Down Expand Up @@ -106,7 +106,7 @@ private async Task OpenDialog()
finally
{
// Resume the shortcut processing when the dialog is closed:
await this.RustService.ResumeShortcutProcessing();
await this.GlobalShortcutService.ResumeShortcutProcessing();
}
}
}
97 changes: 95 additions & 2 deletions app/MindWork AI Studio/Components/VoiceRecorder.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public partial class VoiceRecorder : MSGComponentBase
[Inject]
private RustService RustService { get; init; } = null!;

[Inject]
private GlobalShortcutService GlobalShortcutService { get; init; } = null!;

[Inject]
private ISnackbar Snackbar { get; init; } = null!;

Expand All @@ -35,6 +38,8 @@ public partial class VoiceRecorder : MSGComponentBase

protected override async Task OnInitializedAsync()
{
this.GlobalShortcutService.RuntimeStateChanged += this.OnShortcutRuntimeStateChanged;

// Register for global shortcut events:
this.ApplyFilters([], [Event.TAURI_EVENT_RECEIVED, Event.VOICE_RECORDING_AVAILABILITY_CHANGED]);

Expand All @@ -43,8 +48,15 @@ protected override async Task OnInitializedAsync()

protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && this.ShouldRenderVoiceRecording)
await this.EnsureSoundEffectsAvailableAsync("during the first interactive render");
if (firstRender)
{
this.localShortcutDotNetReference = DotNetObjectReference.Create(this);
this.localShortcutInteropReady = true;
await this.ApplyLocalShortcutState(this.GlobalShortcutService.GetRuntimeState(Shortcut.VOICE_RECORDING_TOGGLE));

if (this.ShouldRenderVoiceRecording)
await this.EnsureSoundEffectsAvailableAsync("during the first interactive render");
}

await base.OnAfterRenderAsync(firstRender);
}
Expand All @@ -69,6 +81,36 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
}
}

private async Task OnShortcutRuntimeStateChanged(GlobalShortcutRuntimeState runtimeState)
{
try
{
await this.InvokeAsync(() => this.ApplyLocalShortcutState(runtimeState));
}
catch (ObjectDisposedException)
{
this.Logger.LogDebug("Ignoring a shortcut state change after the voice recorder was disposed.");
}
catch (InvalidOperationException ex)
{
this.Logger.LogDebug(ex, "The focused-window shortcut listener could not be updated because the component dispatcher is unavailable.");
}
}

[JSInvokable]
public async Task OnLocalShortcutPressed()
{
var runtimeState = this.GlobalShortcutService.GetRuntimeState(Shortcut.VOICE_RECORDING_TOGGLE);
if (runtimeState.Backend is not ShortcutBackend.LOCAL || runtimeState.IsSuspended)
{
this.Logger.LogDebug("Ignoring a stale focused-window shortcut event.");
return;
}

this.Logger.LogInformation("Focused-window shortcut triggered for voice recording toggle.");
await this.ToggleRecordingFromShortcut();
}

/// <summary>
/// Toggles the recording state when triggered by a global shortcut.
/// </summary>
Expand Down Expand Up @@ -101,6 +143,48 @@ private async Task ToggleRecordingFromShortcut()
private string? currentRecordingPath;
private string? finalRecordingPath;
private DotNetObjectReference<VoiceRecorder>? dotNetReference;
private DotNetObjectReference<VoiceRecorder>? localShortcutDotNetReference;
private bool localShortcutInteropReady;

private async Task ApplyLocalShortcutState(GlobalShortcutRuntimeState runtimeState)
{
if (!this.localShortcutInteropReady
|| this.localShortcutDotNetReference is null
|| runtimeState.ShortcutId is not Shortcut.VOICE_RECORDING_TOGGLE)
{
return;
}

try
{
if (runtimeState.Backend is ShortcutBackend.LOCAL
&& !runtimeState.IsSuspended
&& !string.IsNullOrWhiteSpace(runtimeState.Shortcut))
{
await this.JsRuntime.InvokeVoidAsync(
"localShortcut.register",
"voice-recording-toggle",
runtimeState.Shortcut,
this.localShortcutDotNetReference);
}
else
{
await this.JsRuntime.InvokeVoidAsync("localShortcut.unregister", "voice-recording-toggle");
}
}
catch (JSDisconnectedException)
{
this.Logger.LogDebug("The focused-window shortcut listener could not be updated because the JS runtime disconnected.");
}
catch (OperationCanceledException)
{
this.Logger.LogDebug("Updating the focused-window shortcut listener was canceled.");
}
catch (JSException ex)
{
this.Logger.LogWarning(ex, "Failed to update the focused-window shortcut listener.");
}
}

private bool ShouldRenderVoiceRecording => PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)
&& !string.IsNullOrWhiteSpace(this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider);
Expand Down Expand Up @@ -482,6 +566,15 @@ private static string BuildSoundEffectsFailureDetails(SoundEffectsInitialization

protected override void DisposeResources()
{
this.GlobalShortcutService.RuntimeStateChanged -= this.OnShortcutRuntimeStateChanged;

if (this.localShortcutInteropReady)
_ = this.JsRuntime.InvokeVoidAsync("localShortcut.unregister", "voice-recording-toggle");

this.localShortcutDotNetReference?.Dispose();
this.localShortcutDotNetReference = null;
this.localShortcutInteropReady = false;

// Clean up recording resources if still active:
if (this.currentRecordingStream is not null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8946,6 +8946,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T8163
-- The generated assistant plugin is invalid. Issue: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T939708112"] = "Das generierte Assistenten-Plugin ist ungültig. Problem: {0}"

-- The voice recording shortcut currently works only while AI Studio is focused.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T1204510649"] = "Die Tastenkombination für Sprachaufnahmen funktioniert derzeit nur, wenn AI Studio im Vordergrund aktiv ist."

-- The global shortcut could not be registered. The previous shortcut remains active.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T2266307101"] = "Die globale Tastenkombination konnte nicht registriert werden. Die vorherige Tastenkombination bleibt aktiv."

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8946,6 +8946,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T8163
-- The generated assistant plugin is invalid. Issue: {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::ASSISTANTPLUGININSTALLSERVICE::T939708112"] = "The generated assistant plugin is invalid. Issue: {0}"

-- The voice recording shortcut currently works only while AI Studio is focused.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T1204510649"] = "The voice recording shortcut currently works only while AI Studio is focused."

-- The global shortcut could not be registered. The previous shortcut remains active.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::GLOBALSHORTCUTSERVICE::T2266307101"] = "The global shortcut could not be registered. The previous shortcut remains active."

Expand Down
3 changes: 2 additions & 1 deletion app/MindWork AI Studio/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ public static async Task Main()
builder.Services.AddSingleton<AIJobService>();
builder.Services.AddSingleton<AssistantSessionService>();
builder.Services.AddSingleton<VoiceRecordingAvailabilityService>();
builder.Services.AddSingleton<GlobalShortcutService>();
builder.Services.AddSingleton<MediaTranscriptionService>();
builder.Services.AddSingleton<AssistantPluginInstallService>();
builder.Services.AddSingleton<UpdatePolicy>();
Expand All @@ -180,7 +181,7 @@ public static async Task Main()
builder.Services.AddHostedService<TranscriptStagingCleanupService>();
builder.Services.AddHostedService<EnterpriseEnvironmentService>();
builder.Services.AddSingleton<DatabaseClientProvider>();
builder.Services.AddHostedService<GlobalShortcutService>();
builder.Services.AddHostedService<GlobalShortcutService>(serviceProvider => serviceProvider.GetRequiredService<GlobalShortcutService>());
builder.Services.AddHostedService<RustAvailabilityMonitorService>();

// ReSharper disable AccessToDisposedClosure
Expand Down
1 change: 1 addition & 0 deletions app/MindWork AI Studio/Tools/Rust/ShortcutBackend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ public enum ShortcutBackend
NONE,
PORTAL,
TAURI,
LOCAL,
}
92 changes: 91 additions & 1 deletion app/MindWork AI Studio/Tools/Services/GlobalShortcutService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@ private enum ShortcutSyncSource
}

private readonly SemaphoreSlim registrationSemaphore = new(1, 1);
private readonly object runtimeStateLock = new();
private readonly Dictionary<Shortcut, ShortcutState> lastSentStates = [];
private readonly Dictionary<Shortcut, string> lastNonEmptyShortcuts = [];
private readonly Dictionary<Shortcut, ShortcutRuntimeBinding> runtimeBindings = [];
private readonly ILogger<GlobalShortcutService> logger;
private readonly SettingsManager settingsManager;
private readonly MessageBus messageBus;
private readonly RustService rustService;
private readonly VoiceRecordingAvailabilityService voiceRecordingAvailabilityService;
private bool isProcessingSuspended;
private bool localFallbackWarningShown;

public event Func<GlobalShortcutRuntimeState, Task>? RuntimeStateChanged;

public GlobalShortcutService(
ILogger<GlobalShortcutService> logger,
Expand Down Expand Up @@ -58,6 +64,45 @@ public override async Task StopAsync(CancellationToken cancellationToken)
await base.StopAsync(cancellationToken);
}

/// <summary>
/// Returns the active backend and processing state for a shortcut.
/// </summary>
public GlobalShortcutRuntimeState GetRuntimeState(Shortcut shortcutId)
{
lock (this.runtimeStateLock)
{
if (this.runtimeBindings.TryGetValue(shortcutId, out var binding))
return new(shortcutId, binding.Shortcut, binding.Backend, this.isProcessingSuspended);

return new(shortcutId, string.Empty, ShortcutBackend.NONE, this.isProcessingSuspended);
}
}

/// <summary>
/// Pauses native and focused-window shortcut processing.
/// </summary>
public async Task<bool> SuspendShortcutProcessing()
{
lock (this.runtimeStateLock)
this.isProcessingSuspended = true;

await this.PublishAllRuntimeStates();
return await this.rustService.SuspendShortcutProcessing();
}

/// <summary>
/// Resumes native and focused-window shortcut processing.
/// </summary>
public async Task<bool> ResumeShortcutProcessing()
{
var result = await this.rustService.ResumeShortcutProcessing();
lock (this.runtimeStateLock)
this.isProcessingSuspended = false;

await this.PublishAllRuntimeStates();
return result;
}

#region IMessageBusReceiver

public async Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data)
Expand Down Expand Up @@ -155,6 +200,9 @@ private async Task RegisterAllShortcuts(ShortcutSyncSource source)
if (!string.IsNullOrWhiteSpace(requestedState.Shortcut))
this.lastNonEmptyShortcuts[shortcutId] = requestedState.Shortcut;

lock (this.runtimeStateLock)
this.runtimeBindings[shortcutId] = new(requestedState.Shortcut, result.Backend);

this.logger.LogInformation(
"Global shortcut '{ShortcutId}' ({Shortcut}) synchronized through {Backend}.",
shortcutId,
Expand All @@ -163,6 +211,15 @@ private async Task RegisterAllShortcuts(ShortcutSyncSource source)

if (result.Backend is ShortcutBackend.PORTAL)
await this.UpdateEffectiveDisplayName(shortcutId, result.EffectiveDisplayName);

await this.PublishRuntimeState(shortcutId);
if (result.Backend is ShortcutBackend.LOCAL && !this.localFallbackWarningShown)
{
this.localFallbackWarningShown = true;
await this.messageBus.SendWarning(new(
Icons.Material.Filled.Keyboard,
TB("The voice recording shortcut currently works only while AI Studio is focused.")));
}
}
else
{
Expand Down Expand Up @@ -236,6 +293,31 @@ private async Task UpdateEffectiveDisplayName(Shortcut shortcutId, string effect
await this.messageBus.SendMessage<bool>(null, Event.GLOBAL_SHORTCUT_CHANGED);
}

private async Task PublishAllRuntimeStates()
{
Shortcut[] shortcutIds;
lock (this.runtimeStateLock)
shortcutIds = this.runtimeBindings.Keys.ToArray();

foreach (var shortcutId in shortcutIds)
await this.PublishRuntimeState(shortcutId);
}

private async Task PublishRuntimeState(Shortcut shortcutId)
{
var subscribers = this.RuntimeStateChanged;
if (subscribers is null)
return;

var handlers = subscribers.GetInvocationList()
.Cast<Func<GlobalShortcutRuntimeState, Task>>()
.ToArray();
var runtimeState = this.GetRuntimeState(shortcutId);

foreach (var handler in handlers)
await handler(runtimeState);
}

private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(GlobalShortcutService).Namespace, nameof(GlobalShortcutService));

private async Task<ShortcutState> GetShortcutState(Shortcut shortcutId, ShortcutSyncSource source)
Expand All @@ -262,4 +344,12 @@ private async Task<ShortcutState> GetShortcutState(Shortcut shortcutId, Shortcut
}

private readonly record struct ShortcutState(string Shortcut, bool IsEnabled, bool UsesPersistedFallback);
}

private readonly record struct ShortcutRuntimeBinding(string Shortcut, ShortcutBackend Backend);
}

public sealed record GlobalShortcutRuntimeState(
Shortcut ShortcutId,
string Shortcut,
ShortcutBackend Backend,
bool IsSuspended);
Loading