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