From 47ad0ab3aa7247bb379a11e3121c9d80fc3a4dab Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 6 Jul 2026 13:25:49 -0400 Subject: [PATCH 001/226] Add target-app autocorrection learning (Wispr-Flow-style, Linux) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silently learn a correction when the user types over a dictated word in the target app, then auto-apply it to future dictations via the existing dictionary pipeline. - AtSpiEventClient: persistent, event-driven AT-SPI (Tmds.DBus.Protocol) connection — focus/text-changed signals + one-shot text reads, no polling or per-read subprocess. - TargetAppCorrectionLearningService: arms a bounded tracking window after a qualifying insertion, anchors a baseline, and on commit diffs baseline<->final through CorrectionSuggestionService, silently LearnCorrection()-ing high-confidence recognition fixes. - Opt-in AppSettings flag (default off), insertion gating, DI wiring, and a fire-and-forget orchestrator hook. - Dictation settings toggle + en/de/es/ru strings; README note. Safety: fully silent + opt-in; password fields excluded (fail closed on indeterminate role); similarity gate rejects change-of-intent edits; widening guard stops idle commits growing a learned replacement; serialized start/stop on opt-out; graceful no-op when AT-SPI is unavailable. --- README.md | 1 + src/TypeWhisper.Core/Models/AppSettings.cs | 4 + .../Services/CorrectionSuggestionService.cs | 13 + .../Resources/Localization/de.json | 2 + .../Resources/Localization/en.json | 2 + .../Resources/Localization/es.json | 2 + .../Resources/Localization/ru.json | 2 + src/TypeWhisper.Linux/ServiceRegistrations.cs | 6 + .../Services/ActiveWindow/AtSpiEventClient.cs | 497 +++++++++++++ .../ActiveWindow/IAtSpiEventClient.cs | 62 ++ .../Services/DictationOrchestrator.cs | 40 ++ .../TargetAppCorrectionLearningService.cs | 663 ++++++++++++++++++ .../TypeWhisper.Linux.csproj | 4 + .../Sections/DictationSectionViewModel.cs | 9 + .../Views/Sections/DictationSection.axaml | 23 +- .../CorrectionSuggestionServiceTests.cs | 31 + ...TargetAppCorrectionLearningServiceTests.cs | 647 +++++++++++++++++ 17 files changed, 2007 insertions(+), 1 deletion(-) create mode 100644 src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs create mode 100644 src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs create mode 100644 src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs create mode 100644 tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs diff --git a/README.md b/README.md index 7a2f78937..a30b34129 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Press a key, talk, and have clean, punctuated text land in whatever app you're i - **AI text cleanup & formatting** in the Wispr-Flow style, driven by your own LLM, with profile style presets and developer-safe formatting. See [Text Cleanup](https://github.com/csmashe/typewhisper-linux/wiki/Text-Cleanup). - **File transcription and a recorder** — batch queues, watch folders, subtitle (SRT/VTT) export, and longer WAV captures. See [File Transcription](https://github.com/csmashe/typewhisper-linux/wiki/File-Transcription) and [Recorder](https://github.com/csmashe/typewhisper-linux/wiki/Recorder). - **Personalization** — searchable history, a dictionary with term packs, snippets, and app/URL-matched profiles. See [Profiles](https://github.com/csmashe/typewhisper-linux/wiki/Profiles). +- **Learns from your corrections** in the Wispr-Flow style — when you type over a dictated word in the target app to fix it, TypeWhisper silently learns the correction (via AT-SPI) and auto-applies it to future dictations. Off by default (it reads the focused field); enable it under Dictation settings, and review or remove learned entries in the dictionary. - **A localized interface** — English, German, Spanish, or Russian, switched live (or Auto, to follow your system locale). See [General Settings](https://github.com/csmashe/typewhisper-linux/wiki/General-Settings). - **Automation** — a local [HTTP API](https://github.com/csmashe/typewhisper-linux/wiki/HTTP-API) and an installable `typewhisper` [CLI](https://github.com/csmashe/typewhisper-linux/wiki/CLI). - **Desktop integration** — tray icon, XDG autostart, single-instance handoff, and a user-level installer. See [Desktop Integration](https://github.com/csmashe/typewhisper-linux/wiki/Desktop-Integration). diff --git a/src/TypeWhisper.Core/Models/AppSettings.cs b/src/TypeWhisper.Core/Models/AppSettings.cs index 0cdaa3a06..954761d2d 100644 --- a/src/TypeWhisper.Core/Models/AppSettings.cs +++ b/src/TypeWhisper.Core/Models/AppSettings.cs @@ -118,6 +118,10 @@ public Dictionary AppInsertionStrategies public bool VocabularyBoostingEnabled { get; init; } public bool AutoAddDictionaryCorrections { get; init; } + // Silently learn corrections when you type over a dictated word in the target app + // (Wispr-Flow-style). Default off — opt-in, since it reads other apps' field text. + public bool TargetAppCorrectionLearningEnabled { get; init; } + // Onboarding public bool HasCompletedOnboarding { get; init; } public string SelectedIndustryPresetId { get; init; } = "general"; diff --git a/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs b/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs index 1a56f13fa..55ba14f95 100644 --- a/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs +++ b/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs @@ -93,6 +93,19 @@ IReadOnlyList correctedChanged return false; } + // Reject any changed token that straddles a line break. Tokenize only splits on + // spaces, so a word adjacent to a newline gloms into one token (e.g. "foo\nbar"); + // a correction that embeds a line break is never a sensible word-level fix in + // either the history or target-app flow. + if ( + originalChanged + .Concat(correctedChanged) + .Any(token => token.Trimmed.Contains('\n') || token.Trimmed.Contains('\r')) + ) + { + return false; + } + // Avoid learning apostrophe-only churn from contractions or straight-vs-curly quote changes. return !originalChanged .Concat(correctedChanged) diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index 31c1a5b8f..92a2bd5b0 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -170,6 +170,8 @@ "Dictation.AutoCleanup": "Automatische Bereinigung", "Dictation.AutoLearnCorrections": "Genehmigte Verlaufskorrekturen automatisch lernen", "Dictation.AutoLearnCorrectionsHint": "Wenn Sie einen Verlaufseintrag bearbeiten, können eindeutige Phrasen-Vorschläge sofort gelernt werden.", + "Dictation.TargetAppCorrectionLearning": "Korrekturen aus anderen Apps lernen", + "Dictation.TargetAppCorrectionLearningHint": "Wenn Sie ein diktiertes Wort in einer anderen App überschreiben, um es zu korrigieren, lernt TypeWhisper die Korrektur stillschweigend und wendet sie auf zukünftige Diktate an. Liest das fokussierte Textfeld; standardmäßig deaktiviert.", "Dictation.AutoPaste": "Nach der Transkription automatisch einfügen", "Dictation.AutoStopOnSilence": "Bei Stille automatisch stoppen", "Dictation.CleanupHigh": "Hoch", diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index 2b4713e77..a83eb79b6 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -170,6 +170,8 @@ "Dictation.AutoCleanup": "Auto cleanup", "Dictation.AutoLearnCorrections": "Auto-learn approved history corrections", "Dictation.AutoLearnCorrectionsHint": "When you edit a history item, clear phrase-level suggestions can be learned immediately.", + "Dictation.TargetAppCorrectionLearning": "Learn corrections from other apps", + "Dictation.TargetAppCorrectionLearningHint": "When you type over a dictated word in another app to fix it, TypeWhisper silently learns the correction and applies it to future dictations. Reads the focused text field; off by default.", "Dictation.AutoPaste": "Auto paste after transcription", "Dictation.AutoStopOnSilence": "Auto-stop on silence", "Dictation.CleanupHigh": "High", diff --git a/src/TypeWhisper.Linux/Resources/Localization/es.json b/src/TypeWhisper.Linux/Resources/Localization/es.json index 35435432f..a5a6d445e 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -170,6 +170,8 @@ "Dictation.AutoCleanup": "Limpieza automática", "Dictation.AutoLearnCorrections": "Aprender automáticamente las correcciones aprobadas del historial", "Dictation.AutoLearnCorrectionsHint": "Cuando editas un elemento del historial, las sugerencias claras a nivel de frase pueden aprenderse de inmediato.", + "Dictation.TargetAppCorrectionLearning": "Aprender correcciones de otras apps", + "Dictation.TargetAppCorrectionLearningHint": "Cuando escribes sobre una palabra dictada en otra app para corregirla, TypeWhisper aprende la corrección de forma silenciosa y la aplica a los dictados futuros. Lee el campo de texto enfocado; desactivado de forma predeterminada.", "Dictation.AutoPaste": "Pegar automáticamente tras la transcripción", "Dictation.AutoStopOnSilence": "Detener automáticamente al detectar silencio", "Dictation.CleanupHigh": "Alta", diff --git a/src/TypeWhisper.Linux/Resources/Localization/ru.json b/src/TypeWhisper.Linux/Resources/Localization/ru.json index d65addd64..3362ce797 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -170,6 +170,8 @@ "Dictation.AutoCleanup": "Автоочистка", "Dictation.AutoLearnCorrections": "Автоматически учить одобренные исправления из истории", "Dictation.AutoLearnCorrectionsHint": "Когда вы редактируете элемент истории, очевидные исправления на уровне фраз могут быть выучены сразу.", + "Dictation.TargetAppCorrectionLearning": "Учить исправления из других приложений", + "Dictation.TargetAppCorrectionLearningHint": "Когда вы исправляете продиктованное слово, набирая поверх него в другом приложении, TypeWhisper незаметно запоминает исправление и применяет его к будущим диктовкам. Читает текстовое поле в фокусе; по умолчанию выключено.", "Dictation.AutoPaste": "Автоматическая вставка после транскрипции", "Dictation.AutoStopOnSilence": "Автоостановка при тишине", "Dictation.CleanupHigh": "Высокая", diff --git a/src/TypeWhisper.Linux/ServiceRegistrations.cs b/src/TypeWhisper.Linux/ServiceRegistrations.cs index 735234d99..b6193866d 100644 --- a/src/TypeWhisper.Linux/ServiceRegistrations.cs +++ b/src/TypeWhisper.Linux/ServiceRegistrations.cs @@ -80,6 +80,12 @@ public static void Register(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // Event-driven AT-SPI client + silent target-app correction learning + // (Wispr-Flow-style). The client holds one a11y-bus connection open; the + // learning service arms a tracking window after each qualifying insertion. + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService() diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs new file mode 100644 index 000000000..a8cca1864 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -0,0 +1,497 @@ +using System.Diagnostics; +using Tmds.DBus.Protocol; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; + +namespace TypeWhisper.Linux.Services.ActiveWindow; + +/// +/// Persistent, event-driven client for the AT-SPI accessibility bus, backed by +/// the managed Tmds.DBus.Protocol binding. It registers for +/// object:state-changed:focused and object:text-changed signals and +/// raises .NET events carrying the source element's (bus name, object path). Unlike +/// — which spawns a busctl process per read +/// and is only invoked on demand — this holds one connection open for the app's +/// lifetime so focus/edit events arrive with no polling. +/// +/// Everything is best-effort: if the a11y bus is unreachable (headless, minimal, +/// or remote sessions) returns false and +/// the feature that depends on it simply no-ops. +/// +/// +public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable +{ + // ReSharper disable once InconsistentNaming -- "a11y" is the standard accessibility numeronym (a + 11 letters + y) mirroring the org.a11y.Bus service name; ReSharper's PascalCase splitter mis-reads "11y" and wants the non-standard "A11Y". + private const string A11yBusName = "org.a11y.Bus"; + // ReSharper disable once InconsistentNaming -- see A11yBusName; keep the "a11y" numeronym mirroring the bus path. + private const string A11yBusPath = "/org/a11y/bus"; + // ReSharper disable once InconsistentNaming -- see A11yBusName; keep the "a11y" numeronym mirroring the bus interface. + private const string A11yBusInterface = "org.a11y.Bus"; + + private const string RegistryBusName = "org.a11y.atspi.Registry"; + private const string RegistryPath = "/org/a11y/atspi/registry"; + private const string RegistryInterface = "org.a11y.atspi.Registry"; + + private const string EventObjectInterface = "org.a11y.atspi.Event.Object"; + private const string TextInterface = "org.a11y.atspi.Text"; + private const string AccessibleInterface = "org.a11y.atspi.Accessible"; + private const string PropertiesInterface = "org.freedesktop.DBus.Properties"; + + private const string FocusedStateName = "focused"; + private const int StateGained = 1; + + // AtspiRole.PASSWORD_TEXT — confirmed against the existing extractor's role numbering + // (ROLE_FRAME = 23) which shares the same AtspiRole enum. + private const uint RolePasswordText = 40; + + private static readonly MessageValueReader s_readString = + static (m, _) => m.GetBodyReader().ReadString(); + + private static readonly MessageValueReader s_readVariantInt32 = + static (m, _) => m.GetBodyReader().ReadVariantValue().GetInt32(); + + private static readonly MessageValueReader s_readUInt32 = + static (m, _) => m.GetBodyReader().ReadUInt32(); + + // Reads the leading (detail: string, detail1: int) of an AT-SPI event body + // (full signature "siiv(so)"); the source element is taken from the message + // header (sender + path), matching how libatspi/pyatspi derive event.source. + private static readonly MessageValueReader s_readSignal = + static (m, _) => + { + var reader = m.GetBodyReader(); + var detail = reader.ReadString(); + var detail1 = reader.ReadInt32(); + return new AtSpiSignal( + m.SenderAsString ?? string.Empty, + m.PathAsString ?? string.Empty, + detail, + detail1 + ); + }; + + private readonly IErrorLogService _errorLog; + private readonly Lock _focusLock = new(); + private readonly SemaphoreSlim _startGate = new(1, 1); + + private AtSpiElementRef? _currentFocused; + private DBusConnection? _connection; + private bool _available; + private bool _disposed; + private bool _loggedUnavailable; + private bool _started; + private IDisposable? _stateSubscription; + private IDisposable? _textSubscription; + + public AtSpiEventClient(IErrorLogService errorLog) + { + _errorLog = errorLog; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + try + { + _stateSubscription?.Dispose(); + _textSubscription?.Dispose(); + _connection?.Dispose(); + } + catch + { + // best effort — teardown of a dying bus connection must not throw. + } + + _startGate.Dispose(); + } + + public event Action? FocusChanged; + public event Action? TextChanged; + + public AtSpiElementRef? CurrentFocusedElement + { + get + { + lock (_focusLock) + { + return _currentFocused; + } + } + } + + public async Task EnsureStartedAsync() + { + if (_started) + { + return _available; + } + + await _startGate.WaitAsync().ConfigureAwait(false); + try + { + if (_started) + { + return _available; + } + + var started = await TryStartAsync().ConfigureAwait(false); + _available = started; + // Only cache success. On failure TryStartAsync has already torn down any partial + // connection, so leaving _started false lets a later call retry (e.g. the a11y bus + // became available, or a transient connect error cleared). + _started = started; + return started; + } + finally + { + _startGate.Release(); + } + } + + public async Task StopAsync() + { + await _startGate.WaitAsync().ConfigureAwait(false); + try + { + try + { + _stateSubscription?.Dispose(); + _textSubscription?.Dispose(); + _connection?.Dispose(); + } + catch + { + // best effort — teardown of a dying bus connection must not throw. + } + + _stateSubscription = null; + _textSubscription = null; + _connection = null; + // Reset so the next EnsureStartedAsync reconnects fresh rather than returning + // the stale cached availability. + _started = false; + _available = false; + + lock (_focusLock) + { + _currentFocused = null; + } + } + finally + { + _startGate.Release(); + } + } + + public async Task TryReadTextAsync(AtSpiElementRef element, int maxLength) + { + var conn = _connection; + if (conn is null || !element.IsValid || maxLength <= 0) + { + return null; + } + + try + { + var characterCount = await GetCharacterCountAsync(conn, element).ConfigureAwait(false); + if (characterCount <= 0) + { + return null; + } + + var end = Math.Min(characterCount, maxLength); + return await GetTextAsync(conn, element, 0, end).ConfigureAwait(false); + } + catch (Exception ex) + { + LogOnce($"AT-SPI text read failed: {ex.Message}"); + return null; + } + } + + public async Task IsPasswordFieldAsync(AtSpiElementRef element) + { + var conn = _connection; + if (conn is null || !element.IsValid) + { + // Cannot determine the role → indeterminate, not "safe". This is a privacy + // boundary, so the caller must fail closed rather than proceed to read text. + return null; + } + + try + { + MessageBuffer message; + using (var writer = conn.GetMessageWriter()) + { + writer.WriteMethodCallHeader( + destination: element.BusName, + path: element.ObjectPath, + @interface: AccessibleInterface, + member: "GetRole" + ); + message = writer.CreateMessage(); + } + + var role = await conn.CallMethodAsync(message, s_readUInt32).ConfigureAwait(false); + return role == RolePasswordText; + } + catch + { + // Role read failed (denied / transient / toolkit without a reliable role). Return + // indeterminate so the caller skips rather than risk reading a password field. + return null; + } + } + + private async Task TryStartAsync() + { + try + { + var address = await ResolveA11yBusAddressAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(address)) + { + LogOnce("AT-SPI event client: a11y bus address unavailable."); + return false; + } + + var conn = new DBusConnection(address); + await conn.ConnectAsync().ConfigureAwait(false); + _connection = conn; + + _stateSubscription = await conn.AddMatchAsync( + new MatchRule + { + Type = MessageType.Signal, + Interface = EventObjectInterface, + Member = "StateChanged", + // The AT-SPI event detail (focused/showing/visible/checked/…) is the first + // body arg, so Arg0="focused" lets the bus daemon filter to focus changes + // for us instead of waking us for every state change session-wide. The + // in-handler detail/detail1 checks below stay as defense in depth. + Arg0 = FocusedStateName + }, + s_readSignal, + HandleStateChanged, + emitOnCapturedContext: false + ).ConfigureAwait(false); + + _textSubscription = await conn.AddMatchAsync( + new MatchRule + { + Type = MessageType.Signal, + Interface = EventObjectInterface, + Member = "TextChanged" + }, + s_readSignal, + HandleTextChanged, + emitOnCapturedContext: false + ).ConfigureAwait(false); + + // Tell registryd which events have listeners so toolkits that gate + // event emission on demand (GTK) actually broadcast them. + await RegisterEventAsync(conn, "object:state-changed:focused").ConfigureAwait(false); + await RegisterEventAsync(conn, "object:text-changed").ConfigureAwait(false); + + return true; + } + catch (Exception ex) + { + LogOnce($"AT-SPI event client start failed: {ex.Message}"); + // A connection may have been made before AddMatchAsync/RegisterEventAsync threw. + // Tear down any partial state so we don't leak a live connection/match, and so the + // next EnsureStartedAsync retries from a clean slate. + try + { + _stateSubscription?.Dispose(); + _textSubscription?.Dispose(); + _connection?.Dispose(); + } + catch + { + // best effort — teardown of a half-open connection must not throw. + } + + _stateSubscription = null; + _textSubscription = null; + _connection = null; + return false; + } + } + + private void HandleStateChanged(Notification notification) + { + // Only value notifications carry a signal. Completion notifications have no value + // (HasValue == false); their Exception must not be read for value notifications, so + // gate on HasValue rather than touching Exception here. + if (!notification.HasValue) + { + return; + } + + var signal = notification.Value; + if ( + !string.Equals(signal.Detail, FocusedStateName, StringComparison.Ordinal) + || signal.Detail1 != StateGained + ) + { + return; + } + + var element = new AtSpiElementRef(signal.Sender, signal.Path); + if (!element.IsValid) + { + return; + } + + lock (_focusLock) + { + _currentFocused = element; + } + + // A subscriber throwing here runs on the D-Bus dispatch thread and would fault the + // connection, killing all future events. Isolate it. + try + { + FocusChanged?.Invoke(element); + } + catch (Exception ex) + { + Trace.WriteLine($"[AtSpiEventClient] FocusChanged subscriber threw: {ex.Message}"); + } + } + + private void HandleTextChanged(Notification notification) + { + if (!notification.HasValue) + { + return; + } + + var element = new AtSpiElementRef(notification.Value.Sender, notification.Value.Path); + if (!element.IsValid) + { + return; + } + + try + { + TextChanged?.Invoke(element); + } + catch (Exception ex) + { + Trace.WriteLine($"[AtSpiEventClient] TextChanged subscriber threw: {ex.Message}"); + } + } + + // ReSharper disable once InconsistentNaming -- "a11y" is the standard accessibility numeronym mirroring org.a11y.Bus; ReSharper's PascalCase splitter mis-reads "11y". + private async Task ResolveA11yBusAddressAsync() + { + try + { + var session = DBusConnection.Session; + MessageBuffer message; + using (var writer = session.GetMessageWriter()) + { + writer.WriteMethodCallHeader( + destination: A11yBusName, + path: A11yBusPath, + @interface: A11yBusInterface, + member: "GetAddress" + ); + message = writer.CreateMessage(); + } + + return await session.CallMethodAsync(message, s_readString).ConfigureAwait(false); + } + catch (Exception ex) + { + LogOnce($"AT-SPI GetAddress failed: {ex.Message}"); + return null; + } + } + + private static async Task RegisterEventAsync(DBusConnection conn, string eventName) + { + MessageBuffer message; + using (var writer = conn.GetMessageWriter()) + { + writer.WriteMethodCallHeader( + destination: RegistryBusName, + path: RegistryPath, + @interface: RegistryInterface, + member: "RegisterEvent", + signature: "s" + ); + writer.WriteString(eventName); + message = writer.CreateMessage(); + } + + await conn.CallMethodAsync(message).ConfigureAwait(false); + } + + private static async Task GetCharacterCountAsync(DBusConnection conn, AtSpiElementRef element) + { + MessageBuffer message; + using (var writer = conn.GetMessageWriter()) + { + writer.WriteMethodCallHeader( + destination: element.BusName, + path: element.ObjectPath, + @interface: PropertiesInterface, + member: "Get", + signature: "ss" + ); + writer.WriteString(TextInterface); + writer.WriteString("CharacterCount"); + message = writer.CreateMessage(); + } + + return await conn.CallMethodAsync(message, s_readVariantInt32).ConfigureAwait(false); + } + + private static async Task GetTextAsync( + DBusConnection conn, + AtSpiElementRef element, + int start, + int end + ) + { + MessageBuffer message; + using (var writer = conn.GetMessageWriter()) + { + writer.WriteMethodCallHeader( + destination: element.BusName, + path: element.ObjectPath, + @interface: TextInterface, + member: "GetText", + signature: "ii" + ); + writer.WriteInt32(start); + writer.WriteInt32(end); + message = writer.CreateMessage(); + } + + return await conn.CallMethodAsync(message, s_readString).ConfigureAwait(false); + } + + private void LogOnce(string message) + { + Trace.WriteLine($"[AtSpiEventClient] {message}"); + if (_loggedUnavailable) + { + return; + } + + _loggedUnavailable = true; + _errorLog.AddEntry(message, ErrorCategory.Detection); + } + + private readonly record struct AtSpiSignal(string Sender, string Path, string Detail, int Detail1); +} diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs new file mode 100644 index 000000000..f86394256 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs @@ -0,0 +1,62 @@ +namespace TypeWhisper.Linux.Services.ActiveWindow; + +/// +/// Identifies an accessible element on the AT-SPI bus as the pair +/// (unique D-Bus bus name of the owning application, object path). Value +/// equality lets callers compare "is this the same element I armed?". +/// +public readonly record struct AtSpiElementRef(string BusName, string ObjectPath) +{ + public bool IsValid => !string.IsNullOrEmpty(BusName) && !string.IsNullOrEmpty(ObjectPath); +} + +/// +/// Event-driven view of the AT-SPI accessibility bus: a persistent connection +/// that surfaces focus/text-edit signals and one-shot text reads, without any +/// polling or per-read subprocess. Behind an interface so the correction-learning +/// orchestration can be unit-tested with a fake. +/// +public interface IAtSpiEventClient +{ + /// Raised when an element gains keyboard focus (object:state-changed:focused, gained). + event Action? FocusChanged; + + /// Raised when an element's text changes (object:text-changed, insert/delete). + event Action? TextChanged; + + /// The element that most recently gained focus, or null if none seen yet. + AtSpiElementRef? CurrentFocusedElement { get; } + + /// + /// Connects to the a11y bus and registers event listeners on first call. + /// Returns true when the bus is reachable and listeners are live, + /// false when AT-SPI is unavailable (headless/minimal/remote sessions). + /// Idempotent — subsequent calls return the cached availability. + /// + Task EnsureStartedAsync(); + + /// + /// Tears down the event subscriptions and the a11y-bus connection and resets state + /// so a later reconnects fresh. Called when the + /// user disables the feature so the process stops receiving a11y event traffic. + /// Safe to call when never started; safe against a concurrent + /// (both serialize on the same start gate). + /// + Task StopAsync(); + + /// + /// One-shot read of an element's text via org.a11y.atspi.Text, clamped to + /// characters. Returns null when the + /// element does not expose readable text (e.g. Electron, terminals) or the + /// read fails. + /// + Task TryReadTextAsync(AtSpiElementRef element, int maxLength); + + /// + /// true when the element's AT-SPI role is PASSWORD_TEXT, false when it is + /// positively a non-password role, and null when the role could not be read + /// (denied/transient/missing). Callers must treat null as unsafe and fail closed + /// — this guards a privacy boundary, so "unknown" must never be read as "safe". + /// + Task IsPasswordFieldAsync(AtSpiElementRef element); +} diff --git a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs index ee433064c..7c5a0764d 100644 --- a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs +++ b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs @@ -67,6 +67,7 @@ public sealed class DictationOrchestrator : IDisposable private readonly ISnippetService _snippets; private readonly SoundFeedbackService _soundFeedback; private readonly SpeechFeedbackService _speechFeedback; + private readonly TargetAppCorrectionLearningService _targetAppLearning; private readonly TextInsertionService _textInsertion; // The debounce check-and-write must be atomic: two threads (hook + IPC) can @@ -138,6 +139,7 @@ public DictationOrchestrator( RecentTranscriptionsService recentTranscriptions, IdeFileReferenceService ideFileReferences, SystemCommandAvailabilityService commands, + TargetAppCorrectionLearningService targetAppLearning, IDetectionFailureTracker failureTracker, IErrorLogService errorLog ) @@ -167,6 +169,7 @@ IErrorLogService errorLog _recentTranscriptions = recentTranscriptions; _ideFileReferences = ideFileReferences; _commands = commands; + _targetAppLearning = targetAppLearning; _failureTracker = failureTracker; _errorLog = errorLog; } @@ -315,6 +318,10 @@ public void Initialize() // Lambdas (not method groups): StartAsync/ToggleAsync have optional parameters // that prevent zero-arg method-group conversion. + // Start the AT-SPI focus listener now (when enabled) so it has captured the + // target field's focus before the user dictates into it. + _targetAppLearning.Initialize(); + _toggleHandler = (_, _) => FireAndLog(() => ToggleAsync(), nameof(ToggleAsync)); _startHandler = (_, _) => FireAndLog(() => StartAsync(), nameof(StartAsync)); _stopHandler = (_, _) => FireAndLog(StopAsync, nameof(StopAsync)); @@ -1639,6 +1646,18 @@ or InsertionResult.CopiedToClipboard ); } + if (ShouldArmTargetAppLearning(insertion, actionPlugin, insertionText)) + { + // Fire-and-forget: arm a bounded tracking window on the field that just + // received the text, so a follow-up type-over is learned silently. Mirrors + // the memory-extraction hook below — never blocks the dictation path. + // ReSharper disable once MethodSupportsCancellation -- background arm; not tied to the dictation token. + FireAndLog( + () => _targetAppLearning.ArmAsync(insertionText), + "target-app correction learning" + ); + } + var transcriptionId = Guid.NewGuid().ToString(); var timestamp = context.RecordingStart == default ? DateTime.UtcNow : context.RecordingStart; @@ -1941,6 +1960,27 @@ CancellationToken cancelToken return result.Success ? InsertionResult.ActionHandled : InsertionResult.ActionFailed; } + /// + /// Gate for arming silent target-app correction learning: the feature is enabled, + /// the text went into the field directly (typed or pasted — not a clipboard + /// fallback), it was plain dictation output (no action plugin), and it is short + /// enough to be a normal edit rather than a document dump. AT-SPI availability is + /// checked inside . + /// + private bool ShouldArmTargetAppLearning( + InsertionResult insertion, + IActionPlugin? actionPlugin, + string insertionText + ) + { + return TargetAppCorrectionLearningService.ShouldArm( + _settings.Current.TargetAppCorrectionLearningEnabled, + insertion, + actionPlugin is not null, + insertionText.Length + ); + } + private static void FireAndLog(Func start, string label) { Task task; diff --git a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs new file mode 100644 index 000000000..ff8e2465c --- /dev/null +++ b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs @@ -0,0 +1,663 @@ +using System.Diagnostics; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Core.Services; +using TypeWhisper.Linux.Services.ActiveWindow; + +namespace TypeWhisper.Linux.Services; + +/// +/// Wispr-Flow-style silent correction learning from the target app. After a +/// dictation inserts text, anchors a baseline read of the +/// focused field and opens a bounded tracking window. If the user then types over a +/// word to fix it and moves on (focus leaves the field, or a short idle after edits), +/// the final field text is diffed against the baseline through +/// and any high-confidence result is +/// silently persisted via — no toast, +/// no prompt. Learned entries are reviewable/removable in Settings → Dictionary. +/// +/// The AT-SPI plumbing lives behind , so the +/// arm/commit orchestration is unit-testable with a fake. +/// +/// +public sealed class TargetAppCorrectionLearningService : IDisposable +{ + // Longest insertion we will arm on. Longer than a normal edit is likely a document + // dump the user won't hand-correct word-by-word, so tracking it wastes a read. + private const int MaxInsertionLength = 2048; + + // Baseline/final reads are clamped to this; larger than the insertion gate because the + // field can hold pre-existing surrounding text around the dictated span. + private const int MaxTrackedTextLength = 8192; + + // Defaults for the timing seams below. Kept as instance fields (not static readonly) so + // unit tests can shrink them via the internal constructor without waiting real seconds. + + // How long we keep tracking a field after insertion before giving up (matches Wispr + // Flow's "during a dictation session" scoping and bounds resource/privacy exposure). + private static readonly TimeSpan s_defaultTrackingWindow = TimeSpan.FromSeconds(30); + + // Commit this long after the last edit when focus hasn't left — covers the "fix a word + // and keep the cursor there" case without waiting for the full tracking window. + private static readonly TimeSpan s_defaultIdleCommitDelay = TimeSpan.FromSeconds(3); + + // Backoff between baseline read retries while the injected text is still draining into + // the field (Wayland can return from the injection tool before the app has applied it). + private static readonly TimeSpan s_defaultBaselineRetryDelay = TimeSpan.FromMilliseconds(150); + + private readonly IAtSpiEventClient _client; + private readonly IDictionaryService _dictionary; + private readonly IErrorLogService _errorLog; + private readonly Lock _gate = new(); + + // Serializes AT-SPI start/stop so a rapid enable→disable can't interleave. Not disposed: + // reconciles run fire-and-forget and could be mid-wait at shutdown; SemaphoreSlim needs no + // disposal unless its AvailableWaitHandle is used (it isn't). + // ReSharper disable once InconsistentNaming + private readonly SemaphoreSlim _listenGate = new(1, 1); + private readonly ISettingsService _settings; + + private readonly TimeSpan _trackingWindow; + private readonly TimeSpan _idleCommitDelay; + private readonly TimeSpan _baselineRetryDelay; + + private ArmedState? _armed; + private bool _disposed; + private Timer? _idleTimer; + private bool _initialized; + private bool _loggedSkip; + private bool _subscribed; + private Timer? _timeoutTimer; + + // Test seam: the most recently scheduled background commit, so unit tests can await + // completion deterministically instead of polling. Null until the first commit runs. + // Commits are chained onto this task (see CommitInBackground) so awaiting it covers every + // scheduled commit, not just the last-started one. + internal Task? LastCommitTask { get; private set; } + + // Test seam: the most recently scheduled start/stop reconcile. Reconciles serialize on + // _listenGate, so awaiting the last-assigned task guarantees all prior ones have finished. + internal Task? LastListenTask { get; private set; } + + public TargetAppCorrectionLearningService( + IAtSpiEventClient client, + IDictionaryService dictionary, + ISettingsService settings, + IErrorLogService errorLog + ) + : this( + client, + dictionary, + settings, + errorLog, + s_defaultTrackingWindow, + s_defaultIdleCommitDelay, + s_defaultBaselineRetryDelay + ) + { + } + + // Test-only overload: lets unit tests shrink the tracking/idle/retry timers to + // milliseconds so the arm → commit loop can be exercised deterministically. + internal TargetAppCorrectionLearningService( + IAtSpiEventClient client, + IDictionaryService dictionary, + ISettingsService settings, + IErrorLogService errorLog, + TimeSpan trackingWindow, + TimeSpan idleCommitDelay, + TimeSpan baselineRetryDelay + ) + { + _client = client; + _dictionary = dictionary; + _settings = settings; + _errorLog = errorLog; + _trackingWindow = trackingWindow; + _idleCommitDelay = idleCommitDelay; + _baselineRetryDelay = baselineRetryDelay; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + _settings.SettingsChanged -= OnSettingsChanged; + if (_subscribed) + { + _client.FocusChanged -= OnFocusChanged; + _client.TextChanged -= OnTextChanged; + } + + lock (_gate) + { + StopTimers(); + _armed = null; + } + } + + /// + /// Wires up settings-change handling and, when the feature is already enabled, + /// starts the AT-SPI listener early so it captures the focus of whatever field + /// the user dictates into (focus is usually gained before dictation starts). + /// Safe to call once from the orchestrator's initialization. + /// + public void Initialize() + { + if (_initialized || _disposed) + { + return; + } + + _initialized = true; + _settings.SettingsChanged += OnSettingsChanged; + ReconcileListeningInBackground(); + } + + /// + /// Called (fire-and-forget) right after a qualifying dictation insertion. Reads the + /// current field text as a baseline and opens the tracking window. No-ops silently + /// when the feature is disabled, AT-SPI is unavailable, the focused element is a + /// password field, or its text can't be read. + /// + public async Task ArmAsync(string insertedText) + { + if (IsOptedOut()) + { + return; + } + + if (string.IsNullOrWhiteSpace(insertedText)) + { + return; + } + + if (!await _client.EnsureStartedAsync().ConfigureAwait(false)) + { + LogSkipOnce("AT-SPI unavailable; target-app correction learning inactive."); + return; + } + + EnsureSubscribed(); + + var focused = _client.CurrentFocusedElement; + if (focused is not { IsValid: true } element) + { + Trace.WriteLine("[TargetAppLearning] No focused AT-SPI element to track; skipping."); + return; + } + + // Re-check opt-out after EnsureStartedAsync: the user could have disabled the feature + // while it was connecting. Disable runs StopAsync on a separate gate and does not + // serialize with this fire-and-forget arm, so we must not do further accessibility + // reads after opt-out. (Re-checked again immediately before the text read below.) + if (IsOptedOut()) + { + Disarm(); + return; + } + + // Fail closed: skip unless the element is positively a non-password role. A null + // (indeterminate) result means the role could not be read — never read text then. + if (await _client.IsPasswordFieldAsync(element).ConfigureAwait(false) != false) + { + Trace.WriteLine( + "[TargetAppLearning] Element is a password field or its role is unknown; skipping." + ); + Disarm(); + return; + } + + // Confirm the field actually contains the text we just inserted before anchoring on + // it. On Wayland the app may still be draining injected keystrokes when the injection + // tool returns, so an immediate read can be truncated ("Hello wor" vs the final + // "Hello world") — anchoring on that would learn garbage like `wor -> world`. Compare + // with whitespace runs collapsed to single spaces and case-insensitively (tolerates + // autocapitalize). Retry a couple of times to let the field settle. This also guards + // against arming on the wrong field when focus moved mid-dictation; and a field longer + // than the MaxTrackedTextLength read clamp will honestly skip here (the edit past the + // clamp would be invisible to us anyway). + string? baseline = null; + var anchored = false; + for (var attempt = 0; attempt < 3; attempt++) + { + if (attempt > 0) + { + await Task.Delay(_baselineRetryDelay).ConfigureAwait(false); + } + + // Re-check opt-out immediately before every text read: a disable during the + // password read or a retry delay must stop us reading the target app's text. + if (IsOptedOut()) + { + Disarm(); + return; + } + + var read = await _client.TryReadTextAsync(element, MaxTrackedTextLength) + .ConfigureAwait(false); + if (read is null) + { + continue; + } + + // Keep the most recent successful read as the candidate baseline. + baseline = read; + // Positive break reads clearer than a trailing `continue` at the loop tail, which + // would skip nothing since the loop re-iterates anyway. Keep found -> anchor -> break. + // ReSharper disable once InvertIf + if (ContainsCollapsed(read, insertedText)) + { + anchored = true; + break; + } + } + + if (!anchored || baseline is null) + { + Trace.WriteLine( + "[TargetAppLearning] Baseline could not be anchored to the inserted text; skipping." + ); + Disarm(); + return; + } + + lock (_gate) + { + StopTimers(); + _armed = new ArmedState(element, baseline); + _timeoutTimer = new Timer( + static state => ((TargetAppCorrectionLearningService)state!).OnTimeout(), + this, + _trackingWindow, + Timeout.InfiniteTimeSpan + ); + } + } + + // Whitespace-insensitive, case-insensitive containment: splits both strings on any + // whitespace and rejoins with single spaces so a baseline whose spacing differs from the + // injected text (tabs, doubled spaces, wrapped newlines) still anchors. + private static bool ContainsCollapsed(string haystack, string needle) + { + return CollapseWhitespace(haystack) + .Contains(CollapseWhitespace(needle), StringComparison.OrdinalIgnoreCase); + } + + private static string CollapseWhitespace(string text) + { + return string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + } + + // A learned pair must share at least this fraction of characters (1 - normalized edit + // distance) to count as a recognition/spelling fix rather than a change of intent. + private const double MinCorrectionSimilarity = 0.5; + + // True when is plus one or more + // appended words (the previous value followed by a word boundary). Distinguishes "the user + // kept typing new content" (widening — reject) from "the user refined the corrected word + // itself" (e.g. "Kubernete" -> "Kubernetes" — allowed, no whitespace at the seam). + private static bool IsWidening(string previous, string next) + { + return next.Length > previous.Length + && next.StartsWith(previous, StringComparison.Ordinal) + && char.IsWhiteSpace(next[previous.Length]); + } + + private static bool IsLikelyRecognitionFix(string original, string replacement) + { + var a = CollapseWhitespace(original).ToLowerInvariant(); + var b = CollapseWhitespace(replacement).ToLowerInvariant(); + if (a.Length == 0 || b.Length == 0) + { + return false; + } + + var similarity = 1.0 - (double)LevenshteinDistance(a, b) / Math.Max(a.Length, b.Length); + return similarity >= MinCorrectionSimilarity; + } + + private static int LevenshteinDistance(string a, string b) + { + var previous = new int[b.Length + 1]; + var current = new int[b.Length + 1]; + for (var j = 0; j <= b.Length; j++) + { + previous[j] = j; + } + + for (var i = 1; i <= a.Length; i++) + { + current[0] = i; + for (var j = 1; j <= b.Length; j++) + { + var cost = a[i - 1] == b[j - 1] ? 0 : 1; + current[j] = Math.Min( + Math.Min(current[j - 1] + 1, previous[j] + 1), + previous[j - 1] + cost + ); + } + + (previous, current) = (current, previous); + } + + return previous[b.Length]; + } + + /// + /// Pure gate for whether a dictation insertion should arm target-app learning: + /// the feature is on, the text went into the field directly (typed or pasted — not + /// a clipboard fallback), it was plain dictation (no action plugin), and it is short + /// enough to be a normal edit. Kept static and side-effect-free for unit testing. + /// + public static bool ShouldArm( + bool featureEnabled, + InsertionResult insertion, + bool hasActionPlugin, + int insertionLength + ) + { + if (!featureEnabled || hasActionPlugin) + { + return false; + } + + if (insertion is not (InsertionResult.Typed or InsertionResult.Pasted)) + { + return false; + } + + return insertionLength is > 0 and <= MaxInsertionLength; + } + + // True when the feature has been turned off (or the service torn down). Used as the guard + // after every await in ArmAsync/CommitAsync so a mid-flight operation never reads target + // text after opt-out — disable runs on a separate gate and doesn't serialize with them. + private bool IsOptedOut() + { + return _disposed || !_settings.Current.TargetAppCorrectionLearningEnabled; + } + + private void OnSettingsChanged(AppSettings settings) + { + ReconcileListeningInBackground(); + } + + private void ReconcileListeningInBackground() + { + LastListenTask = Task.Run(ReconcileListeningAsync); + } + + // Drives the AT-SPI listener to match the current setting. Serialized on _listenGate and + // re-reading _settings.Current each time, so a rapid enable→disable can never leave a + // queued start reconnecting after the stop: whichever reconcile runs last observes the + // final setting and wins. On opt-out it disarms any tracking window and tears down the + // a11y-bus connection so the process stops receiving accessibility event traffic. + private async Task ReconcileListeningAsync() + { + await _listenGate.WaitAsync().ConfigureAwait(false); + try + { + if (!_disposed && _settings.Current.TargetAppCorrectionLearningEnabled) + { + if (await _client.EnsureStartedAsync().ConfigureAwait(false)) + { + EnsureSubscribed(); + } + else + { + LogSkipOnce("AT-SPI unavailable; target-app correction learning inactive."); + } + } + else + { + Disarm(); + await _client.StopAsync().ConfigureAwait(false); + } + } + catch (Exception ex) + { + Trace.WriteLine($"[TargetAppLearning] Listener reconcile failed: {ex.Message}"); + } + finally + { + _listenGate.Release(); + } + } + + private void EnsureSubscribed() + { + lock (_gate) + { + if (_subscribed) + { + return; + } + + _client.FocusChanged += OnFocusChanged; + _client.TextChanged += OnTextChanged; + _subscribed = true; + } + } + + private void OnTextChanged(AtSpiElementRef element) + { + lock (_gate) + { + if (_armed is null || !_armed.Element.Equals(element)) + { + return; + } + + _armed.Edited = true; + _idleTimer ??= new Timer( + static state => ((TargetAppCorrectionLearningService)state!).OnIdle(), + this, + Timeout.InfiniteTimeSpan, + Timeout.InfiniteTimeSpan + ); + _idleTimer.Change(_idleCommitDelay, Timeout.InfiniteTimeSpan); + } + } + + private void OnFocusChanged(AtSpiElementRef element) + { + lock (_gate) + { + if (_armed is null || _armed.Element.Equals(element)) + { + // Not tracking, or focus merely re-asserted on the same element. + return; + } + } + + // Focus left the armed element: a FINAL commit — it learns the edit if there was one + // and otherwise cleanly disarms. + CommitInBackground(final: true); + } + + private void OnIdle() + { + // Idle commits are NON-FINAL: we persist the current diff but stay armed on the same + // baseline so a later focus-out/timeout can re-diff and overwrite a partial edit the + // user was still typing when the idle timer fired (LearnCorrection overwrites the + // Replacement for an Original it already knows — the self-heal mechanism). + CommitInBackground(final: false); + } + + private void OnTimeout() + { + // Timeout is a FINAL commit: learn any pending edit that never triggered a focus-out + // or idle commit and drop the state. + CommitInBackground(final: true); + } + + private void CommitInBackground(bool final) + { + lock (_gate) + { + var state = _armed; + if (state is null || !state.Edited) + { + // Inverting to `if (!final) return;` would duplicate the return and split the + // cleanup; conditional-cleanup-then-return is clearer here. + // ReSharper disable once InvertIf + if (final) + { + // Nothing to learn; drop the armed state and stop the timers. + _armed = null; + StopTimers(); + } + + return; + } + + if (final) + { + // Final commits disarm and stop timers up front; the ArmedState snapshot is + // then touched only inside the serialized commit chain below. + _armed = null; + StopTimers(); + } + + // Serialize commits: a non-final idle commit can race a final focus-out commit, + // and both read the field and call LearnCorrection. Chaining onto LastCommitTask + // guarantees the previous commit finishes first, so awaiting LastCommitTask covers + // every scheduled commit. + LastCommitTask = (LastCommitTask ?? Task.CompletedTask) + .ContinueWith(_ => CommitAsync(state), TaskScheduler.Default) + .Unwrap(); + } + } + + private async Task CommitAsync(ArmedState state) + { + try + { + // A commit may have been queued before the user opted out; never read the field + // or learn once the feature is disabled (or the service is being torn down). + if (IsOptedOut()) + { + return; + } + + var finalText = await _client.TryReadTextAsync(state.Element, MaxTrackedTextLength) + .ConfigureAwait(false); + if (finalText is null || string.Equals(finalText, state.Baseline, StringComparison.Ordinal)) + { + return; + } + + var suggestions = CorrectionSuggestionService.GenerateSuggestions( + state.Baseline, + finalText + ); + foreach (var suggestion in suggestions) + { + // Silent auto-learn holds a higher bar than the review-first history flow: only + // persist when the replacement is a plausible recognition/spelling fix of the + // original. CorrectionSuggestionService only rejects majority rewrites once the + // total token count exceeds 3, so without this a short "call mom" -> "email dad" + // change of intent would be silently learned as a correction. + if (!IsLikelyRecognitionFix(suggestion.Original, suggestion.Replacement)) + { + Trace.WriteLine( + $"[TargetAppLearning] Rejected low-similarity edit '{suggestion.Original}'" + + $" -> '{suggestion.Replacement}' (likely a change of intent)." + ); + continue; + } + + if (state.LearnedByOriginal.TryGetValue(suggestion.Original, out var previous)) + { + // Identical to what we already learned this session — skip so a non-final + // idle commit followed by an identical final commit doesn't inflate + // TimesCorrected/UsageCount. + if (string.Equals(previous, suggestion.Replacement, StringComparison.Ordinal)) + { + continue; + } + + // The user kept typing words after the correction was already complete, so + // the diff now appends them to the replacement (e.g. "Kubernetes" then + // "Kubernetes now"). Keep the earlier, correct value rather than widen it. + if (IsWidening(previous, suggestion.Replacement)) + { + Trace.WriteLine( + $"[TargetAppLearning] Ignoring widened replacement '{suggestion.Original}'" + + $" -> '{suggestion.Replacement}' (keeping '{previous}')." + ); + continue; + } + } + + _dictionary.LearnCorrection(suggestion.Original, suggestion.Replacement); + state.LearnedByOriginal[suggestion.Original] = suggestion.Replacement; + Trace.WriteLine( + $"[TargetAppLearning] Learned '{suggestion.Original}' -> " + + $"'{suggestion.Replacement}' from target-app edit." + ); + } + } + catch (Exception ex) + { + Trace.WriteLine($"[TargetAppLearning] Commit failed: {ex.Message}"); + } + } + + private void Disarm() + { + _ = TakeArmed(); + } + + private ArmedState? TakeArmed() + { + lock (_gate) + { + var state = _armed; + _armed = null; + StopTimers(); + return state; + } + } + + // Caller must hold _gate. + private void StopTimers() + { + _idleTimer?.Dispose(); + _idleTimer = null; + _timeoutTimer?.Dispose(); + _timeoutTimer = null; + } + + private void LogSkipOnce(string message) + { + Trace.WriteLine($"[TargetAppLearning] {message}"); + if (_loggedSkip) + { + return; + } + + _loggedSkip = true; + _errorLog.AddEntry(message, ErrorCategory.Detection); + } + + private sealed class ArmedState(AtSpiElementRef element, string baseline) + { + public AtSpiElementRef Element { get; } = element; + public string Baseline { get; } = baseline; + public bool Edited { get; set; } + + // The last replacement persisted for each original during this armed session. Lets a + // later commit refine an earlier partial (overwrite) while ignoring identical repeats + // (no count inflation) and widenings (trailing words the user kept typing). Touched + // only inside the serialized commit chain, so it needs no synchronization of its own. + public Dictionary LearnedByOriginal { get; } = + new(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj b/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj index 7c255efbc..752f666ce 100644 --- a/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj +++ b/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj @@ -52,6 +52,10 @@ + + - + @@ -562,6 +562,27 @@ HorizontalAlignment="Right" /> + + + + + + + + + + + + diff --git a/tests/TypeWhisper.Core.Tests/Services/CorrectionSuggestionServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/CorrectionSuggestionServiceTests.cs index af41cd344..20de51ade 100644 --- a/tests/TypeWhisper.Core.Tests/Services/CorrectionSuggestionServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/CorrectionSuggestionServiceTests.cs @@ -32,6 +32,23 @@ public void GenerateSuggestions_ReturnsMultiWordCorrection() Assert.Equal("TypeWhisper", suggestion.Replacement); } + [Fact] + public void GenerateSuggestions_IsolatesEditedWordWithinUnchangedSurroundingText() + { + // Mirrors the target-app learning flow: baseline is the whole field text right + // after insertion; final is the same field after the user types over one word. + // The common prefix/suffix trim must isolate just the corrected word even though + // most of the surrounding sentence is unchanged. + var result = CorrectionSuggestionService.GenerateSuggestions( + "Please email jon about the kubernets migration next week", + "Please email jon about the Kubernetes migration next week" + ); + + var suggestion = Assert.Single(result); + Assert.Equal("kubernets", suggestion.Original); + Assert.Equal("Kubernetes", suggestion.Replacement); + } + [Fact] public void GenerateSuggestions_DoesNotSuggestLargeRewrite() { @@ -63,4 +80,18 @@ public void GenerateSuggestions_DoesNotSuggestWhenOnlyPunctuationChanged() Assert.Empty(result); } + + [Fact] + public void GenerateSuggestions_DoesNotSuggestWhenChangedTokenEmbedsNewline() + { + // Tokenize only splits on spaces, so a word adjacent to a line break gloms into one + // token ("update\nplease"); a correction embedding a newline is never a sensible + // word-level fix and must be rejected. + var result = CorrectionSuggestionService.GenerateSuggestions( + "send the status update please", + "send the status update\nplease" + ); + + Assert.Empty(result); + } } \ No newline at end of file diff --git a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs new file mode 100644 index 000000000..9e37d3c1d --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs @@ -0,0 +1,647 @@ +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Core.Services; +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.ActiveWindow; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +/// +/// Covers : the silent +/// arm → observe edit → commit → learn loop (driven by a fake +/// ) and the static +/// gating matrix. +/// +public sealed class TargetAppCorrectionLearningServiceTests : IDisposable +{ + private static readonly AtSpiElementRef s_field = new("app", "/field/1"); + private static readonly AtSpiElementRef s_otherField = new("app", "/field/2"); + + private readonly string _dictionaryPath = + Path.Join(Path.GetTempPath(), $"tw-dict-{Guid.NewGuid():N}.json"); + + private readonly DictionaryService _dictionary; + + public TargetAppCorrectionLearningServiceTests() + { + _dictionary = new DictionaryService(_dictionaryPath); + } + + public void Dispose() + { + if (File.Exists(_dictionaryPath)) + { + File.Delete(_dictionaryPath); + } + } + + [Fact] + public async Task Arm_ThenEdit_ThenFocusOut_LearnsCorrectionSilently() + { + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService(client, enabled: true); + + client.TextToReturn = "I deployed to kubernets today"; + await service.ArmAsync("I deployed to kubernets today"); + + // User types over the misrecognized word to fix it. + client.TextToReturn = "I deployed to Kubernetes today"; + client.RaiseText(s_field); + + // ...then moves focus away, which commits the edit. + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + var correction = Assert.Single(_dictionary.GetCorrections()); + Assert.Equal("kubernets", correction.Original); + Assert.Equal("Kubernetes", correction.Replacement); + } + + [Fact] + public async Task Arm_FocusOutWithoutEdit_LearnsNothing() + { + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService(client, enabled: true); + + client.TextToReturn = "hello world"; + await service.ArmAsync("hello world"); + + // Focus leaves without any text-changed event — nothing to learn. + client.RaiseFocus(s_otherField); + + Assert.Null(service.LastCommitTask); + Assert.Empty(_dictionary.GetCorrections()); + } + + [Fact] + public async Task Arm_EditRejectedByCorrectionService_LearnsNothing() + { + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService(client, enabled: true); + + client.TextToReturn = "this is a rough draft for tomorrow"; + await service.ArmAsync("this is a rough draft for tomorrow"); + + // A wholesale rewrite is rejected by CorrectionSuggestionService's safety gates. + client.TextToReturn = "please send a concise status update instead"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + Assert.Empty(_dictionary.GetCorrections()); + } + + [Fact] + public async Task Arm_WhenDisabled_DoesNotTouchAtSpiOrLearn() + { + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService(client, enabled: false); + + client.TextToReturn = "hello world"; + await service.ArmAsync("hello world"); + + Assert.Equal(0, client.EnsureStartedCalls); + Assert.Equal(0, client.TextReadCalls); + Assert.Empty(_dictionary.GetCorrections()); + } + + [Fact] + public async Task Arm_PasswordField_LearnsNothing() + { + var client = new FakeAtSpiEventClient + { + CurrentFocusedElement = s_field, PasswordResult = true + }; + using var service = CreateService(client, enabled: true); + + client.TextToReturn = "hunter2"; + await service.ArmAsync("hunter2"); + + client.TextToReturn = "hunter3"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + + Assert.Null(service.LastCommitTask); + Assert.Empty(_dictionary.GetCorrections()); + // The password guard runs before the baseline read, so the field is never read. + Assert.Equal(0, client.TextReadCalls); + } + + [Fact] + public async Task Arm_PasswordRoleIndeterminate_FailsClosed_LearnsNothing() + { + // The role read could not be determined (null). A privacy boundary must fail closed: + // never proceed to read the field text. + var client = new FakeAtSpiEventClient + { + CurrentFocusedElement = s_field, PasswordResult = null + }; + using var service = CreateService(client, enabled: true); + + client.TextToReturn = "secret value"; + await service.ArmAsync("secret value"); + + Assert.Equal(0, client.TextReadCalls); + Assert.Null(service.LastCommitTask); + Assert.Empty(_dictionary.GetCorrections()); + } + + [Fact] + public async Task Commit_SingleWordSpellingFix_StillLearns() + { + // The similarity gate must NOT break the flagship one-word case (no surrounding context). + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService(client, enabled: true); + + client.TextToReturn = "kubernets"; + await service.ArmAsync("kubernets"); + + client.TextToReturn = "Kubernetes"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + var correction = Assert.Single(_dictionary.GetCorrections()); + Assert.Equal("kubernets", correction.Original); + Assert.Equal("Kubernetes", correction.Replacement); + } + + [Fact] + public async Task Commit_LowSimilarityIntentChange_LearnsNothing() + { + // A short whole-phrase rewrite ("call mom" -> "email dad") passes the shared word-diff + // (<=3 tokens), but is a change of intent, not a recognition fix — must not be learned. + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService(client, enabled: true); + + client.TextToReturn = "call mom"; + await service.ArmAsync("call mom"); + + client.TextToReturn = "email dad"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + Assert.Empty(_dictionary.GetCorrections()); + } + + [Fact] + public async Task Arm_IdleCommit_ThenMoreWordsTyped_DoesNotWidenReplacement() + { + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService( + client, + new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ), + idleCommitDelay: TimeSpan.FromMilliseconds(50) + ); + + client.TextToReturn = "kubernets"; + await service.ArmAsync("kubernets"); + + // Idle fires once the single-word correction is complete → learns kubernets->Kubernetes. + client.TextToReturn = "Kubernetes"; + client.RaiseText(s_field); + await AwaitScheduledCommit(service); + Assert.Equal("Kubernetes", Assert.Single(_dictionary.GetCorrections()).Replacement); + + // The user then keeps typing new words. Diffing the unchanged baseline against + // "Kubernetes now" would widen the replacement — the guard must keep the earlier value. + client.TextToReturn = "Kubernetes now"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + var correction = Assert.Single(_dictionary.GetCorrections()); + Assert.Equal("kubernets", correction.Original); + Assert.Equal("Kubernetes", correction.Replacement); + } + + [Fact] + public async Task Arm_DisabledDuringStartup_DoesNotReadTargetText() + { + // The user disables the feature while ArmAsync is still awaiting EnsureStartedAsync. + // The opt-out re-check must bail before any field text is read. + var startGate = new TaskCompletionSource(); + var client = new FakeAtSpiEventClient + { + CurrentFocusedElement = s_field, StartGate = startGate + }; + var settings = new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ); + using var service = CreateService(client, settings); + + client.TextToReturn = "I deployed to kubernets today"; + var arm = service.ArmAsync("I deployed to kubernets today"); // blocks in EnsureStartedAsync + await WaitUntilAsync(() => client.EnsureStartedCalls == 1); + + settings.Save(AppSettings.Default with { TargetAppCorrectionLearningEnabled = false }); + startGate.SetResult(); + await arm.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(0, client.TextReadCalls); + Assert.Empty(_dictionary.GetCorrections()); + } + + [Fact] + public async Task EnableThenDisable_WhileStartInFlight_EndsStoppedAndSubscribedQuiet() + { + // Race: a start reconcile is in flight (blocked mid-EnsureStartedAsync) when the user + // disables. Serialization must guarantee the stop reconcile runs *after* the start, so + // the connection ends torn down (StopAsync called) rather than left listening. + var startGate = new TaskCompletionSource(); + var client = new FakeAtSpiEventClient + { + CurrentFocusedElement = s_field, StartGate = startGate + }; + var settings = new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ); + using var service = CreateService(client, settings); + + service.Initialize(); // fires the (blocked) start reconcile + await WaitUntilAsync(() => client.EnsureStartedCalls == 1); + + settings.Save(AppSettings.Default with { TargetAppCorrectionLearningEnabled = false }); + startGate.SetResult(); // let the start finish; the stop reconcile is queued behind it + + var last = service.LastListenTask; + Assert.NotNull(last); + await last.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.True(client.StopCalls >= 1); + } + + [Fact] + public async Task Arm_ThenEdit_ThenIdle_LearnsWithoutFocusOut() + { + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService( + client, + new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ), + idleCommitDelay: TimeSpan.FromMilliseconds(20) + ); + + client.TextToReturn = "I deployed to kubernets today"; + await service.ArmAsync("I deployed to kubernets today"); + + // User fixes the word but keeps the cursor in the field — no focus-out, only idle. + client.TextToReturn = "I deployed to Kubernetes today"; + client.RaiseText(s_field); + await AwaitScheduledCommit(service); + + var correction = Assert.Single(_dictionary.GetCorrections()); + Assert.Equal("kubernets", correction.Original); + Assert.Equal("Kubernetes", correction.Replacement); + } + + [Fact] + public async Task Arm_PartialIdleCommit_ThenCompleteFinalCommit_SelfHeals() + { + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService( + client, + new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ), + idleCommitDelay: TimeSpan.FromMilliseconds(20) + ); + + client.TextToReturn = "I deployed to kubernets today"; + await service.ArmAsync("I deployed to kubernets today"); + + // Idle fires mid-correction: the user has typed all but the final letter + // ("Kubernete"). This partial is still similar enough to pass the recognition-fix gate, + // so it is learned and later overwritten. + client.TextToReturn = "I deployed to Kubernete today"; + client.RaiseText(s_field); + await AwaitScheduledCommit(service); + + Assert.Equal("Kubernete", Assert.Single(_dictionary.GetCorrections()).Replacement); + + // The user finishes the word and moves on: the final commit re-diffs and overwrites. + client.TextToReturn = "I deployed to Kubernetes today"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + var correction = Assert.Single(_dictionary.GetCorrections()); + Assert.Equal("kubernets", correction.Original); + Assert.Equal("Kubernetes", correction.Replacement); + } + + [Fact] + public async Task Arm_IdleThenIdenticalFinalCommit_DoesNotInflateCount() + { + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService( + client, + new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ), + idleCommitDelay: TimeSpan.FromMilliseconds(20) + ); + + client.TextToReturn = "I deployed to kubernets today"; + await service.ArmAsync("I deployed to kubernets today"); + + // Idle commits the completed correction; the later focus-out commit sees the same text. + client.TextToReturn = "I deployed to Kubernetes today"; + client.RaiseText(s_field); + await AwaitScheduledCommit(service); + + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + var entry = Assert.Single( + _dictionary.Entries, + e => e.EntryType == DictionaryEntryType.Correction + ); + Assert.Equal("kubernets", entry.Original); + Assert.Equal("Kubernetes", entry.Replacement); + Assert.Equal(1, entry.TimesCorrected); + } + + [Fact] + public async Task Arm_ThenEdit_ThenTimeout_LearnsWithoutFocusOut() + { + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService( + client, + new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ), + trackingWindow: TimeSpan.FromMilliseconds(30) + ); + + client.TextToReturn = "I deployed to kubernets today"; + await service.ArmAsync("I deployed to kubernets today"); + + // Edit, then neither focus-out nor idle (idle default 3s) — the tracking window + // elapses first and its final commit learns the correction. + client.TextToReturn = "I deployed to Kubernetes today"; + client.RaiseText(s_field); + await AwaitScheduledCommit(service); + + var correction = Assert.Single(_dictionary.GetCorrections()); + Assert.Equal("kubernets", correction.Original); + Assert.Equal("Kubernetes", correction.Replacement); + } + + [Fact] + public async Task Disable_MidWindow_StopsClientAndLearnsNothing() + { + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + var settings = new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ); + using var service = CreateService(client, settings); + // Wire settings-change handling (the orchestrator does this on startup). + service.Initialize(); + + client.TextToReturn = "I deployed to kubernets today"; + await service.ArmAsync("I deployed to kubernets today"); + + // User disables the feature while a tracking window is open. + settings.Save(AppSettings.Default with { TargetAppCorrectionLearningEnabled = false }); + await WaitUntilAsync(() => client.StopCalls > 0); + + // A subsequent edit + focus-out must not learn anything. + client.TextToReturn = "I deployed to Kubernetes today"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + + Assert.Null(service.LastCommitTask); + Assert.Empty(_dictionary.GetCorrections()); + } + + [Fact] + public async Task Arm_WhenBusUnavailable_NoOps() + { + var client = new FakeAtSpiEventClient { Available = false, CurrentFocusedElement = s_field }; + using var service = CreateService(client, enabled: true); + + client.TextToReturn = "I deployed to kubernets today"; + await service.ArmAsync("I deployed to kubernets today"); + + Assert.Equal(1, client.EnsureStartedCalls); + Assert.Equal(0, client.TextReadCalls); + Assert.Empty(_dictionary.GetCorrections()); + } + + [Fact] + public async Task Arm_BaselineNeverContainsInsertedText_SkipsAfterRetries() + { + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService(client, enabled: true); + + // The read never reflects the inserted text (e.g. still draining, or wrong field): + // arming must be skipped after exactly three read attempts. + client.TextToReturn = "totally unrelated field contents"; + await service.ArmAsync("I deployed to kubernets today"); + + Assert.Equal(3, client.TextReadCalls); + + // A later edit + focus-out on the (never-armed) field learns nothing. + client.TextToReturn = "I deployed to Kubernetes today"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + + Assert.Null(service.LastCommitTask); + Assert.Empty(_dictionary.GetCorrections()); + } + + [Theory] + // Feature on + direct insertion of a normal-length edit → arm. + [InlineData(true, InsertionResult.Typed, false, 20, true)] + [InlineData(true, InsertionResult.Pasted, false, 20, true)] + // Feature off → never arm. + [InlineData(false, InsertionResult.Typed, false, 20, false)] + // Clipboard fallback (not a direct insertion) → never arm. + [InlineData(true, InsertionResult.CopiedToClipboard, false, 20, false)] + // Action-plugin output (not plain dictation) → never arm. + [InlineData(true, InsertionResult.Typed, true, 20, false)] + // Oversized insertion (document dump) → never arm. + [InlineData(true, InsertionResult.Typed, false, 2049, false)] + // Empty insertion → never arm. + [InlineData(true, InsertionResult.Typed, false, 0, false)] + public void ShouldArm_GatingMatrix( + bool enabled, + InsertionResult insertion, + bool hasActionPlugin, + int length, + bool expected + ) + { + Assert.Equal( + expected, + TargetAppCorrectionLearningService.ShouldArm(enabled, insertion, hasActionPlugin, length) + ); + } + + private TargetAppCorrectionLearningService CreateService( + FakeAtSpiEventClient client, + bool enabled + ) + { + return CreateService( + client, + new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = enabled } + ) + ); + } + + private TargetAppCorrectionLearningService CreateService( + FakeAtSpiEventClient client, + FakeSettingsService settings, + TimeSpan? trackingWindow = null, + TimeSpan? idleCommitDelay = null + ) + { + return new TargetAppCorrectionLearningService( + client, + _dictionary, + settings, + new NullErrorLogService(), + trackingWindow ?? TimeSpan.FromSeconds(30), + idleCommitDelay ?? TimeSpan.FromSeconds(3), + TimeSpan.FromMilliseconds(1) + ); + } + + private static async Task AwaitCommit(TargetAppCorrectionLearningService service) + { + var task = service.LastCommitTask; + Assert.NotNull(task); + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + // Waits for a background (idle/timeout) commit to be scheduled, then awaits it. Unlike + // AwaitCommit, the commit here is fired by a timer, so LastCommitTask is null until the + // callback runs. + private static async Task AwaitScheduledCommit(TargetAppCorrectionLearningService service) + { + await WaitUntilAsync(() => service.LastCommitTask is not null); + var task = service.LastCommitTask; + Assert.NotNull(task); + await task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + private static async Task WaitUntilAsync(Func condition) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (!condition() && DateTime.UtcNow < deadline) + { + await Task.Delay(10); + } + + Assert.True(condition()); + } + + private sealed class FakeAtSpiEventClient : IAtSpiEventClient + { + public bool Available { get; init; } = true; + + // null models a role read that could not be determined (fail-closed path). + public bool? PasswordResult { get; init; } = false; + public string? TextToReturn { get; set; } + public AtSpiElementRef? CurrentFocusedElement { get; set; } + public int EnsureStartedCalls { get; private set; } + public int TextReadCalls { get; private set; } + public int StopCalls { get; private set; } + + // When set, EnsureStartedAsync blocks on this until released — lets a test hold a start + // in flight while a disable is issued, to exercise the start/stop serialization. + public TaskCompletionSource? StartGate { get; init; } + + public event Action? FocusChanged; + public event Action? TextChanged; + + public async Task EnsureStartedAsync() + { + EnsureStartedCalls++; + if (StartGate is not null) + { + await StartGate.Task.ConfigureAwait(false); + } + + return Available; + } + + public Task StopAsync() + { + StopCalls++; + return Task.CompletedTask; + } + + public Task TryReadTextAsync(AtSpiElementRef element, int maxLength) + { + TextReadCalls++; + return Task.FromResult(TextToReturn); + } + + public Task IsPasswordFieldAsync(AtSpiElementRef element) + { + return Task.FromResult(PasswordResult); + } + + public void RaiseFocus(AtSpiElementRef element) + { + CurrentFocusedElement = element; + FocusChanged?.Invoke(element); + } + + public void RaiseText(AtSpiElementRef element) + { + TextChanged?.Invoke(element); + } + } + + private sealed class FakeSettingsService(AppSettings current) : ISettingsService + { + public AppSettings Current { get; private set; } = current; + + public AppSettings Load() + { + return Current; + } + + public void Save(AppSettings settings) + { + Current = settings; + SettingsChanged?.Invoke(settings); + } + + public event Action? SettingsChanged; + } + + private sealed class NullErrorLogService : IErrorLogService + { + public IReadOnlyList Entries => []; + + public void AddEntry(string message, string category = ErrorCategory.General) + { + } + + public void ClearAll() + { + } + + public string ExportDiagnostics() + { + return string.Empty; + } + + public event Action? EntriesChanged + { + add { } + remove { } + } + } +} From e4469b962795c9cc29b666ce1dbb8f8e85574899 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 6 Jul 2026 13:48:47 -0400 Subject: [PATCH 002/226] Address CodeRabbit review: bind timers to arm generation; redact learning logs - Timer callbacks (idle/timeout) now carry the arm generation they were created for, so a callback queued just before StopTimers disposes its timer can't commit or disarm a newer armed session. - Redact raw dictated/corrected text from the target-app learning Trace logs (privacy). --- .../TargetAppCorrectionLearningService.cs | 63 +++++++++++++------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs index ff8e2465c..d4533cc80 100644 --- a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs +++ b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs @@ -62,6 +62,11 @@ public sealed class TargetAppCorrectionLearningService : IDisposable private readonly TimeSpan _baselineRetryDelay; private ArmedState? _armed; + + // Incremented per arm. Timer callbacks capture the generation live at creation and are + // ignored if they fire after a re-arm/disarm (a disposed timer's callback can still be + // queued), so a stale idle/timeout can never commit or disarm a newer armed session. + private int _armGeneration; private bool _disposed; private Timer? _idleTimer; private bool _initialized; @@ -270,10 +275,15 @@ public async Task ArmAsync(string insertedText) lock (_gate) { StopTimers(); - _armed = new ArmedState(element, baseline); + var generation = unchecked(++_armGeneration); + _armed = new ArmedState(element, baseline, generation); _timeoutTimer = new Timer( - static state => ((TargetAppCorrectionLearningService)state!).OnTimeout(), - this, + static state => + { + var token = (TimerToken)state!; + token.Owner.OnTimeout(token.Generation); + }, + new TimerToken(this, generation), _trackingWindow, Timeout.InfiniteTimeSpan ); @@ -456,8 +466,12 @@ private void OnTextChanged(AtSpiElementRef element) _armed.Edited = true; _idleTimer ??= new Timer( - static state => ((TargetAppCorrectionLearningService)state!).OnIdle(), - this, + static state => + { + var token = (TimerToken)state!; + token.Owner.OnIdle(token.Generation); + }, + new TimerToken(this, _armed.Generation), Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan ); @@ -481,27 +495,35 @@ private void OnFocusChanged(AtSpiElementRef element) CommitInBackground(final: true); } - private void OnIdle() + private void OnIdle(int generation) { // Idle commits are NON-FINAL: we persist the current diff but stay armed on the same // baseline so a later focus-out/timeout can re-diff and overwrite a partial edit the // user was still typing when the idle timer fired (LearnCorrection overwrites the // Replacement for an Original it already knows — the self-heal mechanism). - CommitInBackground(final: false); + CommitInBackground(final: false, generation); } - private void OnTimeout() + private void OnTimeout(int generation) { // Timeout is a FINAL commit: learn any pending edit that never triggered a focus-out // or idle commit and drop the state. - CommitInBackground(final: true); + CommitInBackground(final: true, generation); } - private void CommitInBackground(bool final) + // generation is set for timer-driven commits (idle/timeout) and null for event-driven ones + // (focus-out). A timer callback can be queued just before StopTimers disposes its timer, so + // reject it when it belongs to a superseded armed session. + private void CommitInBackground(bool final, int? generation = null) { lock (_gate) { var state = _armed; + if (generation is not null && state?.Generation != generation) + { + return; + } + if (state is null || !state.Edited) { // Inverting to `if (!final) return;` would duplicate the return and split the @@ -566,9 +588,9 @@ private async Task CommitAsync(ArmedState state) // change of intent would be silently learned as a correction. if (!IsLikelyRecognitionFix(suggestion.Original, suggestion.Replacement)) { + // Redact the raw strings: they can contain sensitive target-app text. Trace.WriteLine( - $"[TargetAppLearning] Rejected low-similarity edit '{suggestion.Original}'" - + $" -> '{suggestion.Replacement}' (likely a change of intent)." + "[TargetAppLearning] Rejected low-similarity edit (likely a change of intent)." ); continue; } @@ -589,8 +611,7 @@ private async Task CommitAsync(ArmedState state) if (IsWidening(previous, suggestion.Replacement)) { Trace.WriteLine( - $"[TargetAppLearning] Ignoring widened replacement '{suggestion.Original}'" - + $" -> '{suggestion.Replacement}' (keeping '{previous}')." + "[TargetAppLearning] Ignoring widened replacement (kept earlier value)." ); continue; } @@ -598,10 +619,7 @@ private async Task CommitAsync(ArmedState state) _dictionary.LearnCorrection(suggestion.Original, suggestion.Replacement); state.LearnedByOriginal[suggestion.Original] = suggestion.Replacement; - Trace.WriteLine( - $"[TargetAppLearning] Learned '{suggestion.Original}' -> " - + $"'{suggestion.Replacement}' from target-app edit." - ); + Trace.WriteLine("[TargetAppLearning] Learned a correction from a target-app edit."); } } catch (Exception ex) @@ -647,10 +665,17 @@ private void LogSkipOnce(string message) _errorLog.AddEntry(message, ErrorCategory.Detection); } - private sealed class ArmedState(AtSpiElementRef element, string baseline) + // Ties a timer callback to the owner and the arm generation it was created for. + private readonly record struct TimerToken( + TargetAppCorrectionLearningService Owner, + int Generation + ); + + private sealed class ArmedState(AtSpiElementRef element, string baseline, int generation) { public AtSpiElementRef Element { get; } = element; public string Baseline { get; } = baseline; + public int Generation { get; } = generation; public bool Edited { get; set; } // The last replacement persisted for each original during this armed session. Lets a From ccd714fc52664f4fc08151e36bab9e660002d0b9 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 6 Jul 2026 20:17:07 -0400 Subject: [PATCH 003/226] Address review: trim Dictation settings grid to used rows; re-check opt-out before commit read --- .../TargetAppCorrectionLearningService.cs | 9 ++++++ .../Views/Sections/DictationSection.axaml | 2 +- ...TargetAppCorrectionLearningServiceTests.cs | 30 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs index d4533cc80..397f838b7 100644 --- a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs +++ b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs @@ -568,6 +568,15 @@ private async Task CommitAsync(ArmedState state) return; } + // Re-check opt-out immediately before the text read: a disable between queueing this + // commit and running it must stop us reading the target app's text (mirrors ArmAsync, + // which re-checks before every read). Without this, disabling in that gap still lets + // one more accessibility read (and potentially a learn) slip through. + if (IsOptedOut()) + { + return; + } + var finalText = await _client.TryReadTextAsync(state.Element, MaxTrackedTextLength) .ConfigureAwait(false); if (finalText is null || string.Equals(finalText, state.Baseline, StringComparison.Ordinal)) diff --git a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml index df74980b5..b251559e9 100644 --- a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml @@ -311,7 +311,7 @@ - + diff --git a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs index 9e37d3c1d..e9949824e 100644 --- a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs @@ -420,6 +420,36 @@ public async Task Disable_MidWindow_StopsClientAndLearnsNothing() Assert.Empty(_dictionary.GetCorrections()); } + [Fact] + public async Task Disable_BetweenArmAndCommit_DoesNotReadOrLearn() + { + // The tracking window is open (armed + edited) when the user disables the feature, but + // the listener reconcile has not disarmed yet (Initialize is not wired here, so flipping + // the setting does not run StopAsync/Disarm). The opt-out re-check in CommitAsync must + // bail before reading the field again or learning. + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + var settings = new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ); + using var service = CreateService(client, settings); + + client.TextToReturn = "I deployed to kubernets today"; + await service.ArmAsync("I deployed to kubernets today"); + Assert.Equal(1, client.TextReadCalls); // baseline read only + + // The user fixes the word (arming the edit), then disables before focus-out commits it. + client.TextToReturn = "I deployed to Kubernetes today"; + client.RaiseText(s_field); + settings.Save(AppSettings.Default with { TargetAppCorrectionLearningEnabled = false }); + + // Focus-out schedules a final commit; the opt-out guard must stop it before the read. + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + Assert.Equal(1, client.TextReadCalls); // no second read after opt-out + Assert.Empty(_dictionary.GetCorrections()); + } + [Fact] public async Task Arm_WhenBusUnavailable_NoOps() { From 319204c91417d271777e9620b9f2bd175961a784 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Wed, 8 Jul 2026 18:15:22 -0400 Subject: [PATCH 004/226] Fix target-app correction learning: startup crash + Wayland paste race Pin Tmds.DBus.Protocol to 0.92.0 (0.94.2 renames a type Avalonia.FreeDesktop 12.0.1 loads at startup, throwing TypeLoadException before any window appears) and adapt the AT-SPI observer callbacks to the 0.92.0 signature. Bump the clipboard-restore delay 200->500ms so AT-SPI-slowed GTK/Wayland async paste doesn't lose the race. Add the correction-learning toggle to the Dictation section. --- .../Services/ActiveWindow/AtSpiEventClient.cs | 19 ++--- .../TargetAppCorrectionLearningService.cs | 2 + .../Services/TextInsertionService.cs | 9 ++- .../TypeWhisper.Linux.csproj | 8 +- .../Views/Sections/DictationSection.axaml | 74 +++++++++---------- 5 files changed, 63 insertions(+), 49 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs index a8cca1864..60183c998 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -279,6 +279,7 @@ private async Task TryStartAsync() }, s_readSignal, HandleStateChanged, + ObserverFlags.None, emitOnCapturedContext: false ).ConfigureAwait(false); @@ -291,6 +292,7 @@ private async Task TryStartAsync() }, s_readSignal, HandleTextChanged, + ObserverFlags.None, emitOnCapturedContext: false ).ConfigureAwait(false); @@ -325,17 +327,16 @@ private async Task TryStartAsync() } } - private void HandleStateChanged(Notification notification) + private void HandleStateChanged(Exception? exception, AtSpiSignal signal, object? readerState, object? handlerState) { - // Only value notifications carry a signal. Completion notifications have no value - // (HasValue == false); their Exception must not be read for value notifications, so - // gate on HasValue rather than touching Exception here. - if (!notification.HasValue) + // Only successful reads carry a signal. On error/disconnect the observer is invoked + // with a non-null exception and a default value; skip those rather than acting on an + // empty AtSpiSignal. + if (exception is not null) { return; } - var signal = notification.Value; if ( !string.Equals(signal.Detail, FocusedStateName, StringComparison.Ordinal) || signal.Detail1 != StateGained @@ -367,14 +368,14 @@ private void HandleStateChanged(Notification notification) } } - private void HandleTextChanged(Notification notification) + private void HandleTextChanged(Exception? exception, AtSpiSignal signal, object? readerState, object? handlerState) { - if (!notification.HasValue) + if (exception is not null) { return; } - var element = new AtSpiElementRef(notification.Value.Sender, notification.Value.Path); + var element = new AtSpiElementRef(signal.Sender, signal.Path); if (!element.IsValid) { return; diff --git a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs index 397f838b7..6624c0a68 100644 --- a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs +++ b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs @@ -82,6 +82,8 @@ public sealed class TargetAppCorrectionLearningService : IDisposable // Test seam: the most recently scheduled start/stop reconcile. Reconciles serialize on // _listenGate, so awaiting the last-assigned task guarantees all prior ones have finished. + // ReSharper disable once UnusedAutoPropertyAccessor.Global -- getter is read by + // TargetAppCorrectionLearningServiceTests (cross-project usage the single-project scan can't see). internal Task? LastListenTask { get; private set; } public TargetAppCorrectionLearningService( diff --git a/src/TypeWhisper.Linux/Services/TextInsertionService.cs b/src/TypeWhisper.Linux/Services/TextInsertionService.cs index 794845d26..8b08bf16e 100644 --- a/src/TypeWhisper.Linux/Services/TextInsertionService.cs +++ b/src/TypeWhisper.Linux/Services/TextInsertionService.cs @@ -56,7 +56,14 @@ public sealed class TextInsertionService { private const int PasteAttemptCount = 3; private static readonly TimeSpan s_focusDelay = TimeSpan.FromMilliseconds(100); - private static readonly TimeSpan s_clipboardRestoreDelayDefault = TimeSpan.FromMilliseconds(200); + + // After Ctrl+V we hold our text on the clipboard this long before restoring the user's + // previous content. On Wayland the target reads the clipboard asynchronously, so restoring + // too soon races the paste: the app reads back the restored (old) content and nothing lands. + // 200 ms was marginal for GTK apps and lost the race outright once accessibility is active + // (AT-SPI makes the app do extra per-event work, delaying its clipboard read) — e.g. the + // target-app correction-learning feature. 500 ms comfortably covers GTK4's async paste. + private static readonly TimeSpan s_clipboardRestoreDelayDefault = TimeSpan.FromMilliseconds(500); // KDE Plasma's Klipper races us when restoring the clipboard — the // ~600 ms delay matches what OpenWhispr landed after the same race. diff --git a/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj b/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj index 752f666ce..816bcc8f0 100644 --- a/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj +++ b/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj @@ -54,8 +54,12 @@ - + persistent connection with signal match rules, no per-read subprocess. + Pinned to 0.92.0 to match the version Avalonia.FreeDesktop 12.0.1 loads + at startup: 0.94.2 renamed Tmds.DBus.Protocol.Connection (which Avalonia + calls during X11/IME init) to DBusConnection, so a higher pin makes + Avalonia throw TypeLoadException before the window ever appears. --> + + + + + + + + + + + + + @@ -421,10 +442,10 @@ - - - - @@ -457,10 +478,10 @@ - - - - - - - - @@ -520,10 +541,10 @@ - - @@ -541,10 +562,10 @@ - - @@ -562,27 +583,6 @@ HorizontalAlignment="Right" /> - - - - - - - - - - - - From a9ad99d442fc1daaa7ab8696766ae801f5fb0935 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Wed, 8 Jul 2026 18:58:08 -0400 Subject: [PATCH 005/226] Address CodeRabbit: clarify Tmds.DBus.Protocol pin rationale --- src/TypeWhisper.Linux/TypeWhisper.Linux.csproj | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj b/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj index 816bcc8f0..118ef7845 100644 --- a/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj +++ b/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj @@ -55,10 +55,11 @@ + Pinned to 0.92.0 because that is the version Avalonia.FreeDesktop + 12.0.1 depends on and resolves at startup. Newer releases (e.g. 0.94.2) + change the Tmds.DBus.Protocol types Avalonia loads during X11/IME init, + so a higher pin makes Avalonia throw TypeLoadException before the window + ever appears. --> From 5e07b29908681ef6dbc7755ed1c3b75e7907e32a Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Thu, 9 Jul 2026 06:15:47 -0400 Subject: [PATCH 006/226] Make Wayland clipboard paste reliable when correction learning is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With target-app correction learning enabled, dictated text intermittently failed to paste into GTK apps (e.g. gnome-text-editor) — roughly 2/3 of the time it "pasted into nothing". Enabling the feature registers AT-SPI listeners, which flips every GTK app into emitting a11y events and adds main-loop latency that widened a latent race in the clipboard-paste path. Two-part fix in TextInsertionService: - Verify the clipboard actually serves the dictated text (bounded wl-paste read) before sending Ctrl+V, re-setting the clipboard once if needed. This establishes a happens-before chain so GTK processes the selection offer before the keystroke, and also closes a latent bug where a silently-failed wl-copy pasted the user's previous clipboard into the document. - Restore the previous clipboard only after the paste is confirmed to have landed, via a new IPasteConfirmationSource that watches the AT-SPI text-changed signal (armed before Ctrl+V) instead of a fixed delay that could overwrite an in-flight transfer. Falls back to the existing floor delay when AT-SPI is not running, and an ownership check avoids clobbering a clipboard the user changed meanwhile. The confirmer never starts AT-SPI itself (gated on a new IAtSpiEventClient.IsRunning), so the feature-off path is unchanged. Adds an env-gated (TW_PASTE_DIAG) diagnostic for the paste stages. --- src/TypeWhisper.Linux/ServiceRegistrations.cs | 10 +- .../Services/ActiveWindow/AtSpiEventClient.cs | 2 + .../ActiveWindow/IAtSpiEventClient.cs | 9 + .../Insertion/AtSpiPasteConfirmation.cs | 95 ++++ .../Insertion/IPasteConfirmationSource.cs | 52 +++ .../Services/TextInsertionService.cs | 184 +++++++- ...TargetAppCorrectionLearningServiceTests.cs | 4 + .../TextInsertionServiceTests.cs | 412 +++++++++++++++++- 8 files changed, 752 insertions(+), 16 deletions(-) create mode 100644 src/TypeWhisper.Linux/Services/Insertion/AtSpiPasteConfirmation.cs create mode 100644 src/TypeWhisper.Linux/Services/Insertion/IPasteConfirmationSource.cs diff --git a/src/TypeWhisper.Linux/ServiceRegistrations.cs b/src/TypeWhisper.Linux/ServiceRegistrations.cs index b6193866d..d86155f97 100644 --- a/src/TypeWhisper.Linux/ServiceRegistrations.cs +++ b/src/TypeWhisper.Linux/ServiceRegistrations.cs @@ -85,6 +85,10 @@ public static void Register(IServiceCollection services) // learning service arms a tracking window after each qualifying insertion. services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); + // Event-driven paste confirmation for TextInsertionService's clipboard restore. + // Read-only over the AT-SPI client: it never starts the listeners itself, so the + // insertion path is unchanged unless correction learning already turned them on. + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => @@ -113,7 +117,11 @@ public static void Register(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(sp => new TextInsertionService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + )); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs index 60183c998..bb64c2550 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -125,6 +125,8 @@ public AtSpiElementRef? CurrentFocusedElement } } + public bool IsRunning => _available; + public async Task EnsureStartedAsync() { if (_started) diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs index f86394256..cb925845f 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs @@ -27,6 +27,15 @@ public interface IAtSpiEventClient /// The element that most recently gained focus, or null if none seen yet. AtSpiElementRef? CurrentFocusedElement { get; } + /// + /// true while the client holds a live a11y-bus connection with listeners + /// registered (a successful not yet undone by + /// ). Read-only — never connects; consumers that must not + /// start the listeners themselves (privacy/consent lives with the feature toggle) + /// check this instead of calling . + /// + bool IsRunning { get; } + /// /// Connects to the a11y bus and registers event listeners on first call. /// Returns true when the bus is reachable and listeners are live, diff --git a/src/TypeWhisper.Linux/Services/Insertion/AtSpiPasteConfirmation.cs b/src/TypeWhisper.Linux/Services/Insertion/AtSpiPasteConfirmation.cs new file mode 100644 index 000000000..08cfa088f --- /dev/null +++ b/src/TypeWhisper.Linux/Services/Insertion/AtSpiPasteConfirmation.cs @@ -0,0 +1,95 @@ +using TypeWhisper.Linux.Services.ActiveWindow; + +namespace TypeWhisper.Linux.Services.Insertion; + +/// +/// Confirms a clipboard paste landed by watching the already-running AT-SPI event +/// stream: the first object:text-changed event after Ctrl+V means the target +/// inserted something, so the clipboard restore can proceed immediately instead of +/// sitting out a fixed worst-case delay. The watch must be armed BEFORE the keystroke +/// () — the paste's event fires while Ctrl+V is being +/// processed, so a subscription made in the restore step arrives too late and misses +/// it every time. +/// +/// This class never starts the AT-SPI listeners itself — +/// is a privacy/consent decision owned by the correction-learning feature, and +/// registering listeners is precisely what perturbs GTK's main loop. When the +/// client is not running returns null +/// (indeterminate) immediately, so the feature-off insertion path is unchanged; +/// the confirmer only engages when the feature is already on — the only case the +/// restore race widens AND the only case the events are flowing. +/// +/// +public sealed class AtSpiPasteConfirmation : IPasteConfirmationSource +{ + private readonly IAtSpiEventClient _client; + + public AtSpiPasteConfirmation(IAtSpiEventClient client) + { + _client = client; + } + + public bool? HasFocusedElement => + _client.IsRunning ? _client.CurrentFocusedElement is not null : null; + + public IPasteWatch? BeginWatch() + { + return _client.IsRunning ? new AtSpiPasteWatch(_client) : null; + } + + private sealed class AtSpiPasteWatch : IPasteWatch + { + private readonly IAtSpiEventClient _client; + + private readonly TaskCompletionSource _textChanged = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + internal AtSpiPasteWatch(IAtSpiEventClient client) + { + _client = client; + // Subscribed here — before the caller sends Ctrl+V — so the paste's + // text-changed can never fire unobserved; one that arrives before + // WaitAsync latches in the TCS and the later await completes instantly. + _client.TextChanged += OnTextChanged; + } + + public async Task WaitAsync(TimeSpan timeout, CancellationToken ct) + { + // Already latched between BeginWatch and this call — confirm without + // spinning up the timeout timer at all. + if (_textChanged.Task.IsCompleted) + { + return true; + } + + var completed = await Task.WhenAny(_textChanged.Task, Task.Delay(timeout, ct)) + .ConfigureAwait(false); + if (completed == _textChanged.Task) + { + return true; + } + + // Propagates OperationCanceledException when ct fired; otherwise the window + // elapsed without an event — indeterminate, never false (some targets simply + // don't emit text-changed). + await completed.ConfigureAwait(false); + return null; + } + + public void Dispose() + { + _client.TextChanged -= OnTextChanged; + } + + // First TextChanged from ANY element counts. Do not match against + // CurrentFocusedElement and do not read the text back: focus events sometimes + // yield containers without the Text interface (the `No such interface + // "org.a11y.atspi.Text"` failure), and the text-changed source object routinely + // differs from the focus object. + private void OnTextChanged(AtSpiElementRef _) + { + _textChanged.TrySetResult(true); + } + } +} diff --git a/src/TypeWhisper.Linux/Services/Insertion/IPasteConfirmationSource.cs b/src/TypeWhisper.Linux/Services/Insertion/IPasteConfirmationSource.cs new file mode 100644 index 000000000..264eb53d0 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/Insertion/IPasteConfirmationSource.cs @@ -0,0 +1,52 @@ +namespace TypeWhisper.Linux.Services.Insertion; + +/// +/// Optional source of positive "the paste actually landed" signals. After sending +/// Ctrl+V the insertion path must keep our text on the clipboard until the target +/// app has read it — restoring the user's previous clipboard too early cuts off the +/// in-flight transfer and the app pastes nothing (or the old content). A confirmation +/// source lets that restore be event-driven instead of a fixed delay. +/// +/// Two-phase on purpose: the paste's confirmation event fires while the Ctrl+V +/// keystroke is being processed — before the restore step ever runs — so a +/// subscribe-then-wait inside the restore misses it every time and burns the full +/// timeout. The caller arms a watch via BEFORE sending +/// the keystroke and awaits after; an event +/// that arrives in between is latched by the watch, not lost. +/// +/// +public interface IPasteConfirmationSource +{ + /// + /// Read-only diagnostic: whether the underlying source currently knows a focused + /// element, or null when the source is not running. Logged (env-gated) at + /// Ctrl+V time to judge whether a pre-paste focus gate would ever be needed. + /// + bool? HasFocusedElement { get; } + + /// + /// Starts watching for an insertion signal; call BEFORE sending the paste + /// keystroke. Returns null when the source is not running (feature off) — + /// indeterminate, the caller falls back to its fixed floor delay exactly as if no + /// confirmer were wired. + /// + IPasteWatch? BeginWatch(); +} + +/// +/// A live confirmation watch armed by . +/// Dispose to stop watching — the owner must dispose it on every path so the +/// underlying event subscription never outlives the insertion. +/// +public interface IPasteWatch : IDisposable +{ + /// + /// Waits up to for a positive insertion signal, and + /// completes immediately when one was already latched between + /// and this call. + /// true = insertion positively observed; null = indeterminate + /// (no event within the window). Never returns false: absence of an + /// a11y event is not proof of absence. + /// + Task WaitAsync(TimeSpan timeout, CancellationToken ct); +} diff --git a/src/TypeWhisper.Linux/Services/TextInsertionService.cs b/src/TypeWhisper.Linux/Services/TextInsertionService.cs index 8b08bf16e..8c0f0e208 100644 --- a/src/TypeWhisper.Linux/Services/TextInsertionService.cs +++ b/src/TypeWhisper.Linux/Services/TextInsertionService.cs @@ -69,7 +69,27 @@ public sealed class TextInsertionService // ~600 ms delay matches what OpenWhispr landed after the same race. private static readonly TimeSpan s_clipboardRestoreDelayKde = TimeSpan.FromMilliseconds(600); private static readonly TimeSpan s_pasteRetryDelay = TimeSpan.FromMilliseconds(75); + + // Pre-paste readiness: wl-copy forks a child to own the selection, and until that + // child is actually serving, GTK's async Ctrl+V read finds nothing (or the user's + // stale previous clipboard when wl-copy silently died) and inserts nothing. Verify + // the clipboard serves OUR text before sending the keystroke — happy path is a + // single ~20-50 ms read; the retry delay only accrues while the serve is late. + private const int ClipboardVerifyAttempts = 4; + private static readonly TimeSpan s_clipboardVerifyRetryDelay = TimeSpan.FromMilliseconds(40); + + // How long the event-driven restore waits for a positive "text landed" signal + // before falling back to the fixed floor delay above. + private static readonly TimeSpan s_pasteConfirmTimeout = TimeSpan.FromSeconds(2); + + // Env-gated per-paste diagnostics (TW_PASTE_DIAG=1): verify attempts, restore + // gate (confirmed vs floor) + elapsed, and whether AT-SPI knew a focused element + // at Ctrl+V time — the signal that would justify a future pre-paste focus gate. + private static readonly bool s_pasteDiagEnabled = + Environment.GetEnvironmentVariable("TW_PASTE_DIAG") == "1"; + private readonly IErrorLogService? _errorLog; + private readonly IPasteConfirmationSource? _pasteConfirmation; private readonly ITextInsertionPlatform _platform; @@ -88,19 +108,22 @@ public TextInsertionService(IErrorLogService errorLog) // without this the singleton's chain is frozen at startup and ydotool changes need a restart. public TextInsertionService( IErrorLogService errorLog, - SystemCommandAvailabilityService commands + SystemCommandAvailabilityService commands, + IPasteConfirmationSource? pasteConfirmation = null ) - : this(new LinuxTextInsertionPlatform(commands), errorLog) + : this(new LinuxTextInsertionPlatform(commands), errorLog, pasteConfirmation) { } internal TextInsertionService( ITextInsertionPlatform platform, - IErrorLogService? errorLog = null + IErrorLogService? errorLog = null, + IPasteConfirmationSource? pasteConfirmation = null ) { _platform = platform; _errorLog = errorLog; + _pasteConfirmation = pasteConfirmation; } /// @@ -221,8 +244,32 @@ strategy is TextInsertionStrategy.DirectTyping return InsertionResult.CopiedToClipboard; } + if (!await VerifyClipboardServesAsync(text)) + { + LogInsertionFallback( + "Auto paste fell back to clipboard: the clipboard never served the dictated text, " + + "so Ctrl+V was not sent (it would have pasted nothing or stale content)." + ); + return InsertionResult.CopiedToClipboard; + } + + if (s_pasteDiagEnabled) + { + var focusKnown = _pasteConfirmation?.HasFocusedElement; + PasteDiag( + $"focused element known at Ctrl+V: {focusKnown?.ToString() ?? "n/a (AT-SPI not running)"}" + ); + } + + // Arm the confirmation watch BEFORE the keystroke: the target's text-changed + // fires while Ctrl+V is being processed, so a subscription made in the restore + // step (after the paste) misses it every time and waits out the full timeout. + var pasteWatch = _pasteConfirmation?.BeginWatch(); + if (!await TrySendPasteAsync()) { + pasteWatch?.Dispose(); + // Prefer the platform's diagnostic (e.g. "compositor unsupported") // over the generic retries-exhausted reason. if (LastFailureReason == InsertionFailureReason.None) @@ -241,7 +288,9 @@ strategy is TextInsertionStrategy.DirectTyping LogInsertionFallback("Auto paste sent Ctrl+V, but Enter could not be sent."); } - await RestorePreviousClipboardAsync(previousClipboard); + // Awaited inline (not fire-and-forget) so rapid consecutive dictations stay + // serialized: the next insertion's clipboard snapshot must not race this restore. + await RestorePreviousClipboardAsync(text, previousClipboard, pasteWatch); return InsertionResult.Pasted; } @@ -353,22 +402,119 @@ private async Task FocusTargetWindowAsync(string? targetWindowId) return focusRequested || _platform.GetActiveWindowId() == targetWindowId; } - private async Task RestorePreviousClipboardAsync(string? previousClipboard) + /// + /// Confirms the clipboard actually serves before we + /// send Ctrl+V, with one clipboard re-set + re-verify when the first pass fails + /// (wl-copy occasionally dies before its serving child takes over the selection). + /// + private async Task VerifyClipboardServesAsync(string expected) { - var delay = _platform.IsKdePlasma ? s_clipboardRestoreDelayKde : s_clipboardRestoreDelayDefault; - await _platform.DelayAsync(delay); - if (previousClipboard is null) + if (await WaitForClipboardToServeAsync(expected)) { - return; + return true; } - try + PasteDiag("clipboard verify exhausted; re-setting clipboard once"); + return await _platform.SetClipboardTextAsync(expected) + && await WaitForClipboardToServeAsync(expected); + } + + private async Task WaitForClipboardToServeAsync(string expected) + { + for (var attempt = 0; attempt < ClipboardVerifyAttempts; attempt++) { - await _platform.SetClipboardTextAsync(previousClipboard); + if (attempt > 0) + { + await _platform.DelayAsync(s_clipboardVerifyRetryDelay); + } + + // wl-paste may append a trailing newline the write never had — compare + // content modulo that, matching the ownership check in the restore below. + var read = await _platform.TryGetClipboardTextAsync(); + if ( + read is not null + && string.Equals( + read.TrimEnd('\n'), + expected.TrimEnd('\n'), + StringComparison.Ordinal + ) + ) + { + PasteDiag($"clipboard verified serving on attempt {attempt + 1}"); + return true; + } } - catch + + PasteDiag($"clipboard verify failed after {ClipboardVerifyAttempts} attempts"); + return false; + } + + private async Task RestorePreviousClipboardAsync( + string pastedText, + string? previousClipboard, + IPasteWatch? watch + ) + { + if (previousClipboard is null) { - /* best effort restore */ + // Nothing to restore — no restore write can cut off the in-flight paste, + // so there is nothing to wait for either. Still drop the watch armed + // before Ctrl+V: its event subscription must not outlive the insertion. + watch?.Dispose(); + return; + } + + using (watch) + { + var stopwatch = s_pasteDiagEnabled ? Stopwatch.StartNew() : null; + + // Event-driven gate: a positive "text landed" signal means the target has read + // the clipboard, so restoring now cannot cut off the transfer. The watch was + // armed before the keystroke, so a text-changed that already fired is latched + // and confirms instantly. Indeterminate (no watch — confirmer absent or AT-SPI + // idle — or no event within the window) falls back to the fixed floor delay + // that previously bounded this race on its own. + var confirmed = + watch is not null + && await watch.WaitAsync(s_pasteConfirmTimeout, CancellationToken.None) == true; + if (!confirmed) + { + await _platform.DelayAsync( + _platform.IsKdePlasma + ? s_clipboardRestoreDelayKde + : s_clipboardRestoreDelayDefault + ); + } + + PasteDiag( + $"restore gate: {(confirmed ? "confirmed" : "floor")} after {stopwatch?.ElapsedMilliseconds ?? 0} ms" + ); + + // Ownership check: only restore when the clipboard still holds OUR text — + // content equality, not identity, since Wayland re-serves can differ by a + // trailing newline. If another app replaced it meanwhile, restoring would + // clobber the user's newer copy. + var current = await _platform.TryGetClipboardTextAsync(); + if ( + current is not null + && !string.Equals( + current.TrimEnd('\n'), + pastedText.TrimEnd('\n'), + StringComparison.Ordinal + ) + ) + { + return; + } + + try + { + await _platform.SetClipboardTextAsync(previousClipboard); + } + catch + { + /* best effort restore */ + } } } @@ -554,6 +700,18 @@ private static bool IsAsciiSafe(string text) return true; } + /// + /// Env-gated (TW_PASTE_DIAG=1) per-paste diagnostic trace. Off by default so the + /// hot path stays silent; used to validate the paste-readiness fix in the field. + /// + private static void PasteDiag(string message) + { + if (s_pasteDiagEnabled) + { + Trace.WriteLine($"[PasteDiag] {message}"); + } + } + private void LogInsertionFallback(string message) { Trace.WriteLine($"[TextInsertionService] {message}"); diff --git a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs index e9949824e..99c444435 100644 --- a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs @@ -593,6 +593,8 @@ private sealed class FakeAtSpiEventClient : IAtSpiEventClient public event Action? FocusChanged; public event Action? TextChanged; + public bool IsRunning { get; private set; } + public async Task EnsureStartedAsync() { EnsureStartedCalls++; @@ -601,12 +603,14 @@ public async Task EnsureStartedAsync() await StartGate.Task.ConfigureAwait(false); } + IsRunning = Available; return Available; } public Task StopAsync() { StopCalls++; + IsRunning = false; return Task.CompletedTask; } diff --git a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs index ed739343d..a046e5164 100644 --- a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs @@ -1,5 +1,7 @@ using TypeWhisper.Core.Models; using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.ActiveWindow; +using TypeWhisper.Linux.Services.Insertion; using Xunit; namespace TypeWhisper.Linux.Tests; @@ -32,7 +34,8 @@ public async Task InsertTextAsync_retries_failed_paste_before_fallback() Clipboard = "previous", PasteSucceeds = false }; - var sut = new TextInsertionService(platform); + var confirmation = new FakePasteConfirmationSource { Result = true }; + var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); var result = await sut.InsertTextAsync("new text"); @@ -40,6 +43,10 @@ public async Task InsertTextAsync_retries_failed_paste_before_fallback() Assert.Equal("new text", platform.Clipboard); Assert.True(platform.PasteSent); Assert.Equal(3, platform.PasteAttemptCount); + // The pre-armed watch is dropped unconsulted when the paste never went out. + Assert.NotNull(confirmation.LastWatch); + Assert.False(confirmation.LastWatch.WaitCalled); + Assert.True(confirmation.LastWatch.Disposed); } [Fact] @@ -59,6 +66,287 @@ public async Task InsertTextAsync_successful_retry_restores_previous_clipboard() Assert.Equal(3, platform.PasteAttemptCount); } + [Fact] + public async Task InsertTextAsync_verifies_clipboard_serves_before_paste_and_retries_read() + { + // wl-copy's serving child isn't up yet: the first verify read still returns the + // OLD clipboard. The bounded verify loop must retry the read (not the write) and + // proceed once the clipboard serves the new text. + var platform = new FakeTextInsertionPlatform + { + Clipboard = "previous", + PasteSucceeds = true, + ClipboardReadResults = new Queue( + [ + "previous", // snapshot of the user's clipboard + "previous", // verify attempt 1 — wl-copy not serving yet + "new text" // verify attempt 2 — serving + ] + ) + }; + var sut = new TextInsertionService(platform); + + var result = await sut.InsertTextAsync("new text"); + + Assert.Equal(InsertionResult.Pasted, result); + Assert.Equal(1, platform.PasteAttemptCount); + // One inter-attempt verify delay ran; no second clipboard write was needed + // before the paste (initial set + post-paste restore only). + Assert.Contains(TimeSpan.FromMilliseconds(40), platform.Delays); + Assert.Equal(2, platform.SetClipboardCount); + Assert.Equal("previous", platform.Clipboard); + } + + [Fact] + public async Task InsertTextAsync_verify_failure_resets_clipboard_once_then_proceeds() + { + // The whole first verify pass fails (wl-copy died before serving); the one + // re-set + re-verify recovers and the paste still goes out. + var platform = new FakeTextInsertionPlatform + { + Clipboard = "previous", + PasteSucceeds = true, + ClipboardReadResults = new Queue( + [ + "previous", // snapshot + "previous", "previous", "previous", "previous", // verify pass 1 — all stale + "new text" // verify pass 2 after the re-set — serving + ] + ) + }; + var sut = new TextInsertionService(platform); + + var result = await sut.InsertTextAsync("new text"); + + Assert.Equal(InsertionResult.Pasted, result); + Assert.Equal(1, platform.PasteAttemptCount); + // initial set + one verify re-set + post-paste restore + Assert.Equal(3, platform.SetClipboardCount); + Assert.Equal("previous", platform.Clipboard); + } + + [Fact] + public async Task InsertTextAsync_verify_never_serves_skips_paste_and_falls_back_to_clipboard() + { + // A silently broken wl-copy means Ctrl+V would paste the user's stale previous + // clipboard. The verify gate must swallow the paste entirely and report the + // clipboard fallback instead. + var platform = new FakeTextInsertionPlatform + { + Clipboard = "previous", + PasteSucceeds = true, + ClipboardReadResults = new Queue( + [ + "previous", // snapshot + "previous", "previous", "previous", "previous", // verify pass 1 + "previous", "previous", "previous", "previous" // verify pass 2 after re-set + ] + ) + }; + var sut = new TextInsertionService(platform); + + var result = await sut.InsertTextAsync("new text"); + + Assert.Equal(InsertionResult.CopiedToClipboard, result); + Assert.False(platform.PasteSent); + // initial set + the single re-set retry — no restore write after the fallback + Assert.Equal(2, platform.SetClipboardCount); + } + + [Fact] + public async Task InsertTextAsync_confirmed_paste_restores_immediately_without_floor_delay() + { + var platform = new FakeTextInsertionPlatform + { + Clipboard = "previous", + PasteSucceeds = true + }; + var confirmation = new FakePasteConfirmationSource { Result = true }; + var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); + + var result = await sut.InsertTextAsync("new text"); + + Assert.Equal(InsertionResult.Pasted, result); + Assert.NotNull(confirmation.LastWatch); + Assert.True(confirmation.LastWatch.WaitCalled); + Assert.True(confirmation.LastWatch.Disposed); + Assert.Equal("previous", platform.Clipboard); + // Positive confirmation must skip the fixed restore floor entirely. + Assert.DoesNotContain(TimeSpan.FromMilliseconds(500), platform.Delays); + Assert.DoesNotContain(TimeSpan.FromMilliseconds(600), platform.Delays); + } + + [Fact] + public async Task InsertTextAsync_arms_paste_watch_before_sending_ctrl_v() + { + // Regression guard for the timing bug: the confirmer used to subscribe inside + // the restore step — AFTER Ctrl+V — so the paste's text-changed had already + // fired unobserved and every restore burned the full confirmation timeout. + var order = new List(); + var platform = new FakeTextInsertionPlatform + { + Clipboard = "previous", + PasteSucceeds = true, + OnPasteSent = () => order.Add("ctrl-v") + }; + var confirmation = new FakePasteConfirmationSource + { + Result = true, + OnBeginWatch = () => order.Add("begin-watch") + }; + var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); + + var result = await sut.InsertTextAsync("new text"); + + Assert.Equal(InsertionResult.Pasted, result); + Assert.Equal(new[] { "begin-watch", "ctrl-v" }, order); + } + + [Fact] + public async Task InsertTextAsync_text_changed_during_paste_is_latched_and_confirms_immediately() + { + // End-to-end through the real AtSpiPasteConfirmation: the target's text-changed + // arrives while Ctrl+V is being processed — before the restore step ever awaits + // the watch. The pre-armed watch must have latched it, so the restore confirms + // instantly instead of waiting out the timeout and then floor-delaying anyway. + var client = new FakeAtSpiEventClient(); + var platform = new FakeTextInsertionPlatform + { + Clipboard = "previous", + PasteSucceeds = true + }; + platform.OnPasteSent = () => + client.RaiseTextChanged(new AtSpiElementRef(":1.7", "/org/a11y/atspi/accessible/42")); + var sut = new TextInsertionService( + platform, + pasteConfirmation: new AtSpiPasteConfirmation(client) + ); + + var result = await sut.InsertTextAsync("new text"); + + Assert.Equal(InsertionResult.Pasted, result); + Assert.Equal("previous", platform.Clipboard); + Assert.DoesNotContain(TimeSpan.FromMilliseconds(500), platform.Delays); + Assert.DoesNotContain(TimeSpan.FromMilliseconds(600), platform.Delays); + // The disposed watch left no dangling subscription on the client. + Assert.False(client.HasTextChangedSubscribers); + } + + [Fact] + public async Task InsertTextAsync_without_confirmer_uses_floor_delay_then_restores() + { + var platform = new FakeTextInsertionPlatform + { + Clipboard = "previous", + PasteSucceeds = true + }; + var sut = new TextInsertionService(platform); + + var result = await sut.InsertTextAsync("new text"); + + Assert.Equal(InsertionResult.Pasted, result); + Assert.Contains(TimeSpan.FromMilliseconds(500), platform.Delays); + Assert.Equal("previous", platform.Clipboard); + } + + [Fact] + public async Task InsertTextAsync_indeterminate_confirmation_uses_floor_delay_then_restores() + { + // The confirmer is wired but AT-SPI is idle (feature off) — BeginWatch returns + // null, and the restore must behave exactly like the pre-existing fixed-delay path. + var platform = new FakeTextInsertionPlatform + { + Clipboard = "previous", + PasteSucceeds = true + }; + var confirmation = new FakePasteConfirmationSource { SourceNotRunning = true }; + var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); + + var result = await sut.InsertTextAsync("new text"); + + Assert.Equal(InsertionResult.Pasted, result); + Assert.True(confirmation.BeginWatchCalled); + Assert.Null(confirmation.LastWatch); + Assert.Contains(TimeSpan.FromMilliseconds(500), platform.Delays); + Assert.Equal("previous", platform.Clipboard); + } + + [Fact] + public async Task InsertTextAsync_watch_timeout_uses_floor_delay_then_restores() + { + // AT-SPI is running but the target never emitted text-changed within the + // confirmation window — indeterminate, so the floor delay still applies. + var platform = new FakeTextInsertionPlatform + { + Clipboard = "previous", + PasteSucceeds = true + }; + var confirmation = new FakePasteConfirmationSource { Result = null }; + var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); + + var result = await sut.InsertTextAsync("new text"); + + Assert.Equal(InsertionResult.Pasted, result); + Assert.NotNull(confirmation.LastWatch); + Assert.True(confirmation.LastWatch.WaitCalled); + Assert.Equal(TimeSpan.FromSeconds(2), confirmation.LastWatch.LastTimeout); + Assert.True(confirmation.LastWatch.Disposed); + Assert.Contains(TimeSpan.FromMilliseconds(500), platform.Delays); + Assert.Equal("previous", platform.Clipboard); + } + + [Fact] + public async Task InsertTextAsync_skips_restore_when_clipboard_no_longer_holds_our_text() + { + // Between Ctrl+V and the restore the user copied something themselves — + // restoring the old snapshot now would clobber their newer copy. + var platform = new FakeTextInsertionPlatform + { + Clipboard = "previous", + PasteSucceeds = true, + ClipboardReadResults = new Queue( + [ + "previous", // snapshot + "new text", // verify — serving + "user copied meanwhile" // ownership check before restore + ] + ) + }; + var confirmation = new FakePasteConfirmationSource { Result = true }; + var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); + + var result = await sut.InsertTextAsync("new text"); + + Assert.Equal(InsertionResult.Pasted, result); + // No restore write happened: only the initial set. + Assert.Equal(1, platform.SetClipboardCount); + Assert.Equal("new text", platform.Clipboard); + } + + [Fact] + public async Task InsertTextAsync_null_previous_clipboard_skips_wait_and_restore() + { + // Nothing to restore means no restore write can cut off the in-flight paste — + // the service must return without awaiting the watch or delaying, but must + // still dispose the pre-armed watch so its subscription doesn't leak. + var platform = new FakeTextInsertionPlatform + { + Clipboard = null, + PasteSucceeds = true + }; + var confirmation = new FakePasteConfirmationSource { Result = true }; + var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); + + var result = await sut.InsertTextAsync("new text"); + + Assert.Equal(InsertionResult.Pasted, result); + Assert.NotNull(confirmation.LastWatch); + Assert.False(confirmation.LastWatch.WaitCalled); + Assert.True(confirmation.LastWatch.Disposed); + Assert.DoesNotContain(TimeSpan.FromMilliseconds(500), platform.Delays); + Assert.Equal("new text", platform.Clipboard); + } + [Fact] public async Task InsertTextAsync_copy_only_sets_clipboard_without_restore() { @@ -1291,6 +1579,16 @@ private sealed class FakeTextInsertionPlatform : ITextInsertionPlatform public string? SelectionText { get; init; } public bool CopySucceeds { get; init; } = true; + // When non-null, each TryGetClipboardTextAsync dequeues the next scripted read + // (models wl-paste racing wl-copy: reads that lag behind what was just set). + // An exhausted queue falls back to the live Clipboard value. + public Queue? ClipboardReadResults { get; init; } + public int SetClipboardCount { get; private set; } + + // Every DelayAsync is recorded so tests can assert which waits ran + // (e.g. the 500 ms restore floor must be skipped on a confirmed paste). + public List Delays { get; } = []; + public bool IsClipboardSetAvailable => ClipboardSetAvailable; public bool IsPasteAvailable => PasteAvailable; @@ -1303,17 +1601,21 @@ private sealed class FakeTextInsertionPlatform : ITextInsertionPlatform public Task TryGetClipboardTextAsync() { - return Task.FromResult(Clipboard); + return Task.FromResult( + ClipboardReadResults is { Count: > 0 } ? ClipboardReadResults.Dequeue() : Clipboard + ); } public Task SetClipboardTextAsync(string text) { + SetClipboardCount++; Clipboard = text; return Task.FromResult(true); } public Task DelayAsync(TimeSpan delay) { + Delays.Add(delay); return Task.CompletedTask; } @@ -1332,10 +1634,16 @@ public Task ActivateWindowAsync(string windowId) return Task.FromResult(ActivateSucceeds); } + // Invoked on every SendPasteAsync — lets tests record ordering relative to the + // paste keystroke (the confirmation watch must be armed before it) or raise an + // AT-SPI event "during" the paste. + public Action? OnPasteSent { get; set; } + public Task SendPasteAsync() { PasteSent = true; PasteAttemptCount++; + OnPasteSent?.Invoke(); return Task.FromResult( PasteResults?.Count > 0 ? PasteResults.Dequeue() : PasteSucceeds ); @@ -1372,4 +1680,104 @@ public Task SendEnterAsync() return Task.FromResult(true); } } + + private sealed class FakePasteConfirmationSource : IPasteConfirmationSource + { + // Scripted outcome of the vended watch: true = insertion observed; null = + // indeterminate (window elapsed). Never false — mirrors the contract. + public bool? Result { get; init; } + + // When true, BeginWatch returns null — models the AT-SPI client not running. + public bool SourceNotRunning { get; init; } + + // Invoked from BeginWatch so ordering tests can record when arming happened + // relative to the platform's paste call. + public Action? OnBeginWatch { get; init; } + + public bool BeginWatchCalled { get; private set; } + public FakePasteWatch? LastWatch { get; private set; } + + public bool? HasFocusedElement { get; init; } + + public IPasteWatch? BeginWatch() + { + BeginWatchCalled = true; + OnBeginWatch?.Invoke(); + if (SourceNotRunning) + { + return null; + } + + LastWatch = new FakePasteWatch { Result = Result }; + return LastWatch; + } + } + + private sealed class FakePasteWatch : IPasteWatch + { + public bool? Result { get; init; } + public bool WaitCalled { get; private set; } + public bool Disposed { get; private set; } + public TimeSpan LastTimeout { get; private set; } + + public Task WaitAsync(TimeSpan timeout, CancellationToken ct) + { + WaitCalled = true; + LastTimeout = timeout; + return Task.FromResult(Result); + } + + public void Dispose() + { + Disposed = true; + } + } + + /// + /// Minimal AT-SPI client fake for driving the real : + /// always reports running and lets a test raise at a + /// chosen moment (e.g. mid-paste, before the restore step awaits the watch). + /// + private sealed class FakeAtSpiEventClient : IAtSpiEventClient + { + // Interface-required; the paste confirmer never subscribes to focus changes. + public event Action? FocusChanged + { + add { } + remove { } + } + + public event Action? TextChanged; + + public AtSpiElementRef? CurrentFocusedElement => null; + + public bool IsRunning => true; + + public bool HasTextChangedSubscribers => TextChanged is not null; + + public Task EnsureStartedAsync() + { + return Task.FromResult(true); + } + + public Task StopAsync() + { + return Task.CompletedTask; + } + + public Task TryReadTextAsync(AtSpiElementRef element, int maxLength) + { + return Task.FromResult(null); + } + + public Task IsPasswordFieldAsync(AtSpiElementRef element) + { + return Task.FromResult(null); + } + + public void RaiseTextChanged(AtSpiElementRef element) + { + TextChanged?.Invoke(element); + } + } } \ No newline at end of file From e6b21dde0ffa510a0bdbcbe851d7c056bd559db2 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Thu, 9 Jul 2026 06:16:51 -0400 Subject: [PATCH 007/226] Keep benign AT-SPI text-read failures out of the error log Correction learning reads the focused field's text via AT-SPI on a best-effort basis. Targets that don't implement org.a11y.atspi.Text (terminals, TUIs, Claude Code) or whose accessible vanishes between the focus signal and the read throw benign D-Bus errors (InvalidArgs "No such interface", UnknownObject, etc.). These are expected "can't learn from this app" outcomes, not TypeWhisper faults, but they were surfacing in the user-facing error log as "AT-SPI text read failed". Classify the well-known unreadable-target / gone / unresponsive D-Bus error names and route them to Trace only; genuinely unexpected failures still log. --- .../Services/ActiveWindow/AtSpiEventClient.cs | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs index bb64c2550..8159fb92a 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -212,7 +212,22 @@ public async Task StopAsync() } catch (Exception ex) { - LogOnce($"AT-SPI text read failed: {ex.Message}"); + // Reading the focused field is best-effort: many targets simply don't implement the + // AT-SPI Text interface (terminals, TUIs, Claude Code), or the accessible disappears + // between the focus signal and the read. Those surface as benign D-Bus errors and mean + // "can't learn from this app" — not a TypeWhisper fault — so keep them out of the + // user-facing error log (Trace only). Genuinely unexpected failures still log once. + if (IsExpectedUnreadableTarget(ex)) + { + Trace.WriteLine( + $"[AtSpiEventClient] AT-SPI text read skipped (target has no readable text): {ex.Message}" + ); + } + else + { + LogOnce($"AT-SPI text read failed: {ex.Message}"); + } + return null; } } @@ -484,6 +499,38 @@ int end return await conn.CallMethodAsync(message, s_readString).ConfigureAwait(false); } + // The D-Bus error name leads the reply exception's message, e.g. + // "org.freedesktop.DBus.Error.InvalidArgs: No such interface ...". These names are stable + // wire-protocol constants (not localized), so a prefix match on the guarded exception is safe. + private static readonly string[] s_benignReadErrorNames = + [ + "org.freedesktop.DBus.Error.InvalidArgs", // element has no Text interface + "org.freedesktop.DBus.Error.UnknownObject", // accessible vanished after focus + "org.freedesktop.DBus.Error.UnknownInterface", + "org.freedesktop.DBus.Error.UnknownMethod", + "org.freedesktop.DBus.Error.ServiceUnknown", // app's a11y bridge went away + "org.freedesktop.DBus.Error.NoReply", // app busy / not responding + "org.freedesktop.DBus.Error.Disconnected" + ]; + + // AT-SPI text reads run against whatever third-party app holds focus, so failure is expected, + // not exceptional: terminals / TUIs / Claude Code don't implement org.a11y.atspi.Text, and an + // accessible can vanish between the focus signal and the read. Tmds surfaces these as + // DBusErrorReplyException; the well-known "not readable / gone / unresponsive" names are benign. + private static bool IsExpectedUnreadableTarget(Exception ex) + { + if (ex is not DBusErrorReplyException) + { + return false; + } + + var message = ex.Message; + return Array.Exists( + s_benignReadErrorNames, + name => message.StartsWith(name, StringComparison.Ordinal) + ); + } + private void LogOnce(string message) { Trace.WriteLine($"[AtSpiEventClient] {message}"); From b5d0a527ac0da4b798b3e9868233023315fa007c Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Thu, 9 Jul 2026 14:34:16 -0400 Subject: [PATCH 008/226] Implement per-word splitting in SplitAtLearnedWords to better handle edits involving learned and new words; add extensive tests for correction splitting, merging, and edge cases; include fixes for text insertion order assertions. Files changed: - src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs (added SplitAtLearnedWords logic) - tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs (new tests for correction splitting and merging) - tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs (fixed order assertion syntax) --- .../TargetAppCorrectionLearningService.cs | 129 +++++++++- ...TargetAppCorrectionLearningServiceTests.cs | 229 ++++++++++++++++++ .../TextInsertionServiceTests.cs | 12 +- 3 files changed, 359 insertions(+), 11 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs index 6624c0a68..42fa4dbe1 100644 --- a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs +++ b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs @@ -361,6 +361,117 @@ private static int LevenshteinDistance(string a, string b) return previous[b.Length]; } + // Separates an edit already learned earlier in this armed session from a genuinely new one. A + // frozen baseline makes a later commit re-diff the ORIGINAL insertion, so once + // "kharrington -> Carrington" is known, fixing an adjacent word comes back fused as + // "Chris kharrington -> Curris Carrington"; this drops the settled part and keeps only the new + // edit. Absent any settled word the suggestion is returned intact, so a genuine phrase or + // merge/split fix (e.g. "type whisper" -> "TypeWhisper") is never speculatively broken up. + private static List<(string Original, string Replacement)> SplitAtLearnedWords( + CorrectionSuggestion suggestion, + Dictionary learned + ) + { + var originals = suggestion.Original.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var replacements = suggestion.Replacement.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + // Drop anchor words from the START/END of the span. Done for ANY token counts (front pairs + // with front, back with back), so a settled prior edit next to an otherwise unalignable + // merge/split fix is removed instead of fusing into it — and any unchanged connector it + // exposes at the new edge (e.g. "type whisper in" -> "TypeWhisper in") is trimmed too. + var startO = 0; + var startR = 0; + var endO = originals.Length - 1; + var endR = replacements.Length - 1; + while (startO <= endO && startR <= endR && IsAnchor(originals[startO], replacements[startR])) + { + startO++; + startR++; + } + + while (endO >= startO && endR >= startR && IsAnchor(originals[endO], replacements[endR])) + { + endO--; + endR--; + } + + var remOriginals = originals[startO..(endO + 1)]; + var remReplacements = replacements[startR..(endR + 1)]; + + // Only an equal-length multi-word remainder can be aligned position-by-position; a word + // merge/split (unequal counts) or a single word is returned whole, minus any no-op the edge + // trim may have left behind. + if (remOriginals.Length < 2 || remOriginals.Length != remReplacements.Length) + { + var original = string.Join(' ', remOriginals); + var replacement = string.Join(' ', remReplacements); + return original.Length > 0 + && replacement.Length > 0 + && !string.Equals(original, replacement, StringComparison.Ordinal) + ? [(original, replacement)] + : []; + } + + // Split the remainder into segments at any interior already-learned word. Each segment is + // emitted as ONE atomic correction after trimming unchanged connector words off its ends + // (interior ones are kept, so "kubernets in cluster" -> "Kubernetes in clusters" stays + // whole). With no learned word inside, the remainder is a single segment. + var result = new List<(string, string)>(); + var i = 0; + while (i < remOriginals.Length) + { + if (IsSettled(remOriginals[i], remReplacements[i])) + { + i++; + continue; + } + + var start = i; + while (i < remOriginals.Length && !IsSettled(remOriginals[i], remReplacements[i])) + { + i++; + } + + var lo = start; + var hi = i - 1; + while (lo <= hi && IsUnchanged(lo)) + { + lo++; + } + + while (hi >= lo && IsUnchanged(hi)) + { + hi--; + } + + if (lo <= hi) + { + result.Add( + ( + string.Join(' ', remOriginals[lo..(hi + 1)]), + string.Join(' ', remReplacements[lo..(hi + 1)]) + ) + ); + } + } + + return result; + + bool IsSettled(string original, string replacement) => + learned.TryGetValue(original, out var known) + && string.Equals(known, replacement, StringComparison.Ordinal); + + // An edge token that is not part of a new edit: either already learned this session, or a + // truly unchanged connector (Ordinal, so a case-only change still counts as an edit). + bool IsAnchor(string original, string replacement) => + IsSettled(original, replacement) + || string.Equals(original, replacement, StringComparison.Ordinal); + + // A truly unchanged token: a diff anchor, never part of a correction. + bool IsUnchanged(int index) => + string.Equals(remOriginals[index], remReplacements[index], StringComparison.Ordinal); + } + /// /// Pure gate for whether a dictation insertion should arm target-app learning: /// the feature is on, the text went into the field directly (typed or pasted — not @@ -591,13 +702,19 @@ private async Task CommitAsync(ArmedState state) finalText ); foreach (var suggestion in suggestions) + // De-fuse any edit already learned this session from the genuinely new one (see + // SplitAtLearnedWords) before applying the gates below. + foreach (var (original, replacement) in SplitAtLearnedWords( + suggestion, + state.LearnedByOriginal + )) { // Silent auto-learn holds a higher bar than the review-first history flow: only // persist when the replacement is a plausible recognition/spelling fix of the // original. CorrectionSuggestionService only rejects majority rewrites once the // total token count exceeds 3, so without this a short "call mom" -> "email dad" // change of intent would be silently learned as a correction. - if (!IsLikelyRecognitionFix(suggestion.Original, suggestion.Replacement)) + if (!IsLikelyRecognitionFix(original, replacement)) { // Redact the raw strings: they can contain sensitive target-app text. Trace.WriteLine( @@ -606,12 +723,12 @@ private async Task CommitAsync(ArmedState state) continue; } - if (state.LearnedByOriginal.TryGetValue(suggestion.Original, out var previous)) + if (state.LearnedByOriginal.TryGetValue(original, out var previous)) { // Identical to what we already learned this session — skip so a non-final // idle commit followed by an identical final commit doesn't inflate // TimesCorrected/UsageCount. - if (string.Equals(previous, suggestion.Replacement, StringComparison.Ordinal)) + if (string.Equals(previous, replacement, StringComparison.Ordinal)) { continue; } @@ -619,7 +736,7 @@ private async Task CommitAsync(ArmedState state) // The user kept typing words after the correction was already complete, so // the diff now appends them to the replacement (e.g. "Kubernetes" then // "Kubernetes now"). Keep the earlier, correct value rather than widen it. - if (IsWidening(previous, suggestion.Replacement)) + if (IsWidening(previous, replacement)) { Trace.WriteLine( "[TargetAppLearning] Ignoring widened replacement (kept earlier value)." @@ -628,8 +745,8 @@ private async Task CommitAsync(ArmedState state) } } - _dictionary.LearnCorrection(suggestion.Original, suggestion.Replacement); - state.LearnedByOriginal[suggestion.Original] = suggestion.Replacement; + _dictionary.LearnCorrection(original, replacement); + state.LearnedByOriginal[original] = replacement; Trace.WriteLine("[TargetAppLearning] Learned a correction from a target-app edit."); } } diff --git a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs index 99c444435..c3a3310ef 100644 --- a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs @@ -219,6 +219,235 @@ public async Task Arm_IdleCommit_ThenMoreWordsTyped_DoesNotWidenReplacement() Assert.Equal("Kubernetes", correction.Replacement); } + [Fact] + public async Task Arm_TwoAdjacentWordsCorrectedInSequence_LearnsThemSeparately() + { + // Regression: fix one misrecognized name (an idle commit learns it), then fix the adjacent + // name. The second commit re-diffs the unchanged baseline, so BOTH words now differ from it + // — without per-word splitting they fused into one "Chris kharrington" -> "Curris + // Carrington" entry. + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService( + client, + new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ), + idleCommitDelay: TimeSpan.FromMilliseconds(50) + ); + + client.TextToReturn = "Kim Chris kharrington Quinn"; + await service.ArmAsync("Kim Chris kharrington Quinn"); + + // Fix the second name first; an idle commit learns kharrington -> Carrington. + client.TextToReturn = "Kim Chris Carrington Quinn"; + client.RaiseText(s_field); + await AwaitScheduledCommit(service); + Assert.Equal("Carrington", Assert.Single(_dictionary.GetCorrections()).Replacement); + + // Now fix the first name and move on. The diff against the original baseline shows both + // names changed, but only the genuinely new edit should be learned. + client.TextToReturn = "Kim Curris Carrington Quinn"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + var corrections = _dictionary.GetCorrections(); + Assert.Equal(2, corrections.Count); + Assert.Contains(corrections, c => c is { Original: "Chris", Replacement: "Curris" }); + Assert.Contains(corrections, c => c is { Original: "kharrington", Replacement: "Carrington" }); + // The fused multi-word entry must never be created. + Assert.DoesNotContain(corrections, c => c.Original.Contains(' ')); + } + + [Fact] + public async Task Arm_LearnedAndNewEditSeparatedByUnchangedWord_LearnsOnlyTheNewWord() + { + // After an idle commit learns kharrington -> Carrington, the user fixes a second word with + // an UNCHANGED word ("in") between them. The re-diff spans all three, but the unchanged + // anchor must be dropped: we learn smyth -> Smith, not "in smyth" -> "in Smith", and never + // a no-op "in" -> "in". + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService( + client, + new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ), + idleCommitDelay: TimeSpan.FromMilliseconds(50) + ); + + client.TextToReturn = "please note kharrington in smyth right here"; + await service.ArmAsync("please note kharrington in smyth right here"); + + client.TextToReturn = "please note Carrington in smyth right here"; + client.RaiseText(s_field); + await AwaitScheduledCommit(service); + Assert.Equal("Carrington", Assert.Single(_dictionary.GetCorrections()).Replacement); + + client.TextToReturn = "please note Carrington in Smith right here"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + var corrections = _dictionary.GetCorrections(); + Assert.Equal(2, corrections.Count); + Assert.Contains(corrections, c => c is { Original: "kharrington", Replacement: "Carrington" }); + Assert.Contains(corrections, c => c is { Original: "smyth", Replacement: "Smith" }); + Assert.DoesNotContain(corrections, c => c.Original.Contains(' ')); + Assert.DoesNotContain(corrections, c => c.Original == c.Replacement); + } + + [Fact] + public async Task Arm_MergeFixAdjacentToLearnedWord_DropsLearnedWordFromPhrase() + { + // After an idle commit learns kharrington -> Carrington, the user makes an adjacent + // merge/split fix ("type whisper" -> "TypeWhisper"). The re-diff fuses them into unequal + // token counts; the settled word must still be trimmed off the edge so we learn the clean + // merge, not "type whisper kharrington" -> "TypeWhisper Carrington". + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService( + client, + new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ), + idleCommitDelay: TimeSpan.FromMilliseconds(50) + ); + + client.TextToReturn = "please open type whisper kharrington now thanks"; + await service.ArmAsync("please open type whisper kharrington now thanks"); + + client.TextToReturn = "please open type whisper Carrington now thanks"; + client.RaiseText(s_field); + await AwaitScheduledCommit(service); + Assert.Equal("Carrington", Assert.Single(_dictionary.GetCorrections()).Replacement); + + client.TextToReturn = "please open TypeWhisper Carrington now thanks"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + var corrections = _dictionary.GetCorrections(); + Assert.Equal(2, corrections.Count); + Assert.Contains(corrections, c => c is { Original: "kharrington", Replacement: "Carrington" }); + Assert.Contains(corrections, c => c is { Original: "type whisper", Replacement: "TypeWhisper" }); + } + + [Fact] + public async Task Arm_MergeFixSeparatedFromLearnedWordByConnector_TrimsConnector() + { + // Like the merge/split case, but an unchanged connector ("in") sits between the merge fix + // and the learned edge word. Trimming the learned word exposes that connector at the edge + // of an unequal remainder; it must be trimmed too, so we learn the clean + // "type whisper" -> "TypeWhisper", not "type whisper in" -> "TypeWhisper in". + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService( + client, + new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ), + idleCommitDelay: TimeSpan.FromMilliseconds(50) + ); + + client.TextToReturn = "please open type whisper in kharrington now thanks"; + await service.ArmAsync("please open type whisper in kharrington now thanks"); + + client.TextToReturn = "please open type whisper in Carrington now thanks"; + client.RaiseText(s_field); + await AwaitScheduledCommit(service); + Assert.Equal("Carrington", Assert.Single(_dictionary.GetCorrections()).Replacement); + + client.TextToReturn = "please open TypeWhisper in Carrington now thanks"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + var corrections = _dictionary.GetCorrections(); + Assert.Equal(2, corrections.Count); + Assert.Contains(corrections, c => c is { Original: "kharrington", Replacement: "Carrington" }); + Assert.Contains(corrections, c => c is { Original: "type whisper", Replacement: "TypeWhisper" }); + } + + [Fact] + public async Task Arm_EqualLengthPhraseEdit_LearnsWholePhrase_NotSplitWords() + { + // A same-length multi-word edit with no prior session learning must stay one atomic phrase + // correction. Splitting it into per-word rules would let a phrase fix silently rewrite + // unrelated future text (e.g. a stray "kubernets" or "cluster"). + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService(client, enabled: true); + + client.TextToReturn = "deploy kubernets cluster"; + await service.ArmAsync("deploy kubernets cluster"); + + client.TextToReturn = "deploy Kubernetes clusters"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + var correction = Assert.Single(_dictionary.GetCorrections()); + Assert.Equal("kubernets cluster", correction.Original); + Assert.Equal("Kubernetes clusters", correction.Replacement); + } + + [Fact] + public async Task Arm_PhraseEditWithUnchangedConnector_LearnsWholePhrase() + { + // Same as above but with an unchanged connector word ("in") inside the changed span and no + // prior session learning. The connector must NOT act as a split point: the whole phrase is + // learned atomically, never as per-word rules like "cluster" -> "clusters". + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService(client, enabled: true); + + client.TextToReturn = "we deploy kubernets in cluster now"; + await service.ArmAsync("we deploy kubernets in cluster now"); + + client.TextToReturn = "we deploy Kubernetes in clusters now"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + var correction = Assert.Single(_dictionary.GetCorrections()); + Assert.Equal("kubernets in cluster", correction.Original); + Assert.Equal("Kubernetes in clusters", correction.Replacement); + } + + [Fact] + public async Task Arm_LearnedWordAdjacentToFreshPhrase_KeepsPhraseAtomic() + { + // A learned word (kharrington -> Carrington) is de-fused off the span, but the adjacent NEW + // edit is itself a phrase with an unchanged connector ("in"). De-fusing must not also split + // that phrase: we learn kharrington -> Carrington plus the atomic phrase, never per-word + // "kubernets" -> "Kubernetes" / "cluster" -> "clusters". + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService( + client, + new FakeSettingsService( + AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } + ), + idleCommitDelay: TimeSpan.FromMilliseconds(50) + ); + + client.TextToReturn = "the note about kharrington kubernets in cluster is here"; + await service.ArmAsync("the note about kharrington kubernets in cluster is here"); + + client.TextToReturn = "the note about Carrington kubernets in cluster is here"; + client.RaiseText(s_field); + await AwaitScheduledCommit(service); + Assert.Equal("Carrington", Assert.Single(_dictionary.GetCorrections()).Replacement); + + client.TextToReturn = "the note about Carrington Kubernetes in clusters is here"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherField); + await AwaitCommit(service); + + var corrections = _dictionary.GetCorrections(); + Assert.Equal(2, corrections.Count); + Assert.Contains(corrections, c => c is { Original: "kharrington", Replacement: "Carrington" }); + Assert.Contains( + corrections, + c => c is { Original: "kubernets in cluster", Replacement: "Kubernetes in clusters" } + ); + } + [Fact] public async Task Arm_DisabledDuringStartup_DoesNotReadTargetText() { diff --git a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs index a046e5164..df919bcee 100644 --- a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs @@ -199,7 +199,7 @@ public async Task InsertTextAsync_arms_paste_watch_before_sending_ctrl_v() var result = await sut.InsertTextAsync("new text"); Assert.Equal(InsertionResult.Pasted, result); - Assert.Equal(new[] { "begin-watch", "ctrl-v" }, order); + Assert.Equal(["begin-watch", "ctrl-v"], order); } [Fact] @@ -213,10 +213,10 @@ public async Task InsertTextAsync_text_changed_during_paste_is_latched_and_confi var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, + OnPasteSent = () => + client.RaiseTextChanged(new AtSpiElementRef(":1.7", "/org/a11y/atspi/accessible/42")) }; - platform.OnPasteSent = () => - client.RaiseTextChanged(new AtSpiElementRef(":1.7", "/org/a11y/atspi/accessible/42")); var sut = new TextInsertionService( platform, pasteConfirmation: new AtSpiPasteConfirmation(client) @@ -1637,7 +1637,7 @@ public Task ActivateWindowAsync(string windowId) // Invoked on every SendPasteAsync — lets tests record ordering relative to the // paste keystroke (the confirmation watch must be armed before it) or raise an // AT-SPI event "during" the paste. - public Action? OnPasteSent { get; set; } + public Action? OnPasteSent { get; init; } public Task SendPasteAsync() { @@ -1697,6 +1697,8 @@ private sealed class FakePasteConfirmationSource : IPasteConfirmationSource public bool BeginWatchCalled { get; private set; } public FakePasteWatch? LastWatch { get; private set; } + // ReSharper disable once UnusedAutoPropertyAccessor.Local — configurable surface mirroring + // the fake's other init properties and IPasteConfirmationSource; no test sets it yet. public bool? HasFocusedElement { get; init; } public IPasteWatch? BeginWatch() From 02501d9f10e140b136f95488ec81911cfac65921 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Fri, 10 Jul 2026 08:10:03 -0400 Subject: [PATCH 009/226] Add Hyprland accessibility-bridge setup for target-app correction learning Hyprland (bare wlroots) never turns on org.a11y.Status.IsEnabled, so Chromium/Electron/Qt apps publish no accessibility tree and correction learning silently reads nothing. Surface a Hyprland-only panel in the Dictation settings that toggles IsEnabled (plus ScreenReaderEnabled) over the session bus via busctl, with a remove button offered only when TypeWhisper itself enabled the flag this session. Files changed: - src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs (new service + interface) - src/TypeWhisper.Linux/ServiceRegistrations.cs (DI registration) - src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs (bridge state, enable/remove commands) - src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml (setup/remove panels) - src/TypeWhisper.Linux/Resources/Localization/{en,de,es,ru}.json (new strings) - tests/TypeWhisper.Linux.Tests/AccessibilityBusActivationServiceTests.cs (new tests) --- .../Resources/Localization/de.json | 7 + .../Resources/Localization/en.json | 7 + .../Resources/Localization/es.json | 7 + .../Resources/Localization/ru.json | 7 + src/TypeWhisper.Linux/ServiceRegistrations.cs | 4 + .../AccessibilityBusActivationService.cs | 123 +++++++++++++++ .../Sections/DictationSectionViewModel.cs | 98 +++++++++++- .../Views/Sections/DictationSection.axaml | 74 +++++++-- .../AccessibilityBusActivationServiceTests.cs | 140 ++++++++++++++++++ 9 files changed, 452 insertions(+), 15 deletions(-) create mode 100644 src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs create mode 100644 tests/TypeWhisper.Linux.Tests/AccessibilityBusActivationServiceTests.cs diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index cd4ec4c63..d46a3e789 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -183,6 +183,13 @@ "Dictation.AutoLearnCorrectionsHint": "Wenn Sie einen Verlaufseintrag bearbeiten, können eindeutige Phrasen-Vorschläge sofort gelernt werden.", "Dictation.TargetAppCorrectionLearning": "Korrekturen aus anderen Apps lernen", "Dictation.TargetAppCorrectionLearningHint": "Wenn Sie ein diktiertes Wort in einer anderen App überschreiben, um es zu korrigieren, lernt TypeWhisper die Korrektur stillschweigend und wendet sie auf zukünftige Diktate an. Liest das fokussierte Textfeld; standardmäßig deaktiviert.", + "Dictation.A11yBridgeSetupExplanation": "Sie verwenden Hyprland, das die Barrierefreiheitsbrücke, über die Apps ihren Text bereitstellen, nicht aktiviert. Das Korrekturlernen benötigt sie. Aktivieren Sie sie unten und starten Sie dann die App neu, aus der gelernt werden soll — Electron-Apps wie VS Code übernehmen die Einstellung nur beim Start.", + "Dictation.A11yBridgeEnableButton": "Barrierefreiheitsbrücke aktivieren", + "Dictation.A11yBridgeRemoveExplanation": "Die Barrierefreiheitsbrücke ist aktiviert. Sie aktiviert die Barrierefreiheit für alle Apps, was einen geringen Mehraufwand verursacht. Sie können sie entfernen, wenn Sie das Korrekturlernen nicht mehr verwenden.", + "Dictation.A11yBridgeRemoveButton": "Barrierefreiheitsbrücke entfernen", + "Dictation.A11yBridgeEnabledStatus": "Barrierefreiheitsbrücke aktiviert. Starten Sie die Zielanwendung neu, falls sie bereits geöffnet war.", + "Dictation.A11yBridgeRemovedStatus": "Barrierefreiheitsbrücke entfernt.", + "Dictation.A11yBridgeActionFailed": "Die Einstellung der Barrierefreiheitsbrücke konnte nicht geändert werden.", "Dictation.AutoPaste": "Nach der Transkription automatisch einfügen", "Dictation.AutoStopOnSilence": "Bei Stille automatisch stoppen", "Dictation.CleanupHigh": "Hoch", diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index 8dec16254..4eabdfad8 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -183,6 +183,13 @@ "Dictation.AutoLearnCorrectionsHint": "When you edit a history item, clear phrase-level suggestions can be learned immediately.", "Dictation.TargetAppCorrectionLearning": "Learn corrections from other apps", "Dictation.TargetAppCorrectionLearningHint": "When you type over a dictated word in another app to fix it, TypeWhisper silently learns the correction and applies it to future dictations. Reads the focused text field; off by default.", + "Dictation.A11yBridgeSetupExplanation": "You're on Hyprland, which doesn't enable the accessibility bridge that lets apps expose their text. Correction learning needs it. Enable it below, then restart the app you want to learn from — Electron apps like VS Code only pick it up when they launch.", + "Dictation.A11yBridgeEnableButton": "Enable accessibility bridge", + "Dictation.A11yBridgeRemoveExplanation": "The accessibility bridge is enabled. It activates accessibility for all apps, which adds a small overhead. You can remove it if you stop using correction learning.", + "Dictation.A11yBridgeRemoveButton": "Remove accessibility bridge", + "Dictation.A11yBridgeEnabledStatus": "Accessibility bridge enabled. Restart the target app if it was already open.", + "Dictation.A11yBridgeRemovedStatus": "Accessibility bridge removed.", + "Dictation.A11yBridgeActionFailed": "Could not change the accessibility bridge setting.", "Dictation.AutoPaste": "Auto paste after transcription", "Dictation.AutoStopOnSilence": "Auto-stop on silence", "Dictation.CleanupHigh": "High", diff --git a/src/TypeWhisper.Linux/Resources/Localization/es.json b/src/TypeWhisper.Linux/Resources/Localization/es.json index 2ef5bb864..05a9b317c 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -183,6 +183,13 @@ "Dictation.AutoLearnCorrectionsHint": "Cuando editas un elemento del historial, las sugerencias claras a nivel de frase pueden aprenderse de inmediato.", "Dictation.TargetAppCorrectionLearning": "Aprender correcciones de otras apps", "Dictation.TargetAppCorrectionLearningHint": "Cuando escribes sobre una palabra dictada en otra app para corregirla, TypeWhisper aprende la corrección de forma silenciosa y la aplica a los dictados futuros. Lee el campo de texto enfocado; desactivado de forma predeterminada.", + "Dictation.A11yBridgeSetupExplanation": "Estás en Hyprland, que no activa el puente de accesibilidad que permite a las apps exponer su texto. El aprendizaje de correcciones lo necesita. Actívalo abajo y luego reinicia la app de la que quieres aprender: las apps de Electron como VS Code solo lo detectan al iniciarse.", + "Dictation.A11yBridgeEnableButton": "Activar puente de accesibilidad", + "Dictation.A11yBridgeRemoveExplanation": "El puente de accesibilidad está activado. Activa la accesibilidad para todas las apps, lo que añade una pequeña sobrecarga. Puedes quitarlo si dejas de usar el aprendizaje de correcciones.", + "Dictation.A11yBridgeRemoveButton": "Quitar puente de accesibilidad", + "Dictation.A11yBridgeEnabledStatus": "Puente de accesibilidad activado. Reinicia la app de destino si ya estaba abierta.", + "Dictation.A11yBridgeRemovedStatus": "Puente de accesibilidad quitado.", + "Dictation.A11yBridgeActionFailed": "No se pudo cambiar la configuración del puente de accesibilidad.", "Dictation.AutoPaste": "Pegar automáticamente tras la transcripción", "Dictation.AutoStopOnSilence": "Detener automáticamente al detectar silencio", "Dictation.CleanupHigh": "Alta", diff --git a/src/TypeWhisper.Linux/Resources/Localization/ru.json b/src/TypeWhisper.Linux/Resources/Localization/ru.json index 35b9251f0..16357044a 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -183,6 +183,13 @@ "Dictation.AutoLearnCorrectionsHint": "Когда вы редактируете элемент истории, очевидные исправления на уровне фраз могут быть выучены сразу.", "Dictation.TargetAppCorrectionLearning": "Учить исправления из других приложений", "Dictation.TargetAppCorrectionLearningHint": "Когда вы исправляете продиктованное слово, набирая поверх него в другом приложении, TypeWhisper незаметно запоминает исправление и применяет его к будущим диктовкам. Читает текстовое поле в фокусе; по умолчанию выключено.", + "Dictation.A11yBridgeSetupExplanation": "Вы используете Hyprland, который не включает мост специальных возможностей, позволяющий приложениям предоставлять свой текст. Обучению исправлениям он необходим. Включите его ниже, затем перезапустите приложение, из которого нужно обучаться, — приложения на Electron, такие как VS Code, применяют эту настройку только при запуске.", + "Dictation.A11yBridgeEnableButton": "Включить мост специальных возможностей", + "Dictation.A11yBridgeRemoveExplanation": "Мост специальных возможностей включён. Он активирует специальные возможности для всех приложений, что создаёт небольшую нагрузку. Вы можете удалить его, если больше не используете обучение исправлениям.", + "Dictation.A11yBridgeRemoveButton": "Удалить мост специальных возможностей", + "Dictation.A11yBridgeEnabledStatus": "Мост специальных возможностей включён. Перезапустите целевое приложение, если оно уже было открыто.", + "Dictation.A11yBridgeRemovedStatus": "Мост специальных возможностей удалён.", + "Dictation.A11yBridgeActionFailed": "Не удалось изменить настройку моста специальных возможностей.", "Dictation.AutoPaste": "Автоматическая вставка после транскрипции", "Dictation.AutoStopOnSilence": "Автоостановка при тишине", "Dictation.CleanupHigh": "Высокая", diff --git a/src/TypeWhisper.Linux/ServiceRegistrations.cs b/src/TypeWhisper.Linux/ServiceRegistrations.cs index d86155f97..a7630177c 100644 --- a/src/TypeWhisper.Linux/ServiceRegistrations.cs +++ b/src/TypeWhisper.Linux/ServiceRegistrations.cs @@ -90,6 +90,10 @@ public static void Register(IServiceCollection services) // insertion path is unchanged unless correction learning already turned them on. services.AddSingleton(); services.AddSingleton(); + // Toggles the session-bus accessibility flag (org.a11y.Status.IsEnabled) so + // Chromium/Electron/Qt apps expose text on Hyprland; surfaced as a button in the + // Dictation settings when target-app correction learning is enabled. + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService() diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs new file mode 100644 index 000000000..4fd11b820 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs @@ -0,0 +1,123 @@ +using System.Diagnostics; + +namespace TypeWhisper.Linux.Services.ActiveWindow; + +/// +/// Reads and toggles the session-bus accessibility activation flag +/// (org.a11y.Status.IsEnabled). Chromium/Electron and Qt apps only build and +/// publish their accessibility tree when this flag is true; GNOME/Cinnamon set +/// it at login, but bare wlroots sessions (Hyprland) leave it false, so those +/// apps expose no readable text and target-app correction learning silently no-ops. +/// Behind an interface so the settings ViewModel can be unit-tested with a fake. +/// +public interface IAccessibilityBusActivation +{ + /// + /// true on a Hyprland session — where the accessibility bridge is not managed + /// by the desktop, so TypeWhisper offers to toggle it. Other desktops either manage it + /// themselves (GNOME/Cinnamon) or are out of scope, so the setup UI stays hidden there. + /// + bool IsHyprlandSession { get; } + + /// + /// Reads org.a11y.Status.IsEnabled from the session bus. Returns null + /// when the value can't be determined (busctl missing, bus unreachable, unparsable). + /// + Task IsActivatedAsync(CancellationToken ct = default); + + /// + /// Sets org.a11y.Status.IsEnabled (and ScreenReaderEnabled, which + /// Chromium/Electron also key off) on the session bus. Runtime-only — the value + /// resets at logout. Returns true when the write succeeded. + /// + Task SetActivatedAsync(bool enabled, CancellationToken ct = default); +} + +public sealed class AccessibilityBusActivationService : IAccessibilityBusActivation +{ + private const string BusName = "org.a11y.Bus"; + private const string ObjectPath = "/org/a11y/bus"; + private const string StatusInterface = "org.a11y.Status"; + + private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(5); + + private readonly IProcessRunner _processRunner; + + public AccessibilityBusActivationService(IProcessRunner processRunner) + { + _processRunner = processRunner; + } + + public bool IsHyprlandSession => + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE")) + || (Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP") ?? string.Empty).Contains( + "Hyprland", + StringComparison.OrdinalIgnoreCase + ); + + public async Task IsActivatedAsync(CancellationToken ct = default) + { + var result = await _processRunner + .RunAsync( + "busctl", + ["--user", "get-property", BusName, ObjectPath, StatusInterface, "IsEnabled"], + timeout: s_timeout, + ct: ct + ) + .ConfigureAwait(false); + + if (!result.Succeeded) + { + return null; + } + + // busctl prints the boolean variant as "b true" / "b false". + var text = result.StandardOutput.Trim(); + if (text.EndsWith("true", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return text.EndsWith("false", StringComparison.OrdinalIgnoreCase) ? false : null; + } + + public async Task SetActivatedAsync(bool enabled, CancellationToken ct = default) + { + // IsEnabled is the flag Chromium/Qt/Firefox gate their a11y tree on; ScreenReaderEnabled + // is set alongside it because some Chromium builds check that one instead. The result of + // the primary write is what we report; the secondary is best-effort. + var ok = await SetPropertyAsync("IsEnabled", enabled, ct).ConfigureAwait(false); + await SetPropertyAsync("ScreenReaderEnabled", enabled, ct).ConfigureAwait(false); + return ok; + } + + private async Task SetPropertyAsync(string property, bool value, CancellationToken ct) + { + var result = await _processRunner + .RunAsync( + "busctl", + [ + "--user", + "set-property", + BusName, + ObjectPath, + StatusInterface, + property, + "b", + value ? "true" : "false" + ], + timeout: s_timeout, + ct: ct + ) + .ConfigureAwait(false); + + if (!result.Succeeded) + { + Trace.WriteLine( + $"[A11yBusActivation] Failed to set {property}={value}: {result.StandardError.Trim()}" + ); + } + + return result.Succeeded; + } +} diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs index bfa84ac41..cf4fbe0b3 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs @@ -6,6 +6,7 @@ using TypeWhisper.Core.Models; using TypeWhisper.Core.Services; using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.ActiveWindow; using TypeWhisper.Linux.Services.Localization; using TypeWhisper.Linux.Services.Plugins; using TypeWhisper.PluginSDK; @@ -17,6 +18,7 @@ namespace TypeWhisper.Linux.ViewModels.Sections; // ReSharper disable UnusedParameterInPartialMethod public partial class DictationSectionViewModel : ObservableObject { + private readonly IAccessibilityBusActivation _a11yBus; private readonly AudioRecordingService _audio; private readonly SystemCommandAvailabilityService _commands; private readonly DictationOrchestrator _dictation; @@ -59,6 +61,20 @@ public partial class DictationSectionViewModel : ObservableObject [ObservableProperty] private bool _targetAppCorrectionLearningEnabled; + // Reflects org.a11y.Status.IsEnabled on the session bus. Drives which of the + // Hyprland accessibility-bridge buttons (enable vs remove) is shown. + [ObservableProperty] + private bool _accessibilityBridgeActivated; + + [ObservableProperty] + private string _accessibilityBridgeStatus = ""; + + // True only when TypeWhisper turned the bridge on this session. Gates the Remove action so + // we never offer to disable a session-global accessibility flag a screen reader or other + // tool enabled. The flag resets at logout, so ownership is deliberately not persisted — + // after an app restart we simply stop offering removal rather than risk a false claim. + private bool _bridgeEnabledByThisApp; + [ObservableProperty] private bool _autoPaste; @@ -176,7 +192,8 @@ public DictationSectionViewModel( AudioRecordingService audio, ISettingsService settings, PluginManager pluginManager, - SystemCommandAvailabilityService commands + SystemCommandAvailabilityService commands, + IAccessibilityBusActivation a11yBus ) { _dictation = dictation; @@ -185,6 +202,7 @@ SystemCommandAvailabilityService commands _settings = settings; _pluginManager = pluginManager; _commands = commands; + _a11yBus = a11yBus; // Unload the active local model before moving its files so the source // path isn't held open during migration. _modelStorage = new LocalModelStorageService(_settings, () => _models.UnloadModel()); @@ -230,6 +248,10 @@ SystemCommandAvailabilityService commands RefreshModels(); RefreshDevices(); RefreshFromSettings(_settings.Current); + + // Read the current accessibility-bridge flag so the Hyprland enable/remove button + // reflects reality on first paint (Hyprland-only; the call no-ops elsewhere). + _ = RefreshAccessibilityBridgeStateAsync(); } public ObservableCollection ModelOptions { get; } = []; @@ -303,6 +325,19 @@ SystemCommandAvailabilityService commands public string SoundFeedbackUnavailableReason => Loc.Instance["Dictation.AudioPlayerUnavailable"]; + // Hyprland doesn't enable the accessibility bridge that lets apps expose their text, so + // correction learning can't read them until it's turned on. Offer the enable button only + // when the feature is on and the bridge is off; offer removal only when WE turned it on + // this session — the flag is session-global, so a screen reader or other tool may have + // enabled it and we must never present a button that disables their accessibility. + public bool ShowAccessibilityBridgeSetup => + _a11yBus.IsHyprlandSession + && TargetAppCorrectionLearningEnabled + && !AccessibilityBridgeActivated; + + public bool ShowAccessibilityBridgeRemove => + _a11yBus.IsHyprlandSession && AccessibilityBridgeActivated && _bridgeEnabledByThisApp; + public bool CanDeleteSelectedModel => SelectedModel is { } selected && _models.CanDeleteModel(selected.ModelId); @@ -1305,6 +1340,67 @@ partial void OnAutoAddDictionaryCorrectionsChanged(bool value) partial void OnTargetAppCorrectionLearningEnabledChanged(bool value) { _settings.Save(_settings.Current with { TargetAppCorrectionLearningEnabled = value }); + OnPropertyChanged(nameof(ShowAccessibilityBridgeSetup)); + // Re-read the live flag when the feature is switched on so the enable button appears + // if the bridge isn't active yet. + if (value) + { + _ = RefreshAccessibilityBridgeStateAsync(); + } + } + + partial void OnAccessibilityBridgeActivatedChanged(bool value) + { + OnPropertyChanged(nameof(ShowAccessibilityBridgeSetup)); + OnPropertyChanged(nameof(ShowAccessibilityBridgeRemove)); + } + + // Turns the session-bus accessibility bridge on so Electron/Chromium/Qt apps expose their + // text to correction learning. Runtime-only (resets at logout); an already-running target + // app must be restarted to pick it up, which the status message calls out. + [RelayCommand] + private Task EnableAccessibilityBridge() + { + return ToggleAccessibilityBridgeAsync(true); + } + + [RelayCommand] + private Task RemoveAccessibilityBridge() + { + return ToggleAccessibilityBridgeAsync(false); + } + + private async Task ToggleAccessibilityBridgeAsync(bool enable) + { + var ok = await _a11yBus.SetActivatedAsync(enable); + if (ok) + { + // Remember we own the bridge only after a successful enable; a successful remove + // clears ownership. See ShowAccessibilityBridgeRemove for why this gates the button. + _bridgeEnabledByThisApp = enable; + } + + await RefreshAccessibilityBridgeStateAsync(); + OnPropertyChanged(nameof(ShowAccessibilityBridgeRemove)); + AccessibilityBridgeStatus = ok + ? Loc.Instance[ + enable ? "Dictation.A11yBridgeEnabledStatus" : "Dictation.A11yBridgeRemovedStatus" + ] + : Loc.Instance["Dictation.A11yBridgeActionFailed"]; + } + + private async Task RefreshAccessibilityBridgeStateAsync() + { + if (!_a11yBus.IsHyprlandSession) + { + return; + } + + var activated = await _a11yBus.IsActivatedAsync(); + if (activated is { } value) + { + Dispatcher.UIThread.Post(() => AccessibilityBridgeActivated = value); + } } partial void OnLiveTranscriptionEnabledChanged(bool value) diff --git a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml index 52234930b..2156e2a08 100644 --- a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml @@ -405,20 +405,66 @@ - - - - - - - + + + + + + + + + + + + + + +public sealed class AccessibilityBusActivationServiceTests +{ + private static bool IsGetIsEnabled(string file, IReadOnlyList args) => + file == "busctl" && args.Contains("get-property") && args.Contains("IsEnabled"); + + [Fact] + public async Task IsActivatedAsync_parses_true() + { + var runner = new FakeProcessRunner(); + runner.RespondWith(IsGetIsEnabled, "b true\n"); + var service = new AccessibilityBusActivationService(runner); + + Assert.Equal(true, await service.IsActivatedAsync()); + } + + [Fact] + public async Task IsActivatedAsync_parses_false() + { + var runner = new FakeProcessRunner(); + runner.RespondWith(IsGetIsEnabled, "b false\n"); + var service = new AccessibilityBusActivationService(runner); + + Assert.Equal(false, await service.IsActivatedAsync()); + } + + [Fact] + public async Task IsActivatedAsync_returns_null_when_command_fails() + { + var runner = new FakeProcessRunner(); + runner.FailWhen(IsGetIsEnabled, "bus unreachable"); + var service = new AccessibilityBusActivationService(runner); + + Assert.Null(await service.IsActivatedAsync()); + } + + [Fact] + public async Task IsActivatedAsync_returns_null_on_unparsable_output() + { + var runner = new FakeProcessRunner(); + runner.RespondWith(IsGetIsEnabled, "unexpected"); + var service = new AccessibilityBusActivationService(runner); + + Assert.Null(await service.IsActivatedAsync()); + } + + [Fact] + public async Task SetActivatedAsync_true_sets_both_flags_true() + { + var runner = new FakeProcessRunner(); + var service = new AccessibilityBusActivationService(runner); + + var ok = await service.SetActivatedAsync(true); + + Assert.True(ok); + var setCalls = runner + .Invocations.Where(i => i.FileName == "busctl" && i.Args.Contains("set-property")) + .ToList(); + Assert.Equal(2, setCalls.Count); + Assert.Contains( + setCalls, + c => c.Args.Contains("IsEnabled") && c.Args[^2] == "b" && c.Args[^1] == "true" + ); + Assert.Contains( + setCalls, + c => c.Args.Contains("ScreenReaderEnabled") && c.Args[^1] == "true" + ); + } + + [Fact] + public async Task SetActivatedAsync_false_sets_flags_false() + { + var runner = new FakeProcessRunner(); + var service = new AccessibilityBusActivationService(runner); + + await service.SetActivatedAsync(false); + + var isEnabled = runner.Invocations.Single(i => + i.Args.Contains("set-property") && i.Args.Contains("IsEnabled") + ); + Assert.Equal("false", isEnabled.Args[^1]); + } + + [Fact] + public async Task SetActivatedAsync_reports_failure_when_primary_write_fails() + { + var runner = new FakeProcessRunner(); + runner.FailWhen((file, args) => args.Contains("set-property") && args.Contains("IsEnabled")); + var service = new AccessibilityBusActivationService(runner); + + Assert.False(await service.SetActivatedAsync(true)); + } + + [Fact] + public void IsHyprlandSession_true_when_instance_signature_present() + { + var sig = Environment.GetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE"); + var desktop = Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP"); + try + { + Environment.SetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE", "abc123"); + Environment.SetEnvironmentVariable("XDG_CURRENT_DESKTOP", ""); + var service = new AccessibilityBusActivationService(new FakeProcessRunner()); + Assert.True(service.IsHyprlandSession); + } + finally + { + Environment.SetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE", sig); + Environment.SetEnvironmentVariable("XDG_CURRENT_DESKTOP", desktop); + } + } + + [Fact] + public void IsHyprlandSession_false_on_gnome() + { + var sig = Environment.GetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE"); + var desktop = Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP"); + try + { + Environment.SetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE", null); + Environment.SetEnvironmentVariable("XDG_CURRENT_DESKTOP", "GNOME"); + var service = new AccessibilityBusActivationService(new FakeProcessRunner()); + Assert.False(service.IsHyprlandSession); + } + finally + { + Environment.SetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE", sig); + Environment.SetEnvironmentVariable("XDG_CURRENT_DESKTOP", desktop); + } + } +} From 821275aacf9c4b5f32a0f72a89ea88f436ed9827 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Fri, 10 Jul 2026 09:04:45 -0400 Subject: [PATCH 010/226] Fix correction learning in LibreOffice Writer: tolerate focus flapping Live AT-SPI capture showed Writer alternates the focused state between the caret paragraph (readable) and the document root pane (no Text interface), with the pane usually winning the race and re-asserting focus between keystrokes. That broke learning twice over: ArmAsync anchored on the unreadable pane and gave up, and even a successful arm was final-committed by the very next pane focus event before the user could correct anything. Fixes: - AtSpiEventClient keeps a bounded most-recent-first focus history (GetRecentFocusedElements) alongside CurrentFocusedElement. - ArmAsync falls back through recently focused same-app elements until one anchors to the inserted text; the per-candidate password check still fails closed, and the anchor requirement keeps stale siblings out. - OnFocusChanged only ends tracking when focus moves to a different application; same-app flapping is ignored (idle/timeout commits cover real same-app field moves, text-changed matching stays element-exact). Files changed: - src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs (GetRecentFocusedElements) - src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs (focus history ring) - src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs (candidate fallback + same-app focus rule) - tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs (3 new tests, other-app fixture) - tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs (fake interface member) --- .../Services/ActiveWindow/AtSpiEventClient.cs | 23 +++ .../ActiveWindow/IAtSpiEventClient.cs | 9 ++ .../TargetAppCorrectionLearningService.cs | 116 ++++++++++----- ...TargetAppCorrectionLearningServiceTests.cs | 132 +++++++++++++++--- .../TextInsertionServiceTests.cs | 5 + 5 files changed, 227 insertions(+), 58 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs index 8159fb92a..0285a9a8a 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -40,6 +40,11 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable private const string FocusedStateName = "focused"; private const int StateGained = 1; + // LibreOffice Writer alternates focus between the caret paragraph and the document's + // root pane, so two entries would suffice there; a few more absorb apps that flap + // across additional structural nodes. + private const int RecentFocusCapacity = 8; + // AtspiRole.PASSWORD_TEXT — confirmed against the existing extractor's role numbering // (ROLE_FRAME = 23) which shares the same AtspiRole enum. private const uint RolePasswordText = 40; @@ -74,6 +79,9 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable private readonly Lock _focusLock = new(); private readonly SemaphoreSlim _startGate = new(1, 1); + // Bounded most-recent-first focus history behind _focusLock; see GetRecentFocusedElements. + private readonly List _recentFocused = []; + private AtSpiElementRef? _currentFocused; private DBusConnection? _connection; private bool _available; @@ -125,6 +133,14 @@ public AtSpiElementRef? CurrentFocusedElement } } + public IReadOnlyList GetRecentFocusedElements() + { + lock (_focusLock) + { + return [.. _recentFocused]; + } + } + public bool IsRunning => _available; public async Task EnsureStartedAsync() @@ -183,6 +199,7 @@ public async Task StopAsync() lock (_focusLock) { _currentFocused = null; + _recentFocused.Clear(); } } finally @@ -371,6 +388,12 @@ private void HandleStateChanged(Exception? exception, AtSpiSignal signal, object lock (_focusLock) { _currentFocused = element; + _recentFocused.Remove(element); + _recentFocused.Insert(0, element); + if (_recentFocused.Count > RecentFocusCapacity) + { + _recentFocused.RemoveAt(RecentFocusCapacity); + } } // A subscriber throwing here runs on the D-Bus dispatch thread and would fault the diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs index cb925845f..7322bf3e4 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs @@ -27,6 +27,15 @@ public interface IAtSpiEventClient /// The element that most recently gained focus, or null if none seen yet. AtSpiElementRef? CurrentFocusedElement { get; } + /// + /// Snapshot of distinct recently focused elements, most recent first (the head is + /// ), bounded to a handful of entries. Some apps + /// (LibreOffice Writer) flap the focused state between the caret's text widget and a + /// structural pane that exposes no text, so the most recent element is not always the + /// readable one — consumers can fall back through this history. + /// + IReadOnlyList GetRecentFocusedElements(); + /// /// true while the client holds a live a11y-bus connection with listeners /// registered (a successful not yet undone by diff --git a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs index 42fa4dbe1..328d1de1e 100644 --- a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs +++ b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs @@ -30,6 +30,11 @@ public sealed class TargetAppCorrectionLearningService : IDisposable // field can hold pre-existing surrounding text around the dictated span. private const int MaxTrackedTextLength = 8192; + // How many recently focused same-app elements ArmAsync probes when the newest focused + // element can't be anchored. Two covers LibreOffice Writer's paragraph/root-pane flap; + // a couple more absorb apps that flap across additional structural nodes. + private const int MaxArmCandidates = 4; + // Defaults for the timing seams below. Kept as instance fields (not static readonly) so // unit tests can shrink them via the internal constructor without waiting real seconds. @@ -209,15 +214,28 @@ public async Task ArmAsync(string insertedText) return; } - // Fail closed: skip unless the element is positively a non-password role. A null - // (indeterminate) result means the role could not be read — never read text then. - if (await _client.IsPasswordFieldAsync(element).ConfigureAwait(false) != false) + // Some apps (LibreOffice Writer) flap the AT-SPI focused state between the caret's + // text widget and a structural pane with no readable text, so the newest focused + // element is not always the one the dictation landed in. Try it first, then fall back + // through recently focused elements of the same application; the anchoring check + // below (the candidate must contain the just-inserted text) is what keeps a stale + // sibling from being armed. + var candidates = new List { element }; + foreach (var recent in _client.GetRecentFocusedElements()) { - Trace.WriteLine( - "[TargetAppLearning] Element is a password field or its role is unknown; skipping." - ); - Disarm(); - return; + if (candidates.Count == MaxArmCandidates) + { + break; + } + + if ( + recent.IsValid + && string.Equals(recent.BusName, element.BusName, StringComparison.Ordinal) + && !candidates.Contains(recent) + ) + { + candidates.Add(recent); + } } // Confirm the field actually contains the text we just inserted before anchoring on @@ -230,45 +248,58 @@ public async Task ArmAsync(string insertedText) // than the MaxTrackedTextLength read clamp will honestly skip here (the edit past the // clamp would be invisible to us anyway). string? baseline = null; - var anchored = false; - for (var attempt = 0; attempt < 3; attempt++) + AtSpiElementRef? anchored = null; + // Fail closed per candidate: only read text from elements positively known to be a + // non-password role — null (role unreadable) counts as unsafe. Verdicts are cached so + // retry attempts don't re-issue role reads. + var safeToRead = new Dictionary(); + for (var attempt = 0; attempt < 3 && anchored is null; attempt++) { if (attempt > 0) { await Task.Delay(_baselineRetryDelay).ConfigureAwait(false); } - // Re-check opt-out immediately before every text read: a disable during the - // password read or a retry delay must stop us reading the target app's text. - if (IsOptedOut()) + foreach (var candidate in candidates) { - Disarm(); - return; - } + // Re-check opt-out immediately before every accessibility read: a disable + // during a role read or a retry delay must stop us reading the target app. + if (IsOptedOut()) + { + Disarm(); + return; + } - var read = await _client.TryReadTextAsync(element, MaxTrackedTextLength) - .ConfigureAwait(false); - if (read is null) - { - continue; - } + if (!safeToRead.TryGetValue(candidate, out var safe)) + { + safe = + await _client.IsPasswordFieldAsync(candidate).ConfigureAwait(false) + == false; + safeToRead[candidate] = safe; + } - // Keep the most recent successful read as the candidate baseline. - baseline = read; - // Positive break reads clearer than a trailing `continue` at the loop tail, which - // would skip nothing since the loop re-iterates anyway. Keep found -> anchor -> break. - // ReSharper disable once InvertIf - if (ContainsCollapsed(read, insertedText)) - { - anchored = true; + if (!safe) + { + continue; + } + + var read = await _client.TryReadTextAsync(candidate, MaxTrackedTextLength) + .ConfigureAwait(false); + if (read is null || !ContainsCollapsed(read, insertedText)) + { + continue; + } + + baseline = read; + anchored = candidate; break; } } - if (!anchored || baseline is null) + if (anchored is not { } anchoredElement || baseline is null) { Trace.WriteLine( - "[TargetAppLearning] Baseline could not be anchored to the inserted text; skipping." + "[TargetAppLearning] No focused element could be anchored to the inserted text; skipping." ); Disarm(); return; @@ -278,7 +309,7 @@ public async Task ArmAsync(string insertedText) { StopTimers(); var generation = unchecked(++_armGeneration); - _armed = new ArmedState(element, baseline, generation); + _armed = new ArmedState(anchoredElement, baseline, generation); _timeoutTimer = new Timer( static state => { @@ -596,15 +627,26 @@ private void OnFocusChanged(AtSpiElementRef element) { lock (_gate) { - if (_armed is null || _armed.Element.Equals(element)) + if ( + _armed is null + || string.Equals( + element.BusName, + _armed.Element.BusName, + StringComparison.Ordinal + ) + ) { - // Not tracking, or focus merely re-asserted on the same element. + // Not tracking, or focus stayed inside the same application. Same-app focus + // changes never end tracking because some apps (LibreOffice Writer) re-assert + // focus on structural panes between keystrokes while the user is still editing + // the armed field; a genuine move to another field of the same app is covered + // by the idle and timeout commits (text-changed matching stays element-exact). return; } } - // Focus left the armed element: a FINAL commit — it learns the edit if there was one - // and otherwise cleanly disarms. + // Focus left the application that owns the armed element: a FINAL commit — it learns + // the edit if there was one and otherwise cleanly disarms. CommitInBackground(final: true); } diff --git a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs index c3a3310ef..9130edd0b 100644 --- a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs @@ -16,7 +16,11 @@ namespace TypeWhisper.Linux.Tests; public sealed class TargetAppCorrectionLearningServiceTests : IDisposable { private static readonly AtSpiElementRef s_field = new("app", "/field/1"); - private static readonly AtSpiElementRef s_otherField = new("app", "/field/2"); + + // A field in a DIFFERENT application: focus moving here ends tracking. Same-app focus + // changes deliberately do not (LibreOffice Writer re-asserts focus on structural panes + // while the user is still editing the armed field). + private static readonly AtSpiElementRef s_otherAppField = new("other-app", "/field/2"); private readonly string _dictionaryPath = Path.Join(Path.GetTempPath(), $"tw-dict-{Guid.NewGuid():N}.json"); @@ -50,7 +54,83 @@ public async Task Arm_ThenEdit_ThenFocusOut_LearnsCorrectionSilently() client.RaiseText(s_field); // ...then moves focus away, which commits the edit. - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); + await AwaitCommit(service); + + var correction = Assert.Single(_dictionary.GetCorrections()); + Assert.Equal("kubernets", correction.Original); + Assert.Equal("Kubernetes", correction.Replacement); + } + + [Fact] + public async Task Arm_FallsBackToRecentSameAppElement_WhenFocusedElementHasNoText() + { + // LibreOffice Writer: the document's root pane (no Text interface) is the most + // recent focused element, while the caret paragraph the dictation landed in sits + // one entry back in the focus history. + var pane = new AtSpiElementRef("app", "/pane"); + var client = new FakeAtSpiEventClient + { + CurrentFocusedElement = pane, + TextProvider = e => e.Equals(s_field) ? "I deployed to kubernets today" : null + }; + client.RecentFocusedElements.AddRange([pane, s_field]); + using var service = CreateService(client, enabled: true); + + await service.ArmAsync("I deployed to kubernets today"); + + client.TextProvider = e => e.Equals(s_field) ? "I deployed to Kubernetes today" : null; + client.RaiseText(s_field); + client.RaiseFocus(s_otherAppField); + await AwaitCommit(service); + + var correction = Assert.Single(_dictionary.GetCorrections()); + Assert.Equal("kubernets", correction.Original); + Assert.Equal("Kubernetes", correction.Replacement); + } + + [Fact] + public async Task Arm_IgnoresRecentElementsFromOtherApps() + { + // The fallback must never read a stale field that belongs to a different + // application, even if that field happens to contain the inserted text. + var pane = new AtSpiElementRef("app", "/pane"); + var foreignField = new AtSpiElementRef("foreign-app", "/field/1"); + var client = new FakeAtSpiEventClient + { + CurrentFocusedElement = pane, + TextProvider = e => e.Equals(foreignField) ? "hello world" : null + }; + client.RecentFocusedElements.AddRange([pane, foreignField]); + using var service = CreateService(client, enabled: true); + + await service.ArmAsync("hello world"); + + // Arming skipped, so an edit in the foreign field must not be learned. + client.TextProvider = e => e.Equals(foreignField) ? "hello there world" : null; + client.RaiseText(foreignField); + client.RaiseFocus(s_otherAppField); + + Assert.Null(service.LastCommitTask); + Assert.Empty(_dictionary.GetCorrections()); + } + + [Fact] + public async Task SameAppFocusChange_DoesNotEndTracking() + { + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService(client, enabled: true); + + client.TextToReturn = "I deployed to kubernets today"; + await service.ArmAsync("I deployed to kubernets today"); + + // LibreOffice Writer re-asserts focus on the document's root pane between + // keystrokes; that must not end tracking of the armed paragraph. + client.RaiseFocus(new AtSpiElementRef("app", "/pane")); + + client.TextToReturn = "I deployed to Kubernetes today"; + client.RaiseText(s_field); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); var correction = Assert.Single(_dictionary.GetCorrections()); @@ -68,7 +148,7 @@ public async Task Arm_FocusOutWithoutEdit_LearnsNothing() await service.ArmAsync("hello world"); // Focus leaves without any text-changed event — nothing to learn. - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); Assert.Null(service.LastCommitTask); Assert.Empty(_dictionary.GetCorrections()); @@ -86,7 +166,7 @@ public async Task Arm_EditRejectedByCorrectionService_LearnsNothing() // A wholesale rewrite is rejected by CorrectionSuggestionService's safety gates. client.TextToReturn = "please send a concise status update instead"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); Assert.Empty(_dictionary.GetCorrections()); @@ -120,7 +200,7 @@ public async Task Arm_PasswordField_LearnsNothing() client.TextToReturn = "hunter3"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); Assert.Null(service.LastCommitTask); Assert.Empty(_dictionary.GetCorrections()); @@ -159,7 +239,7 @@ public async Task Commit_SingleWordSpellingFix_StillLearns() client.TextToReturn = "Kubernetes"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); var correction = Assert.Single(_dictionary.GetCorrections()); @@ -180,7 +260,7 @@ public async Task Commit_LowSimilarityIntentChange_LearnsNothing() client.TextToReturn = "email dad"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); Assert.Empty(_dictionary.GetCorrections()); @@ -211,7 +291,7 @@ public async Task Arm_IdleCommit_ThenMoreWordsTyped_DoesNotWidenReplacement() // "Kubernetes now" would widen the replacement — the guard must keep the earlier value. client.TextToReturn = "Kubernetes now"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); var correction = Assert.Single(_dictionary.GetCorrections()); @@ -248,7 +328,7 @@ public async Task Arm_TwoAdjacentWordsCorrectedInSequence_LearnsThemSeparately() // names changed, but only the genuinely new edit should be learned. client.TextToReturn = "Kim Curris Carrington Quinn"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); var corrections = _dictionary.GetCorrections(); @@ -285,7 +365,7 @@ public async Task Arm_LearnedAndNewEditSeparatedByUnchangedWord_LearnsOnlyTheNew client.TextToReturn = "please note Carrington in Smith right here"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); var corrections = _dictionary.GetCorrections(); @@ -322,7 +402,7 @@ public async Task Arm_MergeFixAdjacentToLearnedWord_DropsLearnedWordFromPhrase() client.TextToReturn = "please open TypeWhisper Carrington now thanks"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); var corrections = _dictionary.GetCorrections(); @@ -357,7 +437,7 @@ public async Task Arm_MergeFixSeparatedFromLearnedWordByConnector_TrimsConnector client.TextToReturn = "please open TypeWhisper in Carrington now thanks"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); var corrections = _dictionary.GetCorrections(); @@ -380,7 +460,7 @@ public async Task Arm_EqualLengthPhraseEdit_LearnsWholePhrase_NotSplitWords() client.TextToReturn = "deploy Kubernetes clusters"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); var correction = Assert.Single(_dictionary.GetCorrections()); @@ -402,7 +482,7 @@ public async Task Arm_PhraseEditWithUnchangedConnector_LearnsWholePhrase() client.TextToReturn = "we deploy Kubernetes in clusters now"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); var correction = Assert.Single(_dictionary.GetCorrections()); @@ -436,7 +516,7 @@ public async Task Arm_LearnedWordAdjacentToFreshPhrase_KeepsPhraseAtomic() client.TextToReturn = "the note about Carrington Kubernetes in clusters is here"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); var corrections = _dictionary.GetCorrections(); @@ -556,7 +636,7 @@ public async Task Arm_PartialIdleCommit_ThenCompleteFinalCommit_SelfHeals() // The user finishes the word and moves on: the final commit re-diffs and overwrites. client.TextToReturn = "I deployed to Kubernetes today"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); var correction = Assert.Single(_dictionary.GetCorrections()); @@ -584,7 +664,7 @@ public async Task Arm_IdleThenIdenticalFinalCommit_DoesNotInflateCount() client.RaiseText(s_field); await AwaitScheduledCommit(service); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); var entry = Assert.Single( @@ -643,7 +723,7 @@ public async Task Disable_MidWindow_StopsClientAndLearnsNothing() // A subsequent edit + focus-out must not learn anything. client.TextToReturn = "I deployed to Kubernetes today"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); Assert.Null(service.LastCommitTask); Assert.Empty(_dictionary.GetCorrections()); @@ -672,7 +752,7 @@ public async Task Disable_BetweenArmAndCommit_DoesNotReadOrLearn() settings.Save(AppSettings.Default with { TargetAppCorrectionLearningEnabled = false }); // Focus-out schedules a final commit; the opt-out guard must stop it before the read. - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); await AwaitCommit(service); Assert.Equal(1, client.TextReadCalls); // no second read after opt-out @@ -709,7 +789,7 @@ public async Task Arm_BaselineNeverContainsInsertedText_SkipsAfterRetries() // A later edit + focus-out on the (never-armed) field learns nothing. client.TextToReturn = "I deployed to Kubernetes today"; client.RaiseText(s_field); - client.RaiseFocus(s_otherField); + client.RaiseFocus(s_otherAppField); Assert.Null(service.LastCommitTask); Assert.Empty(_dictionary.GetCorrections()); @@ -810,7 +890,12 @@ private sealed class FakeAtSpiEventClient : IAtSpiEventClient // null models a role read that could not be determined (fail-closed path). public bool? PasswordResult { get; init; } = false; public string? TextToReturn { get; set; } + + // Per-element text for candidate-fallback tests; when null, TextToReturn is used + // for every element. + public Func? TextProvider { get; set; } public AtSpiElementRef? CurrentFocusedElement { get; set; } + public List RecentFocusedElements { get; } = []; public int EnsureStartedCalls { get; private set; } public int TextReadCalls { get; private set; } public int StopCalls { get; private set; } @@ -843,10 +928,15 @@ public Task StopAsync() return Task.CompletedTask; } + public IReadOnlyList GetRecentFocusedElements() + { + return [.. RecentFocusedElements]; + } + public Task TryReadTextAsync(AtSpiElementRef element, int maxLength) { TextReadCalls++; - return Task.FromResult(TextToReturn); + return Task.FromResult(TextProvider is not null ? TextProvider(element) : TextToReturn); } public Task IsPasswordFieldAsync(AtSpiElementRef element) diff --git a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs index df919bcee..2e32ea40b 100644 --- a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs @@ -1755,6 +1755,11 @@ public event Action? FocusChanged public bool IsRunning => true; + public IReadOnlyList GetRecentFocusedElements() + { + return []; + } + public bool HasTextChangedSubscribers => TextChanged is not null; public Task EnsureStartedAsync() From 951f31164d755f31f8c2d529a40ff79753df3ae7 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Fri, 10 Jul 2026 10:07:53 -0400 Subject: [PATCH 011/226] Enhance accessibility and stability in Linux services - Improved AccessibilityBusActivationService: better handling of ScreenReaderEnabled - Refined AtSpiEventClient: added IsRunning property, improved start/stop logic - Updated TargetAppCorrectionLearningService: added check to skip fallback when focused element is password - Removed default constructors from TextInsertionService for DI compatibility - Adjusted DictationSectionViewModel: clarified comments on accessibility flag ownership - Added test for skipping fallback on password fields in TargetAppCorrectionLearningService - Improved tests to verify behavior with focus on password fields and partial failures --- .../AccessibilityBusActivationService.cs | 18 ++++++-- .../Services/ActiveWindow/AtSpiEventClient.cs | 11 +++-- .../TargetAppCorrectionLearningService.cs | 18 +++++++- .../Services/TextInsertionService.cs | 29 +++---------- .../Sections/DictationSectionViewModel.cs | 43 +++++++++++++++---- .../AccessibilityBusActivationServiceTests.cs | 18 +++++++- ...TargetAppCorrectionLearningServiceTests.cs | 35 ++++++++++++++- 7 files changed, 127 insertions(+), 45 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs index 4fd11b820..6b67d3270 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs @@ -27,8 +27,11 @@ public interface IAccessibilityBusActivation /// /// Sets org.a11y.Status.IsEnabled (and ScreenReaderEnabled, which - /// Chromium/Electron also key off) on the session bus. Runtime-only — the value - /// resets at logout. Returns true when the write succeeded. + /// Chromium/Electron also key off) on the session bus. Where a GSettings/dconf backend + /// is present the a11y launcher mirrors these to + /// org.gnome.desktop.interface toolkit-accessibility, so the change can persist + /// across sessions rather than resetting at logout. Returns true when the write + /// succeeded. /// Task SetActivatedAsync(bool enabled, CancellationToken ct = default); } @@ -87,7 +90,16 @@ public async Task SetActivatedAsync(bool enabled, CancellationToken ct = d // is set alongside it because some Chromium builds check that one instead. The result of // the primary write is what we report; the secondary is best-effort. var ok = await SetPropertyAsync("IsEnabled", enabled, ct).ConfigureAwait(false); - await SetPropertyAsync("ScreenReaderEnabled", enabled, ct).ConfigureAwait(false); + + // When enabling, only mirror to ScreenReaderEnabled if the primary gate actually took: + // turning the screen-reader flag on by itself does nothing useful and would leave + // orphaned global state we just reported as failed. When disabling, always clear it so + // nothing is left behind. + if (ok || !enabled) + { + await SetPropertyAsync("ScreenReaderEnabled", enabled, ct).ConfigureAwait(false); + } + return ok; } diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs index 0285a9a8a..e4978b083 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -84,7 +84,6 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable private AtSpiElementRef? _currentFocused; private DBusConnection? _connection; - private bool _available; private bool _disposed; private bool _loggedUnavailable; private bool _started; @@ -141,13 +140,13 @@ public IReadOnlyList GetRecentFocusedElements() } } - public bool IsRunning => _available; + public bool IsRunning { get; private set; } public async Task EnsureStartedAsync() { if (_started) { - return _available; + return IsRunning; } await _startGate.WaitAsync().ConfigureAwait(false); @@ -155,11 +154,11 @@ public async Task EnsureStartedAsync() { if (_started) { - return _available; + return IsRunning; } var started = await TryStartAsync().ConfigureAwait(false); - _available = started; + IsRunning = started; // Only cache success. On failure TryStartAsync has already torn down any partial // connection, so leaving _started false lets a later call retry (e.g. the a11y bus // became available, or a transient connect error cleared). @@ -194,7 +193,7 @@ public async Task StopAsync() // Reset so the next EnsureStartedAsync reconnects fresh rather than returning // the stale cached availability. _started = false; - _available = false; + IsRunning = false; lock (_focusLock) { diff --git a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs index 328d1de1e..1eaf73358 100644 --- a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs +++ b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs @@ -214,6 +214,19 @@ public async Task ArmAsync(string insertedText) return; } + // If the field the dictation actually landed in is itself a password field, abort the + // whole arm — do not fall back to same-app siblings below. The inserted text IS the + // secret, and a sibling that happened to contain it would leak password-derived text + // into learned corrections. (An indeterminate role is not treated as a password here; + // it still falls through and is failed-closed per candidate during the read below.) + var focusedPassword = await _client.IsPasswordFieldAsync(element).ConfigureAwait(false); + if (focusedPassword == true) + { + Trace.WriteLine("[TargetAppLearning] Focused element is a password field; skipping."); + Disarm(); + return; + } + // Some apps (LibreOffice Writer) flap the AT-SPI focused state between the caret's // text widget and a structural pane with no readable text, so the newest focused // element is not always the one the dictation landed in. Try it first, then fall back @@ -251,8 +264,9 @@ public async Task ArmAsync(string insertedText) AtSpiElementRef? anchored = null; // Fail closed per candidate: only read text from elements positively known to be a // non-password role — null (role unreadable) counts as unsafe. Verdicts are cached so - // retry attempts don't re-issue role reads. - var safeToRead = new Dictionary(); + // retry attempts don't re-issue role reads; the focused element's verdict is already + // known from the password guard above. + var safeToRead = new Dictionary { [element] = focusedPassword == false }; for (var attempt = 0; attempt < 3 && anchored is null; attempt++) { if (attempt > 0) diff --git a/src/TypeWhisper.Linux/Services/TextInsertionService.cs b/src/TypeWhisper.Linux/Services/TextInsertionService.cs index 8c0f0e208..62b3b9690 100644 --- a/src/TypeWhisper.Linux/Services/TextInsertionService.cs +++ b/src/TypeWhisper.Linux/Services/TextInsertionService.cs @@ -93,16 +93,6 @@ public sealed class TextInsertionService private readonly ITextInsertionPlatform _platform; - public TextInsertionService() - : this(new LinuxTextInsertionPlatform()) - { - } - - public TextInsertionService(IErrorLogService errorLog) - : this(new LinuxTextInsertionPlatform(), errorLog) - { - } - // DI-preferred ctor: passes the shared SystemCommandAvailabilityService so the platform // subscribes to snapshot refreshes and rebuilds its chain live after ydotool setup — // without this the singleton's chain is frozen at startup and ydotool changes need a restart. @@ -432,17 +422,19 @@ private async Task WaitForClipboardToServeAsync(string expected) // content modulo that, matching the ownership check in the restore below. var read = await _platform.TryGetClipboardTextAsync(); if ( - read is not null - && string.Equals( + read is null + || !string.Equals( read.TrimEnd('\n'), expected.TrimEnd('\n'), StringComparison.Ordinal ) ) { - PasteDiag($"clipboard verified serving on attempt {attempt + 1}"); - return true; + continue; } + + PasteDiag($"clipboard verified serving on attempt {attempt + 1}"); + return true; } PasteDiag($"clipboard verify failed after {ClipboardVerifyAttempts} attempts"); @@ -786,15 +778,6 @@ private readonly Func< private LinuxCapabilitySnapshot _snapshot; - public LinuxTextInsertionPlatform() - : this( - new SystemCommandAvailabilityService(), - DefaultProcessRunnerWithEnv, - DefaultProcessRunnerWithStderr - ) - { - } - public LinuxTextInsertionPlatform(SystemCommandAvailabilityService commands) : this(commands, DefaultProcessRunnerWithEnv, DefaultProcessRunnerWithStderr) { diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs index cf4fbe0b3..51cf7bdee 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs @@ -18,6 +18,7 @@ namespace TypeWhisper.Linux.ViewModels.Sections; // ReSharper disable UnusedParameterInPartialMethod public partial class DictationSectionViewModel : ObservableObject { + // ReSharper disable once InconsistentNaming -- "a11y" is the standard accessibility numeronym (a + 11 letters + y) mirroring the org.a11y.Bus service name; ReSharper's camelCase splitter mis-reads "11y" and wants the non-standard "a11YBus". private readonly IAccessibilityBusActivation _a11yBus; private readonly AudioRecordingService _audio; private readonly SystemCommandAvailabilityService _commands; @@ -71,8 +72,9 @@ public partial class DictationSectionViewModel : ObservableObject // True only when TypeWhisper turned the bridge on this session. Gates the Remove action so // we never offer to disable a session-global accessibility flag a screen reader or other - // tool enabled. The flag resets at logout, so ownership is deliberately not persisted — - // after an app restart we simply stop offering removal rather than risk a false claim. + // tool enabled. Ownership is deliberately not persisted: the flag is dconf-backed and can + // survive a restart, and another tool may have changed it while we were closed, so after a + // restart we stop offering removal rather than risk a false claim. private bool _bridgeEnabledByThisApp; [ObservableProperty] @@ -193,6 +195,7 @@ public DictationSectionViewModel( ISettingsService settings, PluginManager pluginManager, SystemCommandAvailabilityService commands, + // ReSharper disable once InconsistentNaming -- "a11y" is the standard accessibility numeronym mirroring org.a11y.Bus; ReSharper's camelCase splitter mis-reads "11y". IAccessibilityBusActivation a11yBus ) { @@ -1356,8 +1359,8 @@ partial void OnAccessibilityBridgeActivatedChanged(bool value) } // Turns the session-bus accessibility bridge on so Electron/Chromium/Qt apps expose their - // text to correction learning. Runtime-only (resets at logout); an already-running target - // app must be restarted to pick it up, which the status message calls out. + // text to correction learning. An already-running target app must be restarted to pick it + // up, which the status message calls out. [RelayCommand] private Task EnableAccessibilityBridge() { @@ -1372,12 +1375,34 @@ private Task RemoveAccessibilityBridge() private async Task ToggleAccessibilityBridgeAsync(bool enable) { - var ok = await _a11yBus.SetActivatedAsync(enable); - if (ok) + bool ok; + if (enable) { - // Remember we own the bridge only after a successful enable; a successful remove - // clears ownership. See ShowAccessibilityBridgeRemove for why this gates the button. - _bridgeEnabledByThisApp = enable; + // Flip the bridge on only when we can confirm it is currently off. AccessibilityBridgeActivated + // is read asynchronously and defaults to false, so the Enable button can be showing over a + // bridge a screen reader already turned on — blindly re-writing would re-assert persistent + // global accessibility flags we won't offer a Remove for, leaving no in-app undo. So we no-op + // an already-on bridge, fail an indeterminate read, and claim ownership only on a flip we made. + var current = await _a11yBus.IsActivatedAsync(); + if (current == false) + { + ok = await _a11yBus.SetActivatedAsync(true); + _bridgeEnabledByThisApp = ok; + } + else + { + ok = current == true; + } + } + else + { + ok = await _a11yBus.SetActivatedAsync(false); + if (ok) + { + // A successful remove always clears ownership. See ShowAccessibilityBridgeRemove + // for why this gates the button. + _bridgeEnabledByThisApp = false; + } } await RefreshAccessibilityBridgeStateAsync(); diff --git a/tests/TypeWhisper.Linux.Tests/AccessibilityBusActivationServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AccessibilityBusActivationServiceTests.cs index e064d7221..bf91b722b 100644 --- a/tests/TypeWhisper.Linux.Tests/AccessibilityBusActivationServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AccessibilityBusActivationServiceTests.cs @@ -94,12 +94,28 @@ public async Task SetActivatedAsync_false_sets_flags_false() public async Task SetActivatedAsync_reports_failure_when_primary_write_fails() { var runner = new FakeProcessRunner(); - runner.FailWhen((file, args) => args.Contains("set-property") && args.Contains("IsEnabled")); + runner.FailWhen((_, args) => args.Contains("set-property") && args.Contains("IsEnabled")); var service = new AccessibilityBusActivationService(runner); Assert.False(await service.SetActivatedAsync(true)); } + [Fact] + public async Task SetActivatedAsync_true_skips_screen_reader_write_when_primary_write_fails() + { + // ScreenReaderEnabled alone is useless and would orphan global state we reported as failed. + var runner = new FakeProcessRunner(); + runner.FailWhen((_, args) => args.Contains("set-property") && args.Contains("IsEnabled")); + var service = new AccessibilityBusActivationService(runner); + + await service.SetActivatedAsync(true); + + Assert.DoesNotContain( + runner.Invocations, + i => i.Args.Contains("set-property") && i.Args.Contains("ScreenReaderEnabled") + ); + } + [Fact] public void IsHyprlandSession_true_when_instance_signature_present() { diff --git a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs index 9130edd0b..908df24e8 100644 --- a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs @@ -208,6 +208,35 @@ public async Task Arm_PasswordField_LearnsNothing() Assert.Equal(0, client.TextReadCalls); } + [Fact] + public async Task Arm_FocusedPasswordField_DoesNotFallBackToSibling() + { + // The dictation landed in a password field, but a same-app sibling happens to contain + // the same text. Falling back to it would leak password-derived text into learned + // corrections, so the whole arm must abort — and no field text may ever be read. + var password = new AtSpiElementRef("app", "/password"); + var sibling = new AtSpiElementRef("app", "/sibling"); + var client = new FakeAtSpiEventClient + { + CurrentFocusedElement = password, + PasswordProvider = e => e.Equals(password), + TextProvider = _ => "hunter2" + }; + client.RecentFocusedElements.AddRange([password, sibling]); + using var service = CreateService(client, enabled: true); + + await service.ArmAsync("hunter2"); + + // Not armed: an edit in the sibling must not be learned, and no text was ever read. + client.TextProvider = e => e.Equals(sibling) ? "hunter2 corrected" : "hunter2"; + client.RaiseText(sibling); + client.RaiseFocus(s_otherAppField); + + Assert.Null(service.LastCommitTask); + Assert.Empty(_dictionary.GetCorrections()); + Assert.Equal(0, client.TextReadCalls); + } + [Fact] public async Task Arm_PasswordRoleIndeterminate_FailsClosed_LearnsNothing() { @@ -894,6 +923,10 @@ private sealed class FakeAtSpiEventClient : IAtSpiEventClient // Per-element text for candidate-fallback tests; when null, TextToReturn is used // for every element. public Func? TextProvider { get; set; } + + // Per-element password verdict for candidate-fallback tests; when null, PasswordResult + // is used for every element. + public Func? PasswordProvider { get; init; } public AtSpiElementRef? CurrentFocusedElement { get; set; } public List RecentFocusedElements { get; } = []; public int EnsureStartedCalls { get; private set; } @@ -941,7 +974,7 @@ public IReadOnlyList GetRecentFocusedElements() public Task IsPasswordFieldAsync(AtSpiElementRef element) { - return Task.FromResult(PasswordResult); + return Task.FromResult(PasswordProvider is not null ? PasswordProvider(element) : PasswordResult); } public void RaiseFocus(AtSpiElementRef element) From a78997291164c4adaa1d6c14eb75d6f809629e42 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Fri, 10 Jul 2026 10:15:18 -0400 Subject: [PATCH 012/226] Offer the accessibility-bridge setup on all desktops, not just Hyprland MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hyprland-only gating was based on a wrong premise: GNOME (through 50) also leaves org.a11y.Status.IsEnabled off by default — the gsettings toolkit-accessibility key it mirrors has defaulted to false since GNOME made GTK accessibility unconditional instead. GTK apps work regardless, but Chromium/Electron (VS Code) and Qt apps gate their accessibility tree on this flag at launch, so correction learning needs the enable button on every desktop where the flag reads as off. The panel now shows whenever correction learning is on and the flag was positively read as false (never when the bus/busctl is unreadable — the button could only fail there); Hyprland session detection is removed. Setup explanation reworded desktop-neutrally in all four locales. Files changed: - src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs (drop IsHyprlandSession, correct docs) - src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs (state-known gating, desktop-neutral refresh) - src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml (comments) - src/TypeWhisper.Linux/ServiceRegistrations.cs (comment) - src/TypeWhisper.Linux/Resources/Localization/{en,de,es,ru}.json (neutral setup explanation) - tests/TypeWhisper.Linux.Tests/AccessibilityBusActivationServiceTests.cs (drop Hyprland detection tests) --- .../Resources/Localization/de.json | 2 +- .../Resources/Localization/en.json | 2 +- .../Resources/Localization/es.json | 2 +- .../Resources/Localization/ru.json | 2 +- src/TypeWhisper.Linux/ServiceRegistrations.cs | 7 ++-- .../AccessibilityBusActivationService.cs | 26 ++++-------- .../Sections/DictationSectionViewModel.cs | 40 +++++++++++-------- .../Views/Sections/DictationSection.axaml | 9 +++-- .../AccessibilityBusActivationServiceTests.cs | 40 +------------------ 9 files changed, 45 insertions(+), 85 deletions(-) diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index d46a3e789..4ef355267 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -183,7 +183,7 @@ "Dictation.AutoLearnCorrectionsHint": "Wenn Sie einen Verlaufseintrag bearbeiten, können eindeutige Phrasen-Vorschläge sofort gelernt werden.", "Dictation.TargetAppCorrectionLearning": "Korrekturen aus anderen Apps lernen", "Dictation.TargetAppCorrectionLearningHint": "Wenn Sie ein diktiertes Wort in einer anderen App überschreiben, um es zu korrigieren, lernt TypeWhisper die Korrektur stillschweigend und wendet sie auf zukünftige Diktate an. Liest das fokussierte Textfeld; standardmäßig deaktiviert.", - "Dictation.A11yBridgeSetupExplanation": "Sie verwenden Hyprland, das die Barrierefreiheitsbrücke, über die Apps ihren Text bereitstellen, nicht aktiviert. Das Korrekturlernen benötigt sie. Aktivieren Sie sie unten und starten Sie dann die App neu, aus der gelernt werden soll — Electron-Apps wie VS Code übernehmen die Einstellung nur beim Start.", + "Dictation.A11yBridgeSetupExplanation": "Ihre Desktop-Sitzung hat die Barrierefreiheitsbrücke, über die Apps ihren Text bereitstellen, nicht aktiviert. Das Korrekturlernen benötigt sie für Chromium/Electron-Apps wie VS Code sowie für Qt-Apps. Aktivieren Sie sie unten und starten Sie dann die App neu, aus der gelernt werden soll — diese Apps übernehmen die Einstellung nur beim Start.", "Dictation.A11yBridgeEnableButton": "Barrierefreiheitsbrücke aktivieren", "Dictation.A11yBridgeRemoveExplanation": "Die Barrierefreiheitsbrücke ist aktiviert. Sie aktiviert die Barrierefreiheit für alle Apps, was einen geringen Mehraufwand verursacht. Sie können sie entfernen, wenn Sie das Korrekturlernen nicht mehr verwenden.", "Dictation.A11yBridgeRemoveButton": "Barrierefreiheitsbrücke entfernen", diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index 4eabdfad8..c34c3f3b8 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -183,7 +183,7 @@ "Dictation.AutoLearnCorrectionsHint": "When you edit a history item, clear phrase-level suggestions can be learned immediately.", "Dictation.TargetAppCorrectionLearning": "Learn corrections from other apps", "Dictation.TargetAppCorrectionLearningHint": "When you type over a dictated word in another app to fix it, TypeWhisper silently learns the correction and applies it to future dictations. Reads the focused text field; off by default.", - "Dictation.A11yBridgeSetupExplanation": "You're on Hyprland, which doesn't enable the accessibility bridge that lets apps expose their text. Correction learning needs it. Enable it below, then restart the app you want to learn from — Electron apps like VS Code only pick it up when they launch.", + "Dictation.A11yBridgeSetupExplanation": "Your desktop session hasn't enabled the accessibility bridge that lets apps expose their text. Correction learning needs it for Chromium/Electron apps like VS Code and for Qt apps. Enable it below, then restart the app you want to learn from — those apps only pick it up when they launch.", "Dictation.A11yBridgeEnableButton": "Enable accessibility bridge", "Dictation.A11yBridgeRemoveExplanation": "The accessibility bridge is enabled. It activates accessibility for all apps, which adds a small overhead. You can remove it if you stop using correction learning.", "Dictation.A11yBridgeRemoveButton": "Remove accessibility bridge", diff --git a/src/TypeWhisper.Linux/Resources/Localization/es.json b/src/TypeWhisper.Linux/Resources/Localization/es.json index 05a9b317c..46b107e83 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -183,7 +183,7 @@ "Dictation.AutoLearnCorrectionsHint": "Cuando editas un elemento del historial, las sugerencias claras a nivel de frase pueden aprenderse de inmediato.", "Dictation.TargetAppCorrectionLearning": "Aprender correcciones de otras apps", "Dictation.TargetAppCorrectionLearningHint": "Cuando escribes sobre una palabra dictada en otra app para corregirla, TypeWhisper aprende la corrección de forma silenciosa y la aplica a los dictados futuros. Lee el campo de texto enfocado; desactivado de forma predeterminada.", - "Dictation.A11yBridgeSetupExplanation": "Estás en Hyprland, que no activa el puente de accesibilidad que permite a las apps exponer su texto. El aprendizaje de correcciones lo necesita. Actívalo abajo y luego reinicia la app de la que quieres aprender: las apps de Electron como VS Code solo lo detectan al iniciarse.", + "Dictation.A11yBridgeSetupExplanation": "Tu sesión de escritorio no ha activado el puente de accesibilidad que permite a las apps exponer su texto. El aprendizaje de correcciones lo necesita para apps de Chromium/Electron como VS Code y para apps de Qt. Actívalo abajo y luego reinicia la app de la que quieres aprender: esas apps solo lo detectan al iniciarse.", "Dictation.A11yBridgeEnableButton": "Activar puente de accesibilidad", "Dictation.A11yBridgeRemoveExplanation": "El puente de accesibilidad está activado. Activa la accesibilidad para todas las apps, lo que añade una pequeña sobrecarga. Puedes quitarlo si dejas de usar el aprendizaje de correcciones.", "Dictation.A11yBridgeRemoveButton": "Quitar puente de accesibilidad", diff --git a/src/TypeWhisper.Linux/Resources/Localization/ru.json b/src/TypeWhisper.Linux/Resources/Localization/ru.json index 16357044a..21bf6c35a 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -183,7 +183,7 @@ "Dictation.AutoLearnCorrectionsHint": "Когда вы редактируете элемент истории, очевидные исправления на уровне фраз могут быть выучены сразу.", "Dictation.TargetAppCorrectionLearning": "Учить исправления из других приложений", "Dictation.TargetAppCorrectionLearningHint": "Когда вы исправляете продиктованное слово, набирая поверх него в другом приложении, TypeWhisper незаметно запоминает исправление и применяет его к будущим диктовкам. Читает текстовое поле в фокусе; по умолчанию выключено.", - "Dictation.A11yBridgeSetupExplanation": "Вы используете Hyprland, который не включает мост специальных возможностей, позволяющий приложениям предоставлять свой текст. Обучению исправлениям он необходим. Включите его ниже, затем перезапустите приложение, из которого нужно обучаться, — приложения на Electron, такие как VS Code, применяют эту настройку только при запуске.", + "Dictation.A11yBridgeSetupExplanation": "Ваш сеанс рабочего стола не включил мост специальных возможностей, позволяющий приложениям предоставлять свой текст. Обучению исправлениям он необходим для приложений на Chromium/Electron, таких как VS Code, и для приложений на Qt. Включите его ниже, затем перезапустите приложение, из которого нужно обучаться, — такие приложения применяют эту настройку только при запуске.", "Dictation.A11yBridgeEnableButton": "Включить мост специальных возможностей", "Dictation.A11yBridgeRemoveExplanation": "Мост специальных возможностей включён. Он активирует специальные возможности для всех приложений, что создаёт небольшую нагрузку. Вы можете удалить его, если больше не используете обучение исправлениям.", "Dictation.A11yBridgeRemoveButton": "Удалить мост специальных возможностей", diff --git a/src/TypeWhisper.Linux/ServiceRegistrations.cs b/src/TypeWhisper.Linux/ServiceRegistrations.cs index a7630177c..64dbc016b 100644 --- a/src/TypeWhisper.Linux/ServiceRegistrations.cs +++ b/src/TypeWhisper.Linux/ServiceRegistrations.cs @@ -90,9 +90,10 @@ public static void Register(IServiceCollection services) // insertion path is unchanged unless correction learning already turned them on. services.AddSingleton(); services.AddSingleton(); - // Toggles the session-bus accessibility flag (org.a11y.Status.IsEnabled) so - // Chromium/Electron/Qt apps expose text on Hyprland; surfaced as a button in the - // Dictation settings when target-app correction learning is enabled. + // Toggles the session-bus accessibility flag (org.a11y.Status.IsEnabled) that + // Chromium/Electron/Qt apps gate their accessibility tree on; most desktops leave it + // off by default. Surfaced as a button in the Dictation settings when target-app + // correction learning is enabled and the flag reads as off. services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs index 6b67d3270..7204f5600 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs @@ -4,21 +4,16 @@ namespace TypeWhisper.Linux.Services.ActiveWindow; /// /// Reads and toggles the session-bus accessibility activation flag -/// (org.a11y.Status.IsEnabled). Chromium/Electron and Qt apps only build and -/// publish their accessibility tree when this flag is true; GNOME/Cinnamon set -/// it at login, but bare wlroots sessions (Hyprland) leave it false, so those -/// apps expose no readable text and target-app correction learning silently no-ops. -/// Behind an interface so the settings ViewModel can be unit-tested with a fake. +/// (org.a11y.Status.IsEnabled). GTK apps expose their accessibility tree +/// unconditionally, but Chromium/Electron (VS Code) and Qt apps only build theirs when +/// this flag is true at app launch — and most desktops leave it false by +/// default (GNOME through 50 mirrors the gsettings toolkit-accessibility key, +/// whose default is false; bare wlroots sessions like Hyprland set nothing at all), so +/// those apps expose no readable text and target-app correction learning silently +/// no-ops. Behind an interface so the settings ViewModel can be unit-tested with a fake. /// public interface IAccessibilityBusActivation { - /// - /// true on a Hyprland session — where the accessibility bridge is not managed - /// by the desktop, so TypeWhisper offers to toggle it. Other desktops either manage it - /// themselves (GNOME/Cinnamon) or are out of scope, so the setup UI stays hidden there. - /// - bool IsHyprlandSession { get; } - /// /// Reads org.a11y.Status.IsEnabled from the session bus. Returns null /// when the value can't be determined (busctl missing, bus unreachable, unparsable). @@ -51,13 +46,6 @@ public AccessibilityBusActivationService(IProcessRunner processRunner) _processRunner = processRunner; } - public bool IsHyprlandSession => - !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE")) - || (Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP") ?? string.Empty).Contains( - "Hyprland", - StringComparison.OrdinalIgnoreCase - ); - public async Task IsActivatedAsync(CancellationToken ct = default) { var result = await _processRunner diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs index 51cf7bdee..a944943db 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs @@ -63,10 +63,14 @@ public partial class DictationSectionViewModel : ObservableObject private bool _targetAppCorrectionLearningEnabled; // Reflects org.a11y.Status.IsEnabled on the session bus. Drives which of the - // Hyprland accessibility-bridge buttons (enable vs remove) is shown. + // accessibility-bridge buttons (enable vs remove) is shown. [ObservableProperty] private bool _accessibilityBridgeActivated; + // Set once the flag has been successfully read from the session bus; until then neither + // bridge button is offered (a toggle on an unreadable bus could only fail). + private bool _accessibilityBridgeStateKnown; + [ObservableProperty] private string _accessibilityBridgeStatus = ""; @@ -252,8 +256,8 @@ IAccessibilityBusActivation a11yBus RefreshDevices(); RefreshFromSettings(_settings.Current); - // Read the current accessibility-bridge flag so the Hyprland enable/remove button - // reflects reality on first paint (Hyprland-only; the call no-ops elsewhere). + // Read the current accessibility-bridge flag so the enable/remove button reflects + // reality on first paint. _ = RefreshAccessibilityBridgeStateAsync(); } @@ -328,18 +332,20 @@ IAccessibilityBusActivation a11yBus public string SoundFeedbackUnavailableReason => Loc.Instance["Dictation.AudioPlayerUnavailable"]; - // Hyprland doesn't enable the accessibility bridge that lets apps expose their text, so - // correction learning can't read them until it's turned on. Offer the enable button only - // when the feature is on and the bridge is off; offer removal only when WE turned it on - // this session — the flag is session-global, so a screen reader or other tool may have - // enabled it and we must never present a button that disables their accessibility. + // Most desktops (GNOME through 50, Hyprland/wlroots) leave the session accessibility + // flag off, and Chromium/Electron and Qt apps won't expose their text to correction + // learning until it's on. Offer the enable button only when the feature is on, the flag + // was positively read as off (never on systems where it can't be read — the click would + // just fail), and offer removal only when WE turned it on this session — the flag is + // session-global, so a screen reader or other tool may have enabled it and we must never + // present a button that disables their accessibility. public bool ShowAccessibilityBridgeSetup => - _a11yBus.IsHyprlandSession + _accessibilityBridgeStateKnown && TargetAppCorrectionLearningEnabled && !AccessibilityBridgeActivated; public bool ShowAccessibilityBridgeRemove => - _a11yBus.IsHyprlandSession && AccessibilityBridgeActivated && _bridgeEnabledByThisApp; + AccessibilityBridgeActivated && _bridgeEnabledByThisApp; public bool CanDeleteSelectedModel => SelectedModel is { } selected && _models.CanDeleteModel(selected.ModelId); @@ -1416,15 +1422,17 @@ private async Task ToggleAccessibilityBridgeAsync(bool enable) private async Task RefreshAccessibilityBridgeStateAsync() { - if (!_a11yBus.IsHyprlandSession) - { - return; - } - var activated = await _a11yBus.IsActivatedAsync(); if (activated is { } value) { - Dispatcher.UIThread.Post(() => AccessibilityBridgeActivated = value); + Dispatcher.UIThread.Post(() => + { + _accessibilityBridgeStateKnown = true; + AccessibilityBridgeActivated = value; + // The activated setter only notifies on a change; the state-known flip alone + // (false -> false read) must still reveal the setup panel. + OnPropertyChanged(nameof(ShowAccessibilityBridgeSetup)); + }); } } diff --git a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml index 2156e2a08..0fd73d7a0 100644 --- a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml @@ -421,8 +421,9 @@ HorizontalAlignment="Right" /> - + - + /// Covers : parsing the busctl -/// get-property output, issuing the correct set-property calls, failure handling, -/// and Hyprland session detection. +/// get-property output, issuing the correct set-property calls, and failure handling. /// public sealed class AccessibilityBusActivationServiceTests { @@ -116,41 +115,4 @@ public async Task SetActivatedAsync_true_skips_screen_reader_write_when_primary_ ); } - [Fact] - public void IsHyprlandSession_true_when_instance_signature_present() - { - var sig = Environment.GetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE"); - var desktop = Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP"); - try - { - Environment.SetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE", "abc123"); - Environment.SetEnvironmentVariable("XDG_CURRENT_DESKTOP", ""); - var service = new AccessibilityBusActivationService(new FakeProcessRunner()); - Assert.True(service.IsHyprlandSession); - } - finally - { - Environment.SetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE", sig); - Environment.SetEnvironmentVariable("XDG_CURRENT_DESKTOP", desktop); - } - } - - [Fact] - public void IsHyprlandSession_false_on_gnome() - { - var sig = Environment.GetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE"); - var desktop = Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP"); - try - { - Environment.SetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE", null); - Environment.SetEnvironmentVariable("XDG_CURRENT_DESKTOP", "GNOME"); - var service = new AccessibilityBusActivationService(new FakeProcessRunner()); - Assert.False(service.IsHyprlandSession); - } - finally - { - Environment.SetEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE", sig); - Environment.SetEnvironmentVariable("XDG_CURRENT_DESKTOP", desktop); - } - } } From 78623bbc5b0eb24c520ef8fb052358bf9033e8df Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Fri, 10 Jul 2026 10:26:26 -0400 Subject: [PATCH 013/226] Never set ScreenReaderEnabled: it launches Orca on GNOME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling the accessibility bridge on GNOME made the whole desktop start speaking: at-spi-bus-launcher mirrors org.a11y.Status.ScreenReaderEnabled into the gsettings key org.gnome.desktop.a11y.applications screen-reader-enabled, and GNOME launches the Orca screen reader when that key turns on. The secondary write was also pointless for its stated purpose — Chromium reads only IsEnabled (verified against Chromium 148 source), and Qt accepts IsEnabled as well. SetActivatedAsync now writes IsEnabled only, in both directions, with a regression-guard test asserting ScreenReaderEnabled is never touched. Files changed: - src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs (drop ScreenReaderEnabled write, document why) - tests/TypeWhisper.Linux.Tests/AccessibilityBusActivationServiceTests.cs (single-flag assertions + never-touches guard) - docs/target-app-correction-learning/jetbrains-rider-atspi-support.md (accuracy: toggle sets IsEnabled only) --- .../AccessibilityBusActivationService.cs | 32 +++++------- .../AccessibilityBusActivationServiceTests.cs | 51 ++++++++----------- 2 files changed, 35 insertions(+), 48 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs index 7204f5600..17399493a 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs @@ -21,12 +21,17 @@ public interface IAccessibilityBusActivation Task IsActivatedAsync(CancellationToken ct = default); /// - /// Sets org.a11y.Status.IsEnabled (and ScreenReaderEnabled, which - /// Chromium/Electron also key off) on the session bus. Where a GSettings/dconf backend - /// is present the a11y launcher mirrors these to + /// Sets org.a11y.Status.IsEnabled on the session bus. Where a GSettings/dconf + /// backend is present the a11y launcher mirrors it to /// org.gnome.desktop.interface toolkit-accessibility, so the change can persist /// across sessions rather than resetting at logout. Returns true when the write /// succeeded. + /// + /// Deliberately never touches ScreenReaderEnabled: GNOME mirrors that one to + /// org.gnome.desktop.a11y.applications screen-reader-enabled, which LAUNCHES + /// the Orca screen reader and makes the whole desktop speak. Chromium reads only + /// IsEnabled, and Qt accepts either, so IsEnabled alone is sufficient. + /// /// Task SetActivatedAsync(bool enabled, CancellationToken ct = default); } @@ -72,23 +77,12 @@ public AccessibilityBusActivationService(IProcessRunner processRunner) return text.EndsWith("false", StringComparison.OrdinalIgnoreCase) ? false : null; } - public async Task SetActivatedAsync(bool enabled, CancellationToken ct = default) + public Task SetActivatedAsync(bool enabled, CancellationToken ct = default) { - // IsEnabled is the flag Chromium/Qt/Firefox gate their a11y tree on; ScreenReaderEnabled - // is set alongside it because some Chromium builds check that one instead. The result of - // the primary write is what we report; the secondary is best-effort. - var ok = await SetPropertyAsync("IsEnabled", enabled, ct).ConfigureAwait(false); - - // When enabling, only mirror to ScreenReaderEnabled if the primary gate actually took: - // turning the screen-reader flag on by itself does nothing useful and would leave - // orphaned global state we just reported as failed. When disabling, always clear it so - // nothing is left behind. - if (ok || !enabled) - { - await SetPropertyAsync("ScreenReaderEnabled", enabled, ct).ConfigureAwait(false); - } - - return ok; + // IsEnabled is the flag Chromium/Qt/Firefox gate their a11y tree on, and the ONLY + // property we write. Never set ScreenReaderEnabled here — GNOME mirrors it into the + // screen-reader-enabled gsettings key, which launches Orca and makes the desktop speak. + return SetPropertyAsync("IsEnabled", enabled, ct); } private async Task SetPropertyAsync(string property, bool value, CancellationToken ct) diff --git a/tests/TypeWhisper.Linux.Tests/AccessibilityBusActivationServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AccessibilityBusActivationServiceTests.cs index 3e6f10e2e..bf1f46033 100644 --- a/tests/TypeWhisper.Linux.Tests/AccessibilityBusActivationServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AccessibilityBusActivationServiceTests.cs @@ -53,7 +53,7 @@ public async Task IsActivatedAsync_returns_null_on_unparsable_output() } [Fact] - public async Task SetActivatedAsync_true_sets_both_flags_true() + public async Task SetActivatedAsync_true_sets_only_IsEnabled() { var runner = new FakeProcessRunner(); var service = new AccessibilityBusActivationService(runner); @@ -61,58 +61,51 @@ public async Task SetActivatedAsync_true_sets_both_flags_true() var ok = await service.SetActivatedAsync(true); Assert.True(ok); - var setCalls = runner - .Invocations.Where(i => i.FileName == "busctl" && i.Args.Contains("set-property")) - .ToList(); - Assert.Equal(2, setCalls.Count); - Assert.Contains( - setCalls, - c => c.Args.Contains("IsEnabled") && c.Args[^2] == "b" && c.Args[^1] == "true" - ); - Assert.Contains( - setCalls, - c => c.Args.Contains("ScreenReaderEnabled") && c.Args[^1] == "true" + var setCall = Assert.Single( + runner.Invocations, + i => i.FileName == "busctl" && i.Args.Contains("set-property") ); + Assert.Contains("IsEnabled", setCall.Args); + Assert.Equal("b", setCall.Args[^2]); + Assert.Equal("true", setCall.Args[^1]); } [Fact] - public async Task SetActivatedAsync_false_sets_flags_false() + public async Task SetActivatedAsync_never_touches_ScreenReaderEnabled() { + // GNOME mirrors ScreenReaderEnabled into the screen-reader-enabled gsettings key, + // which LAUNCHES Orca and makes the whole desktop speak. Regression guard: this + // service must never write that property, in either direction. var runner = new FakeProcessRunner(); var service = new AccessibilityBusActivationService(runner); + await service.SetActivatedAsync(true); await service.SetActivatedAsync(false); - var isEnabled = runner.Invocations.Single(i => - i.Args.Contains("set-property") && i.Args.Contains("IsEnabled") - ); - Assert.Equal("false", isEnabled.Args[^1]); + Assert.DoesNotContain(runner.Invocations, i => i.Args.Contains("ScreenReaderEnabled")); } [Fact] - public async Task SetActivatedAsync_reports_failure_when_primary_write_fails() + public async Task SetActivatedAsync_false_sets_IsEnabled_false() { var runner = new FakeProcessRunner(); - runner.FailWhen((_, args) => args.Contains("set-property") && args.Contains("IsEnabled")); var service = new AccessibilityBusActivationService(runner); - Assert.False(await service.SetActivatedAsync(true)); + await service.SetActivatedAsync(false); + + var isEnabled = runner.Invocations.Single(i => + i.Args.Contains("set-property") && i.Args.Contains("IsEnabled") + ); + Assert.Equal("false", isEnabled.Args[^1]); } [Fact] - public async Task SetActivatedAsync_true_skips_screen_reader_write_when_primary_write_fails() + public async Task SetActivatedAsync_reports_failure_when_write_fails() { - // ScreenReaderEnabled alone is useless and would orphan global state we reported as failed. var runner = new FakeProcessRunner(); runner.FailWhen((_, args) => args.Contains("set-property") && args.Contains("IsEnabled")); var service = new AccessibilityBusActivationService(runner); - await service.SetActivatedAsync(true); - - Assert.DoesNotContain( - runner.Invocations, - i => i.Args.Contains("set-property") && i.Args.Contains("ScreenReaderEnabled") - ); + Assert.False(await service.SetActivatedAsync(true)); } - } From 7409ec70b1f0cb5ddbaee3465821286d75dfa618 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Fri, 10 Jul 2026 12:13:16 -0400 Subject: [PATCH 014/226] Implement accessibility tree poke in AtSpiEventClient and trigger upon enabling correction learning - Added PokeAccessibilityTreesAsync() to IAtSpiEventClient.cs and AtSpiEventClient.cs - Implemented poke logic to unlock Chromium/Electron apps by touching app root and children - Called PokeAccessibilityTreesAsync() when TargetAppCorrectionLearningService enables correction learning and after ensure started - Updated relevant tests to mock poke method calls for Chromium app unlocking - Enhanced AppSettings with AccessibilityBridgeEnabledByApp to track ownership - Modified DictationSectionViewModel to use settings for visibility and toggle of accessibility bridge - Ensured unlock sweep runs after correction feature is toggled on and during app startup, improving accessibility detection for Chromium-based apps --- src/TypeWhisper.Core/Models/AppSettings.cs | 7 + .../Services/ActiveWindow/AtSpiEventClient.cs | 173 ++++++++++++++++++ .../ActiveWindow/IAtSpiEventClient.cs | 10 + .../TargetAppCorrectionLearningService.cs | 36 +++- .../Sections/DictationSectionViewModel.cs | 28 ++- ...TargetAppCorrectionLearningServiceTests.cs | 22 +++ .../TextInsertionServiceTests.cs | 5 + 7 files changed, 264 insertions(+), 17 deletions(-) diff --git a/src/TypeWhisper.Core/Models/AppSettings.cs b/src/TypeWhisper.Core/Models/AppSettings.cs index 450f2c729..a7cc584f5 100644 --- a/src/TypeWhisper.Core/Models/AppSettings.cs +++ b/src/TypeWhisper.Core/Models/AppSettings.cs @@ -124,6 +124,13 @@ public Dictionary AppInsertionStrategies // (Wispr-Flow-style). Default off — opt-in, since it reads other apps' field text. public bool TargetAppCorrectionLearningEnabled { get; init; } + // True when TypeWhisper itself turned on the session accessibility flag + // (org.a11y.Status.IsEnabled) via the Dictation-settings bridge button. Gates the + // "Remove accessibility bridge" button across restarts: the flag is session-global + // (and persists via gsettings on GNOME), so removal is only ever offered for a state + // this app created — never for one a screen reader or other tool may rely on. + public bool AccessibilityBridgeEnabledByApp { get; init; } + // Onboarding public bool HasCompletedOnboarding { get; init; } public string SelectedIndustryPresetId { get; init; } = "general"; diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs index e4978b083..265d69804 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -31,6 +31,11 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable private const string RegistryBusName = "org.a11y.atspi.Registry"; private const string RegistryPath = "/org/a11y/atspi/registry"; private const string RegistryInterface = "org.a11y.atspi.Registry"; + private const string RegistryRootPath = "/org/a11y/atspi/accessible/root"; + + // How many top-level children (windows) of each application root get the Chromium + // unlock poke; one window is the normal case, a few more cover multi-window apps. + private const int MaxPokedChildren = 4; private const string EventObjectInterface = "org.a11y.atspi.Event.Object"; private const string TextInterface = "org.a11y.atspi.Text"; @@ -58,6 +63,24 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable private static readonly MessageValueReader s_readUInt32 = static (m, _) => m.GetBodyReader().ReadUInt32(); + // Reads a D-Bus a(so) array — the (unique bus name, object path) pairs the registry + // and Accessible.GetChildren return. + private static readonly MessageValueReader> s_readElementRefArray = + static (m, _) => + { + var reader = m.GetBodyReader(); + var list = new List(); + var end = reader.ReadArrayStart(DBusType.Struct); + while (reader.HasNext(end)) + { + var bus = reader.ReadString(); + var path = reader.ReadObjectPath().ToString(); + list.Add(new AtSpiElementRef(bus, path)); + } + + return list; + }; + // Reads the leading (detail: string, detail1: int) of an AT-SPI event body // (full signature "siiv(so)"); the source element is taken from the message // header (sender + path), matching how libatspi/pyatspi derive event.source. @@ -82,6 +105,10 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable // Bounded most-recent-first focus history behind _focusLock; see GetRecentFocusedElements. private readonly List _recentFocused = []; + // Unique bus names already sent the Chromium web-content unlock poke on the current + // connection (unique names are never recycled within a bus lifetime). Behind _focusLock. + private readonly HashSet _pokedApps = []; + private AtSpiElementRef? _currentFocused; private DBusConnection? _connection; private bool _disposed; @@ -199,6 +226,9 @@ public async Task StopAsync() { _currentFocused = null; _recentFocused.Clear(); + // Unique names may outlive our connection, but a fresh connection re-pokes + // cheaply and correctly (apps keep their unlocked state anyway). + _pokedApps.Clear(); } } finally @@ -283,6 +313,149 @@ public async Task StopAsync() } } + public async Task PokeAccessibilityTreesAsync() + { + var conn = _connection; + if (conn is null) + { + return; + } + + try + { + var apps = await GetChildrenAsync(conn, RegistryBusName, RegistryRootPath) + .ConfigureAwait(false); + foreach (var app in apps) + { + bool alreadyPoked; + lock (_focusLock) + { + alreadyPoked = !_pokedApps.Add(app.BusName); + } + + if (alreadyPoked) + { + continue; + } + + // Fire-and-forget per app: a hung target must not stall the sweep. The + // optimistic Add above dedupes concurrent sweeps; on failure we un-mark the + // app so a later arm retries it — a transient D-Bus error must never leave an + // app permanently skipped with its tree still locked. + _ = PokeAppAndTrackAsync(conn, app); + } + } + catch (Exception ex) + { + Trace.WriteLine($"[AtSpiEventClient] a11y app enumeration for poke failed: {ex.Message}"); + } + } + + private async Task PokeAppAndTrackAsync(DBusConnection conn, AtSpiElementRef appRoot) + { + if (!await PokeAppAsync(conn, appRoot).ConfigureAwait(false)) + { + lock (_focusLock) + { + _pokedApps.Remove(appRoot.BusName); + } + } + } + + // Chromium/Electron apps join the a11y bus (when org.a11y.Status.IsEnabled was true at + // their launch) but expose only their application root and toplevel window until an + // assistive tool calls GetAttributes or GetRelationSet on one of their nodes — that call + // is Chromium's "a screen reader is actually reading me" signal and switches the web + // content (editor) tree on. Touch the app root and its first few children; results are + // discarded, only the calls matter. Harmless no-op for every other toolkit. Each node/call + // is isolated (see TouchElementAsync), so one unavailable member or defunct child never + // skips the rest. Returns false only when nothing reached the app at all, so the caller can + // un-mark it for a later retry; a partial success still counts as poked. + private static async Task PokeAppAsync(DBusConnection conn, AtSpiElementRef appRoot) + { + var anyTouched = await TouchElementAsync(conn, appRoot).ConfigureAwait(false); + + List children; + try + { + children = await GetChildrenAsync(conn, appRoot.BusName, appRoot.ObjectPath) + .ConfigureAwait(false); + } + catch (Exception ex) + { + // The app may have exited or not expose GetChildren; the root touch above may + // still have unlocked it, so report whatever it achieved. + Trace.WriteLine( + $"[AtSpiEventClient] a11y child enumeration of {appRoot.BusName} failed: {ex.Message}" + ); + return anyTouched; + } + + foreach (var child in children.Take(MaxPokedChildren)) + { + anyTouched |= await TouchElementAsync(conn, child).ConfigureAwait(false); + } + + return anyTouched; + } + + // Issues both Chromium unlock triggers (GetAttributes, GetRelationSet) on one node, + // independently: one being unavailable or failing must not skip the other. Returns true if + // at least one call succeeded. + private static async Task TouchElementAsync(DBusConnection conn, AtSpiElementRef element) + { + var touched = false; + foreach (var member in (string[])["GetAttributes", "GetRelationSet"]) + { + try + { + MessageBuffer message; + using (var writer = conn.GetMessageWriter()) + { + writer.WriteMethodCallHeader( + destination: element.BusName, + path: element.ObjectPath, + @interface: AccessibleInterface, + member: member + ); + message = writer.CreateMessage(); + } + + await conn.CallMethodAsync(message).ConfigureAwait(false); + touched = true; + } + catch (Exception ex) + { + Trace.WriteLine( + $"[AtSpiEventClient] a11y {member} on {element.BusName} failed: {ex.Message}" + ); + } + } + + return touched; + } + + private static async Task> GetChildrenAsync( + DBusConnection conn, + string busName, + string objectPath + ) + { + MessageBuffer message; + using (var writer = conn.GetMessageWriter()) + { + writer.WriteMethodCallHeader( + destination: busName, + path: objectPath, + @interface: AccessibleInterface, + member: "GetChildren" + ); + message = writer.CreateMessage(); + } + + return await conn.CallMethodAsync(message, s_readElementRefArray).ConfigureAwait(false); + } + private async Task TryStartAsync() { try diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs index 7322bf3e4..490776370 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs @@ -77,4 +77,14 @@ public interface IAtSpiEventClient /// — this guards a privacy boundary, so "unknown" must never be read as "safe". /// Task IsPasswordFieldAsync(AtSpiElementRef element); + + /// + /// Best-effort sweep over the applications on the a11y bus, touching each unseen + /// app's tree once (Accessible.GetAttributes/GetRelationSet). Chromium/Electron apps + /// expose only a stub tree until an assistive tool makes such a call — it is their + /// "someone is reading me" signal — so this unlocks their text for correction + /// learning. Harmless no-op for other toolkits; per-app failures are swallowed. + /// No-op when the client is not connected. + /// + Task PokeAccessibilityTreesAsync(); } diff --git a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs index 1eaf73358..b1d5e4e9e 100644 --- a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs +++ b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs @@ -197,23 +197,32 @@ public async Task ArmAsync(string insertedText) EnsureSubscribed(); - var focused = _client.CurrentFocusedElement; - if (focused is not { IsValid: true } element) - { - Trace.WriteLine("[TargetAppLearning] No focused AT-SPI element to track; skipping."); - return; - } - // Re-check opt-out after EnsureStartedAsync: the user could have disabled the feature // while it was connecting. Disable runs StopAsync on a separate gate and does not // serialize with this fire-and-forget arm, so we must not do further accessibility - // reads after opt-out. (Re-checked again immediately before the text read below.) + // work after opt-out. (Re-checked again immediately before the text read below.) if (IsOptedOut()) { Disarm(); return; } + // Unlock any Chromium/Electron app that joined the a11y bus since the client started + // (their tree stays a stub until poked — see PokeAccessibilityTreesAsync). Runs after + // the opt-out re-check above so a disable mid-connect stops this cross-app sweep, but + // before the focused-element check below so a late app that exposes no focused node + // yet — the bootstrap case this sweep exists for — is still unlocked for its next arm. + // Fire-and-forget: the unlock takes effect for the NEXT dictation into that app; + // holding this arm for it would delay every arm for a rare first-contact case. + _ = _client.PokeAccessibilityTreesAsync(); + + var focused = _client.CurrentFocusedElement; + if (focused is not { IsValid: true } element) + { + Trace.WriteLine("[TargetAppLearning] No focused AT-SPI element to track; skipping."); + return; + } + // If the field the dictation actually landed in is itself a password field, abort the // whole arm — do not fall back to same-app siblings below. The inserted text IS the // secret, and a sibling that happened to contain it would leak password-derived text @@ -576,6 +585,17 @@ private async Task ReconcileListeningAsync() if (await _client.EnsureStartedAsync().ConfigureAwait(false)) { EnsureSubscribed(); + + // Unlock already-running Chromium/Electron apps now that the feature is on + // (their tree stays a stub until poked — see PokeAccessibilityTreesAsync). + // Re-check consent after the connect await: a disable during EnsureStartedAsync + // is queued behind _listenGate and runs its teardown next, so we must not + // launch the cross-app sweep once the setting has flipped off. Later arms + // re-poke any app that joined the bus after this. + if (!_disposed && _settings.Current.TargetAppCorrectionLearningEnabled) + { + _ = _client.PokeAccessibilityTreesAsync(); + } } else { diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs index a944943db..ff0db31bc 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs @@ -74,12 +74,12 @@ public partial class DictationSectionViewModel : ObservableObject [ObservableProperty] private string _accessibilityBridgeStatus = ""; - // True only when TypeWhisper turned the bridge on this session. Gates the Remove action so - // we never offer to disable a session-global accessibility flag a screen reader or other - // tool enabled. Ownership is deliberately not persisted: the flag is dconf-backed and can - // survive a restart, and another tool may have changed it while we were closed, so after a - // restart we stop offering removal rather than risk a false claim. - private bool _bridgeEnabledByThisApp; + // Ownership of the bridge flag (did TypeWhisper turn it on?) lives in persisted settings + // (AppSettings.AccessibilityBridgeEnabledByApp) so the Remove button survives app + // restarts — the flag itself is dconf-backed on GNOME and outlives the session, so a + // session-only memory would strand users with no in-app undo. Removal is still never + // offered for a flag some other tool (screen reader) enabled: only a flip WE made while + // it read as off records ownership. [ObservableProperty] private bool _autoPaste; @@ -345,7 +345,7 @@ IAccessibilityBusActivation a11yBus && !AccessibilityBridgeActivated; public bool ShowAccessibilityBridgeRemove => - AccessibilityBridgeActivated && _bridgeEnabledByThisApp; + AccessibilityBridgeActivated && _settings.Current.AccessibilityBridgeEnabledByApp; public bool CanDeleteSelectedModel => SelectedModel is { } selected && _models.CanDeleteModel(selected.ModelId); @@ -717,6 +717,9 @@ private void RefreshFromSettings(AppSettings settings) OnPropertyChanged(nameof(SelectedNewInsertionStrategyOption)); OnPropertyChanged(nameof(SelectedAccelerationOption)); OnPropertyChanged(nameof(AccelerationStatusText)); + // Remove-button visibility reads AccessibilityBridgeEnabledByApp from settings, so a + // reload (e.g. backup restore) must re-notify it or the button can go stale. + OnPropertyChanged(nameof(ShowAccessibilityBridgeRemove)); RefreshModelState(); } @@ -1393,7 +1396,12 @@ private async Task ToggleAccessibilityBridgeAsync(bool enable) if (current == false) { ok = await _a11yBus.SetActivatedAsync(true); - _bridgeEnabledByThisApp = ok; + if (ok) + { + _settings.Save( + _settings.Current with { AccessibilityBridgeEnabledByApp = true } + ); + } } else { @@ -1407,7 +1415,9 @@ private async Task ToggleAccessibilityBridgeAsync(bool enable) { // A successful remove always clears ownership. See ShowAccessibilityBridgeRemove // for why this gates the button. - _bridgeEnabledByThisApp = false; + _settings.Save( + _settings.Current with { AccessibilityBridgeEnabledByApp = false } + ); } } diff --git a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs index 908df24e8..dd3da7024 100644 --- a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs @@ -138,6 +138,20 @@ public async Task SameAppFocusChange_DoesNotEndTracking() Assert.Equal("Kubernetes", correction.Replacement); } + [Fact] + public async Task Arm_PokesAccessibilityTrees_ToUnlockChromiumApps() + { + // Chromium/Electron apps expose a stub tree until an AT touches it; every arm must + // fire the unlock sweep so apps launched after the client started become readable. + var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field }; + using var service = CreateService(client, enabled: true); + + client.TextToReturn = "hello world"; + await service.ArmAsync("hello world"); + + Assert.True(client.PokeCalls >= 1); + } + [Fact] public async Task Arm_FocusOutWithoutEdit_LearnsNothing() { @@ -966,6 +980,14 @@ public IReadOnlyList GetRecentFocusedElements() return [.. RecentFocusedElements]; } + public int PokeCalls { get; private set; } + + public Task PokeAccessibilityTreesAsync() + { + PokeCalls++; + return Task.CompletedTask; + } + public Task TryReadTextAsync(AtSpiElementRef element, int maxLength) { TextReadCalls++; diff --git a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs index 2e32ea40b..234440a88 100644 --- a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs @@ -1760,6 +1760,11 @@ public IReadOnlyList GetRecentFocusedElements() return []; } + public Task PokeAccessibilityTreesAsync() + { + return Task.CompletedTask; + } + public bool HasTextChangedSubscribers => TextChanged is not null; public Task EnsureStartedAsync() From 1f5a5970c624243989c627d9010498550fd086b8 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 11 Jul 2026 09:39:26 -0400 Subject: [PATCH 015/226] Add learned corrections models, extended localization, and Linux UI components - Added LearnedDictionaryCorrection record and learning interface updates - Implemented DictionaryService improvements: LearnCorrections, UndoLearnedCorrections, safety guards - Expanded localization JSONs for correction learning feedback (de, en, es, ru) - Enhanced Linux App.axaml.cs: added notification/overlay initialization, accessibility checks - Implemented AT-SPI active window services with extents reading and focus tracking - Added learned-corrections feedback presenter and Linux toast window for desktop hints - Created learned corrections placement logic for on-screen positioning - Updated TargetAppCorrectionLearningService: diff-based, safe correction learning, focus and role checks, self-healing - Wrote comprehensive unit tests for dictionary, correction learning, accessibility, and UI positioning - Added Linux notification service for silent feedback with undo, replacing overlay on tiling WMs - Enhanced accessibility bus service to handle connection resets, signals, and extents fetching - Extended app localization for correction feedback and accessibility info - General refactoring for safety, thread-safety, and testability in Linux platform components --- .../Interfaces/IDictionaryService.cs | 14 + .../Models/LearnedDictionaryCorrection.cs | 11 + .../Services/DictionaryService.cs | 154 ++++ src/TypeWhisper.Linux/App.axaml.cs | 16 + .../Resources/Localization/de.json | 9 +- .../Resources/Localization/en.json | 9 +- .../Resources/Localization/es.json | 9 +- .../Resources/Localization/ru.json | 9 +- src/TypeWhisper.Linux/ServiceRegistrations.cs | 7 + .../AccessibilityBusActivationService.cs | 23 +- .../Services/ActiveWindow/AtSpiEventClient.cs | 575 ++++++++++++++- .../ActiveWindow/IAtSpiEventClient.cs | 31 + .../Insertion/AtSpiPasteConfirmation.cs | 46 +- .../LearnedCorrectionsFeedbackPresenter.cs | 151 ++++ .../LearnedCorrectionsNotificationService.cs | 665 ++++++++++++++++++ .../LearnedCorrectionsToastController.cs | 143 ++++ .../Services/LearnedToastPlacement.cs | 101 +++ .../TargetAppCorrectionLearningService.cs | 379 ++++++++-- .../Sections/DictationSectionViewModel.cs | 13 + .../Views/LearnedCorrectionToastWindow.axaml | 47 ++ .../LearnedCorrectionToastWindow.axaml.cs | 185 +++++ .../Services/DictionaryServiceTests.cs | 166 +++++ .../VocabularyBoostingServiceTests.cs | 15 + .../AccessibilityBusActivationServiceTests.cs | 35 + ...earnedCorrectionsFeedbackPresenterTests.cs | 226 ++++++ ...rnedCorrectionsNotificationServiceTests.cs | 312 ++++++++ .../LearnedToastPlacementTests.cs | 124 ++++ ...TargetAppCorrectionLearningServiceTests.cs | 525 +++++++++++++- .../TextInsertionServiceTests.cs | 62 ++ 29 files changed, 3938 insertions(+), 124 deletions(-) create mode 100644 src/TypeWhisper.Core/Models/LearnedDictionaryCorrection.cs create mode 100644 src/TypeWhisper.Linux/Services/LearnedCorrectionsFeedbackPresenter.cs create mode 100644 src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs create mode 100644 src/TypeWhisper.Linux/Services/LearnedCorrectionsToastController.cs create mode 100644 src/TypeWhisper.Linux/Services/LearnedToastPlacement.cs create mode 100644 src/TypeWhisper.Linux/Views/LearnedCorrectionToastWindow.axaml create mode 100644 src/TypeWhisper.Linux/Views/LearnedCorrectionToastWindow.axaml.cs create mode 100644 tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs create mode 100644 tests/TypeWhisper.Linux.Tests/LearnedCorrectionsNotificationServiceTests.cs create mode 100644 tests/TypeWhisper.Linux.Tests/LearnedToastPlacementTests.cs diff --git a/src/TypeWhisper.Core/Interfaces/IDictionaryService.cs b/src/TypeWhisper.Core/Interfaces/IDictionaryService.cs index e807b12de..36017ce06 100644 --- a/src/TypeWhisper.Core/Interfaces/IDictionaryService.cs +++ b/src/TypeWhisper.Core/Interfaces/IDictionaryService.cs @@ -60,6 +60,20 @@ IReadOnlyList GetEnabledTerms() /// Records a user-confirmed correction so the same mistake is auto-fixed next time. void LearnCorrection(string original, string replacement); + /// + /// Silently learns a batch of corrections. New, safe originals are added; an existing + /// entry is only ever updated when its id is listed in + /// (session-created entries the caller is self-healing) — every other existing entry is left + /// untouched regardless of source. Returns the entries added or updated so they can be undone. + /// + IReadOnlyList LearnCorrections( + IEnumerable suggestions, + IReadOnlySet? replaceableEntryIds = null + ); + + /// Removes correction entries by id (used to undo a learned batch); safe if some no longer exist. + void UndoLearnedCorrections(IEnumerable learnedCorrections); + /// Adds the term pack's entries; idempotent, so re-activating an active pack is a no-op. void ActivatePack(TermPack pack); diff --git a/src/TypeWhisper.Core/Models/LearnedDictionaryCorrection.cs b/src/TypeWhisper.Core/Models/LearnedDictionaryCorrection.cs new file mode 100644 index 000000000..d8bd0d328 --- /dev/null +++ b/src/TypeWhisper.Core/Models/LearnedDictionaryCorrection.cs @@ -0,0 +1,11 @@ +namespace TypeWhisper.Core.Models; + +/// +/// A correction learned automatically from an observed edit. Carries the +/// dictionary that was added or updated so the same batch can +/// be undone later. +/// +/// Dictionary entry id that was added or updated. +/// Original token that was corrected. +/// Replacement token learned for the original token. +public sealed record LearnedDictionaryCorrection(string Id, string Original, string Replacement); diff --git a/src/TypeWhisper.Core/Services/DictionaryService.cs b/src/TypeWhisper.Core/Services/DictionaryService.cs index b3167cf98..d5dbedd2e 100644 --- a/src/TypeWhisper.Core/Services/DictionaryService.cs +++ b/src/TypeWhisper.Core/Services/DictionaryService.cs @@ -429,6 +429,14 @@ public void LearnCorrection(string original, string replacement) if (existing is not null) { + // A user-authored or imported mapping always wins over silent auto-learning: + // overwriting it would turn one observed edit into a replacement the user + // explicitly configured differently, with no notice. + if (existing.Source is DictionaryEntrySource.Manual or DictionaryEntrySource.Import) + { + return; + } + var idx = newCache.FindIndex(e => e.Id == existing.Id); if (idx >= 0) { @@ -464,6 +472,152 @@ public void LearnCorrection(string original, string replacement) EntriesChanged?.Invoke(); } + public IReadOnlyList LearnCorrections( + IEnumerable suggestions, + IReadOnlySet? replaceableEntryIds = null + ) + { + EnsureCacheLoaded(); + + var learned = new List(); + var changed = false; + + lock (_gate) + { + var newCache = new List(_cache); + + // First occurrence of an original within one batch wins; later ones are dropped. + var seenOriginals = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var suggestion in suggestions) + { + var original = suggestion.Original.Trim(); + var replacement = suggestion.Replacement.Trim(); + + if (original.Length == 0 + || replacement.Length == 0 + || string.Equals(original, replacement, StringComparison.OrdinalIgnoreCase) + || !IsSafeAutomaticallyLearnedToken(original) + || !IsSafeAutomaticallyLearnedToken(replacement) + || !seenOriginals.Add(original)) + { + continue; + } + + var existing = newCache.FirstOrDefault(e => + e.EntryType == DictionaryEntryType.Correction + && e.Original.Equals(original, StringComparison.OrdinalIgnoreCase) + ); + + if (existing is not null) + { + // The only entries we may overwrite are the session-created ones the caller is + // self-healing (idle then final commit of the same utterance). Every other + // existing correction is left as-is, regardless of source. + if (replaceableEntryIds is null || !replaceableEntryIds.Contains(existing.Id)) + { + continue; + } + + var idx = newCache.FindIndex(e => e.Id == existing.Id); + if (idx < 0) + { + continue; + } + + var updated = existing with + { + Replacement = replacement, + TimesCorrected = existing.TimesCorrected + 1, + LastCorrectedAt = DateTime.UtcNow + }; + newCache[idx] = updated; + learned.Add( + new LearnedDictionaryCorrection(updated.Id, updated.Original, replacement) + ); + changed = true; + continue; + } + + var entry = new DictionaryEntry + { + Id = Guid.NewGuid().ToString(), + EntryType = DictionaryEntryType.Correction, + Original = original, + Replacement = replacement, + TimesCorrected = 1, + LastCorrectedAt = DateTime.UtcNow, + Source = DictionaryEntrySource.AutoLearned + }; + newCache.Add(entry); + learned.Add(new LearnedDictionaryCorrection(entry.Id, entry.Original, replacement)); + changed = true; + } + + if (changed) + { + SaveToDisk(newCache); + _cache = newCache; + } + } + + if (changed) + { + EntriesChanged?.Invoke(); + } + + return learned; + } + + public void UndoLearnedCorrections(IEnumerable learnedCorrections) + { + EnsureCacheLoaded(); + + var learnedIds = learnedCorrections + .Select(c => c.Id) + .ToHashSet(StringComparer.Ordinal); + if (learnedIds.Count == 0) + { + return; + } + + bool removed; + lock (_gate) + { + var newCache = _cache + .Where(e => e.EntryType != DictionaryEntryType.Correction + || !learnedIds.Contains(e.Id)) + .ToList(); + removed = newCache.Count != _cache.Count; + + if (removed) + { + SaveToDisk(newCache); + _cache = newCache; + } + } + + if (removed) + { + EntriesChanged?.Invoke(); + } + } + + // Guards silent auto-learning against picking up punctuation-fenced or multi-word fragments: + // the first and last chars must be alphanumeric and the interior only letters/digits/hyphen/apostrophe. + private static bool IsSafeAutomaticallyLearnedToken(string token) + { + if (token.Length == 0 + || !char.IsLetterOrDigit(token[0]) + || !char.IsLetterOrDigit(token[^1])) + { + return false; + } + + return token.All(static c => + char.IsLetterOrDigit(c) || c == '-' || c == '\''); + } + public void ActivatePack(TermPack pack) { EnsureCacheLoaded(); diff --git a/src/TypeWhisper.Linux/App.axaml.cs b/src/TypeWhisper.Linux/App.axaml.cs index 740c529d8..70c5618c1 100644 --- a/src/TypeWhisper.Linux/App.axaml.cs +++ b/src/TypeWhisper.Linux/App.axaml.cs @@ -152,6 +152,22 @@ main.DataContext as MainWindowViewModel overlay.Initialize(); BootTrace.Stage("overlay.Initialize"); + // Surface silently-learned target-app corrections with an Undo. On desktop + // environments this is a dedicated toast window placed beside the corrected element; + // on tiling WMs the overlay/toast is the wrong primitive, so it goes out as a desktop + // notification instead — each surface subscribes only in its own environment, so + // exactly one owns CorrectionsLearned and it's never double-shown. Both marshal the + // background commit event onto the UI thread internally. When the feature is off the + // event never fires — neither starts anything on its own. + if (DesktopDetector.UsesNotificationRecordingIndicator()) + { + services.GetRequiredService().Initialize(); + } + else + { + services.GetRequiredService().Initialize(); + } + // On tiling window managers the overlay is suppressed (it's the wrong // primitive there); recording is surfaced via a desktop notification // instead. No-op on desktop environments, which keep the overlay. diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index 4ef355267..5c36d1e20 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -183,12 +183,13 @@ "Dictation.AutoLearnCorrectionsHint": "Wenn Sie einen Verlaufseintrag bearbeiten, können eindeutige Phrasen-Vorschläge sofort gelernt werden.", "Dictation.TargetAppCorrectionLearning": "Korrekturen aus anderen Apps lernen", "Dictation.TargetAppCorrectionLearningHint": "Wenn Sie ein diktiertes Wort in einer anderen App überschreiben, um es zu korrigieren, lernt TypeWhisper die Korrektur stillschweigend und wendet sie auf zukünftige Diktate an. Liest das fokussierte Textfeld; standardmäßig deaktiviert.", - "Dictation.A11yBridgeSetupExplanation": "Ihre Desktop-Sitzung hat die Barrierefreiheitsbrücke, über die Apps ihren Text bereitstellen, nicht aktiviert. Das Korrekturlernen benötigt sie für Chromium/Electron-Apps wie VS Code sowie für Qt-Apps. Aktivieren Sie sie unten und starten Sie dann die App neu, aus der gelernt werden soll — diese Apps übernehmen die Einstellung nur beim Start.", + "Dictation.A11yBridgeSetupExplanation": "Ihre Desktop-Sitzung hat die Barrierefreiheitsbrücke, über die Apps ihren Text bereitstellen, nicht aktiviert. Das Korrekturlernen benötigt sie für Chromium/Electron-Apps wie VS Code sowie für Qt-Apps. Aktivieren Sie sie unten und starten Sie dann Chromium/Electron-Apps neu, aus denen gelernt werden soll — sie übernehmen die Einstellung nur beim Start (Qt-Apps übernehmen sie sofort).", "Dictation.A11yBridgeEnableButton": "Barrierefreiheitsbrücke aktivieren", "Dictation.A11yBridgeRemoveExplanation": "Die Barrierefreiheitsbrücke ist aktiviert. Sie aktiviert die Barrierefreiheit für alle Apps, was einen geringen Mehraufwand verursacht. Sie können sie entfernen, wenn Sie das Korrekturlernen nicht mehr verwenden.", "Dictation.A11yBridgeRemoveButton": "Barrierefreiheitsbrücke entfernen", - "Dictation.A11yBridgeEnabledStatus": "Barrierefreiheitsbrücke aktiviert. Starten Sie die Zielanwendung neu, falls sie bereits geöffnet war.", + "Dictation.A11yBridgeEnabledStatus": "Barrierefreiheitsbrücke aktiviert. Starten Sie bereits geöffnete Chromium/Electron-Apps (wie VS Code) neu; andere Apps übernehmen die Einstellung sofort.", "Dictation.A11yBridgeRemovedStatus": "Barrierefreiheitsbrücke entfernt.", + "Dictation.A11yBridgeRemoveBlockedScreenReader": "Ein Bildschirmleser scheint aktiv zu sein, daher wurde die Barrierefreiheitsbrücke nicht entfernt.", "Dictation.A11yBridgeActionFailed": "Die Einstellung der Barrierefreiheitsbrücke konnte nicht geändert werden.", "Dictation.AutoPaste": "Nach der Transkription automatisch einfügen", "Dictation.AutoStopOnSilence": "Bei Stille automatisch stoppen", @@ -315,6 +316,10 @@ "Dictionary.TypeCorrection": "Korrektur", "Dictionary.TypeTerm": "Begriff", "Dictionary.VocabularyBoostingHint": "Verbessert die Erkennung aktiver Begriffe aus Wörterbuch und Paketen bei lokalen Transkriptionen", + "Feedback.CorrectionLearningUndone": "Korrekturlernen rückgängig gemacht.", + "Feedback.LearnedCorrectionFormat": "„{0}“ → „{1}“ gelernt", + "Feedback.LearnedCorrectionsFormat": "{0} Korrekturen gelernt", + "Feedback.Undo": "Rückgängig", "FileTranscription.AutoStartWatching": "Überwachung automatisch starten, wenn TypeWhisper geöffnet wird", "FileTranscription.Automation": "Automatisierung", "FileTranscription.BatchTranscription": "Batch-Transkription", diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index c34c3f3b8..c35fa55c4 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -183,12 +183,13 @@ "Dictation.AutoLearnCorrectionsHint": "When you edit a history item, clear phrase-level suggestions can be learned immediately.", "Dictation.TargetAppCorrectionLearning": "Learn corrections from other apps", "Dictation.TargetAppCorrectionLearningHint": "When you type over a dictated word in another app to fix it, TypeWhisper silently learns the correction and applies it to future dictations. Reads the focused text field; off by default.", - "Dictation.A11yBridgeSetupExplanation": "Your desktop session hasn't enabled the accessibility bridge that lets apps expose their text. Correction learning needs it for Chromium/Electron apps like VS Code and for Qt apps. Enable it below, then restart the app you want to learn from — those apps only pick it up when they launch.", + "Dictation.A11yBridgeSetupExplanation": "Your desktop session hasn't enabled the accessibility bridge that lets apps expose their text. Correction learning needs it for Chromium/Electron apps like VS Code and for Qt apps. Enable it below, then restart any Chromium/Electron app you want to learn from — they only pick it up at launch (Qt apps apply it immediately).", "Dictation.A11yBridgeEnableButton": "Enable accessibility bridge", "Dictation.A11yBridgeRemoveExplanation": "The accessibility bridge is enabled. It activates accessibility for all apps, which adds a small overhead. You can remove it if you stop using correction learning.", "Dictation.A11yBridgeRemoveButton": "Remove accessibility bridge", - "Dictation.A11yBridgeEnabledStatus": "Accessibility bridge enabled. Restart the target app if it was already open.", + "Dictation.A11yBridgeEnabledStatus": "Accessibility bridge enabled. Restart Chromium/Electron apps (like VS Code) that were already open; other apps pick it up immediately.", "Dictation.A11yBridgeRemovedStatus": "Accessibility bridge removed.", + "Dictation.A11yBridgeRemoveBlockedScreenReader": "A screen reader appears to be active, so the accessibility bridge was left on.", "Dictation.A11yBridgeActionFailed": "Could not change the accessibility bridge setting.", "Dictation.AutoPaste": "Auto paste after transcription", "Dictation.AutoStopOnSilence": "Auto-stop on silence", @@ -315,6 +316,10 @@ "Dictionary.TypeCorrection": "Correction", "Dictionary.TypeTerm": "Term", "Dictionary.VocabularyBoostingHint": "Improves recognition of active dictionary terms and packs for local transcriptions", + "Feedback.CorrectionLearningUndone": "Correction learning undone.", + "Feedback.LearnedCorrectionFormat": "Learned \"{0}\" → \"{1}\"", + "Feedback.LearnedCorrectionsFormat": "Learned {0} corrections", + "Feedback.Undo": "Undo", "FileTranscription.AutoStartWatching": "Start watching automatically when TypeWhisper opens", "FileTranscription.Automation": "Automation", "FileTranscription.BatchTranscription": "Batch transcription", diff --git a/src/TypeWhisper.Linux/Resources/Localization/es.json b/src/TypeWhisper.Linux/Resources/Localization/es.json index 46b107e83..cf5f59292 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -183,12 +183,13 @@ "Dictation.AutoLearnCorrectionsHint": "Cuando editas un elemento del historial, las sugerencias claras a nivel de frase pueden aprenderse de inmediato.", "Dictation.TargetAppCorrectionLearning": "Aprender correcciones de otras apps", "Dictation.TargetAppCorrectionLearningHint": "Cuando escribes sobre una palabra dictada en otra app para corregirla, TypeWhisper aprende la corrección de forma silenciosa y la aplica a los dictados futuros. Lee el campo de texto enfocado; desactivado de forma predeterminada.", - "Dictation.A11yBridgeSetupExplanation": "Tu sesión de escritorio no ha activado el puente de accesibilidad que permite a las apps exponer su texto. El aprendizaje de correcciones lo necesita para apps de Chromium/Electron como VS Code y para apps de Qt. Actívalo abajo y luego reinicia la app de la que quieres aprender: esas apps solo lo detectan al iniciarse.", + "Dictation.A11yBridgeSetupExplanation": "Tu sesión de escritorio no ha activado el puente de accesibilidad que permite a las apps exponer su texto. El aprendizaje de correcciones lo necesita para apps de Chromium/Electron como VS Code y para apps de Qt. Actívalo abajo y luego reinicia las apps de Chromium/Electron de las que quieras aprender: solo lo detectan al iniciarse (las apps de Qt lo aplican de inmediato).", "Dictation.A11yBridgeEnableButton": "Activar puente de accesibilidad", "Dictation.A11yBridgeRemoveExplanation": "El puente de accesibilidad está activado. Activa la accesibilidad para todas las apps, lo que añade una pequeña sobrecarga. Puedes quitarlo si dejas de usar el aprendizaje de correcciones.", "Dictation.A11yBridgeRemoveButton": "Quitar puente de accesibilidad", - "Dictation.A11yBridgeEnabledStatus": "Puente de accesibilidad activado. Reinicia la app de destino si ya estaba abierta.", + "Dictation.A11yBridgeEnabledStatus": "Puente de accesibilidad activado. Reinicia las apps de Chromium/Electron (como VS Code) que ya estuvieran abiertas; las demás apps lo aplican de inmediato.", "Dictation.A11yBridgeRemovedStatus": "Puente de accesibilidad quitado.", + "Dictation.A11yBridgeRemoveBlockedScreenReader": "Parece que hay un lector de pantalla activo, así que el puente de accesibilidad no se ha quitado.", "Dictation.A11yBridgeActionFailed": "No se pudo cambiar la configuración del puente de accesibilidad.", "Dictation.AutoPaste": "Pegar automáticamente tras la transcripción", "Dictation.AutoStopOnSilence": "Detener automáticamente al detectar silencio", @@ -315,6 +316,10 @@ "Dictionary.TypeCorrection": "Corrección", "Dictionary.TypeTerm": "Término", "Dictionary.VocabularyBoostingHint": "Mejora el reconocimiento de los términos activos del diccionario y de los paquetes en las transcripciones locales", + "Feedback.CorrectionLearningUndone": "Aprendizaje de corrección deshecho.", + "Feedback.LearnedCorrectionFormat": "Se aprendió «{0}» → «{1}»", + "Feedback.LearnedCorrectionsFormat": "Se aprendieron {0} correcciones", + "Feedback.Undo": "Deshacer", "FileTranscription.AutoStartWatching": "Empezar a vigilar automáticamente cuando se abra TypeWhisper", "FileTranscription.Automation": "Automatización", "FileTranscription.BatchTranscription": "Transcripción por lotes", diff --git a/src/TypeWhisper.Linux/Resources/Localization/ru.json b/src/TypeWhisper.Linux/Resources/Localization/ru.json index 21bf6c35a..b319eb07e 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -183,12 +183,13 @@ "Dictation.AutoLearnCorrectionsHint": "Когда вы редактируете элемент истории, очевидные исправления на уровне фраз могут быть выучены сразу.", "Dictation.TargetAppCorrectionLearning": "Учить исправления из других приложений", "Dictation.TargetAppCorrectionLearningHint": "Когда вы исправляете продиктованное слово, набирая поверх него в другом приложении, TypeWhisper незаметно запоминает исправление и применяет его к будущим диктовкам. Читает текстовое поле в фокусе; по умолчанию выключено.", - "Dictation.A11yBridgeSetupExplanation": "Ваш сеанс рабочего стола не включил мост специальных возможностей, позволяющий приложениям предоставлять свой текст. Обучению исправлениям он необходим для приложений на Chromium/Electron, таких как VS Code, и для приложений на Qt. Включите его ниже, затем перезапустите приложение, из которого нужно обучаться, — такие приложения применяют эту настройку только при запуске.", + "Dictation.A11yBridgeSetupExplanation": "Ваш сеанс рабочего стола не включил мост специальных возможностей, позволяющий приложениям предоставлять свой текст. Обучению исправлениям он необходим для приложений на Chromium/Electron, таких как VS Code, и для приложений на Qt. Включите его ниже, затем перезапустите приложения на Chromium/Electron, из которых нужно обучаться, — они применяют эту настройку только при запуске (приложения на Qt применяют её сразу).", "Dictation.A11yBridgeEnableButton": "Включить мост специальных возможностей", "Dictation.A11yBridgeRemoveExplanation": "Мост специальных возможностей включён. Он активирует специальные возможности для всех приложений, что создаёт небольшую нагрузку. Вы можете удалить его, если больше не используете обучение исправлениям.", "Dictation.A11yBridgeRemoveButton": "Удалить мост специальных возможностей", - "Dictation.A11yBridgeEnabledStatus": "Мост специальных возможностей включён. Перезапустите целевое приложение, если оно уже было открыто.", + "Dictation.A11yBridgeEnabledStatus": "Мост специальных возможностей включён. Перезапустите уже открытые приложения на Chromium/Electron (например, VS Code); остальные приложения применяют настройку сразу.", "Dictation.A11yBridgeRemovedStatus": "Мост специальных возможностей удалён.", + "Dictation.A11yBridgeRemoveBlockedScreenReader": "Похоже, активна программа чтения с экрана, поэтому мост специальных возможностей не был удалён.", "Dictation.A11yBridgeActionFailed": "Не удалось изменить настройку моста специальных возможностей.", "Dictation.AutoPaste": "Автоматическая вставка после транскрипции", "Dictation.AutoStopOnSilence": "Автоостановка при тишине", @@ -315,6 +316,10 @@ "Dictionary.TypeCorrection": "Исправление", "Dictionary.TypeTerm": "Термин", "Dictionary.VocabularyBoostingHint": "Улучшает распознавание активных терминов словаря и пакетов для локальных транскрипций", + "Feedback.CorrectionLearningUndone": "Обучение исправлению отменено.", + "Feedback.LearnedCorrectionFormat": "Запомнено «{0}» → «{1}»", + "Feedback.LearnedCorrectionsFormat": "Запомнено исправлений: {0}", + "Feedback.Undo": "Отменить", "FileTranscription.AutoStartWatching": "Автоматически начинать наблюдение при запуске TypeWhisper", "FileTranscription.Automation": "Автоматизация", "FileTranscription.BatchTranscription": "Пакетная транскрипция", diff --git a/src/TypeWhisper.Linux/ServiceRegistrations.cs b/src/TypeWhisper.Linux/ServiceRegistrations.cs index 64dbc016b..8998ff483 100644 --- a/src/TypeWhisper.Linux/ServiceRegistrations.cs +++ b/src/TypeWhisper.Linux/ServiceRegistrations.cs @@ -184,10 +184,17 @@ public static void Register(IServiceCollection services) // Tiling WM recording indicator (desktop notification instead of overlay; no-op on DEs). services.AddSingleton(); + // Tiling WM learned-corrections feedback: same suppressed-overlay situation as above, + // so the "Learned X → Y" toast + Undo is delivered as a desktop notification instead. + services.AddSingleton(); + // Desktop-environment learned-corrections feedback: a dedicated toast window placed + // beside the corrected element (inert on tiling WMs, which use the notification above). + services.AddSingleton(); // Avalonia windows services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs index 17399493a..2274e1d62 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs @@ -20,6 +20,15 @@ public interface IAccessibilityBusActivation /// Task IsActivatedAsync(CancellationToken ct = default); + /// + /// Reads org.a11y.Status.ScreenReaderEnabled (read-only — see + /// for why it is never written). true means a + /// screen reader (Orca) is or was active this session and may rely on the + /// accessibility flag, so removal must be refused. null = indeterminate; + /// callers should fail closed. + /// + Task IsScreenReaderActiveAsync(CancellationToken ct = default); + /// /// Sets org.a11y.Status.IsEnabled on the session bus. Where a GSettings/dconf /// backend is present the a11y launcher mirrors it to @@ -51,12 +60,22 @@ public AccessibilityBusActivationService(IProcessRunner processRunner) _processRunner = processRunner; } - public async Task IsActivatedAsync(CancellationToken ct = default) + public Task IsActivatedAsync(CancellationToken ct = default) + { + return ReadBoolPropertyAsync("IsEnabled", ct); + } + + public Task IsScreenReaderActiveAsync(CancellationToken ct = default) + { + return ReadBoolPropertyAsync("ScreenReaderEnabled", ct); + } + + private async Task ReadBoolPropertyAsync(string property, CancellationToken ct) { var result = await _processRunner .RunAsync( "busctl", - ["--user", "get-property", BusName, ObjectPath, StatusInterface, "IsEnabled"], + ["--user", "get-property", BusName, ObjectPath, StatusInterface, property], timeout: s_timeout, ct: ct ) diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs index 265d69804..2e3bab987 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -39,12 +39,29 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable private const string EventObjectInterface = "org.a11y.atspi.Event.Object"; private const string TextInterface = "org.a11y.atspi.Text"; + private const string ComponentInterface = "org.a11y.atspi.Component"; private const string AccessibleInterface = "org.a11y.atspi.Accessible"; private const string PropertiesInterface = "org.freedesktop.DBus.Properties"; + // ATSPI_COORD_TYPE_SCREEN: GetExtents returns the element's box in global screen pixels + // (not window- or parent-relative), which is what we need to place the toast beside it. + private const uint CoordTypeScreen = 0; + private const string FocusedStateName = "focused"; private const int StateGained = 1; + // AT-SPI event names driven through RegisterEvent/DeregisterEvent. focused is registered + // permanently (focus tracking must run before any dictation arms); text-changed is + // registered on demand only while a holder needs it — see AcquireTextChangedEvents. + private const string FocusedEventName = "object:state-changed:focused"; + private const string TextChangedEventName = "object:text-changed"; + + // A failed text-changed register/deregister leaves the tracked state unknown; retry a few times + // with a short delay so a FINAL deregister (refcount back to 0, no following lease edge to + // re-drive it) still converges rather than stranding the GTK text-event flood on. + private const int MaxTextEventReconcileAttempts = 4; + private static readonly TimeSpan s_textEventRetryDelay = TimeSpan.FromSeconds(1); + // LibreOffice Writer alternates focus between the caret paragraph and the document's // root pane, so two entries would suffice there; a few more absorb apps that flap // across additional structural nodes. @@ -54,6 +71,13 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable // (ROLE_FRAME = 23) which shares the same AtspiRole enum. private const uint RolePasswordText = 40; + // One-shot calls target arbitrary third-party apps, and a hung target (stopped process, + // busy main loop) would otherwise pin the awaiting task forever — Tmds.DBus 0.92 applies + // no timeout of its own — stalling the serialized commit chain with it. WaitAsync leaves + // the pending reply entry behind until the reply or a disconnect arrives; that is + // bounded and far preferable to an unbounded stall. + private static readonly TimeSpan s_callTimeout = TimeSpan.FromSeconds(4); + private static readonly MessageValueReader s_readString = static (m, _) => m.GetBodyReader().ReadString(); @@ -63,6 +87,21 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable private static readonly MessageValueReader s_readUInt32 = static (m, _) => m.GetBodyReader().ReadUInt32(); + // GetExtents' reply body is a single D-Bus struct "(iiii)" = (x, y, width, height) in screen + // pixels. AlignStruct() advances to the 8-byte struct boundary (a no-op when the struct leads + // the body, but correct regardless) before the four Int32 members are read in order. + private static readonly MessageValueReader s_readExtents = + static (m, _) => + { + var reader = m.GetBodyReader(); + reader.AlignStruct(); + var x = reader.ReadInt32(); + var y = reader.ReadInt32(); + var width = reader.ReadInt32(); + var height = reader.ReadInt32(); + return new AtSpiScreenRect(x, y, width, height); + }; + // Reads a D-Bus a(so) array — the (unique bus name, object path) pairs the registry // and Accessible.GetChildren return. private static readonly MessageValueReader> s_readElementRefArray = @@ -81,6 +120,37 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable return list; }; + // Reads GetRegisteredEvents' a(ss) reply — (registrant unique name, event type) pairs. Used to + // check whether a DeregisterEvent actually removed our text-changed listener (some registryd + // versions acknowledge the call without removing it). + private static readonly MessageValueReader> + s_readRegisteredEvents = + static (m, _) => + { + var reader = m.GetBodyReader(); + var list = new List<(string, string)>(); + var end = reader.ReadArrayStart(DBusType.Struct); + while (reader.HasNext(end)) + { + var sender = reader.ReadString(); + var eventType = reader.ReadString(); + list.Add((sender, eventType)); + } + + return list; + }; + + // Reads a NameOwnerChanged body (s name, s oldOwner, s newOwner); only the new owner + // matters (empty = the name went away, non-empty = a new registryd took over). + private static readonly MessageValueReader s_readNameOwnerChanged = + static (m, _) => + { + var reader = m.GetBodyReader(); + reader.ReadString(); // name (already filtered by Arg0) + reader.ReadString(); // old owner + return reader.ReadString(); + }; + // Reads the leading (detail: string, detail1: int) of an AT-SPI event body // (full signature "siiv(so)"); the source element is taken from the message // header (sender + path), matching how libatspi/pyatspi derive event.source. @@ -102,6 +172,32 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable private readonly Lock _focusLock = new(); private readonly SemaphoreSlim _startGate = new(1, 1); + // Guards _textChangedRefCount and _textChangedRegistered. A dedicated lock (not _focusLock) so + // an acquire/release from a commit or paste path never contends with the focus-event fast path + // on the dispatch thread. + private readonly Lock _textEventLock = new(); + + // Serializes the registry register/deregister of text-changed. Every edge (lease acquire / + // release, reconnect, registryd restart) drives state through the single reconciler under this + // gate, so transitions apply in one last-write-wins order — never as reversed fire-and-forget + // D-Bus calls that could leave events off while a lease is live, or on with none live. See + // ReconcileTextChangedAsync. + private readonly SemaphoreSlim _textEventReconcileGate = new(1, 1); + + // Number of live AcquireTextChangedEvents leases. >0 means "object:text-changed" must be + // registered with the registry whenever we hold a connection; 0 means it must not be. The + // count OUTLIVES a StopAsync/reconnect (holders still expect events after a reconnect), so + // the reconciler re-registers text-changed exactly when this is >0. + private int _textChangedRefCount; + + // What the reconciler last drove the CURRENT connection to: true/false = known + // registered/deregistered, null = UNKNOWN (a RegisterEvent/DeregisterEvent failed or timed out). + // Guarded by _textEventLock; set false when the connection is torn down or a restarted registryd + // forgets our registration. A known value keeps a steady-state paste cycle (refcount 1→2→1) a + // no-op; null forces the next reconcile to re-drive, so a failure is retried and a timed-out- + // then-succeeded call can't strand events on with no lease live. + private bool? _textChangedRegistered; + // Bounded most-recent-first focus history behind _focusLock; see GetRecentFocusedElements. private readonly List _recentFocused = []; @@ -116,6 +212,11 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable private bool _started; private IDisposable? _stateSubscription; private IDisposable? _textSubscription; + private IDisposable? _registryOwnerSubscription; + + // 1 once a signal observer reported a fatal connection error and a reset was scheduled; + // back to 0 when a fresh connection starts. Ensures one reset per dead connection. + private int _resetScheduled; public AtSpiEventClient(IErrorLogService errorLog) { @@ -135,6 +236,7 @@ public void Dispose() { _stateSubscription?.Dispose(); _textSubscription?.Dispose(); + _registryOwnerSubscription?.Dispose(); _connection?.Dispose(); } catch @@ -198,6 +300,173 @@ public async Task EnsureStartedAsync() } } + public IDisposable AcquireTextChangedEvents() + { + lock (_textEventLock) + { + _textChangedRefCount++; + } + + // Fire-and-forget: registration must not block the caller (arm/paste paths are + // latency-sensitive). The reconciler serializes this against every other edge and reads + // the live refcount when it runs, so concurrent acquire/release can't be applied out of + // order; a failure is benign (the registry drops our registrations on disconnect and a + // missed one only degrades to the consumer's own timeout fallback). + _ = ReconcileTextChangedAsync(); + + return new TextChangedLease(this); + } + + // Releases one text-changed lease. Idempotent per handle: the handle guards its own + // double-dispose, so this runs at most once per acquire. + private void ReleaseTextChangedEvents() + { + lock (_textEventLock) + { + if (_textChangedRefCount == 0) + { + // Defensive: a correct handle disposes exactly once, but never underflow. + return; + } + + _textChangedRefCount--; + } + + _ = ReconcileTextChangedAsync(); + } + + // Drives the registry's text-changed registration to match the live lease count. Serialized on + // _textEventReconcileGate so acquire/release edges, reconnects and registryd restarts apply + // strictly in order: whichever reconcile runs last observes the final refcount and leaves the + // registration matching it. _textChangedRegistered makes an unchanged desired state a no-op. + private async Task ReconcileTextChangedAsync(int attempt = 0) + { + bool failed; + await _textEventReconcileGate.WaitAsync().ConfigureAwait(false); + try + { + bool desired; + DBusConnection? conn; + lock (_textEventLock) + { + desired = _textChangedRefCount > 0; + conn = _connection; + if (conn is null || _textChangedRegistered == desired) + { + // No connection yet (TryStartAsync registers from the refcount on connect), or + // the registration is KNOWN to already match what we want. A null (unknown) + // state never equals desired, so a prior failure always re-drives here. + return; + } + } + + bool? outcome; + if (desired) + { + outcome = await RegisterTextChangedAsync(conn).ConfigureAwait(false) + ? true + : (bool?)null; + } + else if (await DeregisterTextChangedAsync(conn).ConfigureAwait(false)) + { + // Trust-but-verify: an AT-SPI Registry v2 registryd (at-spi2-core 2.60.4) ACKs + // DeregisterEvent without actually removing the listener. Re-read the registry for + // the real state, so a no-op deregister is recorded as still-registered and the next + // acquire skips re-registering — otherwise a duplicate stacks on every lease cycle, + // recreating the text-event flood this path exists to prevent. + try + { + outcome = await IsTextChangedRegisteredAsync(conn).ConfigureAwait(false); + } + catch (Exception ex) + { + Trace.WriteLine($"[AtSpiEventClient] deregister verify failed: {ex.Message}"); + outcome = null; + } + } + else + { + outcome = null; + } + + lock (_textEventLock) + { + // Record the real state; null (a call failed / couldn't verify) re-drives next edge. + // Never assume a register/deregister took effect (see _textChangedRegistered). + if (ReferenceEquals(_connection, conn)) + { + _textChangedRegistered = outcome; + } + } + + // Retry only when the state is genuinely unknown. A verified v2 no-op deregister is a + // KNOWN "still registered" state, not a failure — retrying could never remove it. + failed = outcome is null; + } + finally + { + _textEventReconcileGate.Release(); + } + + if (failed && attempt + 1 < MaxTextEventReconcileAttempts && !_disposed) + { + _ = RetryReconcileTextChangedAsync(attempt + 1); + } + } + + // A register/deregister failed, leaving the state unknown. A later lease edge would re-drive it, + // but the final deregister may have none, so retry after a short delay to guarantee convergence + // (its own failure schedules the next attempt, up to MaxTextEventReconcileAttempts). + private async Task RetryReconcileTextChangedAsync(int attempt) + { + try + { + await Task.Delay(s_textEventRetryDelay).ConfigureAwait(false); + if (!_disposed) + { + await ReconcileTextChangedAsync(attempt).ConfigureAwait(false); + } + } + catch (Exception ex) + { + Trace.WriteLine( + $"[AtSpiEventClient] text-changed reconcile retry failed: {ex.Message}" + ); + } + } + + private static async Task RegisterTextChangedAsync(DBusConnection conn) + { + try + { + await RegisterEventAsync(conn, TextChangedEventName).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + Trace.WriteLine($"[AtSpiEventClient] text-changed RegisterEvent failed: {ex.Message}"); + return false; + } + } + + private static async Task DeregisterTextChangedAsync(DBusConnection conn) + { + try + { + await DeregisterEventAsync(conn, TextChangedEventName).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + // Benign: registryd also drops our registrations when this connection disconnects, + // so a failed deregister just means it happens slightly later or on disconnect. + Trace.WriteLine( + $"[AtSpiEventClient] text-changed DeregisterEvent failed: {ex.Message}" + ); + return false; + } + } + public async Task StopAsync() { await _startGate.WaitAsync().ConfigureAwait(false); @@ -207,6 +476,7 @@ public async Task StopAsync() { _stateSubscription?.Dispose(); _textSubscription?.Dispose(); + _registryOwnerSubscription?.Dispose(); _connection?.Dispose(); } catch @@ -216,6 +486,7 @@ public async Task StopAsync() _stateSubscription = null; _textSubscription = null; + _registryOwnerSubscription = null; _connection = null; // Reset so the next EnsureStartedAsync reconnects fresh rather than returning // the stale cached availability. @@ -230,6 +501,16 @@ public async Task StopAsync() // cheaply and correctly (apps keep their unlocked state anyway). _pokedApps.Clear(); } + + // Deliberately NOT touching _textChangedRefCount: the registration dies with the + // connection, but holders still exist and expect events after a reconnect, so the + // next EnsureStartedAsync/TryStartAsync re-registers text-changed when the count > 0. + // Do clear _textChangedRegistered: this connection's registration is gone, so the next + // connect must re-drive it from the surviving refcount rather than treat it as applied. + lock (_textEventLock) + { + _textChangedRegistered = false; + } } finally { @@ -302,7 +583,9 @@ public async Task StopAsync() message = writer.CreateMessage(); } - var role = await conn.CallMethodAsync(message, s_readUInt32).ConfigureAwait(false); + var role = await conn.CallMethodAsync(message, s_readUInt32) + .WaitAsync(s_callTimeout) + .ConfigureAwait(false); return role == RolePasswordText; } catch @@ -313,6 +596,44 @@ public async Task StopAsync() } } + public async Task TryGetScreenExtentsAsync(AtSpiElementRef element) + { + var conn = _connection; + if (conn is null || !element.IsValid) + { + return null; + } + + try + { + MessageBuffer message; + using (var writer = conn.GetMessageWriter()) + { + writer.WriteMethodCallHeader( + destination: element.BusName, + path: element.ObjectPath, + @interface: ComponentInterface, + member: "GetExtents", + signature: "u" + ); + writer.WriteUInt32(CoordTypeScreen); + message = writer.CreateMessage(); + } + + return await conn.CallMethodAsync(message, s_readExtents) + .WaitAsync(s_callTimeout) + .ConfigureAwait(false); + } + catch (Exception ex) + { + // Extents are only for positioning feedback UI: many targets don't implement the + // Component interface (or the accessible vanished), and the caller falls back to a + // fixed on-screen spot — so keep every failure out of the error log (Trace only). + Trace.WriteLine($"[AtSpiEventClient] GetExtents failed: {ex.Message}"); + return null; + } + } + public async Task PokeAccessibilityTreesAsync() { var conn = _connection; @@ -391,6 +712,8 @@ private static async Task PokeAppAsync(DBusConnection conn, AtSpiElementRe return anyTouched; } + // ReSharper disable once LoopCanBeConvertedToQuery -- the body awaits a D-Bus call per + // child and OR-accumulates; that isn't a pure query and can't be a LINQ expression. foreach (var child in children.Take(MaxPokedChildren)) { anyTouched |= await TouchElementAsync(conn, child).ConfigureAwait(false); @@ -421,7 +744,7 @@ private static async Task TouchElementAsync(DBusConnection conn, AtSpiElem message = writer.CreateMessage(); } - await conn.CallMethodAsync(message).ConfigureAwait(false); + await conn.CallMethodAsync(message).WaitAsync(s_callTimeout).ConfigureAwait(false); touched = true; } catch (Exception ex) @@ -453,7 +776,9 @@ string objectPath message = writer.CreateMessage(); } - return await conn.CallMethodAsync(message, s_readElementRefArray).ConfigureAwait(false); + return await conn.CallMethodAsync(message, s_readElementRefArray) + .WaitAsync(s_callTimeout) + .ConfigureAwait(false); } private async Task TryStartAsync() @@ -502,10 +827,47 @@ private async Task TryStartAsync() emitOnCapturedContext: false ).ConfigureAwait(false); - // Tell registryd which events have listeners so toolkits that gate - // event emission on demand (GTK) actually broadcast them. - await RegisterEventAsync(conn, "object:state-changed:focused").ConfigureAwait(false); - await RegisterEventAsync(conn, "object:text-changed").ConfigureAwait(false); + // Our RegisterEvent state lives inside registryd, and registryd can be replaced + // mid-session (the a11y bus broker force-disconnects it under event-flood quota + // pressure and D-Bus activation spawns a fresh instance with an empty listener + // table — observed live). Watch the registry name's owner and re-register with + // every new incarnation, exactly like GTK's own bridge does. + _registryOwnerSubscription = await conn.AddMatchAsync( + new MatchRule + { + Type = MessageType.Signal, + Sender = "org.freedesktop.DBus", + Interface = "org.freedesktop.DBus", + Member = "NameOwnerChanged", + Arg0 = RegistryBusName + }, + s_readNameOwnerChanged, + HandleRegistryOwnerChanged, + ObserverFlags.None, + emitOnCapturedContext: false + ).ConfigureAwait(false); + + // Tell registryd which events have listeners so toolkits that gate event emission on + // demand (GTK) actually broadcast them. focused is permanent (focus tracking must run + // before any dictation arms). text-changed is registered ONLY when a lease is live: a + // standing text-changed registration makes every GTK app emit an event per text + // mutation (terminals: one per output line), which floods the a11y bus — so it is + // gated on the refcount, which survives reconnects and reflects live holders here. + await RegisterEventAsync(conn, FocusedEventName).ConfigureAwait(false); + + // Fresh connection: registryd holds none of our registrations. Drive text-changed from + // the live lease count through the same serialized reconciler the leases use, so a + // concurrent acquire/release can't race this initial registration. + lock (_textEventLock) + { + _textChangedRegistered = false; + } + + await ReconcileTextChangedAsync().ConfigureAwait(false); + + // Fresh connection: re-arm the one-reset-per-connection guard so a later + // disconnect of THIS connection schedules its own reconnect. + Interlocked.Exchange(ref _resetScheduled, 0); return true; } @@ -519,6 +881,7 @@ private async Task TryStartAsync() { _stateSubscription?.Dispose(); _textSubscription?.Dispose(); + _registryOwnerSubscription?.Dispose(); _connection?.Dispose(); } catch @@ -528,6 +891,7 @@ private async Task TryStartAsync() _stateSubscription = null; _textSubscription = null; + _registryOwnerSubscription = null; _connection = null; return false; } @@ -536,10 +900,11 @@ private async Task TryStartAsync() private void HandleStateChanged(Exception? exception, AtSpiSignal signal, object? readerState, object? handlerState) { // Only successful reads carry a signal. On error/disconnect the observer is invoked - // with a non-null exception and a default value; skip those rather than acting on an - // empty AtSpiSignal. + // with a non-null exception and a default value; schedule a reconnect rather than + // acting on an empty AtSpiSignal. if (exception is not null) { + OnObserverError(exception); return; } @@ -584,6 +949,7 @@ private void HandleTextChanged(Exception? exception, AtSpiSignal signal, object? { if (exception is not null) { + OnObserverError(exception); return; } @@ -603,9 +969,103 @@ private void HandleTextChanged(Exception? exception, AtSpiSignal signal, object? } } + private void HandleRegistryOwnerChanged( + Exception? exception, + string newOwner, + object? readerState, + object? handlerState + ) + { + if (exception is not null) + { + OnObserverError(exception); + return; + } + + if (string.IsNullOrEmpty(newOwner)) + { + // The name is momentarily unowned (old registryd gone); the replacement's + // takeover fires this signal again with a real owner. + return; + } + + var conn = _connection; + if (conn is null) + { + return; + } + + Trace.WriteLine( + "[AtSpiEventClient] a11y registry restarted; re-registering event listeners." + ); + _ = ReRegisterEventsAsync(conn); + } + + // Instance (not static) so it can read the live lease count: a restarted registryd has an + // empty listener table, so focused must ALWAYS be re-registered, but text-changed only when a + // lease is currently held — re-registering it unconditionally would reinstate the flood. + private async Task ReRegisterEventsAsync(DBusConnection conn) + { + try + { + await RegisterEventAsync(conn, FocusedEventName).ConfigureAwait(false); + + // The restarted registryd came up with an empty listener table, so it has forgotten our + // text-changed registration regardless of what we last drove. Clear the flag and + // reconcile so a live lease re-registers (and no lease stays a stale no-op). + lock (_textEventLock) + { + _textChangedRegistered = false; + } + + await ReconcileTextChangedAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[AtSpiEventClient] re-register after registry restart failed: {ex.Message}" + ); + } + } + + // An observer exception is per-connection-fatal (bus daemon restart, socket closed). + // Without a reset, _started stays true and EnsureStartedAsync returns the dead + // connection forever — an a11y-bus restart would permanently kill the feature. Reset + // once; the next EnsureStartedAsync (next dictation arm) reconnects and re-registers. + private void OnObserverError(Exception exception) + { + if (_disposed || Interlocked.Exchange(ref _resetScheduled, 1) == 1) + { + return; + } + + Trace.WriteLine( + $"[AtSpiEventClient] a11y bus connection lost ({exception.Message}); scheduling reconnect." + ); + _ = Task.Run(async () => + { + try + { + await StopAsync().ConfigureAwait(false); + } + catch + { + // Resetting a dead connection is best-effort and must not throw. + } + }); + } + // ReSharper disable once InconsistentNaming -- "a11y" is the standard accessibility numeronym mirroring org.a11y.Bus; ReSharper's PascalCase splitter mis-reads "11y". private async Task ResolveA11yBusAddressAsync() { + // Standard override honored by libatspi and Qt; takes precedence over the + // org.a11y.Bus lookup (test rigs, nested/remote sessions). + var overrideAddress = Environment.GetEnvironmentVariable("AT_SPI_BUS_ADDRESS"); + if (!string.IsNullOrWhiteSpace(overrideAddress)) + { + return overrideAddress; + } + try { var session = DBusConnection.Session; @@ -621,7 +1081,9 @@ private void HandleTextChanged(Exception? exception, AtSpiSignal signal, object? message = writer.CreateMessage(); } - return await session.CallMethodAsync(message, s_readString).ConfigureAwait(false); + return await session.CallMethodAsync(message, s_readString) + .WaitAsync(s_callTimeout) + .ConfigureAwait(false); } catch (Exception ex) { @@ -646,7 +1108,67 @@ private static async Task RegisterEventAsync(DBusConnection conn, string eventNa message = writer.CreateMessage(); } - await conn.CallMethodAsync(message).ConfigureAwait(false); + await conn.CallMethodAsync(message).WaitAsync(s_callTimeout).ConfigureAwait(false); + } + + // A single "s" arg (event only) mirrors what we registered with. NOTE: this reply is not proof + // of removal — AT-SPI Registry v2 (at-spi2-core 2.60.4) ACKs it without removing the listener, + // so callers verify via IsTextChangedRegisteredAsync rather than trusting the reply. + private static async Task DeregisterEventAsync(DBusConnection conn, string eventName) + { + MessageBuffer message; + using (var writer = conn.GetMessageWriter()) + { + writer.WriteMethodCallHeader( + destination: RegistryBusName, + path: RegistryPath, + @interface: RegistryInterface, + member: "DeregisterEvent", + signature: "s" + ); + writer.WriteString(eventName); + message = writer.CreateMessage(); + } + + await conn.CallMethodAsync(message).WaitAsync(s_callTimeout).ConfigureAwait(false); + } + + // True when the registry still lists a text-changed registration for THIS connection. Lets the + // reconciler detect a registryd that ACKs DeregisterEvent without removing it (AT-SPI Registry + // v2) and stop re-registering, which would otherwise stack a duplicate on every lease cycle. + private static async Task IsTextChangedRegisteredAsync(DBusConnection conn) + { + var me = conn.UniqueName; + if (string.IsNullOrEmpty(me)) + { + return false; + } + + var events = await GetRegisteredEventsAsync(conn).ConfigureAwait(false); + return events.Any(e => + string.Equals(e.Sender, me, StringComparison.Ordinal) + && e.EventType.Contains("TextChanged", StringComparison.OrdinalIgnoreCase)); + } + + private static async Task> GetRegisteredEventsAsync( + DBusConnection conn + ) + { + MessageBuffer message; + using (var writer = conn.GetMessageWriter()) + { + writer.WriteMethodCallHeader( + destination: RegistryBusName, + path: RegistryPath, + @interface: RegistryInterface, + member: "GetRegisteredEvents" + ); + message = writer.CreateMessage(); + } + + return await conn.CallMethodAsync(message, s_readRegisteredEvents) + .WaitAsync(s_callTimeout) + .ConfigureAwait(false); } private static async Task GetCharacterCountAsync(DBusConnection conn, AtSpiElementRef element) @@ -666,7 +1188,9 @@ private static async Task GetCharacterCountAsync(DBusConnection conn, AtSpi message = writer.CreateMessage(); } - return await conn.CallMethodAsync(message, s_readVariantInt32).ConfigureAwait(false); + return await conn.CallMethodAsync(message, s_readVariantInt32) + .WaitAsync(s_callTimeout) + .ConfigureAwait(false); } private static async Task GetTextAsync( @@ -691,7 +1215,9 @@ int end message = writer.CreateMessage(); } - return await conn.CallMethodAsync(message, s_readString).ConfigureAwait(false); + return await conn.CallMethodAsync(message, s_readString) + .WaitAsync(s_callTimeout) + .ConfigureAwait(false); } // The D-Bus error name leads the reply exception's message, e.g. @@ -714,6 +1240,13 @@ int end // DBusErrorReplyException; the well-known "not readable / gone / unresponsive" names are benign. private static bool IsExpectedUnreadableTarget(Exception ex) { + // A call timeout (WaitAsync) means the target app is hung or too busy to answer — + // "can't learn from this app right now", not a TypeWhisper fault. + if (ex is TimeoutException) + { + return true; + } + if (ex is not DBusErrorReplyException) { return false; @@ -739,4 +1272,20 @@ private void LogOnce(string message) } private readonly record struct AtSpiSignal(string Sender, string Path, string Detail, int Detail1); + + // Handle returned by AcquireTextChangedEvents. Idempotent: only the first Dispose releases the + // underlying lease, so a caller (or a double-dispose from finalization patterns) can't drive + // the refcount negative or deregister twice. Interlocked keeps that guarantee thread-safe. + private sealed class TextChangedLease(AtSpiEventClient owner) : IDisposable + { + private int _released; + + public void Dispose() + { + if (Interlocked.Exchange(ref _released, 1) == 0) + { + owner.ReleaseTextChangedEvents(); + } + } + } } diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs index 490776370..6ea1bb6b7 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs @@ -10,6 +10,15 @@ public readonly record struct AtSpiElementRef(string BusName, string ObjectPath) public bool IsValid => !string.IsNullOrEmpty(BusName) && !string.IsNullOrEmpty(ObjectPath); } +/// +/// Screen-coordinate bounding box of an accessible element, as reported by +/// org.a11y.atspi.Component.GetExtents with ATSPI_COORD_TYPE_SCREEN: / +/// are the top-left corner in global screen pixels, / +/// its size. Used to place the learned-corrections toast beside the +/// element the correction came from. +/// +public readonly record struct AtSpiScreenRect(int X, int Y, int Width, int Height); + /// /// Event-driven view of the AT-SPI accessibility bus: a persistent connection /// that surfaces focus/text-edit signals and one-shot text reads, without any @@ -53,6 +62,19 @@ public interface IAtSpiEventClient /// Task EnsureStartedAsync(); + /// + /// Acquires a reference-counted lease on object:text-changed registration: + /// while at least one lease is held, the registry is told an AT wants text-changed + /// events, which is what makes on-demand toolkits (GTK) actually emit them. The first + /// lease registers, the last one disposed deregisters, so a session with nothing + /// tracking imposes no text-event traffic on every GTK app (a registered listener + /// makes terminals emit an event per output line — an accessibility-bus flood). + /// Dispose the returned handle when text events are no longer needed; disposing twice + /// is a no-op. Safe to call before — the lease is + /// honored the moment a connection is (re)established. + /// + IDisposable AcquireTextChangedEvents(); + /// /// Tears down the event subscriptions and the a11y-bus connection and resets state /// so a later reconnects fresh. Called when the @@ -78,6 +100,15 @@ public interface IAtSpiEventClient /// Task IsPasswordFieldAsync(AtSpiElementRef element); + /// + /// Best-effort read of an element's on-screen bounding box via + /// org.a11y.atspi.Component.GetExtents (screen coordinates). Returns null when the + /// element doesn't implement the Component interface, the read fails, or the client isn't + /// connected. Used only to position feedback UI, so any failure is non-fatal — the caller + /// falls back to a fixed on-screen spot. + /// + Task TryGetScreenExtentsAsync(AtSpiElementRef element); + /// /// Best-effort sweep over the applications on the a11y bus, touching each unseen /// app's tree once (Accessible.GetAttributes/GetRelationSet). Chromium/Electron apps diff --git a/src/TypeWhisper.Linux/Services/Insertion/AtSpiPasteConfirmation.cs b/src/TypeWhisper.Linux/Services/Insertion/AtSpiPasteConfirmation.cs index 08cfa088f..2157f167b 100644 --- a/src/TypeWhisper.Linux/Services/Insertion/AtSpiPasteConfirmation.cs +++ b/src/TypeWhisper.Linux/Services/Insertion/AtSpiPasteConfirmation.cs @@ -41,16 +41,31 @@ private sealed class AtSpiPasteWatch : IPasteWatch { private readonly IAtSpiEventClient _client; + // The application (unique bus name) holding focus when the watch was armed — i.e. + // where the paste is about to land. Null when no focus is known; then any app's + // event has to count. + private readonly string? _targetBusName; + private readonly TaskCompletionSource _textChanged = new( TaskCreationOptions.RunContinuationsAsynchronously ); + // Keeps AT-SPI object:text-changed registered with the registry for the life of the watch. + // The correction-learning feature registers it only while a field is armed, so without our + // own lease a paste with no armed field would observe no text-changed at all. + private readonly IDisposable _textEventsLease; + internal AtSpiPasteWatch(IAtSpiEventClient client) { _client = client; - // Subscribed here — before the caller sends Ctrl+V — so the paste's - // text-changed can never fire unobserved; one that arrives before - // WaitAsync latches in the TCS and the later await completes instantly. + _targetBusName = client.CurrentFocusedElement?.BusName; + // Acquire the text-changed lease and subscribe here — before the caller sends Ctrl+V — + // so the paste's text-changed can never fire unobserved; one that arrives before + // WaitAsync latches in the TCS and the later await completes instantly. The registry + // RegisterEvent this triggers is fire-and-forget: its propagation is fast relative to + // the clipboard staging that follows the keystroke, and if the very first event still + // races ahead of it, the watch simply degrades to the existing timeout fallback. + _textEventsLease = client.AcquireTextChangedEvents(); _client.TextChanged += OnTextChanged; } @@ -80,16 +95,27 @@ internal AtSpiPasteWatch(IAtSpiEventClient client) public void Dispose() { _client.TextChanged -= OnTextChanged; + // Release the text-changed lease alongside the unsubscribe: once no watch (and no + // armed field) holds it, the registration is dropped so idle GTK apps stop emitting. + _textEventsLease.Dispose(); } - // First TextChanged from ANY element counts. Do not match against - // CurrentFocusedElement and do not read the text back: focus events sometimes - // yield containers without the Text interface (the `No such interface - // "org.a11y.atspi.Text"` failure), and the text-changed source object routinely - // differs from the focus object. - private void OnTextChanged(AtSpiElementRef _) + // First TextChanged from the TARGET APPLICATION counts — matched by unique bus + // name, never by element: the text-changed source object routinely differs from + // the focus object (containers, sibling widgets), but it always belongs to the + // same app connection. Without the app match, a background app's text event (an + // arriving chat message, a ticking log view) would falsely confirm the paste and + // restore the clipboard before the real target consumed it. When no focused app + // was known at arm time, fall back to any-app (indeterminate targets). + private void OnTextChanged(AtSpiElementRef element) { - _textChanged.TrySetResult(true); + if ( + _targetBusName is null + || string.Equals(element.BusName, _targetBusName, StringComparison.Ordinal) + ) + { + _textChanged.TrySetResult(true); + } } } } diff --git a/src/TypeWhisper.Linux/Services/LearnedCorrectionsFeedbackPresenter.cs b/src/TypeWhisper.Linux/Services/LearnedCorrectionsFeedbackPresenter.cs new file mode 100644 index 000000000..7ede4e144 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/LearnedCorrectionsFeedbackPresenter.cs @@ -0,0 +1,151 @@ +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services.Localization; + +namespace TypeWhisper.Linux.Services; + +/// +/// Immutable snapshot of the learned-corrections feedback the overlay should render: +/// the toast text plus whether the Undo affordance is live. Empty +/// means "hide the toast". +/// +public sealed record LearnedCorrectionsFeedback(string Text, bool ShowUndo) +{ + public static LearnedCorrectionsFeedback Hidden { get; } = new(string.Empty, false); +} + +/// +/// UI-thread-agnostic presenter for the Wispr-Flow-style "Learned 'X' → 'Y'" toast with +/// an Undo action, mirroring the Windows app's ShowLearnedCorrectionsFeedback flow. Holds +/// the pending batch and all the timing/undo logic so the overlay view model stays a thin +/// binding surface. Timing is injected via a one-shot scheduleDelay factory +/// so this is exercisable headless (production wires a DispatcherTimer; tests fire manually). +/// +/// All members must be touched on the UI thread — the composition-root subscription to +/// CorrectionsLearned marshals onto it before calling . +/// +/// +public sealed class LearnedCorrectionsFeedbackPresenter +{ + // Matches the Windows app: a learned-corrections toast lingers for 8s so the user can read + // it and decide to undo; the post-undo confirmation is a brief 2s acknowledgement. + private static readonly TimeSpan s_learnedAutoHide = TimeSpan.FromSeconds(8); + private static readonly TimeSpan s_confirmationAutoHide = TimeSpan.FromSeconds(2); + + private readonly IDictionaryService _dictionary; + + // Schedules a one-shot delay and returns a handle whose disposal cancels the pending + // callback. Re-arming disposes the previous handle so only the latest timer can fire. + private readonly Func _scheduleDelay; + + private IDisposable? _autoHide; + private List _pending = []; + + // Bumped on every Emit/Reset so a superseded auto-hide no-ops. The production scheduler wraps a + // System.Threading.Timer that posts Hide to the UI thread; if that callback is already queued + // when a newer ShowLearned/Undo re-arms, disposing the old timer can't retract it, and the + // stale Hide would otherwise clear the fresh pending batch and close its Undo toast at once. + private int _feedbackGeneration; + + public LearnedCorrectionsFeedbackPresenter( + IDictionaryService dictionary, + Func scheduleDelay + ) + { + _dictionary = dictionary; + _scheduleDelay = scheduleDelay; + } + + /// + /// Raised whenever the toast should change: the overlay view model pushes + /// and Undo visibility into its bindings. + /// + public event Action? FeedbackChanged; + + /// True while a batch is pending undo (Undo is live only in this window). + public bool HasPendingBatch => _pending.Count > 0; + + /// + /// Surfaces a freshly learned batch. A new learn while a previous toast is still up + /// replaces the pending batch (matches the Windows behavior) and re-arms the 8s hide. + /// + public void ShowLearned(IReadOnlyList learned) + { + if (learned.Count == 0) + { + return; + } + + _pending = [.. learned]; + + var text = learned.Count == 1 + ? Loc.Instance.GetString( + "Feedback.LearnedCorrectionFormat", + learned[0].Original, + learned[0].Replacement) + : Loc.Instance.GetString("Feedback.LearnedCorrectionsFormat", learned.Count); + + Emit(new LearnedCorrectionsFeedback(text, ShowUndo: true), s_learnedAutoHide); + } + + /// + /// Removes the pending batch from the dictionary and swaps the toast for a brief + /// confirmation. No-ops when nothing is pending (e.g. a double click after auto-hide). + /// + public void Undo() + { + if (_pending.Count == 0) + { + return; + } + + _dictionary.UndoLearnedCorrections(_pending); + _pending = []; + + Emit( + new LearnedCorrectionsFeedback( + Loc.Instance["Feedback.CorrectionLearningUndone"], + ShowUndo: false), + s_confirmationAutoHide); + } + + /// + /// Silently drops the pending batch and cancels the auto-hide without raising + /// . Used when something else (a new dictation) has + /// taken over the feedback band, so the toast doesn't reassert itself or fire a + /// stale hide over the new content. + /// + public void Reset() + { + _autoHide?.Dispose(); + _autoHide = null; + _pending = []; + // Invalidate any auto-hide already posted before this Reset (see _feedbackGeneration). + _feedbackGeneration++; + } + + private void Emit(LearnedCorrectionsFeedback feedback, TimeSpan autoHide) + { + _autoHide?.Dispose(); + var generation = ++_feedbackGeneration; + FeedbackChanged?.Invoke(feedback); + _autoHide = _scheduleDelay(autoHide, () => Hide(generation)); + } + + private void Hide(int generation) + { + // A superseded timer's callback may already be queued when this runs; only the latest + // generation may hide, so a stale Hide can't clear a fresh batch it doesn't own. + if (generation != _feedbackGeneration) + { + return; + } + + // Auto-hide clears the pending batch too, so Undo can't act on a toast the user can no + // longer see (matches the Windows app dropping _pendingLearnedCorrections on expiry). + _autoHide?.Dispose(); + _autoHide = null; + _pending = []; + FeedbackChanged?.Invoke(LearnedCorrectionsFeedback.Hidden); + } +} diff --git a/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs b/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs new file mode 100644 index 000000000..0a958c908 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs @@ -0,0 +1,665 @@ +using System.Diagnostics; +using Avalonia.Threading; +using Tmds.DBus.Protocol; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services.Hotkey.DeSetup; +using TypeWhisper.Linux.Services.Localization; + +namespace TypeWhisper.Linux.Services; + +/// +/// Delivers the learned-corrections feedback (with an Undo action) as a desktop +/// notification on tiling WMs, where the dictation overlay — and with it the overlay's +/// feedback band — is suppressed (see +/// ). Reuses +/// for the pending-batch/timing/undo +/// logic, and mirrors its FeedbackChanged stream onto an +/// org.freedesktop.Notifications popup: text updates replace the popup in place, +/// an empty text closes it, and the daemon's ActionInvoked/NotificationClosed +/// signals feed back into the presenter. +/// +/// Fully inert on full desktop environments (GNOME/KDE/Cinnamon), which keep the +/// overlay path — no subscriptions and no bus connection. The presenter is not +/// thread-safe, so every access (learned event, D-Bus signal callbacks, timer +/// callbacks) is marshalled onto a single serializing post (Dispatcher.UIThread by +/// default), matching the overlay wiring's contract. +/// +/// +public sealed class LearnedCorrectionsNotificationService : IDisposable +{ + // Only a daemon-side backstop: the presenter auto-hides (8s learned / 2s confirmation) and + // closes the popup itself. Finite and well past the 8s window — unlike -1 ("daemon default", + // which can be shorter and cut Undo short) or 0 ("never expires", which strands a popup we fail + // to close, e.g. on shutdown mid-show or a failed replacement). + private const int ServerBackstopExpiryMs = 30_000; + + // gdbus/notify uses id 0 to mean "new notification" for replaces_id; the first Notify + // passes 0, later ones pass the previous id so the popup is replaced in place. + private const uint NoReplaceId = 0; + + private readonly INotificationChannel _channel; + private readonly bool _enabled; + private readonly IErrorLogService _errorLog; + private readonly TargetAppCorrectionLearningService _learning; + + // Serializes all presenter access. Defaults to Dispatcher.UIThread.Post (same thread the + // overlay path marshals to); injectable so tests drive it synchronously without a + // headless dispatcher. + private readonly Action _post; + + private readonly LearnedCorrectionsFeedbackPresenter _presenter; + + private bool _disposed; + private bool _loggedFailure; + + // Id of the popup currently showing our feedback, 0 when none is up. Only touched via + // _post, so no lock is needed. Passed as replaces_id so a follow-up (e.g. the undo + // confirmation) replaces it rather than stacking a second popup. + private uint _currentId; + + // Single-flight dispatch state, only touched via _post. The D-Bus show/close is async, so two + // feedback events before the first Notify returns would both read replaces_id 0 and stack a + // duplicate popup. Instead one op is in flight at a time; a newer event overwrites + // _pendingFeedback (latest wins) and the in-flight op picks it up on completion. + private LearnedCorrectionsFeedback? _pendingFeedback; + private bool _dispatching; + + public LearnedCorrectionsNotificationService( + TargetAppCorrectionLearningService learning, + IDictionaryService dictionary, + IErrorLogService errorLog + ) + : this(learning, dictionary, errorLog, channel: null, post: null, scheduleDelay: null) + { + } + + // Test seam: inject a fake channel (the D-Bus transport is not unit-testable), a + // synchronous post, and a manually-fired delay so the orchestration (including the + // presenter's auto-hide → close) can be exercised without a bus, dispatcher, or real timer. + internal LearnedCorrectionsNotificationService( + TargetAppCorrectionLearningService learning, + IDictionaryService dictionary, + IErrorLogService errorLog, + INotificationChannel? channel, + Action? post, + Func? scheduleDelay + ) + { + _learning = learning; + _errorLog = errorLog; + _enabled = DesktopDetector.UsesNotificationRecordingIndicator(); + _post = post ?? (action => Dispatcher.UIThread.Post(action)); + _channel = channel ?? new DBusNotificationChannel(); + + // The presenter's auto-hide is a one-shot delay whose callback re-enters the presenter, + // so it must marshal back through _post like every other presenter access. Production + // wraps a System.Threading.Timer; tests inject a hand-fired scheduler. + var schedule = scheduleDelay + ?? ((delay, callback) => new PostingTimer(delay, () => _post(callback))); + _presenter = new LearnedCorrectionsFeedbackPresenter(dictionary, schedule); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + if (!_enabled) + { + return; + } + + _learning.CorrectionsLearned -= OnCorrectionsLearned; + _channel.ActionInvoked -= OnActionInvoked; + _channel.Closed -= OnNotificationClosed; + _presenter.FeedbackChanged -= OnFeedbackChanged; + + // freedesktop notifications outlive their sender's bus connection, so a toast still on + // screen at shutdown would sit with a now-dead Undo button until the backstop expiry. Close + // the owned id now so it goes immediately. Best-effort and bounded: a hung daemon must not + // stall exit; a show still in flight here is caught by the backstop expiry, not this close. + var liveId = _currentId; + if (liveId != NoReplaceId) + { + try + { + _channel.CloseAsync(liveId).Wait(TimeSpan.FromSeconds(2)); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[LearnedCorrectionsNotification] close on dispose failed: {ex.Message}" + ); + } + } + + _channel.Dispose(); + } + + public void Initialize() + { + if (!_enabled) + { + return; + } + + _presenter.FeedbackChanged += OnFeedbackChanged; + _channel.ActionInvoked += OnActionInvoked; + _channel.Closed += OnNotificationClosed; + _learning.CorrectionsLearned += OnCorrectionsLearned; + } + + private void OnCorrectionsLearned(LearnedCorrectionsBatch batch) + { + // Fires on a background commit task; marshal onto the serializing post before the + // presenter (which then raises FeedbackChanged synchronously on the same thread). The + // notification popup has no on-screen anchor, so SourceExtents is ignored here. + _post(() => _presenter.ShowLearned(batch.Corrections)); + } + + private void OnFeedbackChanged(LearnedCorrectionsFeedback feedback) + { + // Already on the serializing post (ShowLearned/Undo/Hide all run there). Record this as the + // latest desired state; an in-flight show/close will pick it up when it finishes. + _pendingFeedback = feedback; + if (!_dispatching) + { + DispatchPending(); + } + } + + // Starts the next show/close if one is queued, else parks. Runs only on the _post thread, so + // the _currentId read/write here is serialized with the signal callbacks that also read it. + private void DispatchPending() + { + if (_pendingFeedback is not { } feedback) + { + _dispatching = false; + return; + } + + _pendingFeedback = null; + _dispatching = true; + + if (string.IsNullOrEmpty(feedback.Text)) + { + var id = _currentId; + _currentId = NoReplaceId; + _ = RunCloseAsync(id); + } + else + { + _ = RunShowAsync(feedback, _currentId); + } + } + + private async Task RunShowAsync(LearnedCorrectionsFeedback feedback, uint replacesId) + { + uint? shownId = null; + try + { + shownId = await _channel + .ShowAsync(replacesId, feedback.Text, feedback.ShowUndo) + .ConfigureAwait(false); + } + catch (Exception ex) + { + // Feedback is best-effort; a failed notification must never affect learning. Log + // once (the notification daemon is likely missing/unreachable — a persistent + // condition, not worth a line per learned batch); everything after is Trace-only. + Trace.WriteLine($"[LearnedCorrectionsNotification] show failed: {ex.Message}"); + if (!_loggedFailure) + { + _loggedFailure = true; + _errorLog.AddEntry( + $"Learned-corrections notification failed: {ex.Message}", + ErrorCategory.Detection + ); + } + } + + // Record the id and pump the next queued feedback on the post thread so both stay + // serialized with the reads and with each other. + _post(() => + { + if (shownId is { } id) + { + _currentId = id; + } + else if (replacesId != NoReplaceId) + { + // A replacement Notify failed: the previous popup (replacesId) may still be on + // screen while the presenter has advanced to a newer batch, so its stale Undo would + // act on the wrong batch. Drop our claim on it and close the leftover; the current + // batch is shown by the next dispatch (or cleared by its own auto-hide). + _currentId = NoReplaceId; + _ = CloseOrphanAsync(replacesId); + } + + DispatchPending(); + }); + } + + // Closes a popup OUTSIDE the single-flight dispatch (so it can't re-enter DispatchPending and + // break the one-in-flight invariant). Best-effort: clears a leftover whose replacement failed. + private async Task CloseOrphanAsync(uint id) + { + try + { + await _channel.CloseAsync(id).ConfigureAwait(false); + } + catch (Exception ex) + { + Trace.WriteLine($"[LearnedCorrectionsNotification] orphan close failed: {ex.Message}"); + } + } + + private async Task RunCloseAsync(uint id) + { + if (id != NoReplaceId) + { + try + { + await _channel.CloseAsync(id).ConfigureAwait(false); + } + catch (Exception ex) + { + Trace.WriteLine($"[LearnedCorrectionsNotification] close failed: {ex.Message}"); + } + } + + _post(DispatchPending); + } + + private void OnActionInvoked(uint id, string actionKey) + { + _post(() => + { + // Ignore actions on notifications that aren't the one we currently own (a stale + // popup, or one already superseded by a newer batch). + if (id != _currentId || !string.Equals(actionKey, "undo", StringComparison.Ordinal)) + { + return; + } + + // A show/close is in flight, so a newer batch may already have replaced the presenter's + // pending batch while this popup still displays (and is keyed to) the old one. Undoing + // now would delete the newer batch instead — ignore until the display settles; the + // replacement popup carries its own live Undo. + if (_dispatching) + { + return; + } + + // Undo emits the 2s confirmation via FeedbackChanged, which replaces this popup + // in place (same _currentId as replaces_id). + _presenter.Undo(); + }); + } + + private void OnNotificationClosed(uint id, uint reason) + { + _post(() => + { + if (id != _currentId) + { + return; + } + + // The user (or daemon) dismissed the popup; drop the batch so a later + // ActionInvoked can't act on an invisible, un-undoable toast. Reset is silent, so + // no FeedbackChanged fires to re-show it. + _currentId = NoReplaceId; + _presenter.Reset(); + }); + } + + /// + /// Notification transport behind which the D-Bus implementation is hidden so the + /// orchestration is unit-testable with a fake. and + /// carry the freedesktop signal args (notification id + key / + /// reason). + /// + internal interface INotificationChannel : IDisposable + { + event Action? ActionInvoked; + event Action? Closed; + + /// + /// Shows or replaces a notification and returns its id. When + /// is 0 a new popup is created; otherwise the + /// existing one is updated in place. + /// + Task ShowAsync(uint replacesId, string summary, bool withUndoAction); + + Task CloseAsync(uint id); + } + + // System.Threading.Timer wrapped as the presenter's one-shot cancel handle. Disposal + // cancels the pending callback (the presenter re-arms by disposing the prior handle). + private sealed class PostingTimer : IDisposable + { + private readonly Timer _timer; + + public PostingTimer(TimeSpan delay, Action callback) + { + _timer = new Timer(_ => callback(), null, delay, Timeout.InfiniteTimeSpan); + } + + public void Dispose() + { + _timer.Dispose(); + } + } + + /// + /// over the session bus using + /// Tmds.DBus.Protocol directly (not gdbus): a subprocess can't receive the + /// daemon's ActionInvoked/NotificationClosed signals, which the Undo button needs. + /// Connects lazily on the first show; every one-shot call is time-bounded so a hung + /// daemon can't pin the task. + /// + private sealed class DBusNotificationChannel : INotificationChannel + { + private const string NotificationsService = "org.freedesktop.Notifications"; + private const string NotificationsPath = "/org/freedesktop/Notifications"; + private const string NotificationsInterface = "org.freedesktop.Notifications"; + + // Bound one-shot Notify/CloseNotification calls; a hung daemon must not pin us. Same + // rationale as AtSpiEventClient's call timeout (Tmds applies none of its own). + private static readonly TimeSpan s_callTimeout = TimeSpan.FromSeconds(4); + + private static readonly MessageValueReader s_readUInt32 = + static (m, _) => m.GetBodyReader().ReadUInt32(); + + // ActionInvoked body is (u id, s action_key). + private static readonly MessageValueReader<(uint Id, string Action)> s_readActionInvoked = + static (m, _) => + { + var reader = m.GetBodyReader(); + var id = reader.ReadUInt32(); + var action = reader.ReadString(); + return (id, action); + }; + + // NotificationClosed body is (u id, u reason). + private static readonly MessageValueReader<(uint Id, uint Reason)> s_readClosed = + static (m, _) => + { + var reader = m.GetBodyReader(); + var id = reader.ReadUInt32(); + var reason = reader.ReadUInt32(); + return (id, reason); + }; + + private readonly SemaphoreSlim _connectGate = new(1, 1); + + private DBusConnection? _connection; + private bool _disposed; + private IDisposable? _actionSubscription; + private IDisposable? _closedSubscription; + + // 1 once a signal observer reported a fatal connection error and a reset was scheduled; + // back to 0 when a fresh connection is established. One reset per dead connection. + private int _resetScheduled; + + public event Action? ActionInvoked; + public event Action? Closed; + + public async Task ShowAsync(uint replacesId, string summary, bool withUndoAction) + { + var conn = await EnsureConnectedAsync().ConfigureAwait(false); + + MessageBuffer message; + using (var writer = conn.GetMessageWriter()) + { + writer.WriteMethodCallHeader( + destination: NotificationsService, + path: NotificationsPath, + @interface: NotificationsInterface, + member: "Notify", + signature: "susssasa{sv}i" + ); + writer.WriteString("TypeWhisper"); // app_name + writer.WriteUInt32(replacesId); // replaces_id + writer.WriteString(string.Empty); // app_icon: this feedback toast carries no icon + writer.WriteString(summary); // summary + writer.WriteString(string.Empty); // body + + // actions: ["undo", public sealed class AudioRecordingService : IDisposable { + internal sealed class AudioCaptureSession + { + internal AudioCaptureSession(long diagnosticId) + { + DiagnosticId = diagnosticId; + } + + internal long DiagnosticId { get; } + + public override string ToString() => $"AudioCaptureSession({DiagnosticId})"; + } + + private sealed record LiveFrameSubscription( + AudioCaptureSession Session, + Action Sink + ); + private const int SampleRate = 16000; private const int Channels = 1; private const uint FramesPerBuffer = 512; @@ -27,21 +44,28 @@ public sealed class AudioRecordingService : IDisposable private static int s_paInitCount; private static readonly Lock s_paInitLock = new(); + private readonly Lock _captureLock = new(); + private readonly Func _ensureInputStreamStarted; + private readonly IErrorLogService? _errorLog; private readonly List _sampleChunks = []; private readonly Lock _sampleLock = new(); + private readonly Action _stopAndDisposeInputStream; + private readonly bool _terminatePortAudioOnDispose; + private AudioCaptureSession? _activeCaptureSession; + private long _captureSessionGeneration; private float _currentRmsLevel; private int _disposed; private int _isPreviewing; private int _isRecording; private long _lastLevelPostedTicksUtc; - // Per-frame tap fired from the PortAudio realtime thread when copySamples is true. + // Per-frame tap fired from the PortAudio realtime thread during an owned capture. // Must be allocation-free and non-blocking; sink borrows processedBuffer (no copy). // A throw detaches the sink via CAS so the same exception can't kill every frame. - private Action? _liveFrameSink; + private LiveFrameSubscription? _liveFrameSink; private int _sampleCount; private PaStream? _stream; - private readonly IErrorLogService? _errorLog; + private int _whisperModeEnabled; internal int CaptureSampleRate { get; private set; } = SampleRate; // PortAudio is initialized lazily via EnsurePortAudioInitialized, so @@ -53,6 +77,23 @@ public sealed class AudioRecordingService : IDisposable public AudioRecordingService(IErrorLogService? errorLog = null) { _errorLog = errorLog; + _ensureInputStreamStarted = EnsureInputStreamStarted; + _stopAndDisposeInputStream = StopAndDisposeInputStream; + _terminatePortAudioOnDispose = true; + } + + // Test seam: exercises the real ownership/buffering state machine + // without loading PortAudio or touching a device. + internal AudioRecordingService( + Func ensureInputStreamStarted, + Action stopAndDisposeInputStream, + IErrorLogService? errorLog = null + ) + { + _errorLog = errorLog; + _ensureInputStreamStarted = ensureInputStreamStarted; + _stopAndDisposeInputStream = stopAndDisposeInputStream; + _terminatePortAudioOnDispose = false; } public bool IsRecording => Volatile.Read(ref _isRecording) == 1; @@ -62,27 +103,29 @@ public AudioRecordingService(IErrorLogService? errorLog = null) public int? SelectedDeviceIndex { get; set; } - public bool WhisperModeEnabled { get; set; } - - internal Action? LiveFrameSink - { - get => _liveFrameSink; - set => _liveFrameSink = value; - } - public void Dispose() { - if (Interlocked.Exchange(ref _disposed, 1) == 1) + lock (_captureLock) { - return; + if (Volatile.Read(ref _disposed) == 1) + { + return; + } + + Volatile.Write(ref _disposed, 1); + Volatile.Write(ref _activeCaptureSession, null); + Volatile.Write(ref _liveFrameSink, null); + Volatile.Write(ref _isPreviewing, 0); + Volatile.Write(ref _isRecording, 0); + _stopAndDisposeInputStream(); } - Volatile.Write(ref _isPreviewing, 0); - Volatile.Write(ref _isRecording, 0); - StopAndDisposeInputStream(); UpdateLevel(0f); - TerminatePortAudioIfInitialized(); + if (_terminatePortAudioOnDispose) + { + TerminatePortAudioIfInitialized(); + } } public static IReadOnlyList GetInputDevices() @@ -116,72 +159,129 @@ public static IReadOnlyList GetInputDevices() return result; } - public void StartRecording() + internal AudioCaptureSession? TryStartRecording(bool whisperModeEnabled) { - if (IsRecording || Volatile.Read(ref _disposed) == 1) + lock (_captureLock) { - return; - } - - lock (_sampleLock) - { - _sampleChunks.Clear(); - _sampleCount = 0; - // Do NOT reset _captureSampleRate: EnsureInputStreamStarted may reuse a - // preview stream, and the negotiated rate is only assigned inside - // CreateInputStream. Resetting early would tag samples at the wrong rate. - } + if (_activeCaptureSession is not null || Volatile.Read(ref _disposed) == 1) + { + return null; + } - try - { - if (!EnsureInputStreamStarted()) + try + { + if (!_ensureInputStreamStarted()) + { + _errorLog?.AddEntry( + "Recording could not start: no usable microphone was found. " + + "Check that an input device is connected and selected in Recorder settings.", + ErrorCategory.Recording + ); + return null; + } + } + catch (Exception ex) { + // Surface a stuck-at-silent dictation: the user pressed the hotkey but no + // input stream could be opened (device busy, all sample rates rejected, …). _errorLog?.AddEntry( - "Recording could not start: no usable microphone was found. " - + "Check that an input device is connected and selected in Recorder settings.", + $"Recording could not start: the microphone could not be opened ({ex.Message}).", ErrorCategory.Recording ); - return; + throw; } + + lock (_sampleLock) + { + _sampleChunks.Clear(); + _sampleCount = 0; + // Do NOT reset CaptureSampleRate: the input seam may reuse a preview + // stream, whose negotiated rate was assigned when that stream opened. + } + + Volatile.Write(ref _whisperModeEnabled, whisperModeEnabled ? 1 : 0); + Volatile.Write(ref _liveFrameSink, null); + var session = new AudioCaptureSession(++_captureSessionGeneration); + Volatile.Write(ref _activeCaptureSession, session); + Volatile.Write(ref _isRecording, 1); + + Trace.WriteLine( + $"[AudioRecordingService] Recording started: session={session.DiagnosticId}, " + + $"captureSampleRate={CaptureSampleRate} Hz, target={SampleRate} Hz." + ); + return session; } - catch (Exception ex) + } + + internal bool IsRecordingOwnedBy(AudioCaptureSession? session) + { + lock (_captureLock) { - // Surface a stuck-at-silent dictation: the user pressed the hotkey but no - // input stream could be opened (device busy, all sample rates rejected, …). - _errorLog?.AddEntry( - $"Recording could not start: the microphone could not be opened ({ex.Message}).", - ErrorCategory.Recording - ); - throw; + return session is not null && ReferenceEquals(_activeCaptureSession, session); } + } - Trace.WriteLine( - $"[AudioRecordingService] Recording started: captureSampleRate={CaptureSampleRate} Hz, target={SampleRate} Hz." - ); + internal bool TrySetWhisperMode(AudioCaptureSession session, bool enabled) + { + lock (_captureLock) + { + if (!ReferenceEquals(_activeCaptureSession, session)) + { + return false; + } - Volatile.Write(ref _isRecording, 1); + Volatile.Write(ref _whisperModeEnabled, enabled ? 1 : 0); + return true; + } } - public byte[] StopRecording() + internal bool TrySetLiveFrameSink(AudioCaptureSession session, Action? sink) { - if (!IsRecording) + lock (_captureLock) { - return []; - } + if (!ReferenceEquals(_activeCaptureSession, session)) + { + return false; + } - Volatile.Write(ref _isRecording, 0); + Volatile.Write( + ref _liveFrameSink, + sink is null ? null : new LiveFrameSubscription(session, sink) + ); + return true; + } + } - if (!IsPreviewing) + internal byte[] StopRecording(AudioCaptureSession session) + { + lock (_captureLock) { - StopAndDisposeInputStream(); - } + if (!ReferenceEquals(_activeCaptureSession, session)) + { + return []; + } - return BuildWavFromRecordedAudio(); + Volatile.Write(ref _activeCaptureSession, null); + Volatile.Write(ref _liveFrameSink, null); + Volatile.Write(ref _isRecording, 0); + + if (!IsPreviewing) + { + _stopAndDisposeInputStream(); + } + + // Keep the capture lock through materialization. A new owner cannot + // clear or reuse the sample list until this WAV is complete. + return BuildWavFromRecordedAudio(); + } } - public async Task StopRecordingAsync(CancellationToken cancellationToken = default) + internal async Task StopRecordingAsync( + AudioCaptureSession session, + CancellationToken cancellationToken = default + ) { - if (!IsRecording) + if (!IsRecordingOwnedBy(session)) { return []; } @@ -195,72 +295,87 @@ public async Task StopRecordingAsync(CancellationToken cancellationToken // Still stop and return the samples captured so far. } - return StopRecording(); + // StopRecording validates again so a stale delayed stop cannot affect a + // newer capture that started while this method was draining. + return StopRecording(session); } - public byte[]? GetCurrentBuffer() + internal byte[]? GetCurrentBuffer(AudioCaptureSession session) { - if (!IsRecording) - { - return null; - } - - lock (_sampleLock) + lock (_captureLock) { - if (_sampleCount == 0) + if (!ReferenceEquals(_activeCaptureSession, session)) { return null; } - } - return BuildWavFromRecordedAudio(); + lock (_sampleLock) + { + if (_sampleCount == 0) + { + return null; + } + } + + return BuildWavFromRecordedAudio(); + } } public bool StartPreview() { - if (Volatile.Read(ref _disposed) == 1 || IsRecording || IsPreviewing) - { - return false; - } - - try + lock (_captureLock) { - if (!EnsureInputStreamStarted()) + if ( + Volatile.Read(ref _disposed) == 1 + || _activeCaptureSession is not null + || IsPreviewing + ) { return false; } - Volatile.Write(ref _isPreviewing, 1); - return true; - } - catch (Exception ex) - { - Trace.WriteLine($"[AudioRecordingService] Failed to start preview: {ex.Message}"); - _errorLog?.AddEntry( - $"Microphone preview could not start: {ex.Message}", - ErrorCategory.Recording - ); - Volatile.Write(ref _isPreviewing, 0); - if (!IsRecording) + try { - StopAndDisposeInputStream(); + if (!_ensureInputStreamStarted()) + { + return false; + } + + Volatile.Write(ref _isPreviewing, 1); + return true; } + catch (Exception ex) + { + Trace.WriteLine($"[AudioRecordingService] Failed to start preview: {ex.Message}"); + _errorLog?.AddEntry( + $"Microphone preview could not start: {ex.Message}", + ErrorCategory.Recording + ); + Volatile.Write(ref _isPreviewing, 0); + if (_activeCaptureSession is null) + { + _stopAndDisposeInputStream(); + } - return false; + return false; + } } } public void StopPreview() { - if (!IsPreviewing) + lock (_captureLock) { - return; - } + if (!IsPreviewing) + { + return; + } - Volatile.Write(ref _isPreviewing, 0); - if (!IsRecording) - { - StopAndDisposeInputStream(); + Volatile.Write(ref _isPreviewing, 0); + if (_activeCaptureSession is null) + { + _stopAndDisposeInputStream(); + } } UpdateLevel(0f); @@ -378,7 +493,7 @@ int targetSampleRate return output; } - internal StreamCallbackResult ProcessAudioBufferForTest(float[] frame, bool copySamples) + internal StreamCallbackResult ProcessAudioBufferForTest(float[] frame) { var handle = GCHandle.Alloc(frame, GCHandleType.Pinned); try @@ -386,7 +501,7 @@ internal StreamCallbackResult ProcessAudioBufferForTest(float[] frame, bool copy return ProcessAudioBuffer( handle.AddrOfPinnedObject(), (uint)frame.Length, - copySamples + Volatile.Read(ref _activeCaptureSession) ); } finally @@ -412,10 +527,18 @@ private StreamCallbackResult InputAudioCallback( IntPtr userData ) { - return ProcessAudioBuffer(input, frameCount, IsRecording); + return ProcessAudioBuffer( + input, + frameCount, + Volatile.Read(ref _activeCaptureSession) + ); } - private StreamCallbackResult ProcessAudioBuffer(IntPtr input, uint frameCount, bool copySamples) + private StreamCallbackResult ProcessAudioBuffer( + IntPtr input, + uint frameCount, + AudioCaptureSession? captureSession + ) { if (input == IntPtr.Zero || frameCount == 0) { @@ -425,29 +548,42 @@ private StreamCallbackResult ProcessAudioBuffer(IntPtr input, uint frameCount, b var buffer = new float[frameCount]; Marshal.Copy(input, buffer, 0, (int)frameCount); - var processedBuffer = ApplyWhisperModeGain(buffer, copySamples && WhisperModeEnabled); + var processedBuffer = ApplyWhisperModeGain( + buffer, + captureSession is not null && Volatile.Read(ref _whisperModeEnabled) == 1 + ); UpdateLevel(ComputeRmsLevel(processedBuffer)); - if (!copySamples) + if (captureSession is null) { return StreamCallbackResult.Continue; } lock (_sampleLock) { + // Re-check the token here: a callback from a stopped preview-backed + // recording can still land after a later owner reset the buffer. + if (!ReferenceEquals(Volatile.Read(ref _activeCaptureSession), captureSession)) + { + return StreamCallbackResult.Continue; + } + _sampleChunks.Add(processedBuffer); _sampleCount += processedBuffer.Length; } - var sink = _liveFrameSink; - if (sink is null) + var subscription = Volatile.Read(ref _liveFrameSink); + if ( + subscription is null + || !ReferenceEquals(subscription.Session, captureSession) + ) { return StreamCallbackResult.Continue; } try { - sink(processedBuffer); + subscription.Sink(processedBuffer); } catch (Exception ex) { @@ -457,7 +593,7 @@ private StreamCallbackResult ProcessAudioBuffer(IntPtr input, uint frameCount, b Trace.WriteLine( $"[AudioRecordingService] LiveFrameSink threw, detaching: {ex.Message}" ); - Interlocked.CompareExchange(ref _liveFrameSink, null, sink); + Interlocked.CompareExchange(ref _liveFrameSink, null, subscription); } return StreamCallbackResult.Continue; @@ -776,4 +912,4 @@ public sealed record AudioInputDevice( int MaxInputChannels, bool IsDefault, string PersistentId -); \ No newline at end of file +); diff --git a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs index c59f393e8..296f263bf 100644 --- a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs +++ b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs @@ -94,6 +94,7 @@ public sealed class DictationOrchestrator : IDisposable private readonly DictationToggleGate _toggleGate = new(); private readonly ITranslationService _translation; private readonly IVocabularyBoostingService _vocabularyBoosting; + private AudioRecordingService.AudioCaptureSession? _audioCaptureSession; private CancellationTokenSource? _activeDictationCts; // Cancels an in-flight spoken command (its LLM stream + typing). Distinct from @@ -207,7 +208,7 @@ ISessionActivityMonitor sessionActivityMonitor _sessionActivityMonitor = sessionActivityMonitor; } - public bool IsRecording => _audio.IsRecording; + public bool IsRecording => _audio.IsRecordingOwnedBy(_audioCaptureSession); /// /// Current pipeline phase for typewhisper status. The audio @@ -218,7 +219,7 @@ public string CurrentStateLabel { get { - if (_audio.IsRecording) + if (IsRecording) { return "recording"; } @@ -279,11 +280,12 @@ public void Dispose() // Stop any active recording and undo ducking/media-pause before teardown // so the user isn't left with a muted system after exit. - if (_audio.IsRecording) + var captureSession = _audioCaptureSession; + if (_audio.IsRecordingOwnedBy(captureSession)) { try { - _audio.StopRecording(); + _audio.StopRecording(captureSession!); } catch (Exception ex) { @@ -309,6 +311,8 @@ public void Dispose() } } + Interlocked.CompareExchange(ref _audioCaptureSession, null, captureSession); + ShutdownPartialTranscriptionSession(); // Null the audio tap and snapshot the coordinator before awaiting close @@ -320,11 +324,6 @@ public void Dispose() _streamingProviderId = null; _streamingModelId = null; _streamingLanguageHint = null; - if (disposingCoordinator is not null) - { - _audio.LiveFrameSink = null; - } - var streamingTeardown = TeardownStreamingSessionAsync( disposingCoordinator, disposingStartupCts, @@ -452,7 +451,7 @@ public async Task ToggleAsync(string? forcedProfileId = null) _lastToggleUtc = now; } - if (_audio.IsRecording) + if (IsRecording) { await StopAsync(); } @@ -480,7 +479,7 @@ public async Task CancelAsync() // cancel intent explicitly so a racing StartAsync that clears the shared // _cancelRequested flag between here and the gate probe can't downgrade // this discard to a normal save. - if (_audio.IsRecording) + if (IsRecording) { _cancelRequested = true; await StopAsync(cancelRequested: true); @@ -498,7 +497,7 @@ public async Task StartAsync(string? forcedProfileId = null) DictationDeferredStop pendingStop; try { - if (_audio.IsRecording) + if (IsRecording) { goto StartupComplete; } @@ -514,15 +513,16 @@ public async Task StartAsync(string? forcedProfileId = null) goto StartupComplete; } - _audio.WhisperModeEnabled = _settings.Current.WhisperModeEnabled; - // Start capturing immediately — user may already be speaking (especially PTT). _recordingStart = DateTime.UtcNow; _lastSpeechDetectedAtUtc = _recordingStart; _silenceStopRequested = false; + AudioRecordingService.AudioCaptureSession? captureSession; try { - _audio.StartRecording(); + captureSession = _audio.TryStartRecording( + _settings.Current.WhisperModeEnabled + ); } catch (Exception ex) { @@ -533,7 +533,7 @@ public async Task StartAsync(string? forcedProfileId = null) goto StartupComplete; } - if (!_audio.IsRecording) + if (captureSession is null) { var message = BuildRecordingStartFailureMessage(null); ReportStatus(message); @@ -541,6 +541,8 @@ public async Task StartAsync(string? forcedProfileId = null) goto StartupComplete; } + _audioCaptureSession = captureSession; + // Set overlay to "Recording…" after the stream is confirmed open but // before slow startup work (playerctl, sound). On Wayland the earlier // ordering made the stale feedback bubble linger until after PauseMedia. @@ -627,18 +629,22 @@ state with ) { StartStreamingTranscriptionSession( - startupPlugin, startupLanguageHint, sessionVersion); + startupPlugin, + startupLanguageHint, + sessionVersion, + captureSession + ); } // Always start the partial loop — it drives silence-auto-stop. // When streaming is active, the in-loop policy short-circuits // PollPartialTranscriptOnceAsync so polling stays a no-op. - StartPartialTranscriptionSession(sessionVersion); + StartPartialTranscriptionSession(sessionVersion, captureSession); } catch (Exception ex) { Trace.WriteLine($"[Dictation] Post-start setup failed: {ex}"); - RollBackStartedRecording(); + RollBackStartedRecording(captureSession); _ = await StopPartialTranscriptionSessionAsync(); var faultedCoordinator = _streamingCoordinator; var faultedStartupCts = _streamingStartupCts; @@ -647,7 +653,6 @@ state with _streamingProviderId = null; _streamingModelId = null; _streamingLanguageHint = null; - _audio.LiveFrameSink = null; _ = await TeardownStreamingSessionAsync( faultedCoordinator, faultedStartupCts, @@ -754,8 +759,11 @@ state with return; } - _audio.WhisperModeEnabled = - matchedProfile?.WhisperModeOverride ?? _settings.Current.WhisperModeEnabled; + _audio.TrySetWhisperMode( + captureSession, + matchedProfile?.WhisperModeOverride + ?? _settings.Current.WhisperModeEnabled + ); SetOverlayState(state => state with { ActiveProfileName = matchedProfile?.Name, ActiveAppName = appTitle } ); @@ -850,9 +858,11 @@ rematch.Profile is not null state with { ActiveProfileName = rematch.Profile.Name } ); - _audio.WhisperModeEnabled = + _audio.TrySetWhisperMode( + captureSession, rematch.Profile.WhisperModeOverride - ?? _settings.Current.WhisperModeEnabled; + ?? _settings.Current.WhisperModeEnabled + ); } } } @@ -874,7 +884,7 @@ rematch.Profile is not null if (!_sessionActivityMonitor.IsInputAllowed) { Trace.WriteLine("[Dictation] Session locked during start; rolling back recording."); - RollBackStartedRecording(); + RollBackStartedRecording(captureSession); _ = await StopPartialTranscriptionSessionAsync(); StreamingTranscriptionCoordinator? rolledBackCoordinator; @@ -893,7 +903,6 @@ rematch.Profile is not null _streamingLanguageHint = null; } - _audio.LiveFrameSink = null; _ = await TeardownStreamingSessionAsync( rolledBackCoordinator, rolledBackStartupCts, @@ -1052,7 +1061,8 @@ private async Task StopWhileHoldingGateAsync() int? insertionOrderSessionId = null; try { - if (!_audio.IsRecording) + var captureSession = _audioCaptureSession; + if (!_audio.IsRecordingOwnedBy(captureSession)) { return; } @@ -1063,8 +1073,16 @@ private async Task StopWhileHoldingGateAsync() var canceledThisStop = _cancelRequested; _cancelRequested = false; - // ReSharper disable once MethodSupportsCancellation -- stop path must run teardown to completion; recording stop is intentionally non-cancellable. - var wav = await _audio.StopRecordingAsync(); + byte[] wav; + try + { + // ReSharper disable once MethodSupportsCancellation -- stop path must run teardown to completion; recording stop is intentionally non-cancellable. + wav = await _audio.StopRecordingAsync(captureSession!); + } + finally + { + Interlocked.CompareExchange(ref _audioCaptureSession, null, captureSession); + } var recoveredPartialPreview = await StopPartialTranscriptionSessionAsync(); await AwaitRecordingSnapshotAsync(); _audioDucking.RestoreAudio(); @@ -1134,11 +1152,6 @@ private async Task StopWhileHoldingGateAsync() _recordingStart = default; } - if (stoppedStreamingCoordinator is not null) - { - _audio.LiveFrameSink = null; - } - // Release the gate now that capture is torn down and context is // snapshotted. A new StartAsync can record while transcription runs. // Reserve this session's insertion-order slot before releasing the @@ -2656,7 +2669,7 @@ state with // Don't disarm Escape if a new recording — or a newer overlapping spoken command (its // CTS is still set above) — has taken over the shortcut meanwhile. - if (_activeCommandCts is null && _activeDictationCts is null && !_audio.IsRecording) + if (_activeCommandCts is null && _activeDictationCts is null && !IsRecording) { _hotkey.IsCancelShortcutEnabled = false; } @@ -3540,13 +3553,13 @@ private bool IsContextStillOwningOverlay(RecordingContext context) return current <= context.SessionId + 1; } - private void RollBackStartedRecording() + private void RollBackStartedRecording(AudioRecordingService.AudioCaptureSession captureSession) { try { - if (_audio.IsRecording) + if (_audio.IsRecordingOwnedBy(captureSession)) { - _audio.StopRecording(); + _audio.StopRecording(captureSession); } } catch (Exception ex) @@ -3555,6 +3568,10 @@ private void RollBackStartedRecording() $"[Dictation] Failed to stop recording during start rollback: {ex.Message}" ); } + finally + { + Interlocked.CompareExchange(ref _audioCaptureSession, null, captureSession); + } try { @@ -3612,7 +3629,10 @@ private void SetOverlayState(Func } } - private void StartPartialTranscriptionSession(int sessionVersion) + private void StartPartialTranscriptionSession( + int sessionVersion, + AudioRecordingService.AudioCaptureSession captureSession + ) { _partialTranscriptionCts?.Cancel(); _partialTranscriptionCts?.Dispose(); @@ -3622,21 +3642,22 @@ private void StartPartialTranscriptionSession(int sessionVersion) _partialTranscriptionCts = cts; // ReSharper disable once MethodSupportsCancellation -- the loop receives cts.Token directly; a Task.Run token would be redundant. _partialTranscriptionTask = Task.Run(() => - RunPartialTranscriptionLoopAsync(sessionVersion, cts.Token) + RunPartialTranscriptionLoopAsync(sessionVersion, captureSession, cts.Token) ); } private void StartStreamingTranscriptionSession( ITranscriptionEnginePlugin plugin, string? language, - int sessionVersion + int sessionVersion, + AudioRecordingService.AudioCaptureSession captureSession ) { var coordinator = new StreamingTranscriptionCoordinator( plugin, language, sessionVersion, - TryPublishPartialTranscript, + (version, text) => TryPublishPartialTranscript(version, captureSession, text), ex => { // Coordinator already sets its own Faulted flag — just log. @@ -3651,10 +3672,17 @@ int sessionVersion // Wire the audio tap BEFORE StartAsync resolves so frames captured // during the connect handshake queue in the coordinator's pending - // buffer (1 MB cap, drop-oldest). Detached in - // TeardownStreamingSessionAsync. - _audio.LiveFrameSink = samples => - coordinator.AcceptAudioFrame(samples, _audio.CaptureSampleRate); + // buffer (1 MB cap, drop-oldest). The audio service detaches it at the + // token-protected stop boundary. + if ( + !_audio.TrySetLiveFrameSink( + captureSession, + samples => coordinator.AcceptAudioFrame(samples, _audio.CaptureSampleRate) + ) + ) + { + throw new InvalidOperationException("Audio capture ended before streaming setup."); + } // Owns cancellation of the queued connect handshake. The coordinator // creates its own internal _cts inside StartAsync, but if teardown @@ -3864,7 +3892,11 @@ private void ShutdownPartialTranscriptionSession() _partialTranscriptState.StopSession(); } - private async Task RunPartialTranscriptionLoopAsync(int sessionVersion, CancellationToken ct) + private async Task RunPartialTranscriptionLoopAsync( + int sessionVersion, + AudioRecordingService.AudioCaptureSession captureSession, + CancellationToken ct + ) { var partialPollInterval = TimeSpan.FromSeconds(3); var loopDelay = TimeSpan.FromMilliseconds(250); @@ -3872,7 +3904,10 @@ private async Task RunPartialTranscriptionLoopAsync(int sessionVersion, Cancella try { - while (!ct.IsCancellationRequested && _audio.IsRecording) + while ( + !ct.IsCancellationRequested + && _audio.IsRecordingOwnedBy(captureSession) + ) { if (_audio.HasSpeechEnergy) { @@ -3889,7 +3924,7 @@ private async Task RunPartialTranscriptionLoopAsync(int sessionVersion, Cancella if (DateTime.UtcNow >= nextPartialPollAtUtc) { - var wav = _audio.GetCurrentBuffer(); + var wav = _audio.GetCurrentBuffer(captureSession); // Partials are best-effort/cosmetic. Only poll when a model is // already loaded (never *initiate* a load for a partial), and // use TryAcquire so a partial silently skips when a final @@ -3925,6 +3960,7 @@ await PollPartialTranscriptOnceAsync( lease.Plugin, wav, sessionVersion, + captureSession, ct ); } @@ -3963,6 +3999,7 @@ private async Task PollPartialTranscriptOnceAsync( ITranscriptionEnginePlugin plugin, byte[] wav, int sessionVersion, + AudioRecordingService.AudioCaptureSession captureSession, CancellationToken ct ) { @@ -3985,13 +4022,14 @@ CancellationToken ct null, partial => { - TryPublishPartialTranscript(sessionVersion, partial); - return !ct.IsCancellationRequested && _audio.IsRecording; + TryPublishPartialTranscript(sessionVersion, captureSession, partial); + return !ct.IsCancellationRequested + && _audio.IsRecordingOwnedBy(captureSession); }, ct ); - TryPublishPartialTranscript(sessionVersion, result.Text); + TryPublishPartialTranscript(sessionVersion, captureSession, result.Text); } catch (OperationCanceledException) { } catch (Exception ex) @@ -4045,7 +4083,11 @@ private void FinalizeSession(int sessionId, string status, string? message) PublishSessionTerminal(sessionId, status, message); } - private void TryPublishPartialTranscript(int sessionVersion, string? text) + private void TryPublishPartialTranscript( + int sessionVersion, + AudioRecordingService.AudioCaptureSession captureSession, + string? text + ) { if ( !_partialTranscriptState.TryApplyPolling( @@ -4069,7 +4111,7 @@ out var partialText new PartialTranscriptionUpdateEvent { PartialText = partialText, - IsRecording = _audio.IsRecording, + IsRecording = _audio.IsRecordingOwnedBy(captureSession), ElapsedSeconds = _recordingStart == default ? 0 diff --git a/src/TypeWhisper.Linux/Services/TransformSelectionService.cs b/src/TypeWhisper.Linux/Services/TransformSelectionService.cs index 38303fefd..41cf9745d 100644 --- a/src/TypeWhisper.Linux/Services/TransformSelectionService.cs +++ b/src/TypeWhisper.Linux/Services/TransformSelectionService.cs @@ -162,10 +162,10 @@ await ShowWarningAsync( return; } - _audio.WhisperModeEnabled = _settings.Current.WhisperModeEnabled; + AudioRecordingService.AudioCaptureSession? captureSession; try { - _audio.StartRecording(); + captureSession = _audio.TryStartRecording(_settings.Current.WhisperModeEnabled); } catch (Exception ex) { @@ -174,13 +174,19 @@ await ShowWarningAsync( return; } - if (!_audio.IsRecording) + if (captureSession is null) { await ShowWarningAsync("Could not start recording. Check your microphone settings."); return; } - _session = new TransformSelectionSession(selectedText, windowId, processName, windowTitle); + _session = new TransformSelectionSession( + selectedText, + windowId, + processName, + windowTitle, + captureSession + ); PublishOverlay(state => state with { @@ -225,7 +231,7 @@ state with byte[] wav; try { - wav = await _audio.StopRecordingAsync(); + wav = await _audio.StopRecordingAsync(session.CaptureSession); } catch (Exception ex) { @@ -434,6 +440,7 @@ private sealed record TransformSelectionSession( string SelectedText, string? WindowId, string? ProcessName, - string? WindowTitle + string? WindowTitle, + AudioRecordingService.AudioCaptureSession CaptureSession ); } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/RecorderSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/RecorderSectionViewModel.cs index 5960e1f65..ed6517d9d 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/RecorderSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/RecorderSectionViewModel.cs @@ -24,6 +24,7 @@ public partial class RecorderSectionViewModel : ObservableObject private readonly AudioRecordingService _audio; private readonly ModelManagerService _models; private readonly ISettingsService _settings; + private AudioRecordingService.AudioCaptureSession? _captureSession; // Command execution and continuations that access this flag run on the UI thread. private bool _stopSaveInProgress; @@ -104,13 +105,14 @@ private void SetStopSaveInProgress(bool value) private void StartRecording() { - _audio.StartRecording(); - if (!_audio.IsRecording) + var captureSession = _audio.TryStartRecording(_settings.Current.WhisperModeEnabled); + if (captureSession is null) { StatusText = Loc.Instance["Recorder.StatusNoMicrophone"]; return; } + _captureSession = captureSession; IsRecording = true; OnPropertyChanged(nameof(RecordButtonText)); _recordingStart = DateTime.UtcNow; @@ -134,12 +136,16 @@ private async Task StopRecordingAsync() _timer = null; var duration = DateTime.UtcNow - _recordingStart; + var captureSession = _captureSession; + _captureSession = null; byte[] wav; string filePath; try { - wav = await _audio.StopRecordingAsync(); + wav = captureSession is null + ? [] + : await _audio.StopRecordingAsync(captureSession); if (wav.Length == 0) { StatusText = Loc.Instance["Recorder.StatusNoAudio"]; diff --git a/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs b/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs index a9ae0a81d..b2b35e8e1 100644 --- a/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs @@ -46,6 +46,7 @@ public partial class WelcomeWizardViewModel : ObservableObject private readonly IReadOnlyList _setupTasks; private readonly TextInsertionService _textInsertion; private bool _cleanedUp; + private AudioRecordingService.AudioCaptureSession? _firstDictationCaptureSession; [ObservableProperty] private string _cudaBenchmarkStatus = Loc.Instance["Wizard.CudaBenchmarkIdle"]; @@ -265,9 +266,14 @@ public void Cleanup() _audio.StopPreview(); } - if (IsFirstDictationRecording) + var firstDictationCaptureSession = _firstDictationCaptureSession; + _firstDictationCaptureSession = null; + if (firstDictationCaptureSession is not null) { - FireAndLog(() => _audio.StopRecordingAsync(), "welcome wizard stop recording"); + FireAndLog( + () => _audio.StopRecordingAsync(firstDictationCaptureSession), + "welcome wizard stop recording" + ); } IsMicTestRunning = false; @@ -761,9 +767,12 @@ private async Task ToggleFirstDictationAsync() _audio.SelectedDeviceIndex = SelectedMic.Index; } + _firstDictationCaptureSession = null; try { - _audio.StartRecording(); + _firstDictationCaptureSession = _audio.TryStartRecording( + _settings.Current.WhisperModeEnabled + ); } catch (Exception ex) { @@ -775,7 +784,7 @@ private async Task ToggleFirstDictationAsync() return; } - if (!_audio.IsRecording) + if (_firstDictationCaptureSession is null) { FirstDictationStatus = Loc.Instance["Wizard.FirstDictationStartFailedGeneric"]; IsFirstDictationRecording = false; @@ -788,10 +797,14 @@ private async Task ToggleFirstDictationAsync() IsFirstDictationRecording = false; FirstDictationStatus = Loc.Instance["Wizard.FirstDictationStopping"]; + var captureSession = _firstDictationCaptureSession; + _firstDictationCaptureSession = null; byte[] wav; try { - wav = await _audio.StopRecordingAsync(); + wav = captureSession is null + ? [] + : await _audio.StopRecordingAsync(captureSession); } catch (Exception ex) { @@ -1053,4 +1066,4 @@ private void NotifyDerived() OnPropertyChanged(nameof(StatusTone)); OnPropertyChanged(nameof(StatusGlyph)); } -} \ No newline at end of file +} diff --git a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs index 67fd53357..616f0e6d5 100644 --- a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs @@ -60,9 +60,12 @@ public void ResampleToSampleRate_ReturnsSameArrayWhenRateAlreadyMatches() [Fact] public void LiveFrameSink_InvokedFromCallback_WithProcessedSamples() { - using var service = new AudioRecordingService { WhisperModeEnabled = true }; + using var service = new AudioRecordingService(() => true, () => { }); + var session = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: true) + ); var captured = new List(); - service.LiveFrameSink = captured.Add; + Assert.True(service.TrySetLiveFrameSink(session, captured.Add)); var input = new[] { 0.01f, -0.01f, 0.01f, -0.01f }; var expected = AudioRecordingService.ApplyWhisperModeGain( @@ -70,7 +73,7 @@ public void LiveFrameSink_InvokedFromCallback_WithProcessedSamples() true ); - var result = service.ProcessAudioBufferForTest(input, copySamples: true); + var result = service.ProcessAudioBufferForTest(input); Assert.Equal(StreamCallbackResult.Continue, result); Assert.Single(captured); @@ -81,19 +84,33 @@ public void LiveFrameSink_InvokedFromCallback_WithProcessedSamples() [Fact] public void LiveFrameSink_ThrowingSubscriber_DoesNotKillCapture() { - using var service = new AudioRecordingService(); - service.LiveFrameSink = _ => throw new InvalidOperationException("boom"); + using var service = new AudioRecordingService(() => true, () => { }); + var session = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + var invocationCount = 0; + Assert.True( + service.TrySetLiveFrameSink( + session, + _ => + { + invocationCount++; + throw new InvalidOperationException("boom"); + } + ) + ); var frame1 = new[] { 0.1f, -0.2f, 0.1f, -0.2f }; - var result1 = service.ProcessAudioBufferForTest(frame1, copySamples: true); + var result1 = service.ProcessAudioBufferForTest(frame1); Assert.Equal(StreamCallbackResult.Continue, result1); - Assert.Null(service.LiveFrameSink); + Assert.Equal(1, invocationCount); var frame2 = new[] { 0.4f, -0.3f, 0.4f, -0.3f }; - var result2 = service.ProcessAudioBufferForTest(frame2, copySamples: true); + var result2 = service.ProcessAudioBufferForTest(frame2); Assert.Equal(StreamCallbackResult.Continue, result2); + Assert.Equal(1, invocationCount); // CurrentRmsLevel is written synchronously inside ProcessAudioBuffer // before the UI-thread post; reading it confirms the second frame's // processing path ran end-to-end after the throwing sink detached. @@ -107,14 +124,161 @@ public void LiveFrameSink_ThrowingSubscriber_DoesNotKillCapture() [Fact] public void LiveFrameSink_OnlyFiresWhenIsRecording() { - using var service = new AudioRecordingService(); + using var service = new AudioRecordingService(() => true, () => { }); + var session = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); var invoked = false; - service.LiveFrameSink = _ => invoked = true; + Assert.True(service.TrySetLiveFrameSink(session, _ => invoked = true)); + service.StopRecording(session); var frame = new[] { 0.1f, -0.2f, 0.1f, -0.2f }; - var result = service.ProcessAudioBufferForTest(frame, copySamples: false); + var result = service.ProcessAudioBufferForTest(frame); Assert.Equal(StreamCallbackResult.Continue, result); Assert.False(invoked); } -} \ No newline at end of file + + [Fact] + public void TryStartRecording_WhenBusy_ReturnsNullWithoutAdoptingOrReconfiguringOwner() + { + var streamStartCount = 0; + var streamStopCount = 0; + using var service = new AudioRecordingService( + () => + { + streamStartCount++; + return true; + }, + () => streamStopCount++ + ); + + var first = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + var competing = service.TryStartRecording(whisperModeEnabled: true); + + Assert.Null(competing); + Assert.True(service.IsRecordingOwnedBy(first)); + Assert.Equal(1, streamStartCount); + Assert.Equal(0, streamStopCount); + + service.ProcessAudioBufferForTest([0.01f]); + var wav = service.StopRecording(first); + + Assert.Equal(1, streamStopCount); + Assert.InRange(BitConverter.ToInt16(wav, 44), (short)300, (short)350); + } + + [Fact] + public async Task StaleSession_CannotReadOrStopNewOwner() + { + var streamStartCount = 0; + var streamStopCount = 0; + using var service = new AudioRecordingService( + () => + { + streamStartCount++; + return true; + }, + () => streamStopCount++ + ); + + var sessionA = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + service.ProcessAudioBufferForTest([0.1f, -0.1f, 0.1f]); + var delayedStopA = service.StopRecordingAsync(sessionA); + // ReSharper disable once MethodHasAsyncOverload -- deliberately races the synchronous stop against the pending async stop. + var wavA = service.StopRecording(sessionA); + Assert.True(wavA.Length > 44); + + var sessionB = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + service.ProcessAudioBufferForTest([0.2f, -0.2f]); + + Assert.False(service.TrySetWhisperMode(sessionA, enabled: true)); + Assert.Null(service.GetCurrentBuffer(sessionA)); + // ReSharper disable once MethodHasAsyncOverload -- verifies the synchronous stop overload is a no-op for a superseded session. + Assert.Empty(service.StopRecording(sessionA)); + Assert.Empty(await delayedStopA); + Assert.Equal(1, streamStopCount); + Assert.True(service.IsRecordingOwnedBy(sessionB)); + + var currentB = Assert.IsType(service.GetCurrentBuffer(sessionB)); + // ReSharper disable once MethodHasAsyncOverload -- exercises the synchronous stop overload for the owning session. + var wavB = service.StopRecording(sessionB); + + Assert.Equal(2, streamStartCount); + Assert.Equal(2, streamStopCount); + Assert.Equal(48, currentB.Length); + Assert.Equal(currentB, wavB); + } + + [Fact] + public async Task TryStartRecording_ConcurrentCallers_YieldExactlyOneOwnerAndOneStreamStart() + { + var streamStartCount = 0; + var streamStopCount = 0; + using var service = new AudioRecordingService( + () => + { + Interlocked.Increment(ref streamStartCount); + return true; + }, + () => Interlocked.Increment(ref streamStopCount) + ); + using var barrier = new Barrier(3); + + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- kept adjacent to its two call sites below for readability. + Task StartConcurrently() => Task.Run(() => + { + barrier.SignalAndWait(); + return service.TryStartRecording(whisperModeEnabled: false); + }); + + var firstStart = StartConcurrently(); + var secondStart = StartConcurrently(); + barrier.SignalAndWait(); + var sessions = await Task.WhenAll(firstStart, secondStart); + + var owner = Assert.Single(sessions, session => session is not null)!; + Assert.Equal(1, streamStartCount); + Assert.True(service.IsRecordingOwnedBy(owner)); + + // ReSharper disable once MethodHasAsyncOverload -- synchronous stop is sufficient to assert a single stream-stop for the sole owner. + service.StopRecording(owner); + Assert.Equal(1, streamStopCount); + } + + [Fact] + public void OwningSession_StopsOnce_AndRepeatedStopCannotAffectLaterSession() + { + var streamStopCount = 0; + using var service = new AudioRecordingService( + () => true, + () => streamStopCount++ + ); + + var sessionA = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + service.ProcessAudioBufferForTest([0.1f]); + + Assert.True(service.StopRecording(sessionA).Length > 44); + Assert.Empty(service.StopRecording(sessionA)); + Assert.Equal(1, streamStopCount); + + var sessionB = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + service.ProcessAudioBufferForTest([0.2f, -0.2f]); + + Assert.Empty(service.StopRecording(sessionA)); + Assert.Equal(1, streamStopCount); + Assert.True(service.IsRecordingOwnedBy(sessionB)); + Assert.True(service.StopRecording(sessionB).Length > 44); + Assert.Equal(2, streamStopCount); + } +} From bfbc1bc29e8bfc28f9f0f8efec11ce7d54f91eab Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 05:51:35 +0000 Subject: [PATCH 090/226] =?UTF-8?q?Detect=20stale=20desktop=20integration?= =?UTF-8?q?=20and=20offer=20refresh=20after=20hotkey=20or=20mode=20changes?= =?UTF-8?q?=20(audit=20=C2=A74=20M6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/Localization/en.json | 4 + .../Hotkey/DeSetup/GnomeShortcutWriter.cs | 58 +-- .../Hotkey/DeSetup/HyprlandShortcutWriter.cs | 106 +++--- .../Hotkey/DeSetup/IDeShortcutWriter.cs | 10 +- .../Hotkey/DeSetup/KdeShortcutWriter.cs | 9 + .../Hotkey/DeSetup/SwayShortcutWriter.cs | 60 ++-- .../Sections/ShortcutsSectionViewModel.cs | 239 ++++++++++++- .../Views/Sections/ShortcutsSection.axaml | 33 +- .../Views/Sections/ShortcutsSection.axaml.cs | 9 +- ...ompositorShortcutWriterConcurrencyTests.cs | 71 +++- .../FakeDeShortcutWriter.cs | 118 ++++++- .../GnomeShortcutWriterTests.cs | 62 ++++ .../KdeShortcutWriterTests.cs | 31 ++ .../LocalizationResourcesTests.cs | 23 ++ .../ShortcutsSectionViewModelTests.cs | 329 ++++++++++++++++++ 15 files changed, 1029 insertions(+), 133 deletions(-) diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index b879a2740..b96310cab 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -724,6 +724,9 @@ "Shortcuts.DesktopInstructionsMate": "Open System Settings → Keyboard Shortcuts → Add.\nPaste the command above and assign a key combination.", "Shortcuts.DesktopInstructionsSway": "Edit ~/.config/sway/config and add a bindsym, e.g.:\n bindsym $mod+space exec typewhisper\nReload with `swaymsg reload`.", "Shortcuts.DesktopInstructionsXfce": "Open Settings → Keyboard → Application Shortcuts → Add.\nPaste the command above and choose the key combination when prompted.", + "Shortcuts.DesktopIntegrationStale": "⚠ Desktop dictation integration is out of date", + "Shortcuts.DesktopIntegrationStaleHint": "The desktop integration still has an older hotkey or activation mode. The old desktop shortcut may remain active until you refresh or remove it.", + "Shortcuts.DesktopIntegrationStaleUnsupported": "The desktop integration still has an older hotkey or activation mode. {0} can't refresh it for {1} mode, and the old desktop shortcut may remain active. Switch to a supported mode or remove the old integration.", "Shortcuts.DetectedDesktop": "Detected desktop: {0}", "Shortcuts.Done": "Done.", "Shortcuts.EvdevNoKeyboardAccess": "Can't read any keyboard yet. Enable keyboard access from Settings → Shortcuts (installs a udev rule, no reboot).", @@ -766,6 +769,7 @@ "Shortcuts.RecentTranscriptionsHotkeySet": "Recent transcriptions hotkey set to {0}.", "Shortcuts.RemovalFailed": "Removal failed: {0}", "Shortcuts.RemovingShortcut": "Removing shortcut from {0}…", + "Shortcuts.RefreshDesktopIntegrationOn": "Refresh desktop integration ({0})", "Shortcuts.ScopeFocusedOnly": "Focused only (TypeWhisper window)", "Shortcuts.ScopeGlobal": "Global (works in any focused window)", "Shortcuts.SetupAutomaticallyOn": "Set up automatically ({0})", diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs index 882730bcc..2e769d41a 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs @@ -65,31 +65,8 @@ public string PreviewLines(DeShortcutSpec spec) public async Task IsInstalledAsync(DeShortcutSpec spec, CancellationToken ct) { - if (!DesktopDetector.BinaryExists("gsettings")) - { - return false; - } - var path = BuildCustomPath(spec.ShortcutId); - var (ok, listOut, _) = await RunAsync( - "gsettings", - ["get", MediaKeysSchema, ListKey], - ct - ) - .ConfigureAwait(false); - if (!ok) - { - return false; - } - - try - { - if (!ParseGSettingsList(listOut).Contains(path)) - { - return false; - } - } - catch (FormatException) + if (!await IsManagedPathListedAsync(path, ct).ConfigureAwait(false)) { return false; } @@ -102,6 +79,11 @@ public async Task IsInstalledAsync(DeShortcutSpec spec, CancellationToken return command == spec.OnPressCommand && binding == FormatGnomeAccel(spec.Trigger); } + public Task IsManagedShortcutPresentAsync(string shortcutId, CancellationToken ct) + { + return IsManagedPathListedAsync(BuildCustomPath(shortcutId), ct); + } + public async Task WriteAsync(DeShortcutSpec spec, CancellationToken ct) { var path = BuildCustomPath(spec.ShortcutId); @@ -597,6 +579,34 @@ CancellationToken ct return (ok, raw, error); } + private async Task IsManagedPathListedAsync(string path, CancellationToken ct) + { + if (!DesktopDetector.BinaryExists("gsettings")) + { + return false; + } + + var (ok, listOut, _) = await RunAsync( + "gsettings", + ["get", MediaKeysSchema, ListKey], + ct + ) + .ConfigureAwait(false); + if (!ok) + { + return false; + } + + try + { + return ParseGSettingsList(listOut).Contains(path); + } + catch (FormatException) + { + return false; + } + } + private async Task SnapshotListAsync(string currentValue, CancellationToken ct) { try diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs index 4cd0bf804..e43562800 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs @@ -6,9 +6,9 @@ namespace TypeWhisper.Linux.Services.Hotkey.DeSetup; /// /// Hyprland shortcut writer. On Write, a managed sentinel block with the /// bind/bindr/cancel lines is upserted into -/// ~/.config/hypr/hyprland.conf, then each line is applied live via -/// hyprctl keyword. If hyprctl fails the config write still succeeds -/// — a warning is surfaced instead of an error. +/// ~/.config/hypr/hyprland.conf, then the compositor is reloaded so +/// replaced or removed binds are dropped as well. If hyprctl reload fails, +/// the config write still succeeds and a warning is surfaced instead of an error. /// public sealed class HyprlandShortcutWriter : IDeShortcutWriter { @@ -38,13 +38,13 @@ Func> conditionalWrite public string DisplayName => "Hyprland"; public bool SupportsPushToTalk => true; - // hyprctl applies the bind live (a warning is surfaced if it couldn't). + // hyprctl reload applies the committed config live (a warning is surfaced if it couldn't). public bool RequiresSessionRestartToApply => false; public bool IsCurrentDesktop() { // HYPRLAND_INSTANCE_SIGNATURE is only set inside a live session; - // hyprctl must also be present for the runtime-bind step. + // hyprctl must also be present for the live reload step. return DesktopDetector.DetectId() == "hyprland" && DesktopDetector.BinaryExists("hyprctl"); } @@ -62,30 +62,19 @@ public string PreviewLines(DeShortcutSpec spec) public async Task IsInstalledAsync(DeShortcutSpec spec, CancellationToken ct) { - var path = ResolveConfigPath(); - if (!File.Exists(path)) - { - return false; - } - - try - { - var existing = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); - var inner = SentinelBlock.ExtractBlockLines(existing); - if (inner is null) - { - return false; - } + var inner = await ReadManagedBlockLinesAsync(ct).ConfigureAwait(false); + // Stale or manually edited blocks read as not-installed so the + // checklist re-registers them. + var expected = BuildManagedLines(spec).Select(l => l.TrimEnd()).ToList(); + return inner is not null && inner.SequenceEqual(expected); + } - // Stale or manually edited blocks read as not-installed so the - // checklist re-registers them. - var expected = BuildManagedLines(spec).Select(l => l.TrimEnd()).ToList(); - return inner.SequenceEqual(expected); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - return false; - } + public async Task IsManagedShortcutPresentAsync( + string shortcutId, + CancellationToken ct + ) + { + return await ReadManagedBlockLinesAsync(ct).ConfigureAwait(false) is not null; } public async Task WriteAsync(DeShortcutSpec spec, CancellationToken ct) @@ -156,14 +145,14 @@ await _conditionalWriteAsync(snapshot, updated, ct).ConfigureAwait(false) ); } - // Apply live via hyprctl one line at a time to isolate failures. - // Non-fatal — the persistent config is already written. - var liveOk = await ApplyLiveAsync(spec, ct).ConfigureAwait(false); + // A full reload is required to drop any old trigger/release/cancel binds that + // were replaced in the persistent block. Non-fatal: the config is committed. + var liveOk = await ReloadAsync(ct).ConfigureAwait(false); const string message = "Hyprland shortcut installed in ~/.config/hypr/hyprland.conf"; var warning = liveOk ? null - : "Config written, but `hyprctl` could not apply the bind live. Run `hyprctl reload` (or restart Hyprland) to pick it up."; + : "Config written, but `hyprctl reload` failed. Reload or restart Hyprland to pick up the binding."; return new DeShortcutWriteResult(true, message, [path], warning); } @@ -214,13 +203,15 @@ public async Task RemoveAsync(string shortcutId, Cancella continue; } - // Hyprland's unbind syntax varies across versions; asking the user - // to reload is more robust than attempting a live removal. + var reloaded = await ReloadAsync(ct).ConfigureAwait(false); + var warning = reloaded + ? null + : RemovalRequiresReloadWarning; return new DeShortcutWriteResult( true, "Hyprland managed block removed.", [path], - RemovalRequiresReloadWarning + warning ); } catch (OperationCanceledException) @@ -305,35 +296,42 @@ private static IEnumerable BuildManagedLines(DeShortcutSpec spec) yield return $"bind = {cmods}, {ckey}, exec, {spec.OnCancelCommand}"; } - private static async Task ApplyLiveAsync(DeShortcutSpec spec, CancellationToken ct) + private static async Task?> ReadManagedBlockLinesAsync( + CancellationToken ct + ) { - if (!DesktopDetector.BinaryExists("hyprctl")) + var path = ResolveConfigPath(); + if (!File.Exists(path)) { - return false; + return null; } - var anyFailed = false; - foreach (var line in BuildManagedLines(spec)) + try { - // hyprctl keyword wants keyword and value as separate args. - var trimmed = line.TrimStart(); - var eq = trimmed.IndexOf('='); - if (eq < 0) + var existing = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); + var scan = SentinelBlock.Scan(existing); + if (scan.Mismatched || scan.OpenLine is null) { - continue; + return null; } - var keyword = trimmed[..eq].Trim(); - var value = trimmed[(eq + 1)..].Trim(); - var (ok, _, _) = await RunAsync("hyprctl", ["keyword", keyword, value], ct) - .ConfigureAwait(false); - if (!ok) - { - anyFailed = true; - } + return SentinelBlock.ExtractBlockLines(existing); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return null; + } + } + + private static async Task ReloadAsync(CancellationToken ct) + { + if (!DesktopDetector.BinaryExists("hyprctl")) + { + return false; } - return !anyFailed; + var (ok, _, _) = await RunAsync("hyprctl", ["reload"], ct).ConfigureAwait(false); + return ok; } private static string ResolveConfigPath() diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/IDeShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/IDeShortcutWriter.cs index 8ae051602..091a4e78c 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/IDeShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/IDeShortcutWriter.cs @@ -55,6 +55,14 @@ public interface IDeShortcutWriter // ReSharper disable once UnusedMember.Global interface contract member, part of the IDeShortcutWriter surface Task IsInstalledAsync(DeShortcutSpec spec, CancellationToken ct); + /// + /// True when this writer can identify a TypeWhisper-managed shortcut for the stable + /// , regardless of its current trigger or commands. Unlike + /// , this detects stale managed entries. Never mutates; + /// normal absence, malformed ownership markers, and read errors return false. + /// + Task IsManagedShortcutPresentAsync(string shortcutId, CancellationToken ct); + /// /// Install the shortcut. Idempotent: running twice with the same /// spec must produce the same final state, not duplicate entries. @@ -107,4 +115,4 @@ public sealed record DeShortcutWriteResult( // ReSharper disable once NotAccessedPositionalProperty.Global carried in the result record's data shape (files changed, surfaced to callers/diagnostics) IReadOnlyList FilesChanged, string? Warning = null -); \ No newline at end of file +); diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/KdeShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/KdeShortcutWriter.cs index 088af085f..ed7aac4f4 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/KdeShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/KdeShortcutWriter.cs @@ -52,6 +52,15 @@ public Task IsInstalledAsync(DeShortcutSpec spec, CancellationToken ct) } } + public Task IsManagedShortcutPresentAsync(string shortcutId, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var (_, target) = ResolveTargetPath(shortcutId); + return Task.FromResult( + File.Exists(target) && IsOwnedByTypeWhisper(target, shortcutId) + ); + } + public async Task WriteAsync(DeShortcutSpec spec, CancellationToken ct) { var (dir, target) = ResolveTargetPath(spec.ShortcutId); diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs index 54c7eb00e..a86e221ce 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs @@ -56,29 +56,18 @@ public string PreviewLines(DeShortcutSpec spec) public async Task IsInstalledAsync(DeShortcutSpec spec, CancellationToken ct) { - var path = ResolveConfigPath(); - if (!File.Exists(path)) - { - return false; - } - - try - { - var existing = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); - var inner = SentinelBlock.ExtractBlockLines(existing); - if (inner is null) - { - return false; - } + var inner = await ReadManagedBlockLinesAsync(ct).ConfigureAwait(false); + // Must match exactly — a stale trigger or manual edit reads as not-installed. + var expected = BuildManagedLines(spec).Select(l => l.TrimEnd()).ToList(); + return inner is not null && inner.SequenceEqual(expected); + } - // Must match exactly — a stale trigger or manual edit reads as not-installed. - var expected = BuildManagedLines(spec).Select(l => l.TrimEnd()).ToList(); - return inner.SequenceEqual(expected); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - return false; - } + public async Task IsManagedShortcutPresentAsync( + string shortcutId, + CancellationToken ct + ) + { + return await ReadManagedBlockLinesAsync(ct).ConfigureAwait(false) is not null; } public async Task WriteAsync(DeShortcutSpec spec, CancellationToken ct) @@ -338,6 +327,33 @@ private static bool IsFunctionKey(string k) return true; } + private static async Task?> ReadManagedBlockLinesAsync( + CancellationToken ct + ) + { + var path = ResolveConfigPath(); + if (!File.Exists(path)) + { + return null; + } + + try + { + var existing = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); + var scan = SentinelBlock.Scan(existing); + if (scan.Mismatched || scan.OpenLine is null) + { + return null; + } + + return SentinelBlock.ExtractBlockLines(existing); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return null; + } + } + private static string ResolveConfigPath() { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs index 95b471535..8ba308583 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs @@ -9,6 +9,14 @@ namespace TypeWhisper.Linux.ViewModels.Sections; +internal enum ManagedDesktopIntegrationState +{ + Unknown, + Absent, + Current, + Stale +} + // MVVM Toolkit [ObservableProperty] generates the OnChanged(value) partial hooks; the // value parameter is part of the generated signature and cannot be dropped even when ignored here. // ReSharper disable UnusedParameterInPartialMethod @@ -37,6 +45,15 @@ public partial class ShortcutsSectionViewModel : ObservableObject // probe that finishes late must not clobber the latest result. private int _keyboardAccessRefreshVersion; + // Desktop-integration probes are independent from M5's startup ownership probe: config + // presence can identify a stale managed entry, but does not prove that the desktop route is + // live. The generation prevents an old spec probe from overwriting a later setting change or + // an explicit refresh/removal result. + private int _desktopIntegrationRefreshVersion; + private ManagedDesktopIntegrationState _desktopIntegrationState = + ManagedDesktopIntegrationState.Unknown; + private Task _pendingDesktopIntegrationRefresh = Task.CompletedTask; + // While false, the compositor-bind fallback auto-tracks keyboard access: every // probe re-applies ComputeCompositorBindsRelevant() so the disclosure stays in // sync as access changes (e.g. granted by onboarding). An explicit Show/Hide @@ -214,15 +231,23 @@ private bool ComputeCompositorBindsRelevant() return _hasKeyboardAccess == false; } - // Called by the view each time the Shortcuts section is shown. Re-probes so the + // Invoked via RefreshSectionState each time the Shortcuts section is shown. Re-probes so the // banner/fallback reflect access granted since construction — e.g. by first-run // onboarding, which grants access via HotkeyService outside this VM. Fire-and-forget: // the probe updates the bound properties on completion. - public void RefreshKeyboardAccess() + private void RefreshKeyboardAccess() { _ = RefreshKeyboardAccessAsync(); } + // Both checks are read-only; desktop settings change only via the explicit + // setup/remove commands below. + public void RefreshSectionState() + { + RefreshKeyboardAccess(); + _ = ScheduleDesktopIntegrationRefresh(); + } + // Probe keyboard access off the UI thread, then refresh the access-dependent // properties. InputDeviceAccessCheck.HasKeyboardAccess() opens every /dev/input // keyboard node (~0.5s) — running it during the constructor or a binding getter @@ -356,10 +381,61 @@ private IDeShortcutWriter? ActiveWriter public bool CanSetupAutomatically => ActiveWriter is not null; + public bool CanWriteDesktopIntegration + { + get + { + var writer = ActiveWriter; + return writer is not null && BuildSpec(writer) is not null; + } + } + + public bool ShowStaleIntegrationBanner => + _desktopIntegrationState == ManagedDesktopIntegrationState.Stale; + + public bool CanRefreshDesktopIntegration => + ShowStaleIntegrationBanner && CanWriteDesktopIntegration; + + public bool CanRemoveDesktopIntegration => + _desktopIntegrationState is ManagedDesktopIntegrationState.Current + or ManagedDesktopIntegrationState.Stale; + + public string StaleIntegrationMessage + { + get + { + var writer = ActiveWriter; + if (!ShowStaleIntegrationBanner || writer is null) + { + return string.Empty; + } + + return BuildSpec(writer) is null + ? Loc.Instance.GetString( + "Shortcuts.DesktopIntegrationStaleUnsupported", + writer.DisplayName, + GetModeDisplayName() + ) + : Loc.Instance["Shortcuts.DesktopIntegrationStaleHint"]; + } + } + + // ReSharper disable once ConvertToAutoPropertyWithPrivateSetter -- backing field is mutated by the deliberate versioned-probe race guard / invalidate-around-mutation pattern; keep it a field. + internal ManagedDesktopIntegrationState DesktopIntegrationState => + _desktopIntegrationState; + + // ReSharper disable once ConvertToAutoPropertyWithPrivateSetter -- backing field is reassigned by ScheduleDesktopIntegrationRefresh under the deliberate race-guard pattern; keep it a field. + internal Task PendingDesktopIntegrationRefresh => _pendingDesktopIntegrationRefresh; + public string SetupAutomaticallyLabel => ActiveWriter is null ? Loc.Instance["TextInsertion.SetUpAutomatically"] - : Loc.Instance.GetString("Shortcuts.SetupAutomaticallyOn", ActiveWriter.DisplayName); + : Loc.Instance.GetString( + ShowStaleIntegrationBanner + ? "Shortcuts.RefreshDesktopIntegrationOn" + : "Shortcuts.SetupAutomaticallyOn", + ActiveWriter.DisplayName + ); public string IntegrationPreview { @@ -383,6 +459,118 @@ public string IntegrationPreview return DictationShortcutSpecFactory.Build(_settings, writer); } + internal async Task RefreshDesktopIntegrationStateAsync(CancellationToken ct) + { + var version = Interlocked.Increment(ref _desktopIntegrationRefreshVersion); + try + { + // Capture both before the first await. Later setting changes start a newer version, + // so this result cannot describe a different hotkey/mode by accident. + var writer = ActiveWriter; + var spec = writer is null ? null : BuildSpec(writer); + if (writer is null) + { + SetDesktopIntegrationStateIfCurrent( + version, + ManagedDesktopIntegrationState.Absent + ); + return; + } + + if (spec is not null) + { + var exact = await writer.IsInstalledAsync(spec, ct).ConfigureAwait(true); + if (exact) + { + SetDesktopIntegrationStateIfCurrent( + version, + ManagedDesktopIntegrationState.Current + ); + return; + } + } + + var present = await writer + .IsManagedShortcutPresentAsync(DictationShortcutId, ct) + .ConfigureAwait(true); + SetDesktopIntegrationStateIfCurrent( + version, + present + ? ManagedDesktopIntegrationState.Stale + : ManagedDesktopIntegrationState.Absent + ); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // An indeterminate probe must not erase a known stale/current state. + System.Diagnostics.Trace.WriteLine( + $"[Shortcuts] Desktop integration status probe failed: {ex.Message}" + ); + } + } + + private Task ScheduleDesktopIntegrationRefresh() + { + var task = RefreshDesktopIntegrationStateAsync(CancellationToken.None); + _pendingDesktopIntegrationRefresh = task; + return task; + } + + private void SetDesktopIntegrationStateIfCurrent( + int version, + ManagedDesktopIntegrationState state + ) + { + if (version != Volatile.Read(ref _desktopIntegrationRefreshVersion)) + { + return; + } + + SetDesktopIntegrationState(state); + } + + private void SetDesktopIntegrationState(ManagedDesktopIntegrationState state) + { + if (_desktopIntegrationState == state) + { + return; + } + + _desktopIntegrationState = state; + OnPropertyChanged(nameof(DesktopIntegrationState)); + OnPropertyChanged(nameof(ShowStaleIntegrationBanner)); + OnPropertyChanged(nameof(CanRefreshDesktopIntegration)); + OnPropertyChanged(nameof(CanRemoveDesktopIntegration)); + OnPropertyChanged(nameof(StaleIntegrationMessage)); + OnPropertyChanged(nameof(SetupAutomaticallyLabel)); + } + + private void CompleteDesktopIntegrationMutation( + IDeShortcutWriter writer, + DeShortcutSpec writtenSpec + ) + { + // Invalidate every probe that could have observed the pre-commit state. + Interlocked.Increment(ref _desktopIntegrationRefreshVersion); + var currentSpec = BuildSpec(writer); + if (currentSpec == writtenSpec) + { + SetDesktopIntegrationState(ManagedDesktopIntegrationState.Current); + return; + } + + _ = ScheduleDesktopIntegrationRefresh(); + } + + private void InvalidateDesktopIntegrationProbes() + { + Interlocked.Increment(ref _desktopIntegrationRefreshVersion); + } + internal async Task RefreshNativeDictationBindingStateAsync(CancellationToken ct) { try @@ -414,18 +602,22 @@ internal async Task RefreshNativeDictationBindingStateAsync(CancellationToken ct private string GetUnsupportedModeMessage(IDeShortcutWriter writer) { - var modeDisplayName = _settings.Current.Mode switch + return Loc.Instance.GetString( + "Shortcuts.AutoSetupModeUnsupported", + writer.DisplayName, + GetModeDisplayName() + ); + } + + private string GetModeDisplayName() + { + return _settings.Current.Mode switch { RecordingMode.Toggle => Loc.Instance["Common.ModeToggle"], RecordingMode.PushToTalk => Loc.Instance["Common.ModePushToTalk"], RecordingMode.Hybrid => Loc.Instance["Common.ModeHybrid"], _ => "" }; - return Loc.Instance.GetString( - "Shortcuts.AutoSetupModeUnsupported", - writer.DisplayName, - modeDisplayName - ); } // VMs don't have direct clipboard access in Avalonia — the view @@ -532,6 +724,7 @@ private async Task SetupAutomaticallyAsync() IntegrationStatusMessage = Loc.Instance.GetString("Shortcuts.InstallingShortcut", writer.DisplayName); + InvalidateDesktopIntegrationProbes(); try { var result = await writer @@ -556,10 +749,19 @@ private async Task SetupAutomaticallyAsync() $"{IntegrationStatusMessage} " + Loc.Instance["Shortcuts.NativeDictationInstallDeferred"]; } + + CompleteDesktopIntegrationMutation(writer, spec); + } + else + { + // A write can partially mutate config before failing (e.g. GNOME's managed + // path added, then gsettings set fails); re-probe so it surfaces as stale, not silent. + _ = ScheduleDesktopIntegrationRefresh(); } } catch (Exception ex) { + _ = ScheduleDesktopIntegrationRefresh(); IntegrationStatusMessage = Loc.Instance.GetString("Shortcuts.SetupFailed", ex.Message); } } @@ -576,6 +778,7 @@ private async Task RemoveIntegrationAsync() IntegrationStatusMessage = Loc.Instance.GetString("Shortcuts.RemovingShortcut", writer.DisplayName); + InvalidateDesktopIntegrationProbes(); try { var result = await writer @@ -600,10 +803,20 @@ private async Task RemoveIntegrationAsync() $"{IntegrationStatusMessage} " + Loc.Instance["Shortcuts.NativeDictationRemovalDeferred"]; } + + InvalidateDesktopIntegrationProbes(); + SetDesktopIntegrationState(ManagedDesktopIntegrationState.Absent); + } + else + { + // A failed removal may leave the managed block partially in place; + // re-probe rather than trust the pre-removal state. + _ = ScheduleDesktopIntegrationRefresh(); } } catch (Exception ex) { + _ = ScheduleDesktopIntegrationRefresh(); IntegrationStatusMessage = Loc.Instance.GetString("Shortcuts.RemovalFailed", ex.Message); } } @@ -678,13 +891,15 @@ private void CopyPushToTalkPair() } [RelayCommand] - private void ApplyHotkey() + private async Task ApplyHotkeyAsync() { if (_hotkey.TrySetHotkeyFromString(HotkeyText)) { _settings.Save(_settings.Current with { ToggleHotkey = _hotkey.CurrentHotkeyString }); StatusMessage = Loc.Instance.GetString("Shortcuts.HotkeySet", _hotkey.CurrentHotkeyString); HotkeyText = _hotkey.CurrentHotkeyString; + OnPropertyChanged(nameof(IntegrationPreview)); + await ScheduleDesktopIntegrationRefresh(); } else { @@ -733,6 +948,10 @@ partial void OnModeChanged(RecordingMode value) }; OnPropertyChanged(nameof(ShowCapabilityMismatch)); OnPropertyChanged(nameof(IntegrationPreview)); + OnPropertyChanged(nameof(CanWriteDesktopIntegration)); + OnPropertyChanged(nameof(CanRefreshDesktopIntegration)); + OnPropertyChanged(nameof(StaleIntegrationMessage)); + _ = ScheduleDesktopIntegrationRefresh(); } partial void OnWaylandEvdevHotkeysEnabledChanged(bool value) diff --git a/src/TypeWhisper.Linux/Views/Sections/ShortcutsSection.axaml b/src/TypeWhisper.Linux/Views/Sections/ShortcutsSection.axaml index 4f6cd5eef..d598472fb 100644 --- a/src/TypeWhisper.Linux/Views/Sections/ShortcutsSection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/ShortcutsSection.axaml @@ -81,6 +81,34 @@ + + + + + + + + public sealed partial class AudioDuckingService : IAudioDuckingService { + private const double MaximumRawVolume = 98_304d; + private static readonly TimeSpan s_pactlTimeout = TimeSpan.FromMilliseconds(1500); + private static readonly IReadOnlyDictionary s_pactlEnvironment = + new Dictionary(StringComparer.Ordinal) { ["LC_ALL"] = "C" }; + // "Sink Input #593" — block header in `pactl list sink-inputs` output. [GeneratedRegex(@"^Sink Input #(\d+)")] private static partial Regex SinkInputIdRegex(); - // First percentage on a "Volume:" line (e.g. "... / 65% / -9.30 dB"). - [GeneratedRegex(@"(\d+)%")] - private static partial Regex VolumePercentRegex(); + // Raw pa_volume_t followed by its percentage representation. + [GeneratedRegex(@"(? _savedVolumes = new(StringComparer.Ordinal); + private readonly IProcessRunner _processRunner; + private readonly Dictionary _savedVolumes = new(StringComparer.Ordinal); private bool _isDucked; + public AudioDuckingService(IProcessRunner processRunner) + { + _processRunner = processRunner; + } + public void DuckAudio(float factor) { if (_isDucked) @@ -35,17 +46,27 @@ public void DuckAudio(float factor) { // pactl has no "get-sink-input-volume" subcommand, so read current // volumes by parsing the long `list sink-inputs` output instead. - var listing = CommandRunner.Run("pactl", "list", "sink-inputs"); - if (string.IsNullOrWhiteSpace(listing)) + var listingResult = RunPactl(["list", "sink-inputs"]); + if ( + !listingResult.Succeeded + || string.IsNullOrWhiteSpace(listingResult.StandardOutput) + ) { return; } - foreach (var (inputId, currentVolume) in ParseSinkInputVolumes(listing)) + foreach ( + var (inputId, currentVolumes) in ParseSinkInputVolumes( + listingResult.StandardOutput + ) + ) { - _savedVolumes[inputId] = currentVolume; - var duckedVolume = ScaleVolume(currentVolume, factor); - CommandRunner.Run("pactl", "set-sink-input-volume", inputId, duckedVolume); + var savedVolumes = currentVolumes.ToArray(); + _savedVolumes[inputId] = savedVolumes; + var duckedVolumes = savedVolumes + .Select(volume => ScaleVolume(volume, factor)) + .ToArray(); + SetSinkInputVolume(inputId, duckedVolumes); } _isDucked = _savedVolumes.Count > 0; @@ -67,9 +88,9 @@ public void RestoreAudio() try { - foreach (var (inputId, volume) in _savedVolumes) + foreach (var (inputId, volumes) in _savedVolumes) { - CommandRunner.Run("pactl", "set-sink-input-volume", inputId, volume); + SetSinkInputVolume(inputId, volumes); } } catch (Exception ex) @@ -84,10 +105,12 @@ public void RestoreAudio() } /// - /// Walks the pactl list sink-inputs output, yielding the first - /// volume percentage of each "Sink Input #N" block. + /// Walks the pactl list sink-inputs output, yielding every raw + /// channel volume from the first "Volume:" line of each "Sink Input #N" block. /// - private static IEnumerable<(string Id, string Volume)> ParseSinkInputVolumes(string listing) + private static IEnumerable<(string Id, string[] Volumes)> ParseSinkInputVolumes( + string listing + ) { string? currentId = null; @@ -105,10 +128,13 @@ public void RestoreAudio() continue; } - var volMatch = VolumePercentRegex().Match(line); - if (volMatch.Success) + var volumes = RawVolumeRegex() + .Matches(line) + .Select(match => match.Groups[1].Value) + .ToArray(); + if (volumes.Length > 0) { - yield return (currentId, volMatch.Groups[1].Value + "%"); + yield return (currentId, volumes); } // Only the first Volume line per block is relevant. @@ -116,22 +142,46 @@ public void RestoreAudio() } } - private static string ScaleVolume(string volumePercent, float factor) + private void SetSinkInputVolume(string inputId, string[] volumes) + { + var arguments = new List(2 + volumes.Length) + { + "set-sink-input-volume", + inputId + }; + arguments.AddRange(volumes); + RunPactl(arguments); + } + + private ProcessRunResult RunPactl(IReadOnlyList arguments) + { + return _processRunner + .RunAsync( + "pactl", + arguments, + environment: s_pactlEnvironment, + timeout: s_pactlTimeout + ) + .GetAwaiter() + .GetResult(); + } + + private static string ScaleVolume(string rawVolume, float factor) { - var numericPart = volumePercent.Trim().TrimEnd('%'); if ( - !float.TryParse( - numericPart, - NumberStyles.Float, + !ulong.TryParse( + rawVolume, + NumberStyles.None, CultureInfo.InvariantCulture, - out var percent + out var numericVolume ) ) { - return volumePercent; + return rawVolume; } - var scaled = Math.Clamp(percent * factor, 0f, 150f); - return $"{scaled.ToString("0.##", CultureInfo.InvariantCulture)}%"; + var scaled = Math.Clamp(numericVolume * (double)factor, 0d, MaximumRawVolume); + var rounded = Math.Round(scaled, MidpointRounding.AwayFromZero); + return rounded.ToString("0", CultureInfo.InvariantCulture); } } diff --git a/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs new file mode 100644 index 000000000..5e2730078 --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs @@ -0,0 +1,64 @@ +using TypeWhisper.Linux.Services; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class AudioDuckingServiceTests +{ + [Fact] + public void DuckAndRestore_preserve_channel_vectors_for_each_sink_input() + { + const string listing = """ + Sink Input #593 + Volume: front-left: 45875 / 70% / -9.30 dB, front-right: 26214 / 40% / -23.88 dB + Base Volume: 65536 / 100% / 0.00 dB + Sink Input #42 + Volume: mono: 32769 / 50% / -18.06 dB + """; + var runner = new FakeProcessRunner(); + runner.RespondWith( + (fileName, args) => + fileName == "pactl" && args.SequenceEqual(["list", "sink-inputs"]), + listing + ); + var service = new AudioDuckingService(runner); + + service.DuckAudio(0.5f); + service.RestoreAudio(); + + Assert.Equal(5, runner.Invocations.Count); + Assert.All(runner.Invocations, invocation => Assert.Equal("pactl", invocation.FileName)); + Assert.All( + runner.Invocations, + invocation => Assert.Equal(TimeSpan.FromMilliseconds(1500), invocation.Timeout) + ); + Assert.Equal(["list", "sink-inputs"], runner.Invocations[0].Args); + Assert.Equal( + ["set-sink-input-volume", "593", "22938", "13107"], + runner.Invocations[1].Args + ); + Assert.Equal( + ["set-sink-input-volume", "42", "16385"], + runner.Invocations[2].Args + ); + Assert.Equal( + ["set-sink-input-volume", "593", "45875", "26214"], + runner.Invocations[3].Args + ); + Assert.Equal( + ["set-sink-input-volume", "42", "32769"], + runner.Invocations[4].Args + ); + + var stereoInvocations = runner.Invocations + .Where(invocation => invocation.Args.Count > 1 && invocation.Args[1] == "593") + .ToArray(); + Assert.Equal(2, stereoInvocations.Length); + Assert.DoesNotContain( + stereoInvocations, + invocation => + invocation.Args.SequenceEqual(["set-sink-input-volume", "593", "70%"]) || + invocation.Args.SequenceEqual(["set-sink-input-volume", "593", "45875"]) + ); + } +} From 4382c56ac103a921191e69fe76e6d5746df1f6a3 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 13:56:01 +0000 Subject: [PATCH 096/226] =?UTF-8?q?Finish=20start=20feedback=20before=20op?= =?UTF-8?q?ening=20dictation=20capture=20(audit=20=C2=A75=20M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/DictationOrchestrator.cs | 97 +++++- .../Services/SoundFeedbackService.cs | 113 +++--- .../Services/SpeechFeedbackService.cs | 275 ++++++++++----- ...DictationOrchestratorStartFeedbackTests.cs | 221 ++++++++++++ .../SoundFeedbackServiceTests.cs | 154 +++++++++ .../SpeechFeedbackServiceTests.cs | 327 +++++++++++++++++- 6 files changed, 1018 insertions(+), 169 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/DictationOrchestratorStartFeedbackTests.cs create mode 100644 tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs diff --git a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs index c876d1d15..a7f8a6c47 100644 --- a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs +++ b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs @@ -498,6 +498,50 @@ public async Task CancelAsync() } } + /// + /// Orders optional startup feedback ahead of capture. Feedback is best + /// effort; the permission check and capture result remain exact. + /// + internal static async Task StartCaptureAfterFeedbackAsync( + bool soundFeedbackEnabled, + Func stopPriorSpeechAsync, + Func playStartSoundAsync, + Func announceRecordingStartedAsync, + Func isInputAllowed, + Func startCapture + ) + where TCapture : class + { + try + { + await stopPriorSpeechAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Trace.WriteLine($"[Dictation] Could not finish prior speech before capture: {ex.Message}"); + } + + try + { + if (soundFeedbackEnabled) + { + await playStartSoundAsync().ConfigureAwait(false); + } + else + { + await announceRecordingStartedAsync().ConfigureAwait(false); + } + } + catch (Exception ex) + { + Trace.WriteLine($"[Dictation] Optional recording-start feedback failed: {ex.Message}"); + } + + // The cue adds bounded work while the startup gate is held. Revalidate + // immediately before opening capture so a lock during feedback wins. + return isInputAllowed() ? startCapture() : null; + } + public async Task StartAsync(string? forcedProfileId = null) { if (!_toggleGate.TryBeginStartup(() => _cancelRequested = false)) @@ -525,15 +569,30 @@ public async Task StartAsync(string? forcedProfileId = null) goto StartupComplete; } - // Start capturing immediately — user may already be speaking (especially PTT). - _recordingStart = DateTime.UtcNow; - _lastSpeechDetectedAtUtc = _recordingStart; - _silenceStopRequested = false; + // One immutable view controls cue arbitration and the initial capture + // mode even if settings change while the bounded cue is playing. + var startupSettings = _settings.Current; + var inputRejectedAfterCue = false; AudioRecordingService.AudioCaptureSession? captureSession; try { - captureSession = _audio.TryStartRecording( - _settings.Current.WhisperModeEnabled + captureSession = await StartCaptureAfterFeedbackAsync( + startupSettings.SoundFeedbackEnabled, + _speechFeedback.StopCurrentPlaybackBeforeCaptureAsync, + () => _soundFeedback.PlayRecordingStartedAsync(), + () => _speechFeedback.AnnounceRecordingStartedAsync( + startupSettings.SpokenFeedbackEnabled + ), + () => + { + // Disposal can restore system audio and stop capture while the + // bounded cue is still pending; never open a new capture session + // once shutdown has begun. + var allowed = !_disposed && _sessionActivityMonitor.IsInputAllowed; + inputRejectedAfterCue = !allowed; + return allowed; + }, + () => _audio.TryStartRecording(startupSettings.WhisperModeEnabled) ); } catch (Exception ex) @@ -547,6 +606,14 @@ public async Task StartAsync(string? forcedProfileId = null) if (captureSession is null) { + if (inputRejectedAfterCue) + { + Trace.WriteLine( + "[Dictation] Start rejected: session locked or inactive during feedback." + ); + goto StartupComplete; + } + var message = BuildRecordingStartFailureMessage(null); ReportStatus(message); ShowFeedback(message, true); @@ -554,9 +621,12 @@ public async Task StartAsync(string? forcedProfileId = null) } _audioCaptureSession = captureSession; + _recordingStart = DateTime.UtcNow; + _lastSpeechDetectedAtUtc = _recordingStart; + _silenceStopRequested = false; // Set overlay to "Recording…" after the stream is confirmed open but - // before slow startup work (playerctl, sound). On Wayland the earlier + // before slow startup work (playerctl). On Wayland the earlier // ordering made the stale feedback bubble linger until after PauseMedia. SetOverlayState(state => // ReSharper disable once WithExpressionModifiesAllMembers -- `with` preserves any future-added state members; intentional even though all current members are set. @@ -578,29 +648,22 @@ state with try { - if (_settings.Current.AudioDuckingEnabled) + if (startupSettings.AudioDuckingEnabled) { - _audioDucking.DuckAudio(_settings.Current.AudioDuckingLevel); + _audioDucking.DuckAudio(startupSettings.AudioDuckingLevel); } - if (_settings.Current.PauseMediaDuringRecording) + if (startupSettings.PauseMediaDuringRecording) { _mediaPause.PauseMedia(); } - if (_settings.Current.SoundFeedbackEnabled) - { - _soundFeedback.PlayRecordingStarted(); - } - - _speechFeedback.AnnounceRecordingStarted(); RecordingStateChanged?.Invoke(this, true); // Bump the session version once per recording; both the polling loop // and the streaming coordinator share this version. Bumping twice // would immediately invalidate the streaming session. var sessionVersion = _partialTranscriptState.StartSession(); - var startupSettings = _settings.Current; // A profile hotkey forces a specific profile; resolve it // synchronously here so streaming/language decisions don't use the // stale _recordingProfile from the previous session. The background diff --git a/src/TypeWhisper.Linux/Services/SoundFeedbackService.cs b/src/TypeWhisper.Linux/Services/SoundFeedbackService.cs index 388c92d6e..2232971cc 100644 --- a/src/TypeWhisper.Linux/Services/SoundFeedbackService.cs +++ b/src/TypeWhisper.Linux/Services/SoundFeedbackService.cs @@ -7,56 +7,68 @@ namespace TypeWhisper.Linux.Services; /// pw-play, paplay, or aplay. Shells out instead of /// using libcanberra so cues play regardless of the desktop sound theme and /// GNOME's "System Sounds" toggle (libcanberra respected that toggle). -/// Fire-and-forget; silently no-ops when no player or file is available. /// public sealed class SoundFeedbackService { + internal static readonly TimeSpan s_startCueTimeout = TimeSpan.FromSeconds(2); + private static readonly string s_soundsDir = Path.Join(AppContext.BaseDirectory, "Resources", "Sounds"); - // First available player on PATH: pw-play (PipeWire), paplay (PulseAudio), aplay (ALSA). - private static readonly string? s_player = ResolvePlayer(); + private readonly string? _player; + private readonly IProcessRunner _processRunner; + private readonly string _soundsDir; + + // ReSharper disable once UnusedMember.Global -- resolved by DI (AddSingleton). + public SoundFeedbackService(IProcessRunner processRunner) + : this(processRunner, ResolvePlayer(), s_soundsDir) + { + } + + internal SoundFeedbackService( + IProcessRunner processRunner, + string? player, + string soundsDir + ) + { + _processRunner = processRunner; + _player = player; + _soundsDir = soundsDir; + } - // kept instance: injected as a DI/test seam by callers - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] - // ReSharper disable once MemberCanBeMadeStatic.Global - public void PlayRecordingStarted() + /// + /// Plays the startup cue to completion before capture opens. The process + /// runner kills and reaps a player that exceeds the finite cue budget. + /// Missing players/files and playback failures remain optional no-ops. + /// + internal Task PlayRecordingStartedAsync(CancellationToken ct = default) { - Play("start.wav"); + return PlayAsync("start.wav", s_startCueTimeout, ct); } - // kept instance: injected as a DI/test seam by callers - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] - // ReSharper disable once MemberCanBeMadeStatic.Global public void PlayRecordingStopped() { - Play("stop.wav"); + Observe(PlayAsync("stop.wav", s_startCueTimeout)); } - // kept instance: injected as a DI/test seam by callers - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] - // ReSharper disable once MemberCanBeMadeStatic.Global public void PlaySuccess() { - Play("success.wav"); + Observe(PlayAsync("success.wav", s_startCueTimeout)); } - // kept instance: injected as a DI/test seam by callers - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] - // ReSharper disable once MemberCanBeMadeStatic.Global public void PlayError() { - Play("error.wav"); + Observe(PlayAsync("error.wav", s_startCueTimeout)); } - private static void Play(string fileName) + private async Task PlayAsync(string fileName, TimeSpan timeout, CancellationToken ct = default) { - if (s_player is null) + if (_player is null) { return; } - var path = Path.Join(s_soundsDir, fileName); + var path = Path.Join(_soundsDir, fileName); if (!File.Exists(path)) { return; @@ -64,51 +76,34 @@ private static void Play(string fileName) try { - var startInfo = new ProcessStartInfo(s_player) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - startInfo.ArgumentList.Add(path); - - var process = Process.Start(startInfo); - if (process is null) - { - return; - } - - _ = Task.Run(() => - { - try - { - // Cues are short (≤0.4s); 2s is ample headroom. - process.WaitForExit(2000); - } - catch - { - // Best-effort only. - } - finally - { - process.Dispose(); - } - }); + _ = await _processRunner + .RunAsync(_player, [path], timeout: timeout, ct: ct) + .ConfigureAwait(false); } - catch + catch (Exception ex) { - // Optional platform feedback only. + // Optional platform feedback only. IProcessRunner has already killed + // and reaped the process tree before cancellation is surfaced. + Trace.WriteLine($"[SoundFeedback] {fileName} playback failed: {ex.Message}"); } } + private static void Observe(Task task) + { + _ = task.ContinueWith( + completed => Trace.WriteLine($"[SoundFeedback] Playback task failed: {completed.Exception}"), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + private static string? ResolvePlayer() { - // Same candidate order as SystemCommandAvailabilityService.HasAudioPlayer - // so s_player is non-null exactly when HasAudioPlayer is true. + // Same candidate order as SystemCommandAvailabilityService.HasAudioPlayer. return Array.Find( ["pw-play", "paplay", "aplay"], SystemCommandAvailabilityService.IsCommandAvailable ); } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs b/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs index 6bd813045..c70a2190e 100644 --- a/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs +++ b/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs @@ -15,6 +15,54 @@ public sealed record TtsVoiceOption(string Id, string DisplayName, string? Local public sealed class SpeechFeedbackService : IDisposable { public const string DefaultVoiceOptionId = "__typewhisper_default_voice__"; + internal static readonly TimeSpan s_recordingAnnouncementTimeout = TimeSpan.FromSeconds(2); + internal static readonly TimeSpan s_stopPlaybackTimeout = TimeSpan.FromMilliseconds(500); + + private sealed class PlaybackRequest(long version) + { + private int _completed; + + public CancellationTokenSource Cancellation { get; } = new(); + public TaskCompletionSource Completion { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + public ITtsPlaybackSession? Session; + public long Version { get; } = version; + + public void CancelAndStop() + { + try + { + Cancellation.Cancel(); + } + catch (ObjectDisposedException) + { + // Completion won the race and already released the source. + } + + try + { + Volatile.Read(ref Session)?.Stop(); + } + catch + { + // Best-effort stop of the speech session. + } + } + + public void Complete() + { + if (Interlocked.Exchange(ref _completed, 1) != 0) + { + return; + } + + Completion.TrySetResult(); + Cancellation.Dispose(); + } + } + + private readonly Func _delay; private readonly Lock _lock = new(); private readonly PluginManager _pluginManager; @@ -22,11 +70,10 @@ public sealed class SpeechFeedbackService : IDisposable private readonly ITtsProviderPlugin _systemProvider; private bool _disposed; private bool _isPlaybackPending; + private PlaybackRequest? _playbackRequest; private ITtsPlaybackSession? _playbackSession; private long _playbackVersion; - private CancellationTokenSource? _speakCts; - // ReSharper disable once UnusedMember.Global -- resolved by DI (AddSingleton); the analyzer cannot see the reflection-driven construction. public SpeechFeedbackService( ISettingsService settings, @@ -45,12 +92,14 @@ IProcessRunner processRunner internal SpeechFeedbackService( ISettingsService settings, PluginManager pluginManager, - ITtsProviderPlugin systemProvider + ITtsProviderPlugin systemProvider, + Func? delay = null ) { _settings = settings; _pluginManager = pluginManager; _systemProvider = systemProvider; + _delay = delay ?? Task.Delay; _pluginManager.PluginStateChanged += OnPluginStateChanged; } @@ -174,57 +223,83 @@ public void AnnounceRecordingStarted() Speak(Loc.Instance["Speech.Recording"]); } - public void AnnounceTranscriptionComplete( - string text, - string? language = null, - bool useConfiguredLanguageFallback = true - ) - { - SpeakAutomaticTranscription(text, language, useConfiguredLanguageFallback); - } - - public void AnnounceError(string reason) - { - Speak(Loc.Instance.GetString("Speech.Error", reason)); - } - - private void Stop() + internal async Task StopCurrentPlaybackBeforeCaptureAsync() { - CancellationTokenSource? cts; - ITtsPlaybackSession? session; - - lock (_lock) + var request = StopPlayback(); + if (request is null) { - cts = _speakCts; - session = _playbackSession; - _speakCts = null; - _playbackSession = null; - _isPlaybackPending = false; + return; } try { - cts?.Cancel(); + _ = await WaitForCompletionAsync(request, s_stopPlaybackTimeout) + .ConfigureAwait(false); + } + catch (Exception ex) + { + Debug.WriteLine($"SpeechFeedback stop wait error: {ex.Message}"); } - catch + } + + internal async Task AnnounceRecordingStartedAsync(bool spokenFeedbackEnabled) + { + if (!spokenFeedbackEnabled) { - // Best-effort cancellation; the source is disposed in the finally below. + return; } - finally + + var request = StartPlayback( + new TtsSpeakRequest(Loc.Instance["Speech.Recording"]), + requireEnabled: false + ); + if (request is null) { - cts?.Dispose(); + return; } try { - session?.Stop(); + if ( + await WaitForCompletionAsync(request, s_recordingAnnouncementTimeout) + .ConfigureAwait(false) + ) + { + return; + } + + request.CancelAndStop(); + _ = await WaitForCompletionAsync(request, s_stopPlaybackTimeout) + .ConfigureAwait(false); } - catch + catch (Exception ex) { - // Best-effort stop of the speech session. + // Spoken feedback is optional; a failed timeout wait or provider + // completion must not leave the request's session unstopped. + request.CancelAndStop(); + Debug.WriteLine($"SpeechFeedback recording announcement error: {ex.Message}"); } } + public void AnnounceTranscriptionComplete( + string text, + string? language = null, + bool useConfiguredLanguageFallback = true + ) + { + SpeakAutomaticTranscription(text, language, useConfiguredLanguageFallback); + } + + public void AnnounceError(string reason) + { + Speak(Loc.Instance.GetString("Speech.Error", reason)); + } + + private void Stop() + { + _ = StopPlayback(); + } + public event EventHandler? ProvidersChanged; private void SpeakCore( @@ -232,15 +307,24 @@ private void SpeakCore( bool requireEnabled, bool useConfiguredLanguageFallback = true ) + { + _ = StartPlayback(request, requireEnabled, useConfiguredLanguageFallback); + } + + private PlaybackRequest? StartPlayback( + TtsSpeakRequest request, + bool requireEnabled, + bool useConfiguredLanguageFallback = true + ) { if (_disposed || string.IsNullOrWhiteSpace(request.Text)) { - return; + return null; } if (requireEnabled && !_settings.Current.SpokenFeedbackEnabled) { - return; + return null; } // Callers that have already resolved the readback language (e.g. the @@ -253,16 +337,17 @@ private void SpeakCore( Stop(); - var cts = new CancellationTokenSource(); var version = Interlocked.Increment(ref _playbackVersion); + var playbackRequest = new PlaybackRequest(version); lock (_lock) { - _speakCts = cts; + _playbackRequest = playbackRequest; _isPlaybackPending = true; } - _ = SpeakAsync(request, cts, version); + _ = SpeakAsync(request, playbackRequest); + return playbackRequest; } // When a transcription / manual-readback request carries no language, fall @@ -297,21 +382,17 @@ private static bool ShouldUseConfiguredLanguageFallback(TtsPurpose purpose) private async Task SpeakAsync( TtsSpeakRequest request, - CancellationTokenSource cts, - long version + PlaybackRequest playbackRequest ) { ITtsPlaybackSession? session; try { var provider = ResolveSpeakProvider(); - session = await provider.SpeakAsync(request, cts.Token).ConfigureAwait(false); - - if (cts.IsCancellationRequested) - { - session.Stop(); - return; - } + session = await provider + .SpeakAsync(request, playbackRequest.Cancellation.Token) + .ConfigureAwait(false); + Volatile.Write(ref playbackRequest.Session, session); // Check that no newer Speak / Stop call has superseded us while // SpeakAsync was awaited. If the version has advanced, discard @@ -319,7 +400,11 @@ long version var accepted = false; lock (_lock) { - if (_speakCts == cts && version == Volatile.Read(ref _playbackVersion)) + if ( + ReferenceEquals(_playbackRequest, playbackRequest) + && playbackRequest.Version == Volatile.Read(ref _playbackVersion) + && !playbackRequest.Cancellation.IsCancellationRequested + ) { _playbackSession = session; _isPlaybackPending = false; @@ -327,83 +412,111 @@ long version } } - if (!accepted) - { - session.Stop(); - return; - } - EventHandler? completedHandler = null; completedHandler = (_, _) => { session.Completed -= completedHandler; - OnPlaybackCompleted(session, cts, version); + OnPlaybackCompleted(session, playbackRequest); }; session.Completed += completedHandler; + if (!accepted) + { + playbackRequest.CancelAndStop(); + } + if (!session.IsActive) { - OnPlaybackCompleted(session, cts, version); + session.Completed -= completedHandler; + OnPlaybackCompleted(session, playbackRequest); } } catch (OperationCanceledException) { - ClearPending(cts, version); + ClearPending(playbackRequest); } catch (Exception ex) { Debug.WriteLine($"SpeechFeedback error: {ex.Message}"); - ClearPending(cts, version); + ClearPending(playbackRequest); } } private void OnPlaybackCompleted( ITtsPlaybackSession session, - CancellationTokenSource cts, - long version + PlaybackRequest playbackRequest ) { - var disposeCts = false; lock (_lock) { if ( - ReferenceEquals(_playbackSession, session) - && version == Volatile.Read(ref _playbackVersion) + ReferenceEquals(_playbackRequest, playbackRequest) + && ReferenceEquals(_playbackSession, session) + && playbackRequest.Version == Volatile.Read(ref _playbackVersion) ) { _playbackSession = null; _isPlaybackPending = false; - if (_speakCts == cts) - { - _speakCts = null; - disposeCts = true; - } + _playbackRequest = null; } } - if (disposeCts) - { - cts.Dispose(); - } + playbackRequest.Complete(); } - private void ClearPending(CancellationTokenSource cts, long version) + private void ClearPending(PlaybackRequest playbackRequest) { - var disposeCts = false; lock (_lock) { - if (_speakCts == cts && version == Volatile.Read(ref _playbackVersion)) + if ( + ReferenceEquals(_playbackRequest, playbackRequest) + && playbackRequest.Version == Volatile.Read(ref _playbackVersion) + ) { - _speakCts = null; + _playbackRequest = null; _isPlaybackPending = false; - disposeCts = true; } } - if (disposeCts) + playbackRequest.Complete(); + } + + private PlaybackRequest? StopPlayback() + { + PlaybackRequest? playbackRequest; + lock (_lock) + { + playbackRequest = _playbackRequest; + _playbackRequest = null; + _playbackSession = null; + _isPlaybackPending = false; + } + + playbackRequest?.CancelAndStop(); + return playbackRequest; + } + + private async Task WaitForCompletionAsync( + PlaybackRequest playbackRequest, + TimeSpan timeout + ) + { + var completion = playbackRequest.Completion.Task; + if (completion.IsCompleted) { - cts.Dispose(); + await completion.ConfigureAwait(false); + return true; } + + var timeoutTask = _delay(timeout); + if (await Task.WhenAny(completion, timeoutTask).ConfigureAwait(false) == completion) + { + await completion.ConfigureAwait(false); + return true; + } + + await timeoutTask.ConfigureAwait(false); + return false; } private IReadOnlyList AllProviders() diff --git a/tests/TypeWhisper.Linux.Tests/DictationOrchestratorStartFeedbackTests.cs b/tests/TypeWhisper.Linux.Tests/DictationOrchestratorStartFeedbackTests.cs new file mode 100644 index 000000000..f66a7b3bb --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/DictationOrchestratorStartFeedbackTests.cs @@ -0,0 +1,221 @@ +using TypeWhisper.Linux.Services; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class DictationOrchestratorStartFeedbackTests +{ + private static readonly TimeSpan s_testGuard = TimeSpan.FromSeconds(2); + + [Fact] + public async Task Capture_waits_for_prior_speech_stop_and_selected_sound_in_exact_order() + { + var stopCompletion = NewSignal(); + var soundStarted = NewSignal(); + var soundCompletion = NewSignal(); + var order = new List(); + var captureInvoked = false; + + var startup = DictationOrchestrator.StartCaptureAfterFeedbackAsync( + soundFeedbackEnabled: true, + stopPriorSpeechAsync: () => + { + order.Add("stop prior speech"); + return stopCompletion.Task; + }, + playStartSoundAsync: () => + { + order.Add("sound"); + soundStarted.TrySetResult(); + return soundCompletion.Task; + }, + announceRecordingStartedAsync: () => + throw new InvalidOperationException("Speech must not overlap sound."), + isInputAllowed: () => true, + startCapture: () => + { + captureInvoked = true; + order.Add("capture"); + return new object(); + } + ); + + Assert.Equal(["stop prior speech"], order); + Assert.False(captureInvoked); + + stopCompletion.TrySetResult(); + await soundStarted.Task.WaitAsync(s_testGuard); + + Assert.Equal(["stop prior speech", "sound"], order); + Assert.False(captureInvoked); + + soundCompletion.TrySetResult(); + _ = await startup.WaitAsync(s_testGuard); + + Assert.Equal(["stop prior speech", "sound", "capture"], order); + Assert.True(captureInvoked); + } + + [Fact] + public async Task Sound_wins_when_both_feedback_modes_are_enabled() + { + var speechInvocations = 0; + + _ = await DictationOrchestrator.StartCaptureAfterFeedbackAsync( + soundFeedbackEnabled: true, + stopPriorSpeechAsync: () => Task.CompletedTask, + playStartSoundAsync: () => Task.CompletedTask, + announceRecordingStartedAsync: () => + { + speechInvocations++; + return Task.CompletedTask; + }, + isInputAllowed: () => true, + startCapture: () => new object() + ); + + Assert.Equal(0, speechInvocations); + } + + [Fact] + public async Task Sound_disabled_startup_waits_for_spoken_cue_before_capture() + { + var speechStarted = NewSignal(); + var speechCompletion = NewSignal(); + var captureInvoked = false; + + var startup = DictationOrchestrator.StartCaptureAfterFeedbackAsync( + soundFeedbackEnabled: false, + stopPriorSpeechAsync: () => Task.CompletedTask, + playStartSoundAsync: () => + throw new InvalidOperationException("Sound is disabled."), + announceRecordingStartedAsync: () => + { + speechStarted.TrySetResult(); + return speechCompletion.Task; + }, + isInputAllowed: () => true, + startCapture: () => + { + captureInvoked = true; + return new object(); + } + ); + + await speechStarted.Task.WaitAsync(s_testGuard); + Assert.False(captureInvoked); + + speechCompletion.TrySetResult(); + _ = await startup.WaitAsync(s_testGuard); + + Assert.True(captureInvoked); + } + + [Fact] + public async Task Unavailable_feedback_still_stops_prior_speech_then_attempts_capture() + { + var order = new List(); + + _ = await DictationOrchestrator.StartCaptureAfterFeedbackAsync( + soundFeedbackEnabled: false, + stopPriorSpeechAsync: () => + { + order.Add("stop"); + return Task.CompletedTask; + }, + playStartSoundAsync: () => Task.CompletedTask, + announceRecordingStartedAsync: () => + { + order.Add("speech no-op"); + return Task.CompletedTask; + }, + isInputAllowed: () => true, + startCapture: () => + { + order.Add("capture"); + return new object(); + } + ); + + Assert.Equal(["stop", "speech no-op", "capture"], order); + } + + [Fact] + public async Task Failed_feedback_remains_best_effort_and_capture_is_attempted() + { + var order = new List(); + + _ = await DictationOrchestrator.StartCaptureAfterFeedbackAsync( + soundFeedbackEnabled: true, + stopPriorSpeechAsync: () => + { + order.Add("stop"); + return Task.CompletedTask; + }, + playStartSoundAsync: () => + { + order.Add("sound failure"); + return Task.FromException(new InvalidOperationException("player failed")); + }, + announceRecordingStartedAsync: () => Task.CompletedTask, + isInputAllowed: () => true, + startCapture: () => + { + order.Add("capture"); + return new object(); + } + ); + + Assert.Equal(["stop", "sound failure", "capture"], order); + } + + [Fact] + public async Task Session_disallowed_while_cue_is_pending_never_invokes_capture() + { + var soundCompletion = NewSignal(); + var inputAllowed = true; + var captureInvoked = false; + + var startup = DictationOrchestrator.StartCaptureAfterFeedbackAsync( + soundFeedbackEnabled: true, + stopPriorSpeechAsync: () => Task.CompletedTask, + playStartSoundAsync: () => soundCompletion.Task, + announceRecordingStartedAsync: () => Task.CompletedTask, + // ReSharper disable once AccessToModifiedClosure -- the test flips inputAllowed after the cue starts to prove the pre-capture re-check wins. + isInputAllowed: () => inputAllowed, + startCapture: () => + { + captureInvoked = true; + return new object(); + } + ); + + inputAllowed = false; + soundCompletion.TrySetResult(); + + Assert.Null(await startup.WaitAsync(s_testGuard)); + Assert.False(captureInvoked); + } + + [Fact] + public async Task Capture_result_preserves_the_delegates_exact_reference() + { + var captureToken = new object(); + + var result = await DictationOrchestrator.StartCaptureAfterFeedbackAsync( + soundFeedbackEnabled: false, + stopPriorSpeechAsync: () => Task.CompletedTask, + playStartSoundAsync: () => Task.CompletedTask, + announceRecordingStartedAsync: () => Task.CompletedTask, + isInputAllowed: () => true, + startCapture: () => captureToken + ); + + Assert.Same(captureToken, result); + } + + private static TaskCompletionSource NewSignal() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } +} diff --git a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs new file mode 100644 index 000000000..7dbbcc25e --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs @@ -0,0 +1,154 @@ +using TypeWhisper.Linux.Services; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class SoundFeedbackServiceTests +{ + private static readonly TimeSpan s_testGuard = TimeSpan.FromSeconds(2); + + [Fact] + public async Task Awaited_start_cue_uses_real_argv_and_finite_timeout_then_waits_for_runner() + { + using var sounds = new TemporarySoundsDirectory(); + var runner = new ControlledProcessRunner(); + var sut = new SoundFeedbackService(runner, "fake-player", sounds.Path); + + var playback = sut.PlayRecordingStartedAsync(); + await runner.Invoked.Task.WaitAsync(s_testGuard); + + Assert.False(playback.IsCompleted); + var invocation = Assert.Single(runner.Invocations); + Assert.Equal("fake-player", invocation.FileName); + Assert.Equal([sounds.StartWavPath], invocation.Args); + Assert.Equal(SoundFeedbackService.s_startCueTimeout, invocation.Timeout); + Assert.Equal(TimeSpan.FromSeconds(2), invocation.Timeout); + Assert.Null(invocation.StandardInput); + + runner.Complete(new ProcessRunResult(true, false, 0, "", "")); + await playback.WaitAsync(s_testGuard); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Start_cue_launch_failure_and_timeout_remain_best_effort(bool timedOut) + { + using var sounds = new TemporarySoundsDirectory(); + var result = timedOut + ? new ProcessRunResult(true, true, -1, "", "") + : new ProcessRunResult(false, false, -1, "", "launch failed"); + var runner = ControlledProcessRunner.WithImmediateResult(result); + var sut = new SoundFeedbackService(runner, "fake-player", sounds.Path); + + await sut.PlayRecordingStartedAsync().WaitAsync(s_testGuard); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(SoundFeedbackService.s_startCueTimeout, invocation.Timeout); + } + + [Fact] + public async Task Start_cue_runner_exception_remains_best_effort() + { + using var sounds = new TemporarySoundsDirectory(); + var runner = ControlledProcessRunner.WithException( + new InvalidOperationException("fake runner failure") + ); + var sut = new SoundFeedbackService(runner, "fake-player", sounds.Path); + + await sut.PlayRecordingStartedAsync().WaitAsync(s_testGuard); + + Assert.Single(runner.Invocations); + } + + private sealed class ControlledProcessRunner : IProcessRunner + { + private readonly TaskCompletionSource _completion = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + private readonly Exception? _exception; + private readonly ProcessRunResult? _immediateResult; + + public TaskCompletionSource Invoked { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + public List Invocations { get; } = []; + + public ControlledProcessRunner() { } + + private ControlledProcessRunner(ProcessRunResult immediateResult) + { + _immediateResult = immediateResult; + } + + private ControlledProcessRunner(Exception exception) + { + _exception = exception; + } + + public static ControlledProcessRunner WithImmediateResult(ProcessRunResult result) + { + return new ControlledProcessRunner(result); + } + + public static ControlledProcessRunner WithException(Exception exception) + { + return new ControlledProcessRunner(exception); + } + + public Task RunAsync( + string fileName, + IReadOnlyList args, + IReadOnlyDictionary? environment = null, + string? standardInput = null, + TimeSpan? timeout = null, + CancellationToken ct = default + ) + { + Invocations.Add(new Invocation(fileName, args.ToArray(), standardInput, timeout)); + Invoked.TrySetResult(); + if (_exception is not null) + { + return Task.FromException(_exception); + } + + return _immediateResult is not null + ? Task.FromResult(_immediateResult) + : _completion.Task; + } + + public void Complete(ProcessRunResult result) + { + _completion.TrySetResult(result); + } + } + + private sealed record Invocation( + string FileName, + IReadOnlyList Args, + string? StandardInput, + TimeSpan? Timeout + ); + + private sealed class TemporarySoundsDirectory : IDisposable + { + public TemporarySoundsDirectory() + { + Path = System.IO.Path.Join( + System.IO.Path.GetTempPath(), + $"typewhisper-sound-tests-{Guid.NewGuid():N}" + ); + Directory.CreateDirectory(Path); + StartWavPath = System.IO.Path.Join(Path, "start.wav"); + File.WriteAllBytes(StartWavPath, "RIFF"u8); + } + + public string Path { get; } + public string StartWavPath { get; } + + public void Dispose() + { + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs index 476d4bbca..0c011446c 100644 --- a/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs @@ -8,6 +8,8 @@ namespace TypeWhisper.Linux.Tests; public sealed class SpeechFeedbackServiceTests { + private static readonly TimeSpan s_testGuard = TimeSpan.FromSeconds(2); + [Fact] public void AvailableProviders_includes_system_and_plugin_tts() { @@ -76,7 +78,7 @@ public async Task SpeakAutomaticTranscription_substitutes_configured_language_wh sut.SpeakAutomaticTranscription("Hallo Welt"); - await WaitUntilAsync(() => plugin.Requests.Count > 0); + await plugin.RequestReceived.Task.WaitAsync(s_testGuard); var request = Assert.Single(plugin.Requests); Assert.Equal("de", request.Language); @@ -104,7 +106,7 @@ public async Task SpeakAutomaticTranscription_keeps_explicit_request_language() sut.SpeakAutomaticTranscription("Hallo Welt", "fr"); - await WaitUntilAsync(() => plugin.Requests.Count > 0); + await plugin.RequestReceived.Task.WaitAsync(s_testGuard); var request = Assert.Single(plugin.Requests); Assert.Equal("fr", request.Language); @@ -137,28 +139,325 @@ public async Task SpeakAutomaticTranscription_skips_configured_language_fallback useConfiguredLanguageFallback: false ); - await WaitUntilAsync(() => plugin.Requests.Count > 0); + await plugin.RequestReceived.Task.WaitAsync(s_testGuard); var request = Assert.Single(plugin.Requests); Assert.Null(request.Language); Assert.Equal(TtsPurpose.Transcription, request.Purpose); } - // The service speaks on a fire-and-forget background task, so poll until - // the captured request is observable rather than asserting synchronously. - private static async Task WaitUntilAsync(Func condition) + [Fact] + public async Task Stop_before_capture_stops_prior_playback_before_awaiting_completion() + { + var settings = TestPluginManagerFactory.CreateSettings( + new AppSettings { SpokenFeedbackEnabled = true } + ); + var manager = TestPluginManagerFactory.Create(); + var priorSession = new ControlledPlaybackSession(); + var provider = new ControlledTtsProvider(priorSession); + var delay = new ControlledDelay(); + using var sut = new SpeechFeedbackService( + settings.Object, + manager, + provider, + delay.WaitAsync + ); + sut.SpeakAutomaticTranscription("prior playback"); + await priorSession.HandlerAttached.Task.WaitAsync(s_testGuard); + + var stop = sut.StopCurrentPlaybackBeforeCaptureAsync(); + + Assert.Equal(1, priorSession.StopCount); + Assert.False(stop.IsCompleted); + + priorSession.Complete(); + await stop.WaitAsync(s_testGuard); + } + + [Fact] + public async Task Stop_before_capture_returns_at_its_finite_bound_when_completion_is_missing() + { + var settings = TestPluginManagerFactory.CreateSettings( + new AppSettings { SpokenFeedbackEnabled = true } + ); + var manager = TestPluginManagerFactory.Create(); + var priorSession = new ControlledPlaybackSession(); + var provider = new ControlledTtsProvider(priorSession); + var delay = new ControlledDelay(); + using var sut = new SpeechFeedbackService( + settings.Object, + manager, + provider, + delay.WaitAsync + ); + sut.SpeakAutomaticTranscription("prior playback"); + await priorSession.HandlerAttached.Task.WaitAsync(s_testGuard); + + var stop = sut.StopCurrentPlaybackBeforeCaptureAsync(); + var timeout = await delay.NextRequestAsync(); + + Assert.Equal(SpeechFeedbackService.s_stopPlaybackTimeout, timeout.Duration); + Assert.Equal(1, priorSession.StopCount); + timeout.Complete(); + await stop.WaitAsync(s_testGuard); + } + + [Fact] + public async Task Recording_start_announcement_awaits_session_completion() + { + var settings = TestPluginManagerFactory.CreateSettings( + new AppSettings { SpokenFeedbackEnabled = true } + ); + var manager = TestPluginManagerFactory.Create(); + var session = new ControlledPlaybackSession(); + var provider = new ControlledTtsProvider(session); + var delay = new ControlledDelay(); + using var sut = new SpeechFeedbackService( + settings.Object, + manager, + provider, + delay.WaitAsync + ); + + var announcement = sut.AnnounceRecordingStartedAsync(spokenFeedbackEnabled: true); + await session.HandlerAttached.Task.WaitAsync(s_testGuard); + + Assert.False(announcement.IsCompleted); + session.Complete(); + await announcement.WaitAsync(s_testGuard); + Assert.Single(provider.Requests); + } + + [Fact] + public async Task Recording_start_announcement_timeout_stops_session_before_returning() + { + var settings = TestPluginManagerFactory.CreateSettings( + new AppSettings { SpokenFeedbackEnabled = true } + ); + var manager = TestPluginManagerFactory.Create(); + var session = new ControlledPlaybackSession(completeOnStop: true); + var provider = new ControlledTtsProvider(session); + var delay = new ControlledDelay(); + using var sut = new SpeechFeedbackService( + settings.Object, + manager, + provider, + delay.WaitAsync + ); + + var announcement = sut.AnnounceRecordingStartedAsync(spokenFeedbackEnabled: true); + await session.HandlerAttached.Task.WaitAsync(s_testGuard); + var timeout = await delay.NextRequestAsync(); + + Assert.Equal(SpeechFeedbackService.s_recordingAnnouncementTimeout, timeout.Duration); + Assert.Equal(0, session.StopCount); + timeout.Complete(); + + await announcement.WaitAsync(s_testGuard); + Assert.Equal(1, session.StopCount); + } + + [Fact] + public async Task Older_completion_cannot_clear_or_complete_newer_request() + { + var settings = TestPluginManagerFactory.CreateSettings( + new AppSettings { SpokenFeedbackEnabled = true } + ); + var manager = TestPluginManagerFactory.Create(); + var olderSession = new ControlledPlaybackSession(); + var newerSession = new ControlledPlaybackSession(); + var provider = new ControlledTtsProvider(olderSession, newerSession); + var delay = new ControlledDelay(); + using var sut = new SpeechFeedbackService( + settings.Object, + manager, + provider, + delay.WaitAsync + ); + sut.SpeakAutomaticTranscription("older playback"); + await olderSession.HandlerAttached.Task.WaitAsync(s_testGuard); + + var newerAnnouncement = sut.AnnounceRecordingStartedAsync( + spokenFeedbackEnabled: true + ); + await newerSession.HandlerAttached.Task.WaitAsync(s_testGuard); + Assert.Equal(1, olderSession.StopCount); + + olderSession.Complete(); + var stopNewer = sut.StopCurrentPlaybackBeforeCaptureAsync(); + + Assert.Equal(1, newerSession.StopCount); + Assert.False(newerAnnouncement.IsCompleted); + newerSession.Complete(); + await Task.WhenAll(newerAnnouncement, stopNewer).WaitAsync(s_testGuard); + } + + private sealed class ControlledDelay { - for (var attempt = 0; attempt < 100; attempt++) + private readonly Queue _requests = new(); + private readonly SemaphoreSlim _requestAvailable = new(0); + + public Task WaitAsync(TimeSpan duration) + { + var request = new DelayRequest(duration); + lock (_requests) + { + _requests.Enqueue(request); + } + + _requestAvailable.Release(); + return request.Completion.Task; + } + + public async Task NextRequestAsync() + { + await _requestAvailable.WaitAsync().WaitAsync(s_testGuard); + lock (_requests) + { + return _requests.Dequeue(); + } + } + } + + private sealed class DelayRequest(TimeSpan duration) + { + public TimeSpan Duration { get; } = duration; + public TaskCompletionSource Completion { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + public void Complete() + { + Completion.TrySetResult(); + } + } + + private sealed class ControlledTtsProvider(params ITtsPlaybackSession[] sessions) + : ITtsProviderPlugin + { + private readonly Queue _sessions = new(sessions); + + public string PluginId => "plugin.controlled"; + public string PluginName => "Controlled"; + public string PluginVersion => "1.0.0"; + public string ProviderId => "controlled"; + public string ProviderDisplayName => "Controlled"; + public bool IsConfigured => true; + public IReadOnlyList AvailableVoices => []; + public string? SelectedVoiceId => null; + public List Requests { get; } = []; + + public Task ActivateAsync(IPluginHostServices host) + { + return Task.CompletedTask; + } + + public Task DeactivateAsync() + { + return Task.CompletedTask; + } + + public void SelectVoice(string? voiceId) { } + + public Task SpeakAsync( + TtsSpeakRequest request, + CancellationToken ct + ) + { + Requests.Add(request); + return Task.FromResult(_sessions.Dequeue()); + } + + public void Dispose() { } + } + + private sealed class ControlledPlaybackSession(bool completeOnStop = false) + : ITtsPlaybackSession + { + private readonly Lock _sync = new(); + private EventHandler? _completed; + private bool _isActive = true; + private int _stopCount; + + public bool IsActive { - if (condition()) + get { - return; + lock (_sync) + { + return _isActive; + } } + } + + public int StopCount => Volatile.Read(ref _stopCount); + public TaskCompletionSource HandlerAttached { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); - await Task.Delay(20); + public event EventHandler? Completed + { + add + { + if (value is null) + { + return; + } + + var alreadyCompleted = false; + lock (_sync) + { + if (_isActive) + { + _completed += value; + } + else + { + alreadyCompleted = true; + } + } + + HandlerAttached.TrySetResult(); + if (alreadyCompleted) + { + value(this, EventArgs.Empty); + } + } + remove + { + lock (_sync) + { + _completed -= value; + } + } } - Assert.True(condition(), "Condition was not met within the timeout."); + public void Stop() + { + Interlocked.Increment(ref _stopCount); + if (completeOnStop) + { + Complete(); + } + } + + public void Complete() + { + EventHandler? handlers; + lock (_sync) + { + if (!_isActive) + { + return; + } + + _isActive = false; + handlers = _completed; + _completed = null; + } + + handlers?.Invoke(this, EventArgs.Empty); + } } private sealed class FakeTtsProvider(string providerId, string displayName, bool configured) @@ -173,6 +472,9 @@ private sealed class FakeTtsProvider(string providerId, string displayName, bool public IReadOnlyList AvailableVoices { get; } = [new("voice", "Voice")]; public string? SelectedVoiceId { get; private set; } public List Requests { get; } = []; + public TaskCompletionSource RequestReceived { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); public Task ActivateAsync(IPluginHostServices host) { @@ -195,6 +497,7 @@ CancellationToken ct ) { Requests.Add(request); + RequestReceived.TrySetResult(); return Task.FromResult(InactiveSession.Instance); } @@ -214,4 +517,4 @@ public event EventHandler? Completed public void Stop() { } } -} \ No newline at end of file +} From f1e5a7b5c47ce9d0cd1dee5eaebde5d581a92b9a Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 14:02:14 +0000 Subject: [PATCH 097/226] =?UTF-8?q?Honor=20the=20resolved=20readback=20lan?= =?UTF-8?q?guage=20in=20Linux=20system=20TTS=20with=20a=20bounded=20defaul?= =?UTF-8?q?t-voice=20fallback=20(audit=20=C2=A75=20M7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/LinuxSystemTtsProvider.cs | 87 +++++- .../LinuxSystemTtsProviderTests.cs | 279 +++++++++++++++++- 2 files changed, 347 insertions(+), 19 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs index 9381aff7b..3fbbf4f40 100644 --- a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs +++ b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs @@ -94,12 +94,22 @@ public Task SpeakAsync(TtsSpeakRequest request, Cancellatio ct.ThrowIfCancellationRequested(); - // espeak/espeak-ng and spd-say both own their audio output; passing the - // text as a single argv avoids a shell and keeps the runner in sole control. + var language = NormalizeLanguageHint(request.Language); + var args = BuildArguments(command, request.Text, language); + IReadOnlyList? fallbackArgs = language is not null && args.Count > 1 + ? [request.Text] + : null; + + // espeak/espeak-ng and spd-say both own their audio output. Arguments + // remain separate argv items so no shell or intermediate audio is needed. + // If a backend rejects a requested language/voice with a nonzero exit, + // the session makes one best-effort default-voice attempt within the same + // timeout budget. Launch failures, timeouts, and cancellation never retry. var session = new TaskBackedTtsPlaybackSession( _processRunner, command, - [request.Text], + args, + fallbackArgs, CalculatePlaybackTimeout(request.Text.Length), ct ); @@ -108,6 +118,34 @@ public Task SpeakAsync(TtsSpeakRequest request, Cancellatio public void Dispose() { } + private static string? NormalizeLanguageHint(string? language) + { + var normalized = language?.Trim(); + return string.IsNullOrEmpty(normalized) + || string.Equals(normalized, "auto", StringComparison.OrdinalIgnoreCase) + ? null + : normalized; + } + + private static IReadOnlyList BuildArguments( + string command, + string text, + string? language + ) + { + if (language is null) + { + return [text]; + } + + return command switch + { + "espeak" or "espeak-ng" => ["-v", language, text], + "spd-say" => ["-l", language, text], + _ => [text] + }; + } + internal static TimeSpan CalculatePlaybackTimeout(int utf16CharacterCount) { ArgumentOutOfRangeException.ThrowIfNegative(utf16CharacterCount); @@ -140,15 +178,17 @@ public TaskBackedTtsPlaybackSession( IProcessRunner processRunner, string command, IReadOnlyList args, + IReadOnlyList? fallbackArgs, TimeSpan timeout, CancellationToken ct ) { _invocationCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - _runnerTask = RunInvocationAsync( + _runnerTask = RunInvocationSequenceAsync( processRunner, command, args, + fallbackArgs, timeout, _invocationCts.Token ); @@ -218,6 +258,45 @@ public void Dispose() Stop(); } + private static async Task RunInvocationSequenceAsync( + IProcessRunner processRunner, + string command, + IReadOnlyList args, + IReadOnlyList? fallbackArgs, + TimeSpan timeout, + CancellationToken ct + ) + { + var stopwatch = Stopwatch.StartNew(); + var result = await RunInvocationAsync(processRunner, command, args, timeout, ct) + .ConfigureAwait(false); + if ( + fallbackArgs is null + || !result.Started + || result.TimedOut + || result.ExitCode == 0 + ) + { + return result; + } + + ct.ThrowIfCancellationRequested(); + var remainingTimeout = timeout - stopwatch.Elapsed; + if (remainingTimeout <= TimeSpan.Zero) + { + return result; + } + + return await RunInvocationAsync( + processRunner, + command, + fallbackArgs, + remainingTimeout, + ct + ) + .ConfigureAwait(false); + } + private static async Task RunInvocationAsync( IProcessRunner processRunner, string command, diff --git a/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs b/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs index 7157ad84d..89eb5dcec 100644 --- a/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs @@ -51,6 +51,26 @@ public async Task SpeakAsync_routes_espeak_directly_with_text_as_one_argv_item(s Assert.Null(invocation.StandardInput); } + [Theory] + [InlineData("espeak-ng")] + [InlineData("espeak")] + public async Task SpeakAsync_routes_espeak_language_as_voice_selector(string command) + { + const string text = "Bonjour tout le monde"; + var runner = ControlledProcessRunner.WithImmediateResult(Success()); + using var provider = CreateProvider(command, runner); + + await provider.SpeakAsync( + new TtsSpeakRequest(text, "fr"), + CancellationToken.None + ); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(command, invocation.FileName); + Assert.Equal(["-v", "fr", text], invocation.Args); + Assert.Null(invocation.StandardInput); + } + [Fact] public async Task SpeakAsync_routes_spd_say_through_runner_with_existing_argument_contract() { @@ -67,6 +87,137 @@ public async Task SpeakAsync_routes_spd_say_through_runner_with_existing_argumen Assert.Null(invocation.StandardInput); } + [Fact] + public async Task SpeakAsync_trims_spd_say_language_and_keeps_text_as_one_argv_item() + { + const string text = "um; \"dois itens\" $HOME"; + var runner = ControlledProcessRunner.WithImmediateResult(Success()); + using var provider = CreateProvider("spd-say", runner); + + await provider.SpeakAsync( + new TtsSpeakRequest(text, " pt-BR "), + CancellationToken.None + ); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal("spd-say", invocation.FileName); + Assert.Equal(["-l", "pt-BR", text], invocation.Args); + Assert.NotEqual("sh", invocation.FileName); + Assert.DoesNotContain(invocation.FileName, s_audioPlaybackCommands); + Assert.Null(invocation.StandardInput); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("auto")] + [InlineData(" AUTO ")] + public async Task SpeakAsync_uses_default_voice_when_language_has_no_usable_hint( + string? language + ) + { + const string text = "default voice"; + var runner = ControlledProcessRunner.WithImmediateResult(Success()); + using var provider = CreateProvider("espeak", runner); + + await provider.SpeakAsync( + new TtsSpeakRequest(text, language), + CancellationToken.None + ); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal([text], invocation.Args); + } + + [Fact] + public async Task SpeakAsync_preserves_default_argv_for_unknown_command() + { + const string text = "unknown backend"; + var runner = ControlledProcessRunner.WithImmediateResult(Success()); + using var provider = CreateProvider("custom-tts", runner); + + await provider.SpeakAsync( + new TtsSpeakRequest(text, "de"), + CancellationToken.None + ); + + var invocation = Assert.Single(runner.Invocations); + Assert.Equal([text], invocation.Args); + } + + [Fact] + public async Task Rejected_localized_invocation_retries_default_voice_once_with_remaining_timeout() + { + const string text = "fallback text"; + var runner = ControlledProcessRunner.WithImmediateResults( + new ProcessRunResult(true, false, 23, "", "voice unavailable"), + Success() + ); + using var provider = CreateProvider("espeak-ng", runner); + + var session = await provider.SpeakAsync( + new TtsSpeakRequest(text, "nl-BE"), + CancellationToken.None + ); + var completion = NewCompletionSignal(); + var completedCount = 0; + session.Completed += (_, _) => + { + // ReSharper disable once AccessToModifiedClosure -- completedCount is deliberately shared between the completion handler and the test body (read via Volatile.Read); interlocked/volatile access is the intended synchronization. + Interlocked.Increment(ref completedCount); + completion.TrySetResult(); + }; + + await completion.Task.WaitAsync(s_testGuard); + Assert.False(session.IsActive); + Assert.Equal(1, Volatile.Read(ref completedCount)); + Assert.Equal(2, runner.Invocations.Count); + var primary = runner.Invocations[0]; + var fallback = runner.Invocations[1]; + Assert.Equal("espeak-ng", primary.FileName); + Assert.Equal(["-v", "nl-BE", text], primary.Args); + Assert.Equal("espeak-ng", fallback.FileName); + Assert.Equal([text], fallback.Args); + Assert.NotNull(primary.Timeout); + Assert.NotNull(fallback.Timeout); + Assert.True(fallback.Timeout > TimeSpan.Zero); + Assert.True( + fallback.Timeout < primary.Timeout, + $"Expected fallback timeout {fallback.Timeout} to be less than primary timeout {primary.Timeout}." + ); + Assert.Null(primary.StandardInput); + Assert.Null(fallback.StandardInput); + } + + [Theory] + [InlineData("success")] + [InlineData("not-started")] + [InlineData("timed-out")] + public async Task Localized_invocation_does_not_retry_without_voice_rejection(string outcome) + { + var result = outcome switch + { + "success" => Success(), + "not-started" => new ProcessRunResult(false, false, -1, "", "launch failed"), + "timed-out" => new ProcessRunResult(true, true, -1, "", ""), + _ => throw new ArgumentOutOfRangeException(nameof(outcome)) + }; + var runner = ControlledProcessRunner.WithImmediateResults(result, Success()); + using var provider = CreateProvider("spd-say", runner); + + var session = await provider.SpeakAsync( + new TtsSpeakRequest("say once", "it"), + CancellationToken.None + ); + var completion = NewCompletionSignal(); + session.Completed += (_, _) => completion.TrySetResult(); + + await completion.Task.WaitAsync(s_testGuard); + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(["-l", "it", "say once"], invocation.Args); + } + [Theory] [InlineData(1, 15_000)] [InlineData(50, 15_000)] @@ -157,6 +308,43 @@ public async Task Stop_is_idempotent_and_cancels_pending_runner_once() Assert.Equal(1, Volatile.Read(ref completedCount)); } + [Fact] + public async Task Pending_default_voice_fallback_stays_active_and_stop_completes_once() + { + const string text = "stop fallback"; + var runner = ControlledProcessRunner.WithPendingResults(2); + using var provider = CreateProvider("espeak", runner); + var session = await provider.SpeakAsync( + new TtsSpeakRequest(text, "pl"), + CancellationToken.None + ); + var completion = NewCompletionSignal(); + var completedCount = 0; + session.Completed += (_, _) => + { + // ReSharper disable once AccessToModifiedClosure -- completedCount is deliberately shared between the completion handler and the test body (read via Volatile.Read); interlocked/volatile access is the intended synchronization. + Interlocked.Increment(ref completedCount); + completion.TrySetResult(); + }; + + Assert.True(session.IsActive); + runner.Complete(new ProcessRunResult(true, false, 17, "", "voice unavailable")); + await runner.WaitForInvocationAsync(2).WaitAsync(s_testGuard); + Assert.True(session.IsActive); + Assert.Equal(2, runner.Invocations.Count); + Assert.Equal(["-v", "pl", text], runner.Invocations[0].Args); + Assert.Equal([text], runner.Invocations[1].Args); + + session.Stop(); + session.Stop(); + + await completion.Task.WaitAsync(s_testGuard); + Assert.False(session.IsActive); + Assert.Equal(1, runner.CancellationCount); + Assert.Equal(1, Volatile.Read(ref completedCount)); + Assert.Equal(2, runner.Invocations.Count); + } + [Theory] [InlineData("not-started")] [InlineData("non-zero")] @@ -249,17 +437,21 @@ private static TaskCompletionSource NewCompletionSignal() private sealed class ControlledProcessRunner : IProcessRunner { - private readonly TaskCompletionSource _completion = new( - TaskCreationOptions.RunContinuationsAsynchronously - ); - private readonly ProcessRunResult? _immediateResult; + private readonly ControlledResult[] _results; private int _cancellationCount; + private int _completionIndex; + private int _invocationIndex; - public ControlledProcessRunner() { } + public ControlledProcessRunner() + : this(1) { } - private ControlledProcessRunner(ProcessRunResult immediateResult) + private ControlledProcessRunner(int resultCount) { - _immediateResult = immediateResult; + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resultCount); + _results = Enumerable + .Range(0, resultCount) + .Select(_ => new ControlledResult()) + .ToArray(); } public List Invocations { get; } = []; @@ -267,7 +459,36 @@ private ControlledProcessRunner(ProcessRunResult immediateResult) public static ControlledProcessRunner WithImmediateResult(ProcessRunResult result) { - return new ControlledProcessRunner(result); + return WithImmediateResults(result); + } + + public static ControlledProcessRunner WithImmediateResults( + params ProcessRunResult[] results + ) + { + var runner = new ControlledProcessRunner(results.Length); + foreach (var result in results) + { + runner.Complete(result); + } + + return runner; + } + + public static ControlledProcessRunner WithPendingResults(int resultCount) + { + return new ControlledProcessRunner(resultCount); + } + + public Task WaitForInvocationAsync(int invocationNumber) + { + ArgumentOutOfRangeException.ThrowIfLessThan(invocationNumber, 1); + if (invocationNumber > _results.Length) + { + throw new ArgumentOutOfRangeException(nameof(invocationNumber)); + } + + return _results[invocationNumber - 1].Invoked.Task; } public async Task RunAsync( @@ -279,21 +500,49 @@ public async Task RunAsync( CancellationToken ct = default ) { + var invocationIndex = Interlocked.Increment(ref _invocationIndex) - 1; + if (invocationIndex >= _results.Length) + { + throw new InvalidOperationException("No controlled process result remains."); + } + + var result = _results[invocationIndex]; Invocations.Add(new Invocation(fileName, args.ToArray(), standardInput, timeout)); - if (_immediateResult is not null) + result.Invoked.TrySetResult(); + if (result.Completion.Task.IsCompleted) { - return _immediateResult; + return await result.Completion.Task.ConfigureAwait(false); } - await using var registration = ct.Register( - () => Interlocked.Increment(ref _cancellationCount) - ); - return await _completion.Task.WaitAsync(ct).ConfigureAwait(false); + try + { + return await result.Completion.Task.WaitAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Interlocked.Increment(ref _cancellationCount); + throw; + } } public void Complete(ProcessRunResult result) { - _completion.TrySetResult(result); + var completionIndex = Interlocked.Increment(ref _completionIndex) - 1; + if (completionIndex >= _results.Length) + { + throw new InvalidOperationException("No controlled process completion remains."); + } + + _results[completionIndex].Completion.TrySetResult(result); + } + + private sealed class ControlledResult + { + public TaskCompletionSource Completion { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + public TaskCompletionSource Invoked { get; } = NewCompletionSignal(); } } From e4c352c94dd62db3a5f48fede4b68f5efb10d8be Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 17:40:07 +0000 Subject: [PATCH 098/226] =?UTF-8?q?Preserve=20the=20saved=20microphone=20p?= =?UTF-8?q?reference=20when=20its=20identity=20is=20missing=20or=20ambiguo?= =?UTF-8?q?us=20(audit=20=C2=A75=20M4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/TypeWhisper.Linux/App.axaml.cs | 3 +- .../Services/AudioRecordingService.cs | 45 ++-- .../AudioRecordingServiceTests.cs | 208 ++++++++++++++++++ 3 files changed, 239 insertions(+), 17 deletions(-) diff --git a/src/TypeWhisper.Linux/App.axaml.cs b/src/TypeWhisper.Linux/App.axaml.cs index 37d7de2dc..0530665f3 100644 --- a/src/TypeWhisper.Linux/App.axaml.cs +++ b/src/TypeWhisper.Linux/App.axaml.cs @@ -717,7 +717,7 @@ private static async Task BootstrapDeferredAsync(IServiceProvider services) } } - private static void ApplyConfiguredMicrophone( + internal static void ApplyConfiguredMicrophone( AudioRecordingService audio, ISettingsService settings ) @@ -734,6 +734,7 @@ ISettingsService settings var resolved = audio.ResolveConfiguredDevice(configuredIndex, configuredId); if (resolved is null) { + audio.SelectedDeviceIndex = null; return; } diff --git a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs index 5c5e2b7d7..c44a7d80b 100644 --- a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs @@ -48,6 +48,7 @@ Action Sink private readonly Func _defaultInputDeviceIndexProvider; private readonly Action _ensurePortAudioInitialized; private readonly IErrorLogService? _errorLog; + private readonly Func> _inputDeviceListProvider; private readonly Action _openInputStream; private readonly List _sampleChunks = []; private readonly Lock _sampleLock = new(); @@ -83,6 +84,7 @@ public AudioRecordingService(IErrorLogService? errorLog = null) _errorLog = errorLog; _defaultInputDeviceIndexProvider = static () => PortAudio.DefaultInputDevice; _ensurePortAudioInitialized = EnsurePortAudioInitialized; + _inputDeviceListProvider = GetInputDevices; _openInputStream = OpenInputStream; _stopAndDisposeInputStreamCore = StopAndDisposeInputStreamCore; _terminatePortAudioOnDispose = true; @@ -95,11 +97,31 @@ internal AudioRecordingService( Func defaultInputDeviceIndexProvider, Action stopAndDisposeInputStream, IErrorLogService? errorLog = null + ) + : this( + static () => [], + openInputStream, + defaultInputDeviceIndexProvider, + stopAndDisposeInputStream, + errorLog + ) + { + } + + // Test seam for configured-device resolution. The provider supplies descriptors only; + // matching and fallback decisions remain in ResolveConfiguredDevice. + internal AudioRecordingService( + Func> inputDeviceListProvider, + Action openInputStream, + Func defaultInputDeviceIndexProvider, + Action stopAndDisposeInputStream, + IErrorLogService? errorLog = null ) { _errorLog = errorLog; _defaultInputDeviceIndexProvider = defaultInputDeviceIndexProvider; _ensurePortAudioInitialized = static () => { }; + _inputDeviceListProvider = inputDeviceListProvider; _openInputStream = openInputStream; _stopAndDisposeInputStreamCore = stopAndDisposeInputStream; _terminatePortAudioOnDispose = false; @@ -406,31 +428,22 @@ public void StopPreview() UpdateLevel(0f); } - // kept instance: invoked on the injected _audio service by callers - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] - // ReSharper disable once MemberCanBeMadeStatic.Global public AudioInputDevice? ResolveConfiguredDevice(int? preferredIndex, string? preferredDeviceId) { - var devices = GetInputDevices(); + var devices = _inputDeviceListProvider(); if (!string.IsNullOrWhiteSpace(preferredDeviceId)) { - var byId = devices.FirstOrDefault(d => d.PersistentId == preferredDeviceId); - if (byId is not null) - { - return byId; - } + var matches = devices + .Where(d => string.Equals(d.PersistentId, preferredDeviceId, StringComparison.Ordinal)) + .Take(2) + .ToArray(); + return matches.Length == 1 ? matches[0] : null; } - // ReSharper disable once InvertIf — fall-through tail is a coalesce/ternary expression - // that inverting this block would duplicate. if (preferredIndex.HasValue) { - var byIndex = devices.FirstOrDefault(d => d.Index == preferredIndex.Value); - if (byIndex is not null) - { - return byIndex; - } + return null; } return devices.FirstOrDefault(d => d.IsDefault) ?? (devices.Count > 0 ? devices[0] : null); diff --git a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs index 7cbcffddc..a7f7917a0 100644 --- a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs @@ -1,4 +1,6 @@ using PortAudioSharp; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; using TypeWhisper.Linux.Services; using Xunit; @@ -139,6 +141,167 @@ public void LiveFrameSink_OnlyFiresWhenIsRecording() Assert.False(invoked); } + [Fact] + public void ApplyConfiguredMicrophone_WhenSavedIdentityIsMissing_UsesDefaultWithoutChangingPreference() + { + const int staleIndex = 4; + const int defaultIndex = 9; + const string missingId = "Wanted Mic|1"; + IReadOnlyList devices = + [ + new(staleIndex, "Replacement Mic", 1, false, "Replacement Mic|1"), + new(defaultIndex, "Current Default", 1, true, "Current Default|1") + ]; + var operations = new List(); + using var service = CreateConfiguredDeviceService(devices, defaultIndex, operations); + service.SelectedDeviceIndex = staleIndex; + var originalSettings = AppSettings.Default with + { + SelectedMicrophoneDevice = staleIndex, + SelectedMicrophoneDeviceId = missingId + }; + var settings = new FakeSettingsService(originalSettings); + + App.ApplyConfiguredMicrophone(service, settings); + + Assert.Equal(0, settings.SaveCount); + Assert.Same(originalSettings, settings.Current); + Assert.Equal(staleIndex, settings.Current.SelectedMicrophoneDevice); + Assert.Equal(missingId, settings.Current.SelectedMicrophoneDeviceId); + Assert.Null(service.SelectedDeviceIndex); + + var session = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + Assert.Equal(["open:9"], operations); + + service.StopRecording(session); + Assert.Equal(["open:9", "stop:9"], operations); + } + + [Theory] + [InlineData(null)] + [InlineData(" ")] + public void ApplyConfiguredMicrophone_WhenStoredIdentityIsAbsent_DoesNotTrustCachedIndex( + string? storedDeviceId + ) + { + const int staleIndex = 6; + const int defaultIndex = 8; + IReadOnlyList devices = + [ + new(staleIndex, "Cached Index Device", 1, false, "Cached Index Device|1"), + new(defaultIndex, "Current Default", 1, true, "Current Default|1") + ]; + var operations = new List(); + using var service = CreateConfiguredDeviceService(devices, defaultIndex, operations); + service.SelectedDeviceIndex = staleIndex; + var originalSettings = AppSettings.Default with + { + SelectedMicrophoneDevice = staleIndex, + SelectedMicrophoneDeviceId = storedDeviceId + }; + var settings = new FakeSettingsService(originalSettings); + + App.ApplyConfiguredMicrophone(service, settings); + + Assert.Equal(0, settings.SaveCount); + Assert.Same(originalSettings, settings.Current); + Assert.Equal(staleIndex, settings.Current.SelectedMicrophoneDevice); + Assert.Equal(storedDeviceId, settings.Current.SelectedMicrophoneDeviceId); + Assert.Null(service.SelectedDeviceIndex); + + var session = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + Assert.Equal(["open:8"], operations); + + service.StopRecording(session); + Assert.Equal(["open:8", "stop:8"], operations); + } + + [Theory] + [InlineData(4, 1)] + [InlineData(9, 0)] + public void ApplyConfiguredMicrophone_WhenStoredIdentityIsUnique_SelectsItAndRefreshesIndexOnlyWhenNeeded( + int storedIndex, + int expectedSaveCount + ) + { + const int intendedIndex = 9; + const int defaultIndex = 12; + const string intendedId = "Wanted Mic|1"; + IReadOnlyList devices = + [ + new(4, "Replacement Mic", 1, false, "Replacement Mic|1"), + new(intendedIndex, "Wanted Mic", 1, false, intendedId), + new(defaultIndex, "Current Default", 1, true, "Current Default|1") + ]; + var operations = new List(); + using var service = CreateConfiguredDeviceService(devices, defaultIndex, operations); + var settings = new FakeSettingsService( + AppSettings.Default with + { + SelectedMicrophoneDevice = storedIndex, + SelectedMicrophoneDeviceId = intendedId + } + ); + + App.ApplyConfiguredMicrophone(service, settings); + + Assert.Equal(expectedSaveCount, settings.SaveCount); + Assert.Equal(intendedIndex, settings.Current.SelectedMicrophoneDevice); + Assert.Equal(intendedId, settings.Current.SelectedMicrophoneDeviceId); + Assert.Equal(intendedIndex, service.SelectedDeviceIndex); + + var session = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + Assert.Equal(["open:9"], operations); + + service.StopRecording(session); + Assert.Equal(["open:9", "stop:9"], operations); + } + + [Fact] + public void ApplyConfiguredMicrophone_WhenStoredIdentityIsAmbiguous_UsesDefaultWithoutChangingPreference() + { + const int staleIndex = 4; + const int defaultIndex = 12; + const string duplicateId = "Identical Mic|1"; + IReadOnlyList devices = + [ + new(7, "Identical Mic", 1, false, duplicateId), + new(staleIndex, "Identical Mic", 1, false, duplicateId), + new(defaultIndex, "Current Default", 1, true, "Current Default|1") + ]; + var operations = new List(); + using var service = CreateConfiguredDeviceService(devices, defaultIndex, operations); + service.SelectedDeviceIndex = staleIndex; + var originalSettings = AppSettings.Default with + { + SelectedMicrophoneDevice = staleIndex, + SelectedMicrophoneDeviceId = duplicateId + }; + var settings = new FakeSettingsService(originalSettings); + + App.ApplyConfiguredMicrophone(service, settings); + + Assert.Equal(0, settings.SaveCount); + Assert.Same(originalSettings, settings.Current); + Assert.Equal(staleIndex, settings.Current.SelectedMicrophoneDevice); + Assert.Equal(duplicateId, settings.Current.SelectedMicrophoneDeviceId); + Assert.Null(service.SelectedDeviceIndex); + + var session = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + Assert.Equal(["open:12"], operations); + + service.StopRecording(session); + Assert.Equal(["open:12", "stop:12"], operations); + } + [Fact] public void TryStartRecording_WhenPreviewDeviceChanged_RebuildsBeforeCreatingOwner() { @@ -377,4 +540,49 @@ public void OwningSession_StopsOnce_AndRepeatedStopCannotAffectLaterSession() Assert.True(service.StopRecording(sessionB).Length > 44); Assert.Equal(2, streamStopCount); } + + private static AudioRecordingService CreateConfiguredDeviceService( + IReadOnlyList devices, + int defaultDeviceIndex, + List operations + ) + { + int? openDeviceIndex = null; + return new AudioRecordingService( + () => devices, + deviceIndex => + { + Assert.Null(openDeviceIndex); + openDeviceIndex = deviceIndex; + operations.Add($"open:{deviceIndex}"); + }, + () => defaultDeviceIndex, + () => + { + Assert.True(openDeviceIndex.HasValue); + operations.Add($"stop:{openDeviceIndex.Value}"); + openDeviceIndex = null; + } + ); + } + + private sealed class FakeSettingsService(AppSettings current) : ISettingsService + { + public int SaveCount { get; private set; } + public AppSettings Current { get; private set; } = current; + + public AppSettings Load() + { + return Current; + } + + public void Save(AppSettings settings) + { + SaveCount++; + Current = settings; + SettingsChanged?.Invoke(settings); + } + + public event Action? SettingsChanged; + } } From c36b666f0af39c550c10f3af907fe7b8a52868a2 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 17:44:57 +0000 Subject: [PATCH 099/226] =?UTF-8?q?Observe=20Recorder=20stop=20persistence?= =?UTF-8?q?=20and=20report=20save=20failures=20honestly=20(audit=20=C2=A75?= =?UTF-8?q?=20M14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sections/RecorderSectionViewModel.cs | 132 +++++---- .../RecorderSectionViewModelTests.cs | 255 ++++++++++++++++++ 2 files changed, 329 insertions(+), 58 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/RecorderSectionViewModelTests.cs diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/RecorderSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/RecorderSectionViewModel.cs index ed6517d9d..fca39da5e 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/RecorderSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/RecorderSectionViewModel.cs @@ -22,13 +22,11 @@ public sealed record RecordingItem( public partial class RecorderSectionViewModel : ObservableObject { private readonly AudioRecordingService _audio; - private readonly ModelManagerService _models; + private readonly string _recordingDirectory; private readonly ISettingsService _settings; + private readonly Func> _transcribeAsync; private AudioRecordingService.AudioCaptureSession? _captureSession; - // Command execution and continuations that access this flag run on the UI thread. - private bool _stopSaveInProgress; - [ObservableProperty] private double _audioLevel; @@ -52,11 +50,32 @@ public RecorderSectionViewModel( AudioRecordingService audio, ModelManagerService models, ISettingsService settings + ) + : this( + audio, + settings, + TypeWhisperEnvironment.AudioPath, + CreateTranscriptionDelegate(models, settings) + ) + { + } + + internal RecorderSectionViewModel( + AudioRecordingService audio, + ISettingsService settings, + string recordingDirectory, + Func> transcribeAsync ) { + ArgumentNullException.ThrowIfNull(audio); + ArgumentNullException.ThrowIfNull(settings); + ArgumentException.ThrowIfNullOrWhiteSpace(recordingDirectory); + ArgumentNullException.ThrowIfNull(transcribeAsync); + _audio = audio; - _models = models; _settings = settings; + _recordingDirectory = recordingDirectory; + _transcribeAsync = transcribeAsync; _audio.LevelChanged += (_, level) => Dispatcher.UIThread.Post(() => AudioLevel = Math.Clamp(level * 8, 0, 1)); LoadExistingRecordings(); @@ -68,18 +87,12 @@ ISettingsService settings public ObservableCollection Recordings { get; } = []; public bool HasRecordings => Recordings.Count > 0; - [RelayCommand(CanExecute = nameof(CanToggleRecording))] - private void ToggleRecording() + [RelayCommand] + private async Task ToggleRecording() { - if (_stopSaveInProgress) - { - return; - } - if (IsRecording) { - SetStopSaveInProgress(true); - _ = StopRecordingAsync(); + await StopRecordingAsync(); } else { @@ -87,22 +100,6 @@ private void ToggleRecording() } } - private bool CanToggleRecording() - { - return !_stopSaveInProgress; - } - - private void SetStopSaveInProgress(bool value) - { - if (_stopSaveInProgress == value) - { - return; - } - - _stopSaveInProgress = value; - ToggleRecordingCommand.NotifyCanExecuteChanged(); - } - private void StartRecording() { var captureSession = _audio.TryStartRecording(_settings.Current.WhisperModeEnabled); @@ -155,10 +152,9 @@ private async Task StopRecordingAsync() // Off the dispatcher so a large WAV or slow disk doesn't freeze the UI; // CommitRecording touches no UI state. - var recordingPath = TypeWhisperEnvironment.AudioPath; var wavBytes = wav; filePath = await Task.Run( - () => RecorderFileNamer.CommitRecording(recordingPath, DateTime.Now, wavBytes) + () => RecorderFileNamer.CommitRecording(_recordingDirectory, DateTime.Now, wavBytes) ); } catch @@ -172,7 +168,6 @@ private async Task StopRecordingAsync() IsRecording = false; OnPropertyChanged(nameof(RecordButtonText)); AudioLevel = 0; - SetStopSaveInProgress(false); } var fileName = Path.GetFileName(filePath); @@ -183,28 +178,7 @@ private async Task StopRecordingAsync() string? transcript; try { - var effectiveModelId = _settings.Current.SelectedModelId; - await using var lease = await _models.AcquireTranscriptionAsync(effectiveModelId); - try - { - var result = await lease.Plugin.TranscribeAsync( - wav, - null, - false, - null, - CancellationToken.None - ); - transcript = result.Text; - } - finally - { - // Release the model lock before writing to disk so a concurrent - // dictation isn't blocked by the file I/O that follows. - // The using-statement above will call DisposeAsync again on - // exit, but the lease is idempotent so the double-dispose is safe. - // ReSharper disable once DisposeOnUsingVariable -- intentional early release of the model lock before the file I/O below. - await lease.DisposeAsync(); - } + transcript = await _transcribeAsync(wav); } catch { @@ -213,11 +187,13 @@ private async Task StopRecordingAsync() } var transcriptWriteFailed = false; + var transcriptPersisted = false; if (!string.IsNullOrWhiteSpace(transcript)) { try { AtomicFileWrite.WriteAllText(Path.ChangeExtension(filePath, ".txt"), transcript); + transcriptPersisted = true; } catch { @@ -235,12 +211,52 @@ private async Task StopRecordingAsync() OnPropertyChanged(nameof(HasRecordings)); StatusText = transcriptWriteFailed ? Loc.Instance["Recorder.StatusTranscriptSaveFailed"] - : transcript is not null + : transcriptPersisted ? Loc.Instance["Recorder.StatusDone"] : Loc.Instance["Recorder.StatusSavedNoModel"]; DurationText = "0:00"; } + private static Func> CreateTranscriptionDelegate( + ModelManagerService models, + ISettingsService settings + ) + { + ArgumentNullException.ThrowIfNull(models); + ArgumentNullException.ThrowIfNull(settings); + return wav => TranscribeAsync(models, settings, wav); + } + + private static async Task TranscribeAsync( + ModelManagerService models, + ISettingsService settings, + byte[] wav + ) + { + var effectiveModelId = settings.Current.SelectedModelId; + await using var lease = await models.AcquireTranscriptionAsync(effectiveModelId); + try + { + var result = await lease.Plugin.TranscribeAsync( + wav, + null, + false, + null, + CancellationToken.None + ); + return result.Text; + } + finally + { + // Release the model lock before writing to disk so a concurrent + // dictation isn't blocked by the file I/O that follows. + // The using-statement above will call DisposeAsync again on + // exit, but the lease is idempotent so the double-dispose is safe. + // ReSharper disable once DisposeOnUsingVariable -- intentional early release of the model lock before the file I/O below. + await lease.DisposeAsync(); + } + } + [RelayCommand] private void DeleteRecording(RecordingItem? item) { @@ -275,14 +291,14 @@ private void LoadExistingRecordings() { try { - if (!Directory.Exists(TypeWhisperEnvironment.AudioPath)) + if (!Directory.Exists(_recordingDirectory)) { return; } foreach ( var file in Directory - .GetFiles(TypeWhisperEnvironment.AudioPath, "recording-*.wav") + .GetFiles(_recordingDirectory, "recording-*.wav") .OrderByDescending(path => path) ) { diff --git a/tests/TypeWhisper.Linux.Tests/RecorderSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/RecorderSectionViewModelTests.cs new file mode 100644 index 000000000..589e0f00c --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/RecorderSectionViewModelTests.cs @@ -0,0 +1,255 @@ +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Localization; +using TypeWhisper.Linux.ViewModels.Sections; +using TypeWhisper.Tests; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class RecorderSectionViewModelTests : IDisposable +{ + private readonly string _tempDir = TestPaths.CreateTempDirectory( + "TypeWhisper.RecorderSectionViewModelTests" + ); + + public void Dispose() + { + try + { + TestPaths.DeleteDirectory(_tempDir); + } + catch + { + // Best-effort cleanup for temp test directories. + } + } + + [Fact] + public async Task ToggleRecordingCommand_AwaitsAndSerializesCompleteWorkflow() + { + const string expectedTranscript = "A complete gated transcript."; + var transcriptionStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var releaseTranscription = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + using var audio = CreateAudioService(); + var sut = CreateViewModel( + audio, + _tempDir, + async _ => + { + transcriptionStarted.SetResult(); + await releaseTranscription.Task; + return expectedTranscript; + } + ); + await StartRecordingWithFramesAsync(sut, audio); + + var stopTask = sut.ToggleRecordingCommand.ExecuteAsync(null); + await transcriptionStarted.Task; + + try + { + Assert.False(stopTask.IsCompleted); + Assert.True(sut.ToggleRecordingCommand.IsRunning); + Assert.False(sut.ToggleRecordingCommand.CanExecute(null)); + Assert.True(sut.IsTranscribing); + Assert.Equal( + Loc.Instance["Recorder.StatusSavedTranscribing"], + sut.StatusText + ); + Assert.Single(Directory.GetFiles(_tempDir, "recording-*.wav")); + } + finally + { + releaseTranscription.TrySetResult(); + await stopTask; + } + + Assert.False(sut.ToggleRecordingCommand.IsRunning); + Assert.True(sut.ToggleRecordingCommand.CanExecute(null)); + Assert.False(sut.IsRecording); + Assert.False(sut.IsTranscribing); + Assert.Single(sut.Recordings); + Assert.Equal(Loc.Instance["Recorder.StatusDone"], sut.StatusText); + } + + [Fact] + public async Task ToggleRecordingCommand_WhenWavCommitFails_ShowsSaveFailureAndRecovers() + { + var poisonedParent = Path.Join(_tempDir, "poisoned-parent"); + await File.WriteAllTextAsync(poisonedParent, "not a directory"); + var poisonedRecordingDirectory = Path.Join(poisonedParent, "recordings"); + var transcriptionInvocations = 0; + using var audio = CreateAudioService(); + var sut = CreateViewModel( + audio, + poisonedRecordingDirectory, + _ => + { + transcriptionInvocations++; + return Task.FromResult("must not be returned"); + } + ); + await StartRecordingWithFramesAsync(sut, audio); + + var exception = await Record.ExceptionAsync( + () => sut.ToggleRecordingCommand.ExecuteAsync(null) + ); + + Assert.Null(exception); + Assert.Equal(Loc.Instance["Recorder.StatusSaveFailed"], sut.StatusText); + Assert.Empty(sut.Recordings); + Assert.False(sut.HasRecordings); + Assert.Equal(0, transcriptionInvocations); + Assert.False(sut.IsRecording); + Assert.False(sut.IsTranscribing); + Assert.Equal(0, sut.AudioLevel); + Assert.Equal("0:00", sut.DurationText); + Assert.Equal(Loc.Instance["Recorder.Record"], sut.RecordButtonText); + Assert.False(sut.ToggleRecordingCommand.IsRunning); + Assert.True(sut.ToggleRecordingCommand.CanExecute(null)); + } + + [Fact] + public async Task ToggleRecordingCommand_WhenTranscriptCommitFails_KeepsWavAndTranscript() + { + const string expectedTranscript = "Keep this transcript available for copying."; + using var audio = CreateAudioService(); + var sut = CreateViewModel( + audio, + _tempDir, + _ => + { + var wavPath = Directory.GetFiles(_tempDir, "recording-*.wav").Single(); + Directory.CreateDirectory(Path.ChangeExtension(wavPath, ".txt")); + return Task.FromResult(expectedTranscript); + } + ); + await StartRecordingWithFramesAsync(sut, audio); + + await sut.ToggleRecordingCommand.ExecuteAsync(null); + + var recording = Assert.Single(sut.Recordings); + var transcriptPath = Path.ChangeExtension(recording.FilePath, ".txt"); + Assert.True(File.Exists(recording.FilePath)); + Assert.Equal(expectedTranscript, recording.Transcript); + Assert.False(File.Exists(transcriptPath)); + Assert.True(Directory.Exists(transcriptPath)); + Assert.Equal( + Loc.Instance["Recorder.StatusTranscriptSaveFailed"], + sut.StatusText + ); + Assert.NotEqual(Loc.Instance["Recorder.StatusDone"], sut.StatusText); + Assert.False(sut.IsRecording); + Assert.False(sut.IsTranscribing); + Assert.Equal("0:00", sut.DurationText); + Assert.False(sut.ToggleRecordingCommand.IsRunning); + Assert.True(sut.ToggleRecordingCommand.CanExecute(null)); + } + + [Fact] + public async Task ToggleRecordingCommand_DoneRequiresDurableTranscript() + { + const string expectedTranscript = "The full durable transcript.\nSecond line."; + using var audio = CreateAudioService(); + var sut = CreateViewModel( + audio, + _tempDir, + _ => Task.FromResult(expectedTranscript) + ); + await StartRecordingWithFramesAsync(sut, audio); + + await sut.ToggleRecordingCommand.ExecuteAsync(null); + + var recording = Assert.Single(sut.Recordings); + var transcriptPath = Path.ChangeExtension(recording.FilePath, ".txt"); + Assert.True(File.Exists(recording.FilePath)); + Assert.Equal(expectedTranscript, recording.Transcript); + Assert.True(File.Exists(transcriptPath)); + Assert.Equal(expectedTranscript, await File.ReadAllTextAsync(transcriptPath)); + Assert.Equal(Loc.Instance["Recorder.StatusDone"], sut.StatusText); + Assert.False(sut.IsRecording); + Assert.False(sut.IsTranscribing); + Assert.Equal("0:00", sut.DurationText); + Assert.False(sut.ToggleRecordingCommand.IsRunning); + Assert.True(sut.ToggleRecordingCommand.CanExecute(null)); + } + + [Theory] + [InlineData(null)] + [InlineData(" ")] + public async Task ToggleRecordingCommand_WhenTranscriptBlankOrMissing_ReportsSavedNoModelWithoutSidecar( + string? transcript + ) + { + using var audio = CreateAudioService(); + var sut = CreateViewModel(audio, _tempDir, _ => Task.FromResult(transcript)); + await StartRecordingWithFramesAsync(sut, audio); + + await sut.ToggleRecordingCommand.ExecuteAsync(null); + + var recording = Assert.Single(sut.Recordings); + var transcriptPath = Path.ChangeExtension(recording.FilePath, ".txt"); + Assert.True(File.Exists(recording.FilePath)); + Assert.False(File.Exists(transcriptPath)); + Assert.Equal(Loc.Instance["Recorder.StatusSavedNoModel"], sut.StatusText); + Assert.NotEqual(Loc.Instance["Recorder.StatusDone"], sut.StatusText); + Assert.False(sut.IsTranscribing); + Assert.Equal("0:00", sut.DurationText); + Assert.False(sut.ToggleRecordingCommand.IsRunning); + Assert.True(sut.ToggleRecordingCommand.CanExecute(null)); + } + + private static AudioRecordingService CreateAudioService() + { + return new AudioRecordingService(_ => { }, () => 0, () => { }); + } + + private static RecorderSectionViewModel CreateViewModel( + AudioRecordingService audio, + string recordingDirectory, + Func> transcribeAsync + ) + { + return new RecorderSectionViewModel( + audio, + new FakeSettingsService(AppSettings.Default), + recordingDirectory, + transcribeAsync + ); + } + + private static async Task StartRecordingWithFramesAsync( + RecorderSectionViewModel sut, + AudioRecordingService audio + ) + { + await sut.ToggleRecordingCommand.ExecuteAsync(null); + Assert.True(sut.IsRecording); + Assert.Equal(Loc.Instance["Recorder.Stop"], sut.RecordButtonText); + audio.ProcessAudioBufferForTest([0.1f, -0.1f, 0.2f, -0.2f]); + } + + private sealed class FakeSettingsService(AppSettings current) : ISettingsService + { + public AppSettings Current { get; private set; } = current; + + public AppSettings Load() + { + return Current; + } + + public void Save(AppSettings settings) + { + Current = settings; + SettingsChanged?.Invoke(settings); + } + + public event Action? SettingsChanged; + } +} From 21a9cfee644c39f52d8c08089b4f3ba018651465 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 18:03:41 +0000 Subject: [PATCH 100/226] =?UTF-8?q?Retain=20and=20retry=20failed=20volume?= =?UTF-8?q?=20and=20media=20restoration=20with=20bounded=20passes=20(audit?= =?UTF-8?q?=20=C2=A75=20M12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/AudioDuckingService.cs | 85 +++++++-- .../Services/CommandRunner.cs | 52 ------ .../Services/MediaPauseService.cs | 104 +++++++++-- .../AudioDuckingServiceTests.cs | 157 +++++++++++++++- .../MediaPauseServiceTests.cs | 168 ++++++++++++++++++ 5 files changed, 480 insertions(+), 86 deletions(-) delete mode 100644 src/TypeWhisper.Linux/Services/CommandRunner.cs create mode 100644 tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs diff --git a/src/TypeWhisper.Linux/Services/AudioDuckingService.cs b/src/TypeWhisper.Linux/Services/AudioDuckingService.cs index 4def1ac7b..ba9ace70d 100644 --- a/src/TypeWhisper.Linux/Services/AudioDuckingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioDuckingService.cs @@ -11,7 +11,7 @@ namespace TypeWhisper.Linux.Services; /// Uses pactl — available on PipeWire via pipewire-pulse as well as /// on native PulseAudio. Silently no-ops when pactl is absent. /// -public sealed partial class AudioDuckingService : IAudioDuckingService +public sealed partial class AudioDuckingService : IAudioDuckingService, IDisposable { private const double MaximumRawVolume = 98_304d; private static readonly TimeSpan s_pactlTimeout = TimeSpan.FromMilliseconds(1500); @@ -27,12 +27,14 @@ public sealed partial class AudioDuckingService : IAudioDuckingService private static partial Regex RawVolumeRegex(); private readonly IProcessRunner _processRunner; + private readonly IErrorLogService _errorLog; private readonly Dictionary _savedVolumes = new(StringComparer.Ordinal); private bool _isDucked; - public AudioDuckingService(IProcessRunner processRunner) + public AudioDuckingService(IProcessRunner processRunner, IErrorLogService errorLog) { _processRunner = processRunner; + _errorLog = errorLog; } public void DuckAudio(float factor) @@ -66,7 +68,7 @@ public void DuckAudio(float factor) var duckedVolumes = savedVolumes .Select(volume => ScaleVolume(volume, factor)) .ToArray(); - SetSinkInputVolume(inputId, duckedVolumes); + _ = SetSinkInputVolume(inputId, duckedVolumes); } _isDucked = _savedVolumes.Count > 0; @@ -86,22 +88,35 @@ public void RestoreAudio() return; } - try + foreach (var (inputId, volumes) in _savedVolumes.ToArray()) { - foreach (var (inputId, volumes) in _savedVolumes) + try { - SetSinkInputVolume(inputId, volumes); + var result = SetSinkInputVolume(inputId, volumes); + if (result.Succeeded) + { + _savedVolumes.Remove(inputId); + continue; + } + + ReportRestoreFailure( + $"Failed to restore sink input {inputId}: {DescribeFailure(result)}" + ); + } + catch (Exception ex) + { + ReportRestoreFailure( + $"Failed to restore sink input {inputId}: exception: {ex.Message}" + ); } } - catch (Exception ex) - { - Debug.WriteLine($"[AudioDuckingService] Restore failed: {ex.Message}"); - } - finally - { - _savedVolumes.Clear(); - _isDucked = false; - } + + _isDucked = _savedVolumes.Count > 0; + } + + public void Dispose() + { + RestoreAudio(); } /// @@ -142,7 +157,7 @@ string listing } } - private void SetSinkInputVolume(string inputId, string[] volumes) + private ProcessRunResult SetSinkInputVolume(string inputId, string[] volumes) { var arguments = new List(2 + volumes.Length) { @@ -150,7 +165,7 @@ private void SetSinkInputVolume(string inputId, string[] volumes) inputId }; arguments.AddRange(volumes); - RunPactl(arguments); + return RunPactl(arguments); } private ProcessRunResult RunPactl(IReadOnlyList arguments) @@ -166,6 +181,42 @@ private ProcessRunResult RunPactl(IReadOnlyList arguments) .GetResult(); } + private void ReportRestoreFailure(string message) + { + WriteDiagnostic($"[AudioDuckingService] {message}"); + try + { + _errorLog.AddEntry(message); + } + catch (Exception ex) + { + WriteDiagnostic($"[AudioDuckingService] Error reporting failed: {ex.Message}"); + } + } + + private static string DescribeFailure(ProcessRunResult result) + { + var outcome = !result.Started + ? "process did not start (Started=false)" + : result.TimedOut + ? "process timed out (TimedOut=true)" + : $"process exited with ExitCode={result.ExitCode}"; + var error = result.StandardError.Trim(); + return string.IsNullOrWhiteSpace(error) ? outcome : $"{outcome}; error: {error}"; + } + + private static void WriteDiagnostic(string message) + { + try + { + Debug.WriteLine(message); + } + catch + { + // Restoration and retries must not depend on diagnostic output. + } + } + private static string ScaleVolume(string rawVolume, float factor) { if ( diff --git a/src/TypeWhisper.Linux/Services/CommandRunner.cs b/src/TypeWhisper.Linux/Services/CommandRunner.cs deleted file mode 100644 index 2bfc05e6d..000000000 --- a/src/TypeWhisper.Linux/Services/CommandRunner.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Diagnostics; - -namespace TypeWhisper.Linux.Services; - -/// -/// Runs a short-lived CLI tool synchronously and returns its trimmed stdout, -/// or null on any failure (couldn't start, non-zero exit, exception). -/// Forces LC_ALL=C so output is stable and parseable regardless of the -/// user's locale. -/// -/// This is a deliberately simple, fire-and-forget capture for fast helpers such -/// as playerctl and pactl. Services that need cancellation, stdin, -/// timeout reporting, or a testable seam should use -/// instead. -/// -internal static class CommandRunner -{ - public static string? Run(string fileName, params string[] arguments) - { - try - { - var psi = new ProcessStartInfo(fileName) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - foreach (var argument in arguments) - { - psi.ArgumentList.Add(argument); - } - - // Force a stable, parseable locale for command output. - psi.Environment["LC_ALL"] = "C"; - - using var process = Process.Start(psi); - if (process is null) - { - return null; - } - - var stdout = process.StandardOutput.ReadToEnd(); - process.WaitForExit(1500); - return process.ExitCode == 0 ? stdout.Trim() : null; - } - catch - { - return null; - } - } -} diff --git a/src/TypeWhisper.Linux/Services/MediaPauseService.cs b/src/TypeWhisper.Linux/Services/MediaPauseService.cs index 198009a0f..6c85b266e 100644 --- a/src/TypeWhisper.Linux/Services/MediaPauseService.cs +++ b/src/TypeWhisper.Linux/Services/MediaPauseService.cs @@ -8,10 +8,22 @@ namespace TypeWhisper.Linux.Services; /// playerctl and resumes them afterward. Silently no-ops when /// playerctl is absent or no players are currently playing. /// -public sealed class MediaPauseService : IMediaPauseService +public sealed class MediaPauseService : IMediaPauseService, IDisposable { + private static readonly TimeSpan s_playerctlTimeout = TimeSpan.FromMilliseconds(1500); + private static readonly IReadOnlyDictionary s_playerctlEnvironment = + new Dictionary(StringComparer.Ordinal) { ["LC_ALL"] = "C" }; + + private readonly IProcessRunner _processRunner; + private readonly IErrorLogService _errorLog; private readonly HashSet _pausedPlayers = new(StringComparer.OrdinalIgnoreCase); + public MediaPauseService(IProcessRunner processRunner, IErrorLogService errorLog) + { + _processRunner = processRunner; + _errorLog = errorLog; + } + public void PauseMedia() { if (_pausedPlayers.Count > 0) @@ -21,20 +33,19 @@ public void PauseMedia() try { - var players = CommandRunner.Run( - "playerctl", - "-a", - "--format", - "{{playerName}} {{status}}", - "status" + var playersResult = RunPlayerctl( + ["-a", "--format", "{{playerName}} {{status}}", "status"] ); - if (string.IsNullOrWhiteSpace(players)) + if ( + !playersResult.Succeeded + || string.IsNullOrWhiteSpace(playersResult.StandardOutput) + ) { return; } foreach ( - var line in players.Split( + var line in playersResult.StandardOutput.Split( '\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) @@ -53,7 +64,7 @@ var line in players.Split( continue; } - if (CommandRunner.Run("playerctl", "-p", parts[0], "pause") is not null) + if (RunPlayerctl(["-p", parts[0], "pause"]).Succeeded) { _pausedPlayers.Add(parts[0]); } @@ -73,20 +84,81 @@ public void ResumeMedia() return; } - try + foreach (var player in _pausedPlayers.ToArray()) { - foreach (var player in _pausedPlayers) + try + { + var result = RunPlayerctl(["-p", player, "play"]); + if (result.Succeeded) + { + _pausedPlayers.Remove(player); + continue; + } + + ReportResumeFailure( + $"Failed to resume media player {player}: {DescribeFailure(result)}" + ); + } + catch (Exception ex) { - CommandRunner.Run("playerctl", "-p", player, "play"); + ReportResumeFailure( + $"Failed to resume media player {player}: exception: {ex.Message}" + ); } } + } + + public void Dispose() + { + ResumeMedia(); + } + + private ProcessRunResult RunPlayerctl(IReadOnlyList arguments) + { + return _processRunner + .RunAsync( + "playerctl", + arguments, + environment: s_playerctlEnvironment, + timeout: s_playerctlTimeout + ) + .GetAwaiter() + .GetResult(); + } + + private void ReportResumeFailure(string message) + { + WriteDiagnostic($"[MediaPauseService] {message}"); + try + { + _errorLog.AddEntry(message); + } catch (Exception ex) { - Debug.WriteLine($"[MediaPauseService] Resume failed: {ex.Message}"); + WriteDiagnostic($"[MediaPauseService] Error reporting failed: {ex.Message}"); } - finally + } + + private static string DescribeFailure(ProcessRunResult result) + { + var outcome = !result.Started + ? "process did not start (Started=false)" + : result.TimedOut + ? "process timed out (TimedOut=true)" + : $"process exited with ExitCode={result.ExitCode}"; + var error = result.StandardError.Trim(); + return string.IsNullOrWhiteSpace(error) ? outcome : $"{outcome}; error: {error}"; + } + + private static void WriteDiagnostic(string message) + { + try { - _pausedPlayers.Clear(); + Debug.WriteLine(message); + } + catch + { + // Restoration and retries must not depend on diagnostic output. } } } diff --git a/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs index 5e2730078..d8ab62aa7 100644 --- a/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs @@ -1,3 +1,6 @@ +using Moq; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; using TypeWhisper.Linux.Services; using Xunit; @@ -21,7 +24,7 @@ Sink Input #42 fileName == "pactl" && args.SequenceEqual(["list", "sink-inputs"]), listing ); - var service = new AudioDuckingService(runner); + var service = new AudioDuckingService(runner, Mock.Of()); service.DuckAudio(0.5f); service.RestoreAudio(); @@ -61,4 +64,156 @@ Sink Input #42 invocation.Args.SequenceEqual(["set-sink-input-volume", "593", "45875"]) ); } + + [Fact] + public void RestoreAudio_retains_only_failed_vectors_for_dispose_retry() + { + const string listing = """ + Sink Input #10 + Volume: front-left: 40000 / 61% / -12.00 dB, front-right: 20000 / 31% / -25.00 dB + Sink Input #20 + Volume: front-left: 30001 / 46% / -17.00 dB, front-right: 10003 / 15% / -32.00 dB + """; + string[] list = ["list", "sink-inputs"]; + string[] firstDuck = ["set-sink-input-volume", "10", "20000", "10000"]; + string[] secondDuck = ["set-sink-input-volume", "20", "15001", "5002"]; + string[] successfulRestore = ["set-sink-input-volume", "10", "40000", "20000"]; + string[] failedRestore = ["set-sink-input-volume", "20", "30001", "10003"]; + var runner = new FakeProcessRunner(); + runner.RespondWith( + (fileName, args) => fileName == "pactl" && args.SequenceEqual(list), + listing + ); + var failedRestoreAttempts = 0; + runner.FailWhen( + (fileName, args) => + fileName == "pactl" + && args.SequenceEqual(failedRestore) + && failedRestoreAttempts++ == 0, + "restore denied" + ); + var errorLog = new Mock(); + var service = new AudioDuckingService(runner, errorLog.Object); + + service.DuckAudio(0.5f); + service.RestoreAudio(); + service.Dispose(); + service.RestoreAudio(); + + Assert.Equal(6, runner.Invocations.Count); + Assert.Equal(1, CountInvocations(runner, list)); + Assert.Equal(1, CountInvocations(runner, firstDuck)); + Assert.Equal(1, CountInvocations(runner, secondDuck)); + Assert.Equal(1, CountInvocations(runner, successfulRestore)); + Assert.Equal(2, CountInvocations(runner, failedRestore)); + Assert.All(runner.Invocations, invocation => Assert.Equal("pactl", invocation.FileName)); + Assert.All( + runner.Invocations, + invocation => Assert.Equal(TimeSpan.FromMilliseconds(1500), invocation.Timeout) + ); + Assert.DoesNotContain( + runner.Invocations, + invocation => invocation.Args.Any(argument => argument.EndsWith('%')) + ); + errorLog.Verify( + log => + log.AddEntry( + It.Is(message => + message.Contains("sink input 20", StringComparison.Ordinal) + && message.Contains("ExitCode=1", StringComparison.Ordinal) + && message.Contains("restore denied", StringComparison.Ordinal) + ), + ErrorCategory.General + ), + Times.Once + ); + errorLog.VerifyNoOtherCalls(); + } + + [Fact] + public void Dispose_retries_persistently_failed_restore_without_follow_up_call() + { + const string listing = """ + Sink Input #30 + Volume: mono: 48000 / 73% / -8.00 dB + """; + string[] list = ["list", "sink-inputs"]; + string[] duck = ["set-sink-input-volume", "30", "24000"]; + string[] restore = ["set-sink-input-volume", "30", "48000"]; + var runner = new FakeProcessRunner(); + runner.RespondWith( + (fileName, args) => fileName == "pactl" && args.SequenceEqual(list), + listing + ); + runner.FailWhen( + (fileName, args) => fileName == "pactl" && args.SequenceEqual(restore), + "persistent restore failure" + ); + var service = new AudioDuckingService(runner, Mock.Of()); + + service.DuckAudio(0.5f); + service.RestoreAudio(); + service.Dispose(); + + Assert.Equal(4, runner.Invocations.Count); + Assert.Equal(1, CountInvocations(runner, list)); + Assert.Equal(1, CountInvocations(runner, duck)); + Assert.Equal(2, CountInvocations(runner, restore)); + } + + [Fact] + public void RestoreAudio_retains_timed_out_vector_even_with_zero_exit_code() + { + const string listing = """ + Sink Input #7 + Volume: mono: 55555 / 85% / -4.00 dB + """; + string[] list = ["list", "sink-inputs"]; + string[] restore = ["set-sink-input-volume", "7", "55555"]; + var runner = new FakeProcessRunner + { + Default = new ProcessRunResult( + true, + true, + 0, + string.Empty, + "forced timeout" + ) + }; + runner.RespondWith( + (fileName, args) => fileName == "pactl" && args.SequenceEqual(list), + listing + ); + var errorLog = new Mock(); + var service = new AudioDuckingService(runner, errorLog.Object); + + service.DuckAudio(0.5f); + service.RestoreAudio(); + service.RestoreAudio(); + + Assert.Equal(2, CountInvocations(runner, restore)); + Assert.All(runner.Invocations, invocation => Assert.Equal("pactl", invocation.FileName)); + Assert.All( + runner.Invocations, + invocation => Assert.Equal(TimeSpan.FromMilliseconds(1500), invocation.Timeout) + ); + errorLog.Verify( + log => + log.AddEntry( + It.Is(message => + message.Contains("sink input 7", StringComparison.Ordinal) + && message.Contains("TimedOut=true", StringComparison.Ordinal) + && message.Contains("forced timeout", StringComparison.Ordinal) + ), + ErrorCategory.General + ), + Times.Exactly(2) + ); + errorLog.VerifyNoOtherCalls(); + } + + private static int CountInvocations(FakeProcessRunner runner, IReadOnlyList args) + { + return runner.Invocations.Count(invocation => invocation.Args.SequenceEqual(args)); + } } diff --git a/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs b/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs new file mode 100644 index 000000000..b15b85eb0 --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs @@ -0,0 +1,168 @@ +using Moq; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class MediaPauseServiceTests +{ + [Fact] + public void ResumeMedia_retains_only_failed_player_for_dispose_retry() + { + const string players = """ + vlc Playing + spotify Playing + podcast Paused + """; + string[] status = ["-a", "--format", "{{playerName}} {{status}}", "status"]; + string[] firstPause = ["-p", "vlc", "pause"]; + string[] secondPause = ["-p", "spotify", "pause"]; + string[] nonPlayingPause = ["-p", "podcast", "pause"]; + string[] successfulPlay = ["-p", "vlc", "play"]; + string[] failedPlay = ["-p", "spotify", "play"]; + var runner = new FakeProcessRunner(); + runner.RespondWith( + (fileName, args) => fileName == "playerctl" && args.SequenceEqual(status), + players + ); + var failedPlayAttempts = 0; + runner.FailWhen( + (fileName, args) => + fileName == "playerctl" + && args.SequenceEqual(failedPlay) + && failedPlayAttempts++ == 0, + "player unavailable" + ); + var errorLog = new Mock(); + var service = new MediaPauseService(runner, errorLog.Object); + + service.PauseMedia(); + service.ResumeMedia(); + service.Dispose(); + service.ResumeMedia(); + + Assert.Equal(6, runner.Invocations.Count); + Assert.Equal(status, runner.Invocations[0].Args); + Assert.Equal(1, CountInvocations(runner, status)); + Assert.Equal(1, CountInvocations(runner, firstPause)); + Assert.Equal(1, CountInvocations(runner, secondPause)); + Assert.Equal(0, CountInvocations(runner, nonPlayingPause)); + Assert.Equal(1, CountInvocations(runner, successfulPlay)); + Assert.Equal(2, CountInvocations(runner, failedPlay)); + Assert.All( + runner.Invocations, + invocation => Assert.Equal("playerctl", invocation.FileName) + ); + Assert.All( + runner.Invocations, + invocation => Assert.Equal(TimeSpan.FromMilliseconds(1500), invocation.Timeout) + ); + errorLog.Verify( + log => + log.AddEntry( + It.Is(message => + message.Contains("spotify", StringComparison.Ordinal) + && message.Contains("ExitCode=1", StringComparison.Ordinal) + && message.Contains("player unavailable", StringComparison.Ordinal) + ), + ErrorCategory.General + ), + Times.Once + ); + errorLog.VerifyNoOtherCalls(); + } + + [Fact] + public void Dispose_retries_persistently_failed_resume_without_follow_up_call() + { + const string players = "vlc Playing"; + string[] status = ["-a", "--format", "{{playerName}} {{status}}", "status"]; + string[] pause = ["-p", "vlc", "pause"]; + string[] play = ["-p", "vlc", "play"]; + var runner = new FakeProcessRunner(); + runner.RespondWith( + (fileName, args) => fileName == "playerctl" && args.SequenceEqual(status), + players + ); + runner.FailWhen( + (fileName, args) => fileName == "playerctl" && args.SequenceEqual(play), + "persistent resume failure" + ); + var service = new MediaPauseService(runner, Mock.Of()); + + service.PauseMedia(); + service.ResumeMedia(); + service.Dispose(); + + Assert.Equal(4, runner.Invocations.Count); + Assert.Equal(1, CountInvocations(runner, status)); + Assert.Equal(1, CountInvocations(runner, pause)); + Assert.Equal(2, CountInvocations(runner, play)); + } + + [Fact] + public void ResumeMedia_retains_timed_out_player_even_with_zero_exit_code() + { + const string players = "spotify Playing"; + string[] status = ["-a", "--format", "{{playerName}} {{status}}", "status"]; + string[] pause = ["-p", "spotify", "pause"]; + string[] play = ["-p", "spotify", "play"]; + var runner = new FakeProcessRunner + { + Default = new ProcessRunResult( + true, + true, + 0, + string.Empty, + "forced timeout" + ) + }; + runner.RespondWith( + (fileName, args) => fileName == "playerctl" && args.SequenceEqual(status), + players + ); + runner.RespondWith( + (fileName, args) => fileName == "playerctl" && args.SequenceEqual(pause), + string.Empty + ); + var playAttempts = 0; + runner.RespondWith( + (fileName, args) => + fileName == "playerctl" + && args.SequenceEqual(play) + && playAttempts++ > 0, + string.Empty + ); + var errorLog = new Mock(); + var service = new MediaPauseService(runner, errorLog.Object); + + service.PauseMedia(); + service.ResumeMedia(); + service.ResumeMedia(); + + Assert.Equal(4, runner.Invocations.Count); + Assert.Equal(1, CountInvocations(runner, status)); + Assert.Equal(1, CountInvocations(runner, pause)); + Assert.Equal(2, CountInvocations(runner, play)); + errorLog.Verify( + log => + log.AddEntry( + It.Is(message => + message.Contains("spotify", StringComparison.Ordinal) + && message.Contains("TimedOut=true", StringComparison.Ordinal) + && message.Contains("forced timeout", StringComparison.Ordinal) + ), + ErrorCategory.General + ), + Times.Once + ); + errorLog.VerifyNoOtherCalls(); + } + + private static int CountInvocations(FakeProcessRunner runner, IReadOnlyList args) + { + return runner.Invocations.Count(invocation => invocation.Args.SequenceEqual(args)); + } +} From fa49429d599bc903b97b184786b00a94cae24ea9 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 18:14:38 +0000 Subject: [PATCH 101/226] =?UTF-8?q?Pin=20sound-cue=20process-runner=20rout?= =?UTF-8?q?ing=20and=20timeout=20bounds=20with=20regression=20tests=20(aud?= =?UTF-8?q?it=20=C2=A75=20M13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SoundFeedbackServiceTests.cs | 194 +++++++++++++++--- 1 file changed, 169 insertions(+), 25 deletions(-) diff --git a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs index 7dbbcc25e..cf0ddd7ee 100644 --- a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; using TypeWhisper.Linux.Services; using Xunit; @@ -61,39 +63,175 @@ public async Task Start_cue_runner_exception_remains_best_effort() Assert.Single(runner.Invocations); } + [Theory] + [InlineData("stop.wav")] + [InlineData("success.wav")] + [InlineData("error.wav")] + public async Task Fire_and_forget_cues_use_real_argv_and_finite_timeout_without_waiting_for_runner( + string cueFileName + ) + { + using var sounds = new TemporarySoundsDirectory(); + var runner = new ControlledProcessRunner(); + var sut = new SoundFeedbackService(runner, "fake-player", sounds.Path); + + var call = Task.Run(() => InvokeFireAndForgetCue(sut, cueFileName)); + await runner.Invoked.Task.WaitAsync(s_testGuard); + await call.WaitAsync(s_testGuard); + + Assert.False(runner.Completion.IsCompleted); + var invocation = Assert.Single(runner.Invocations); + Assert.Equal("fake-player", invocation.FileName); + Assert.Equal([sounds.PathFor(cueFileName)], invocation.Args); + Assert.Equal(SoundFeedbackService.s_startCueTimeout, invocation.Timeout); + Assert.Equal(TimeSpan.FromSeconds(2), invocation.Timeout); + Assert.Null(invocation.StandardInput); + + runner.Complete(new ProcessRunResult(true, false, 0, "", "")); + await runner.Completion.WaitAsync(s_testGuard); + } + + [Theory] + [InlineData("stop.wav", RunnerOutcome.NotStarted)] + [InlineData("stop.wav", RunnerOutcome.TimedOut)] + [InlineData("stop.wav", RunnerOutcome.Exception)] + [InlineData("success.wav", RunnerOutcome.NotStarted)] + [InlineData("success.wav", RunnerOutcome.TimedOut)] + [InlineData("success.wav", RunnerOutcome.Exception)] + [InlineData("error.wav", RunnerOutcome.NotStarted)] + [InlineData("error.wav", RunnerOutcome.TimedOut)] + [InlineData("error.wav", RunnerOutcome.Exception)] + public async Task Fire_and_forget_cue_failures_remain_best_effort( + string cueFileName, + RunnerOutcome outcome + ) + { + using var sounds = new TemporarySoundsDirectory(); + var runner = outcome switch + { + RunnerOutcome.NotStarted => ControlledProcessRunner.WithImmediateResult( + new ProcessRunResult(false, false, -1, "", "launch failed") + ), + RunnerOutcome.TimedOut => ControlledProcessRunner.WithImmediateResult( + new ProcessRunResult(true, true, -1, "", "") + ), + RunnerOutcome.Exception => ControlledProcessRunner.WithException( + new InvalidOperationException("fake runner failure") + ), + _ => throw new ArgumentOutOfRangeException(nameof(outcome), outcome, null) + }; + var sut = new SoundFeedbackService(runner, "fake-player", sounds.Path); + + var call = Task.Run(() => InvokeFireAndForgetCue(sut, cueFileName)); + await call.WaitAsync(s_testGuard); + + Assert.True(runner.Completion.IsCompleted); + Assert.Single(runner.Invocations); + } + + [Fact] + public void Source_has_no_direct_process_path_and_observes_every_fire_and_forget_task() + { + var source = File.ReadAllText(SoundFeedbackServiceSourcePath()); + string[] directProcessPatterns = + [ + @"\bProcess\s*\.\s*Start\b", + @"\bProcessStartInfo\b", + @"\bWaitForExit(?:Async)?\b", + @"\bnew\s+Process\s*\(" + ]; + + foreach (var pattern in directProcessPatterns) + { + Assert.False( + Regex.IsMatch(source, pattern, RegexOptions.CultureInvariant), + $"SoundFeedbackService.cs contains a forbidden direct process path: {pattern}" + ); + } + + AssertObservedCue(source, nameof(SoundFeedbackService.PlayRecordingStopped), "stop.wav"); + AssertObservedCue(source, nameof(SoundFeedbackService.PlaySuccess), "success.wav"); + AssertObservedCue(source, nameof(SoundFeedbackService.PlayError), "error.wav"); + } + + private static void InvokeFireAndForgetCue(SoundFeedbackService sut, string cueFileName) + { + switch (cueFileName) + { + case "stop.wav": + sut.PlayRecordingStopped(); + break; + case "success.wav": + sut.PlaySuccess(); + break; + case "error.wav": + sut.PlayError(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(cueFileName), cueFileName, null); + } + } + + private static void AssertObservedCue(string source, string methodName, string cueFileName) + { + var pattern = + $@"public\s+void\s+{Regex.Escape(methodName)}\s*\(\s*\)\s*\{{\s*" + + $@"Observe\s*\(\s*PlayAsync\s*\(\s*""{Regex.Escape(cueFileName)}""\s*,\s*" + + @"s_startCueTimeout\s*\)\s*\)\s*;\s*\}"; + + Assert.True( + Regex.IsMatch(source, pattern, RegexOptions.CultureInvariant), + $"{methodName} must pass its {cueFileName} PlayAsync task to Observe." + ); + } + + private static string SoundFeedbackServiceSourcePath([CallerFilePath] string thisFile = "") + { + var testDir = Path.GetDirectoryName(thisFile)!; + return Path.GetFullPath( + Path.Join( + testDir, + "..", + "..", + "src", + "TypeWhisper.Linux", + "Services", + "SoundFeedbackService.cs" + ) + ); + } + + public enum RunnerOutcome + { + NotStarted, + TimedOut, + Exception + } + private sealed class ControlledProcessRunner : IProcessRunner { private readonly TaskCompletionSource _completion = new( TaskCreationOptions.RunContinuationsAsynchronously ); - private readonly Exception? _exception; - private readonly ProcessRunResult? _immediateResult; public TaskCompletionSource Invoked { get; } = new( TaskCreationOptions.RunContinuationsAsynchronously ); public List Invocations { get; } = []; - - public ControlledProcessRunner() { } - - private ControlledProcessRunner(ProcessRunResult immediateResult) - { - _immediateResult = immediateResult; - } - - private ControlledProcessRunner(Exception exception) - { - _exception = exception; - } + public Task Completion => _completion.Task; public static ControlledProcessRunner WithImmediateResult(ProcessRunResult result) { - return new ControlledProcessRunner(result); + var runner = new ControlledProcessRunner(); + runner.Complete(result); + return runner; } public static ControlledProcessRunner WithException(Exception exception) { - return new ControlledProcessRunner(exception); + var runner = new ControlledProcessRunner(); + runner.Fail(exception); + return runner; } public Task RunAsync( @@ -107,20 +245,18 @@ public Task RunAsync( { Invocations.Add(new Invocation(fileName, args.ToArray(), standardInput, timeout)); Invoked.TrySetResult(); - if (_exception is not null) - { - return Task.FromException(_exception); - } - - return _immediateResult is not null - ? Task.FromResult(_immediateResult) - : _completion.Task; + return _completion.Task; } public void Complete(ProcessRunResult result) { _completion.TrySetResult(result); } + + private void Fail(Exception exception) + { + _completion.TrySetException(exception); + } } private sealed record Invocation( @@ -140,12 +276,20 @@ public TemporarySoundsDirectory() ); Directory.CreateDirectory(Path); StartWavPath = System.IO.Path.Join(Path, "start.wav"); - File.WriteAllBytes(StartWavPath, "RIFF"u8); + foreach (var fileName in new[] { "start.wav", "stop.wav", "success.wav", "error.wav" }) + { + File.WriteAllBytes(PathFor(fileName), "RIFF"u8); + } } public string Path { get; } public string StartWavPath { get; } + public string PathFor(string fileName) + { + return System.IO.Path.Join(Path, fileName); + } + public void Dispose() { Directory.Delete(Path, recursive: true); From f877dd5e5d87351095be945fffa56aaa36927f86 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 18:37:24 +0000 Subject: [PATCH 102/226] =?UTF-8?q?Materialize=20growing=20WAV=20snapshots?= =?UTF-8?q?=20outside=20the=20realtime=20sample=20lock=20(audit=20=C2=A75?= =?UTF-8?q?=20M5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/AudioRecordingService.cs | 77 ++++++---- .../AudioRecordingServiceTests.cs | 140 ++++++++++++++++++ 2 files changed, 191 insertions(+), 26 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs index c44a7d80b..71e2e9af5 100644 --- a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs @@ -32,6 +32,12 @@ private sealed record LiveFrameSubscription( Action Sink ); + private sealed record RecordedAudioSnapshot( + float[][] Chunks, + int SampleCount, + int CaptureSampleRate + ); + private const int SampleRate = 16000; private const int Channels = 1; private const uint FramesPerBuffer = 512; @@ -54,6 +60,7 @@ Action Sink private readonly Lock _sampleLock = new(); private readonly Action _stopAndDisposeInputStreamCore; private readonly bool _terminatePortAudioOnDispose; + private readonly Action? _wavMaterializationObserver; private AudioCaptureSession? _activeCaptureSession; private long _captureSessionGeneration; private float _currentRmsLevel; @@ -88,6 +95,7 @@ public AudioRecordingService(IErrorLogService? errorLog = null) _openInputStream = OpenInputStream; _stopAndDisposeInputStreamCore = StopAndDisposeInputStreamCore; _terminatePortAudioOnDispose = true; + _wavMaterializationObserver = null; } // Test seam: exercises the production device-selection and ownership state machines @@ -96,14 +104,16 @@ internal AudioRecordingService( Action openInputStream, Func defaultInputDeviceIndexProvider, Action stopAndDisposeInputStream, - IErrorLogService? errorLog = null + IErrorLogService? errorLog = null, + Action? wavMaterializationObserver = null ) : this( static () => [], openInputStream, defaultInputDeviceIndexProvider, stopAndDisposeInputStream, - errorLog + errorLog, + wavMaterializationObserver ) { } @@ -115,7 +125,8 @@ internal AudioRecordingService( Action openInputStream, Func defaultInputDeviceIndexProvider, Action stopAndDisposeInputStream, - IErrorLogService? errorLog = null + IErrorLogService? errorLog = null, + Action? wavMaterializationObserver = null ) { _errorLog = errorLog; @@ -125,6 +136,7 @@ internal AudioRecordingService( _openInputStream = openInputStream; _stopAndDisposeInputStreamCore = stopAndDisposeInputStream; _terminatePortAudioOnDispose = false; + _wavMaterializationObserver = wavMaterializationObserver; } public bool IsRecording => Volatile.Read(ref _isRecording) == 1; @@ -319,7 +331,7 @@ internal byte[] StopRecording(AudioCaptureSession session) // Keep the capture lock through materialization. A new owner cannot // clear or reuse the sample list until this WAV is complete. - return BuildWavFromRecordedAudio(); + return BuildWavFromRecordedAudio(SnapshotRecordedAudio()); } } @@ -356,15 +368,13 @@ internal async Task StopRecordingAsync( return null; } - lock (_sampleLock) + var snapshot = SnapshotRecordedAudio(); + if (snapshot.SampleCount == 0) { - if (_sampleCount == 0) - { - return null; - } + return null; } - return BuildWavFromRecordedAudio(); + return BuildWavFromRecordedAudio(snapshot); } } @@ -898,29 +908,44 @@ private static byte[] FloatSamplesToWav(float[] samples, int sampleRate) ); } - private byte[] BuildWavFromRecordedAudio() + private RecordedAudioSnapshot SnapshotRecordedAudio() { lock (_sampleLock) { - var samples = new float[_sampleCount]; - var offset = 0; - foreach (var chunk in _sampleChunks) - { - Array.Copy(chunk, 0, samples, offset, chunk.Length); - offset += chunk.Length; - } - - var outputSamples = ResampleToSampleRate(samples, CaptureSampleRate, SampleRate); - Trace.WriteLine( - $"[AudioRecordingService] Finalized WAV: capturedSamples={samples.Length} @ {CaptureSampleRate} Hz " - + $"({samples.Length / (double)CaptureSampleRate:F2}s real-time), " - + $"outputSamples={outputSamples.Length} @ {SampleRate} Hz " - + $"({outputSamples.Length / (double)SampleRate:F2}s tagged)." + return new RecordedAudioSnapshot( + _sampleChunks.ToArray(), + _sampleCount, + CaptureSampleRate ); - return FloatSamplesToWav(outputSamples, SampleRate); } } + private byte[] BuildWavFromRecordedAudio(RecordedAudioSnapshot snapshot) + { + _wavMaterializationObserver?.Invoke(_sampleLock.IsHeldByCurrentThread); + + var samples = new float[snapshot.SampleCount]; + var offset = 0; + foreach (var chunk in snapshot.Chunks) + { + Array.Copy(chunk, 0, samples, offset, chunk.Length); + offset += chunk.Length; + } + + var outputSamples = ResampleToSampleRate( + samples, + snapshot.CaptureSampleRate, + SampleRate + ); + Trace.WriteLine( + $"[AudioRecordingService] Finalized WAV: capturedSamples={samples.Length} @ {snapshot.CaptureSampleRate} Hz " + + $"({samples.Length / (double)snapshot.CaptureSampleRate:F2}s real-time), " + + $"outputSamples={outputSamples.Length} @ {SampleRate} Hz " + + $"({outputSamples.Length / (double)SampleRate:F2}s tagged)." + ); + return FloatSamplesToWav(outputSamples, SampleRate); + } + private static byte[] WriteWav( int sampleRate, int sampleCount, diff --git a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs index a7f7917a0..85176f206 100644 --- a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs @@ -1,3 +1,5 @@ +using System.Buffers.Binary; +using System.Text; using PortAudioSharp; using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; @@ -59,6 +61,110 @@ public void ResampleToSampleRate_ReturnsSameArrayWhenRateAlreadyMatches() Assert.Same(samples, processed); } + [Fact] + public void GetCurrentBuffer_GrowingSnapshotsPreserveEverySampleInOrder() + { + using var service = new AudioRecordingService(_ => { }, () => 0, () => { }); + var session = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + var firstFrame = new[] { 0.25f, -0.5f, 0.75f }; + var secondFrame = new[] { -1f, 0.125f }; + var lateFrame = new[] { 0.5f, -0.25f, 1f, -0.125f }; + + service.ProcessAudioBufferForTest(firstFrame); + service.ProcessAudioBufferForTest(secondFrame); + var snapshotA = Assert.IsType(service.GetCurrentBuffer(session)); + var expectedA = ToPcm16(firstFrame, secondFrame); + var pcmA = AssertPcm16Wav(snapshotA, expectedA); + + service.ProcessAudioBufferForTest(lateFrame); + var snapshotB = Assert.IsType(service.GetCurrentBuffer(session)); + var expectedLate = ToPcm16(lateFrame); + var expectedB = expectedA.Concat(expectedLate).ToArray(); + var pcmB = AssertPcm16Wav(snapshotB, expectedB); + + Assert.Equal(pcmA, pcmB[..pcmA.Length]); + Assert.Equal(expectedLate, pcmB[pcmA.Length..]); + + var finalWav = service.StopRecording(session); + Assert.Equal(snapshotB, finalWav); + } + + [Fact] + public void GetCurrentBuffer_MaterializationStartsWithoutHoldingSampleLock() + { + var observerInvoked = false; + var sampleLockHeld = true; + using var service = new AudioRecordingService( + _ => { }, + () => 0, + () => { }, + wavMaterializationObserver: isSampleLockHeld => + { + observerInvoked = true; + sampleLockHeld = isSampleLockHeld; + } + ); + var session = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + service.ProcessAudioBufferForTest([0.25f, -0.5f, 0.75f]); + + Assert.NotNull(service.GetCurrentBuffer(session)); + + Assert.True(observerInvoked); + Assert.False(sampleLockHeld); + } + + [Fact] + public void GetCurrentBuffer_CallbackAtSnapshotBoundaryAppearsInNextSnapshotExactlyOnce() + { + var shouldInjectLateFrame = true; + var injectionCount = 0; + AudioRecordingService? recorder = null; + var prefixFrame = new[] { 0.1f, -0.3f, 0.7f }; + var secondPrefixFrame = new[] { -0.2f, 0.4f }; + var lateFrame = new[] { -0.9f, 0.6f, -0.1f, 0.3f }; + using var service = new AudioRecordingService( + _ => { }, + () => 0, + () => { }, + // ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local -- asserting the lock is not held is the point of this seam + wavMaterializationObserver: isSampleLockHeld => + { + Assert.False(isSampleLockHeld); + if (!shouldInjectLateFrame) + { + return; + } + + shouldInjectLateFrame = false; + injectionCount++; + // ReSharper disable once AccessToModifiedClosure -- deliberate late binding: the service reference exists only after construction + recorder!.ProcessAudioBufferForTest(lateFrame); + } + ); + recorder = service; + var session = Assert.IsType( + service.TryStartRecording(whisperModeEnabled: false) + ); + service.ProcessAudioBufferForTest(prefixFrame); + service.ProcessAudioBufferForTest(secondPrefixFrame); + var expectedPrefix = ToPcm16(prefixFrame, secondPrefixFrame); + + var firstSnapshot = Assert.IsType(service.GetCurrentBuffer(session)); + AssertPcm16Wav(firstSnapshot, expectedPrefix); + + var expectedComplete = expectedPrefix.Concat(ToPcm16(lateFrame)).ToArray(); + var secondSnapshot = Assert.IsType(service.GetCurrentBuffer(session)); + AssertPcm16Wav(secondSnapshot, expectedComplete); + + var finalWav = service.StopRecording(session); + AssertPcm16Wav(finalWav, expectedComplete); + Assert.Equal(1, injectionCount); + } + [Fact] public void LiveFrameSink_InvokedFromCallback_WithProcessedSamples() { @@ -566,6 +672,40 @@ List operations ); } + private static short[] ToPcm16(params float[][] frames) + { + return frames + .SelectMany(frame => frame) + .Select(AudioRecordingService.ToPcm16) + .ToArray(); + } + + private static short[] AssertPcm16Wav(byte[] wav, short[] expectedSamples) + { + var expectedDataSize = expectedSamples.Length * sizeof(short); + Assert.Equal(44 + expectedDataSize, wav.Length); + Assert.Equal("RIFF", Encoding.ASCII.GetString(wav, 0, 4)); + Assert.Equal(36 + expectedDataSize, BinaryPrimitives.ReadInt32LittleEndian(wav.AsSpan(4, 4))); + Assert.Equal("WAVE", Encoding.ASCII.GetString(wav, 8, 4)); + Assert.Equal("fmt ", Encoding.ASCII.GetString(wav, 12, 4)); + Assert.Equal(16, BinaryPrimitives.ReadInt32LittleEndian(wav.AsSpan(16, 4))); + Assert.Equal(1, BinaryPrimitives.ReadInt16LittleEndian(wav.AsSpan(20, 2))); + Assert.Equal(1, BinaryPrimitives.ReadInt16LittleEndian(wav.AsSpan(22, 2))); + Assert.Equal(16000, BinaryPrimitives.ReadInt32LittleEndian(wav.AsSpan(24, 4))); + Assert.Equal(32000, BinaryPrimitives.ReadInt32LittleEndian(wav.AsSpan(28, 4))); + Assert.Equal(2, BinaryPrimitives.ReadInt16LittleEndian(wav.AsSpan(32, 2))); + Assert.Equal(16, BinaryPrimitives.ReadInt16LittleEndian(wav.AsSpan(34, 2))); + Assert.Equal("data", Encoding.ASCII.GetString(wav, 36, 4)); + Assert.Equal(expectedDataSize, BinaryPrimitives.ReadInt32LittleEndian(wav.AsSpan(40, 4))); + + var actualSamples = Enumerable + .Range(0, expectedSamples.Length) + .Select(i => BinaryPrimitives.ReadInt16LittleEndian(wav.AsSpan(44 + i * 2, 2))) + .ToArray(); + Assert.Equal(expectedSamples, actualSamples); + return actualSamples; + } + private sealed class FakeSettingsService(AppSettings current) : ISettingsService { public int SaveCount { get; private set; } From 2e4a2cc14d3ba520f4e31d344cd65bcb2ba1fe97 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 18:52:03 +0000 Subject: [PATCH 103/226] =?UTF-8?q?Make=20speech=20playback=20ownership=20?= =?UTF-8?q?publication=20atomic=20and=20release=20hung-provider=20requests?= =?UTF-8?q?=20(audit=20=C2=A75=20M8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/SpeechFeedbackService.cs | 48 ++- .../SpeechFeedbackServiceTests.cs | 301 +++++++++++++++++- 2 files changed, 331 insertions(+), 18 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs b/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs index c70a2190e..2da404d2c 100644 --- a/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs +++ b/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs @@ -64,6 +64,7 @@ public void Complete() private readonly Func _delay; private readonly Lock _lock = new(); + private readonly Action? _playbackVersionAllocated; private readonly PluginManager _pluginManager; private readonly ISettingsService _settings; @@ -93,13 +94,15 @@ internal SpeechFeedbackService( ISettingsService settings, PluginManager pluginManager, ITtsProviderPlugin systemProvider, - Func? delay = null + Func? delay = null, + Action? playbackVersionAllocated = null ) { _settings = settings; _pluginManager = pluginManager; _systemProvider = systemProvider; _delay = delay ?? Task.Delay; + _playbackVersionAllocated = playbackVersionAllocated; _pluginManager.PluginStateChanged += OnPluginStateChanged; } @@ -269,14 +272,18 @@ await WaitForCompletionAsync(request, s_recordingAnnouncementTimeout) } request.CancelAndStop(); + ReleasePlaybackOwnership(request); _ = await WaitForCompletionAsync(request, s_stopPlaybackTimeout) .ConfigureAwait(false); + request.Complete(); } catch (Exception ex) { // Spoken feedback is optional; a failed timeout wait or provider // completion must not leave the request's session unstopped. request.CancelAndStop(); + ReleasePlaybackOwnership(request); + request.Complete(); Debug.WriteLine($"SpeechFeedback recording announcement error: {ex.Message}"); } } @@ -335,21 +342,31 @@ private void SpeakCore( request = ApplyConfiguredLanguageFallback(request); } - Stop(); - - var version = Interlocked.Increment(ref _playbackVersion); - var playbackRequest = new PlaybackRequest(version); + PlaybackRequest? supersededRequest; + PlaybackRequest playbackRequest; lock (_lock) { + supersededRequest = _playbackRequest; + var version = AllocatePlaybackVersion(); + playbackRequest = new PlaybackRequest(version); _playbackRequest = playbackRequest; + _playbackSession = null; _isPlaybackPending = true; } + supersededRequest?.CancelAndStop(); _ = SpeakAsync(request, playbackRequest); return playbackRequest; } + private long AllocatePlaybackVersion() + { + var version = Interlocked.Increment(ref _playbackVersion); + _playbackVersionAllocated?.Invoke(version, _lock.IsHeldByCurrentThread); + return version; + } + // When a transcription / manual-readback request carries no language, fall // back to the configured app language so the TTS provider speaks it in the // expected language rather than guessing. Ported from upstream 552ad88. @@ -412,6 +429,13 @@ PlaybackRequest playbackRequest } } + if (!accepted) + { + playbackRequest.CancelAndStop(); + ClearPending(playbackRequest); + return; + } + EventHandler? completedHandler = null; completedHandler = (_, _) => { @@ -420,11 +444,6 @@ PlaybackRequest playbackRequest }; session.Completed += completedHandler; - if (!accepted) - { - playbackRequest.CancelAndStop(); - } - if (!session.IsActive) { session.Completed -= completedHandler; @@ -465,6 +484,12 @@ PlaybackRequest playbackRequest } private void ClearPending(PlaybackRequest playbackRequest) + { + ReleasePlaybackOwnership(playbackRequest); + playbackRequest.Complete(); + } + + private void ReleasePlaybackOwnership(PlaybackRequest playbackRequest) { lock (_lock) { @@ -474,11 +499,10 @@ private void ClearPending(PlaybackRequest playbackRequest) ) { _playbackRequest = null; + _playbackSession = null; _isPlaybackPending = false; } } - - playbackRequest.Complete(); } private PlaybackRequest? StopPlayback() diff --git a/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs index 0c011446c..b384b9941 100644 --- a/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using TypeWhisper.Core.Models; using TypeWhisper.Linux.Services; using TypeWhisper.PluginSDK; @@ -292,6 +293,212 @@ public async Task Older_completion_cannot_clear_or_complete_newer_request() await Task.WhenAll(newerAnnouncement, stopNewer).WaitAsync(s_testGuard); } + [Fact] + public async Task Concurrent_starts_serialize_version_allocation_and_publication() + { + var settings = TestPluginManagerFactory.CreateSettings( + new AppSettings { SpokenFeedbackEnabled = true } + ); + var manager = TestPluginManagerFactory.Create(); + var provider = new ControlledTtsProvider(controlResponses: true); + var allocations = new ConcurrentQueue<(long Version, bool LockHeld)>(); + var firstAllocationReached = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var releaseFirstAllocation = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + using var sut = new SpeechFeedbackService( + settings.Object, + manager, + provider, + playbackVersionAllocated: (version, lockHeld) => + { + allocations.Enqueue((version, lockHeld)); + if (version != 1) + { + return; + } + + firstAllocationReached.TrySetResult(); + releaseFirstAllocation.Task.WaitAsync(s_testGuard).GetAwaiter().GetResult(); + } + ); + + var olderStart = Task.Run(() => + // ReSharper disable once AccessToDisposedClosure -- Task.WhenAll below awaits completion before sut disposal + sut.SpeakAutomaticTranscription("older playback") + ); + await firstAllocationReached.Task.WaitAsync(s_testGuard); + + var newerStartAttempted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var newerStart = Task.Run(() => + { + newerStartAttempted.TrySetResult(); + // ReSharper disable once AccessToDisposedClosure -- Task.WhenAll below awaits completion before sut disposal + sut.SpeakAutomaticTranscription("newer playback"); + }); + await newerStartAttempted.Task.WaitAsync(s_testGuard); + + releaseFirstAllocation.TrySetResult(); + await Task.WhenAll(olderStart, newerStart).WaitAsync(s_testGuard); + + Assert.Equal([(1, true), (2, true)], allocations.ToArray()); + + var firstCall = await provider.NextRequestAsync(); + var secondCall = await provider.NextRequestAsync(); + var calls = new[] { firstCall, secondCall }.ToDictionary( + call => call.Request.Text + ); + var olderCall = calls["older playback"]; + var newerCall = calls["newer playback"]; + Assert.True(olderCall.CancellationToken.IsCancellationRequested); + Assert.False(newerCall.CancellationToken.IsCancellationRequested); + + var newerSession = new ControlledPlaybackSession(); + newerCall.Return(newerSession); + await newerSession.HandlerAttached.Task.WaitAsync(s_testGuard); + + var olderSession = new ControlledPlaybackSession(); + olderCall.Return(olderSession); + await olderSession.StopCalled.Task.WaitAsync(s_testGuard); + + Assert.Equal(0, olderSession.SubscriberCount); + Assert.True(newerSession.IsActive); + Assert.Equal(0, newerSession.StopCount); + sut.ReadBack("toggle newer playback"); + Assert.Equal(1, newerSession.StopCount); + Assert.Equal(2, provider.Requests.Length); + + newerSession.Complete(); + + sut.ReadBack("subsequent readback"); + var readBackCall = await provider.NextRequestAsync(); + Assert.Equal("subsequent readback", readBackCall.Request.Text); + Assert.Equal(TtsPurpose.ManualReadback, readBackCall.Request.Purpose); + + var readBackSession = new ControlledPlaybackSession(); + readBackCall.Return(readBackSession); + await readBackSession.HandlerAttached.Task.WaitAsync(s_testGuard); + + olderSession.Complete(); + sut.ReadBack("toggle current readback"); + + Assert.Equal(1, readBackSession.StopCount); + Assert.Equal(3, provider.Requests.Length); + readBackSession.Complete(); + } + + [Fact] + public async Task Recording_timeout_releases_late_cancellation_ignoring_request() + { + var settings = TestPluginManagerFactory.CreateSettings( + new AppSettings { SpokenFeedbackEnabled = true } + ); + var manager = TestPluginManagerFactory.Create(); + var provider = new ControlledTtsProvider(controlResponses: true); + var delay = new ControlledDelay(); + using var sut = new SpeechFeedbackService( + settings.Object, + manager, + provider, + delay.WaitAsync + ); + + var announcement = sut.AnnounceRecordingStartedAsync( + spokenFeedbackEnabled: true + ); + var announcementCall = await provider.NextRequestAsync(); + var announcementTimeout = await delay.NextRequestAsync(); + + Assert.Equal( + SpeechFeedbackService.s_recordingAnnouncementTimeout, + announcementTimeout.Duration + ); + announcementTimeout.Complete(); + + var cleanupTimeout = await delay.NextRequestAsync(); + Assert.Equal( + SpeechFeedbackService.s_stopPlaybackTimeout, + cleanupTimeout.Duration + ); + Assert.True(announcementCall.CancellationToken.IsCancellationRequested); + + var lateSession = new ControlledPlaybackSession(); + announcementCall.Return(lateSession); + await lateSession.StopCalled.Task.WaitAsync(s_testGuard); + Assert.Equal(1, lateSession.StopCount); + Assert.Equal(0, lateSession.SubscriberCount); + + cleanupTimeout.Complete(); + await announcement.WaitAsync(s_testGuard); + + sut.ReadBack("manual readback"); + var readBackCall = await provider.NextRequestAsync(); + Assert.Equal("manual readback", readBackCall.Request.Text); + Assert.Equal(TtsPurpose.ManualReadback, readBackCall.Request.Purpose); + + var readBackSession = new ControlledPlaybackSession(); + readBackCall.Return(readBackSession); + await readBackSession.HandlerAttached.Task.WaitAsync(s_testGuard); + + lateSession.Complete(); + readBackSession.Complete(); + } + + [Fact] + public async Task Recording_timeout_releases_hung_provider_request() + { + var settings = TestPluginManagerFactory.CreateSettings( + new AppSettings { SpokenFeedbackEnabled = true } + ); + var manager = TestPluginManagerFactory.Create(); + var provider = new ControlledTtsProvider(controlResponses: true); + var delay = new ControlledDelay(); + using var sut = new SpeechFeedbackService( + settings.Object, + manager, + provider, + delay.WaitAsync + ); + + var announcement = sut.AnnounceRecordingStartedAsync( + spokenFeedbackEnabled: true + ); + var announcementCall = await provider.NextRequestAsync(); + var announcementTimeout = await delay.NextRequestAsync(); + + Assert.Equal( + SpeechFeedbackService.s_recordingAnnouncementTimeout, + announcementTimeout.Duration + ); + announcementTimeout.Complete(); + + var cleanupTimeout = await delay.NextRequestAsync(); + Assert.Equal( + SpeechFeedbackService.s_stopPlaybackTimeout, + cleanupTimeout.Duration + ); + Assert.True(announcementCall.CancellationToken.IsCancellationRequested); + cleanupTimeout.Complete(); + await announcement.WaitAsync(s_testGuard); + + Assert.Single(provider.Requests); + sut.ReadBack("manual readback after hung announcement"); + Assert.Equal(2, provider.Requests.Length); + + var readBackCall = await provider.NextRequestAsync(); + Assert.Equal("manual readback after hung announcement", readBackCall.Request.Text); + Assert.Equal(TtsPurpose.ManualReadback, readBackCall.Request.Purpose); + + var readBackSession = new ControlledPlaybackSession(); + readBackCall.Return(readBackSession); + await readBackSession.HandlerAttached.Task.WaitAsync(s_testGuard); + readBackSession.Complete(); + } + private sealed class ControlledDelay { private readonly Queue _requests = new(); @@ -332,10 +539,25 @@ public void Complete() } } - private sealed class ControlledTtsProvider(params ITtsPlaybackSession[] sessions) - : ITtsProviderPlugin + private sealed class ControlledTtsProvider : ITtsProviderPlugin { - private readonly Queue _sessions = new(sessions); + private readonly bool _controlResponses; + private readonly Lock _sync = new(); + private readonly Queue _calls = new(); + private readonly SemaphoreSlim _callAvailable = new(0); + private readonly List _requests = []; + private readonly Queue _sessions; + + public ControlledTtsProvider(params ITtsPlaybackSession[] sessions) + { + _sessions = new Queue(sessions); + } + + public ControlledTtsProvider(bool controlResponses) + { + _controlResponses = controlResponses; + _sessions = new Queue(); + } public string PluginId => "plugin.controlled"; public string PluginName => "Controlled"; @@ -345,7 +567,16 @@ private sealed class ControlledTtsProvider(params ITtsPlaybackSession[] sessions public bool IsConfigured => true; public IReadOnlyList AvailableVoices => []; public string? SelectedVoiceId => null; - public List Requests { get; } = []; + public TtsSpeakRequest[] Requests + { + get + { + lock (_sync) + { + return _requests.ToArray(); + } + } + } public Task ActivateAsync(IPluginHostServices host) { @@ -364,13 +595,57 @@ public Task SpeakAsync( CancellationToken ct ) { - Requests.Add(request); - return Task.FromResult(_sessions.Dequeue()); + ControlledProviderCall call; + ITtsPlaybackSession? session = null; + lock (_sync) + { + _requests.Add(request); + call = new ControlledProviderCall(request, ct); + _calls.Enqueue(call); + if (!_controlResponses) + { + session = _sessions.Dequeue(); + } + } + + _callAvailable.Release(); + if (session is not null) + { + call.Return(session); + } + + return call.Session.Task; + } + + public async Task NextRequestAsync() + { + await _callAvailable.WaitAsync().WaitAsync(s_testGuard); + lock (_sync) + { + return _calls.Dequeue(); + } } public void Dispose() { } } + private sealed class ControlledProviderCall( + TtsSpeakRequest request, + CancellationToken cancellationToken + ) + { + public TtsSpeakRequest Request { get; } = request; + public CancellationToken CancellationToken { get; } = cancellationToken; + public TaskCompletionSource Session { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + public void Return(ITtsPlaybackSession session) + { + Session.TrySetResult(session); + } + } + private sealed class ControlledPlaybackSession(bool completeOnStop = false) : ITtsPlaybackSession { @@ -391,9 +666,22 @@ public bool IsActive } public int StopCount => Volatile.Read(ref _stopCount); + public int SubscriberCount + { + get + { + lock (_sync) + { + return _completed?.GetInvocationList().Length ?? 0; + } + } + } public TaskCompletionSource HandlerAttached { get; } = new( TaskCreationOptions.RunContinuationsAsynchronously ); + public TaskCompletionSource StopCalled { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); public event EventHandler? Completed { @@ -435,6 +723,7 @@ public event EventHandler? Completed public void Stop() { Interlocked.Increment(ref _stopCount); + StopCalled.TrySetResult(); if (completeOnStop) { Complete(); From 504f21f2ba2f51518037c3f19bb6a5b1623b2eba Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 22:22:29 +0000 Subject: [PATCH 104/226] =?UTF-8?q?Carry=20processing=20and=20terminal=20d?= =?UTF-8?q?ictation=20feedback=20through=20the=20tiling-WM=20notification?= =?UTF-8?q?=20(audit=20=C2=A75=20M10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/DictationOrchestrator.cs | 57 +- .../Services/RecordingNotificationService.cs | 303 +++++++--- ...ctationOrchestratorDiscardFeedbackTests.cs | 122 ++++ .../RecordingNotificationServiceTests.cs | 522 ++++++++++++++++++ 4 files changed, 894 insertions(+), 110 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/DictationOrchestratorDiscardFeedbackTests.cs create mode 100644 tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs diff --git a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs index a7f8a6c47..a383675e5 100644 --- a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs +++ b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs @@ -1107,6 +1107,29 @@ public Task StopAsync() return StopAsync(cancelRequested: false); } + internal static void ReportShortSpeechDiscardOutcome( + LinuxShortSpeechDecision discardReason, + RecordingContext recordingContext, + Action reportStatus, + Action showFeedback + ) + { + var messageKey = discardReason switch + { + LinuxShortSpeechDecision.DiscardTooShort => "Overlay.TooShort", + LinuxShortSpeechDecision.DiscardNoSpeech => "Overlay.NoSpeech", + _ => throw new ArgumentOutOfRangeException( + nameof(discardReason), + discardReason, + "Only discard outcomes can be reported." + ) + }; + var message = Localization.Loc.Instance[messageKey]; + + reportStatus(recordingContext, message); + showFeedback(recordingContext, message, true, false); + } + private async Task StopAsync(bool cancelRequested) { // Fold both intent sources into the gate so a stop deferred behind an in-progress startup @@ -1317,19 +1340,12 @@ state with switch (shortSpeechDecision) { case LinuxShortSpeechDecision.DiscardTooShort: - SetOverlayState(state => - state with - { - IsOverlayVisible = true, - ShowFeedback = true, - FeedbackText = Localization.Loc.Instance["Overlay.TooShort"], - FeedbackIsError = true, - IsRecording = false, - StatusText = Localization.Loc.Instance["Overlay.TooShort"], - PartialText = null - } + ReportShortSpeechDiscardOutcome( + LinuxShortSpeechDecision.DiscardTooShort, + recordingContext, + ReportStatus, + ShowFeedback ); - StatusMessage?.Invoke(this, "Too short"); _ = await TeardownStreamingSessionAsync( stoppedStreamingCoordinator, stoppedStreamingStartupCts, @@ -1339,19 +1355,12 @@ state with FinalizeSession(recordingContext.SessionId, "discarded", "Too short"); return; case LinuxShortSpeechDecision.DiscardNoSpeech: - SetOverlayState(state => - state with - { - IsOverlayVisible = true, - ShowFeedback = true, - FeedbackText = Localization.Loc.Instance["Overlay.NoSpeech"], - FeedbackIsError = true, - IsRecording = false, - StatusText = Localization.Loc.Instance["Overlay.NoSpeech"], - PartialText = null - } + ReportShortSpeechDiscardOutcome( + LinuxShortSpeechDecision.DiscardNoSpeech, + recordingContext, + ReportStatus, + ShowFeedback ); - StatusMessage?.Invoke(this, "No speech detected"); _ = await TeardownStreamingSessionAsync( stoppedStreamingCoordinator, stoppedStreamingStartupCts, diff --git a/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs b/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs index e27189e53..8955e5cc7 100644 --- a/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs +++ b/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs @@ -6,58 +6,91 @@ namespace TypeWhisper.Linux.Services; +internal interface IRecordingNotificationStateSource +{ + event EventHandler? OverlayStateChanged; +} + /// -/// Recording indicator for tiling WMs (Hyprland/Sway/…) via a persistent -/// org.freedesktop.Notifications desktop notification (expire_timeout 0), -/// closed by id when recording stops. No-op on full DEs (GNOME/KDE/Cinnamon) -/// which use the overlay — see . +/// Complete dictation state and feedback surface for notification-indicator +/// WMs (Hyprland/Sway/River/Niri) via org.freedesktop.Notifications. +/// No-op on full DEs (GNOME/KDE/Cinnamon), which use the overlay — see +/// . /// public sealed partial class RecordingNotificationService : IDisposable { private static readonly TimeSpan s_callTimeout = TimeSpan.FromSeconds(3); - private readonly DictationOrchestrator _dictation; private readonly bool _enabled; private readonly Lock _gate = new(); private readonly IProcessRunner _runner; private readonly ISettingsService _settings; + private readonly IRecordingNotificationStateSource _stateSource; private uint _activeId; - - // Monotonic counter bumped on every Start/Stop edge. ShowAsync/CloseAsync are - // fire-and-forget and await a multi-second gdbus call, so a rapid Start→Stop - // can finish out of order. Each handler re-checks the generation after its await - // and bails (closing its own just-created id) if superseded — last edge wins. - private uint _generation; - - private bool _wasRecording; + private NotificationPresentation? _desiredPresentation; + private bool _disposed; + private uint _desiredVersion; + private TaskCompletionSource? _idleCompletion; + private bool _initialized; + private bool _workerRunning; public RecordingNotificationService( DictationOrchestrator dictation, ISettingsService settings, IProcessRunner runner + ) + : this( + new DictationOverlayStateSource(dictation), + settings, + runner, + DesktopDetector.UsesNotificationRecordingIndicator() + ) + { + } + + internal RecordingNotificationService( + IRecordingNotificationStateSource stateSource, + ISettingsService settings, + IProcessRunner runner, + bool enabled ) { - _dictation = dictation; + _stateSource = stateSource; _settings = settings; _runner = runner; - _enabled = DesktopDetector.UsesNotificationRecordingIndicator(); + _enabled = enabled; } public void Dispose() { - if (_enabled) + bool startWorker; + lock (_gate) { - _dictation.OverlayStateChanged -= OnOverlayStateChanged; + if (!_enabled || _disposed) + { + return; + } + + _disposed = true; + if (_initialized) + { + _stateSource.OverlayStateChanged -= OnOverlayStateChanged; + _initialized = false; + } + + if (_desiredPresentation is not null || _activeId != 0) + { + _desiredPresentation = null; + _desiredVersion++; + } + + startWorker = StartWorkerIfNeededLocked(); } - // Teardown — supersede any in-flight show and dismiss whatever is up. - uint generation; - lock (_gate) + if (startWorker) { - generation = ++_generation; + _ = DispatchLoopAsync(); } - - _ = CloseAsync(generation); } /// @@ -76,119 +109,201 @@ public static string BodyFor(RecordingMode mode) public void Initialize() { - if (!_enabled) + lock (_gate) { - return; - } + if (!_enabled || _initialized || _disposed) + { + return; + } - _dictation.OverlayStateChanged += OnOverlayStateChanged; + _stateSource.OverlayStateChanged += OnOverlayStateChanged; + _initialized = true; + } } - private string ResolveBody() + internal Task WaitForIdleAsync() { - return BodyFor(_settings.Current.Mode); + lock (_gate) + { + return _workerRunning ? _idleCompletion!.Task : Task.CompletedTask; + } } private void OnOverlayStateChanged(object? sender, DictationOverlayState state) { - // Edge-trigger: OverlayStateChanged fires many times per recording (partial text, levels). - if (state.IsRecording == _wasRecording) + NotificationPresentation? presentation; + try + { + presentation = ProjectPresentation(state); + } + catch { + // Notifications are advisory and must never disrupt dictation state dispatch. return; } - _wasRecording = state.IsRecording; - uint generation; + bool startWorker; lock (_gate) { - generation = ++_generation; + if (_disposed || Equals(_desiredPresentation, presentation)) + { + return; + } + + _desiredPresentation = presentation; + _desiredVersion++; + startWorker = StartWorkerIfNeededLocked(); } - _ = state.IsRecording ? ShowAsync(generation) : CloseAsync(generation); + if (startWorker) + { + _ = DispatchLoopAsync(); + } } - private async Task ShowAsync(uint generation) + private NotificationPresentation? ProjectPresentation(DictationOverlayState state) { - // Use previous id as replaces_id so a lingered notification is replaced - // in-place rather than stacking a second popup. - uint replaceId; - lock (_gate) + if (state.IsRecording) { - replaceId = _activeId; + return new NotificationPresentation( + Loc.Instance["Appearance.NotificationRecordingTitle"], + BodyFor(_settings.Current.Mode), + 0 + ); } - try + if (state.ShowFeedback && !string.IsNullOrWhiteSpace(state.FeedbackText)) { - var result = await _runner - .RunAsync( - "gdbus", - [ - "call", "--session", "--dest", "org.freedesktop.Notifications", "--object-path", - "/org/freedesktop/Notifications", "--method", "org.freedesktop.Notifications.Notify", - "TypeWhisper", replaceId.ToString(), ResolveIconPath(), - Loc.Instance["Appearance.NotificationRecordingTitle"], ResolveBody(), "[]", // actions - "{}", // hints - "0" // expire_timeout 0 → stay up until we close it - ], - timeout: s_callTimeout - ) - .ConfigureAwait(false); + var expiry = AppSettings.NormalizePreviewBubbleAutoHideMilliseconds( + _settings.Current.PreviewBubbleAutoHideMilliseconds + ); + return expiry <= 0 + ? null + : new NotificationPresentation(state.FeedbackText, string.Empty, expiry); + } - if (!result.Succeeded) + if (state.IsOverlayVisible && !string.IsNullOrWhiteSpace(state.StatusText)) + { + return new NotificationPresentation(state.StatusText, string.Empty, 0); + } + + return null; + } + + private bool StartWorkerIfNeededLocked() + { + if (_workerRunning) + { + return false; + } + + if (_desiredPresentation is null && _activeId == 0) + { + return false; + } + + _workerRunning = true; + _idleCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + return true; + } + + private async Task DispatchLoopAsync() + { + while (true) + { + NotificationPresentation? presentation; + uint replaceId; + uint version; + lock (_gate) { - return; + presentation = _desiredPresentation; + replaceId = _activeId; + version = _desiredVersion; } - // gdbus prints "(uint32 N,)" — anchor on "uint32 " to avoid matching the "32" in the type name. - var match = NotificationIdRegex().Match(result.StandardOutput); - if (!match.Success || !uint.TryParse(match.Groups[1].Value, out var id)) + uint? shownId = null; + if (presentation is null) { - return; + if (replaceId != 0) + { + await CloseByIdAsync(replaceId).ConfigureAwait(false); + } + } + else + { + shownId = await ShowAsync(presentation, replaceId).ConfigureAwait(false); } - bool superseded; + TaskCompletionSource? completed = null; lock (_gate) { - // A newer edge fired while Notify was in flight — dismiss this id. - superseded = generation != _generation; - if (!superseded) + if (presentation is null) + { + _activeId = 0; + } + else if (shownId is { } id) { _activeId = id; } + + if (version == _desiredVersion) + { + _workerRunning = false; + completed = _idleCompletion; + _idleCompletion = null; + } } - if (superseded) + if (completed is not null) { - await CloseByIdAsync(id).ConfigureAwait(false); + completed.TrySetResult(); + return; } } - catch - { - // Notifications are purely advisory — never let one disrupt dictation. - } } - private async Task CloseAsync(uint generation) + private async Task ShowAsync( + NotificationPresentation presentation, + uint replaceId + ) { - uint id; - lock (_gate) + try { - // A newer Start superseded this Stop — closing would dismiss the new recording's notification. - if (generation != _generation) + var result = await _runner + .RunAsync( + "gdbus", + [ + "call", "--session", "--dest", "org.freedesktop.Notifications", "--object-path", + "/org/freedesktop/Notifications", "--method", "org.freedesktop.Notifications.Notify", + "TypeWhisper", replaceId.ToString(), ResolveIconPath(), + presentation.Summary, presentation.Body, "[]", // actions + "{}", // hints + presentation.ExpireTimeout.ToString() + ], + timeout: s_callTimeout + ) + .ConfigureAwait(false); + + if (!result.Succeeded) { - return; + return null; } - id = _activeId; - _activeId = 0; + // gdbus prints "(uint32 N,)" — anchor on "uint32 " to avoid matching the "32" in the type name. + var match = NotificationIdRegex().Match(result.StandardOutput); + return match.Success + && uint.TryParse(match.Groups[1].Value, out var id) + && id != 0 + ? id + : null; } - - if (id == 0) + catch { - return; + // Notifications are purely advisory — never let one disrupt dictation. + return null; } - - await CloseByIdAsync(id).ConfigureAwait(false); } private async Task CloseByIdAsync(uint id) @@ -236,4 +351,20 @@ private static string ResolveIconPath() [GeneratedRegex(@"uint32 (\d+)")] private static partial Regex NotificationIdRegex(); -} \ No newline at end of file + + private sealed class DictationOverlayStateSource(DictationOrchestrator dictation) + : IRecordingNotificationStateSource + { + public event EventHandler? OverlayStateChanged + { + add => dictation.OverlayStateChanged += value; + remove => dictation.OverlayStateChanged -= value; + } + } + + private sealed record NotificationPresentation( + string Summary, + string Body, + int ExpireTimeout + ); +} diff --git a/tests/TypeWhisper.Linux.Tests/DictationOrchestratorDiscardFeedbackTests.cs b/tests/TypeWhisper.Linux.Tests/DictationOrchestratorDiscardFeedbackTests.cs new file mode 100644 index 000000000..14cffb41f --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/DictationOrchestratorDiscardFeedbackTests.cs @@ -0,0 +1,122 @@ +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Localization; +using Xunit; + +// The Assert.Collection lambdas in this file assert on each element; ReSharper reads +// xUnit asserts as precondition checks and concludes the element parameter is only +// validated, never used — but asserting on each element is exactly the test's +// purpose, so the inspection is a false positive here. +// ReSharper disable ParameterOnlyUsedForPreconditionCheck.Local +namespace TypeWhisper.Linux.Tests; + +public sealed class DictationOrchestratorDiscardFeedbackTests +{ + [Theory] + [InlineData((int)LinuxShortSpeechDecision.DiscardTooShort, "Overlay.TooShort")] + [InlineData((int)LinuxShortSpeechDecision.DiscardNoSpeech, "Overlay.NoSpeech")] + public void Short_speech_discard_resolves_message_and_reports_status_before_error_feedback( + int discardReasonValue, + string messageKey + ) + { + var discardReason = (LinuxShortSpeechDecision)discardReasonValue; + var recordingContext = NewRecordingContext(); + var message = Loc.Instance[messageKey]; + var calls = new List(); + + DictationOrchestrator.ReportShortSpeechDiscardOutcome( + discardReason, + recordingContext, + (context, status) => + calls.Add(new OutcomeCall("status", context, status, null, null)), + (context, feedback, isError, isCanceled) => + calls.Add( + new OutcomeCall("feedback", context, feedback, isError, isCanceled) + ) + ); + + Assert.Collection( + calls, + status => + { + Assert.Equal("status", status.Kind); + Assert.Same(recordingContext, status.Context); + Assert.Equal(message, status.Message); + Assert.Null(status.IsError); + Assert.Null(status.IsCanceled); + }, + feedback => + { + Assert.Equal("feedback", feedback.Kind); + Assert.Same(recordingContext, feedback.Context); + Assert.Equal(message, feedback.Message); + Assert.True(feedback.IsError); + Assert.False(feedback.IsCanceled); + } + ); + } + + [Fact] + public void Discard_reasons_resolve_their_distinct_localized_messages() + { + var recordingContext = NewRecordingContext(); + var messages = new Dictionary(); + + foreach ( + var discardReason in new[] + { + LinuxShortSpeechDecision.DiscardTooShort, + LinuxShortSpeechDecision.DiscardNoSpeech + } + ) + { + DictationOrchestrator.ReportShortSpeechDiscardOutcome( + discardReason, + recordingContext, + (_, message) => messages.Add(discardReason, message), + (_, _, _, _) => { } + ); + } + + Assert.Equal( + Loc.Instance["Overlay.TooShort"], + messages[LinuxShortSpeechDecision.DiscardTooShort] + ); + Assert.Equal( + Loc.Instance["Overlay.NoSpeech"], + messages[LinuxShortSpeechDecision.DiscardNoSpeech] + ); + Assert.NotEqual( + messages[LinuxShortSpeechDecision.DiscardTooShort], + messages[LinuxShortSpeechDecision.DiscardNoSpeech] + ); + } + + private static RecordingContext NewRecordingContext() + { + return new RecordingContext( + SessionId: 42, + RecordingStart: DateTime.UnixEpoch, + AppProcess: null, + AppTitle: null, + AppUrl: null, + WindowId: null, + Profile: null, + RecoveredPartialPreview: string.Empty, + StreamingFinalText: null, + StreamingFaulted: false, + StreamingProviderId: null, + StreamingModelId: null, + StreamingLanguageHint: null, + CancelToken: CancellationToken.None + ); + } + + private sealed record OutcomeCall( + string Kind, + RecordingContext Context, + string Message, + bool? IsError, + bool? IsCanceled + ); +} diff --git a/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs b/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs new file mode 100644 index 000000000..eb59f295e --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs @@ -0,0 +1,522 @@ +using Moq; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Localization; +using Xunit; + +// The assertion helpers and Assert.All/Collection lambdas in this file assert on +// their parameters; ReSharper reads xUnit asserts as precondition checks and +// concludes the parameters are only validated, never used — but asserting on them +// is exactly the test's purpose, so the inspection is a false positive here. +// ReSharper disable ParameterOnlyUsedForPreconditionCheck.Local +namespace TypeWhisper.Linux.Tests; + +public sealed class RecordingNotificationServiceTests +{ + private static readonly TimeSpan s_testGuard = TimeSpan.FromSeconds(2); + + [Fact] + public async Task Recording_processing_and_success_replace_one_notification_in_place() + { + const int terminalExpiry = 2345; + var (source, runner, service) = CreateSut( + AppSettings.Default with + { + Mode = RecordingMode.PushToTalk, + PreviewBubbleAutoHideMilliseconds = terminalExpiry + } + ); + service.Initialize(); + + source.Raise( + new DictationOverlayState + { + IsOverlayVisible = true, + IsRecording = true, + StatusText = Loc.Instance["Dictation.StatusRecording"] + } + ); + await service.WaitForIdleAsync().WaitAsync(s_testGuard); + + var processing = Loc.Instance["Overlay.Processing"]; + source.Raise( + new DictationOverlayState + { + IsOverlayVisible = true, + StatusText = processing + } + ); + await service.WaitForIdleAsync().WaitAsync(s_testGuard); + + const string success = "Inserted 18 characters"; + source.Raise( + new DictationOverlayState + { + ShowFeedback = true, + FeedbackText = success + } + ); + await service.WaitForIdleAsync().WaitAsync(s_testGuard); + + var calls = AssertNotifyCalls(runner, 3); + AssertNotify( + calls[0], + replacesId: 0, + Loc.Instance["Appearance.NotificationRecordingTitle"], + RecordingNotificationService.BodyFor(RecordingMode.PushToTalk), + expireTimeout: 0 + ); + AssertNotify(calls[1], replacesId: 41, processing, string.Empty, expireTimeout: 0); + AssertNotify( + calls[2], + replacesId: 41, + success, + string.Empty, + expireTimeout: terminalExpiry + ); + Assert.DoesNotContain(runner.Invocations, IsClose); + } + + [Theory] + [InlineData("Dictation completed", false)] + [InlineData("Overlay.Canceled", false)] + [InlineData("Overlay.NoSpeech", true)] + public async Task Terminal_variants_show_exact_feedback_with_normalized_finite_expiry( + string textOrLocalizationKey, + bool isError + ) + { + var settings = AppSettings.Default with + { + PreviewBubbleAutoHideMilliseconds = + AppSettings.MaxPreviewBubbleAutoHideMilliseconds + 500 + }; + var (source, runner, service) = CreateSut(settings); + service.Initialize(); + var feedbackText = textOrLocalizationKey.StartsWith("Overlay.", StringComparison.Ordinal) + ? Loc.Instance[textOrLocalizationKey] + : textOrLocalizationKey; + + source.Raise( + new DictationOverlayState + { + ShowFeedback = true, + FeedbackIsError = isError, + FeedbackText = feedbackText, + IsRecording = false + } + ); + await service.WaitForIdleAsync().WaitAsync(s_testGuard); + + var call = Assert.Single(AssertNotifyCalls(runner, 1)); + AssertNotify( + call, + replacesId: 0, + feedbackText, + string.Empty, + AppSettings.MaxPreviewBubbleAutoHideMilliseconds + ); + } + + [Fact] + public async Task Non_presentation_changes_are_deduplicated_while_recording_and_processing() + { + var (source, runner, service) = CreateSut(AppSettings.Default); + service.Initialize(); + var recording = new DictationOverlayState + { + IsOverlayVisible = true, + IsRecording = true, + PartialText = "one", + ActiveProfileName = "Profile A", + ActiveAppName = "Editor" + }; + + source.Raise(recording); + await service.WaitForIdleAsync().WaitAsync(s_testGuard); + source.Raise( + recording with + { + PartialText = "one two", + ActiveProfileName = "Profile B", + ActiveAppName = "Terminal", + SessionStartedAtUtc = DateTime.UtcNow + } + ); + await service.WaitForIdleAsync().WaitAsync(s_testGuard); + + var processing = new DictationOverlayState + { + IsOverlayVisible = true, + StatusText = Loc.Instance["Overlay.Processing"] + }; + source.Raise(processing); + await service.WaitForIdleAsync().WaitAsync(s_testGuard); + source.Raise( + processing with + { + PartialText = "ignored preview", + ActiveProfileName = "Profile C", + ActiveAppName = "Browser" + } + ); + await service.WaitForIdleAsync().WaitAsync(s_testGuard); + + var calls = AssertNotifyCalls(runner, 2); + Assert.Equal(Loc.Instance["Appearance.NotificationRecordingTitle"], calls[0].Args[11]); + Assert.Equal(Loc.Instance["Overlay.Processing"], calls[1].Args[11]); + } + + [Fact] + public async Task Hidden_and_zero_duration_terminal_feedback_close_the_owned_notification() + { + var (hiddenSource, hiddenRunner, hiddenService) = CreateSut(AppSettings.Default); + hiddenService.Initialize(); + hiddenSource.Raise(new DictationOverlayState { IsRecording = true }); + await hiddenService.WaitForIdleAsync().WaitAsync(s_testGuard); + + hiddenSource.Raise(DictationOverlayState.Hidden); + await hiddenService.WaitForIdleAsync().WaitAsync(s_testGuard); + + Assert.Equal(2, hiddenRunner.Invocations.Count); + AssertNotify(hiddenRunner.Invocations[0], 0, Loc.Instance["Appearance.NotificationRecordingTitle"], RecordingNotificationService.BodyFor(AppSettings.Default.Mode), 0); + AssertClose(hiddenRunner.Invocations[1], 41); + + var zeroSettings = AppSettings.Default with + { + PreviewBubbleAutoHideMilliseconds = -100 + }; + var (zeroSource, zeroRunner, zeroService) = CreateSut(zeroSettings); + zeroService.Initialize(); + zeroSource.Raise(new DictationOverlayState { IsRecording = true }); + await zeroService.WaitForIdleAsync().WaitAsync(s_testGuard); + + zeroSource.Raise( + new DictationOverlayState + { + ShowFeedback = true, + FeedbackText = "Finished" + } + ); + await zeroService.WaitForIdleAsync().WaitAsync(s_testGuard); + + Assert.Equal(2, zeroRunner.Invocations.Count); + AssertNotify(zeroRunner.Invocations[0], 0, Loc.Instance["Appearance.NotificationRecordingTitle"], RecordingNotificationService.BodyFor(AppSettings.Default.Mode), 0); + AssertClose(zeroRunner.Invocations[1], 41); + } + + [Fact] + public async Task Slow_initial_notify_coalesces_pending_states_to_latest_terminal_feedback() + { + var source = new FakeOverlayStateSource(); + var runner = new ControlledProcessRunner(); + var settings = CreateSettings( + AppSettings.Default with { PreviewBubbleAutoHideMilliseconds = 1700 } + ); + var service = new RecordingNotificationService(source, settings.Object, runner, true); + service.Initialize(); + + source.Raise(new DictationOverlayState { IsRecording = true }); + await runner.FirstStarted.Task.WaitAsync(s_testGuard); + + source.Raise( + new DictationOverlayState + { + IsOverlayVisible = true, + StatusText = Loc.Instance["Overlay.Processing"] + } + ); + const string terminal = "Dictation inserted"; + source.Raise( + new DictationOverlayState + { + ShowFeedback = true, + FeedbackText = terminal + } + ); + Assert.Single(runner.Invocations); + + runner.CompleteFirst(41); + await runner.SecondStarted.Task.WaitAsync(s_testGuard); + + var second = runner.Invocations[1]; + AssertNotify(second, replacesId: 41, terminal, string.Empty, expireTimeout: 1700); + runner.CompleteSecond(41); + await service.WaitForIdleAsync().WaitAsync(s_testGuard); + + Assert.Equal(2, runner.Invocations.Count); + Assert.All(runner.Invocations, invocation => Assert.Equal("gdbus", invocation.FileName)); + Assert.DoesNotContain(runner.Invocations, IsClose); + } + + [Fact] + public async Task Disabled_service_does_not_subscribe_dispatch_or_close_on_dispose() + { + var source = new FakeOverlayStateSource(); + var runner = new FakeProcessRunner(); + var service = new RecordingNotificationService( + source, + CreateSettings(AppSettings.Default).Object, + runner, + false + ); + + service.Initialize(); + source.Raise(new DictationOverlayState { IsRecording = true }); + service.Dispose(); + await service.WaitForIdleAsync().WaitAsync(s_testGuard); + + Assert.Empty(runner.Invocations); + } + + [Fact] + public async Task Enabled_dispose_closes_owned_notification_and_ignores_later_states() + { + var (source, runner, service) = CreateSut(AppSettings.Default); + service.Initialize(); + source.Raise(new DictationOverlayState { IsRecording = true }); + await service.WaitForIdleAsync().WaitAsync(s_testGuard); + + service.Dispose(); + await service.WaitForIdleAsync().WaitAsync(s_testGuard); + source.Raise( + new DictationOverlayState + { + IsOverlayVisible = true, + StatusText = Loc.Instance["Overlay.Processing"] + } + ); + + Assert.Equal(2, runner.Invocations.Count); + AssertClose(runner.Invocations[1], 41); + } + + [Fact] + public async Task Failed_and_invalid_notify_results_do_not_retry_without_a_new_presentation() + { + var failedSource = new FakeOverlayStateSource(); + var failedRunner = new FakeProcessRunner(); + failedRunner.FailWhen(IsNotify); + var failedService = new RecordingNotificationService( + failedSource, + CreateSettings(AppSettings.Default).Object, + failedRunner, + true + ); + failedService.Initialize(); + var recording = new DictationOverlayState { IsRecording = true }; + + failedSource.Raise(recording); + await failedService.WaitForIdleAsync().WaitAsync(s_testGuard); + failedSource.Raise(recording with { PartialText = "noise" }); + await failedService.WaitForIdleAsync().WaitAsync(s_testGuard); + + Assert.Single(failedRunner.Invocations); + + var invalidSource = new FakeOverlayStateSource(); + var invalidRunner = new FakeProcessRunner(); + invalidRunner.RespondWith(IsNotify, "not a notification id"); + var invalidService = new RecordingNotificationService( + invalidSource, + CreateSettings(AppSettings.Default).Object, + invalidRunner, + true + ); + invalidService.Initialize(); + + invalidSource.Raise(recording); + await invalidService.WaitForIdleAsync().WaitAsync(s_testGuard); + invalidSource.Raise(recording with { ActiveAppName = "noise" }); + await invalidService.WaitForIdleAsync().WaitAsync(s_testGuard); + + Assert.Single(invalidRunner.Invocations); + } + + private static ( + FakeOverlayStateSource Source, + FakeProcessRunner Runner, + RecordingNotificationService Service + ) CreateSut(AppSettings current) + { + var source = new FakeOverlayStateSource(); + var runner = new FakeProcessRunner(); + runner.RespondWith(IsNotify, "(uint32 41,)"); + var service = new RecordingNotificationService( + source, + CreateSettings(current).Object, + runner, + true + ); + return (source, runner, service); + } + + private static Mock CreateSettings(AppSettings current) + { + var settings = new Mock(); + settings.SetupGet(service => service.Current).Returns(current); + return settings; + } + + private static List AssertNotifyCalls( + FakeProcessRunner runner, + int expectedCount + ) + { + Assert.Equal(expectedCount, runner.Invocations.Count); + Assert.All(runner.Invocations, invocation => + { + Assert.Equal("gdbus", invocation.FileName); + Assert.Equal("org.freedesktop.Notifications.Notify", invocation.Args[7]); + Assert.Equal(TimeSpan.FromSeconds(3), invocation.Timeout); + }); + return runner.Invocations; + } + + private static void AssertNotify( + FakeProcessRunner.Invocation invocation, + uint replacesId, + string summary, + string body, + int expireTimeout + ) + { + AssertNotify(invocation.FileName, invocation.Args, invocation.Timeout, replacesId, summary, body, expireTimeout); + } + + private static void AssertNotify( + ControlledProcessRunner.Invocation invocation, + uint replacesId, + string summary, + string body, + int expireTimeout + ) + { + AssertNotify(invocation.FileName, invocation.Args, invocation.Timeout, replacesId, summary, body, expireTimeout); + } + + private static void AssertNotify( + string fileName, + IReadOnlyList args, + TimeSpan? timeout, + uint replacesId, + string summary, + string body, + int expireTimeout + ) + { + Assert.Equal("gdbus", fileName); + Assert.Equal("org.freedesktop.Notifications.Notify", args[7]); + Assert.Equal("TypeWhisper", args[8]); + Assert.Equal(replacesId.ToString(), args[9]); + Assert.Equal(summary, args[11]); + Assert.Equal(body, args[12]); + Assert.Equal("[]", args[13]); + Assert.Equal("{}", args[14]); + Assert.Equal(expireTimeout.ToString(), args[15]); + Assert.Equal(TimeSpan.FromSeconds(3), timeout); + } + + private static void AssertClose(FakeProcessRunner.Invocation invocation, uint id) + { + Assert.Equal("gdbus", invocation.FileName); + Assert.Equal("org.freedesktop.Notifications.CloseNotification", invocation.Args[7]); + Assert.Equal(id.ToString(), invocation.Args[8]); + Assert.Equal(TimeSpan.FromSeconds(3), invocation.Timeout); + } + + private static bool IsNotify(string fileName, IReadOnlyList args) + { + return fileName == "gdbus" + && args.Count > 7 + && args[7] == "org.freedesktop.Notifications.Notify"; + } + + private static bool IsClose(FakeProcessRunner.Invocation invocation) + { + return invocation.Args.Count > 7 + && invocation.Args[7] == "org.freedesktop.Notifications.CloseNotification"; + } + + private static bool IsClose(ControlledProcessRunner.Invocation invocation) + { + return invocation.Args.Count > 7 + && invocation.Args[7] == "org.freedesktop.Notifications.CloseNotification"; + } + + private sealed class FakeOverlayStateSource : IRecordingNotificationStateSource + { + public event EventHandler? OverlayStateChanged; + + public void Raise(DictationOverlayState state) + { + OverlayStateChanged?.Invoke(this, state); + } + } + + private sealed class ControlledProcessRunner : IProcessRunner + { + private readonly TaskCompletionSource _firstCompletion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _secondCompletion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource FirstStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource SecondStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public List Invocations { get; } = []; + + public Task RunAsync( + string fileName, + IReadOnlyList args, + IReadOnlyDictionary? environment = null, + string? standardInput = null, + TimeSpan? timeout = null, + CancellationToken ct = default + ) + { + Invocations.Add(new Invocation(fileName, args.ToArray(), timeout)); + if (Invocations.Count == 1) + { + FirstStarted.TrySetResult(); + return _firstCompletion.Task; + } + + if (Invocations.Count == 2) + { + SecondStarted.TrySetResult(); + return _secondCompletion.Task; + } + + return Task.FromResult(Success(41)); + } + + public void CompleteFirst(uint id) + { + _firstCompletion.TrySetResult(Success(id)); + } + + public void CompleteSecond(uint id) + { + _secondCompletion.TrySetResult(Success(id)); + } + + private static ProcessRunResult Success(uint id) + { + return new ProcessRunResult( + true, + false, + 0, + $"(uint32 {id},)", + string.Empty + ); + } + + public sealed record Invocation( + string FileName, + IReadOnlyList Args, + TimeSpan? Timeout + ); + } +} From a21817acd8b428f1e6609eda5ceed99ec44532af Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 22:47:12 +0000 Subject: [PATCH 105/226] =?UTF-8?q?Band-limit=20audio=20with=20a=20windowe?= =?UTF-8?q?d-sinc=20low-pass=20before=20downsampling=20(audit=20=C2=A75=20?= =?UTF-8?q?M6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/AudioRecordingService.cs | 90 ++++++++- .../AudioRecordingServiceTests.cs | 181 +++++++++++++++++- 2 files changed, 265 insertions(+), 6 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs index 71e2e9af5..b37791ff2 100644 --- a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs @@ -508,8 +508,9 @@ internal static float ComputeRmsLevel(float[] samples) return (float)Math.Sqrt(sumSquares / samples.Length); } - // Linear-interpolation resampler: adequate quality for speech (well below - // Nyquist for any capture rate) without a native resampling library. + // Downsampling applies a symmetric Blackman-windowed sinc low-pass with a + // 0.40-to-0.50 target-rate transition band before retaining the existing + // linear interpolation and sample alignment. Upsampling uses interpolation alone. internal static float[] ResampleToSampleRate( float[] samples, int sourceSampleRate, @@ -528,6 +529,41 @@ int targetSampleRate var output = new float[outputLength]; var ratio = (double)sourceSampleRate / targetSampleRate; + if (targetSampleRate > 0 && sourceSampleRate > targetSampleRate) + { + var filterRadius = (int)Math.Ceiling(24 * ratio); + var coefficientCount = filterRadius + 1; + const int maxStackAllocatedCoefficientCount = 256; + Span coefficients = coefficientCount <= maxStackAllocatedCoefficientCount + ? stackalloc double[coefficientCount] + : new double[coefficientCount]; + CreateDownsamplingFilter( + coefficients, + filterRadius, + sourceSampleRate, + targetSampleRate + ); + + for (var i = 0; i < output.Length; i++) + { + var sourceIndex = i * ratio; + var leftIndex = (int)Math.Floor(sourceIndex); + var rightIndex = Math.Min(leftIndex + 1, samples.Length - 1); + var fraction = (float)(sourceIndex - leftIndex); + var leftSample = EvaluateFirAtIndex(samples, leftIndex, coefficients); + + if (rightIndex != leftIndex && fraction != 0f) + { + var rightSample = EvaluateFirAtIndex(samples, rightIndex, coefficients); + leftSample += (rightSample - leftSample) * fraction; + } + + output[i] = (float)leftSample; + } + + return output; + } + for (var i = 0; i < output.Length; i++) { var sourceIndex = i * ratio; @@ -541,6 +577,56 @@ int targetSampleRate return output; } + private static void CreateDownsamplingFilter( + Span coefficients, + int filterRadius, + int sourceSampleRate, + int targetSampleRate + ) + { + var normalizedCutoff = 0.45 * targetSampleRate / sourceSampleRate; + double coefficientSum = 0; + + for (var offset = 0; offset <= filterRadius; offset++) + { + var sincArgument = 2 * normalizedCutoff * offset; + var sinc = offset == 0 + ? 1 + : Math.Sin(Math.PI * sincArgument) / (Math.PI * sincArgument); + var ideal = 2 * normalizedCutoff * sinc; + var window = 0.42 + + 0.50 * Math.Cos(Math.PI * offset / filterRadius) + + 0.08 * Math.Cos(2 * Math.PI * offset / filterRadius); + var coefficient = ideal * window; + coefficients[offset] = coefficient; + coefficientSum += offset == 0 ? coefficient : 2 * coefficient; + } + + for (var offset = 0; offset < coefficients.Length; offset++) + { + coefficients[offset] /= coefficientSum; + } + } + + private static double EvaluateFirAtIndex( + float[] samples, + int index, + ReadOnlySpan coefficients + ) + { + var result = coefficients[0] * samples[index]; + var finalIndex = samples.Length - 1; + + for (var offset = 1; offset < coefficients.Length; offset++) + { + var leftIndex = Math.Max(index - offset, 0); + var rightIndex = Math.Min(index + offset, finalIndex); + result += coefficients[offset] * (samples[leftIndex] + samples[rightIndex]); + } + + return result; + } + internal StreamCallbackResult ProcessAudioBufferForTest(float[] frame) { var handle = GCHandle.Alloc(frame, GCHandleType.Pinned); diff --git a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs index 85176f206..b3d1d9974 100644 --- a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs @@ -40,15 +40,132 @@ public void ApplyWhisperModeGain_LeavesAudioUnchangedWhenDisabled() Assert.Same(samples, processed); } + [Theory] + [InlineData(480, 48000, 16000)] + [InlineData(441, 44100, 16000)] + public void ResampleToSampleRate_DownsamplesToRoundedTargetLength( + int inputLength, + int sourceSampleRate, + int targetSampleRate + ) + { + var samples = new float[inputLength]; + var expectedLength = Math.Max( + 1, + (int)Math.Round(inputLength * (double)targetSampleRate / sourceSampleRate) + ); + + var processed = AudioRecordingService.ResampleToSampleRate( + samples, + sourceSampleRate, + targetSampleRate + ); + + Assert.Equal(expectedLength, processed.Length); + } + + [Fact] + public void ResampleToSampleRate_DownsamplingRejectsStopbandAlias() + { + const int sourceSampleRate = 48000; + const int targetSampleRate = 16000; + const int edgeGuard = 256; + var samples = GenerateTone(12000, sourceSampleRate); + + var processed = AudioRecordingService.ResampleToSampleRate( + samples, + sourceSampleRate, + targetSampleRate + ); + var baseline = DownsampleThreeToOneUnfiltered(samples, processed.Length); + var baselinePower = MeanSquare(baseline, edgeGuard, baseline.Length - edgeGuard); + var filteredPower = MeanSquare(processed, edgeGuard, processed.Length - edgeGuard); + var attenuationDb = 10 * Math.Log10(Math.Max(filteredPower, 1e-300) / baselinePower); + + Assert.True(baselinePower > 0.1, $"Baseline power was only {baselinePower:R}."); + Assert.True( + attenuationDb <= -50, + $"Stopband attenuation was {attenuationDb:R} dB." + ); + } + + [Fact] + public void ResampleToSampleRate_DownsamplingPreservesInBandGainAndAlignment() + { + const int sourceSampleRate = 48000; + const int targetSampleRate = 16000; + const int edgeGuard = 256; + var samples = GenerateTone(1000, sourceSampleRate); + + var processed = AudioRecordingService.ResampleToSampleRate( + samples, + sourceSampleRate, + targetSampleRate + ); + var baseline = DownsampleThreeToOneUnfiltered(samples, processed.Length); + var baselinePower = MeanSquare(baseline, edgeGuard, baseline.Length - edgeGuard); + var outputPower = MeanSquare(processed, edgeGuard, processed.Length - edgeGuard); + var gainDb = 10 * Math.Log10(outputPower / baselinePower); + var rmsError = RootMeanSquareError( + processed, + baseline, + edgeGuard, + processed.Length - edgeGuard + ); + + Assert.InRange(gainDb, -0.25, 0.25); + Assert.True(rmsError < 0.01, $"In-band RMS sample error was {rmsError:R}."); + } + + [Fact] + public void ResampleToSampleRate_DownsamplingPreservesPassbandEdgeTone() + { + // 6 kHz sits inside the passband (Fp = 0.40 * 16 kHz = 6.4 kHz) and below + // the 8 kHz output Nyquist, so its unfiltered 3:1 baseline is alias-free + // and the anti-alias filter must pass it at essentially unity gain. A + // decimate-then-filter design using source-rate coefficients would instead + // gut the 2.4-8 kHz band, so this probe fails that mistake decisively. + const int sourceSampleRate = 48000; + const int targetSampleRate = 16000; + const int edgeGuard = 256; + var samples = GenerateTone(6000, sourceSampleRate); + + var processed = AudioRecordingService.ResampleToSampleRate( + samples, + sourceSampleRate, + targetSampleRate + ); + var baseline = DownsampleThreeToOneUnfiltered(samples, processed.Length); + var baselinePower = MeanSquare(baseline, edgeGuard, baseline.Length - edgeGuard); + var outputPower = MeanSquare(processed, edgeGuard, processed.Length - edgeGuard); + var gainDb = 10 * Math.Log10(outputPower / baselinePower); + var rmsError = RootMeanSquareError( + processed, + baseline, + edgeGuard, + processed.Length - edgeGuard + ); + + Assert.InRange(gainDb, -1.0, 1.0); + Assert.True(rmsError < 0.01, $"Passband-edge RMS sample error was {rmsError:R}."); + } + [Fact] - public void ResampleToSampleRate_DownsamplesToTargetLength() + public void ResampleToSampleRate_DownsamplingPreservesConstantSignalAndFiniteEndpoints() { - var samples = Enumerable.Range(0, 480).Select(i => i / 480f).ToArray(); + const float signal = 0.25f; + var samples = Enumerable.Repeat(signal, 480).ToArray(); var processed = AudioRecordingService.ResampleToSampleRate(samples, 48000, 16000); - Assert.Equal(160, processed.Length); - Assert.Equal(samples[0], processed[0]); + Assert.All( + processed, + sample => + { + Assert.True(float.IsFinite(sample)); + Assert.InRange(sample, signal - 1e-5f, signal + 1e-5f); + } + ); } [Fact] @@ -647,6 +764,62 @@ public void OwningSession_StopsOnce_AndRepeatedStopCannotAffectLaterSession() Assert.Equal(2, streamStopCount); } + private static float[] GenerateTone( + double frequency, + int sampleRate, + double durationSeconds = 0.5, + double amplitude = 0.8, + double phase = 0.37 + ) + { + var samples = new float[(int)(durationSeconds * sampleRate)]; + for (var i = 0; i < samples.Length; i++) + { + samples[i] = (float)(amplitude * Math.Sin(2 * Math.PI * frequency * i / sampleRate + phase)); + } + + return samples; + } + + private static float[] DownsampleThreeToOneUnfiltered(float[] samples, int outputLength) + { + var output = new float[outputLength]; + for (var i = 0; i < output.Length; i++) + { + output[i] = samples[3 * i]; + } + + return output; + } + + private static double MeanSquare(float[] samples, int startIndex, int endIndex) + { + double sumSquares = 0; + for (var i = startIndex; i < endIndex; i++) + { + sumSquares += (double)samples[i] * samples[i]; + } + + return sumSquares / (endIndex - startIndex); + } + + private static double RootMeanSquareError( + float[] actual, + float[] expected, + int startIndex, + int endIndex + ) + { + double sumSquares = 0; + for (var i = startIndex; i < endIndex; i++) + { + var error = (double)actual[i] - expected[i]; + sumSquares += error * error; + } + + return Math.Sqrt(sumSquares / (endIndex - startIndex)); + } + private static AudioRecordingService CreateConfiguredDeviceService( IReadOnlyList devices, int defaultDeviceIndex, From ab005db1ef046e94e48b6ce97686bc7366f30b54 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 23:10:06 +0000 Subject: [PATCH 106/226] =?UTF-8?q?Track=20and=20cancel=20the=20actual=20s?= =?UTF-8?q?pd-say=20utterance=20with=20a=20bounded=20dispatcher=20control?= =?UTF-8?q?=20(audit=20=C2=A75=20M9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/LinuxSystemTtsProvider.cs | 159 +++++++-- .../LinuxSystemTtsProviderTests.cs | 337 ++++++++++++++++-- 2 files changed, 441 insertions(+), 55 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs index 3fbbf4f40..b6ac58a30 100644 --- a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs +++ b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs @@ -13,6 +13,9 @@ public sealed class LinuxSystemTtsProvider : ITtsProviderPlugin private const long PlaybackMillisecondsPerUtf16Character = 200; private const long MinimumPlaybackMilliseconds = 15_000; private const long MaximumPlaybackMilliseconds = 10 * 60 * 1_000; + private static readonly TimeSpan s_dispatcherCancellationTimeout = TimeSpan.FromMilliseconds( + 500 + ); private readonly Func _speechFeedbackCommand; private readonly IProcessRunner _processRunner; @@ -97,11 +100,15 @@ public Task SpeakAsync(TtsSpeakRequest request, Cancellatio var language = NormalizeLanguageHint(request.Language); var args = BuildArguments(command, request.Text, language); IReadOnlyList? fallbackArgs = language is not null && args.Count > 1 - ? [request.Text] + ? BuildDefaultArguments(command, request.Text) : null; + IReadOnlyList? cancellationArgs = command == "spd-say" ? ["-C"] : null; // espeak/espeak-ng and spd-say both own their audio output. Arguments // remain separate argv items so no shell or intermediate audio is needed. + // spd-say waits for END/CANCEL so the session tracks the utterance. Its + // stock CLI exposes only global CANCEL ALL for discarding both current + // and queued messages, so cancellation can affect other dispatcher clients. // If a backend rejects a requested language/voice with a nonzero exit, // the session makes one best-effort default-voice attempt within the same // timeout budget. Launch failures, timeouts, and cancellation never retry. @@ -110,6 +117,8 @@ public Task SpeakAsync(TtsSpeakRequest request, Cancellatio command, args, fallbackArgs, + cancellationArgs, + s_dispatcherCancellationTimeout, CalculatePlaybackTimeout(request.Text.Length), ct ); @@ -135,17 +144,22 @@ private static IReadOnlyList BuildArguments( { if (language is null) { - return [text]; + return BuildDefaultArguments(command, text); } return command switch { "espeak" or "espeak-ng" => ["-v", language, text], - "spd-say" => ["-l", language, text], - _ => [text] + "spd-say" => ["--wait", "-l", language, text], + _ => BuildDefaultArguments(command, text) }; } + private static IReadOnlyList BuildDefaultArguments(string command, string text) + { + return command == "spd-say" ? ["--wait", text] : [text]; + } + internal static TimeSpan CalculatePlaybackTimeout(int utf16CharacterCount) { ArgumentOutOfRangeException.ThrowIfNegative(utf16CharacterCount); @@ -179,6 +193,8 @@ public TaskBackedTtsPlaybackSession( string command, IReadOnlyList args, IReadOnlyList? fallbackArgs, + IReadOnlyList? cancellationArgs, + TimeSpan cancellationTimeout, TimeSpan timeout, CancellationToken ct ) @@ -189,6 +205,8 @@ CancellationToken ct command, args, fallbackArgs, + cancellationArgs, + cancellationTimeout, timeout, _invocationCts.Token ); @@ -263,38 +281,127 @@ private static async Task RunInvocationSequenceAsync( string command, IReadOnlyList args, IReadOnlyList? fallbackArgs, + IReadOnlyList? cancellationArgs, + TimeSpan cancellationTimeout, TimeSpan timeout, CancellationToken ct ) { - var stopwatch = Stopwatch.StartNew(); - var result = await RunInvocationAsync(processRunner, command, args, timeout, ct) - .ConfigureAwait(false); - if ( - fallbackArgs is null - || !result.Started - || result.TimedOut - || result.ExitCode == 0 - ) + try + { + var stopwatch = Stopwatch.StartNew(); + var result = await RunInvocationAsync(processRunner, command, args, timeout, ct) + .ConfigureAwait(false); + if (result.TimedOut) + { + await RunCancellationAsync( + processRunner, + command, + cancellationArgs, + cancellationTimeout + ) + .ConfigureAwait(false); + return result; + } + + if (fallbackArgs is null || !result.Started || result.ExitCode == 0) + { + return result; + } + + ct.ThrowIfCancellationRequested(); + var remainingTimeout = timeout - stopwatch.Elapsed; + if (remainingTimeout <= TimeSpan.Zero) + { + return result; + } + + var fallbackResult = await RunInvocationAsync( + processRunner, + command, + fallbackArgs, + remainingTimeout, + ct + ) + .ConfigureAwait(false); + if (fallbackResult.TimedOut) + { + await RunCancellationAsync( + processRunner, + command, + cancellationArgs, + cancellationTimeout + ) + .ConfigureAwait(false); + } + + return fallbackResult; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { - return result; + await RunCancellationAsync( + processRunner, + command, + cancellationArgs, + cancellationTimeout + ) + .ConfigureAwait(false); + throw; } + } - ct.ThrowIfCancellationRequested(); - var remainingTimeout = timeout - stopwatch.Elapsed; - if (remainingTimeout <= TimeSpan.Zero) + private static async Task RunCancellationAsync( + IProcessRunner processRunner, + string command, + IReadOnlyList? cancellationArgs, + TimeSpan cancellationTimeout + ) + { + if (cancellationArgs is null) { - return result; + return; } - return await RunInvocationAsync( - processRunner, - command, - fallbackArgs, - remainingTimeout, - ct - ) - .ConfigureAwait(false); + try + { + var result = await processRunner + .RunAsync( + command, + cancellationArgs, + timeout: cancellationTimeout, + ct: CancellationToken.None + ) + .ConfigureAwait(false); + if (result.Succeeded) + { + return; + } + + if (result.TimedOut) + { + Debug.WriteLine( + "[LinuxSystemTtsProvider] Speech Dispatcher cancellation timed out." + ); + } + else if (!result.Started) + { + Debug.WriteLine( + "[LinuxSystemTtsProvider] Speech Dispatcher cancellation did not start." + ); + } + else + { + Debug.WriteLine( + $"[LinuxSystemTtsProvider] Speech Dispatcher cancellation exited with code {result.ExitCode}." + ); + } + } + catch (Exception ex) + { + Debug.WriteLine( + $"[LinuxSystemTtsProvider] Speech Dispatcher cancellation failed ({ex.GetType().Name})." + ); + } } private static async Task RunInvocationAsync( diff --git a/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs b/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs index 89eb5dcec..ab4d5c37f 100644 --- a/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs @@ -28,6 +28,7 @@ string text var completedCount = 0; session.Completed += (_, _) => completedCount++; Assert.Equal(1, completedCount); + Assert.False(runner.SurplusInvocationObserved); } [Theory] @@ -49,6 +50,7 @@ public async Task SpeakAsync_routes_espeak_directly_with_text_as_one_argv_item(s Assert.DoesNotContain(invocation.FileName, s_audioPlaybackCommands); Assert.Equal(TimeSpan.FromSeconds(15), invocation.Timeout); Assert.Null(invocation.StandardInput); + Assert.False(runner.SurplusInvocationObserved); } [Theory] @@ -69,22 +71,33 @@ await provider.SpeakAsync( Assert.Equal(command, invocation.FileName); Assert.Equal(["-v", "fr", text], invocation.Args); Assert.Null(invocation.StandardInput); + Assert.False(runner.SurplusInvocationObserved); } [Fact] - public async Task SpeakAsync_routes_spd_say_through_runner_with_existing_argument_contract() + public async Task SpeakAsync_routes_spd_say_with_wait_and_text_as_one_argv_item() { const string text = "keep this as one argument"; var runner = ControlledProcessRunner.WithImmediateResult(Success()); using var provider = CreateProvider("spd-say", runner); - await provider.SpeakAsync(new TtsSpeakRequest(text), CancellationToken.None); + var session = await provider.SpeakAsync( + new TtsSpeakRequest(text), + CancellationToken.None + ); var invocation = Assert.Single(runner.Invocations); Assert.Equal("spd-say", invocation.FileName); - Assert.Equal([text], invocation.Args); + Assert.Equal(["--wait", text], invocation.Args); + Assert.NotEqual("sh", invocation.FileName); + Assert.DoesNotContain(invocation.FileName, s_audioPlaybackCommands); Assert.Equal(TimeSpan.FromSeconds(15), invocation.Timeout); Assert.Null(invocation.StandardInput); + session.Stop(); + session.Stop(); + Assert.Single(runner.Invocations); + Assert.Equal(0, runner.CancellationCount); + Assert.False(runner.SurplusInvocationObserved); } [Fact] @@ -101,10 +114,11 @@ await provider.SpeakAsync( var invocation = Assert.Single(runner.Invocations); Assert.Equal("spd-say", invocation.FileName); - Assert.Equal(["-l", "pt-BR", text], invocation.Args); + Assert.Equal(["--wait", "-l", "pt-BR", text], invocation.Args); Assert.NotEqual("sh", invocation.FileName); Assert.DoesNotContain(invocation.FileName, s_audioPlaybackCommands); Assert.Null(invocation.StandardInput); + Assert.False(runner.SurplusInvocationObserved); } [Theory] @@ -128,6 +142,7 @@ await provider.SpeakAsync( var invocation = Assert.Single(runner.Invocations); Assert.Equal([text], invocation.Args); + Assert.False(runner.SurplusInvocationObserved); } [Fact] @@ -144,6 +159,7 @@ await provider.SpeakAsync( var invocation = Assert.Single(runner.Invocations); Assert.Equal([text], invocation.Args); + Assert.False(runner.SurplusInvocationObserved); } [Fact] @@ -172,7 +188,7 @@ public async Task Rejected_localized_invocation_retries_default_voice_once_with_ await completion.Task.WaitAsync(s_testGuard); Assert.False(session.IsActive); Assert.Equal(1, Volatile.Read(ref completedCount)); - Assert.Equal(2, runner.Invocations.Count); + Assert.Equal(2, runner.Invocations.Length); var primary = runner.Invocations[0]; var fallback = runner.Invocations[1]; Assert.Equal("espeak-ng", primary.FileName); @@ -188,22 +204,59 @@ public async Task Rejected_localized_invocation_retries_default_voice_once_with_ ); Assert.Null(primary.StandardInput); Assert.Null(fallback.StandardInput); + Assert.False(runner.SurplusInvocationObserved); + } + + [Fact] + public async Task Rejected_localized_spd_say_invocation_retries_waiting_default_voice_once() + { + const string text = "fallback text"; + var runner = ControlledProcessRunner.WithImmediateResults( + new ProcessRunResult(true, false, 23, "", "voice unavailable"), + Success() + ); + using var provider = CreateProvider("spd-say", runner); + + var session = await provider.SpeakAsync( + new TtsSpeakRequest(text, "nl-BE"), + CancellationToken.None + ); + var completion = NewCompletionSignal(); + session.Completed += (_, _) => completion.TrySetResult(); + + await completion.Task.WaitAsync(s_testGuard); + Assert.False(session.IsActive); + Assert.Equal(2, runner.Invocations.Length); + var primary = runner.Invocations[0]; + var fallback = runner.Invocations[1]; + Assert.Equal("spd-say", primary.FileName); + Assert.Equal(["--wait", "-l", "nl-BE", text], primary.Args); + Assert.Equal("spd-say", fallback.FileName); + Assert.Equal(["--wait", text], fallback.Args); + Assert.NotNull(primary.Timeout); + Assert.NotNull(fallback.Timeout); + Assert.True(fallback.Timeout > TimeSpan.Zero); + Assert.True( + fallback.Timeout < primary.Timeout, + $"Expected fallback timeout {fallback.Timeout} to be less than primary timeout {primary.Timeout}." + ); + Assert.Null(primary.StandardInput); + Assert.Null(fallback.StandardInput); + Assert.False(runner.SurplusInvocationObserved); } [Theory] [InlineData("success")] [InlineData("not-started")] - [InlineData("timed-out")] public async Task Localized_invocation_does_not_retry_without_voice_rejection(string outcome) { var result = outcome switch { "success" => Success(), "not-started" => new ProcessRunResult(false, false, -1, "", "launch failed"), - "timed-out" => new ProcessRunResult(true, true, -1, "", ""), _ => throw new ArgumentOutOfRangeException(nameof(outcome)) }; - var runner = ControlledProcessRunner.WithImmediateResults(result, Success()); + var runner = ControlledProcessRunner.WithImmediateResult(result); using var provider = CreateProvider("spd-say", runner); var session = await provider.SpeakAsync( @@ -215,7 +268,65 @@ public async Task Localized_invocation_does_not_retry_without_voice_rejection(st await completion.Task.WaitAsync(s_testGuard); var invocation = Assert.Single(runner.Invocations); - Assert.Equal(["-l", "it", "say once"], invocation.Args); + Assert.Equal(["--wait", "-l", "it", "say once"], invocation.Args); + Assert.False(runner.SurplusInvocationObserved); + } + + [Fact] + public async Task Localized_spd_say_timeout_runs_only_bounded_dispatcher_cancellation() + { + const string text = "say once"; + var runner = ControlledProcessRunner.WithImmediateResults( + new ProcessRunResult(true, true, -1, "", ""), + Success() + ); + using var provider = CreateProvider("spd-say", runner); + + var session = await provider.SpeakAsync( + new TtsSpeakRequest(text, "it"), + CancellationToken.None + ); + var completion = NewCompletionSignal(); + session.Completed += (_, _) => completion.TrySetResult(); + + await completion.Task.WaitAsync(s_testGuard); + Assert.False(session.IsActive); + Assert.Equal(2, runner.Invocations.Length); + var primary = runner.Invocations[0]; + var cancellation = runner.Invocations[1]; + Assert.Equal("spd-say", primary.FileName); + Assert.Equal(["--wait", "-l", "it", text], primary.Args); + Assert.Equal(LinuxSystemTtsProvider.CalculatePlaybackTimeout(text.Length), primary.Timeout); + Assert.Null(primary.StandardInput); + AssertDispatcherCancellation(cancellation); + Assert.False(runner.SurplusInvocationObserved); + } + + [Fact] + public async Task Timed_out_spd_say_fallback_runs_one_dispatcher_cancellation() + { + const string text = "fallback timeout"; + var runner = ControlledProcessRunner.WithImmediateResults( + new ProcessRunResult(true, false, 23, "", "voice unavailable"), + new ProcessRunResult(true, true, -1, "", ""), + Success() + ); + using var provider = CreateProvider("spd-say", runner); + + var session = await provider.SpeakAsync( + new TtsSpeakRequest(text, "sv"), + CancellationToken.None + ); + var completion = NewCompletionSignal(); + session.Completed += (_, _) => completion.TrySetResult(); + + await completion.Task.WaitAsync(s_testGuard); + Assert.False(session.IsActive); + Assert.Equal(3, runner.Invocations.Length); + Assert.Equal(["--wait", "-l", "sv", text], runner.Invocations[0].Args); + Assert.Equal(["--wait", text], runner.Invocations[1].Args); + AssertDispatcherCancellation(runner.Invocations[2]); + Assert.False(runner.SurplusInvocationObserved); } [Theory] @@ -242,6 +353,7 @@ await provider.SpeakAsync( Assert.Equal(TimeSpan.FromMilliseconds(expectedMilliseconds), invocation.Timeout); Assert.True(invocation.Timeout > TimeSpan.Zero); Assert.True(invocation.Timeout <= TimeSpan.FromMinutes(10)); + Assert.False(runner.SurplusInvocationObserved); } [Fact] @@ -253,10 +365,10 @@ public void CalculatePlaybackTimeout_is_overflow_safe_for_maximum_input_length() } [Fact] - public async Task Pending_runner_is_active_and_success_completes_once() + public async Task Pending_spd_say_waiter_is_active_until_utterance_invocation_completes() { var runner = new ControlledProcessRunner(); - using var provider = CreateProvider("espeak-ng", runner); + using var provider = CreateProvider("spd-say", runner); var session = await provider.SpeakAsync( new TtsSpeakRequest("pending"), CancellationToken.None @@ -271,20 +383,26 @@ public async Task Pending_runner_is_active_and_success_completes_once() }; Assert.True(session.IsActive); - runner.Complete(Success()); + Assert.Equal(["--wait", "pending"], Assert.Single(runner.Invocations).Args); + Assert.False(completion.Task.IsCompleted); + runner.CompleteInvocation(1, Success()); await completion.Task.WaitAsync(s_testGuard); Assert.False(session.IsActive); Assert.Equal(1, Volatile.Read(ref completedCount)); session.Stop(); Assert.Equal(0, runner.CancellationCount); + Assert.Single(runner.Invocations); + Assert.False(runner.SurplusInvocationObserved); } - [Fact] - public async Task Stop_is_idempotent_and_cancels_pending_runner_once() + [Theory] + [InlineData("espeak")] + [InlineData("espeak-ng")] + public async Task Stop_is_idempotent_for_espeak_without_dispatcher_control(string command) { var runner = new ControlledProcessRunner(); - using var provider = CreateProvider("espeak", runner); + using var provider = CreateProvider(command, runner); var session = await provider.SpeakAsync( new TtsSpeakRequest("stop me"), CancellationToken.None @@ -306,6 +424,119 @@ public async Task Stop_is_idempotent_and_cancels_pending_runner_once() Assert.False(session.IsActive); Assert.Equal(1, runner.CancellationCount); Assert.Equal(1, Volatile.Read(ref completedCount)); + var invocation = Assert.Single(runner.Invocations); + Assert.Equal(command, invocation.FileName); + Assert.Equal(["stop me"], invocation.Args); + Assert.DoesNotContain("-C", invocation.Args); + Assert.DoesNotContain("-S", invocation.Args); + Assert.False(runner.SurplusInvocationObserved); + } + + [Theory] + [InlineData("stop")] + [InlineData("caller-token")] + public async Task Spd_say_cancellation_waits_for_one_bounded_dispatcher_control( + string cancellationSource + ) + { + const string text = "cancel pending speech"; + var runner = ControlledProcessRunner.WithPendingResults(2); + using var provider = CreateProvider("spd-say", runner); + using var callerCts = new CancellationTokenSource(); + var session = await provider.SpeakAsync( + new TtsSpeakRequest(text, "de"), + callerCts.Token + ); + var completion = NewCompletionSignal(); + var completedCount = 0; + session.Completed += (_, _) => + { + // ReSharper disable once AccessToModifiedClosure -- completedCount is deliberately shared between the completion handler and the test body (read via Volatile.Read); interlocked/volatile access is the intended synchronization. + Interlocked.Increment(ref completedCount); + completion.TrySetResult(); + }; + + if (cancellationSource == "stop") + { + session.Stop(); + session.Stop(); + session.Stop(); + } + else + { + // ReSharper disable once MethodHasAsyncOverload -- synchronous Cancel signals cancellation before the test proceeds; CancelAsync would defer callbacks. + callerCts.Cancel(); + } + + // ReSharper disable once MethodSupportsCancellation -- the 2s guard must not be tied to callerCts; forwarding its token would abort the wait on the caller-cancel path. + await runner.WaitForInvocationAsync(2).WaitAsync(s_testGuard); + Assert.True(session.IsActive); + Assert.False(completion.Task.IsCompleted); + Assert.Equal(1, runner.CancellationCount); + Assert.Equal(2, runner.Invocations.Length); + Assert.Equal( + ["--wait", "-l", "de", text], + runner.Invocations[0].Args + ); + AssertDispatcherCancellation(runner.Invocations[1]); + + runner.CompleteInvocation(2, Success()); + + // ReSharper disable once MethodSupportsCancellation -- the 2s guard must not be tied to callerCts; forwarding its token would abort the wait on the caller-cancel path. + await completion.Task.WaitAsync(s_testGuard); + Assert.False(session.IsActive); + Assert.Equal(1, Volatile.Read(ref completedCount)); + Assert.Equal(2, runner.Invocations.Length); + Assert.False(runner.SurplusInvocationObserved); + } + + [Theory] + [InlineData("failed")] + [InlineData("timed-out")] + [InlineData("throwing")] + public async Task Dispatcher_control_failure_still_completes_once_without_recursion( + string outcome + ) + { + ProcessRunResult? controlResult = outcome switch + { + "failed" => new ProcessRunResult(false, false, -1, "", "launch failed"), + "timed-out" => new ProcessRunResult(true, true, -1, "", ""), + "throwing" => null, + _ => throw new ArgumentOutOfRangeException(nameof(outcome)) + }; + var runner = ControlledProcessRunner.WithPendingResults(2); + using var provider = CreateProvider("spd-say", runner); + var session = await provider.SpeakAsync( + new TtsSpeakRequest("best effort cleanup"), + CancellationToken.None + ); + var completion = NewCompletionSignal(); + var completedCount = 0; + session.Completed += (_, _) => + { + // ReSharper disable once AccessToModifiedClosure -- completedCount is deliberately shared between the completion handler and the test body (read via Volatile.Read); interlocked/volatile access is the intended synchronization. + Interlocked.Increment(ref completedCount); + completion.TrySetResult(); + }; + + session.Stop(); + await runner.WaitForInvocationAsync(2).WaitAsync(s_testGuard); + if (controlResult is null) + { + runner.FailInvocation(2, new InvalidOperationException("control failed")); + } + else + { + runner.CompleteInvocation(2, controlResult); + } + + await completion.Task.WaitAsync(s_testGuard); + Assert.False(session.IsActive); + Assert.Equal(1, Volatile.Read(ref completedCount)); + Assert.Equal(2, runner.Invocations.Length); + AssertDispatcherCancellation(runner.Invocations[1]); + Assert.False(runner.SurplusInvocationObserved); } [Fact] @@ -328,10 +559,13 @@ public async Task Pending_default_voice_fallback_stays_active_and_stop_completes }; Assert.True(session.IsActive); - runner.Complete(new ProcessRunResult(true, false, 17, "", "voice unavailable")); + runner.CompleteInvocation( + 1, + new ProcessRunResult(true, false, 17, "", "voice unavailable") + ); await runner.WaitForInvocationAsync(2).WaitAsync(s_testGuard); Assert.True(session.IsActive); - Assert.Equal(2, runner.Invocations.Count); + Assert.Equal(2, runner.Invocations.Length); Assert.Equal(["-v", "pl", text], runner.Invocations[0].Args); Assert.Equal([text], runner.Invocations[1].Args); @@ -342,7 +576,8 @@ public async Task Pending_default_voice_fallback_stays_active_and_stop_completes Assert.False(session.IsActive); Assert.Equal(1, runner.CancellationCount); Assert.Equal(1, Volatile.Read(ref completedCount)); - Assert.Equal(2, runner.Invocations.Count); + Assert.Equal(2, runner.Invocations.Length); + Assert.False(runner.SurplusInvocationObserved); } [Theory] @@ -372,6 +607,8 @@ public async Task Failed_runner_results_end_session_and_complete_once(string fai Assert.Equal(1, completedCount); session.Stop(); Assert.Equal(0, runner.CancellationCount); + Assert.Single(runner.Invocations); + Assert.False(runner.SurplusInvocationObserved); } [Fact] @@ -404,7 +641,7 @@ public async Task Stop_and_runner_completion_race_remains_idempotent() var runnerTask = Task.Run(async () => { await start.Task; - runner.Complete(Success()); + runner.CompleteInvocation(1, Success()); }); start.TrySetResult(); @@ -413,6 +650,8 @@ public async Task Stop_and_runner_completion_race_remains_idempotent() Assert.False(session.IsActive); Assert.Equal(1, Volatile.Read(ref completedCount)); Assert.InRange(runner.CancellationCount, 0, 1); + Assert.Single(runner.Invocations); + Assert.False(runner.SurplusInvocationObserved); } } @@ -425,6 +664,14 @@ IProcessRunner processRunner return new LinuxSystemTtsProvider(settings.Object, processRunner, command); } + private static void AssertDispatcherCancellation(Invocation invocation) + { + Assert.Equal("spd-say", invocation.FileName); + Assert.Equal(["-C"], invocation.Args); + Assert.Equal(TimeSpan.FromMilliseconds(500), invocation.Timeout); + Assert.Null(invocation.StandardInput); + } + private static ProcessRunResult Success() { return new ProcessRunResult(true, false, 0, "", ""); @@ -437,10 +684,12 @@ private static TaskCompletionSource NewCompletionSignal() private sealed class ControlledProcessRunner : IProcessRunner { + private readonly Lock _sync = new(); private readonly ControlledResult[] _results; + private readonly List _invocations = []; private int _cancellationCount; - private int _completionIndex; private int _invocationIndex; + private int _surplusInvocationCount; public ControlledProcessRunner() : this(1) { } @@ -454,9 +703,23 @@ private ControlledProcessRunner(int resultCount) .ToArray(); } - public List Invocations { get; } = []; + public Invocation[] Invocations + { + get + { + lock (_sync) + { + return _invocations.ToArray(); + } + } + } + public int CancellationCount => Volatile.Read(ref _cancellationCount); + private int SurplusInvocationCount => Volatile.Read(ref _surplusInvocationCount); + + public bool SurplusInvocationObserved => SurplusInvocationCount != 0; + public static ControlledProcessRunner WithImmediateResult(ProcessRunResult result) { return WithImmediateResults(result); @@ -467,9 +730,9 @@ params ProcessRunResult[] results ) { var runner = new ControlledProcessRunner(results.Length); - foreach (var result in results) + for (var index = 0; index < results.Length; index++) { - runner.Complete(result); + runner.CompleteInvocation(index + 1, results[index]); } return runner; @@ -491,6 +754,16 @@ public Task WaitForInvocationAsync(int invocationNumber) return _results[invocationNumber - 1].Invoked.Task; } + public void CompleteInvocation(int invocationNumber, ProcessRunResult result) + { + GetResult(invocationNumber).Completion.TrySetResult(result); + } + + public void FailInvocation(int invocationNumber, Exception exception) + { + GetResult(invocationNumber).Completion.TrySetException(exception); + } + public async Task RunAsync( string fileName, IReadOnlyList args, @@ -501,13 +774,19 @@ public async Task RunAsync( ) { var invocationIndex = Interlocked.Increment(ref _invocationIndex) - 1; + lock (_sync) + { + _invocations.Add(new Invocation(fileName, args.ToArray(), standardInput, timeout)); + } + if (invocationIndex >= _results.Length) { + Interlocked.Increment(ref _surplusInvocationCount); throw new InvalidOperationException("No controlled process result remains."); } var result = _results[invocationIndex]; - Invocations.Add(new Invocation(fileName, args.ToArray(), standardInput, timeout)); + result.Invoked.TrySetResult(); if (result.Completion.Task.IsCompleted) { @@ -525,15 +804,15 @@ public async Task RunAsync( } } - public void Complete(ProcessRunResult result) + private ControlledResult GetResult(int invocationNumber) { - var completionIndex = Interlocked.Increment(ref _completionIndex) - 1; - if (completionIndex >= _results.Length) + ArgumentOutOfRangeException.ThrowIfLessThan(invocationNumber, 1); + if (invocationNumber > _results.Length) { - throw new InvalidOperationException("No controlled process completion remains."); + throw new ArgumentOutOfRangeException(nameof(invocationNumber)); } - _results[completionIndex].Completion.TrySetResult(result); + return _results[invocationNumber - 1]; } private sealed class ControlledResult From fa005ce633c794baacf731a86159745198e9d46a Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 23:44:54 +0000 Subject: [PATCH 107/226] =?UTF-8?q?Stage=20live=20restores=20and=20apply?= =?UTF-8?q?=20them=20transactionally=20at=20startup=20before=20caches=20bu?= =?UTF-8?q?ild=20(audit=20=C2=A76=20H3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/TypeWhisper.Linux/Program.cs | 71 ++- .../Resources/Localization/en.json | 1 + .../Services/SettingsBackupService.cs | 598 ++++++++++++++++-- .../Sections/AboutSectionViewModel.cs | 16 +- .../Views/Sections/AboutSection.axaml.cs | 6 +- .../SettingsBackupServiceTests.cs | 554 +++++++++++++++- 6 files changed, 1168 insertions(+), 78 deletions(-) diff --git a/src/TypeWhisper.Linux/Program.cs b/src/TypeWhisper.Linux/Program.cs index 4e9cd6690..c4e303d9f 100644 --- a/src/TypeWhisper.Linux/Program.cs +++ b/src/TypeWhisper.Linux/Program.cs @@ -82,6 +82,11 @@ public static int Main(string[] args) if (!string.IsNullOrEmpty(probeError)) { Trace.WriteLine($"[Program] Control socket probe: {probeError}"); + Console.Error.WriteLine( + "TypeWhisper could not verify that no other instance is running. Startup was canceled." + ); + LinuxStartupNotification.NotifyComplete(); + return 1; } BootTrace.Stage("ControlSocketClient.TrySendToggle (no live peer)"); @@ -92,6 +97,14 @@ public static int Main(string[] args) LinuxStartupNotification.NotifyComplete(); // clear launcher's busy cursor return 0; } + else if (File.Exists(socketPath)) + { + Console.Error.WriteLine( + "TypeWhisper could not verify that no other instance is running. Startup was canceled." + ); + LinuxStartupNotification.NotifyComplete(); + return 1; + } else { BootTrace.Stage("ControlSocketClient.IsLivePeer (none)"); @@ -101,6 +114,62 @@ public static int Main(string[] args) { Trace.WriteLine($"[Program] Control socket probe failed: {ex.Message}"); BootTrace.Stage($"control socket probe threw: {ex.GetType().Name}"); + Console.Error.WriteLine( + "TypeWhisper could not verify that no other instance is running. Startup was canceled." + ); + LinuxStartupNotification.NotifyComplete(); + return 1; + } + + var restoreResult = SettingsBackupService.ApplyPendingRestoreAtStartup( + TypeWhisperEnvironment.BasePath + ); + switch (restoreResult.Status) + { + case StartupRestoreStatus.None: + break; + + case StartupRestoreStatus.Applied: + Trace.WriteLine("[Program] Applied the staged settings restore."); + BootTrace.Stage("staged settings restore applied"); + break; + + case StartupRestoreStatus.PriorGenerationRestored: + Console.Error.WriteLine( + "The staged settings restore could not be applied. The prior settings generation was restored." + ); + if (restoreResult.Error is not null) + { + Trace.WriteLine( + $"[Program] Settings restore rolled back: {restoreResult.Error}" + ); + } + + BootTrace.Stage("staged settings restore rolled back"); + break; + + case StartupRestoreStatus.LockUnavailable: + Console.Error.WriteLine( + "Another TypeWhisper startup is applying a staged settings restore. Startup was canceled." + ); + Trace.WriteLine($"[Program] Restore lock unavailable: {restoreResult.Error}"); + LinuxStartupNotification.NotifyComplete(); + return 1; + + case StartupRestoreStatus.UnresolvedFailure: + Console.Error.WriteLine( + "TypeWhisper could not safely recover the staged settings restore. Startup was canceled." + ); + Trace.WriteLine($"[Program] Settings restore recovery failed: {restoreResult.Error}"); + LinuxStartupNotification.NotifyComplete(); + return 1; + + default: + Console.Error.WriteLine( + "TypeWhisper encountered an unknown staged restore state. Startup was canceled." + ); + LinuxStartupNotification.NotifyComplete(); + return 1; } Services = BuildServices(); @@ -188,4 +257,4 @@ private static bool IsImeDisabled() || value.Equals("yes", StringComparison.OrdinalIgnoreCase) ); } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index b96310cab..8afb6cde6 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -6,6 +6,7 @@ "About.BackupInvalid": "This file is not a valid TypeWhisper settings backup.", "About.BackupInvalidManifest": "The TypeWhisper backup manifest is invalid or unreadable. Restore was canceled.", "About.BackupRestored": "Backup restored from {0} file(s). Some restored settings may require an app restart.", + "About.BackupStaged": "Backup validated and staged from {0} file(s). Quit and reopen TypeWhisper to apply it.", "About.BackupStatusDefault": "Back up settings, profiles, snippets, and plugin data.", "About.BackupUnsafePath": "This backup contains an unsafe path and may have been tampered with: {0}", "About.BackupUnsupportedPath": "This backup contains an unsupported path and may have been tampered with: {0}", diff --git a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs index e872c6db7..151696d31 100644 --- a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs +++ b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs @@ -9,9 +9,38 @@ namespace TypeWhisper.Linux.Services; // ReSharper disable once NotAccessedPositionalProperty.Global UncompressedBytes carried in the backup result record's data shape public sealed record SettingsBackupResult(int FileCount, long UncompressedBytes); +internal enum StartupRestoreStatus +{ + None, + Applied, + PriorGenerationRestored, + LockUnavailable, + UnresolvedFailure +} + +internal sealed record StartupRestoreResult( + StartupRestoreStatus Status, + Exception? Error = null +); + +internal delegate void RestoreCommitObserver(string relativePath, int committedFileCount); + +internal sealed class RestoreInterruptionException(string message) : Exception(message); + public sealed class SettingsBackupService { private const string ManifestEntryName = "typewhisper-backup.json"; + private const string PendingDirectoryName = ".typewhisper-restore-pending"; + private const string StagingDirectoryPrefix = ".typewhisper-restore-staging-"; + private const string RestoreLockFileName = ".typewhisper-restore.lock"; + private const string PendingMarkerFileName = "pending-state.json"; + private const string JournalFileName = "restore-journal.json"; + private const string ContentDirectoryName = "content"; + private const string PreparedDirectoryName = "prepared"; + private const string RollbackDirectoryName = "rollback"; + private const string RollbackWorkDirectoryName = "rollback-work"; + private const int PendingStateVersion = 1; + private const int JournalVersion = 1; // The real manifest is a few hundred bytes; cap it so a decompression-bomb // manifest can't be materialized into memory before shape validation runs. @@ -39,18 +68,34 @@ public sealed class SettingsBackupService private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + private static readonly JsonSerializerOptions s_transactionJsonOptions = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + private readonly string _basePath; + private readonly RestoreCommitObserver? _commitObserver; + private readonly Action? _cleanupObserver; public SettingsBackupService() : this(TypeWhisperEnvironment.BasePath) { } - internal SettingsBackupService(string basePath) + internal SettingsBackupService( + string basePath, + RestoreCommitObserver? commitObserver = null, + Action? cleanupObserver = null + ) { _basePath = Path.GetFullPath(basePath); + _commitObserver = commitObserver; + _cleanupObserver = cleanupObserver; } + internal string PendingDirectoryPath => Path.Join(_basePath, PendingDirectoryName); + public SettingsBackupResult CreateBackup(string destinationZipPath) { if (string.IsNullOrWhiteSpace(destinationZipPath)) @@ -130,17 +175,23 @@ var path in Directory.EnumerateFiles(rootPath, "*", SearchOption.AllDirectories) return new SettingsBackupResult(fileCount, bytes); } - public SettingsBackupResult RestoreBackup(string sourceZipPath) + public SettingsBackupResult StageRestore(string sourceZipPath) { if (string.IsNullOrWhiteSpace(sourceZipPath) || !File.Exists(sourceZipPath)) { throw new FileNotFoundException("Backup file was not found.", sourceZipPath); } - // Extract into a temp dir first; only copy into _basePath after all - // entries are validated, so a corrupt archive can't leave a mixed state. - var tempDir = Path.Join(Path.GetTempPath(), $"typewhisper-restore-{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempDir); + Directory.CreateDirectory(_basePath); + // Keep staging on the same filesystem as the live tree. Publication is a + // single directory rename, and the running process never reads this generation. + var stagingDirectory = Path.Join( + _basePath, + $"{StagingDirectoryPrefix}{Guid.NewGuid():N}" + ); + var contentDirectory = Path.Join(stagingDirectory, ContentDirectoryName); + Directory.CreateDirectory(contentDirectory); + var published = false; try { @@ -177,70 +228,508 @@ public SettingsBackupResult RestoreBackup(string sourceZipPath) continue; } - var targetPath = GetSafeDestinationPath(tempDir, entry.FullName); + var targetPath = GetSafeDestinationPath(contentDirectory, entry.FullName); Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); entry.ExtractToFile(targetPath, true); fileCount++; bytes += entry.Length; } - Directory.CreateDirectory(_basePath); + WriteDurableJson( + Path.Join(stagingDirectory, PendingMarkerFileName), + new PendingState + { + Version = PendingStateVersion, + FileCount = fileCount, + UncompressedBytes = bytes + } + ); - foreach (var relativeFile in s_rootFiles) + // A second staging request may have won the publication race while + // this archive was being extracted. Never replace that valid request. + if (PendingPathExists()) + { + throw new InvalidOperationException( + "A settings restore is already staged. Quit and reopen TypeWhisper to apply it." + ); + } + + Directory.Move(stagingDirectory, PendingDirectoryPath); + published = true; + + return new SettingsBackupResult(fileCount, bytes); + } + finally + { + if (!published) { - var restoredPath = Path.Join(tempDir, relativeFile); - if (!File.Exists(restoredPath)) + try { - continue; + if (Directory.Exists(stagingDirectory)) + { + Directory.Delete(stagingDirectory, true); + } + } + catch + { + // Best effort cleanup of only the unique directory this call created. } + } + } + } - var targetPath = Path.Join(_basePath, relativeFile); - Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); - File.Copy(restoredPath, targetPath, true); + internal static StartupRestoreResult ApplyPendingRestoreAtStartup(string basePath) + { + return new SettingsBackupService(basePath).ApplyPendingRestoreAtStartup(); + } + + internal StartupRestoreResult ApplyPendingRestoreAtStartup() + { + FileStream restoreLock; + try + { + restoreLock = AcquireStartupRestoreLock(_basePath); + } + catch (IOException ex) + { + return new StartupRestoreResult(StartupRestoreStatus.LockUnavailable, ex); + } + catch (Exception ex) + { + return new StartupRestoreResult(StartupRestoreStatus.UnresolvedFailure, ex); + } + + using (restoreLock) + { + try + { + return ApplyPendingRestoreUnderLock(); } + catch (RestoreInterruptionException) + { + // Test seam: models a process disappearing, skipping the ordinary + // caught-exception rollback below. + throw; + } + catch (Exception ex) + { + return new StartupRestoreResult(StartupRestoreStatus.UnresolvedFailure, ex); + } + } + } - foreach (var root in s_backupDirectoryRoots) + internal static FileStream AcquireStartupRestoreLock(string basePath) + { + var fullBasePath = Path.GetFullPath(basePath); + Directory.CreateDirectory(fullBasePath); + return new FileStream( + Path.Join(fullBasePath, RestoreLockFileName), + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None + ); + } + + private StartupRestoreResult ApplyPendingRestoreUnderLock() + { + if (File.Exists(PendingDirectoryPath)) + { + throw new InvalidDataException( + "The staged settings restore path is not a directory." + ); + } + + if (!Directory.Exists(PendingDirectoryPath)) + { + return new StartupRestoreResult(StartupRestoreStatus.None); + } + + var journalPath = Path.Join(PendingDirectoryPath, JournalFileName); + if (File.Exists(journalPath)) + { + var journal = ReadAndValidateJournal(journalPath); + return journal.Phase switch + { + RestoreJournalPhase.Prepared => RollBackPreparedTransaction( + journal, + new IOException("An interrupted settings restore was recovered.") + ), + RestoreJournalPhase.Committed => FinishCommittedTransaction(), + RestoreJournalPhase.RolledBack => FinishRolledBackTransaction(), + _ => throw new InvalidDataException("The settings restore journal phase is invalid.") + }; + } + + _ = ReadAndValidatePendingState(); + var candidates = EnumeratePendingCandidates(); + var items = candidates + .Select(relativePath => new RestoreJournalItem + { + RelativePath = relativePath, + OriginallyExisted = File.Exists(GetLiveTargetPath(relativePath)) + }) + .ToArray(); + + try + { + PrepareTransactionFiles(items); + } + catch (Exception ex) + { + MarkUncommittedRequestRolledBackBestEffort(items); + return new StartupRestoreResult(StartupRestoreStatus.PriorGenerationRestored, ex); + } + + var preparedJournal = new RestoreJournal + { + Version = JournalVersion, + Phase = RestoreJournalPhase.Prepared, + Items = items + }; + + try + { + WriteJournal(preparedJournal); + } + catch (Exception ex) + { + MarkUncommittedRequestRolledBackBestEffort(items); + return new StartupRestoreResult(StartupRestoreStatus.PriorGenerationRestored, ex); + } + + try + { + for (var index = 0; index < items.Length; index++) + { + var item = items[index]; + var preparedPath = GetPendingArtifactPath( + PreparedDirectoryName, + item.RelativePath + ); + var targetPath = GetLiveTargetPath(item.RelativePath); + File.Move(preparedPath, targetPath, true); + _commitObserver?.Invoke(item.RelativePath, index + 1); + } + + WriteJournal(CloneJournalWithPhase(preparedJournal, RestoreJournalPhase.Committed)); + } + catch (RestoreInterruptionException) + { + throw; + } + catch (Exception ex) + { + return RollBackPreparedTransaction(preparedJournal, ex); + } + + TryCleanupPendingDirectory(); + return new StartupRestoreResult(StartupRestoreStatus.Applied); + } + + private string[] EnumeratePendingCandidates() + { + var contentDirectory = Path.Join(PendingDirectoryPath, ContentDirectoryName); + if (!Directory.Exists(contentDirectory)) + { + throw new InvalidDataException("The staged settings restore content is missing."); + } + + return Directory + .EnumerateFiles(contentDirectory, "*", SearchOption.AllDirectories) + .Select(path => NormalizeEntryName(Path.GetRelativePath(contentDirectory, path))) + .Select(ValidateRelativeTargetPath) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + } + + private bool PendingPathExists() + { + return Directory.Exists(PendingDirectoryPath) || File.Exists(PendingDirectoryPath); + } + + private void PrepareTransactionFiles(IReadOnlyList items) + { + foreach (var item in items) + { + var relativePath = ValidateRelativeTargetPath(item.RelativePath); + var sourcePath = GetSafeDestinationPath( + Path.Join(PendingDirectoryPath, ContentDirectoryName), + relativePath + ); + var preparedPath = GetPendingArtifactPath(PreparedDirectoryName, relativePath); + var targetPath = GetLiveTargetPath(relativePath); + + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + CopyFileDurable(sourcePath, preparedPath); + + if (item.OriginallyExisted) + { + CopyFileDurable( + targetPath, + GetPendingArtifactPath(RollbackDirectoryName, relativePath) + ); + } + } + } + + private StartupRestoreResult RollBackPreparedTransaction( + RestoreJournal journal, + Exception applyError + ) + { + try + { + foreach (var item in journal.Items) { - var restoredRoot = Path.Join(tempDir, root); - if (!Directory.Exists(restoredRoot)) + var targetPath = GetLiveTargetPath(item.RelativePath); + if (!item.OriginallyExisted) { + File.Delete(targetPath); continue; } - var targetRoot = Path.Join(_basePath, root); - Directory.CreateDirectory(targetRoot); - - foreach ( - var restoredFile in Directory.EnumerateFiles( - restoredRoot, - "*", - SearchOption.AllDirectories - ) - ) + var rollbackPath = GetPendingArtifactPath( + RollbackDirectoryName, + item.RelativePath + ); + if (!File.Exists(rollbackPath)) { - var relativePath = Path.GetRelativePath(restoredRoot, restoredFile); - var targetPath = Path.Join(targetRoot, relativePath); - Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); - File.Copy(restoredFile, targetPath, true); + throw new InvalidDataException( + $"The rollback snapshot for '{item.RelativePath}' is missing." + ); } + + var rollbackWorkPath = GetPendingArtifactPath( + RollbackWorkDirectoryName, + item.RelativePath + ); + CopyFileDurable(rollbackPath, rollbackWorkPath); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.Move(rollbackWorkPath, targetPath, true); } - return new SettingsBackupResult(fileCount, bytes); + WriteJournal(CloneJournalWithPhase(journal, RestoreJournalPhase.RolledBack)); } - finally + catch (Exception rollbackError) { - try - { - if (Directory.Exists(tempDir)) + return new StartupRestoreResult( + StartupRestoreStatus.UnresolvedFailure, + new AggregateException( + "The settings restore failed and its prior generation could not be fully restored.", + applyError, + rollbackError + ) + ); + } + + TryCleanupPendingDirectory(); + return new StartupRestoreResult( + StartupRestoreStatus.PriorGenerationRestored, + applyError + ); + } + + private StartupRestoreResult FinishCommittedTransaction() + { + TryCleanupPendingDirectory(); + return new StartupRestoreResult(StartupRestoreStatus.Applied); + } + + private StartupRestoreResult FinishRolledBackTransaction() + { + TryCleanupPendingDirectory(); + return new StartupRestoreResult(StartupRestoreStatus.PriorGenerationRestored); + } + + private void MarkUncommittedRequestRolledBackBestEffort(RestoreJournalItem[] items) + { + try + { + WriteJournal( + new RestoreJournal { - Directory.Delete(tempDir, true); + Version = JournalVersion, + Phase = RestoreJournalPhase.RolledBack, + Items = items } - } - catch + ); + TryCleanupPendingDirectory(); + } + catch + { + // No live target was changed. Leaving the complete staged request in + // place is safe; a future startup may retry preparation under the lock. + } + } + + private void TryCleanupPendingDirectory() + { + try + { + _cleanupObserver?.Invoke(); + if (Directory.Exists(PendingDirectoryPath)) { - // Best effort cleanup only. + Directory.Delete(PendingDirectoryPath, true); } } + catch + { + // Terminal journal phases make interrupted cleanup idempotent. + } + } + + private PendingState ReadAndValidatePendingState() + { + var markerPath = Path.Join(PendingDirectoryPath, PendingMarkerFileName); + var state = ReadJson(markerPath); + if ( + state.Version != PendingStateVersion + || state.FileCount < 0 + || state.UncompressedBytes < 0 + ) + { + throw new InvalidDataException("The staged settings restore marker is invalid."); + } + + return state; + } + + private RestoreJournal ReadAndValidateJournal(string journalPath) + { + var journal = ReadJson(journalPath); + if ( + journal.Version != JournalVersion + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract -- deserialized JSON can be null despite the non-null annotation; validation must reject it + || journal.Items is null + || !Enum.IsDefined(journal.Phase) + ) + { + throw new InvalidDataException("The settings restore journal is invalid."); + } + + var validatedPaths = journal.Items + .Select(item => ValidateRelativeTargetPath(item.RelativePath)) + .ToArray(); + if ( + validatedPaths.Distinct(StringComparer.Ordinal).Count() != validatedPaths.Length + || !validatedPaths.SequenceEqual( + validatedPaths.Order(StringComparer.Ordinal), + StringComparer.Ordinal + ) + ) + { + throw new InvalidDataException("The settings restore journal paths are invalid."); + } + + return journal; + } + + private string ValidateRelativeTargetPath(string relativePath) + { + var normalized = NormalizeEntryName(relativePath); + if ( + !string.Equals(normalized, relativePath, StringComparison.Ordinal) + || !IsAllowedEntry(normalized, false) + || ShouldSkipPortableEntry(normalized) + || IsExecutableEntry(normalized) + ) + { + throw new InvalidDataException( + $"The settings restore contains an invalid target path: {relativePath}" + ); + } + + _ = GetSafeDestinationPath(_basePath, normalized); + return normalized; + } + + private string GetLiveTargetPath(string relativePath) + { + return GetSafeDestinationPath(_basePath, ValidateRelativeTargetPath(relativePath)); + } + + private string GetPendingArtifactPath(string directoryName, string relativePath) + { + return GetSafeDestinationPath( + Path.Join(PendingDirectoryPath, directoryName), + ValidateRelativeTargetPath(relativePath) + ); + } + + private void WriteJournal(RestoreJournal journal) + { + WriteDurableJson(Path.Join(PendingDirectoryPath, JournalFileName), journal); + } + + private static RestoreJournal CloneJournalWithPhase( + RestoreJournal journal, + RestoreJournalPhase phase + ) + { + return new RestoreJournal + { + Version = journal.Version, + Phase = phase, + Items = journal.Items + }; + } + + private static T ReadJson(string path) + { + try + { + using var stream = File.OpenRead(path); + return JsonSerializer.Deserialize(stream, s_transactionJsonOptions) + ?? throw new InvalidDataException($"'{Path.GetFileName(path)}' is empty."); + } + catch (Exception ex) when (ex is JsonException or IOException) + { + throw new InvalidDataException( + $"'{Path.GetFileName(path)}' is invalid or unreadable.", + ex + ); + } + } + + private static void WriteDurableJson(string path, T value) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var tempPath = path + ".tmp"; + using (var stream = new FileStream( + tempPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.WriteThrough + )) + { + JsonSerializer.Serialize(stream, value, s_transactionJsonOptions); + stream.Flush(true); + } + + File.Move(tempPath, path, true); + } + + private static void CopyFileDurable(string sourcePath, string destinationPath) + { + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + using var source = new FileStream( + sourcePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read + ); + using var destination = new FileStream( + destinationPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + 81920, + FileOptions.WriteThrough + ); + source.CopyTo(destination); + destination.Flush(true); } private static void AddFileIfExists( @@ -443,6 +932,33 @@ private static string NormalizeEntryName(string path) return path.Replace('\\', '/'); } + private enum RestoreJournalPhase + { + Prepared, + Committed, + RolledBack + } + + private sealed class PendingState + { + public int Version { get; init; } + public int FileCount { get; init; } + public long UncompressedBytes { get; init; } + } + + private sealed class RestoreJournal + { + public int Version { get; init; } + public RestoreJournalPhase Phase { get; init; } + public RestoreJournalItem[] Items { get; init; } = []; + } + + private sealed class RestoreJournalItem + { + public string RelativePath { get; init; } = ""; + public bool OriginallyExisted { get; init; } + } + private sealed class BackupManifest { [JsonPropertyName("app")] diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs index 50f5bd9dc..7f7b486a4 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs @@ -17,8 +17,6 @@ namespace TypeWhisper.Linux.ViewModels.Sections; public partial class AboutSectionViewModel : ObservableObject { private readonly IErrorLogService _errorLog; - private readonly LinuxPreferencesService _linuxPreferences; - private readonly ISettingsService _settings; private readonly SettingsBackupService _settingsBackup; private readonly UpdateCheckService _updateCheck; @@ -50,15 +48,11 @@ [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanCheckForUpdates))] public AboutSectionViewModel( IErrorLogService errorLog, - ISettingsService settings, - LinuxPreferencesService linuxPreferences, SettingsBackupService settingsBackup, UpdateCheckService updateCheck ) { _errorLog = errorLog; - _settings = settings; - _linuxPreferences = linuxPreferences; _settingsBackup = settingsBackup; _updateCheck = updateCheck; RefreshErrors(); @@ -156,13 +150,9 @@ public async Task RestoreSettingsBackupAsync(string path) BackupStatusText = Loc.Instance["About.RestoringBackup"]; try { - var result = await Task.Run(() => _settingsBackup.RestoreBackup(path)); - // Re-load and re-save each settings file so in-memory state - // reflects the just-restored files and SettingsChanged is fired. - _settings.Save(_settings.Load()); - _linuxPreferences.Save(_linuxPreferences.Load()); + var result = await Task.Run(() => _settingsBackup.StageRestore(path)); BackupStatusText = - Loc.Instance.GetString("About.BackupRestored", result.FileCount); + Loc.Instance.GetString("About.BackupStaged", result.FileCount); return result; } finally @@ -358,4 +348,4 @@ public override string ToString() return Display; } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs index 492236aa8..433c2fb49 100644 --- a/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs @@ -131,8 +131,8 @@ [new FilePickerFileType("Zip archive") { Patterns = ["*.zip"] }] var result = await viewModel.RestoreSettingsBackupAsync(path); await ShowMessage( - "Settings restored", - $"Restored {result.FileCount} file(s). Some restored settings may require an app restart." + "Settings restore staged", + $"Validated and staged {result.FileCount} file(s). Quit and reopen TypeWhisper to apply the restore." ); } catch (Exception ex) @@ -146,4 +146,4 @@ private static async Task ShowMessage(string title, string message) var dialog = new MessageDialogWindow(); await dialog.ShowMessageAsync(title, message); } -} \ No newline at end of file +} diff --git a/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs index c09e30b1b..844fbfc63 100644 --- a/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs @@ -1,5 +1,12 @@ using System.IO.Compression; +using System.Text.Json; +using Moq; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Core.Services; using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Plugins; +using TypeWhisper.PluginSDK; using TypeWhisper.Tests; using Xunit; @@ -85,7 +92,7 @@ public void CreateBackup_includes_settings_and_user_data_but_skips_generated_con } [Fact] - public void RestoreBackup_overwrites_settings_and_user_data() + public void StageRestore_then_startup_apply_overwrites_settings_and_user_data() { var sourceData = Path.Join(_tempDir, "source"); var targetData = Path.Join(_tempDir, "target"); @@ -111,10 +118,12 @@ public void RestoreBackup_overwrites_settings_and_user_data() new SettingsBackupService(sourceData).CreateBackup(backupPath); var service = new SettingsBackupService(targetData); - var result = service.RestoreBackup(backupPath); + var result = service.StageRestore(backupPath); + var applyResult = service.ApplyPendingRestoreAtStartup(); // 3, not 4: the native .so is never exported (re-downloadable runtime). Assert.Equal(3, result.FileCount); + Assert.Equal(StartupRestoreStatus.Applied, applyResult.Status); Assert.Contains("de", File.ReadAllText(Path.Join(targetData, "settings.json"))); Assert.Equal( "[\"restored\"]", @@ -139,7 +148,7 @@ public void RestoreBackup_overwrites_settings_and_user_data() } [Fact] - public void RestoreBackup_skips_models_from_older_backup_archives() + public void StageRestore_skips_models_from_older_backup_archives() { var backupPath = Path.Join(_tempDir, "old-backup.zip"); var targetData = Path.Join(_tempDir, "target"); @@ -158,9 +167,11 @@ public void RestoreBackup_skips_models_from_older_backup_archives() var service = new SettingsBackupService(targetData); - var result = service.RestoreBackup(backupPath); + var result = service.StageRestore(backupPath); + var applyResult = service.ApplyPendingRestoreAtStartup(); Assert.Equal(2, result.FileCount); + Assert.Equal(StartupRestoreStatus.Applied, applyResult.Status); Assert.True(File.Exists(Path.Join(targetData, "settings.json"))); Assert.True( File.Exists( @@ -186,7 +197,7 @@ public void RestoreBackup_skips_models_from_older_backup_archives() } [Fact] - public void RestoreBackup_rejects_plugin_content_without_touching_live_files() + public void StageRestore_rejects_plugin_content_without_touching_live_files() { var backupPath = Path.Join(_tempDir, "plugins.zip"); var targetData = Path.Join(_tempDir, "target"); @@ -202,9 +213,10 @@ public void RestoreBackup_rejects_plugin_content_without_touching_live_files() } var service = new SettingsBackupService(targetData); + var pendingBefore = StageValidPending(service, "plugins-valid-pending.zip"); var exception = Assert.Throws(() => - service.RestoreBackup(backupPath) + service.StageRestore(backupPath) ); Assert.Contains("plugin", exception.Message, StringComparison.OrdinalIgnoreCase); @@ -214,10 +226,11 @@ public void RestoreBackup_rejects_plugin_content_without_touching_live_files() File.ReadAllText(Path.Join(livePluginDirectory, "marker.txt")) ); Assert.False(Directory.Exists(Path.Join(targetData, "Plugins", "evil"))); + Assert.Equal(pendingBefore, SnapshotFiles(service.PendingDirectoryPath)); } [Fact] - public void RestoreBackup_rejects_entries_outside_exporter_allowlist() + public void StageRestore_rejects_entries_outside_exporter_allowlist() { var backupPath = Path.Join(_tempDir, "unsupported.zip"); var targetData = Path.Join(_tempDir, "target"); @@ -230,15 +243,17 @@ public void RestoreBackup_rejects_entries_outside_exporter_allowlist() } var service = new SettingsBackupService(targetData); + var pendingBefore = StageValidPending(service, "unsupported-valid-pending.zip"); - Assert.Throws(() => service.RestoreBackup(backupPath)); + Assert.Throws(() => service.StageRestore(backupPath)); Assert.Equal("old settings", File.ReadAllText(Path.Join(targetData, "settings.json"))); + Assert.Equal(pendingBefore, SnapshotFiles(service.PendingDirectoryPath)); } [Theory] [InlineData("")] [InlineData("not json")] - public void RestoreBackup_rejects_invalid_manifest_without_restoring(string manifest) + public void StageRestore_rejects_invalid_manifest_without_staging(string manifest) { var backupPath = Path.Join(_tempDir, "invalid-manifest.zip"); var targetData = Path.Join(_tempDir, "target"); @@ -250,13 +265,15 @@ public void RestoreBackup_rejects_invalid_manifest_without_restoring(string mani } var service = new SettingsBackupService(targetData); + var pendingBefore = StageValidPending(service, "manifest-valid-pending.zip"); - Assert.Throws(() => service.RestoreBackup(backupPath)); + Assert.Throws(() => service.StageRestore(backupPath)); Assert.Equal("old settings", File.ReadAllText(Path.Join(targetData, "settings.json"))); + Assert.Equal(pendingBefore, SnapshotFiles(service.PendingDirectoryPath)); } [Fact] - public void RestoreBackup_rejects_executable_outside_exported_roots() + public void StageRestore_rejects_executable_outside_exported_roots() { var backupPath = Path.Join(_tempDir, "executable.zip"); var targetData = Path.Join(_tempDir, "target"); @@ -268,12 +285,14 @@ public void RestoreBackup_rejects_executable_outside_exported_roots() } var service = new SettingsBackupService(targetData); + var pendingBefore = StageValidPending(service, "executable-valid-pending.zip"); var exception = Assert.Throws(() => - service.RestoreBackup(backupPath) + service.StageRestore(backupPath) ); Assert.Contains("executable", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal("old settings", File.ReadAllText(Path.Join(targetData, "settings.json"))); + Assert.Equal(pendingBefore, SnapshotFiles(service.PendingDirectoryPath)); } [Theory] @@ -282,7 +301,7 @@ public void RestoreBackup_rejects_executable_outside_exported_roots() [InlineData("PluginData/runtime/libprovider.dylib")] [InlineData("PluginData/com.typewhisper.whisper-cpp/Cuda/libcudart.so.12")] [InlineData("PluginData/runtime/libstdc++.so.6")] - public void RestoreBackup_skips_executables_under_exported_roots(string entryName) + public void StageRestore_skips_executables_under_exported_roots(string entryName) { var backupPath = Path.Join(_tempDir, "legacy.zip"); var targetData = Path.Join(_tempDir, "target"); @@ -295,9 +314,11 @@ public void RestoreBackup_skips_executables_under_exported_roots(string entryNam var service = new SettingsBackupService(targetData); - var result = service.RestoreBackup(backupPath); + var result = service.StageRestore(backupPath); + var applyResult = service.ApplyPendingRestoreAtStartup(); Assert.Equal(1, result.FileCount); + Assert.Equal(StartupRestoreStatus.Applied, applyResult.Status); Assert.Equal("restored", File.ReadAllText(Path.Join(targetData, "settings.json"))); Assert.False(File.Exists(Path.Join(targetData, NormalizeSeparators(entryName)))); } @@ -328,7 +349,7 @@ public void CreateBackup_excludes_native_runtime_executables() } [Fact] - public void RestoreBackup_rejects_oversized_manifest() + public void StageRestore_rejects_oversized_manifest() { var backupPath = Path.Join(_tempDir, "big-manifest.zip"); var targetData = Path.Join(_tempDir, "target"); @@ -340,13 +361,15 @@ public void RestoreBackup_rejects_oversized_manifest() } var service = new SettingsBackupService(targetData); + var pendingBefore = StageValidPending(service, "oversized-valid-pending.zip"); - Assert.Throws(() => service.RestoreBackup(backupPath)); + Assert.Throws(() => service.StageRestore(backupPath)); Assert.Equal("old settings", File.ReadAllText(Path.Join(targetData, "settings.json"))); + Assert.Equal(pendingBefore, SnapshotFiles(service.PendingDirectoryPath)); } [Fact] - public void RestoreBackup_tolerates_allowed_directory_placeholders() + public void StageRestore_tolerates_allowed_directory_placeholders() { var backupPath = Path.Join(_tempDir, "directories.zip"); var targetData = Path.Join(_tempDir, "target"); @@ -361,9 +384,11 @@ public void RestoreBackup_tolerates_allowed_directory_placeholders() var service = new SettingsBackupService(targetData); - var result = service.RestoreBackup(backupPath); + var result = service.StageRestore(backupPath); + var applyResult = service.ApplyPendingRestoreAtStartup(); Assert.Equal(1, result.FileCount); + Assert.Equal(StartupRestoreStatus.Applied, applyResult.Status); Assert.True(File.Exists(Path.Join(targetData, "Data", "nested", "value.json"))); } @@ -371,7 +396,7 @@ public void RestoreBackup_tolerates_allowed_directory_placeholders() [InlineData("../escape.txt")] [InlineData("Data/../../escape.txt")] [InlineData(@"Data\..\..\escape.txt")] - public void RestoreBackup_rejects_path_traversal(string entryName) + public void StageRestore_rejects_path_traversal(string entryName) { var backupPath = Path.Join(_tempDir, "bad.zip"); Directory.CreateDirectory(_tempDir); @@ -383,10 +408,499 @@ public void RestoreBackup_rejects_path_traversal(string entryName) var targetData = Path.Join(_tempDir, "target"); var service = new SettingsBackupService(targetData); + var pendingBefore = StageValidPending(service, "traversal-valid-pending.zip"); - Assert.Throws(() => service.RestoreBackup(backupPath)); + Assert.Throws(() => service.StageRestore(backupPath)); Assert.False(File.Exists(Path.Join(_tempDir, "escape.txt"))); Assert.False(File.Exists(Path.Join(targetData, "escape.txt"))); + Assert.Equal(pendingBefore, SnapshotFiles(service.PendingDirectoryPath)); + } + + [Fact] + public void StageRestore_does_not_mutate_live_files() + { + var sourceData = Path.Join(_tempDir, "stage-source"); + var targetData = Path.Join(_tempDir, "stage-target"); + var backupPath = Path.Join(_tempDir, "stage.zip"); + var liveSettingsPath = Path.Join(targetData, "settings.json"); + var liveProfilesPath = Path.Join(targetData, "Data", "profiles.json"); + var livePluginSettingsPath = Path.Join( + targetData, + "PluginData", + "sample.plugin", + "settings.json" + ); + + Write(Path.Join(sourceData, "settings.json"), "{\"language\":\"fr\"}"); + WriteProfiles( + Path.Join(sourceData, "Data", "profiles.json"), + CreateProfile("restored", "Restored") + ); + Write( + Path.Join(sourceData, "PluginData", "sample.plugin", "settings.json"), + "{\"generation\":\"restored\"}" + ); + Write(liveSettingsPath, "{\"language\":\"en\"}"); + WriteProfiles(liveProfilesPath, CreateProfile("old", "Old")); + Write(livePluginSettingsPath, "{\"generation\":\"old\"}"); + + var settingsBefore = File.ReadAllBytes(liveSettingsPath); + var profilesBefore = File.ReadAllBytes(liveProfilesPath); + var pluginSettingsBefore = File.ReadAllBytes(livePluginSettingsPath); + new SettingsBackupService(sourceData).CreateBackup(backupPath); + var service = new SettingsBackupService(targetData); + + var result = service.StageRestore(backupPath); + + Assert.Equal(3, result.FileCount); + Assert.Equal(settingsBefore, File.ReadAllBytes(liveSettingsPath)); + Assert.Equal(profilesBefore, File.ReadAllBytes(liveProfilesPath)); + Assert.Equal(pluginSettingsBefore, File.ReadAllBytes(livePluginSettingsPath)); + Assert.True(Directory.Exists(service.PendingDirectoryPath)); + } + + [Fact] + public void Stale_cache_write_after_staging_cannot_win_over_startup_apply() + { + var sourceData = Path.Join(_tempDir, "stale-source"); + var targetData = Path.Join(_tempDir, "stale-target"); + var backupPath = Path.Join(_tempDir, "stale.zip"); + var profilesPath = Path.Join(targetData, "Data", "profiles.json"); + WriteProfiles(profilesPath, CreateProfile("old", "Old")); + WriteProfiles( + Path.Join(sourceData, "Data", "profiles.json"), + CreateProfile("restored", "Restored") + ); + new SettingsBackupService(sourceData).CreateBackup(backupPath); + + var staleService = new ProfileService(profilesPath); + Assert.Equal("old", Assert.Single(staleService.Profiles).Id); + var backupService = new SettingsBackupService(targetData); + + backupService.StageRestore(backupPath); + staleService.AddProfile(CreateProfile("stale-added", "Stale added")); + + Assert.Equal( + ["old", "stale-added"], + ReadProfiles(profilesPath).Select(profile => profile.Id).Order().ToArray() + ); + + var applyResult = backupService.ApplyPendingRestoreAtStartup(); + var freshService = new ProfileService(profilesPath); + + Assert.Equal(StartupRestoreStatus.Applied, applyResult.Status); + Assert.Equal("restored", Assert.Single(freshService.Profiles).Id); + Assert.Equal("restored", Assert.Single(ReadProfiles(profilesPath)).Id); + } + + [Fact] + public void Fresh_services_serve_restored_root_data_and_plugin_settings_after_apply() + { + var sourceData = Path.Join(_tempDir, "fresh-source"); + var targetData = Path.Join(_tempDir, "fresh-target"); + var backupPath = Path.Join(_tempDir, "fresh.zip"); + Write(Path.Join(sourceData, "settings.json"), "{\"language\":\"fr\"}"); + WriteProfiles( + Path.Join(sourceData, "Data", "profiles.json"), + CreateProfile("restored", "Restored") + ); + Write( + Path.Join(sourceData, "PluginData", "sample.plugin", "settings.json"), + "{\"generation\":\"restored\"}" + ); + Write(Path.Join(targetData, "settings.json"), "{\"language\":\"en\"}"); + WriteProfiles( + Path.Join(targetData, "Data", "profiles.json"), + CreateProfile("old", "Old") + ); + Write( + Path.Join(targetData, "PluginData", "sample.plugin", "settings.json"), + "{\"generation\":\"old\"}" + ); + + new SettingsBackupService(sourceData).CreateBackup(backupPath); + var backupService = new SettingsBackupService(targetData); + backupService.StageRestore(backupPath); + + var applyResult = backupService.ApplyPendingRestoreAtStartup(); + var settings = new SettingsService(Path.Join(targetData, "settings.json")); + var profiles = new ProfileService(Path.Join(targetData, "Data", "profiles.json")); + var pluginHost = new PluginHostServices( + "sample.plugin", + Path.Join(_tempDir, "plugin-binaries"), + Mock.Of(), + Mock.Of(), + profiles, + pluginDataRoot: Path.Join(targetData, "PluginData") + ); + + Assert.Equal(StartupRestoreStatus.Applied, applyResult.Status); + Assert.Equal("fr", settings.Current.Language); + Assert.Equal("restored", Assert.Single(profiles.Profiles).Id); + Assert.Equal("restored", pluginHost.GetSetting("generation")); + } + + [Fact] + public void Apply_failure_during_commit_rolls_back_exact_prior_generation() + { + var targetData = Path.Join(_tempDir, "rollback-target"); + var backupPath = Path.Join(_tempDir, "rollback.zip"); + var existingPath = Path.Join(targetData, "settings.json"); + var absentPath = Path.Join(targetData, "Data", "history.json"); + var originalBytes = "{\n \"language\": \"en\"\n}"u8.ToArray(); + WriteBytes(existingPath, originalBytes); + using (var archive = ZipFile.Open(backupPath, ZipArchiveMode.Create)) + { + WriteValidManifest(archive); + WriteEntry(archive, "Data/history.json", "[]"); + WriteEntry(archive, "settings.json", "{\"language\":\"fr\"}"); + } + + var service = new SettingsBackupService( + targetData, + // ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local -- required by the RestoreCommitObserver signature; used to inject a mid-commit failure + (_, committedFileCount) => + { + // Fail after both targets commit, so rollback overwrites an + // already-changed file, keeping the assertion below non-tautological. + if (committedFileCount == 2) + { + throw new IOException("Injected commit failure."); + } + } + ); + service.StageRestore(backupPath); + + var result = service.ApplyPendingRestoreAtStartup(); + + Assert.Equal(StartupRestoreStatus.PriorGenerationRestored, result.Status); + Assert.Equal(originalBytes, File.ReadAllBytes(existingPath)); + Assert.False(File.Exists(absentPath)); + Assert.False(Directory.Exists(service.PendingDirectoryPath)); + Assert.Equal( + StartupRestoreStatus.None, + new SettingsBackupService(targetData).ApplyPendingRestoreAtStartup().Status + ); + } + + [Fact] + public void Prepared_journal_is_recovered_before_a_new_apply() + { + var targetData = Path.Join(_tempDir, "prepared-target"); + var backupPath = Path.Join(_tempDir, "prepared.zip"); + var settingsPath = Path.Join(targetData, "settings.json"); + var profilesPath = Path.Join(targetData, "Data", "profiles.json"); + Write(settingsPath, "{\"language\":\"en\"}"); + WriteProfiles(profilesPath, CreateProfile("old", "Old")); + using (var archive = ZipFile.Open(backupPath, ZipArchiveMode.Create)) + { + WriteValidManifest(archive); + WriteEntry(archive, "settings.json", "{\"language\":\"fr\"}"); + WriteEntry( + archive, + "Data/profiles.json", + JsonSerializer.Serialize(new[] { CreateProfile("restored", "Restored") }) + ); + } + + var interruptedService = new SettingsBackupService( + targetData, + // ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local -- required by the RestoreCommitObserver signature; used to inject a mid-commit failure + (_, committedFileCount) => + { + if (committedFileCount == 1) + { + throw new RestoreInterruptionException("Simulated process interruption."); + } + } + ); + interruptedService.StageRestore(backupPath); + + Assert.Throws( + interruptedService.ApplyPendingRestoreAtStartup + ); + Assert.Equal("restored", Assert.Single(ReadProfiles(profilesPath)).Id); + + var recoveryResult = new SettingsBackupService(targetData) + .ApplyPendingRestoreAtStartup(); + + Assert.Equal(StartupRestoreStatus.PriorGenerationRestored, recoveryResult.Status); + Assert.Contains("en", File.ReadAllText(settingsPath)); + Assert.Equal("old", Assert.Single(ReadProfiles(profilesPath)).Id); + Assert.False(Directory.Exists(interruptedService.PendingDirectoryPath)); + } + + [Fact] + public void Missing_rollback_snapshot_fails_closed_and_retains_recovery_evidence() + { + var targetData = Path.Join(_tempDir, "missing-rollback-target"); + var backupPath = Path.Join(_tempDir, "missing-rollback.zip"); + var profilesPath = Path.Join(targetData, "Data", "profiles.json"); + var settingsPath = Path.Join(targetData, "settings.json"); + Write(profilesPath, "old profiles"); + Write(settingsPath, "old settings"); + using (var archive = ZipFile.Open(backupPath, ZipArchiveMode.Create)) + { + WriteValidManifest(archive); + WriteEntry(archive, "Data/profiles.json", "restored profiles"); + WriteEntry(archive, "settings.json", "restored settings"); + } + + var interruptedService = new SettingsBackupService( + targetData, + // ReSharper disable once ParameterOnlyUsedForPreconditionCheck.Local -- required by the RestoreCommitObserver signature; used to inject a mid-commit failure + (_, committedFileCount) => + { + if (committedFileCount == 1) + { + throw new RestoreInterruptionException("Simulated process interruption."); + } + } + ); + interruptedService.StageRestore(backupPath); + + Assert.Throws( + interruptedService.ApplyPendingRestoreAtStartup + ); + Assert.Equal("restored profiles", File.ReadAllText(profilesPath)); + Assert.Equal("old settings", File.ReadAllText(settingsPath)); + + var journalPath = Path.Join( + interruptedService.PendingDirectoryPath, + "restore-journal.json" + ); + var missingRollbackPath = Path.Join( + interruptedService.PendingDirectoryPath, + "rollback", + "Data", + "profiles.json" + ); + var remainingRollbackPath = Path.Join( + interruptedService.PendingDirectoryPath, + "rollback", + "settings.json" + ); + Assert.True(File.Exists(journalPath)); + Assert.Contains("\"Phase\": \"Prepared\"", File.ReadAllText(journalPath)); + Assert.True(File.Exists(missingRollbackPath)); + Assert.True(File.Exists(remainingRollbackPath)); + File.Delete(missingRollbackPath); + var liveProfilesBeforeRecovery = File.ReadAllBytes(profilesPath); + var liveSettingsBeforeRecovery = File.ReadAllBytes(settingsPath); + var pendingBeforeRecovery = SnapshotFiles(interruptedService.PendingDirectoryPath); + + var recoveryResult = new SettingsBackupService(targetData) + .ApplyPendingRestoreAtStartup(); + + Assert.Equal(StartupRestoreStatus.UnresolvedFailure, recoveryResult.Status); + var recoveryErrors = Assert.IsType(recoveryResult.Error); + Assert.Equal(2, recoveryErrors.InnerExceptions.Count); + var rollbackError = Assert.IsType( + recoveryErrors.InnerExceptions[1] + ); + Assert.Equal( + "The rollback snapshot for 'Data/profiles.json' is missing.", + rollbackError.Message + ); + Assert.Equal(liveProfilesBeforeRecovery, File.ReadAllBytes(profilesPath)); + Assert.Equal(liveSettingsBeforeRecovery, File.ReadAllBytes(settingsPath)); + Assert.True(File.Exists(journalPath)); + Assert.True(File.Exists(remainingRollbackPath)); + Assert.Equal("old settings", File.ReadAllText(remainingRollbackPath)); + Assert.Equal( + pendingBeforeRecovery, + SnapshotFiles(interruptedService.PendingDirectoryPath) + ); + } + + [Fact] + public void Unexpected_startup_apply_exception_returns_unresolved_failure() + { + var targetData = Path.Join(_tempDir, "unexpected-apply-target"); + var settingsPath = Path.Join(targetData, "settings.json"); + Write(settingsPath, "old settings"); + var service = new SettingsBackupService(targetData); + Write(service.PendingDirectoryPath, "not a directory"); + + var result = service.ApplyPendingRestoreAtStartup(); + + Assert.Equal(StartupRestoreStatus.UnresolvedFailure, result.Status); + var error = Assert.IsType(result.Error); + Assert.Equal("The staged settings restore path is not a directory.", error.Message); + Assert.Equal("old settings", File.ReadAllText(settingsPath)); + Assert.Equal("not a directory", File.ReadAllText(service.PendingDirectoryPath)); + } + + [Fact] + public void Committed_journal_is_not_rolled_back_when_cleanup_was_interrupted() + { + var targetData = Path.Join(_tempDir, "committed-target"); + var backupPath = Path.Join(_tempDir, "committed.zip"); + var settingsPath = Path.Join(targetData, "settings.json"); + Write(settingsPath, "{\"language\":\"en\"}"); + CreateBackupWithEntry(backupPath, "settings.json", "{\"language\":\"fr\"}"); + var service = new SettingsBackupService( + targetData, + cleanupObserver: () => throw new IOException("Injected cleanup interruption.") + ); + service.StageRestore(backupPath); + + var applyResult = service.ApplyPendingRestoreAtStartup(); + + Assert.Equal(StartupRestoreStatus.Applied, applyResult.Status); + Assert.Contains("fr", File.ReadAllText(settingsPath)); + Assert.True(Directory.Exists(service.PendingDirectoryPath)); + + var recoveryResult = new SettingsBackupService(targetData) + .ApplyPendingRestoreAtStartup(); + + Assert.Equal(StartupRestoreStatus.Applied, recoveryResult.Status); + Assert.Contains("fr", File.ReadAllText(settingsPath)); + Assert.False(Directory.Exists(service.PendingDirectoryPath)); + } + + [Fact] + public void RolledBack_journal_does_not_replay_old_snapshot_after_cleanup_was_interrupted() + { + var targetData = Path.Join(_tempDir, "rolled-back-target"); + var backupPath = Path.Join(_tempDir, "rolled-back.zip"); + var settingsPath = Path.Join(targetData, "settings.json"); + Write(settingsPath, "{\"language\":\"en\"}"); + CreateBackupWithEntry(backupPath, "settings.json", "{\"language\":\"fr\"}"); + var service = new SettingsBackupService( + targetData, + (_, _) => throw new IOException("Injected commit failure."), + () => throw new IOException("Injected cleanup interruption.") + ); + service.StageRestore(backupPath); + + var applyResult = service.ApplyPendingRestoreAtStartup(); + + Assert.Equal(StartupRestoreStatus.PriorGenerationRestored, applyResult.Status); + Assert.True(Directory.Exists(service.PendingDirectoryPath)); + Write(settingsPath, "{\"language\":\"newer\"}"); + + var recoveryResult = new SettingsBackupService(targetData) + .ApplyPendingRestoreAtStartup(); + + Assert.Equal(StartupRestoreStatus.PriorGenerationRestored, recoveryResult.Status); + Assert.Contains("newer", File.ReadAllText(settingsPath)); + Assert.False(Directory.Exists(service.PendingDirectoryPath)); + } + + [Fact] + public void Concurrent_startup_apply_is_exclusive() + { + var targetData = Path.Join(_tempDir, "locked-target"); + var backupPath = Path.Join(_tempDir, "locked.zip"); + var settingsPath = Path.Join(targetData, "settings.json"); + Write(settingsPath, "{\"language\":\"en\"}"); + CreateBackupWithEntry(backupPath, "settings.json", "{\"language\":\"fr\"}"); + var service = new SettingsBackupService(targetData); + service.StageRestore(backupPath); + var pendingBefore = SnapshotFiles(service.PendingDirectoryPath); + + using (SettingsBackupService.AcquireStartupRestoreLock(targetData)) + { + var contenderResult = new SettingsBackupService(targetData) + .ApplyPendingRestoreAtStartup(); + + Assert.Equal(StartupRestoreStatus.LockUnavailable, contenderResult.Status); + Assert.Contains("en", File.ReadAllText(settingsPath)); + Assert.Equal(pendingBefore, SnapshotFiles(service.PendingDirectoryPath)); + } + + Assert.Equal(StartupRestoreStatus.Applied, service.ApplyPendingRestoreAtStartup().Status); + Assert.Contains("fr", File.ReadAllText(settingsPath)); + } + + [Fact] + public void Validation_failure_preserves_live_and_previously_staged_generation() + { + var targetData = Path.Join(_tempDir, "preserved-target"); + var validBackupPath = Path.Join(_tempDir, "preserved-valid.zip"); + var invalidBackupPath = Path.Join(_tempDir, "preserved-invalid.zip"); + var settingsPath = Path.Join(targetData, "settings.json"); + Write(settingsPath, "{\"language\":\"en\"}"); + CreateBackupWithEntry(validBackupPath, "settings.json", "{\"language\":\"fr\"}"); + using (var archive = ZipFile.Open(invalidBackupPath, ZipArchiveMode.Create)) + { + WriteValidManifest(archive); + WriteEntry(archive, "settings.json", "{\"language\":\"de\"}"); + WriteEntry(archive, "Other/data.json", "{}"); + } + + var service = new SettingsBackupService(targetData); + service.StageRestore(validBackupPath); + var pendingBefore = SnapshotFiles(service.PendingDirectoryPath); + + Assert.Throws(() => service.StageRestore(invalidBackupPath)); + + Assert.Equal("{\"language\":\"en\"}", File.ReadAllText(settingsPath)); + Assert.Equal(pendingBefore, SnapshotFiles(service.PendingDirectoryPath)); + } + + private static Profile CreateProfile(string id, string name) + { + return new Profile + { + Id = id, + Name = name, + CreatedAt = DateTime.UnixEpoch, + UpdatedAt = DateTime.UnixEpoch + }; + } + + private static void WriteProfiles(string path, params Profile[] profiles) + { + Write( + path, + JsonSerializer.Serialize( + profiles, + new JsonSerializerOptions { WriteIndented = true } + ) + ); + } + + private static Profile[] ReadProfiles(string path) + { + return JsonSerializer.Deserialize(File.ReadAllText(path)) ?? []; + } + + private string[] StageValidPending(SettingsBackupService service, string backupFileName) + { + var backupPath = Path.Join(_tempDir, backupFileName); + CreateBackupWithEntry(backupPath, "settings.json", "{\"language\":\"fr\"}"); + service.StageRestore(backupPath); + return SnapshotFiles(service.PendingDirectoryPath); + } + + private static void CreateBackupWithEntry( + string backupPath, + string entryName, + string content + ) + { + using var archive = ZipFile.Open(backupPath, ZipArchiveMode.Create); + WriteValidManifest(archive); + WriteEntry(archive, entryName, content); + } + + private static string[] SnapshotFiles(string directory) + { + return Directory + .EnumerateFiles(directory, "*", SearchOption.AllDirectories) + .Select(path => + $"{Path.GetRelativePath(directory, path)}:{Convert.ToBase64String(File.ReadAllBytes(path))}" + ) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + } + + private static void WriteBytes(string path, byte[] content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, content); } private static void Write(string path, string content) From 6e02faf3d4e0a5d52e2d19e0d1aa1acc2c848e7f Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 18 Jul 2026 23:59:34 +0000 Subject: [PATCH 108/226] =?UTF-8?q?Make=20Linux=20preferences=20persistenc?= =?UTF-8?q?e=20atomic=20and=20stale-snapshot=20safe=20(audit=20=C2=A76=20M?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/LinuxPreferencesService.cs | 50 +- .../Services/UpdateCheckService.cs | 19 +- .../Sections/GeneralSectionViewModel.cs | 4 +- .../LinuxPreferencesServiceTests.cs | 510 ++++++++++++++++++ 4 files changed, 566 insertions(+), 17 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/LinuxPreferencesServiceTests.cs diff --git a/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs b/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs index 838d984db..097c7166d 100644 --- a/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs +++ b/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Text.Json; using TypeWhisper.Core; +using TypeWhisper.Core.Services; namespace TypeWhisper.Linux.Services; @@ -56,11 +57,20 @@ public sealed class LinuxPreferencesService WriteIndented = true, PropertyNameCaseInsensitive = true }; + private readonly Action _atomicWrite; + private readonly Lock _gate = new(); private readonly string _path; public LinuxPreferencesService() + : this(Path.Join(TypeWhisperEnvironment.BasePath, "linux-preferences.json")) { } + + internal LinuxPreferencesService( + string path, + Action? atomicWrite = null + ) { - _path = Path.Join(TypeWhisperEnvironment.BasePath, "linux-preferences.json"); + _path = path; + _atomicWrite = atomicWrite ?? AtomicFileWrite.WriteAllText; Load(); } @@ -76,6 +86,7 @@ public LinuxPreferences Load() try { var json = File.ReadAllText(_path); + // ReSharper disable once InconsistentlySynchronizedField -- s_jsonOptions is an immutable static readonly options instance; reads require no synchronization. Current = JsonSerializer.Deserialize(json, s_jsonOptions) ?? LinuxPreferences.Default; @@ -91,19 +102,46 @@ public LinuxPreferences Load() public void Save(LinuxPreferences next) { - Current = next; + lock (_gate) + { + SaveLocked(next); + } + } + + public LinuxPreferences Update(Func mutate) + { + ArgumentNullException.ThrowIfNull(mutate); + lock (_gate) + { + var updated = mutate(Current); + SaveLocked(updated); + return updated; + } + } + + private void SaveLocked(LinuxPreferences next) + { try { - Directory.CreateDirectory(TypeWhisperEnvironment.BasePath); - File.WriteAllText(_path, JsonSerializer.Serialize(next, s_jsonOptions)); - Changed?.Invoke(next); + var directory = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(next, s_jsonOptions); + _atomicWrite(_path, json); } catch (Exception ex) { Debug.WriteLine($"[LinuxPreferencesService] Save failed: {ex.Message}"); + throw; } + + Current = next; + Changed?.Invoke(next); } // ReSharper disable once EventNeverSubscribedTo.Global -- public API; raised on preference changes for external/future subscribers. public event Action? Changed; -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/UpdateCheckService.cs b/src/TypeWhisper.Linux/Services/UpdateCheckService.cs index f022328df..06e932f76 100644 --- a/src/TypeWhisper.Linux/Services/UpdateCheckService.cs +++ b/src/TypeWhisper.Linux/Services/UpdateCheckService.cs @@ -174,13 +174,14 @@ public async Task CheckAsync(CancellationToken cancellationTo // the rate-limit clock or wipe the cached latest version. if (!result.Faulted) { - _prefs.Save( - _prefs.Current with - { - LastUpdateCheckUtc = DateTime.UtcNow, - LastKnownLatestVersion = result.LatestVersion, - LastKnownLatestUrl = result.ReleaseUrl - } + _prefs.Update( + preferences => + preferences with + { + LastUpdateCheckUtc = DateTime.UtcNow, + LastKnownLatestVersion = result.LatestVersion, + LastKnownLatestUrl = result.ReleaseUrl + } ); } @@ -201,7 +202,7 @@ public void DismissUpdate(string? version) return; } - _prefs.Save(_prefs.Current with { DismissedUpdateVersion = version }); + _prefs.Update(current => current with { DismissedUpdateVersion = version }); // Re-raise so banner listeners recompute visibility. ResultChanged?.Invoke(LastResult); @@ -295,4 +296,4 @@ private sealed record GitHubRelease public bool Draft { get; init; } } // ReSharper restore UnusedAutoPropertyAccessor.Local -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs index f737d538e..834e2a3f9 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs @@ -220,8 +220,8 @@ partial void OnCloseToTrayChanged(bool value) return; } - _linuxPrefs.Save(_linuxPrefs.Current with { CloseToTray = value }); + _linuxPrefs.Update(current => current with { CloseToTray = value }); } } -public sealed record CommandExample(string Command); \ No newline at end of file +public sealed record CommandExample(string Command); diff --git a/tests/TypeWhisper.Linux.Tests/LinuxPreferencesServiceTests.cs b/tests/TypeWhisper.Linux.Tests/LinuxPreferencesServiceTests.cs new file mode 100644 index 000000000..031b8f57b --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/LinuxPreferencesServiceTests.cs @@ -0,0 +1,510 @@ +using System.Text.Json; +using TypeWhisper.Core.Services; +using TypeWhisper.Linux.Services; +using TypeWhisper.Tests; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class LinuxPreferencesServiceTests +{ + private static readonly TimeSpan s_testGuard = TimeSpan.FromSeconds(5); + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = true, PropertyNameCaseInsensitive = true + }; + + [Fact] + public void Save_TempPathRoundTrip_PublishesCurrentBeforeChangedAndLeavesNoTempFile() + { + using var directory = new TempDirectory("linux-preferences-round-trip"); + var path = Path.Join(directory.Path, "linux-preferences.json"); + var expected = new LinuxPreferences + { + CloseToTray = true, + CheckForUpdatesOnStartup = false, + LastUpdateCheckUtc = new DateTime(2026, 7, 18, 12, 34, 56, DateTimeKind.Utc), + LastKnownLatestVersion = "1.2.3", + LastKnownLatestUrl = "https://example.com/releases/1.2.3", + DismissedUpdateVersion = null + }; + var service = new LinuxPreferencesService(path); + var changedCount = 0; + LinuxPreferences? notified = null; + LinuxPreferences? currentObservedByHandler = null; + service.Changed += next => + { + changedCount++; + notified = next; + currentObservedByHandler = service.Current; + }; + + service.Save(expected); + + Assert.True(File.Exists(path)); + Assert.Equal(expected, Deserialize(path)); + Assert.Equal(expected, new LinuxPreferencesService(path).Current); + Assert.Equal(expected, service.Current); + Assert.Equal(1, changedCount); + Assert.Equal(expected, notified); + Assert.Equal(expected, currentObservedByHandler); + Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.tmp")); + } + + [Fact] + public async Task Update_ConcurrentDisjointMutations_UseLatestCommittedSnapshot() + { + using var directory = new TempDirectory("linux-preferences-updates"); + using var writer = new BlockingAtomicWriter(); + var path = Path.Join(directory.Path, "linux-preferences.json"); + var service = new LinuxPreferencesService(path, writer.Write); + var secondCallerStarted = CreateCompletionSource(); + var secondMutatorEntered = CreateCompletionSource(); + var secondCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + Task? firstUpdate = null; + Thread? secondThread = null; + bool secondReachedGateOrMutator; + bool secondMutatorEnteredBeforeRelease; + + try + { + firstUpdate = Task.Run(() => + service.Update(current => current with { CloseToTray = true }) + ); + await writer.FirstEntered.WaitAsync(s_testGuard); + + secondThread = new Thread(() => + { + secondCallerStarted.TrySetResult(); + try + { + var result = service.Update(current => + { + secondMutatorEntered.TrySetResult(); + return current with { DismissedUpdateVersion = "1.2.3" }; + }); + secondCompletion.TrySetResult(result); + } + catch (Exception ex) + { + secondCompletion.TrySetException(ex); + } + }) + { + IsBackground = true + }; + secondThread.Start(); + await secondCallerStarted.Task.WaitAsync(s_testGuard); + + secondReachedGateOrMutator = SpinWait.SpinUntil( + () => + secondMutatorEntered.Task.IsCompleted + || IsWaiting(secondThread) + || !secondThread.IsAlive, + s_testGuard + ); + secondMutatorEnteredBeforeRelease = secondMutatorEntered.Task.IsCompleted; + } + finally + { + writer.ReleaseFirst(); + await CompleteBestEffort(firstUpdate, secondCompletion.Task); + if (secondThread is { IsAlive: true }) + { + secondThread.Join(s_testGuard); + } + } + + var results = await Task.WhenAll(firstUpdate, secondCompletion.Task) + .WaitAsync(s_testGuard); + + Assert.True(secondReachedGateOrMutator); + Assert.False(secondMutatorEnteredBeforeRelease); + Assert.False(writer.SecondEnteredBeforeFirstRelease); + Assert.Equal(1, writer.MaximumConcurrency); + Assert.Equal( + new LinuxPreferences { CloseToTray = true }, + results[0] + ); + var expected = new LinuxPreferences + { + CloseToTray = true, DismissedUpdateVersion = "1.2.3" + }; + Assert.Equal(expected, results[1]); + Assert.Equal(expected, service.Current); + Assert.Equal(expected, new LinuxPreferencesService(path).Current); + Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.tmp")); + } + + [Fact] + public async Task Save_ConcurrentFullSnapshots_AreSerializedAndRemainWhole() + { + using var directory = new TempDirectory("linux-preferences-saves"); + using var writer = new BlockingAtomicWriter(); + var path = Path.Join(directory.Path, "linux-preferences.json"); + var service = new LinuxPreferencesService(path, writer.Write); + var first = new LinuxPreferences + { + CloseToTray = true, + LastKnownLatestVersion = "first", + LastKnownLatestUrl = "https://example.com/first" + }; + var second = new LinuxPreferences + { + CheckForUpdatesOnStartup = false, + DismissedUpdateVersion = "second" + }; + var secondCallerStarted = CreateCompletionSource(); + var secondCompletion = CreateCompletionSource(); + Task? firstSave = null; + Thread? secondThread = null; + bool secondReachedGateOrWriter; + + try + { + firstSave = Task.Run(() => service.Save(first)); + await writer.FirstEntered.WaitAsync(s_testGuard); + + secondThread = new Thread(() => + { + secondCallerStarted.TrySetResult(); + try + { + service.Save(second); + secondCompletion.TrySetResult(); + } + catch (Exception ex) + { + secondCompletion.TrySetException(ex); + } + }) + { + IsBackground = true + }; + secondThread.Start(); + await secondCallerStarted.Task.WaitAsync(s_testGuard); + + secondReachedGateOrWriter = SpinWait.SpinUntil( + () => + // ReSharper disable once AccessToDisposedClosure -- SpinUntil runs synchronously to completion before the enclosing method disposes writer. + writer.SecondEntered.IsCompleted + || IsWaiting(secondThread) + || !secondThread.IsAlive, + s_testGuard + ); + } + finally + { + writer.ReleaseFirst(); + await CompleteBestEffort(firstSave, secondCompletion.Task); + if (secondThread is { IsAlive: true }) + { + secondThread.Join(s_testGuard); + } + } + + await Task.WhenAll(firstSave, secondCompletion.Task).WaitAsync(s_testGuard); + var onDisk = Deserialize(path); + + Assert.True(secondReachedGateOrWriter); + Assert.False(writer.SecondEnteredBeforeFirstRelease); + Assert.Equal(1, writer.MaximumConcurrency); + Assert.True(onDisk == first || onDisk == second); + Assert.Equal(onDisk, service.Current); + Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.tmp")); + } + + [Fact] + public void Save_WhenRealAtomicStagingFails_PreservesDiskAndCacheAndThrows() + { + var oldPreferences = new LinuxPreferences + { + CloseToTray = true, + LastKnownLatestVersion = "old", + LastKnownLatestUrl = "https://example.com/old" + }; + using var failurePath = new MaximumFileNameTestPath(Serialize(oldPreferences)); + var service = new LinuxPreferencesService(failurePath.FilePath); + var before = File.ReadAllBytes(failurePath.FilePath); + var changedCount = 0; + service.Changed += _ => changedCount++; + var replacement = oldPreferences with + { + CloseToTray = false, LastKnownLatestVersion = "new" + }; + + Assert.ThrowsAny(() => service.Save(replacement)); + + Assert.Equal(before, File.ReadAllBytes(failurePath.FilePath)); + Assert.Equal(oldPreferences, Deserialize(failurePath.FilePath)); + Assert.Equal(oldPreferences, service.Current); + Assert.Equal(0, changedCount); + Assert.Empty(failurePath.TemporaryFiles); + } + + [Fact] + public void Save_WhenInjectedWriterFails_PreservesDiskAndCacheAndThrowsSameException() + { + using var directory = new TempDirectory("linux-preferences-writer-failure"); + var path = Path.Join(directory.Path, "linux-preferences.json"); + var oldPreferences = new LinuxPreferences + { + CloseToTray = true, DismissedUpdateVersion = "old" + }; + new LinuxPreferencesService(path).Save(oldPreferences); + var before = File.ReadAllBytes(path); + var expectedException = new IOException("Injected write failure."); + var service = new LinuxPreferencesService(path, (_, _) => throw expectedException); + var changedCount = 0; + service.Changed += _ => changedCount++; + + var actualException = Assert.Throws(() => + service.Save(oldPreferences with { DismissedUpdateVersion = "new" }) + ); + + Assert.Same(expectedException, actualException); + Assert.Equal(before, File.ReadAllBytes(path)); + Assert.Equal(oldPreferences, service.Current); + Assert.Equal(0, changedCount); + Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.tmp")); + } + + private static LinuxPreferences Deserialize(string path) + { + return JsonSerializer.Deserialize( + File.ReadAllText(path), + s_jsonOptions + ) + ?? throw new InvalidOperationException("Preferences JSON was null."); + } + + private static string Serialize(LinuxPreferences preferences) + { + return JsonSerializer.Serialize(preferences, s_jsonOptions); + } + + private static TaskCompletionSource CreateCompletionSource() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private static bool IsWaiting(Thread thread) + { + return (thread.ThreadState & ThreadState.WaitSleepJoin) != 0; + } + + private static async Task CompleteBestEffort(params Task?[] tasks) + { + var activeTasks = tasks.Where(task => task is not null).Cast().ToArray(); + if (activeTasks.Length == 0) + { + return; + } + + try + { + await Task.WhenAll(activeTasks).WaitAsync(s_testGuard); + } + catch + { + // Best-effort bounded completion before temp-directory cleanup. + } + } + + private sealed class BlockingAtomicWriter : IDisposable + { + private readonly TaskCompletionSource _firstEntered = CreateCompletionSource(); + private readonly ManualResetEventSlim _releaseFirst = new(false); + private readonly TaskCompletionSource _secondEntered = CreateCompletionSource(); + private int _activeWriters; + private int _firstReleased; + private int _invocations; + private int _maximumConcurrency; + private int _secondEnteredBeforeFirstRelease; + + public Task FirstEntered => _firstEntered.Task; + public Task SecondEntered => _secondEntered.Task; + + public int MaximumConcurrency => Volatile.Read(ref _maximumConcurrency); + public bool SecondEnteredBeforeFirstRelease => + Volatile.Read(ref _secondEnteredBeforeFirstRelease) != 0; + + public void Write(string path, string contents) + { + var invocation = Interlocked.Increment(ref _invocations); + var activeWriters = Interlocked.Increment(ref _activeWriters); + UpdateMaximum(activeWriters); + try + { + if (invocation == 1) + { + _firstEntered.TrySetResult(); + if (!_releaseFirst.Wait(s_testGuard)) + { + throw new TimeoutException("The first atomic writer was not released."); + } + } + else + { + if (Volatile.Read(ref _firstReleased) == 0) + { + Interlocked.Exchange(ref _secondEnteredBeforeFirstRelease, 1); + } + + _secondEntered.TrySetResult(); + } + + AtomicFileWrite.WriteAllText(path, contents); + } + finally + { + Interlocked.Decrement(ref _activeWriters); + } + } + + public void ReleaseFirst() + { + Volatile.Write(ref _firstReleased, 1); + _releaseFirst.Set(); + } + + public void Dispose() + { + ReleaseFirst(); + _releaseFirst.Dispose(); + } + + private void UpdateMaximum(int activeWriters) + { + var current = Volatile.Read(ref _maximumConcurrency); + while ( + activeWriters > current + && Interlocked.CompareExchange( + ref _maximumConcurrency, + activeWriters, + current + ) != current + ) + { + current = Volatile.Read(ref _maximumConcurrency); + } + } + } + + private sealed class MaximumFileNameTestPath : IDisposable + { + public MaximumFileNameTestPath(string contents) + { + DirectoryPath = TestPaths.CreateTempDirectory( + "linux-preferences-atomic-failure" + ); + var fileNameLength = FindMaximumFileNameLength(); + FilePath = Path.Join(DirectoryPath, new string('x', fileNameLength)); + File.WriteAllText(FilePath, contents); + } + + private string DirectoryPath { get; } + public string FilePath { get; } + public IEnumerable TemporaryFiles => + Directory.EnumerateFiles(DirectoryPath, "*.tmp"); + + public void Dispose() + { + try + { + TestPaths.DeleteDirectory(DirectoryPath); + } + catch + { + // Best-effort cleanup for a temp test directory. + } + } + + private int FindMaximumFileNameLength() + { + var low = 1; + var high = 128; + while (CanCreateFile(high)) + { + low = high; + high *= 2; + if (high > 16_384) + { + throw new InvalidOperationException( + "Could not find the temporary filesystem path limit." + ); + } + } + + while (low + 1 < high) + { + var middle = low + (high - low) / 2; + if (CanCreateFile(middle)) + { + low = middle; + } + else + { + high = middle; + } + } + + return low; + } + + private bool CanCreateFile(int fileNameLength) + { + var path = Path.Join(DirectoryPath, new string('x', fileNameLength)); + var created = false; + try + { + File.WriteAllText(path, "probe"); + created = true; + } + catch + { + // The first failing length provides the upper bound for the search. + } + finally + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // Best-effort cleanup for a probe file. + } + } + + return created; + } + } + + private sealed class TempDirectory : IDisposable + { + public TempDirectory(string name) + { + Path = TestPaths.CreateTempDirectory(name); + } + + public string Path { get; } + + public void Dispose() + { + try + { + TestPaths.DeleteDirectory(Path); + } + catch + { + // Best-effort cleanup for a temp test directory. + } + } + } +} From 173bc52ad9f33fc607df880da12d4b728c7e0580 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 19 Jul 2026 00:08:14 +0000 Subject: [PATCH 109/226] =?UTF-8?q?Make=20caller=20cancellation=20authorit?= =?UTF-8?q?ative=20at=20every=20ProcessRunner=20result=20boundary=20(audit?= =?UTF-8?q?=20=C2=A76=20M1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/ProcessRunner.cs | 273 ++++++------ .../ProcessRunnerTests.cs | 393 +++++++++++++++--- 2 files changed, 497 insertions(+), 169 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/ProcessRunner.cs b/src/TypeWhisper.Linux/Services/ProcessRunner.cs index 83b171687..ecbb40245 100644 --- a/src/TypeWhisper.Linux/Services/ProcessRunner.cs +++ b/src/TypeWhisper.Linux/Services/ProcessRunner.cs @@ -78,144 +78,177 @@ public async Task RunAsync( CancellationToken ct = default ) { - var psi = new ProcessStartInfo(fileName) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = standardInput is not null, - UseShellExecute = false, - CreateNoWindow = true - }; - foreach (var arg in args) + Process? process = null; + StreamWriter? standardInputWriter = null; + StreamReader? standardOutputReader = null; + StreamReader? standardErrorReader = null; + Task? stdoutTask = null; + Task? stderrTask = null; + try { - psi.ArgumentList.Add(arg); - } + ct.ThrowIfCancellationRequested(); - if (environment is not null) - { - foreach (var (key, value) in environment) + var psi = new ProcessStartInfo(fileName) { - psi.Environment[key] = value; - } - } - - try - { - using var process = Process.Start(psi); - if (process is null) + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = standardInput is not null, + UseShellExecute = false, + CreateNoWindow = true + }; + foreach (var arg in args) { - return ProcessRunResult.NotStarted($"Could not start {fileName}"); + psi.ArgumentList.Add(arg); } - StreamWriter? standardInputWriter = null; - StreamReader? standardOutputReader = null; - StreamReader? standardErrorReader = null; - Task? stdoutTask = null; - Task? stderrTask = null; - try + if (environment is not null) { - using var timeoutCts = timeout is not null - ? CancellationTokenSource.CreateLinkedTokenSource(ct) - : null; - var timeoutStopwatch = timeout is not null ? Stopwatch.StartNew() : null; - if (timeout is { } limit) + foreach (var (key, value) in environment) { - timeoutCts!.CancelAfter(limit); + psi.Environment[key] = value; } + } - var lifecycleToken = timeoutCts?.Token ?? ct; - standardInputWriter = standardInput is not null - ? process.StandardInput - : null; - if (standardInput is not null) - { - try - { - await standardInputWriter! - .WriteAsync(standardInput.AsMemory(), lifecycleToken) - .ConfigureAwait(false); - standardInputWriter.Close(); - } - catch (OperationCanceledException) when ( - timeoutCts?.IsCancellationRequested == true && !ct.IsCancellationRequested - ) - { - await KillAndReapProcessTreeAsync(process).ConfigureAwait(false); - return TimedOutResult(); - } - } + process = Process.Start(psi); + if (process is null) + { + ct.ThrowIfCancellationRequested(); + return ProcessRunResult.NotStarted($"Could not start {fileName}"); + } - standardOutputReader = process.StandardOutput; - standardErrorReader = process.StandardError; - stdoutTask = standardOutputReader.ReadToEndAsync(ct); - stderrTask = standardErrorReader.ReadToEndAsync(ct); + using var timeoutCts = timeout is not null + ? CancellationTokenSource.CreateLinkedTokenSource(ct) + : null; + var timeoutStopwatch = timeout is not null ? Stopwatch.StartNew() : null; + if (timeout is { } limit) + { + timeoutCts!.CancelAfter(limit); + } + var lifecycleToken = timeoutCts?.Token ?? ct; + standardInputWriter = standardInput is not null + ? process.StandardInput + : null; + if (standardInput is not null) + { try { - await process.WaitForExitAsync(lifecycleToken).ConfigureAwait(false); + await standardInputWriter! + .WriteAsync(standardInput.AsMemory(), lifecycleToken) + .ConfigureAwait(false); + standardInputWriter.Close(); } catch (OperationCanceledException) when ( timeoutCts?.IsCancellationRequested == true && !ct.IsCancellationRequested ) { - // Inner timeout fired (not the caller's ct) — kill and return TimedOut - // so the caller can distinguish a timeout from a hard cancellation. await KillAndReapProcessTreeAsync(process).ConfigureAwait(false); - AbandonRead(standardOutputReader, stdoutTask); - AbandonRead(standardErrorReader, stderrTask); + ct.ThrowIfCancellationRequested(); return TimedOutResult(); } + } - var exitCode = process.ExitCode; - if (timeout is not { } timeoutLimit) - { - return new ProcessRunResult( - true, - false, - exitCode, - await stdoutTask.ConfigureAwait(false), - await stderrTask.ConfigureAwait(false) - ); - } + standardOutputReader = process.StandardOutput; + standardErrorReader = process.StandardError; + stdoutTask = standardOutputReader.ReadToEndAsync(ct); + stderrTask = standardErrorReader.ReadToEndAsync(ct); - var remaining = timeoutLimit - timeoutStopwatch!.Elapsed; - // Preserve the lifecycle deadline when time remains, but allow a small - // post-exit grace so a process exiting at the deadline can flush normal - // redirected output. The total run may therefore exceed the limit by at - // most 250 ms when the process exits at deadline-minus-epsilon. - var drainLimit = remaining > s_minimumDrainGrace ? remaining : s_minimumDrainGrace; - using var drainCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - drainCts.CancelAfter(drainLimit); - try - { - await Task.WhenAll(stdoutTask, stderrTask) - .WaitAsync(drainCts.Token) - .ConfigureAwait(false); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - // The process itself exited, so its exit code is authoritative. A - // descendant may still hold the pipe writers (wl-copy/xclip do this). - // Close our redirected stream handles and observe any resulting - // background read faults rather than surfacing a false process timeout - // or waiting for the descendant. - AbandonRead(standardOutputReader, stdoutTask); - AbandonRead(standardErrorReader, stderrTask); - } + try + { + await process.WaitForExitAsync(lifecycleToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when ( + timeoutCts?.IsCancellationRequested == true && !ct.IsCancellationRequested + ) + { + // Inner timeout fired (not the caller's ct) — kill and return TimedOut + // so the caller can distinguish a timeout from a hard cancellation. + await KillAndReapProcessTreeAsync(process).ConfigureAwait(false); + AbandonRead(standardOutputReader, stdoutTask); + AbandonRead(standardErrorReader, stderrTask); + ct.ThrowIfCancellationRequested(); + return TimedOutResult(); + } + var exitCode = process.ExitCode; + if (timeout is not { } timeoutLimit) + { + var standardOutput = await stdoutTask.ConfigureAwait(false); + var standardError = await stderrTask.ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); return new ProcessRunResult( true, false, exitCode, - CompletedOutput(stdoutTask), - CompletedOutput(stderrTask) + standardOutput, + standardError ); } - catch (OperationCanceledException) when (ct.IsCancellationRequested) + + var remaining = timeoutLimit - timeoutStopwatch!.Elapsed; + // Preserve the lifecycle deadline when time remains, but allow a small + // post-exit grace so a process exiting at the deadline can flush normal + // redirected output. The total run may therefore exceed the limit by at + // most 250 ms when the process exits at deadline-minus-epsilon. + var drainLimit = remaining > s_minimumDrainGrace ? remaining : s_minimumDrainGrace; + using var drainCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + drainCts.CancelAfter(drainLimit); + try + { + await Task.WhenAll(stdoutTask, stderrTask) + .WaitAsync(drainCts.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // The process itself exited, so its exit code is authoritative. A + // descendant may still hold the pipe writers (wl-copy/xclip do this). + // Close our redirected stream handles and observe any resulting + // background read faults rather than surfacing a false process timeout + // or waiting for the descendant. + AbandonRead(standardOutputReader, stdoutTask); + AbandonRead(standardErrorReader, stderrTask); + } + + ct.ThrowIfCancellationRequested(); + return new ProcessRunResult( + true, + false, + exitCode, + CompletedOutput(stdoutTask), + CompletedOutput(stderrTask) + ); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // This is caller cancellation, not the inner timeout — kill and reap + // before rethrowing so no child is left running. + if (process is not null) { - // This is caller cancellation, not the inner timeout — kill and reap - // before rethrowing so no child is left running. await KillAndReapProcessTreeAsync(process).ConfigureAwait(false); + } + + if (standardOutputReader is not null && stdoutTask is not null) + { + AbandonRead(standardOutputReader, stdoutTask); + } + + if (standardErrorReader is not null && stderrTask is not null) + { + AbandonRead(standardErrorReader, stderrTask); + } + + throw; + } + catch (Exception ex) + { + if (ct.IsCancellationRequested) + { + if (process is not null) + { + await KillAndReapProcessTreeAsync(process).ConfigureAwait(false); + } + if (standardOutputReader is not null && stdoutTask is not null) { AbandonRead(standardOutputReader, stdoutTask); @@ -226,22 +259,17 @@ await Task.WhenAll(stdoutTask, stderrTask) AbandonRead(standardErrorReader, stderrTask); } - throw; - } - finally - { - DisposeStream(standardInputWriter); - DisposeStream(standardOutputReader); - DisposeStream(standardErrorReader); + ct.ThrowIfCancellationRequested(); } + + return ProcessRunResult.NotStarted(ex.Message); } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - throw; - } - catch (Exception ex) + finally { - return ProcessRunResult.NotStarted(ex.Message); + DisposeSafely(standardInputWriter); + DisposeSafely(standardOutputReader); + DisposeSafely(standardErrorReader); + DisposeSafely(process); } } @@ -257,9 +285,10 @@ private static async Task KillAndReapProcessTreeAsync(Process process) { // Reaping is bounded; Process.Dispose remains the final best effort. } - catch (InvalidOperationException) + catch { - // The process was never associated or has already been reaped. + // Reaping is bounded and best effort; cleanup must not replace the + // original timeout, cancellation, or process failure. } } @@ -282,11 +311,11 @@ private static void KillProcessTree(Process process) } } - private static void DisposeStream(IDisposable? stream) + private static void DisposeSafely(IDisposable? resource) { try { - stream?.Dispose(); + resource?.Dispose(); } catch { diff --git a/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs b/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs index 747ae4e6a..d5ca9369f 100644 --- a/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs @@ -11,25 +11,48 @@ public sealed class ProcessRunnerTests [Fact] public async Task RunAsync_returns_success_when_descendant_holds_stdout_open() { - var stopwatch = Stopwatch.StartNew(); - var runTask = new ProcessRunner().RunAsync( - "/bin/bash", - ["-c", "sleep 30 & exit 0"], - timeout: TimeSpan.FromSeconds(2) - ); + var pidFile = NewPidFile(); + int? childProcessId = null; + try + { + var stopwatch = Stopwatch.StartNew(); + var runTask = new ProcessRunner().RunAsync( + "/bin/bash", + [ + "-c", + "sleep 30 & child=$!; printf '%s' \"$child\" > \"$1\"; exit 0", + "process-runner-test", + pidFile + ], + timeout: TimeSpan.FromSeconds(2) + ); + childProcessId = await WaitForProcessIdAsync(pidFile); - var completedTask = await Task.WhenAny(runTask, Task.Delay(s_testGuard)); - stopwatch.Stop(); + var result = await runTask.WaitAsync(s_testGuard); + stopwatch.Stop(); - Assert.True( - ReferenceEquals(runTask, completedTask) && stopwatch.Elapsed < s_testGuard, - $"ProcessRunner did not bound the output drain; elapsed {stopwatch.Elapsed}." - ); - var result = await runTask; - Assert.True(result.Succeeded); - Assert.True(result.Started); - Assert.False(result.TimedOut); - Assert.Equal(0, result.ExitCode); + Assert.True( + stopwatch.Elapsed < s_testGuard, + $"ProcessRunner did not bound the output drain; elapsed {stopwatch.Elapsed}." + ); + Assert.True(result.Succeeded); + Assert.True(result.Started); + Assert.False(result.TimedOut); + Assert.Equal(0, result.ExitCode); + Assert.True( + ProcessExists(childProcessId.Value), + "A successful bounded drain unexpectedly killed the pipe-holding descendant." + ); + } + finally + { + if (childProcessId is { } leakedProcessId) + { + TryKillProcess(leakedProcessId); + } + + File.Delete(pidFile); + } } [Fact] @@ -53,18 +76,17 @@ public async Task RunAsync_captures_all_output_from_fast_process() [Fact] public async Task RunAsync_kills_process_when_exit_wait_times_out() { - var pidFile = Path.Join( - Path.GetTempPath(), - $"typewhisper-process-runner-{Guid.NewGuid():N}.pid" - ); + var pidFile = NewPidFile(); + FakeProcessIds? processIds = null; try { var stopwatch = Stopwatch.StartNew(); var runTask = new ProcessRunner().RunAsync( "/bin/bash", - ["-c", "printf '%s' \"$$\" > \"$1\"; sleep 30", "process-runner-test", pidFile], + ["-c", PublishPidPairAndWaitScript(), "process-runner-test", pidFile], timeout: TimeSpan.FromSeconds(1) ); + processIds = await WaitForProcessIdsAsync(pidFile); var result = await runTask.WaitAsync(s_testGuard); stopwatch.Stop(); @@ -74,12 +96,13 @@ public async Task RunAsync_kills_process_when_exit_wait_times_out() Assert.True(result.TimedOut); Assert.False(result.Succeeded); Assert.Equal(-1, result.ExitCode); - - var processId = int.Parse(await File.ReadAllTextAsync(pidFile)); - await AssertProcessDisappearsAsync(processId); + Assert.Equal(string.Empty, result.StandardOutput); + Assert.Equal(string.Empty, result.StandardError); + await AssertProcessesDisappearAsync(processIds.Value); } finally { + TryKillProcesses(processIds); File.Delete(pidFile); } } @@ -87,39 +110,77 @@ public async Task RunAsync_kills_process_when_exit_wait_times_out() [Fact] public async Task RunAsync_times_out_when_child_does_not_read_standard_input() { - var pidFile = Path.Join( - Path.GetTempPath(), - $"typewhisper-process-runner-{Guid.NewGuid():N}.pid" - ); + var pidFile = NewPidFile(); + FakeProcessIds? processIds = null; try { var standardInput = new string('x', 256 * 1024); var stopwatch = Stopwatch.StartNew(); var runTask = new ProcessRunner().RunAsync( "/bin/bash", - ["-c", "printf '%s' \"$$\" > \"$1\"; sleep 30", "process-runner-test", pidFile], + ["-c", PublishPidPairAndWaitScript(), "process-runner-test", pidFile], standardInput: standardInput, timeout: TimeSpan.FromSeconds(2) ); + processIds = await WaitForProcessIdsAsync(pidFile); - var completedTask = await Task.WhenAny(runTask, Task.Delay(s_testGuard)); + var result = await runTask.WaitAsync(s_testGuard); stopwatch.Stop(); Assert.True( - ReferenceEquals(runTask, completedTask) && stopwatch.Elapsed < s_testGuard, + stopwatch.Elapsed < s_testGuard, $"ProcessRunner did not bound the stdin write; elapsed {stopwatch.Elapsed}." ); - var result = await runTask; Assert.True(result.Started); Assert.True(result.TimedOut); Assert.False(result.Succeeded); Assert.Equal(-1, result.ExitCode); + Assert.Equal(string.Empty, result.StandardOutput); + Assert.Equal(string.Empty, result.StandardError); + await AssertProcessesDisappearAsync(processIds.Value); + } + finally + { + TryKillProcesses(processIds); + File.Delete(pidFile); + } + } + + [Fact] + public async Task RunAsync_does_not_launch_when_caller_token_is_already_canceled() + { + var pidFile = NewPidFile(); + var argumentsWereRead = false; + var arguments = new ObservedArguments( + ["-c", PublishPidPairAndWaitScript(), "process-runner-test", pidFile], + () => argumentsWereRead = true + ); + using var cts = new CancellationTokenSource(); + // ReSharper disable once MethodHasAsyncOverload -- the token must be canceled before RunAsync is invoked. + cts.Cancel(); + try + { + var runTask = new ProcessRunner().RunAsync( + "/bin/bash", + arguments, + timeout: TimeSpan.FromSeconds(30), + ct: cts.Token + ); - var processId = int.Parse(await File.ReadAllTextAsync(pidFile)); - await AssertProcessDisappearsAsync(processId); + await Assert.ThrowsAnyAsync( + // ReSharper disable once MethodSupportsCancellation -- WaitAsync uses only the test-guard timeout; there is no ambient cancellation token to pass here. + async () => await runTask.WaitAsync(s_testGuard) + ); + Assert.False(argumentsWereRead, "A pre-canceled run progressed toward process launch."); + Assert.False(File.Exists(pidFile), "A pre-canceled run launched the fake child."); } finally { + if (TryReadProcessIds(pidFile, out var leakedProcessIds)) + { + TryKillProcesses(leakedProcessIds); + } + File.Delete(pidFile); } } @@ -127,21 +188,19 @@ public async Task RunAsync_times_out_when_child_does_not_read_standard_input() [Fact] public async Task RunAsync_kills_and_rethrows_on_caller_cancellation() { - var pidFile = Path.Join( - Path.GetTempPath(), - $"typewhisper-process-runner-cancellation-{Guid.NewGuid():N}.pid" - ); + var pidFile = NewPidFile(); using var cts = new CancellationTokenSource(); - int? processId = null; + FakeProcessIds? processIds = null; try { var runTask = new ProcessRunner().RunAsync( "/bin/bash", - ["-c", "printf '%s' \"$$\" > \"$1\"; sleep 30", "process-runner-test", pidFile], + ["-c", PublishPidPairAndWaitScript(), "process-runner-test", pidFile], timeout: TimeSpan.FromSeconds(30), ct: cts.Token ); - processId = await WaitForProcessIdAsync(pidFile); + processIds = await WaitForProcessIdsAsync(pidFile); + Assert.False(runTask.IsCompleted); // ReSharper disable once MethodHasAsyncOverload -- the test deliberately triggers synchronous CTS cancellation to exercise ProcessRunner's cancel-and-kill path. cts.Cancel(); @@ -150,21 +209,151 @@ await Assert.ThrowsAnyAsync( // ReSharper disable once MethodSupportsCancellation -- WaitAsync uses only the test-guard timeout; there is no ambient cancellation token to pass here. async () => await runTask.WaitAsync(s_testGuard) ); - await AssertProcessDisappearsAsync(processId.Value); + await AssertProcessesDisappearAsync(processIds.Value); } finally { // ReSharper disable once MethodHasAsyncOverload -- synchronous cancellation is the intended cleanup here; no async continuation to await in the finally guard. cts.Cancel(); - if (processId is { } leakedProcessId) + TryKillProcesses(processIds); + File.Delete(pidFile); + } + } + + [Fact] + public async Task RunAsync_cancels_blocked_standard_input_and_kills_process_tree() + { + var pidFile = NewPidFile(); + using var cts = new CancellationTokenSource(); + FakeProcessIds? processIds = null; + try + { + var runTask = new ProcessRunner().RunAsync( + "/bin/bash", + ["-c", PublishPidPairAndWaitScript(), "process-runner-test", pidFile], + standardInput: new string('x', 256 * 1024), + ct: cts.Token + ); + processIds = await WaitForProcessIdsAsync(pidFile); + Assert.False(runTask.IsCompleted); + + // ReSharper disable once MethodHasAsyncOverload -- synchronous cancellation establishes the exact stdin-write phase under test. + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + // ReSharper disable once MethodSupportsCancellation -- WaitAsync uses only the test-guard timeout; there is no ambient cancellation token to pass here. + async () => await runTask.WaitAsync(s_testGuard) + ); + await AssertProcessesDisappearAsync(processIds.Value); + } + finally + { + // ReSharper disable once MethodHasAsyncOverload -- synchronous cancellation is the intended cleanup here. + cts.Cancel(); + TryKillProcesses(processIds); + File.Delete(pidFile); + } + } + + [Fact] + public async Task RunAsync_caller_cancellation_wins_when_racing_private_timeout() + { + var pidFile = NewPidFile(); + using var cts = new CancellationTokenSource(); + FakeProcessIds? processIds = null; + try + { + var stopwatch = Stopwatch.StartNew(); + var runTask = new ProcessRunner().RunAsync( + "/bin/bash", + ["-c", PublishPidPairAndWaitScript(), "process-runner-test", pidFile], + timeout: TimeSpan.FromSeconds(1), + ct: cts.Token + ); + processIds = await WaitForProcessIdsAsync(pidFile); + + var callerDeadline = TimeSpan.FromMilliseconds(800) - stopwatch.Elapsed; + if (callerDeadline > TimeSpan.Zero) { - TryKillProcess(leakedProcessId); + cts.CancelAfter(callerDeadline); + } + else + { + // ReSharper disable once MethodHasAsyncOverload -- the deadline already elapsed. + cts.Cancel(); } + await Assert.ThrowsAnyAsync( + // ReSharper disable once MethodSupportsCancellation -- WaitAsync uses only the test-guard timeout; there is no ambient cancellation token to pass here. + async () => await runTask.WaitAsync(s_testGuard) + ); + await AssertProcessesDisappearAsync(processIds.Value); + } + finally + { + // ReSharper disable once MethodHasAsyncOverload -- synchronous cancellation is the intended cleanup here. + cts.Cancel(); + TryKillProcesses(processIds); File.Delete(pidFile); } } + [Fact] + public async Task RunAsync_caller_cancellation_wins_during_post_exit_output_drain() + { + var pidFile = NewPidFile(); + using var cts = new CancellationTokenSource(); + FakeProcessIds? processIds = null; + try + { + var runTask = new ProcessRunner().RunAsync( + "/bin/bash", + [ + "-c", + "sleep 30 & child=$!; printf '%s %s' \"$$\" \"$child\" > \"$1\"; exit 0", + "process-runner-test", + pidFile + ], + timeout: TimeSpan.FromSeconds(30), + ct: cts.Token + ); + processIds = await WaitForProcessIdsAsync(pidFile); + await AssertProcessDisappearsAsync(processIds.Value.BashProcessId); + Assert.True(ProcessExists(processIds.Value.ChildProcessId)); + Assert.False(runTask.IsCompleted); + + // ReSharper disable once MethodHasAsyncOverload -- synchronous cancellation establishes the output-drain phase under test. + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + // ReSharper disable once MethodSupportsCancellation -- WaitAsync uses only the test-guard timeout; there is no ambient cancellation token to pass here. + async () => await runTask.WaitAsync(s_testGuard) + ); + } + finally + { + // Without process-group semantics, the child is unreachable from the exited + // root, so the test reaps it itself rather than relying on ProcessRunner. + // ReSharper disable once MethodHasAsyncOverload -- synchronous cancellation is the intended cleanup here. + cts.Cancel(); + TryKillProcesses(processIds); + File.Delete(pidFile); + } + } + + private static string NewPidFile() + { + return Path.Join( + Path.GetTempPath(), + $"typewhisper-process-runner-{Guid.NewGuid():N}.pid" + ); + } + + private static string PublishPidPairAndWaitScript() + { + return "sleep 30 & child=$!; printf '%s %s' \"$$\" \"$child\" > \"$1\"; wait \"$child\""; + } + private static async Task WaitForProcessIdAsync(string pidFile) { var stopwatch = Stopwatch.StartNew(); @@ -175,7 +364,7 @@ private static async Task WaitForProcessIdAsync(string pidFile) if (File.Exists(pidFile)) { var contents = await File.ReadAllTextAsync(pidFile); - if (int.TryParse(contents, out var processId)) + if (int.TryParse(contents, out var processId) && processId > 0) { return processId; } @@ -192,12 +381,69 @@ private static async Task WaitForProcessIdAsync(string pidFile) throw new Xunit.Sdk.XunitException("Fake child did not publish its PID in time."); } + private static async Task WaitForProcessIdsAsync(string pidFile) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < s_testGuard) + { + try + { + if (TryReadProcessIds(pidFile, out var processIds)) + { + return processIds; + } + } + catch (IOException) + { + // The child may still be publishing the file; retry briefly. + } + + await Task.Delay(20); + } + + throw new Xunit.Sdk.XunitException("Fake child did not publish both PIDs in time."); + } + + private static bool TryReadProcessIds(string pidFile, out FakeProcessIds processIds) + { + processIds = default; + if (!File.Exists(pidFile)) + { + return false; + } + + var parts = File.ReadAllText(pidFile) + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if ( + parts.Length != 2 || + !int.TryParse(parts[0], out var bashProcessId) || + !int.TryParse(parts[1], out var childProcessId) || + bashProcessId <= 0 || + childProcessId <= 0 || + bashProcessId == childProcessId + ) + { + return false; + } + + processIds = new FakeProcessIds(bashProcessId, childProcessId); + return true; + } + + private static Task AssertProcessesDisappearAsync(FakeProcessIds processIds) + { + return Task.WhenAll( + AssertProcessDisappearsAsync(processIds.BashProcessId), + AssertProcessDisappearsAsync(processIds.ChildProcessId) + ); + } + // Kill(true) sends SIGKILL but returns before the kernel reaps the process, so // /proc/{pid} can linger momentarily; poll briefly instead of asserting instantly. private static async Task AssertProcessDisappearsAsync(int processId) { var stopwatch = Stopwatch.StartNew(); - while (Directory.Exists($"/proc/{processId}")) + while (ProcessExists(processId)) { Assert.True( stopwatch.Elapsed < TimeSpan.FromSeconds(1), @@ -207,6 +453,22 @@ private static async Task AssertProcessDisappearsAsync(int processId) } } + private static bool ProcessExists(int processId) + { + return Directory.Exists($"/proc/{processId}"); + } + + private static void TryKillProcesses(FakeProcessIds? processIds) + { + if (processIds is not { } ids) + { + return; + } + + TryKillProcess(ids.BashProcessId); + TryKillProcess(ids.ChildProcessId); + } + private static void TryKillProcess(int processId) { try @@ -219,4 +481,41 @@ private static void TryKillProcess(int processId) // The expected path already reaped the fake child. } } + + private readonly record struct FakeProcessIds(int BashProcessId, int ChildProcessId); + + private sealed class ObservedArguments( + IReadOnlyList values, + Action onRead + ) : IReadOnlyList + { + public int Count + { + get + { + onRead(); + return values.Count; + } + } + + public string this[int index] + { + get + { + onRead(); + return values[index]; + } + } + + public IEnumerator GetEnumerator() + { + onRead(); + return values.GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } } From 2540fe140b9497c77210d188d795de3846c9711f Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 19 Jul 2026 04:02:31 +0000 Subject: [PATCH 110/226] =?UTF-8?q?Bound=20local=20HTTP=20API=20concurrenc?= =?UTF-8?q?y=20and=20request-body=20memory=20(audit=20=C2=A76=20M3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/HttpApiRequestParser.cs | 158 +++++++++------- .../Services/HttpApiService.cs | 172 +++++++++++++++--- .../HttpApiRequestDispatcherTests.cs | 116 ++++++++++++ .../HttpApiRequestParserTests.cs | 120 +++++++++++- 4 files changed, 470 insertions(+), 96 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/HttpApiRequestDispatcherTests.cs diff --git a/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs b/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs index 6ba6a81fa..74b824679 100644 --- a/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs +++ b/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs @@ -12,7 +12,7 @@ internal sealed record HttpApiRequest( string Path, NameValueCollection QueryString, IReadOnlyDictionary Headers, - byte[] Body + ReadOnlyMemory Body ); internal sealed class HttpApiRequestException : Exception @@ -27,7 +27,7 @@ public HttpApiRequestException(int statusCode, string message) } internal sealed record TranscribeApiRequest( - byte[] AudioData, + ReadOnlyMemory AudioData, string FileExtension, string? Language, IReadOnlyList LanguageHints, @@ -44,7 +44,7 @@ internal sealed record MultipartPart( string Name, string? FileName, string? ContentType, - byte[] Data + ReadOnlyMemory Data ); internal sealed record LocalFileTranscribeRequest( @@ -75,9 +75,8 @@ internal sealed record DictionaryTermDeleteRequest(string Term); /// because HttpListener has no multipart support and System.Net.Http's /// parser is server-side only in MultipartReader on netfx-style streams; /// pulling in ASP.NET Core just for boundary scanning is overkill for a -/// single localhost-only endpoint. Body size is capped via -/// so a malicious / runaway client -/// cannot OOM the dictation host. +/// single localhost-only endpoint. Body size is capped while streaming so +/// a malicious / runaway client cannot OOM the dictation host. /// internal static class HttpApiRequestParser { @@ -87,18 +86,12 @@ public static async Task FromListenerRequestAsync( CancellationToken ct ) { - byte[] body; - try - { - await using var buffer = new MemoryStream(); - await using var limited = new LimitedReadStream(request.InputStream, maxBytes); - await limited.CopyToAsync(buffer, ct); - body = buffer.ToArray(); - } - catch (InvalidOperationException) - { - throw new HttpApiRequestException(413, "Request body too large"); - } + var body = await ReadBodyAsync( + request.InputStream, + request.ContentLength64, + maxBytes, + ct + ); var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var key in request.Headers.AllKeys) @@ -118,10 +111,57 @@ CancellationToken ct ); } + internal static async Task> ReadBodyAsync( + Stream input, + long declaredLength, + long maxBytes, + CancellationToken ct + ) + { + ArgumentNullException.ThrowIfNull(input); + if (declaredLength < -1) + { + throw new ArgumentOutOfRangeException(nameof(declaredLength)); + } + + if (maxBytes is < 0 or > int.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(maxBytes)); + } + + if (declaredLength > maxBytes) + { + throw new HttpApiRequestException(413, "Request body too large"); + } + + var initialCapacity = declaredLength >= 0 ? checked((int)declaredLength) : 0; + using var buffer = new MemoryStream(initialCapacity); + try + { + await using var limited = new LimitedReadStream(input, maxBytes); + await limited.CopyToAsync(buffer, ct); + } + catch (RequestBodyTooLargeException) + { + throw new HttpApiRequestException(413, "Request body too large"); + } + + if (!buffer.TryGetBuffer(out var segment)) + { + throw new InvalidOperationException("Request body buffer is not publicly visible."); + } + + return new ReadOnlyMemory( + segment.Array!, + segment.Offset, + checked((int)buffer.Length) + ); + } + public static TranscribeApiRequest ParseTranscribe(HttpApiRequest request) { var contentType = Header(request.Headers, "content-type") ?? ""; - byte[] audioData; + ReadOnlyMemory audioData; string fileExtension; string? language; var languageHints = new List(); @@ -223,16 +263,20 @@ public static TranscribeApiRequest ParseTranscribe(HttpApiRequest request) // ReSharper disable once MemberCanBePrivate.Global // only used internally, but privatizing surfaces CA1859 (return-type) which can't be fixed without altering the signature - public static IReadOnlyList ParseMultipart(byte[] body, string boundary) + public static IReadOnlyList ParseMultipart( + ReadOnlyMemory body, + string boundary + ) { var boundaryBytes = Encoding.UTF8.GetBytes("--" + boundary); - var doubleCrlf = "\r\n\r\n"u8.ToArray(); + ReadOnlySpan doubleCrlf = "\r\n\r\n"u8; var parts = new List(); var searchStart = 0; + var bodySpan = body.Span; - while (searchStart < body.Length) + while (searchStart < bodySpan.Length) { - var boundaryStart = IndexOf(body, boundaryBytes, searchStart); + var boundaryStart = IndexOf(bodySpan, boundaryBytes, searchStart); if (boundaryStart < 0) { break; @@ -240,9 +284,9 @@ public static IReadOnlyList ParseMultipart(byte[] body, string bo var afterBoundary = boundaryStart + boundaryBytes.Length; if ( - afterBoundary + 1 < body.Length - && body[afterBoundary] == (byte)'-' - && body[afterBoundary + 1] == (byte)'-' + afterBoundary + 1 < bodySpan.Length + && bodySpan[afterBoundary] == (byte)'-' + && bodySpan[afterBoundary + 1] == (byte)'-' ) { break; @@ -250,22 +294,22 @@ public static IReadOnlyList ParseMultipart(byte[] body, string bo var partHeaderStart = afterBoundary; if ( - partHeaderStart + 1 < body.Length - && body[partHeaderStart] == (byte)'\r' - && body[partHeaderStart + 1] == (byte)'\n' + partHeaderStart + 1 < bodySpan.Length + && bodySpan[partHeaderStart] == (byte)'\r' + && bodySpan[partHeaderStart + 1] == (byte)'\n' ) { partHeaderStart += 2; } - var headerEnd = IndexOf(body, doubleCrlf, partHeaderStart); + var headerEnd = IndexOf(bodySpan, doubleCrlf, partHeaderStart); if (headerEnd < 0) { break; } var partBodyStart = headerEnd + doubleCrlf.Length; - var nextBoundary = IndexOf(body, boundaryBytes, partBodyStart); + var nextBoundary = IndexOf(bodySpan, boundaryBytes, partBodyStart); if (nextBoundary < 0) { break; @@ -274,8 +318,8 @@ public static IReadOnlyList ParseMultipart(byte[] body, string bo var partBodyEnd = nextBoundary; if ( partBodyEnd >= 2 - && body[partBodyEnd - 2] == (byte)'\r' - && body[partBodyEnd - 1] == (byte)'\n' + && bodySpan[partBodyEnd - 2] == (byte)'\r' + && bodySpan[partBodyEnd - 1] == (byte)'\n' ) { partBodyEnd -= 2; @@ -288,21 +332,17 @@ public static IReadOnlyList ParseMultipart(byte[] body, string bo } var headers = Encoding.UTF8.GetString( - body, - partHeaderStart, - headerEnd - partHeaderStart + bodySpan.Slice(partHeaderStart, headerEnd - partHeaderStart) ); var parsedHeaders = ParsePartHeaders(headers); if (!string.IsNullOrEmpty(parsedHeaders.Name)) { - var data = new byte[partBodyEnd - partBodyStart]; - Buffer.BlockCopy(body, partBodyStart, data, 0, data.Length); parts.Add( new MultipartPart( parsedHeaders.Name, parsedHeaders.FileName, parsedHeaders.ContentType, - data + body.Slice(partBodyStart, partBodyEnd - partBodyStart) ) ); } @@ -415,7 +455,7 @@ string headers { return parts .Where(p => p.Name == name) - .Select(p => Clean(Encoding.UTF8.GetString(p.Data))) + .Select(p => Clean(Encoding.UTF8.GetString(p.Data.Span))) .FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); } @@ -423,7 +463,7 @@ private static IEnumerable Fields(IEnumerable parts, stri { return parts .Where(p => p.Name == name) - .Select(p => Clean(Encoding.UTF8.GetString(p.Data))) + .Select(p => Clean(Encoding.UTF8.GetString(p.Data.Span))) .Where(v => !string.IsNullOrWhiteSpace(v))!; } @@ -492,35 +532,19 @@ private static TranscriptionTask ParseTask(string? value) return lower.Contains("webm") ? "webm" : null; } - private static int IndexOf(byte[] haystack, byte[] needle, int startIndex) + private static int IndexOf( + ReadOnlySpan haystack, + ReadOnlySpan needle, + int startIndex + ) { if (needle.Length == 0) { return startIndex; } - for (var i = startIndex; i <= haystack.Length - needle.Length; i++) - { - var found = true; - // ReSharper disable once LoopCanBeConvertedToQuery -- naive byte-array substring match; the explicit loop is the intended hot-path form. - for (var j = 0; j < needle.Length; j++) - { - if (haystack[i + j] == needle[j]) - { - continue; - } - - found = false; - break; - } - - if (found) - { - return i; - } - } - - return -1; + var relativeIndex = haystack[startIndex..].IndexOf(needle); + return relativeIndex < 0 ? -1 : startIndex + relativeIndex; } private sealed class LimitedReadStream(Stream inner, long maxBytes) : Stream @@ -598,8 +622,10 @@ private void TrackBytes(int read) _bytesRead += read; if (_bytesRead > maxBytes) { - throw new InvalidOperationException("Request body exceeded the configured limit."); + throw new RequestBodyTooLargeException(); } } } -} \ No newline at end of file + + private sealed class RequestBodyTooLargeException : Exception; +} diff --git a/src/TypeWhisper.Linux/Services/HttpApiService.cs b/src/TypeWhisper.Linux/Services/HttpApiService.cs index cc6b5c880..7a9738124 100644 --- a/src/TypeWhisper.Linux/Services/HttpApiService.cs +++ b/src/TypeWhisper.Linux/Services/HttpApiService.cs @@ -10,6 +10,48 @@ namespace TypeWhisper.Linux.Services; +internal sealed class HttpApiRequestDispatcher +{ + private readonly Action _reportException; + private readonly SemaphoreSlim _slots; + + public HttpApiRequestDispatcher(int capacity, Action? reportException = null) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + _slots = new SemaphoreSlim(capacity, capacity); + _reportException = reportException ?? (ex => + Trace.WriteLine($"[HttpApiService] Dispatched request failed: {ex}")); + } + + public Task? TryRun(Func handler) + { + ArgumentNullException.ThrowIfNull(handler); + return _slots.Wait(0) ? RunAsync(handler) : null; + } + + private async Task RunAsync(Func handler) + { + try + { + await handler(); + } + catch (Exception ex) + { + _reportException(ex); + } + finally + { + _slots.Release(); + } + } +} + +internal sealed record HttpApiOverCapacityResponse( + int StatusCode, + string RetryAfter, + string Body +); + /// /// Local HTTP API for dictation/transcription/history. Binds to localhost /// only; CORS is echoed only for the same loopback origin and port so a @@ -17,7 +59,9 @@ namespace TypeWhisper.Linux.Services; /// public sealed class HttpApiService : IDisposable { - private const long MaxTranscribeRequestBytes = 100 * 1024 * 1024; + internal const int MaxConcurrentRequests = 2; + internal const long MaxTranscribeRequestBytes = 100 * 1024 * 1024; + internal const long MaxJsonRequestBytes = 1 * 1024 * 1024; private const string AllowedCorsHeaders = "Authorization, Content-Type, X-Language, X-Language-Hints, X-Task, X-Target-Language, " @@ -38,6 +82,7 @@ public sealed class HttpApiService : IDisposable private readonly ModelManagerService _models; private readonly IPostProcessingPipeline _pipeline; private readonly IProfileService _profiles; + private readonly HttpApiRequestDispatcher _requestDispatcher = new(MaxConcurrentRequests); private readonly DictationSessionResultStore _sessionResults; private readonly ISettingsService _settings; private readonly ITranslationService _translation; @@ -225,7 +270,13 @@ private async Task ListenLoopAsync(CancellationToken ct) try { var context = await listener.GetContextAsync(); - _ = Task.Run(() => HandleRequestAsync(context, ct), ct); + var handlerTask = _requestDispatcher.TryRun(() => + HandleRequestAsync(context, ct) + ); + if (handlerTask is null) + { + await RejectOverCapacityAsync(context, ct); + } } catch (HttpListenerException) when (ct.IsCancellationRequested) { @@ -242,6 +293,50 @@ private async Task ListenLoopAsync(CancellationToken ct) } } + internal static HttpApiOverCapacityResponse CreateOverCapacityResponse() + { + return new HttpApiOverCapacityResponse( + (int)HttpStatusCode.TooManyRequests, + "1", + Serialize(new { error = "Too many concurrent requests" }) + ); + } + + private async Task RejectOverCapacityAsync( + HttpListenerContext context, + CancellationToken ct + ) + { + var response = context.Response; + try + { + var rejection = CreateOverCapacityResponse(); + response.Headers["Retry-After"] = rejection.RetryAfter; + await WriteJsonAsync( + response, + rejection.StatusCode, + rejection.Body, + GetAllowedOrigin(context.Request), + ct + ); + } + catch (Exception ex) + { + Trace.WriteLine($"[HttpApiService] Over-capacity response failed: {ex}"); + } + finally + { + try + { + response.Close(); + } + catch + { + // Best-effort close for disconnected overload clients. + } + } + } + private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken ct) { var response = context.Response; @@ -422,13 +517,27 @@ private static string FormatAccelerationBackend(TranscriptionAccelerationBackend CancellationToken ct ) { - // ContentLength64 is -1 for chunked uploads; reject empty/over-limit known - // lengths up front, let chunked requests through for LimitedReadStream to cap. - if (request.ContentLength64 is 0 or > MaxTranscribeRequestBytes) + if (request.ContentLength64 == 0) { return (413, Serialize(new { error = "Request body too large" })); } + var prepared = await PrepareTranscriptionRequestAsync(request, ct); + try + { + return await RunTranscriptionAsync(prepared.TempPath, prepared.Options, ct); + } + finally + { + DeleteTemporaryFileBestEffort(prepared.TempPath); + } + } + + private static async Task PrepareTranscriptionRequestAsync( + HttpListenerRequest request, + CancellationToken ct + ) + { var apiRequest = await HttpApiRequestParser.FromListenerRequestAsync( request, MaxTranscribeRequestBytes, @@ -454,18 +563,24 @@ CancellationToken ct transcribeRequest.Model, transcribeRequest.AwaitDownload ); - return await RunTranscriptionAsync(tempPath, opts, ct); + return new PreparedTranscriptionRequest(tempPath, opts); } - finally + catch { - try - { - File.Delete(tempPath); - } - catch - { - // Best-effort temp-file cleanup. - } + DeleteTemporaryFileBestEffort(tempPath); + throw; + } + } + + private static void DeleteTemporaryFileBestEffort(string tempPath) + { + try + { + File.Delete(tempPath); + } + catch + { + // Best-effort temp-file cleanup. } } @@ -476,7 +591,7 @@ CancellationToken ct { var apiRequest = await HttpApiRequestParser.FromListenerRequestAsync( request, - MaxTranscribeRequestBytes, + MaxJsonRequestBytes, ct ); if (apiRequest.Body.Length == 0) @@ -488,7 +603,7 @@ CancellationToken ct try { payload = JsonSerializer.Deserialize( - apiRequest.Body, + apiRequest.Body.Span, s_jsonOptions ); } @@ -864,7 +979,7 @@ CancellationToken ct { var apiRequest = await HttpApiRequestParser.FromListenerRequestAsync( request, - MaxTranscribeRequestBytes, + MaxJsonRequestBytes, ct ); if (apiRequest.Body.Length == 0) @@ -876,7 +991,7 @@ CancellationToken ct try { payload = JsonSerializer.Deserialize( - apiRequest.Body, + apiRequest.Body.Span, s_jsonOptions ); } @@ -902,7 +1017,7 @@ CancellationToken ct { var apiRequest = await HttpApiRequestParser.FromListenerRequestAsync( request, - MaxTranscribeRequestBytes, + MaxJsonRequestBytes, ct ); if (apiRequest.Body.Length == 0) @@ -914,7 +1029,7 @@ CancellationToken ct try { payload = JsonSerializer.Deserialize( - apiRequest.Body, + apiRequest.Body.Span, s_jsonOptions ); } @@ -958,7 +1073,7 @@ CancellationToken ct { var apiRequest = await HttpApiRequestParser.FromListenerRequestAsync( request, - MaxTranscribeRequestBytes, + MaxJsonRequestBytes, ct ); if (apiRequest.Body.Length == 0) @@ -970,7 +1085,7 @@ CancellationToken ct try { payload = JsonSerializer.Deserialize( - apiRequest.Body, + apiRequest.Body.Span, s_jsonOptions ); } @@ -1018,7 +1133,7 @@ CancellationToken ct { var apiRequest = await HttpApiRequestParser.FromListenerRequestAsync( request, - MaxTranscribeRequestBytes, + MaxJsonRequestBytes, ct ); if (apiRequest.Body.Length == 0) @@ -1030,7 +1145,7 @@ CancellationToken ct try { payload = JsonSerializer.Deserialize( - apiRequest.Body, + apiRequest.Body.Span, s_jsonOptions ); } @@ -1292,6 +1407,11 @@ private sealed record TranscriptionRunOptions( string? Model, bool AwaitDownload ); + + private sealed record PreparedTranscriptionRequest( + string TempPath, + TranscriptionRunOptions Options + ); } -internal sealed record DictionaryTermsRequest(IReadOnlyList Terms, bool? Replace); \ No newline at end of file +internal sealed record DictionaryTermsRequest(IReadOnlyList Terms, bool? Replace); diff --git a/tests/TypeWhisper.Linux.Tests/HttpApiRequestDispatcherTests.cs b/tests/TypeWhisper.Linux.Tests/HttpApiRequestDispatcherTests.cs new file mode 100644 index 000000000..c51a1d9ac --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/HttpApiRequestDispatcherTests.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using TypeWhisper.Linux.Services; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public class HttpApiRequestDispatcherTests +{ + [Fact] + public void ProductionConcurrencyBound_IsExactlyTwo() + { + Assert.Equal(2, HttpApiService.MaxConcurrentRequests); + } + + [Fact] + public async Task TryRun_RejectsThirdInFlightHandlerThenReusesSlot() + { + var dispatcher = new HttpApiRequestDispatcher(HttpApiService.MaxConcurrentRequests); + var firstEntered = NewSignal(); + var secondEntered = NewSignal(); + var release = NewSignal(); + + var first = dispatcher.TryRun(async () => + { + firstEntered.SetResult(); + await release.Task; + }); + var second = dispatcher.TryRun(async () => + { + secondEntered.SetResult(); + await release.Task; + }); + + await Task.WhenAll(firstEntered.Task, secondEntered.Task); + Assert.NotNull(first); + Assert.NotNull(second); + Assert.False(first.IsCompleted); + Assert.False(second.IsCompleted); + + var thirdInvoked = false; + var third = dispatcher.TryRun(() => + { + thirdInvoked = true; + return Task.CompletedTask; + }); + + Assert.Null(third); + Assert.False(thirdInvoked); + + release.SetResult(); + await Task.WhenAll(first, second); + + var reused = dispatcher.TryRun(() => Task.CompletedTask); + Assert.NotNull(reused); + await reused; + } + + [Fact] + public async Task TryRun_HandlerFailureIsObservedAndDoesNotLeakSlot() + { + var errorObserved = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var dispatcher = new HttpApiRequestDispatcher( + HttpApiService.MaxConcurrentRequests, + errorObserved.SetResult + ); + var entered = NewSignal(); + var fail = NewSignal(); + + var failed = dispatcher.TryRun(async () => + { + entered.SetResult(); + await fail.Task; + throw new InvalidOperationException("Expected dispatcher test failure."); + }); + + await entered.Task; + fail.SetResult(); + await failed!; + + Assert.True(errorObserved.Task.IsCompletedSuccessfully); + var observed = await errorObserved.Task; + Assert.IsType(observed); + + var releaseFreshHandlers = NewSignal(); + var firstFresh = dispatcher.TryRun(() => releaseFreshHandlers.Task); + var secondFresh = dispatcher.TryRun(() => releaseFreshHandlers.Task); + + Assert.NotNull(firstFresh); + Assert.NotNull(secondFresh); + Assert.Null(dispatcher.TryRun(() => Task.CompletedTask)); + + releaseFreshHandlers.SetResult(); + await Task.WhenAll(firstFresh, secondFresh); + } + + [Fact] + public void OverCapacityResponseMetadata_IsPinned() + { + var response = HttpApiService.CreateOverCapacityResponse(); + + Assert.Equal(429, response.StatusCode); + Assert.Equal("1", response.RetryAfter); + using var body = JsonDocument.Parse(response.Body); + Assert.Equal( + "Too many concurrent requests", + body.RootElement.GetProperty("error").GetString() + ); + } + + private static TaskCompletionSource NewSignal() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } +} diff --git a/tests/TypeWhisper.Linux.Tests/HttpApiRequestParserTests.cs b/tests/TypeWhisper.Linux.Tests/HttpApiRequestParserTests.cs index ba2418543..c9a1cd75f 100644 --- a/tests/TypeWhisper.Linux.Tests/HttpApiRequestParserTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HttpApiRequestParserTests.cs @@ -1,4 +1,5 @@ using System.Collections.Specialized; +using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using TypeWhisper.Core.Models; @@ -16,7 +17,7 @@ public class HttpApiRequestParserTests }; [Fact] - public void ParseTranscribe_ReadsMultipartFileAndFields() + public void ParseTranscribe_MultipartAudioSharesRequestBodyBackingArray() { const string boundary = "Boundary123"; var body = Multipart( @@ -45,7 +46,15 @@ public void ParseTranscribe_ReadsMultipartFileAndFields() var parsed = HttpApiRequestParser.ParseTranscribe(request); - Assert.Equal([1, 2, 3, 4], parsed.AudioData); + Assert.True(MemoryMarshal.TryGetArray(request.Body, out var bodySegment)); + Assert.True(MemoryMarshal.TryGetArray(parsed.AudioData, out var audioSegment)); + Assert.Same(bodySegment.Array, audioSegment.Array); + Assert.Equal( + bodySegment.Offset + body.AsSpan().IndexOf(new byte[] { 1, 2, 3, 4 }), + audioSegment.Offset + ); + Assert.Equal(4, audioSegment.Count); + Assert.Equal([1, 2, 3, 4], parsed.AudioData.ToArray()); Assert.Equal("wav", parsed.FileExtension); Assert.Null(parsed.Language); Assert.Equal(["de", "en"], parsed.LanguageHints); @@ -76,11 +85,12 @@ public void ParseTranscribe_ReadsRawBodyHeaders() ["x-engine"] = "openai", ["x-model"] = "gpt-4o-transcribe" }, - [9, 8, 7] + new byte[] { 9, 8, 7 } ); var parsed = HttpApiRequestParser.ParseTranscribe(request); + Assert.Equal([9, 8, 7], parsed.AudioData.ToArray()); Assert.Equal("mp3", parsed.FileExtension); Assert.Equal(["de", "en"], parsed.LanguageHints); Assert.Equal(TranscriptionTask.Translate, parsed.Task); @@ -205,6 +215,58 @@ public void ParseCorrectionUpsert_AcceptsOptionalCaseSensitive() Assert.True(withFlag!.CaseSensitive); } + [Fact] + public async Task ReadBodyAsync_KnownOversizedLengthRejectsBeforeReading() + { + var stream = new CountingThrowingReadStream(); + + var ex = await Assert.ThrowsAsync(() => + HttpApiRequestParser.ReadBodyAsync( + stream, + declaredLength: 9, + maxBytes: 8, + CancellationToken.None + ) + ); + + Assert.Equal(413, ex.StatusCode); + Assert.Equal("Request body too large", ex.Message); + Assert.Equal(0, stream.ReadCalls); + } + + [Fact] + public async Task ReadBodyAsync_UnknownLengthRejectsOverCapAndAcceptsExactCap() + { + var ex = await Assert.ThrowsAsync(() => + HttpApiRequestParser.ReadBodyAsync( + new MemoryStream([1, 2, 3, 4, 5, 6, 7, 8, 9]), + declaredLength: -1, + maxBytes: 8, + CancellationToken.None + ) + ); + + Assert.Equal(413, ex.StatusCode); + Assert.Equal("Request body too large", ex.Message); + + var exact = await HttpApiRequestParser.ReadBodyAsync( + new MemoryStream([1, 2, 3, 4, 5, 6, 7, 8]), + declaredLength: -1, + maxBytes: 8, + CancellationToken.None + ); + + Assert.Equal(8, exact.Length); + Assert.Equal([1, 2, 3, 4, 5, 6, 7, 8], exact.ToArray()); + } + + [Fact] + public void RequestBodyCaps_ArePinned() + { + Assert.Equal(100L * 1024 * 1024, HttpApiService.MaxTranscribeRequestBytes); + Assert.Equal(1L * 1024 * 1024, HttpApiService.MaxJsonRequestBytes); + } + private static byte[] Multipart( string boundary, params (string Name, string? FileName, string? ContentType, byte[] Data)[] parts @@ -240,4 +302,54 @@ private static void Write(Stream stream, string value) var bytes = Encoding.UTF8.GetBytes(value); stream.Write(bytes); } -} \ No newline at end of file + + private sealed class CountingThrowingReadStream : Stream + { + public int ReadCalls { get; private set; } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + ReadCalls++; + throw new InvalidOperationException("The body must not be read."); + } + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) + { + ReadCalls++; + throw new InvalidOperationException("The body must not be read."); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + } +} From 251740b7c45ea4c12bdb8118960cc93b80fd7591 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 19 Jul 2026 04:41:39 +0000 Subject: [PATCH 111/226] =?UTF-8?q?Resolve=20the=20control-socket=20fallba?= =?UTF-8?q?ck=20deterministically=20under=20a=20secured=20user=20directory?= =?UTF-8?q?=20(audit=20=C2=A76=20M5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/Ipc/SocketPathResolver.cs | 90 ++++----- .../SocketPathResolverTests.cs | 172 ++++++++++++++++++ 2 files changed, 208 insertions(+), 54 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/SocketPathResolverTests.cs diff --git a/src/TypeWhisper.Linux/Services/Ipc/SocketPathResolver.cs b/src/TypeWhisper.Linux/Services/Ipc/SocketPathResolver.cs index 2304fc049..7d706ef96 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/SocketPathResolver.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/SocketPathResolver.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Runtime.InteropServices; +using TypeWhisper.Core; namespace TypeWhisper.Linux.Services.Ipc; @@ -9,13 +10,17 @@ namespace TypeWhisper.Linux.Services.Ipc; /// /// /// Preferred: $XDG_RUNTIME_DIR/typewhisper/control.sock (runtime dir is -/// already 0700 via systemd-logind). Falls back to /tmp/typewhisper-$UID/ -/// with an explicit chmod 0700 when XDG_RUNTIME_DIR is unset. +/// already 0700 via systemd-logind). Falls back to +/// TypeWhisperEnvironment.BasePath/Runtime/control.sock with an explicit +/// chmod 0700 when XDG_RUNTIME_DIR is absent or unusable. /// internal static partial class SocketPathResolver { private const string SocketFileName = "control.sock"; + internal static string DefaultFallbackDirectory => + Path.Join(TypeWhisperEnvironment.BasePath, "Runtime"); + // statx(2) ABI: kernel-defined struct, arch-independent. stx_uid is at // offset 20 (after stx_mask:4, stx_blksize:4, stx_attributes:8, stx_nlink:4). // We allocate the full 256-byte buffer the kernel writes into and read @@ -33,50 +38,34 @@ internal static partial class SocketPathResolver /// public static string ResolveControlSocketPath() { + return ResolveControlSocketPath(DefaultFallbackDirectory); + } + + internal static string ResolveControlSocketPath(string fallbackDirectory) + { + var uid = (int)geteuid(); var xdg = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); if (!string.IsNullOrEmpty(xdg) && Directory.Exists(xdg)) { var dir = Path.Join(xdg, "typewhisper"); try { - Directory.CreateDirectory(dir); - // Explicit chmod is cheap insurance against odd umasks. - TryChmod(dir, 0b111_000_000); // 0700 + PreparePrivateDirectory(dir, uid); return Path.Join(dir, SocketFileName); } catch (Exception ex) { Trace.WriteLine( - $"[SocketPathResolver] XDG path {dir} unusable: {ex.Message}. Falling back to /tmp." + $"[SocketPathResolver] XDG path {dir} unusable: {ex.Message}. Falling back to {fallbackDirectory}." ); } } - var uid = (int)geteuid(); - var fallback = $"/tmp/typewhisper-{uid}"; - - // /tmp is world-writable, so a hostile local user could pre-create - // this directory with permissive modes. If it exists with wrong bits, - // we try chmod; if verification still fails we use a per-process - // scratch dir rather than binding inside an attacker-controlled path. - try - { - if (!Directory.Exists(fallback)) - { - Directory.CreateDirectory(fallback); - } - - TryChmod(fallback, 0b111_000_000); // 0700 - } - catch (Exception ex) - { - Trace.WriteLine($"[SocketPathResolver] Could not prepare {fallback}: {ex.Message}"); - return CreatePrivateSocketPath(uid); - } - - return !IsDirectoryPrivateAndOwned(fallback, uid) - ? CreatePrivateSocketPath(uid) - : Path.Join(fallback, SocketFileName); + PreparePrivateDirectory(fallbackDirectory, uid); + Trace.WriteLine( + $"[SocketPathResolver] Using user-data socket directory {fallbackDirectory}." + ); + return Path.Join(fallbackDirectory, SocketFileName); } /// Best-effort chmod; logs on failure but never throws. @@ -98,34 +87,27 @@ public static void TryChmod(string path, uint mode) } } - private static string CreatePrivateSocketPath(int uid) + private static void PreparePrivateDirectory(string directory, int uid) { - var privatePath = Path.Join( - Path.GetTempPath(), - $"typewhisper-{uid}-{Environment.ProcessId}" - ); - Directory.CreateDirectory(privatePath); - TryChmod(privatePath, 0b111_000_000); // 0700 - // If chmod didn't take (read-only FS, odd mount), refuse rather than - // expose a group/other-readable socket — the caller surfaces the exception. - if (!IsDirectoryPrivateAndOwned(privatePath, uid)) + try + { + Directory.CreateDirectory(directory); + } + catch (Exception ex) { - try - { - Directory.Delete(privatePath, true); - } - catch - { - /* best effort */ - } - throw new IOException( - $"Could not secure private socket directory {privatePath} with mode 0700." + $"Could not create private control socket directory {directory}.", + ex ); } - Trace.WriteLine($"[SocketPathResolver] Using private socket directory {privatePath}."); - return Path.Join(privatePath, SocketFileName); + TryChmod(directory, 0b111_000_000); // 0700 + if (!IsDirectoryPrivateAndOwned(directory, uid)) + { + throw new IOException( + $"Could not secure private control socket directory {directory} with owner-only mode 0700." + ); + } } private static bool IsDirectoryPrivateAndOwned(string path, int uid) @@ -231,4 +213,4 @@ private static partial int statx( uint mask, IntPtr statxbuf ); -} \ No newline at end of file +} diff --git a/tests/TypeWhisper.Linux.Tests/SocketPathResolverTests.cs b/tests/TypeWhisper.Linux.Tests/SocketPathResolverTests.cs new file mode 100644 index 000000000..276ffe995 --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/SocketPathResolverTests.cs @@ -0,0 +1,172 @@ +using TypeWhisper.Core; +using TypeWhisper.Linux.Services.Ipc; +using TypeWhisper.Tests; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class SocketPathResolverTests +{ + private const UnixFileMode PrivateDirectoryMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + [Fact] + public void DefaultFallbackDirectory_IsRuntimeUnderBasePath() + { + Assert.Equal( + Path.Join(TypeWhisperEnvironment.BasePath, "Runtime"), + SocketPathResolver.DefaultFallbackDirectory + ); + } + + [Fact] + public void ResolveControlSocketPath_AbsentXdg_ReturnsDeterministicSecureFallback() + { + var tempRoot = TestPaths.CreateTempDirectory( + nameof(ResolveControlSocketPath_AbsentXdg_ReturnsDeterministicSecureFallback) + ); + var fallbackDirectory = Path.Join(tempRoot, "fallback-runtime"); + var originalXdgRuntimeDirectory = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + + try + { + Environment.SetEnvironmentVariable("XDG_RUNTIME_DIR", null); + Directory.CreateDirectory(fallbackDirectory); +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + File.SetUnixFileMode( + fallbackDirectory, + PrivateDirectoryMode | UnixFileMode.GroupRead | UnixFileMode.OtherRead + ); +#pragma warning restore CA1416 + + var firstPath = SocketPathResolver.ResolveControlSocketPath(fallbackDirectory); + var secondPath = SocketPathResolver.ResolveControlSocketPath(fallbackDirectory); + var expectedPath = Path.Join(fallbackDirectory, "control.sock"); + + Assert.Equal(expectedPath, firstPath); + Assert.Equal(expectedPath, secondPath); + Assert.Equal(firstPath, secondPath); + Assert.True(Directory.Exists(fallbackDirectory)); +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + Assert.Equal(PrivateDirectoryMode, File.GetUnixFileMode(fallbackDirectory)); +#pragma warning restore CA1416 + Assert.False(File.Exists(expectedPath)); + } + finally + { + Environment.SetEnvironmentVariable( + "XDG_RUNTIME_DIR", + originalXdgRuntimeDirectory + ); + TestPaths.DeleteDirectory(tempRoot); + } + } + + [Fact] + public void ResolveControlSocketPath_ValidXdg_ReturnsPrimaryWithoutTouchingFallback() + { + var tempRoot = TestPaths.CreateTempDirectory( + nameof(ResolveControlSocketPath_ValidXdg_ReturnsPrimaryWithoutTouchingFallback) + ); + var xdgRuntimeDirectory = Path.Join(tempRoot, "xdg-runtime"); + var fallbackDirectory = Path.Join(tempRoot, "fallback-runtime"); + var originalXdgRuntimeDirectory = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + + try + { + Environment.SetEnvironmentVariable("XDG_RUNTIME_DIR", xdgRuntimeDirectory); + Directory.CreateDirectory(xdgRuntimeDirectory); + + var path = SocketPathResolver.ResolveControlSocketPath(fallbackDirectory); + var containingDirectory = Path.Join(xdgRuntimeDirectory, "typewhisper"); + var expectedPath = Path.Join(containingDirectory, "control.sock"); + + Assert.Equal(expectedPath, path); + Assert.True(Directory.Exists(containingDirectory)); +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + Assert.Equal(PrivateDirectoryMode, File.GetUnixFileMode(containingDirectory)); +#pragma warning restore CA1416 + Assert.False(File.Exists(expectedPath)); + Assert.False(Directory.Exists(fallbackDirectory)); + } + finally + { + Environment.SetEnvironmentVariable( + "XDG_RUNTIME_DIR", + originalXdgRuntimeDirectory + ); + TestPaths.DeleteDirectory(tempRoot); + } + } + + [Fact] + public void ResolveControlSocketPath_UnusableXdgChild_ReturnsSecureFallback() + { + var tempRoot = TestPaths.CreateTempDirectory( + nameof(ResolveControlSocketPath_UnusableXdgChild_ReturnsSecureFallback) + ); + var xdgRuntimeDirectory = Path.Join(tempRoot, "xdg-runtime"); + var fallbackDirectory = Path.Join(tempRoot, "fallback-runtime"); + var originalXdgRuntimeDirectory = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + + try + { + Environment.SetEnvironmentVariable("XDG_RUNTIME_DIR", xdgRuntimeDirectory); + Directory.CreateDirectory(xdgRuntimeDirectory); + File.WriteAllText(Path.Join(xdgRuntimeDirectory, "typewhisper"), "blocking file"); + + var path = SocketPathResolver.ResolveControlSocketPath(fallbackDirectory); + var expectedPath = Path.Join(fallbackDirectory, "control.sock"); + + Assert.Equal(expectedPath, path); + Assert.True(Directory.Exists(fallbackDirectory)); +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + Assert.Equal(PrivateDirectoryMode, File.GetUnixFileMode(fallbackDirectory)); +#pragma warning restore CA1416 + Assert.False(File.Exists(expectedPath)); + } + finally + { + Environment.SetEnvironmentVariable( + "XDG_RUNTIME_DIR", + originalXdgRuntimeDirectory + ); + TestPaths.DeleteDirectory(tempRoot); + } + } + + [Fact] + public void ResolveControlSocketPath_BlockedFallback_ThrowsWithoutReturningSocketPath() + { + var tempRoot = TestPaths.CreateTempDirectory( + nameof(ResolveControlSocketPath_BlockedFallback_ThrowsWithoutReturningSocketPath) + ); + var fallbackDirectory = Path.Join(tempRoot, "blocked-fallback"); + var expectedSocketPath = Path.Join(fallbackDirectory, "control.sock"); + var originalXdgRuntimeDirectory = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + string? socketPath = null; + + try + { + Environment.SetEnvironmentVariable("XDG_RUNTIME_DIR", null); + File.WriteAllText(fallbackDirectory, "blocking file"); + + Assert.Throws(() => + socketPath = SocketPathResolver.ResolveControlSocketPath(fallbackDirectory) + ); + + Assert.Null(socketPath); + Assert.True(File.Exists(fallbackDirectory)); + Assert.False(Directory.Exists(fallbackDirectory)); + Assert.False(File.Exists(expectedSocketPath)); + } + finally + { + Environment.SetEnvironmentVariable( + "XDG_RUNTIME_DIR", + originalXdgRuntimeDirectory + ); + TestPaths.DeleteDirectory(tempRoot); + } + } +} From 2952c9988aa0306e96e1cff88c0a321c418b00b0 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 19 Jul 2026 04:49:55 +0000 Subject: [PATCH 112/226] =?UTF-8?q?Acknowledge=20record.start=20before=20s?= =?UTF-8?q?low=20startup=20completes=20and=20project=20the=20pending=20pha?= =?UTF-8?q?se=20honestly=20(audit=20=C2=A76=20M6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/Ipc/ControlSocketServer.cs | 184 ++++++++--- .../Services/Ipc/JsonControlProtocol.cs | 3 +- .../ControlSocketServerTests.cs | 306 ++++++++++++++++++ 3 files changed, 456 insertions(+), 37 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/ControlSocketServerTests.cs diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs index 8295282bb..c86ab4ea0 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs @@ -42,6 +42,7 @@ internal sealed class ControlSocketServer : IDisposable private readonly DictationOrchestrator _orchestrator; private readonly ISettingsService? _settings; + private readonly ControlSocketStartCoordinator _startCoordinator; private Task? _acceptLoop; // True after a successful bind — lets Dispose distinguish "we own this path" from @@ -49,12 +50,6 @@ internal sealed class ControlSocketServer : IDisposable private bool _bound; private CancellationTokenSource? _cts; private int _disposed; - private Task? _lastStartTask; - - // UTC ticks of the last accepted record.start; used by the tap race guard to decide - // whether an arriving record.stop should await the in-flight start before calling StopAsync. - // Stored as ticks so reads are atomic without a lock. - private long _lastStartTicks; private Socket? _listener; // ReSharper disable once IntroduceOptionalParameters.Global -- kept as explicit overloads; collapsing into optional parameters would delete a member. @@ -72,6 +67,13 @@ public ControlSocketServer( _orchestrator = orchestrator; _hotkey = hotkey; _settings = settings; + _startCoordinator = new ControlSocketStartCoordinator( + () => _orchestrator.CurrentStateLabel, + ex => + Trace.WriteLine( + $"[ControlSocketServer] StartAsync faulted: {ex.GetBaseException().Message}" + ) + ); SocketPath = SocketPathResolver.ResolveControlSocketPath(); } @@ -417,32 +419,9 @@ await writer } } - private async Task HandleStartAsync() + private Task HandleStartAsync() { - var prev = SnapshotState(); - - // Publish the TCS BEFORE invoking the orchestrator: StartAsync runs synchronously - // until its first real await and can hold _toggleGate before yielding. A concurrent - // record.stop would otherwise see IsRecording==false and no-op. HandleStopAsync awaits - // this TCS to ensure the start has completed before calling StopAsync. - var startCompletion = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously - ); - Interlocked.Exchange(ref _lastStartTicks, DateTime.UtcNow.Ticks); - _lastStartTask = startCompletion.Task; - - try - { - await _orchestrator.StartAsync().ConfigureAwait(false); - startCompletion.TrySetResult(); - } - catch (Exception ex) - { - startCompletion.TrySetException(ex); - throw; - } - - return JsonControlProtocol.SerializeAction(prev, SnapshotState()); + return _startCoordinator.DispatchStart(() => _orchestrator.StartAsync()); } private async Task HandleStopAsync() @@ -452,11 +431,10 @@ private async Task HandleStopAsync() // Hyprland `bindr` tap guard: a record.stop within StartStopRaceWindow of a start is // treated as a tap. Await the in-flight start's TCS so StopAsync sees IsRecording==true; // without this, _toggleGate.WaitAsync(0) fails and the user ends up with a stuck recording. - var startTicks = Interlocked.Read(ref _lastStartTicks); + var (startTicks, pendingStart) = _startCoordinator.GetLastStart(); var elapsed = DateTime.UtcNow - new DateTime(startTicks, DateTimeKind.Utc); if (elapsed < s_startStopRaceWindow) { - var pendingStart = _lastStartTask; if (pendingStart is not null && !pendingStart.IsCompleted) { try @@ -540,10 +518,13 @@ private string HandleStatus() return JsonControlProtocol.SerializeStatus(response); } - /// Maps observable orchestrator state to the wire string via . + /// + /// Projects an accepted start as starting until capture is observably open or + /// the complete start operation settles. + /// private string SnapshotState() { - return _orchestrator.CurrentStateLabel; + return _startCoordinator.SnapshotState(); } /// @@ -613,4 +594,135 @@ private static bool NoLivePeer(string path) return false; } } -} \ No newline at end of file +} + +/// +/// Coordinates the one accepted control-socket start phase. The published completion is +/// a tap-stop ordering signal; the separately observed orchestrator task carries failures. +/// +internal sealed class ControlSocketStartCoordinator +{ + private readonly Lock _gate = new(); + private readonly Action _onFault; + private readonly Func _readState; + private Task? _lastStartTask; + private long _lastStartTicks; + + public ControlSocketStartCoordinator(Func readState, Action onFault) + { + _readState = readState; + _onFault = onFault; + } + + /// + /// Accepts one start at a time and returns its action response without awaiting the + /// complete orchestrator operation. The delegate is invoked directly so its synchronous + /// startup-gate prefix runs on the request handler rather than being deferred to the pool. + /// + public Task DispatchStart(Func start) + { + var prev = SnapshotState(); + TaskCompletionSource? startCompletion = null; + + lock (_gate) + { + if (_lastStartTask is null || _lastStartTask.IsCompleted) + { + startCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + _lastStartTask = startCompletion.Task; + _lastStartTicks = DateTime.UtcNow.Ticks; + } + } + + if (startCompletion is null) + { + return Task.FromResult(JsonControlProtocol.SerializeAction(prev, SnapshotState())); + } + + Task startTask; + try + { + startTask = start(); + } + catch (Exception ex) + { + ReportFault(ex); + startCompletion.TrySetResult(); + return Task.FromResult( + JsonControlProtocol.SerializeError(JsonControlProtocol.ErrInternal) + ); + } + + _ = ObserveStartAsync(startTask, startCompletion); + + // A failure that settled synchronously is known before the response is committed. + if (startTask is { IsCompleted: true, IsCompletedSuccessfully: false }) + { + return Task.FromResult( + JsonControlProtocol.SerializeError(JsonControlProtocol.ErrInternal) + ); + } + + return Task.FromResult(JsonControlProtocol.SerializeAction(prev, SnapshotState())); + } + + /// + /// Returns the timestamp/task pair used by the server's 100 ms tap-stop guard under the + /// same synchronization boundary that publishes a new accepted start. + /// + public (long Ticks, Task? Completion) GetLastStart() + { + lock (_gate) + { + return (_lastStartTicks, _lastStartTask); + } + } + + /// Returns the real state, augmented only by the pending startup phase. + public string SnapshotState() + { + var state = _readState(); + if (state == JsonControlProtocol.StateRecording) + { + return state; + } + + lock (_gate) + { + return _lastStartTask is { IsCompleted: false } + ? JsonControlProtocol.StateStarting + : state; + } + } + + private async Task ObserveStartAsync(Task startTask, TaskCompletionSource startCompletion) + { + try + { + await startTask.ConfigureAwait(false); + } + catch (Exception ex) + { + ReportFault(ex); + } + finally + { + // Tap-stop waiters need ordering completion, not the orchestrator's fault. + startCompletion.TrySetResult(); + } + } + + private void ReportFault(Exception ex) + { + try + { + _onFault(ex); + } + catch + { + // Diagnostics must never turn the observer into an unobserved faulted task. + } + } +} diff --git a/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs b/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs index 854f1c4fa..eb6e272a0 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs @@ -34,6 +34,7 @@ internal static class JsonControlProtocol public const string CmdStatus = "status"; public const string StateIdle = "idle"; + public const string StateStarting = "starting"; public const string StateRecording = "recording"; // ReSharper disable once UnusedMember.Global IPC control-protocol state string (status wire vocabulary, mirrors StateIdle/StateRecording); part of the protocol surface even if not emitted in-tree public const string StateTranscribing = "transcribing"; @@ -151,4 +152,4 @@ public sealed class StatusResponse // ReSharper disable once UnusedAutoPropertyAccessor.Global read by the reflection JSON serializer (JsonControlProtocol.JsonOptions) in SerializeStatus public string? Mode { get; set; } } -} \ No newline at end of file +} diff --git a/tests/TypeWhisper.Linux.Tests/ControlSocketServerTests.cs b/tests/TypeWhisper.Linux.Tests/ControlSocketServerTests.cs new file mode 100644 index 000000000..085b644cc --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/ControlSocketServerTests.cs @@ -0,0 +1,306 @@ +using System.Text.Json; +using TypeWhisper.Linux.Services.Ipc; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class ControlSocketServerTests +{ + private static readonly TimeSpan s_testGuard = TimeSpan.FromSeconds(2); + + [Fact] + public async Task Delayed_accepted_start_returns_starting_before_start_completes() + { + var state = JsonControlProtocol.StateIdle; + var startEntered = NewSignal(); + var releaseStart = NewSignal(); + var coordinator = CreateCoordinator(() => state); + + try + { + var responseTask = coordinator.DispatchStart(() => + { + startEntered.TrySetResult(); + return releaseStart.Task; + }); + + await startEntered.Task.WaitAsync(s_testGuard); + Assert.True(responseTask.IsCompletedSuccessfully); + AssertAction( + await responseTask, + JsonControlProtocol.StateIdle, + JsonControlProtocol.StateStarting + ); + Assert.False(releaseStart.Task.IsCompleted); + } + finally + { + releaseStart.TrySetResult(); + await AwaitPublishedStartAsync(coordinator); + } + } + + [Fact] + public async Task Completion_correlation_is_published_before_start_delegate_runs() + { + var releaseStart = NewSignal(); + var coordinator = CreateCoordinator(() => JsonControlProtocol.StateIdle); + Task? correlationSeenByStart = null; + + try + { + _ = coordinator.DispatchStart(() => + { + correlationSeenByStart = coordinator.GetLastStart().Completion; + return releaseStart.Task; + }); + + var publishedCorrelation = coordinator.GetLastStart().Completion; + Assert.NotNull(publishedCorrelation); + Assert.Same(publishedCorrelation, correlationSeenByStart); + Assert.False(publishedCorrelation.IsCompleted); + + releaseStart.TrySetResult(); + await publishedCorrelation.WaitAsync(s_testGuard); + Assert.True(publishedCorrelation.IsCompletedSuccessfully); + } + finally + { + releaseStart.TrySetResult(); + await AwaitPublishedStartAsync(coordinator); + } + } + + [Fact] + public async Task Status_progresses_from_starting_to_recording_and_clears_after_completion() + { + var state = JsonControlProtocol.StateIdle; + var releaseStart = NewSignal(); + // ReSharper disable once AccessToModifiedClosure -- the test deliberately mutates state to drive SnapshotState transitions + var coordinator = CreateCoordinator(() => state); + + try + { + _ = coordinator.DispatchStart(() => releaseStart.Task); + var publishedCorrelation = coordinator.GetLastStart().Completion; + Assert.NotNull(publishedCorrelation); + + Assert.Equal(JsonControlProtocol.StateStarting, coordinator.SnapshotState()); + + state = JsonControlProtocol.StateRecording; + Assert.Equal(JsonControlProtocol.StateRecording, coordinator.SnapshotState()); + + releaseStart.TrySetResult(); + await publishedCorrelation.WaitAsync(s_testGuard); + Assert.Equal(JsonControlProtocol.StateRecording, coordinator.SnapshotState()); + + state = JsonControlProtocol.StateIdle; + var completedResponse = coordinator.DispatchStart(() => Task.CompletedTask); + Assert.True(completedResponse.IsCompletedSuccessfully); + AssertAction( + await completedResponse, + JsonControlProtocol.StateIdle, + JsonControlProtocol.StateIdle + ); + Assert.Equal(JsonControlProtocol.StateIdle, coordinator.SnapshotState()); + } + finally + { + releaseStart.TrySetResult(); + await AwaitPublishedStartAsync(coordinator); + } + } + + [Fact] + public async Task Repeated_start_reuses_in_flight_correlation() + { + var state = JsonControlProtocol.StateIdle; + var releaseStart = NewSignal(); + var startInvocations = 0; + var coordinator = CreateCoordinator(() => state); + + try + { + var firstResponse = coordinator.DispatchStart(() => + { + startInvocations++; + return releaseStart.Task; + }); + var firstCorrelation = coordinator.GetLastStart().Completion; + Assert.NotNull(firstCorrelation); + + var secondResponse = coordinator.DispatchStart(() => + { + startInvocations++; + return Task.CompletedTask; + }); + + Assert.True(firstResponse.IsCompletedSuccessfully); + Assert.True(secondResponse.IsCompletedSuccessfully); + Assert.Equal(1, startInvocations); + AssertAction( + await secondResponse, + JsonControlProtocol.StateStarting, + JsonControlProtocol.StateStarting + ); + Assert.Same(firstCorrelation, coordinator.GetLastStart().Completion); + Assert.False(firstCorrelation.IsCompleted); + + releaseStart.TrySetResult(); + await firstCorrelation.WaitAsync(s_testGuard); + } + finally + { + releaseStart.TrySetResult(); + await AwaitPublishedStartAsync(coordinator); + } + } + + [Fact] + public async Task Background_fault_is_observed_and_clears_starting_phase() + { + var startCompletion = NewSignal(); + var faultObserved = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var faultCount = 0; + var coordinator = new ControlSocketStartCoordinator( + () => JsonControlProtocol.StateIdle, + ex => + { + // ReSharper disable once AccessToModifiedClosure -- deliberate shared fault counter, read via Volatile.Read in the assertions + Interlocked.Increment(ref faultCount); + faultObserved.TrySetResult(ex); + } + ); + var expected = new InvalidOperationException("controlled start failure"); + + try + { + var response = coordinator.DispatchStart(() => startCompletion.Task); + Assert.True(response.IsCompletedSuccessfully); + AssertAction( + await response, + JsonControlProtocol.StateIdle, + JsonControlProtocol.StateStarting + ); + + startCompletion.TrySetException(expected); + var observed = await faultObserved.Task.WaitAsync(s_testGuard); + var publishedCorrelation = coordinator.GetLastStart().Completion; + Assert.NotNull(publishedCorrelation); + await publishedCorrelation.WaitAsync(s_testGuard); + + Assert.Same(expected, observed); + Assert.Equal(1, Volatile.Read(ref faultCount)); + Assert.Equal(JsonControlProtocol.StateIdle, coordinator.SnapshotState()); + } + finally + { + startCompletion.TrySetException(expected); + await AwaitPublishedStartAsync(coordinator); + } + } + + [Fact] + public async Task Synchronous_start_throw_returns_internal_error_and_observes_once() + { + var faultObserved = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var faultCount = 0; + var coordinator = new ControlSocketStartCoordinator( + () => JsonControlProtocol.StateIdle, + ex => + { + // ReSharper disable once AccessToModifiedClosure -- deliberate shared fault counter, read via Volatile.Read in the assertions + Interlocked.Increment(ref faultCount); + faultObserved.TrySetResult(ex); + } + ); + var expected = new InvalidOperationException("synchronous start failure"); + + var response = coordinator.DispatchStart(() => throw expected); + + Assert.True(response.IsCompletedSuccessfully); + AssertError(await response, JsonControlProtocol.ErrInternal); + + var observed = await faultObserved.Task.WaitAsync(s_testGuard); + Assert.Same(expected, observed); + Assert.Equal(1, Volatile.Read(ref faultCount)); + + // The correlation must still settle as an ordering signal even when the start + // faults before returning a task, and the synthetic phase must clear afterward. + await AwaitPublishedStartAsync(coordinator); + Assert.Equal(JsonControlProtocol.StateIdle, coordinator.SnapshotState()); + } + + [Fact] + public async Task Already_faulted_start_task_returns_internal_error_and_observes_once() + { + var faultObserved = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var faultCount = 0; + var coordinator = new ControlSocketStartCoordinator( + () => JsonControlProtocol.StateIdle, + ex => + { + // ReSharper disable once AccessToModifiedClosure -- deliberate shared fault counter, read via Volatile.Read in the assertions + Interlocked.Increment(ref faultCount); + faultObserved.TrySetResult(ex); + } + ); + var expected = new InvalidOperationException("already-faulted start"); + + var response = coordinator.DispatchStart(() => Task.FromException(expected)); + + Assert.True(response.IsCompletedSuccessfully); + AssertError(await response, JsonControlProtocol.ErrInternal); + + var observed = await faultObserved.Task.WaitAsync(s_testGuard); + Assert.Same(expected, observed); + Assert.Equal(1, Volatile.Read(ref faultCount)); + + await AwaitPublishedStartAsync(coordinator); + Assert.Equal(JsonControlProtocol.StateIdle, coordinator.SnapshotState()); + } + + private static ControlSocketStartCoordinator CreateCoordinator(Func readState) + { + return new ControlSocketStartCoordinator(readState, _ => { }); + } + + private static async Task AwaitPublishedStartAsync(ControlSocketStartCoordinator coordinator) + { + var completion = coordinator.GetLastStart().Completion; + if (completion is not null) + { + await completion.WaitAsync(s_testGuard); + } + } + + private static void AssertAction(string json, string expectedPrev, string expectedState) + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.Equal(JsonControlProtocol.CurrentVersion, root.GetProperty("v").GetInt32()); + Assert.True(root.GetProperty("ok").GetBoolean()); + Assert.Equal(expectedPrev, root.GetProperty("prev").GetString()); + Assert.Equal(expectedState, root.GetProperty("state").GetString()); + } + + private static void AssertError(string json, string expectedError) + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.Equal(JsonControlProtocol.CurrentVersion, root.GetProperty("v").GetInt32()); + Assert.False(root.GetProperty("ok").GetBoolean()); + Assert.Equal(expectedError, root.GetProperty("error").GetString()); + } + + private static TaskCompletionSource NewSignal() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } +} From a0e839566d880fdf71663ef6e4ea779a3cf15f64 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 19 Jul 2026 05:00:10 +0000 Subject: [PATCH 113/226] =?UTF-8?q?Make=20profile=20toggles=20atomic=20und?= =?UTF-8?q?er=20one=20service=20gate=20and=20break=20the=20reconcile=20loc?= =?UTF-8?q?k=20inversion=20(audit=20=C2=A76=20M4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interfaces/IProfileService.cs | 7 + .../Services/ProfileService.cs | 142 +++++--- src/TypeWhisper.Linux/App.axaml.cs | 13 +- .../Services/HttpApiService.cs | 5 +- .../Sections/ProfilesSectionViewModel.cs | 2 +- .../Services/ProfileServiceTests.cs | 323 +++++++++++++++++- .../ProfilesSectionViewModelTests.cs | 34 ++ 7 files changed, 472 insertions(+), 54 deletions(-) diff --git a/src/TypeWhisper.Core/Interfaces/IProfileService.cs b/src/TypeWhisper.Core/Interfaces/IProfileService.cs index 4982f43e1..13489737a 100644 --- a/src/TypeWhisper.Core/Interfaces/IProfileService.cs +++ b/src/TypeWhisper.Core/Interfaces/IProfileService.cs @@ -14,6 +14,13 @@ public interface IProfileService void UpdateProfile(Profile profile); void DeleteProfile(string id); + /// + /// Atomically finds the latest profile with , inverts its enabled + /// state, updates its timestamp, persists and publishes the complete list, and returns + /// the committed profile. Returns without writing when missing. + /// + Profile? ToggleProfileEnabled(string id); + /// Seeds the built-in default profiles only on a genuine first run (when no profile file exists yet). void SeedFirstRunDefaultsIfMissing(); diff --git a/src/TypeWhisper.Core/Services/ProfileService.cs b/src/TypeWhisper.Core/Services/ProfileService.cs index fb6b8cc9e..a4ea49f8b 100644 --- a/src/TypeWhisper.Core/Services/ProfileService.cs +++ b/src/TypeWhisper.Core/Services/ProfileService.cs @@ -12,21 +12,30 @@ public sealed class ProfileService : IProfileService { private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + private readonly Action _atomicWrite; private readonly string _filePath; + private readonly Lock _gate = new(); private List _cache = []; private bool _cacheLoaded; public ProfileService(string filePath) + : this(filePath, AtomicFileWrite.WriteAllText) { } + + internal ProfileService(string filePath, Action? atomicWrite) { _filePath = filePath; + _atomicWrite = atomicWrite ?? AtomicFileWrite.WriteAllText; } public IReadOnlyList Profiles { get { - EnsureCacheLoaded(); - return _cache; + lock (_gate) + { + EnsureCacheLoadedLocked(); + return _cache; + } } } @@ -34,63 +43,85 @@ public IReadOnlyList Profiles public void SeedFirstRunDefaultsIfMissing() { - // Seed only when the file has never been written; if the user later deletes - // the seeded profile the file still exists, so we never resurrect it. - if (File.Exists(_filePath)) + lock (_gate) { - return; - } + // Seed only when the file has never been written; if the user later deletes + // the seeded profile the file still exists, so we never resurrect it. + if (File.Exists(_filePath)) + { + return; + } - EnsureCacheLoaded(); - if (_cache.Any(p => p.Id == FirstRunDefaults.AutoFormatProfileId)) - { - return; - } + EnsureCacheLoadedLocked(); + if (_cache.Any(p => p.Id == FirstRunDefaults.AutoFormatProfileId)) + { + return; + } - var newCache = new List(_cache) { FirstRunDefaults.CreateAutoFormatProfile() }; - SortList(newCache); - SaveToDisk(newCache); - _cache = newCache; - ProfilesChanged?.Invoke(); + var newCache = new List(_cache) { FirstRunDefaults.CreateAutoFormatProfile() }; + CommitLocked(newCache); + } } public void AddProfile(Profile profile) { - EnsureCacheLoaded(); - // Persist before swapping _cache so a save failure can't leave the service - // holding an unsaved profile that a later successful save would silently flush. - var newCache = new List(_cache) { profile }; - SortList(newCache); - SaveToDisk(newCache); - _cache = newCache; - ProfilesChanged?.Invoke(); + lock (_gate) + { + EnsureCacheLoadedLocked(); + var newCache = new List(_cache) { profile }; + CommitLocked(newCache); + } } public void UpdateProfile(Profile profile) { - EnsureCacheLoaded(); - var updated = profile with { UpdatedAt = DateTime.UtcNow }; - var newCache = new List(_cache); - var idx = newCache.FindIndex(p => p.Id == profile.Id); - if (idx >= 0) + lock (_gate) { - newCache[idx] = updated; - } + EnsureCacheLoadedLocked(); + var updated = profile with { UpdatedAt = DateTime.UtcNow }; + var newCache = new List(_cache); + var idx = newCache.FindIndex(p => p.Id == profile.Id); + if (idx >= 0) + { + newCache[idx] = updated; + } - SortList(newCache); - SaveToDisk(newCache); - _cache = newCache; - ProfilesChanged?.Invoke(); + CommitLocked(newCache); + } } public void DeleteProfile(string id) { - EnsureCacheLoaded(); - var newCache = new List(_cache); - newCache.RemoveAll(p => p.Id == id); - SaveToDisk(newCache); - _cache = newCache; - ProfilesChanged?.Invoke(); + lock (_gate) + { + EnsureCacheLoadedLocked(); + var newCache = new List(_cache); + newCache.RemoveAll(p => p.Id == id); + CommitLocked(newCache); + } + } + + public Profile? ToggleProfileEnabled(string id) + { + lock (_gate) + { + EnsureCacheLoadedLocked(); + var newCache = new List(_cache); + var idx = newCache.FindIndex(profile => profile.Id == id); + if (idx < 0) + { + return null; + } + + var updated = newCache[idx] with + { + IsEnabled = !newCache[idx].IsEnabled, + UpdatedAt = DateTime.UtcNow + }; + newCache[idx] = updated; + CommitLocked(newCache); + return updated; + } } public MatchResult MatchProfile( @@ -99,8 +130,19 @@ public MatchResult MatchProfile( string? forcedProfileId = null ) { - EnsureCacheLoaded(); + lock (_gate) + { + EnsureCacheLoadedLocked(); + return MatchProfileLocked(processName, url, forcedProfileId); + } + } + private MatchResult MatchProfileLocked( + string? processName, + string? url, + string? forcedProfileId + ) + { if (forcedProfileId is not null) { // A forced selection pointing at a disabled profile should still fall through — @@ -248,7 +290,7 @@ private static void SortList(List profiles) profiles.Sort((a, b) => b.Priority.CompareTo(a.Priority)); } - private void EnsureCacheLoaded() + private void EnsureCacheLoadedLocked() { if (_cacheLoaded) { @@ -281,6 +323,16 @@ private void SaveToDisk(IReadOnlyList profiles) } var json = JsonSerializer.Serialize(profiles, s_jsonOptions); - AtomicFileWrite.WriteAllText(_filePath, json); + _atomicWrite(_filePath, json); + } + + private void CommitLocked(List newCache) + { + SortList(newCache); + // Persist before swapping _cache so a save failure can't leave a published-but-unsaved + // cache; on throw _cache stays on its prior committed list and ProfilesChanged never fires. + SaveToDisk(newCache); + _cache = newCache; + ProfilesChanged?.Invoke(); } } diff --git a/src/TypeWhisper.Linux/App.axaml.cs b/src/TypeWhisper.Linux/App.axaml.cs index 0530665f3..1fff5154c 100644 --- a/src/TypeWhisper.Linux/App.axaml.cs +++ b/src/TypeWhisper.Linux/App.axaml.cs @@ -243,18 +243,23 @@ main.DataContext as MainWindowViewModel // ActionsChanged fires on the UI thread while ProfilesChanged can fire off the // HTTP worker thread (e.g. /v1/profiles/toggle), so the two subscriptions can enter - // this reconcile concurrently. Serialize the snapshot-and-apply so a handler cannot - // capture a stale view of the other service and revive a just-disabled binding. + // this reconcile concurrently. Serialize the apply so the candidate lists are replaced + // atomically for each independently captured service-state snapshot. + // Never read gate-guarded service state (Profiles/Actions) while holding reconcileLock — snapshot first. + // ProfilesChanged/ActionsChanged fire under their service gates, so a read-under-reconcileLock inverts + // the lock order and deadlocks. var reconcileLock = new object(); void ReconcileDynamicHotkeys() { + var actionsSnapshot = promptActions.Actions; + var profilesSnapshot = profileService.Profiles; IReadOnlyList rejections; lock (reconcileLock) { rejections = hotkey.SetDynamicHotkeys( - HotkeyService.ParsePromptActionHotkeys(promptActions.Actions), - HotkeyService.ParseProfileHotkeys(profileService.Profiles) + HotkeyService.ParsePromptActionHotkeys(actionsSnapshot), + HotkeyService.ParseProfileHotkeys(profilesSnapshot) ); } diff --git a/src/TypeWhisper.Linux/Services/HttpApiService.cs b/src/TypeWhisper.Linux/Services/HttpApiService.cs index 7a9738124..9bf025c6c 100644 --- a/src/TypeWhisper.Linux/Services/HttpApiService.cs +++ b/src/TypeWhisper.Linux/Services/HttpApiService.cs @@ -863,14 +863,13 @@ CancellationToken ct return (400, Serialize(new { error = "Missing id parameter" })); } - var profile = _profiles.Profiles.FirstOrDefault(item => item.Id == id); + var profile = _profiles.ToggleProfileEnabled(id); if (profile is null) { return (404, Serialize(new { error = "Profile not found" })); } - var isEnabled = !profile.IsEnabled; - _profiles.UpdateProfile(profile with { IsEnabled = isEnabled }); + var isEnabled = profile.IsEnabled; return (200, Serialize(new { id, isEnabled })); } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs index 63f8d28de..c4ef77b23 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs @@ -716,7 +716,7 @@ private void ToggleProfileEnabled(Profile? profile) return; } - _profiles.UpdateProfile(profile with { IsEnabled = !profile.IsEnabled }); + _profiles.ToggleProfileEnabled(profile.Id); RefreshProfiles(); } diff --git a/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs index 63a762447..4d8f6db00 100644 --- a/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs @@ -6,6 +6,8 @@ namespace TypeWhisper.Core.Tests.Services; /// Covers persistence/round-tripping and forced/hotkey-only profile matching rules. public sealed class ProfileServiceTests : IDisposable { + private static readonly TimeSpan s_testGuard = TimeSpan.FromSeconds(5); + private readonly string _filePath; private readonly ProfileService _sut; @@ -23,6 +25,217 @@ public void Dispose() } } + [Fact] + public void ToggleProfileEnabled_MissingId_DoesNotWriteOrNotify() + { + var original = new Profile + { + Id = "existing", + Name = "Existing", + IsEnabled = false + }; + new ProfileService(_filePath).AddProfile(original); + var writes = 0; + var service = new ProfileService( + _filePath, + (_, _) => + { + Interlocked.Increment(ref writes); + throw new InvalidOperationException("A missing profile must not be written."); + } + ); + var initialProfiles = service.Profiles; + var initialJson = File.ReadAllText(_filePath); + var notifications = 0; + service.ProfilesChanged += () => notifications++; + + var result = service.ToggleProfileEnabled("missing"); + + Assert.Null(result); + Assert.Equal(0, writes); + Assert.Equal(0, notifications); + Assert.Same(initialProfiles, service.Profiles); + Assert.False(Assert.Single(service.Profiles).IsEnabled); + Assert.Equal(initialJson, File.ReadAllText(_filePath)); + } + + [Fact] + public async Task ToggleProfileEnabled_ConcurrentSameProfile_AppliesBothInversions() + { + var original = new Profile + { + Id = "profile", + Name = "Profile", + IsEnabled = false + }; + new ProfileService(_filePath).AddProfile(original); + using var writer = new BlockingAfterCommitWriter(); + var service = new ProfileService(_filePath, writer.Write); + var notifications = 0; + // ReSharper disable once AccessToModifiedClosure -- notifications is an intentional shared counter read via Volatile.Read below + service.ProfilesChanged += () => Interlocked.Increment(ref notifications); + var secondCallerStarted = CreateCompletionSource(); + var secondCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var firstToggle = Task.Run(() => service.ToggleProfileEnabled(original.Id)); + Thread? secondThread = null; + bool secondReachedGateOrWriter; + + try + { + await writer.FirstCommitted.WaitAsync(s_testGuard); + secondThread = new Thread(() => + { + secondCallerStarted.TrySetResult(); + try + { + secondCompletion.TrySetResult(service.ToggleProfileEnabled(original.Id)); + } + catch (Exception ex) + { + secondCompletion.TrySetException(ex); + } + }) + { + IsBackground = true + }; + secondThread.Start(); + await secondCallerStarted.Task.WaitAsync(s_testGuard); + + secondReachedGateOrWriter = SpinWait.SpinUntil( + // ReSharper disable once AccessToDisposedClosure -- lambda runs synchronously inside SpinUntil, before writer is disposed on scope exit + () => + writer.SecondEntered.IsCompleted + || IsWaiting(secondThread) + || !secondThread.IsAlive, + s_testGuard + ); + } + finally + { + writer.ReleaseFirst(); + await CompleteBestEffort(firstToggle, secondCompletion.Task); + if (secondThread is { IsAlive: true }) + { + secondThread.Join(s_testGuard); + } + } + + var results = await Task.WhenAll(firstToggle, secondCompletion.Task) + .WaitAsync(s_testGuard); + + Assert.True(secondReachedGateOrWriter); + Assert.NotNull(results[0]); + Assert.True(results[0]!.IsEnabled); + Assert.NotNull(results[1]); + Assert.False(results[1]!.IsEnabled); + Assert.False(Assert.Single(service.Profiles).IsEnabled); + Assert.False(Assert.Single(new ProfileService(_filePath).Profiles).IsEnabled); + Assert.Equal(2, Volatile.Read(ref notifications)); + Assert.Equal(2, writer.InvocationCount); + Assert.Equal(1, writer.MaximumConcurrency); + Assert.False(writer.SecondEnteredBeforeFirstRelease); + } + + [Fact] + public async Task ToggleProfileEnabled_ConcurrentDifferentProfiles_PreservesBothAndKeepsDiskWithCache() + { + var profileA = new Profile + { + Id = "profile-a", + Name = "Profile A", + IsEnabled = false, + Priority = 20 + }; + var profileB = new Profile + { + Id = "profile-b", + Name = "Profile B", + IsEnabled = false, + Priority = 10 + }; + var seed = new ProfileService(_filePath); + seed.AddProfile(profileA); + seed.AddProfile(profileB); + using var writer = new BlockingAfterCommitWriter(); + var service = new ProfileService(_filePath, writer.Write); + var notifications = 0; + // ReSharper disable once AccessToModifiedClosure -- notifications is an intentional shared counter read via Volatile.Read below + service.ProfilesChanged += () => Interlocked.Increment(ref notifications); + var secondCallerStarted = CreateCompletionSource(); + var secondCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var firstToggle = Task.Run(() => service.ToggleProfileEnabled(profileA.Id)); + Thread? secondThread = null; + bool secondReachedGateOrWriter; + + try + { + await writer.FirstCommitted.WaitAsync(s_testGuard); + secondThread = new Thread(() => + { + secondCallerStarted.TrySetResult(); + try + { + secondCompletion.TrySetResult(service.ToggleProfileEnabled(profileB.Id)); + } + catch (Exception ex) + { + secondCompletion.TrySetException(ex); + } + }) + { + IsBackground = true + }; + secondThread.Start(); + await secondCallerStarted.Task.WaitAsync(s_testGuard); + + secondReachedGateOrWriter = SpinWait.SpinUntil( + // ReSharper disable once AccessToDisposedClosure -- lambda runs synchronously inside SpinUntil, before writer is disposed on scope exit + () => + writer.SecondEntered.IsCompleted + || IsWaiting(secondThread) + || !secondThread.IsAlive, + s_testGuard + ); + } + finally + { + writer.ReleaseFirst(); + await CompleteBestEffort(firstToggle, secondCompletion.Task); + if (secondThread is { IsAlive: true }) + { + secondThread.Join(s_testGuard); + } + } + + var results = await Task.WhenAll(firstToggle, secondCompletion.Task) + .WaitAsync(s_testGuard); + var inMemory = service.Profiles + .Select(profile => (profile.Id, profile.IsEnabled)) + .ToArray(); + var persisted = new ProfileService(_filePath).Profiles + .Select(profile => (profile.Id, profile.IsEnabled)) + .ToArray(); + + Assert.True(secondReachedGateOrWriter); + Assert.NotNull(results[0]); + Assert.True(results[0]!.IsEnabled); + Assert.NotNull(results[1]); + Assert.True(results[1]!.IsEnabled); + Assert.Equal( + [(profileA.Id, true), (profileB.Id, true)], + inMemory + ); + Assert.Equal(inMemory, persisted); + Assert.Equal(2, Volatile.Read(ref notifications)); + Assert.Equal(2, writer.InvocationCount); + Assert.Equal(1, writer.MaximumConcurrency); + Assert.False(writer.SecondEnteredBeforeFirstRelease); + } + [Fact] public void PromptActionId_RoundTrips() { @@ -307,4 +520,112 @@ public void MatchProfile_EmptyMatcherProfileWithoutHotkey_RemainsGlobalFallback( Assert.Equal(MatchKind.Global, result.Kind); Assert.Equal("global", result.Profile!.Id); } -} \ No newline at end of file + + private static TaskCompletionSource CreateCompletionSource() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private static bool IsWaiting(Thread thread) + { + return (thread.ThreadState & ThreadState.WaitSleepJoin) != 0; + } + + private static async Task CompleteBestEffort(params Task?[] tasks) + { + var activeTasks = tasks.Where(task => task is not null).Cast().ToArray(); + if (activeTasks.Length == 0) + { + return; + } + + try + { + await Task.WhenAll(activeTasks).WaitAsync(s_testGuard); + } + catch + { + // Best-effort bounded completion before temporary-file cleanup. + } + } + + private sealed class BlockingAfterCommitWriter : IDisposable + { + private readonly TaskCompletionSource _firstCommitted = CreateCompletionSource(); + private readonly ManualResetEventSlim _releaseFirst = new(false); + private readonly TaskCompletionSource _secondEntered = CreateCompletionSource(); + private int _activeWriters; + private int _firstReleased; + private int _invocations; + private int _maximumConcurrency; + private int _secondEnteredBeforeFirstRelease; + + public Task FirstCommitted => _firstCommitted.Task; + public Task SecondEntered => _secondEntered.Task; + public int InvocationCount => Volatile.Read(ref _invocations); + public int MaximumConcurrency => Volatile.Read(ref _maximumConcurrency); + public bool SecondEnteredBeforeFirstRelease => + Volatile.Read(ref _secondEnteredBeforeFirstRelease) != 0; + + public void Write(string path, string contents) + { + var invocation = Interlocked.Increment(ref _invocations); + var activeWriters = Interlocked.Increment(ref _activeWriters); + UpdateMaximum(activeWriters); + try + { + if (invocation == 1) + { + AtomicFileWrite.WriteAllText(path, contents); + _firstCommitted.TrySetResult(); + if (!_releaseFirst.Wait(s_testGuard)) + { + throw new TimeoutException("The first committed writer was not released."); + } + } + else + { + if (Volatile.Read(ref _firstReleased) == 0) + { + Interlocked.Exchange(ref _secondEnteredBeforeFirstRelease, 1); + } + + _secondEntered.TrySetResult(); + AtomicFileWrite.WriteAllText(path, contents); + } + } + finally + { + Interlocked.Decrement(ref _activeWriters); + } + } + + public void ReleaseFirst() + { + Volatile.Write(ref _firstReleased, 1); + _releaseFirst.Set(); + } + + public void Dispose() + { + ReleaseFirst(); + _releaseFirst.Dispose(); + } + + private void UpdateMaximum(int activeWriters) + { + var current = Volatile.Read(ref _maximumConcurrency); + while ( + activeWriters > current + && Interlocked.CompareExchange( + ref _maximumConcurrency, + activeWriters, + current + ) != current + ) + { + current = Volatile.Read(ref _maximumConcurrency); + } + } + } +} diff --git a/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs index 7ef07c74c..b23498cb0 100644 --- a/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs @@ -76,6 +76,40 @@ public void Constructor_DoesNotInspectActiveWindow() activeWindow.VerifyNoOtherCalls(); } + [Fact] + public void ToggleProfileEnabled_UsesAtomicServiceOperationAndRefreshesProfiles() + { + var profile = CreateEditableProfile() with { IsEnabled = false }; + var committed = profile with { IsEnabled = true }; + var profiles = new Mock(); + profiles + .SetupSequence(service => service.Profiles) + .Returns([profile]) + .Returns([committed]); + profiles + .Setup(service => service.ToggleProfileEnabled(profile.Id)) + .Returns(committed); + var activeWindow = CreateActiveWindowService(); + using var pluginManager = CreatePluginManager(); + var promptActions = new PromptActionService(Path.Join(_tempDir, "prompt-actions.json")); + var sut = new ProfilesSectionViewModel( + profiles.Object, + activeWindow.Object, + pluginManager, + promptActions, + _hotkeys, + Mock.Of(), + new GnomeWindowCallsSetupHelper(), + new BrowserAccessibilitySetupHelper() + ); + + sut.ToggleProfileEnabledCommand.Execute(profile); + + profiles.Verify(service => service.ToggleProfileEnabled(profile.Id), Times.Once); + profiles.Verify(service => service.UpdateProfile(It.IsAny()), Times.Never); + Assert.True(Assert.Single(sut.Profiles).IsEnabled); + } + [Fact] public void SaveProfile_PersistsConfiguredOverrides() { From 4b69bc0cddd10512ada02b7541b0c0f247d02b48 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 19 Jul 2026 05:17:48 +0000 Subject: [PATCH 114/226] =?UTF-8?q?Track=20CUDA=20preload=20per=20library?= =?UTF-8?q?=20so=20a=20partial=20preload=20stays=20retryable=20(audit=20?= =?UTF-8?q?=C2=A76=20M12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SystemCommandAvailabilityService.cs | 73 +++++++-- .../SystemCommandAvailabilityServiceTests.cs | 142 +++++++++++++++++- 2 files changed, 198 insertions(+), 17 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs index 7d9f260fe..a723fda8f 100644 --- a/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs +++ b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs @@ -34,9 +34,16 @@ public sealed partial class SystemCommandAvailabilityService "/usr/local/cuda-12.0/lib64", "/usr/local/cuda-12.0/targets/x86_64-linux/lib" ]; + private static readonly string[] s_requiredCuda12RuntimeLibraries = + [ + "libcudart.so.12", + "libcublas.so.12" + ]; private static readonly Lock s_cudaPreloadLock = new(); - private static readonly List s_cudaPreloadHandles = []; + private static readonly Dictionary s_cudaPreloadHandles = new( + StringComparer.Ordinal + ); private LinuxCapabilitySnapshot _snapshot = BuildSnapshot(); @@ -238,32 +245,66 @@ public static bool TryPreloadCuda12RuntimeLibraries(out string message) // so native whisper/sherpa libs find them even without LD_LIBRARY_PATH. lock (s_cudaPreloadLock) { - if (s_cudaPreloadHandles.Count > 0) + return TryPreloadCuda12RuntimeLibrariesFromDirectory( + directory, + s_cudaPreloadHandles, + LoadCuda12RuntimeLibrary, + out message + ); + } + } + + // Callers sharing loadedHandles must synchronize access around this operation. + internal static bool TryPreloadCuda12RuntimeLibrariesFromDirectory( + string directory, + IDictionary loadedHandles, + Func loadLibrary, + out string message + ) + { + if ( + s_requiredCuda12RuntimeLibraries.All(library => + loadedHandles.TryGetValue(library, out var handle) && handle != IntPtr.Zero + ) + ) + { + message = $"CUDA 12 runtime libraries were preloaded from {directory}."; + return true; + } + + foreach (var library in s_requiredCuda12RuntimeLibraries) + { + if ( + loadedHandles.TryGetValue(library, out var loadedHandle) + && loadedHandle != IntPtr.Zero + ) { - message = $"CUDA 12 runtime libraries were preloaded from {directory}."; - return true; + continue; } - foreach (var library in new[] { "libcudart.so.12", "libcublas.so.12" }) + var (handle, error) = loadLibrary(Path.Join(directory, library)); + if (handle == IntPtr.Zero) { - var path = Path.Join(directory, library); - var handle = dlopen(path, RtldNow | RtldGlobal); - if (handle == IntPtr.Zero) - { - var error = Marshal.PtrToStringAnsi(dlerror()); - message = - $"Could not load {library} from {directory}: {error ?? "unknown error"}"; - return false; - } - - s_cudaPreloadHandles.Add(handle); + message = + $"Could not load {library} from {directory}: {error ?? "unknown error"}"; + return false; } + + loadedHandles[library] = handle; } message = $"CUDA 12 runtime libraries were loaded from {directory}."; return true; } + private static (IntPtr Handle, string? Error) LoadCuda12RuntimeLibrary(string path) + { + var handle = dlopen(path, RtldNow | RtldGlobal); + return handle == IntPtr.Zero + ? (handle, Marshal.PtrToStringAnsi(dlerror())) + : (handle, null); + } + public async Task RunCudaBenchmarkAsync( CancellationToken cancellationToken = default ) diff --git a/tests/TypeWhisper.Linux.Tests/SystemCommandAvailabilityServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SystemCommandAvailabilityServiceTests.cs index 935e66fee..4213265ef 100644 --- a/tests/TypeWhisper.Linux.Tests/SystemCommandAvailabilityServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SystemCommandAvailabilityServiceTests.cs @@ -5,6 +5,146 @@ namespace TypeWhisper.Linux.Tests; public sealed class SystemCommandAvailabilityServiceTests { + [Fact] + public void TryPreloadCuda12RuntimeLibraries_PartialLoadRemainsIncompleteAndRetriesMissingLibrary() + { + const string directory = "/fake/cuda"; + var cudartPath = Path.Join(directory, "libcudart.so.12"); + var cublasPath = Path.Join(directory, "libcublas.so.12"); + var loadedHandles = new Dictionary(StringComparer.Ordinal); + var calls = new List(); + var cublasAttempts = 0; + + (IntPtr Handle, string? Error) LoadLibrary(string path) + { + calls.Add(path); + if (path == cudartPath) + { + return (new IntPtr(1), null); + } + + Assert.Equal(cublasPath, path); + cublasAttempts++; + return cublasAttempts == 1 + ? (IntPtr.Zero, "simulated cublas failure") + : (new IntPtr(2), null); + } + + var firstResult = + SystemCommandAvailabilityService.TryPreloadCuda12RuntimeLibrariesFromDirectory( + directory, + loadedHandles, + LoadLibrary, + out var firstMessage + ); + + Assert.False(firstResult); + Assert.Equal( + "Could not load libcublas.so.12 from /fake/cuda: simulated cublas failure", + firstMessage + ); + Assert.Single(loadedHandles); + Assert.Equal(new IntPtr(1), loadedHandles["libcudart.so.12"]); + Assert.False(loadedHandles.ContainsKey("libcublas.so.12")); + + var secondResult = + SystemCommandAvailabilityService.TryPreloadCuda12RuntimeLibrariesFromDirectory( + directory, + loadedHandles, + LoadLibrary, + out var secondMessage + ); + + Assert.True(secondResult); + Assert.Equal("CUDA 12 runtime libraries were loaded from /fake/cuda.", secondMessage); + Assert.Equal(2, loadedHandles.Count); + Assert.Equal(new IntPtr(2), loadedHandles["libcublas.so.12"]); + Assert.Equal(1, calls.Count(path => path == cudartPath)); + Assert.Equal(2, calls.Count(path => path == cublasPath)); + } + + [Fact] + public void TryPreloadCuda12RuntimeLibraries_CompleteLoadIsCached() + { + const string directory = "/fake/cuda"; + var cudartPath = Path.Join(directory, "libcudart.so.12"); + var cublasPath = Path.Join(directory, "libcublas.so.12"); + var loadedHandles = new Dictionary(StringComparer.Ordinal); + var calls = new List(); + + (IntPtr Handle, string? Error) LoadLibrary(string path) + { + calls.Add(path); + if (path == cudartPath) + { + return (new IntPtr(1), null); + } + + Assert.Equal(cublasPath, path); + return (new IntPtr(2), null); + } + + var firstResult = + SystemCommandAvailabilityService.TryPreloadCuda12RuntimeLibrariesFromDirectory( + directory, + loadedHandles, + LoadLibrary, + out var firstMessage + ); + + Assert.True(firstResult); + Assert.Equal("CUDA 12 runtime libraries were loaded from /fake/cuda.", firstMessage); + Assert.Equal(new[] { cudartPath, cublasPath }, calls); + Assert.Equal(new IntPtr(1), loadedHandles["libcudart.so.12"]); + Assert.Equal(new IntPtr(2), loadedHandles["libcublas.so.12"]); + + var secondResult = + SystemCommandAvailabilityService.TryPreloadCuda12RuntimeLibrariesFromDirectory( + directory, + loadedHandles, + LoadLibrary, + out var secondMessage + ); + + Assert.True(secondResult); + Assert.Equal( + "CUDA 12 runtime libraries were preloaded from /fake/cuda.", + secondMessage + ); + Assert.Equal(2, calls.Count); + } + + [Fact] + public void TryPreloadCuda12RuntimeLibraries_FailedLoadReturnsFalseAndReportsError() + { + const string directory = "/fake/cuda"; + var cudartPath = Path.Join(directory, "libcudart.so.12"); + var loadedHandles = new Dictionary(StringComparer.Ordinal); + var calls = new List(); + + (IntPtr Handle, string? Error) LoadLibrary(string path) + { + calls.Add(path); + return (IntPtr.Zero, "simulated dlopen error"); + } + + var result = + SystemCommandAvailabilityService.TryPreloadCuda12RuntimeLibrariesFromDirectory( + directory, + loadedHandles, + LoadLibrary, + out var message + ); + + Assert.False(result); + Assert.Empty(loadedHandles); + Assert.Equal( + "Could not load libcudart.so.12 from /fake/cuda: simulated dlopen error", + message + ); + Assert.Equal(new[] { cudartPath }, calls); + } + [Fact] public void LinuxCapabilitySnapshot_CanAutoPasteRequiresClipboardAndPasteTools() { @@ -137,4 +277,4 @@ public void LinuxCapabilitySnapshot_WaylandXdotoolOnlyReportsXWayland() Assert.True(snapshot.HasAutomaticPasteTool); Assert.Equal("xdotool available (XWayland only)", snapshot.PasteStatus); } -} \ No newline at end of file +} From f273e5355ef77538957baaf377a5d6de1e41d8fd Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 19 Jul 2026 11:39:31 +0000 Subject: [PATCH 115/226] =?UTF-8?q?Isolate=20watch-folder=20runs=20into=20?= =?UTF-8?q?generations=20with=20bounded=20stop=20drain=20and=20retired-run?= =?UTF-8?q?=20muting=20(audit=20=C2=A76=20M8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ckbh5QuNxeMGGjE6qpVUK4 --- .../Services/WatchFolderService.cs | 627 ++++++++++++++---- .../WatchFolderServiceTests.cs | 478 +++++++++++++ 2 files changed, 973 insertions(+), 132 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/WatchFolderService.cs b/src/TypeWhisper.Linux/Services/WatchFolderService.cs index 4c93e9f13..89e864cee 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderService.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderService.cs @@ -6,44 +6,33 @@ namespace TypeWhisper.Linux.Services; -public sealed class WatchFolderService : IDisposable +public sealed class WatchFolderService : IDisposable, IAsyncDisposable { private const int MaxExportPathAttempts = 1000; + private static readonly TimeSpan s_workerDrainDeadline = TimeSpan.FromSeconds(2); private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true }; - private readonly ConcurrentDictionary _activeFiles = new( + private readonly ConcurrentDictionary _activeFiles = new( StringComparer.OrdinalIgnoreCase ); - private readonly HashSet _failedFingerprints = new(StringComparer.OrdinalIgnoreCase); private readonly List _history = []; private readonly string _historyPath; - private readonly ConcurrentQueue _pendingFiles = []; + private readonly SemaphoreSlim _lifecycleGate = new(1, 1); private readonly Lock _persistenceGate = new(); private readonly HashSet _processedFingerprints = new(StringComparer.OrdinalIgnoreCase); - private readonly string _processedFingerprintsPath; - - private readonly ConcurrentDictionary _queuedFiles = new( - StringComparer.OrdinalIgnoreCase - ); - private readonly Lock _stateGate = new(); - private CancellationTokenSource? _cts; + private readonly Func _waitForWorkers; + private volatile WatchFolderRun? _currentRun; + private WatchFolderRun? _currentlyProcessingRun; + private string? _currentlyProcessing; private bool _disposed; - private WatchFolderOptions? _options; - - private Func< - WatchFolderTranscriptionRequest, - CancellationToken, - Task - >? _transcribeHandler; - - private FileSystemWatcher? _watcher; + private string? _watchPath; public WatchFolderService() : this(TypeWhisperEnvironment.DataPath) @@ -51,7 +40,16 @@ public WatchFolderService() } internal WatchFolderService(string dataPath) + : this(dataPath, static (workers, timeout) => workers.WaitAsync(timeout)) { + } + + internal WatchFolderService( + string dataPath, + Func waitForWorkers + ) + { + _waitForWorkers = waitForWorkers; Directory.CreateDirectory(dataPath); _processedFingerprintsPath = Path.Join(dataPath, "watch-folder-processed.json"); _historyPath = Path.Join(dataPath, "watch-folder-history.json"); @@ -60,9 +58,31 @@ internal WatchFolderService(string dataPath) } // ReSharper disable once UnusedAutoPropertyAccessor.Global public service-state accessor exposing the active watch path (parallels CurrentlyProcessing/IsRunning) - public string? WatchPath { get; private set; } - public string? CurrentlyProcessing { get; private set; } - public bool IsRunning => _watcher is not null; + public string? WatchPath + { + get + { + lock (_stateGate) + { + return _watchPath; + } + } + } + + public string? CurrentlyProcessing + { + get + { + lock (_stateGate) + { + return _currentlyProcessing; + } + } + } + + public bool IsRunning => _currentRun is not null; + + internal WatchFolderRun? CurrentRun => _currentRun; public IReadOnlyList History { @@ -77,13 +97,12 @@ public IReadOnlyList History public void Dispose() { - if (_disposed) - { - return; - } + DisposeAsync().AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + } - _disposed = true; - Stop(); + public ValueTask DisposeAsync() + { + return new ValueTask(DisposeAsyncCore()); } public void Start( @@ -95,64 +114,47 @@ public void Start( > transcribeHandler ) { - ThrowIfDisposed(); - Stop(); - - if (string.IsNullOrWhiteSpace(options.WatchPath)) + _lifecycleGate.Wait(); + try { - throw new ArgumentException("Watch folder path is required.", nameof(options)); - } + ThrowIfDisposed(); + StopCoreAsync().ConfigureAwait(false).GetAwaiter().GetResult(); - Directory.CreateDirectory(options.WatchPath); - if (!string.IsNullOrWhiteSpace(options.OutputPath)) - { - Directory.CreateDirectory(options.OutputPath); - } + if (string.IsNullOrWhiteSpace(options.WatchPath)) + { + throw new ArgumentException("Watch folder path is required.", nameof(options)); + } - _options = options; - _transcribeHandler = transcribeHandler; - _cts = new CancellationTokenSource(); - WatchPath = options.WatchPath; + Directory.CreateDirectory(options.WatchPath); + if (!string.IsNullOrWhiteSpace(options.OutputPath)) + { + Directory.CreateDirectory(options.OutputPath); + } - _watcher = new FileSystemWatcher(options.WatchPath) + StartRun(options, transcribeHandler); + } + finally { - NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size, - IncludeSubdirectories = false, - EnableRaisingEvents = true - }; - _watcher.Created += OnFileCreated; - _watcher.Changed += OnFileChanged; - _watcher.Renamed += OnFileRenamed; - - ScanFolder(options.WatchPath); - Task.Run(() => ProcessQueueAsync(_cts.Token)); - // Periodic rescan catches files missed when the OS event buffer overflows. - Task.Run(() => RescanLoopAsync(options.WatchPath, _cts.Token)); - OnStateChanged(); + _lifecycleGate.Release(); + } } public void Stop() { - _watcher?.Dispose(); - _watcher = null; - _cts?.Cancel(); - _cts?.Dispose(); - _cts = null; - _transcribeHandler = null; - _options = null; - WatchPath = null; - CurrentlyProcessing = null; - - while (_pendingFiles.TryDequeue(out _)) { } + StopAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + } - _queuedFiles.Clear(); - _activeFiles.Clear(); - lock (_persistenceGate) + public async Task StopAsync() + { + await _lifecycleGate.WaitAsync().ConfigureAwait(false); + try { - _failedFingerprints.Clear(); + await StopCoreAsync().ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); } - - OnStateChanged(); } public void ClearHistory() @@ -170,29 +172,180 @@ public void ClearHistory() // ReSharper disable once EventNeverSubscribedTo.Global -- public API; raised for each processed file for external/future subscribers. public event EventHandler? FileProcessed; - private void OnFileCreated(object sender, FileSystemEventArgs e) + private void StartRun( + WatchFolderOptions options, + Func< + WatchFolderTranscriptionRequest, + CancellationToken, + Task + > transcribeHandler + ) { - TryScanEventFolder(e.FullPath); + var cancellationSource = new CancellationTokenSource(); + FileSystemWatcher? watcher = null; + WatchFolderRun run; + try + { + watcher = new FileSystemWatcher(options.WatchPath) + { + NotifyFilter = + NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size, + IncludeSubdirectories = false + }; + run = new WatchFolderRun( + cancellationSource, + options, + transcribeHandler, + watcher + ); + watcher.Created += (_, e) => TryScanEventFolder(run, e.FullPath); + watcher.Changed += (_, e) => TryScanEventFolder(run, e.FullPath); + watcher.Renamed += (_, e) => TryScanEventFolder(run, e.FullPath); + watcher.EnableRaisingEvents = true; + } + catch + { + watcher?.Dispose(); + cancellationSource.Dispose(); + throw; + } + + var queueWorker = Task.Run(() => ProcessQueueAsync(run)); + // Periodic rescan catches files missed when the OS event buffer overflows. + var rescanWorker = Task.Run(() => RescanLoopAsync(run)); + run.SetWorkers(queueWorker, rescanWorker); + + lock (_stateGate) + { + _watchPath = options.WatchPath; + _currentlyProcessing = null; + _currentlyProcessingRun = null; + _currentRun = run; + } + + ScanFolder(run, options.WatchPath); + OnStateChanged(); } - private void OnFileChanged(object sender, FileSystemEventArgs e) + private async Task StopCoreAsync() { - TryScanEventFolder(e.FullPath); + WatchFolderRun? run; + lock (_stateGate) + { + run = _currentRun; + _currentRun = null; + } + + if (run is not null) + { + try + { + run.Watcher.EnableRaisingEvents = false; + } + catch (ObjectDisposedException) + { + // A concurrent watcher callback can observe disposal while retiring the run. + } + + run.Watcher.Dispose(); + try + { + run.CancellationSource.Cancel(); + } + catch (AggregateException ex) + { + Debug.WriteLine($"WatchFolder cancellation callback failed: {ex}"); + } + } + + lock (_stateGate) + { + _watchPath = null; + if (run is null || ReferenceEquals(_currentlyProcessingRun, run)) + { + _currentlyProcessing = null; + _currentlyProcessingRun = null; + } + } + + OnStateChanged(); + if (run is null) + { + return; + } + + var timedOut = false; + try + { + await _waitForWorkers(run.WorkerCompletion, s_workerDrainDeadline) + .ConfigureAwait(false); + } + catch (TimeoutException) when (!run.WorkerCompletion.IsCompleted) + { + timedOut = true; + } + catch (Exception ex) + { + Debug.WriteLine($"WatchFolder worker stopped with an error: {ex}"); + } + + if (timedOut) + { + run.SetRetiredCleanup(ObserveRetiredRunAsync(run)); + return; + } + + run.DisposeCancellationSource(); + } + + private static async Task ObserveRetiredRunAsync(WatchFolderRun run) + { + try + { + await run.WorkerCompletion.ConfigureAwait(false); + } + catch (Exception ex) + { + Debug.WriteLine($"Retired WatchFolder worker stopped with an error: {ex}"); + } + finally + { + run.DisposeCancellationSource(); + } } - private void OnFileRenamed(object sender, RenamedEventArgs e) + private async Task DisposeAsyncCore() { - TryScanEventFolder(e.FullPath); + await _lifecycleGate.WaitAsync().ConfigureAwait(false); + try + { + if (_disposed) + { + return; + } + + _disposed = true; + await StopCoreAsync().ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } } - private void TryScanEventFolder(string filePath) + private void TryScanEventFolder(WatchFolderRun run, string filePath) { + if (!IsRunCurrentAndLive(run)) + { + return; + } + try { var folderPath = Path.GetDirectoryName(filePath); if (!string.IsNullOrWhiteSpace(folderPath)) { - ScanFolder(folderPath); + ScanFolder(run, folderPath); } } catch (Exception ex) when (IsExpectedFolderScanException(ex)) @@ -201,9 +354,9 @@ private void TryScanEventFolder(string filePath) } } - private void ScanFolder(string folderPath) + private void ScanFolder(WatchFolderRun run, string folderPath) { - if (!Directory.Exists(folderPath)) + if (!IsRunCurrentAndLive(run) || !Directory.Exists(folderPath)) { return; } @@ -217,7 +370,12 @@ var filePath in Directory .OrderBy(Path.GetFileName) ) { - EnqueueFile(filePath); + if (!IsRunCurrentAndLive(run)) + { + return; + } + + EnqueueFile(run, filePath); } } catch (Exception ex) when (IsExpectedFolderScanException(ex)) @@ -226,8 +384,13 @@ var filePath in Directory } } - private void EnqueueFile(string filePath) + private void EnqueueFile(WatchFolderRun run, string filePath) { + if (!IsRunCurrentAndLive(run)) + { + return; + } + var fullPath = Path.GetFullPath(filePath); if (_activeFiles.ContainsKey(fullPath)) { @@ -235,24 +398,31 @@ private void EnqueueFile(string filePath) } var fingerprint = CreateFingerprint(fullPath); - if (fingerprint is null || IsKnownFingerprint(fingerprint)) + if (fingerprint is null || IsKnownFingerprint(run, fingerprint)) { return; } - if (!_queuedFiles.TryAdd(fullPath, 0)) + if (!run.QueuedFiles.TryAdd(fullPath, 0)) { return; } - _pendingFiles.Enqueue(fullPath); + if (!IsRunCurrentAndLive(run)) + { + run.QueuedFiles.TryRemove(fullPath, out _); + return; + } + + run.PendingFiles.Enqueue(fullPath); } - private async Task ProcessQueueAsync(CancellationToken ct) + private async Task ProcessQueueAsync(WatchFolderRun run) { + var ct = run.CancellationSource.Token; while (!ct.IsCancellationRequested) { - if (!_pendingFiles.TryDequeue(out var filePath)) + if (!run.PendingFiles.TryDequeue(out var filePath)) { try { @@ -266,10 +436,10 @@ private async Task ProcessQueueAsync(CancellationToken ct) continue; } - _queuedFiles.TryRemove(filePath, out _); + run.QueuedFiles.TryRemove(filePath, out _); try { - await ProcessFileAsync(filePath, ct); + await ProcessFileAsync(run, filePath, ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -278,14 +448,15 @@ private async Task ProcessQueueAsync(CancellationToken ct) } } - private async Task RescanLoopAsync(string folderPath, CancellationToken ct) + private async Task RescanLoopAsync(WatchFolderRun run) { + var ct = run.CancellationSource.Token; while (!ct.IsCancellationRequested) { try { await Task.Delay(TimeSpan.FromSeconds(5), ct); - ScanFolder(folderPath); + ScanFolder(run, run.Options.WatchPath); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -298,46 +469,66 @@ private async Task RescanLoopAsync(string folderPath, CancellationToken ct) } } - private async Task ProcessFileAsync(string filePath, CancellationToken ct) + private async Task ProcessFileAsync( + WatchFolderRun run, + string filePath, + CancellationToken ct + ) { filePath = Path.GetFullPath(filePath); var fileName = Path.GetFileName(filePath); string? fingerprint = null; - _activeFiles.TryAdd(filePath, 0); - CurrentlyProcessing = fileName; - OnStateChanged(); + if (!_activeFiles.TryAdd(filePath, run)) + { + return; + } try { + // Inside the try so a throwing state notification still runs the finally that + // releases this run's reservation; _activeFiles is never cleared on stop. + SetCurrentlyProcessing(run, fileName); await WaitForFileReadyAsync(filePath, ct); + ct.ThrowIfCancellationRequested(); + if (!IsRunCurrentAndLive(run)) + { + return; + } + fingerprint = CreateFingerprint(filePath); - if (fingerprint is null || IsKnownFingerprint(fingerprint)) + if (fingerprint is null || IsKnownFingerprint(run, fingerprint)) { return; } - var options = - _options - ?? throw new InvalidOperationException("Watch folder options are not available."); - var transcribeHandler = - _transcribeHandler - ?? throw new InvalidOperationException( - "Watch folder transcriber is not available." - ); - var result = await transcribeHandler(new WatchFolderTranscriptionRequest(filePath), ct); + var result = await run.TranscribeHandler( + new WatchFolderTranscriptionRequest(filePath), + ct + ); + ct.ThrowIfCancellationRequested(); + if (!IsRunCurrentAndLive(run)) + { + return; + } - var outputFolder = string.IsNullOrWhiteSpace(options.OutputPath) - ? options.WatchPath - : options.OutputPath!; + var outputFolder = string.IsNullOrWhiteSpace(run.Options.OutputPath) + ? run.Options.WatchPath + : run.Options.OutputPath!; Directory.CreateDirectory(outputFolder); var artifact = WatchFolderExportBuilder.Build( - options.OutputFormat, + run.Options.OutputFormat, result, fileName, ResolveEngineName(result), DateTime.Now ); + ct.ThrowIfCancellationRequested(); + if (!IsRunCurrentAndLive(run)) + { + return; + } + var outputPath = CommitExport( outputFolder, Path.GetFileNameWithoutExtension(filePath), @@ -346,11 +537,16 @@ private async Task ProcessFileAsync(string filePath, CancellationToken ct) ); string? sourceDeletionError = null; - if (options.DeleteSource) + if (run.Options.DeleteSource) { // The export write ignores the token; re-check so a Stop that lands mid-commit // can't still delete the source. ct.ThrowIfCancellationRequested(); + if (!IsRunCurrentAndLive(run)) + { + return; + } + try { File.Delete(filePath); @@ -363,8 +559,15 @@ private async Task ProcessFileAsync(string filePath, CancellationToken ct) } } - AddProcessedFingerprint(fingerprint); + ct.ThrowIfCancellationRequested(); + if (!IsRunCurrentAndLive(run)) + { + return; + } + + AddProcessedFingerprint(run, fingerprint); AddHistory( + run, new WatchFolderHistoryItem { Id = Guid.NewGuid().ToString(), @@ -387,12 +590,18 @@ private async Task ProcessFileAsync(string filePath, CancellationToken ct) catch (Exception ex) { Debug.WriteLine($"WatchFolder transcription failed: {ex.Message}"); + if (!IsRunCurrentAndLive(run)) + { + return; + } + if (fingerprint is not null) { - AddFailedFingerprint(fingerprint); + AddFailedFingerprint(run, fingerprint); } AddHistory( + run, new WatchFolderHistoryItem { Id = Guid.NewGuid().ToString(), @@ -406,9 +615,8 @@ private async Task ProcessFileAsync(string filePath, CancellationToken ct) } finally { - _activeFiles.TryRemove(filePath, out _); - CurrentlyProcessing = null; - OnStateChanged(); + _activeFiles.TryRemove(new KeyValuePair(filePath, run)); + ClearCurrentlyProcessing(run); } } @@ -469,37 +677,116 @@ private static string ResolveEngineName(WatchFolderTranscriptionResult result) return result.EngineId ?? result.ModelId ?? "Default"; } - private bool IsKnownFingerprint(string fingerprint) + private bool IsRunCurrentAndLive(WatchFolderRun run) + { + return ReferenceEquals(_currentRun, run) + && !run.CancellationSource.IsCancellationRequested; + } + + private void SetCurrentlyProcessing(WatchFolderRun run, string fileName) + { + lock (_stateGate) + { + if (!ReferenceEquals(_currentRun, run) || run.CancellationSource.IsCancellationRequested) + { + return; + } + + _currentlyProcessing = fileName; + _currentlyProcessingRun = run; + } + + if (IsRunCurrentAndLive(run)) + { + OnStateChanged(); + } + } + + private void ClearCurrentlyProcessing(WatchFolderRun run) + { + lock (_stateGate) + { + if ( + !ReferenceEquals(_currentRun, run) + || !ReferenceEquals(_currentlyProcessingRun, run) + ) + { + return; + } + + _currentlyProcessing = null; + _currentlyProcessingRun = null; + } + + if (IsRunCurrentAndLive(run)) + { + OnStateChanged(); + } + } + + internal bool OwnsActiveFile(WatchFolderRun run, string filePath) + { + return _activeFiles.TryGetValue(Path.GetFullPath(filePath), out var owner) + && ReferenceEquals(owner, run); + } + + private bool IsKnownFingerprint(WatchFolderRun run, string fingerprint) { lock (_persistenceGate) { - return _processedFingerprints.Contains(fingerprint) - || _failedFingerprints.Contains(fingerprint); + if (_processedFingerprints.Contains(fingerprint)) + { + return true; + } + } + + lock (run.FailedFingerprintsGate) + { + return run.FailedFingerprints.Contains(fingerprint); } } - private void AddProcessedFingerprint(string fingerprint) + private void AddProcessedFingerprint(WatchFolderRun run, string fingerprint) { + if (!IsRunCurrentAndLive(run)) + { + return; + } + + lock (run.FailedFingerprintsGate) + { + run.FailedFingerprints.Remove(fingerprint); + } + lock (_persistenceGate) { - _failedFingerprints.Remove(fingerprint); + if (!IsRunCurrentAndLive(run)) + { + return; + } + _processedFingerprints.Add(fingerprint); SaveProcessedFingerprintsCore(); } } - private void AddFailedFingerprint(string fingerprint) + private static void AddFailedFingerprint(WatchFolderRun run, string fingerprint) { - lock (_persistenceGate) + lock (run.FailedFingerprintsGate) { - _failedFingerprints.Add(fingerprint); + run.FailedFingerprints.Add(fingerprint); } } - private void AddHistory(WatchFolderHistoryItem item) + private void AddHistory(WatchFolderRun run, WatchFolderHistoryItem item) { lock (_stateGate) { + if (!ReferenceEquals(_currentRun, run) || run.CancellationSource.IsCancellationRequested) + { + return; + } + _history.Insert(0, item); if (_history.Count > 100) { @@ -508,8 +795,15 @@ private void AddHistory(WatchFolderHistoryItem item) } SaveHistory(); - FileProcessed?.Invoke(this, item); - OnStateChanged(); + if (IsRunCurrentAndLive(run)) + { + FileProcessed?.Invoke(this, item); + } + + if (IsRunCurrentAndLive(run)) + { + OnStateChanged(); + } } private static async Task WaitForFileReadyAsync(string path, CancellationToken ct) @@ -681,7 +975,15 @@ private void SaveHistory() private void OnStateChanged() { - StateChanged?.Invoke(this, EventArgs.Empty); + try + { + StateChanged?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + // A notification subscriber must not abort lifecycle cleanup (worker drain / CTS disposal). + Debug.WriteLine($"WatchFolder StateChanged subscriber threw: {ex}"); + } } private static bool IsExpectedFolderScanException(Exception ex) @@ -703,4 +1005,65 @@ private void ThrowIfDisposed() { ObjectDisposedException.ThrowIf(_disposed, this); } + + internal sealed class WatchFolderRun + { + private int _cancellationSourceDisposed; + + internal WatchFolderRun( + CancellationTokenSource cancellationSource, + WatchFolderOptions options, + Func< + WatchFolderTranscriptionRequest, + CancellationToken, + Task + > transcribeHandler, + FileSystemWatcher watcher + ) + { + CancellationSource = cancellationSource; + Options = options; + TranscribeHandler = transcribeHandler; + Watcher = watcher; + } + + internal CancellationTokenSource CancellationSource { get; } + internal WatchFolderOptions Options { get; } + + internal Func< + WatchFolderTranscriptionRequest, + CancellationToken, + Task + > TranscribeHandler { get; } + + internal FileSystemWatcher Watcher { get; } + internal ConcurrentQueue PendingFiles { get; } = []; + + internal ConcurrentDictionary QueuedFiles { get; } = new( + StringComparer.OrdinalIgnoreCase + ); + + internal Lock FailedFingerprintsGate { get; } = new(); + internal HashSet FailedFingerprints { get; } = new(StringComparer.OrdinalIgnoreCase); + internal Task WorkerCompletion { get; private set; } = Task.CompletedTask; + internal Task RetiredCleanup { get; private set; } = Task.CompletedTask; + + internal void SetWorkers(Task queueWorker, Task rescanWorker) + { + WorkerCompletion = Task.WhenAll(queueWorker, rescanWorker); + } + + internal void SetRetiredCleanup(Task retiredCleanup) + { + RetiredCleanup = retiredCleanup; + } + + internal void DisposeCancellationSource() + { + if (Interlocked.Exchange(ref _cancellationSourceDisposed, 1) == 0) + { + CancellationSource.Dispose(); + } + } + } } diff --git a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs index 10b7f8d62..17b233453 100644 --- a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs @@ -127,6 +127,455 @@ public async Task Start_WhenExportNameIsOccupiedByDirectory_AdvancesSuffix() Assert.True(Directory.Exists(Path.Join(outputPath, "meeting.txt"))); } + [Fact] + public async Task StopAsync_InFlightHandler_AwaitsWorkerBeforeReturning() + { + var watchPath = Path.Join(_tempDir, "await-watch"); + var outputPath = Path.Join(_tempDir, "await-output"); + var dataPath = Path.Join(_tempDir, "await-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + var sourcePath = Path.Join(watchPath, "blocked.wav"); + File.WriteAllBytes(sourcePath, [1, 2, 3]); + + var entered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var release = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var processed = new ConcurrentQueue(); + var service = new WatchFolderService(dataPath); + Task? stopTask = null; + service.FileProcessed += (_, item) => processed.Enqueue(item); + + try + { + service.Start( + CreateOptions(watchPath, outputPath, deleteSource: true), + async (request, ct) => + { + entered.TrySetResult(ct); + await release.Task; + return CreateResult(request); + } + ); + + var oldToken = await entered.Task.WaitAsync(TimeSpan.FromSeconds(15)); + stopTask = service.StopAsync(); + + Assert.False(service.IsRunning); + Assert.Null(service.WatchPath); + Assert.Null(service.CurrentlyProcessing); + Assert.True(oldToken.IsCancellationRequested); + Assert.False(stopTask.IsCompleted); + + release.TrySetResult(true); + await stopTask.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.True(File.Exists(sourcePath)); + Assert.False(File.Exists(Path.Join(outputPath, "blocked.txt"))); + Assert.Empty(service.History); + Assert.Empty(processed); + Assert.False(File.Exists(Path.Join(dataPath, "watch-folder-processed.json"))); + } + finally + { + release.TrySetResult(true); + if (stopTask is not null) + { + await stopTask.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + + [Fact] + public async Task Restart_AfterBoundedDrain_UsesFreshRunAndLeavesOldQueuedWorkRetired() + { + var oldWatchPath = Path.Join(_tempDir, "restart-old-watch"); + var newWatchPath = Path.Join(_tempDir, "restart-new-watch"); + var outputPath = Path.Join(_tempDir, "restart-output"); + Directory.CreateDirectory(oldWatchPath); + Directory.CreateDirectory(newWatchPath); + Directory.CreateDirectory(outputPath); + File.WriteAllBytes(Path.Join(oldWatchPath, "a-blocked.wav"), [1, 2, 3]); + File.WriteAllBytes(Path.Join(oldWatchPath, "b-old-pending.wav"), [4, 5, 6]); + + var oldEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var releaseOld = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var newEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var releaseNew = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var oldCalls = new ConcurrentQueue(); + var newCalls = new ConcurrentQueue(); + Task? retiredWorkers = null; + TimeSpan? requestedDeadline = null; + var retiredWorkersWereIncomplete = false; + var waitCallCount = 0; + + Task WaitForWorkers(Task workers, TimeSpan timeout) + { + if (Interlocked.Increment(ref waitCallCount) == 1) + { + retiredWorkers = workers; + requestedDeadline = timeout; + retiredWorkersWereIncomplete = !workers.IsCompleted; + return Task.FromException(new TimeoutException("Simulated worker drain timeout.")); + } + + return workers.WaitAsync(timeout); + } + + var service = new WatchFolderService( + Path.Join(_tempDir, "restart-data"), + WaitForWorkers + ); + WatchFolderService.WatchFolderRun? oldRun = null; + + try + { + service.Start( + CreateOptions(oldWatchPath, outputPath), + async (request, ct) => + { + var fileName = Path.GetFileName(request.FilePath); + oldCalls.Enqueue(fileName); + if (fileName == "a-blocked.wav") + { + oldEntered.TrySetResult(ct); + await releaseOld.Task; + } + + return CreateResult(request); + } + ); + + var oldToken = await oldEntered.Task.WaitAsync(TimeSpan.FromSeconds(15)); + oldRun = service.CurrentRun; + Assert.NotNull(oldRun); + var oldPendingFiles = oldRun.PendingFiles; + var oldQueuedFiles = oldRun.QueuedFiles; + var oldCancellationSource = oldRun.CancellationSource; + var oldPendingPath = Path.GetFullPath( + Path.Join(oldWatchPath, "b-old-pending.wav") + ); + Assert.Contains(oldPendingPath, oldPendingFiles); + Assert.True(oldQueuedFiles.ContainsKey(oldPendingPath)); + + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.False(service.IsRunning); + Assert.True(oldToken.IsCancellationRequested); + Assert.True(retiredWorkersWereIncomplete); + Assert.Equal(TimeSpan.FromSeconds(2), requestedDeadline); + Assert.Same(oldRun.WorkerCompletion, retiredWorkers); + Assert.False(retiredWorkers!.IsCompleted); + // The retired-run observer is registered but cannot complete while the old + // handler is still gated: this fails if the SetRetiredCleanup registration is dropped. + Assert.False(oldRun.RetiredCleanup.IsCompleted); + + File.WriteAllBytes(Path.Join(newWatchPath, "new.wav"), [7, 8, 9]); + service.Start( + CreateOptions(newWatchPath, outputPath), + async (request, ct) => + { + newCalls.Enqueue(Path.GetFileName(request.FilePath)); + newEntered.TrySetResult(ct); + await releaseNew.Task; + return CreateResult(request); + } + ); + + var newRun = service.CurrentRun; + Assert.NotNull(newRun); + var newToken = await newEntered.Task.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.NotSame(oldRun, newRun); + Assert.NotSame(oldPendingFiles, newRun.PendingFiles); + Assert.NotSame(oldQueuedFiles, newRun.QueuedFiles); + Assert.NotSame(oldCancellationSource, newRun.CancellationSource); + Assert.NotEqual(oldToken, newToken); + Assert.True(oldToken.IsCancellationRequested); + Assert.False(newToken.IsCancellationRequested); + + releaseOld.TrySetResult(true); + await retiredWorkers.WaitAsync(TimeSpan.FromSeconds(15)); + await oldRun.RetiredCleanup.WaitAsync(TimeSpan.FromSeconds(15)); + + // The retired observer disposes the old generation's CTS exactly once when its + // workers settle; accessing the token afterward must throw. + Assert.Throws(() => _ = oldCancellationSource.Token); + Assert.Equal(["a-blocked.wav"], oldCalls); + Assert.Equal(["new.wav"], newCalls); + Assert.Equal("new.wav", service.CurrentlyProcessing); + Assert.True(service.OwnsActiveFile(newRun, Path.Join(newWatchPath, "new.wav"))); + } + finally + { + releaseOld.TrySetResult(true); + releaseNew.TrySetResult(true); + var finalRun = service.CurrentRun; + if (service.IsRunning) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (retiredWorkers is not null) + { + await retiredWorkers.WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (oldRun is not null) + { + await oldRun.RetiredCleanup.WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (finalRun is not null && !ReferenceEquals(finalRun, oldRun)) + { + await finalRun.RetiredCleanup.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + + [Fact] + public async Task SameFolderRestart_OldCompletionCannotOverlapOrPublishIntoNewRun() + { + var watchPath = Path.Join(_tempDir, "same-watch"); + var outputPath = Path.Join(_tempDir, "same-output"); + var dataPath = Path.Join(_tempDir, "same-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + var sharedPath = Path.Join(watchPath, "a-shared.wav"); + var newPath = Path.Join(watchPath, "z-new.wav"); + File.WriteAllBytes(sharedPath, [1, 2, 3]); + + var oldEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var releaseOld = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var newEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var releaseNew = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var newProcessed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var oldCalls = new ConcurrentQueue(); + var newCalls = new ConcurrentQueue(); + var processed = new ConcurrentQueue(); + Task? retiredWorkers = null; + var waitCallCount = 0; + + Task WaitForWorkers(Task workers, TimeSpan timeout) + { + if (Interlocked.Increment(ref waitCallCount) == 1) + { + retiredWorkers = workers; + return Task.FromException(new TimeoutException("Simulated worker drain timeout.")); + } + + return workers.WaitAsync(timeout); + } + + var service = new WatchFolderService(dataPath, WaitForWorkers); + WatchFolderService.WatchFolderRun? oldRun = null; + service.FileProcessed += (_, item) => + { + processed.Enqueue(item); + newProcessed.TrySetResult(item); + }; + + try + { + service.Start( + CreateOptions(watchPath, outputPath, deleteSource: true), + async (request, ct) => + { + oldCalls.Enqueue(Path.GetFileName(request.FilePath)); + oldEntered.TrySetResult(ct); + await releaseOld.Task; + return CreateResult(request); + } + ); + + var oldToken = await oldEntered.Task.WaitAsync(TimeSpan.FromSeconds(15)); + oldRun = service.CurrentRun; + Assert.NotNull(oldRun); + Assert.True(service.OwnsActiveFile(oldRun, sharedPath)); + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + + File.WriteAllBytes(newPath, [4, 5, 6]); + service.Start( + CreateOptions(watchPath, outputPath, deleteSource: true), + async (request, ct) => + { + newCalls.Enqueue(Path.GetFileName(request.FilePath)); + newEntered.TrySetResult(ct); + await releaseNew.Task; + return CreateResult(request); + } + ); + + var newRun = service.CurrentRun; + Assert.NotNull(newRun); + var newToken = await newEntered.Task.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.Equal(["a-shared.wav"], oldCalls); + Assert.Equal(["z-new.wav"], newCalls); + Assert.DoesNotContain("a-shared.wav", newCalls); + Assert.NotEqual(oldToken, newToken); + Assert.True(oldToken.IsCancellationRequested); + Assert.False(newToken.IsCancellationRequested); + Assert.True(service.OwnsActiveFile(oldRun, sharedPath)); + Assert.True(service.OwnsActiveFile(newRun, newPath)); + + releaseOld.TrySetResult(true); + await retiredWorkers!.WaitAsync(TimeSpan.FromSeconds(15)); + await oldRun.RetiredCleanup.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.Equal("z-new.wav", service.CurrentlyProcessing); + Assert.True(service.OwnsActiveFile(newRun, newPath)); + // The retired worker released its own reservation on unwind, so a later rescan of + // the still-present source is no longer suppressed by a stale reservation. + Assert.False(service.OwnsActiveFile(oldRun, sharedPath)); + Assert.True(File.Exists(sharedPath)); + Assert.False(File.Exists(Path.Join(outputPath, "a-shared.txt"))); + Assert.Empty(service.History); + Assert.Empty(processed); + Assert.Empty(oldRun.FailedFingerprints); + Assert.False(File.Exists(Path.Join(dataPath, "watch-folder-processed.json"))); + + releaseNew.TrySetResult(true); + var item = await newProcessed.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.True(item.Success, item.ErrorMessage); + Assert.Equal("z-new.wav", item.FileName); + Assert.Equal(Path.Join(outputPath, "z-new.txt"), item.OutputPath); + Assert.True(File.Exists(item.OutputPath)); + Assert.False(File.Exists(newPath)); + Assert.True(File.Exists(sharedPath)); + Assert.Single(service.History); + Assert.Single(processed); + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + finally + { + releaseOld.TrySetResult(true); + releaseNew.TrySetResult(true); + var finalRun = service.CurrentRun; + if (service.IsRunning) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (retiredWorkers is not null) + { + await retiredWorkers.WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (oldRun is not null) + { + await oldRun.RetiredCleanup.WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (finalRun is not null && !ReferenceEquals(finalRun, oldRun)) + { + await finalRun.RetiredCleanup.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + + [Fact] + public async Task DisposeAsync_UsesBoundedStopAndPreventsRestart() + { + var watchPath = Path.Join(_tempDir, "dispose-watch"); + var outputPath = Path.Join(_tempDir, "dispose-output"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + File.WriteAllBytes(Path.Join(watchPath, "blocked.wav"), [1, 2, 3]); + + var entered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var release = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + Task? retiredWorkers = null; + TimeSpan? requestedDeadline = null; + + Task WaitForWorkers(Task workers, TimeSpan timeout) + { + retiredWorkers = workers; + requestedDeadline = timeout; + return Task.FromException(new TimeoutException("Simulated worker drain timeout.")); + } + + var service = new WatchFolderService( + Path.Join(_tempDir, "dispose-data"), + WaitForWorkers + ); + WatchFolderService.WatchFolderRun? oldRun = null; + + try + { + service.Start( + CreateOptions(watchPath, outputPath), + async (request, ct) => + { + entered.TrySetResult(ct); + await release.Task; + return CreateResult(request); + } + ); + + var oldToken = await entered.Task.WaitAsync(TimeSpan.FromSeconds(15)); + oldRun = service.CurrentRun; + Assert.NotNull(oldRun); + await service.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.False(service.IsRunning); + Assert.True(oldToken.IsCancellationRequested); + Assert.Equal(TimeSpan.FromSeconds(2), requestedDeadline); + Assert.Same(oldRun.WorkerCompletion, retiredWorkers); + Assert.Throws( + () => service.Start(CreateOptions(watchPath, outputPath), TranscribeAsync) + ); + + service.Dispose(); + await service.DisposeAsync(); + } + finally + { + release.TrySetResult(true); + if (retiredWorkers is not null) + { + await retiredWorkers.WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (oldRun is not null) + { + await oldRun.RetiredCleanup.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + private static async Task> StartAndWaitForProcessedItemsAsync( WatchFolderService service, @@ -179,4 +628,33 @@ CancellationToken ct ) ); } + + private static WatchFolderOptions CreateOptions( + string watchPath, + string outputPath, + bool deleteSource = false + ) + { + return new WatchFolderOptions( + watchPath, + outputPath, + WatchFolderOutputFormat.PlainText, + deleteSource + ); + } + + private static WatchFolderTranscriptionResult CreateResult( + WatchFolderTranscriptionRequest request + ) + { + return new WatchFolderTranscriptionResult( + $"Transcribed {Path.GetFileName(request.FilePath)}", + "en", + 1, + 0.1, + [], + "fake", + "test" + ); + } } From f94ce66d41de3211753459c39a814075dcaf1d34 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 19 Jul 2026 11:44:29 +0000 Subject: [PATCH 116/226] =?UTF-8?q?Refuse=20to=20overwrite=20foreign=20CLI?= =?UTF-8?q?=20launchers=20via=20ownership=20classification=20(audit=20?= =?UTF-8?q?=C2=A76=20M14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ckbh5QuNxeMGGjE6qpVUK4 --- .../Services/CliInstallService.cs | 196 +++++++++++++-- .../CliInstallServiceTests.cs | 227 +++++++++++++++++- 2 files changed, 386 insertions(+), 37 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/CliInstallService.cs b/src/TypeWhisper.Linux/Services/CliInstallService.cs index cb0952e35..e8eb09e1d 100644 --- a/src/TypeWhisper.Linux/Services/CliInstallService.cs +++ b/src/TypeWhisper.Linux/Services/CliInstallService.cs @@ -16,6 +16,8 @@ string StatusText public sealed class CliInstallService { private const string CliFileName = "typewhisper"; + private const string LauncherShebang = "#!/usr/bin/env sh"; + private const string LauncherOwnershipMarker = "# Installed by TypeWhisper"; private readonly Func _bundledPathProvider; private readonly Func _installDirectoryProvider; private readonly Func _launcherDirectoryProvider; @@ -43,26 +45,14 @@ public CliInstallState GetState() var installPath = Path.Join(installDirectory, CliFileName); var launcherPath = Path.Join(launcherDirectory, CliFileName); var bundledPath = _bundledPathProvider(); - var installed = - FileExistsWithExactName(installPath) && FileExistsWithExactName(launcherPath); - var inPath = IsDirectoryInPath(launcherDirectory); - - var status = installed - ? inPath - ? $"Installed at {launcherPath}" - : $"Installed at {launcherPath}; add {launcherDirectory} to PATH or restart your shell" - : bundledPath is null - ? "CLI binary not found in this build" - : "Not installed"; + var launcherEntry = ClassifyLauncherEntry(launcherPath, installPath); - return new CliInstallState( - bundledPath is not null, - installed, + return CreateState( bundledPath, installPath, launcherPath, - inPath, - status + launcherDirectory, + launcherEntry ); } @@ -74,15 +64,27 @@ public CliInstallState Install() return state; } + var launcherDirectory = + Path.GetDirectoryName(state.LauncherPath) + ?? throw new InvalidOperationException("Missing CLI launcher directory."); + var launcherEntry = ClassifyLauncherEntry(state.LauncherPath, state.InstallPath); + if (launcherEntry == LauncherEntryClassification.Foreign) + { + return CreateState( + state.BundledPath, + state.InstallPath, + state.LauncherPath, + launcherDirectory, + launcherEntry + ); + } + var sourceDirectory = Path.GetDirectoryName(state.BundledPath) ?? throw new InvalidOperationException("Missing CLI bundle directory."); var installDirectory = Path.GetDirectoryName(state.InstallPath) ?? throw new InvalidOperationException("Missing CLI install directory."); - var launcherDirectory = - Path.GetDirectoryName(state.LauncherPath) - ?? throw new InvalidOperationException("Missing CLI launcher directory."); Directory.CreateDirectory(installDirectory); Directory.CreateDirectory(launcherDirectory); @@ -91,6 +93,18 @@ public CliInstallState Install() CopyCliPayload(sourceDirectory, installDirectory); MarkExecutable(state.InstallPath); + launcherEntry = ClassifyLauncherEntry(state.LauncherPath, state.InstallPath); + if (launcherEntry == LauncherEntryClassification.Foreign) + { + return CreateState( + state.BundledPath, + state.InstallPath, + state.LauncherPath, + launcherDirectory, + launcherEntry + ); + } + File.WriteAllText(state.LauncherPath, BuildLauncherScript(state.InstallPath)); MarkExecutable(state.LauncherPath); @@ -134,10 +148,17 @@ private static void CopyCliPayload(string sourceDirectory, string installDirecto private static string BuildLauncherScript(string installPath) { - return $""" - #!/usr/bin/env sh - exec "{installPath}" "$@" - """; + return $"{LauncherShebang}\n{LauncherOwnershipMarker}\n{BuildLauncherExecLine(installPath)}"; + } + + private static string BuildLegacyLauncherScript(string installPath) + { + return $"{LauncherShebang}\n{BuildLauncherExecLine(installPath)}"; + } + + private static string BuildLauncherExecLine(string installPath) + { + return $"exec \"{installPath}\" \"$@\""; } private static string DefaultInstallDirectory() @@ -197,6 +218,126 @@ private static bool IsCliAppHost(string path) return FileExistsWithExactName(path); } + private static CliInstallState CreateState( + string? bundledPath, + string installPath, + string launcherPath, + string launcherDirectory, + LauncherEntryClassification launcherEntry + ) + { + var launcherExists = launcherEntry != LauncherEntryClassification.Absent; + var launcherOwned = launcherEntry == LauncherEntryClassification.Owned; + var installed = launcherOwned && FileExistsWithExactName(installPath); + var inPath = IsDirectoryInPath(launcherDirectory); + + var status = launcherExists && !launcherOwned + ? $"Left {launcherPath} untouched — it is not managed by TypeWhisper and will not be overwritten." + : installed + ? inPath + ? $"Installed at {launcherPath}" + : $"Installed at {launcherPath}; add {launcherDirectory} to PATH or restart your shell" + : bundledPath is null + ? "CLI binary not found in this build" + : "Not installed"; + + return new CliInstallState( + bundledPath is not null, + installed, + bundledPath, + installPath, + launcherPath, + inPath, + status + ); + } + + private static LauncherEntryClassification ClassifyLauncherEntry( + string launcherPath, + string installPath + ) + { + try + { + var directory = Path.GetDirectoryName(launcherPath); + var fileName = Path.GetFileName(launcherPath); + if ( + string.IsNullOrWhiteSpace(directory) + || string.IsNullOrWhiteSpace(fileName) + || !Directory.Exists(directory) + ) + { + return LauncherEntryClassification.Absent; + } + + // Enumerate case-insensitively so a differently-cased alias is returned even + // when the launcher directory sits on a case-folded filesystem (the process + // default casing follows the temp/root filesystem, not this directory). We then + // pick the ordinal-exact entry ourselves; if only an aliasing variant exists we + // refuse to overwrite it even though it is not our exact name. + var candidates = Directory + .EnumerateFileSystemEntries( + directory, + fileName, + new EnumerationOptions + { + MatchCasing = MatchCasing.CaseInsensitive, + AttributesToSkip = 0 + } + ) + .ToArray(); + var entry = candidates.FirstOrDefault(candidate => + string.Equals(Path.GetFileName(candidate), fileName, StringComparison.Ordinal) + ); + if (entry is null) + { + return candidates.Length == 0 + ? LauncherEntryClassification.Absent + : LauncherEntryClassification.Foreign; + } + + var attributes = File.GetAttributes(entry); + if ( + (attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 + || new FileInfo(entry).LinkTarget is not null + ) + { + return LauncherEntryClassification.Foreign; + } + + var contents = File.ReadAllText(entry); + return HasMarkedOwnershipHeader(contents) || IsLegacyOwnedLauncher(contents, installPath) + ? LauncherEntryClassification.Owned + : LauncherEntryClassification.Foreign; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Refuse destructive changes when the entry cannot be inspected safely. + return LauncherEntryClassification.Foreign; + } + } + + private static bool HasMarkedOwnershipHeader(string contents) + { + using var reader = new StringReader(contents); + return string.Equals(reader.ReadLine(), LauncherShebang, StringComparison.Ordinal) + && string.Equals( + reader.ReadLine(), + LauncherOwnershipMarker, + StringComparison.Ordinal + ); + } + + private static bool IsLegacyOwnedLauncher(string contents, string installPath) + { + var expected = BuildLegacyLauncherScript(installPath); + var expectedWindows = expected.Replace("\n", "\r\n", StringComparison.Ordinal); + return string.Equals(contents, expected, StringComparison.Ordinal) + || string.Equals(contents, expected + "\n", StringComparison.Ordinal) + || string.Equals(contents, expectedWindows, StringComparison.Ordinal) + || string.Equals(contents, expectedWindows + "\r\n", StringComparison.Ordinal); + } + private static bool FileExistsWithExactName(string path) { var directory = Path.GetDirectoryName(path); @@ -270,4 +411,11 @@ private static void MarkExecutable(string path) Trace.WriteLine($"[CliInstallService] chmod failed for {path}: {ex.Message}"); } } -} \ No newline at end of file + + private enum LauncherEntryClassification + { + Absent, + Owned, + Foreign + } +} diff --git a/tests/TypeWhisper.Linux.Tests/CliInstallServiceTests.cs b/tests/TypeWhisper.Linux.Tests/CliInstallServiceTests.cs index 4e66f89b5..20bc7aa13 100644 --- a/tests/TypeWhisper.Linux.Tests/CliInstallServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/CliInstallServiceTests.cs @@ -1,4 +1,5 @@ using TypeWhisper.Linux.Services; +using TypeWhisper.Tests; using Xunit; namespace TypeWhisper.Linux.Tests; @@ -7,17 +8,18 @@ public sealed class CliInstallServiceTests : IDisposable { private readonly string? _originalPath = Environment.GetEnvironmentVariable("PATH"); - private readonly string _tempDir = Path.Join( - Path.GetTempPath(), - $"tw-cli-test-{Guid.NewGuid():N}" - ); + private readonly string _tempDir = TestPaths.CreateTempDirectory("tw-cli-test"); public void Dispose() { Environment.SetEnvironmentVariable("PATH", _originalPath); - if (Directory.Exists(_tempDir)) + try { - Directory.Delete(_tempDir, true); + TestPaths.DeleteDirectory(_tempDir); + } + catch + { + // Best-effort cleanup for temp test directories. } } @@ -43,10 +45,7 @@ public void Install_copies_payload_and_writes_launcher() var sourceDir = Path.Join(_tempDir, "bundle"); var installDir = Path.Join(_tempDir, "install"); var launcherDir = Path.Join(_tempDir, "bin"); - Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Join(sourceDir, "typewhisper"), "apphost"); - File.WriteAllText(Path.Join(sourceDir, "typewhisper.dll"), "dll"); - File.WriteAllText(Path.Join(sourceDir, "typewhisper.runtimeconfig.json"), "{}"); + WriteBundle(sourceDir, "v1"); Environment.SetEnvironmentVariable("PATH", launcherDir); var service = new CliInstallService( @@ -63,12 +62,180 @@ public void Install_copies_payload_and_writes_launcher() Assert.True(File.Exists(Path.Join(installDir, "typewhisper"))); Assert.True(File.Exists(Path.Join(installDir, "typewhisper.dll"))); Assert.True(File.Exists(Path.Join(installDir, "typewhisper.runtimeconfig.json"))); - Assert.Contains( - Path.Join(installDir, "typewhisper"), + Assert.Equal( + ExpectedLauncher(Path.Join(installDir, "typewhisper")), File.ReadAllText(Path.Join(launcherDir, "typewhisper")) ); } + [Fact] + public void Install_preserves_and_reports_foreign_launcher() + { + var sourceDir = Path.Join(_tempDir, "bundle"); + var installDir = Path.Join(_tempDir, "install"); + var launcherDir = Path.Join(_tempDir, "bin"); + var installPath = Path.Join(installDir, "typewhisper"); + var launcherPath = Path.Join(launcherDir, "typewhisper"); + WriteBundle(sourceDir, "new"); + Directory.CreateDirectory(installDir); + Directory.CreateDirectory(launcherDir); + File.WriteAllText(installPath, "old-apphost"); + File.WriteAllText(Path.Join(installDir, "typewhisper.dll"), "old-dll"); + const string foreignLauncher = + "#!/usr/bin/env sh\n# Installed by TypeWhisperer\nexec /other/tool \"$@\""; + File.WriteAllText(launcherPath, foreignLauncher); + + var service = CreateService(sourceDir, installDir, launcherDir); + + var beforeInstall = service.GetState(); + var state = service.Install(); + + Assert.False(beforeInstall.Installed); + Assert.False(state.Installed); + Assert.Equal(foreignLauncher, File.ReadAllText(launcherPath)); + Assert.Equal("old-apphost", File.ReadAllText(installPath)); + Assert.Equal("old-dll", File.ReadAllText(Path.Join(installDir, "typewhisper.dll"))); + Assert.False(File.Exists(Path.Join(installDir, "typewhisper.runtimeconfig.json"))); + Assert.Contains(launcherPath, state.StatusText, StringComparison.Ordinal); + Assert.Contains("not managed", state.StatusText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("untouched", state.StatusText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Install_updates_owned_launcher() + { + var sourceDir = Path.Join(_tempDir, "bundle"); + var installDir = Path.Join(_tempDir, "install"); + var launcherDir = Path.Join(_tempDir, "bin"); + var installPath = Path.Join(installDir, "typewhisper"); + var launcherPath = Path.Join(launcherDir, "typewhisper"); + WriteBundle(sourceDir, "v1"); + var service = CreateService(sourceDir, installDir, launcherDir); + service.Install(); + WriteBundle(sourceDir, "v2"); + + var state = service.Install(); + + Assert.True(state.Installed); + Assert.Equal("apphost-v2", File.ReadAllText(installPath)); + Assert.Equal("dll-v2", File.ReadAllText(Path.Join(installDir, "typewhisper.dll"))); + Assert.Equal(ExpectedLauncher(installPath), File.ReadAllText(launcherPath)); + } + + [Fact] + public void Install_updates_and_marks_legacy_owned_launcher() + { + var sourceDir = Path.Join(_tempDir, "bundle"); + var installDir = Path.Join(_tempDir, "install"); + var launcherDir = Path.Join(_tempDir, "bin"); + var installPath = Path.Join(installDir, "typewhisper"); + var launcherPath = Path.Join(launcherDir, "typewhisper"); + WriteBundle(sourceDir, "v2"); + Directory.CreateDirectory(launcherDir); + File.WriteAllText(launcherPath, ExpectedLegacyLauncher(installPath)); + var service = CreateService(sourceDir, installDir, launcherDir); + + var state = service.Install(); + + Assert.True(state.Installed); + Assert.Equal("apphost-v2", File.ReadAllText(installPath)); + Assert.Equal(ExpectedLauncher(installPath), File.ReadAllText(launcherPath)); + } + + [Fact] + public void Install_preserves_legacy_lookalike_with_different_exec_target() + { + var sourceDir = Path.Join(_tempDir, "bundle"); + var installDir = Path.Join(_tempDir, "install"); + var launcherDir = Path.Join(_tempDir, "bin"); + var launcherPath = Path.Join(launcherDir, "typewhisper"); + WriteBundle(sourceDir, "v1"); + Directory.CreateDirectory(launcherDir); + var foreignLauncher = ExpectedLegacyLauncher(Path.Join(_tempDir, "other", "typewhisper")); + File.WriteAllText(launcherPath, foreignLauncher); + var service = CreateService(sourceDir, installDir, launcherDir); + + var state = service.Install(); + + Assert.False(state.Installed); + Assert.Equal(foreignLauncher, File.ReadAllText(launcherPath)); + Assert.False(Directory.Exists(installDir)); + Assert.Contains("not managed", state.StatusText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Install_preserves_symlink_launcher_and_target() + { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + return; + } + + var sourceDir = Path.Join(_tempDir, "bundle"); + var installDir = Path.Join(_tempDir, "install"); + var launcherDir = Path.Join(_tempDir, "bin"); + var launcherPath = Path.Join(launcherDir, "typewhisper"); + var linkTarget = Path.Join(_tempDir, "package-typewhisper"); + WriteBundle(sourceDir, "v1"); + Directory.CreateDirectory(launcherDir); + File.WriteAllText(linkTarget, ExpectedLauncher(Path.Join(installDir, "typewhisper"))); + File.CreateSymbolicLink(launcherPath, linkTarget); + var service = CreateService(sourceDir, installDir, launcherDir); + + var state = service.Install(); + + Assert.False(state.Installed); + Assert.Equal(linkTarget, new FileInfo(launcherPath).LinkTarget); + Assert.Equal( + ExpectedLauncher(Path.Join(installDir, "typewhisper")), + File.ReadAllText(linkTarget) + ); + Assert.False(Directory.Exists(installDir)); + Assert.Contains("untouched", state.StatusText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Install_preserves_differently_cased_foreign_launcher() + { + var sourceDir = Path.Join(_tempDir, "bundle"); + var installDir = Path.Join(_tempDir, "install"); + var launcherDir = Path.Join(_tempDir, "bin"); + WriteBundle(sourceDir, "v1"); + Directory.CreateDirectory(launcherDir); + var aliasPath = Path.Join(launcherDir, "TypeWhisper"); + const string foreignLauncher = "#!/usr/bin/env sh\nexec /other/tool \"$@\""; + File.WriteAllText(aliasPath, foreignLauncher); + var service = CreateService(sourceDir, installDir, launcherDir); + + var state = service.Install(); + + Assert.False(state.Installed); + Assert.Equal(foreignLauncher, File.ReadAllText(aliasPath)); + Assert.False(Directory.Exists(installDir)); + Assert.Contains("not managed", state.StatusText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Install_preserves_directory_at_launcher_path() + { + var sourceDir = Path.Join(_tempDir, "bundle"); + var installDir = Path.Join(_tempDir, "install"); + var launcherDir = Path.Join(_tempDir, "bin"); + var launcherPath = Path.Join(launcherDir, "typewhisper"); + WriteBundle(sourceDir, "v1"); + Directory.CreateDirectory(launcherPath); + File.WriteAllText(Path.Join(launcherPath, "keep"), "content"); + var service = CreateService(sourceDir, installDir, launcherDir); + + var state = service.Install(); + + Assert.False(state.Installed); + Assert.True(Directory.Exists(launcherPath)); + Assert.Equal("content", File.ReadAllText(Path.Join(launcherPath, "keep"))); + Assert.False(Directory.Exists(installDir)); + Assert.Contains("not managed", state.StatusText, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void Examples_include_linux_bearer_token_setup() { @@ -88,4 +255,38 @@ public void Examples_include_linux_bearer_token_setup() ) ); } -} \ No newline at end of file + + private static CliInstallService CreateService( + string sourceDir, + string installDir, + string launcherDir + ) + { + return new CliInstallService( + () => Path.Join(sourceDir, "typewhisper"), + () => installDir, + () => launcherDir + ); + } + + private static void WriteBundle(string sourceDir, string version) + { + Directory.CreateDirectory(sourceDir); + File.WriteAllText(Path.Join(sourceDir, "typewhisper"), $"apphost-{version}"); + File.WriteAllText(Path.Join(sourceDir, "typewhisper.dll"), $"dll-{version}"); + File.WriteAllText( + Path.Join(sourceDir, "typewhisper.runtimeconfig.json"), + $"{{\"version\":\"{version}\"}}" + ); + } + + private static string ExpectedLauncher(string installPath) + { + return $"#!/usr/bin/env sh\n# Installed by TypeWhisper\nexec \"{installPath}\" \"$@\""; + } + + private static string ExpectedLegacyLauncher(string installPath) + { + return $"#!/usr/bin/env sh\nexec \"{installPath}\" \"$@\""; + } +} From 0eef077b6da5363f01e63c1de69124d27f8ca823 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 19 Jul 2026 11:48:11 +0000 Subject: [PATCH 117/226] =?UTF-8?q?Gate=20all=20control-socket=20unlinks?= =?UTF-8?q?=20behind=20flock=20ownership=20of=20a=20persistent=20lock=20fi?= =?UTF-8?q?le=20(audit=20=C2=A76=20M7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ckbh5QuNxeMGGjE6qpVUK4 --- .../Services/Ipc/ControlSocketClient.cs | 35 +- .../Services/Ipc/ControlSocketOwnership.cs | 266 +++++++++++++ .../Services/Ipc/ControlSocketServer.cs | 371 +++++++++++------- .../ControlSocketOwnershipTests.cs | 296 ++++++++++++++ 4 files changed, 807 insertions(+), 161 deletions(-) create mode 100644 src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs create mode 100644 tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketClient.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketClient.cs index 003f94ebb..81198996d 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketClient.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketClient.cs @@ -40,14 +40,9 @@ public static bool IsLivePeer(string path) } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionRefused) { - try - { - File.Delete(path); - } - catch - { - /* best-effort */ - } + // ECONNREFUSED alone isn't proof of staleness — the peer may have bound but not + // yet started listening. Re-probe under the ownership lock before unlinking. + ControlSocketOwnership.TryCleanupStaleSocket(path); return false; } @@ -137,15 +132,8 @@ public static bool TrySendToggle(string path, out string? error) } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionRefused) { - // Stale socket — remove so the new instance can bind without EADDRINUSE. - try - { - File.Delete(path); - } - catch - { - /* best-effort */ - } + // ECONNREFUSED alone isn't proof of staleness; re-probe under the ownership lock before unlinking. + ControlSocketOwnership.TryCleanupStaleSocket(path); return false; } @@ -249,15 +237,8 @@ out string? error } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionRefused) { - // Stale socket — clean up so a follow-up GUI launch can bind cleanly. - try - { - File.Delete(path); - } - catch - { - /* best-effort */ - } + // ECONNREFUSED alone isn't proof of staleness; re-probe under the ownership lock before unlinking. + ControlSocketOwnership.TryCleanupStaleSocket(path); return false; } @@ -267,4 +248,4 @@ out string? error return false; } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs new file mode 100644 index 000000000..ca837d936 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs @@ -0,0 +1,266 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace TypeWhisper.Linux.Services.Ipc; + +internal enum ControlSocketCleanupResult +{ + Missing, + Removed, + Live, + Indeterminate, + OwnershipContended +} + +/// +/// Owns the stable advisory lock that serializes every control-socket bind and unlink. +/// The lockfile is persistent; closing its file descriptor releases ownership without +/// replacing the inode that all contenders lock. +/// +internal sealed partial class ControlSocketOwnership : IDisposable +{ + private const int LockExclusive = 2; + private const int LockNonBlocking = 4; + private const int ErrorInterrupted = 4; + private const int ErrorTryAgain = 11; + private const int OpenReadWrite = 2; + private const int OpenCreate = 0x40; + private const int OpenNoFollow = 0x20000; + private const int OpenCloseOnExec = 0x80000; + private const uint OwnerReadWriteMode = 0b110_000_000; // 0600 + private static readonly TimeSpan s_probeTimeout = TimeSpan.FromSeconds(2); + + private readonly SafeFileHandle _lockHandle; + private int _disposed; + + private ControlSocketOwnership( + string socketPath, + string lockPath, + SafeFileHandle lockHandle + ) + { + SocketPath = socketPath; + LockPath = lockPath; + _lockHandle = lockHandle; + } + + private string SocketPath { get; } + + internal string LockPath { get; } + + /// + /// Opens the persistent lockfile and attempts an exclusive lock without blocking. + /// Returns false only for ordinary lock contention; other failures are reported. + /// + internal static bool TryAcquire( + string socketPath, + [NotNullWhen(true)] out ControlSocketOwnership? ownership + ) + { + ownership = null; + var lockPath = Path.Join(Path.GetDirectoryName(socketPath)!, "control.lock"); + SafeFileHandle? handle = OpenLockFile(lockPath); + try + { + SetOwnerOnlyMode(handle, lockPath); + while (flock(handle, LockExclusive | LockNonBlocking) != 0) + { + var error = Marshal.GetLastPInvokeError(); + if (error == ErrorInterrupted) + { + continue; + } + + if (error == ErrorTryAgain) + { + return false; + } + + throw new IOException( + $"Could not acquire control socket ownership lock {lockPath}.", + new Win32Exception(error) + ); + } + + ownership = new ControlSocketOwnership(socketPath, lockPath, handle); + handle = null; + return true; + } + finally + { + handle?.Dispose(); + } + } + + /// + /// Best-effort client cleanup. Contention or any acquisition/probe/delete failure + /// leaves the socket pathname untouched. + /// + internal static ControlSocketCleanupResult TryCleanupStaleSocket(string socketPath) + { + try + { + if (!TryAcquire(socketPath, out var ownership)) + { + return ControlSocketCleanupResult.OwnershipContended; + } + + using (ownership) + { + return ownership.CleanupStaleSocket(); + } + } + catch (Exception ex) + { + Trace.WriteLine( + $"[ControlSocketOwnership] Could not acquire cleanup ownership for {socketPath}: {ex.Message}" + ); + return ControlSocketCleanupResult.Indeterminate; + } + } + + /// + /// Re-probes and, only on ECONNREFUSED, unlinks a stale socket while ownership is held. + /// + internal ControlSocketCleanupResult CleanupStaleSocket() + { + ObjectDisposedException.ThrowIf(_disposed != 0, this); + + if (!File.Exists(SocketPath)) + { + return ControlSocketCleanupResult.Missing; + } + + try + { + using var probe = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified + ); + using var timeout = new CancellationTokenSource(s_probeTimeout); + probe + .ConnectAsync(new UnixDomainSocketEndPoint(SocketPath), timeout.Token) + .AsTask() + .GetAwaiter() + .GetResult(); + return ControlSocketCleanupResult.Live; + } + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionRefused) + { + if (!File.Exists(SocketPath)) + { + return ControlSocketCleanupResult.Missing; + } + + try + { + File.Delete(SocketPath); + if (!File.Exists(SocketPath)) + { + Trace.WriteLine( + $"[ControlSocketOwnership] Removed stale socket at {SocketPath}." + ); + return ControlSocketCleanupResult.Removed; + } + } + catch (Exception deleteException) + { + Trace.WriteLine( + $"[ControlSocketOwnership] Failed to remove stale socket {SocketPath}: {deleteException.Message}" + ); + return ControlSocketCleanupResult.Indeterminate; + } + + Trace.WriteLine( + $"[ControlSocketOwnership] Stale socket {SocketPath} remained after deletion." + ); + return ControlSocketCleanupResult.Indeterminate; + } + catch (Exception ex) + { + if (!File.Exists(SocketPath)) + { + return ControlSocketCleanupResult.Missing; + } + + Trace.WriteLine( + $"[ControlSocketOwnership] Probe of {SocketPath} was indeterminate: {ex.Message}" + ); + return ControlSocketCleanupResult.Indeterminate; + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + { + return; + } + + // Closing releases flock ownership. Never unlink the stable lockfile. + _lockHandle.Dispose(); + } + + private static SafeFileHandle OpenLockFile(string lockPath) + { + while (true) + { + var fd = open( + lockPath, + OpenReadWrite | OpenCreate | OpenNoFollow | OpenCloseOnExec, + OwnerReadWriteMode + ); + if (fd >= 0) + { + // Native open has no managed sharing policy, so every contender reaches + // the explicit nonblocking flock below. + return new SafeFileHandle(fd, ownsHandle: true); + } + + var error = Marshal.GetLastPInvokeError(); + if (error == ErrorInterrupted) + { + continue; + } + + throw new IOException( + $"Could not open control socket ownership lock {lockPath}.", + new Win32Exception(error) + ); + } + } + + private static void SetOwnerOnlyMode(SafeFileHandle handle, string lockPath) + { + while (fchmod(handle, OwnerReadWriteMode) != 0) + { + var error = Marshal.GetLastPInvokeError(); + if (error == ErrorInterrupted) + { + continue; + } + + throw new IOException( + $"Could not secure control socket ownership lock {lockPath} with mode 0600.", + new Win32Exception(error) + ); + } + } + + // ReSharper disable once InconsistentNaming -- native libc function name; LibraryImport EntryPoint defaults to the method name. + [LibraryImport("libc", SetLastError = true)] + private static partial int flock(SafeFileHandle fd, int operation); + + // ReSharper disable once InconsistentNaming -- native libc function name; LibraryImport EntryPoint defaults to the method name. + [LibraryImport("libc", SetLastError = true)] + private static partial int fchmod(SafeFileHandle fd, uint mode); + + // ReSharper disable once InconsistentNaming -- native libc function name; LibraryImport EntryPoint defaults to the method name. + [LibraryImport("libc", SetLastError = true, StringMarshalling = StringMarshalling.Utf8)] + private static partial int open(string pathname, int flags, uint mode); +} diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs index c86ab4ea0..63e7de2a9 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs @@ -43,14 +43,15 @@ internal sealed class ControlSocketServer : IDisposable private readonly DictationOrchestrator _orchestrator; private readonly ISettingsService? _settings; private readonly ControlSocketStartCoordinator _startCoordinator; + private readonly Lock _lifecycleGate = new(); private Task? _acceptLoop; - // True after a successful bind — lets Dispose distinguish "we own this path" from - // "we never bound", while the live-probe guard still covers a successor stealing the path. + // True only after the complete bind/listen startup has been published. private bool _bound; private CancellationTokenSource? _cts; private int _disposed; private Socket? _listener; + private ControlSocketOwnership? _ownership; // ReSharper disable once IntroduceOptionalParameters.Global -- kept as explicit overloads; collapsing into optional parameters would delete a member. public ControlSocketServer(DictationOrchestrator orchestrator) @@ -87,20 +88,232 @@ public void Dispose() return; } - // Order: cancel → close listener (unblocks in-flight AcceptAsync) → await loop → unlink. - // Reversing close/wait risks an indefinite accept block; reversing wait/unlink risks - // deleting the file while the loop still holds it. + lock (_lifecycleGate) + { + var listener = _listener; + var cts = _cts; + var acceptLoop = _acceptLoop; + var ownership = _ownership; + + _listener = null; + _cts = null; + _acceptLoop = null; + _ownership = null; + + // Order: cancel → close → await loop → unlink → release, all under _lifecycleGate. + // Reversing close/wait risks an indefinite accept block; unlinking before the loop + // drains risks deleting the file while a handler still holds it. + try + { + cts?.Cancel(); + } + catch + { + /* ignored */ + } + + try + { + listener?.Close(); + } + catch + { + /* ignored */ + } + + try + { + listener?.Dispose(); + } + catch + { + /* ignored */ + } + + try + { + acceptLoop?.Wait(TimeSpan.FromMilliseconds(500)); + } + catch (Exception ex) + { + Trace.WriteLine($"[ControlSocketServer] Accept loop wait threw: {ex.Message}"); + } + + try + { + cts?.Dispose(); + } + catch + { + /* ignored */ + } + + try + { + if (_bound && ownership is not null) + { + var cleanup = ownership.CleanupStaleSocket(); + if (cleanup is ControlSocketCleanupResult.Live) + { + Trace.WriteLine( + $"[ControlSocketServer] Socket path {SocketPath} is held by another listener; leaving it in place." + ); + } + } + } + catch (Exception ex) + { + Trace.WriteLine( + $"[ControlSocketServer] Could not remove socket file on dispose: {ex.Message}" + ); + } + finally + { + _bound = false; + try + { + ownership?.Dispose(); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[ControlSocketServer] Could not release socket ownership: {ex.Message}" + ); + } + } + } + } + + /// + /// Binds the socket and starts the accept loop. Throws + /// with + /// when another live + /// instance owns the path, and — failing closed — whenever ownership + /// cannot be established (lock contention or an indeterminate probe); + /// callers should treat that as the single-instance signal and exit. + /// + public void Start() + { + lock (_lifecycleGate) + { + ObjectDisposedException.ThrowIf(_disposed != 0, this); + + if (_listener is not null) + { + return; + } + + ControlSocketOwnership ownership; + try + { + if (!ControlSocketOwnership.TryAcquire(SocketPath, out var acquiredOwnership)) + { + throw AddressAlreadyInUse(); + } + + ownership = acquiredOwnership; + } + catch (SocketException ex) + when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) + { + throw; + } + catch (Exception ex) + { + // Lock uncertainty must fail closed; App already treats this socket error as + // the authoritative single-instance signal. + Trace.WriteLine( + $"[ControlSocketServer] Could not acquire socket ownership: {ex.Message}" + ); + throw AddressAlreadyInUse(); + } + + Socket? listener = null; + CancellationTokenSource? cts = null; + Task? acceptLoop = null; + var boundThisAttempt = false; + try + { + var cleanup = ownership.CleanupStaleSocket(); + if ( + cleanup + is not ( + ControlSocketCleanupResult.Missing + or ControlSocketCleanupResult.Removed + ) + ) + { + throw AddressAlreadyInUse(); + } + + listener = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified + ); + listener.Bind(new UnixDomainSocketEndPoint(SocketPath)); + boundThisAttempt = true; + + // 0600: owner-only read/write. Defense in depth on shared /tmp; on + // XDG_RUNTIME_DIR the parent dir is already 0700. + SocketPathResolver.TryChmod(SocketPath, 0b110_000_000); // 0600 + listener.Listen(8); + + cts = new CancellationTokenSource(); + var token = cts.Token; + acceptLoop = Task.Run(() => AcceptLoopAsync(listener, token)); + + // Publish only after bind, chmod, listen, and accept-loop creation succeed. + _ownership = ownership; + _listener = listener; + _cts = cts; + _acceptLoop = acceptLoop; + _bound = true; + + Trace.WriteLine($"[ControlSocketServer] Listening on {SocketPath}"); + } + catch + { + CleanupFailedStart( + ownership, + listener, + cts, + acceptLoop, + boundThisAttempt + ); + throw; + } + } + } + + private static SocketException AddressAlreadyInUse() + { + return new SocketException((int)SocketError.AddressAlreadyInUse); + } + + private void CleanupFailedStart( + ControlSocketOwnership ownership, + Socket? listener, + CancellationTokenSource? cts, + Task? acceptLoop, + bool boundThisAttempt + ) + { + _listener = null; + _cts = null; + _acceptLoop = null; + _ownership = null; + _bound = false; + try { - _cts?.Cancel(); + cts?.Cancel(); } catch { /* ignored */ } - var listener = _listener; - _listener = null; try { listener?.Close(); @@ -121,95 +334,52 @@ public void Dispose() try { - _acceptLoop?.Wait(TimeSpan.FromMilliseconds(500)); + acceptLoop?.Wait(TimeSpan.FromMilliseconds(500)); } catch (Exception ex) { - Trace.WriteLine($"[ControlSocketServer] Accept loop wait threw: {ex.Message}"); + Trace.WriteLine( + $"[ControlSocketServer] Failed-start accept loop wait threw: {ex.Message}" + ); } try { - _cts?.Dispose(); + cts?.Dispose(); } catch { /* ignored */ } - // Unlink only if we own the path AND no live peer is listening — a successor instance - // may have already taken it over before our Dispose reaches this point. - try + if (boundThisAttempt) { - if (!_bound || !File.Exists(SocketPath)) - { - return; - } - - if (NoLivePeer(SocketPath)) + try { - File.Delete(SocketPath); + ownership.CleanupStaleSocket(); } - else + catch (Exception ex) { Trace.WriteLine( - $"[ControlSocketServer] Socket path {SocketPath} is held by another listener; leaving it in place." + $"[ControlSocketServer] Failed-start socket cleanup threw: {ex.Message}" ); } } - catch (Exception ex) - { - Trace.WriteLine( - $"[ControlSocketServer] Could not remove socket file on dispose: {ex.Message}" - ); - } - } - - /// - /// Binds the socket and starts the accept loop. Throws - /// with - /// when another live - /// instance owns the path; callers should treat that as the - /// single-instance signal and exit. - /// - public void Start() - { - ObjectDisposedException.ThrowIf(_disposed != 0, this); - - if (_listener is not null) - { - return; - } - TryRemoveStaleSocket(SocketPath); - - var listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); try { - listener.Bind(new UnixDomainSocketEndPoint(SocketPath)); + ownership.Dispose(); } - catch + catch (Exception ex) { - listener.Dispose(); - throw; + Trace.WriteLine( + $"[ControlSocketServer] Failed-start ownership release threw: {ex.Message}" + ); } - - // 0600: owner-only read/write. Defense in depth on shared /tmp; on - // XDG_RUNTIME_DIR the parent dir is already 0700. - SocketPathResolver.TryChmod(SocketPath, 0b110_000_000); // 0600 - _bound = true; - listener.Listen(8); - - _listener = listener; - _cts = new CancellationTokenSource(); - _acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token)); - - Trace.WriteLine($"[ControlSocketServer] Listening on {SocketPath}"); } - private async Task AcceptLoopAsync(CancellationToken ct) + private async Task AcceptLoopAsync(Socket listener, CancellationToken ct) { - var listener = _listener!; while (!ct.IsCancellationRequested) { Socket client; @@ -527,73 +697,6 @@ private string SnapshotState() return _startCoordinator.SnapshotState(); } - /// - /// If the socket path exists but no live peer is listening, deletes it. - /// Never deletes a path that has a live peer — that would silently - /// detach another running instance. - /// - private static void TryRemoveStaleSocket(string path) - { - if (!File.Exists(path)) - { - return; - } - - try - { - using var probe = new Socket( - AddressFamily.Unix, - SocketType.Stream, - ProtocolType.Unspecified - ); - probe.Connect(new UnixDomainSocketEndPoint(path)); - // A live peer accepted us; do NOT delete. - } - catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionRefused) - { - try - { - File.Delete(path); - Trace.WriteLine($"[ControlSocketServer] Removed stale socket at {path}."); - } - catch (Exception delEx) - { - Trace.WriteLine( - $"[ControlSocketServer] Failed to remove stale socket {path}: {delEx.Message}" - ); - } - } - catch (Exception ex) - { - Trace.WriteLine($"[ControlSocketServer] Probe of {path} threw: {ex.Message}"); - } - } - - /// True when ECONNREFUSED — no live listener, safe to unlink. False on any other outcome. - private static bool NoLivePeer(string path) - { - try - { - using var probe = new Socket( - AddressFamily.Unix, - SocketType.Stream, - ProtocolType.Unspecified - ); - probe.Connect(new UnixDomainSocketEndPoint(path)); - return false; - } - catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionRefused) - { - return true; - } - catch - { - // Any other error (e.g. permission denied) — refuse to delete - // out of paranoia; leaking a socket file is fine, deleting - // someone else's is not. - return false; - } - } } /// diff --git a/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs b/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs new file mode 100644 index 000000000..f0cfe7841 --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs @@ -0,0 +1,296 @@ +using System.Net.Sockets; +using System.Runtime.InteropServices; +using TypeWhisper.Linux.Services.Ipc; +using TypeWhisper.Tests; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class ControlSocketOwnershipTests +{ + private const UnixFileMode LockFileMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite; + private static readonly TimeSpan s_guard = TimeSpan.FromSeconds(2); + + [Fact] + public void OwnershipLock_IsExclusivePersistentAndReusable() + { + var tempDirectory = TestPaths.CreateTempDirectory("ipc-m7"); + var socketPath = Path.Join(tempDirectory, "control.sock"); + var lockPath = Path.Join(tempDirectory, "control.lock"); + ControlSocketOwnership? ownerA = null; + ControlSocketOwnership? ownerC = null; + + try + { + Assert.True(ControlSocketOwnership.TryAcquire(socketPath, out ownerA)); + Assert.Equal(lockPath, ownerA.LockPath); + Assert.True(File.Exists(lockPath)); + + Assert.False(ControlSocketOwnership.TryAcquire(socketPath, out var contender)); + Assert.Null(contender); + Assert.True(File.Exists(lockPath)); + + ownerA.Dispose(); + ownerA.Dispose(); + ownerA = null; + + Assert.True(File.Exists(lockPath)); + Assert.True(ControlSocketOwnership.TryAcquire(socketPath, out ownerC)); + Assert.True(File.Exists(lockPath)); +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + Assert.Equal(LockFileMode, File.GetUnixFileMode(lockPath)); +#pragma warning restore CA1416 + } + finally + { + ownerA?.Dispose(); + ownerC?.Dispose(); + TestPaths.DeleteDirectory(tempDirectory); + } + } + + [Fact] + public async Task RefusedClientCleanup_CannotUnlinkBoundOwnerBeforeListen() + { + var tempDirectory = TestPaths.CreateTempDirectory("ipc-m7"); + var socketPath = Path.Join(tempDirectory, "control.sock"); + var bindComplete = NewGate(); + var allowListen = NewGate(); + var listenComplete = NewGate(); + var connectionAccepted = NewGate(); + + var ownerTask = Task.Run(async () => + { + ControlSocketOwnership? ownership = null; + Socket? listener = null; + try + { + Assert.True( + ControlSocketOwnership.TryAcquire(socketPath, out ownership) + ); + listener = CreateSocket(); + listener.Bind(new UnixDomainSocketEndPoint(socketPath)); + bindComplete.TrySetResult(); + + await allowListen.Task.WaitAsync(s_guard); + listener.Listen(8); + listenComplete.TrySetResult(); + + using var accepted = await listener.AcceptAsync().WaitAsync(s_guard); + connectionAccepted.TrySetResult(); + } + catch (Exception ex) + { + bindComplete.TrySetException(ex); + listenComplete.TrySetException(ex); + connectionAccepted.TrySetException(ex); + throw; + } + finally + { + listener?.Dispose(); + ownership?.Dispose(); + } + }); + + try + { + await bindComplete.Task.WaitAsync(s_guard); + Assert.True(File.Exists(socketPath)); + + Assert.False(ControlSocketClient.IsLivePeer(socketPath)); + Assert.True(File.Exists(socketPath)); + + Assert.False(ControlSocketClient.TrySendToggle(socketPath, out var toggleError)); + Assert.Null(toggleError); + Assert.True(File.Exists(socketPath)); + + var request = new JsonControlProtocol.Request + { + Version = JsonControlProtocol.CurrentVersion, + Command = JsonControlProtocol.CmdStatus + }; + Assert.False( + ControlSocketClient.TrySendJson( + socketPath, + request, + out var responseJson, + out var jsonError + ) + ); + Assert.Empty(responseJson); + Assert.Null(jsonError); + Assert.True(File.Exists(socketPath)); + + allowListen.TrySetResult(); + await listenComplete.Task.WaitAsync(s_guard); + + using var client = CreateSocket(); + await client + .ConnectAsync(new UnixDomainSocketEndPoint(socketPath)) + .WaitAsync(s_guard); + await connectionAccepted.Task.WaitAsync(s_guard); + } + finally + { + allowListen.TrySetResult(); + try + { + await ownerTask.WaitAsync(s_guard); + } + finally + { + TestPaths.DeleteDirectory(tempDirectory); + } + } + } + + [Fact] + public async Task StaleSocket_IsCleanedAndReboundWhileOwnershipIsHeld() + { + var tempDirectory = TestPaths.CreateTempDirectory("ipc-m7"); + var socketPath = Path.Join(tempDirectory, "control.sock"); + var boundPath = Path.Join(tempDirectory, "stale-source.sock"); + ControlSocketOwnership? ownership = null; + Socket? listener = null; + Socket? client = null; + + try + { + using (var stale = CreateSocket()) + { + stale.Bind(new UnixDomainSocketEndPoint(boundPath)); + + // SafeSocketHandle unlinks its original bound pathname on orderly disposal. + // A second hard link preserves the same socket inode to model the pathname + // left behind by a process that exits without managed cleanup. + var result = link(boundPath, socketPath); + Assert.True( + result == 0, + $"Could not preserve stale socket inode (errno {Marshal.GetLastPInvokeError()})." + ); + } + + Assert.True(File.Exists(socketPath)); + Assert.True(ControlSocketOwnership.TryAcquire(socketPath, out ownership)); + Assert.Equal( + ControlSocketCleanupResult.Removed, + ownership.CleanupStaleSocket() + ); + Assert.False(File.Exists(socketPath)); + + listener = CreateSocket(); + listener.Bind(new UnixDomainSocketEndPoint(socketPath)); + listener.Listen(8); + + var acceptTask = listener.AcceptAsync(); + client = CreateSocket(); + await client + .ConnectAsync(new UnixDomainSocketEndPoint(socketPath)) + .WaitAsync(s_guard); + using var accepted = await acceptTask.WaitAsync(s_guard); + Assert.True(client.Connected); + } + finally + { + client?.Dispose(); + listener?.Dispose(); + ownership?.Dispose(); + TestPaths.DeleteDirectory(tempDirectory); + } + } + + [Fact] + public async Task FreshProbe_LeavesLiveListenerPathIntact() + { + var tempDirectory = TestPaths.CreateTempDirectory("ipc-m7"); + var socketPath = Path.Join(tempDirectory, "control.sock"); + Socket? listener = null; + + try + { + listener = CreateSocket(); + listener.Bind(new UnixDomainSocketEndPoint(socketPath)); + listener.Listen(8); + + var probeAcceptTask = listener.AcceptAsync(); + Assert.Equal( + ControlSocketCleanupResult.Live, + ControlSocketOwnership.TryCleanupStaleSocket(socketPath) + ); + using (await probeAcceptTask.WaitAsync(s_guard)) + { + Assert.True(File.Exists(socketPath)); + } + + var secondAcceptTask = listener.AcceptAsync(); + using var client = CreateSocket(); + await client + .ConnectAsync(new UnixDomainSocketEndPoint(socketPath)) + .WaitAsync(s_guard); + using var secondConnection = await secondAcceptTask.WaitAsync(s_guard); + Assert.True(File.Exists(socketPath)); + } + finally + { + listener?.Dispose(); + TestPaths.DeleteDirectory(tempDirectory); + } + } + + [Fact] + public void IndeterminateProbe_LeavesSocketPathIntact() + { + var tempDirectory = TestPaths.CreateTempDirectory("ipc-m7"); + var socketPath = Path.Join(tempDirectory, "control.sock"); + ControlSocketOwnership? ownership = null; + Socket? listener = null; + + try + { + listener = CreateSocket(); + listener.Bind(new UnixDomainSocketEndPoint(socketPath)); + listener.Listen(8); +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + File.SetUnixFileMode(socketPath, UnixFileMode.None); +#pragma warning restore CA1416 + + Assert.True(ControlSocketOwnership.TryAcquire(socketPath, out ownership)); + Assert.Equal( + ControlSocketCleanupResult.Indeterminate, + ownership.CleanupStaleSocket() + ); + Assert.True(File.Exists(socketPath)); + } + finally + { + if (File.Exists(socketPath)) + { +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + File.SetUnixFileMode(socketPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); +#pragma warning restore CA1416 + } + + listener?.Dispose(); + ownership?.Dispose(); + TestPaths.DeleteDirectory(tempDirectory); + } + } + + private static Socket CreateSocket() + { + return new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + } + + private static TaskCompletionSource NewGate() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + // Test-only: simulates the pathname a crashed process leaves behind. DllImport + // (not LibraryImport) avoids requiring this test project to allow unsafe code. + // ReSharper disable once InconsistentNaming -- mirrors the native libc function. + [DllImport("libc", SetLastError = true, CharSet = CharSet.Ansi)] + private static extern int link(string oldpath, string newpath); +} From b7391b18543ef7e0e24ff6ffd9324f2cba42355c Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 19 Jul 2026 12:17:19 +0000 Subject: [PATCH 118/226] =?UTF-8?q?Require=20execute=20permission=20and=20?= =?UTF-8?q?socket=20liveness=20in=20capability=20discovery=20(audit=20?= =?UTF-8?q?=C2=A76=20M13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Ckbh5QuNxeMGGjE6qpVUK4 --- .../SystemCommandAvailabilityService.cs | 39 ++++- .../SystemCommandAvailabilityServiceTests.cs | 150 ++++++++++++++++++ 2 files changed, 181 insertions(+), 8 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs index a723fda8f..5494694cc 100644 --- a/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs +++ b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Net.Sockets; using System.Runtime.InteropServices; using TypeWhisper.Linux.Services.Hotkey.DeSetup; @@ -8,6 +9,10 @@ public sealed partial class SystemCommandAvailabilityService { private const int RtldNow = 2; private const int RtldGlobal = 0x100; + private const UnixFileMode ExecutableModeMask = + UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; + private static readonly TimeSpan s_ydotoolSocketConnectTimeout = + TimeSpan.FromMilliseconds(250); private static readonly string[] s_cudaLibraryPathCandidates = [ @@ -436,7 +441,12 @@ var directory in pathValue.Split( try { var candidate = Path.Join(directory, commandName); - if (File.Exists(candidate)) +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + if ( + File.Exists(candidate) + && (File.GetUnixFileMode(candidate) & ExecutableModeMask) != 0 + ) +#pragma warning restore CA1416 { return true; } @@ -469,8 +479,7 @@ internal void RaiseSnapshotChangedForTests(LinuxCapabilitySnapshot snapshot) /// /// Finds the ydotool socket path using the standard priority list. - /// Returns null if no candidate exists. Permissions are not stat-checked — - /// we only need to know whether a candidate is reachable. + /// Returns null if no candidate accepts a bounded datagram connection. /// internal static string? ResolveYdotoolSocketPath() { @@ -490,6 +499,11 @@ internal void RaiseSnapshotChangedForTests(LinuxCapabilitySnapshot snapshot) candidates.Add($"/run/user/{uid}/.ydotool_socket"); } + return ResolveYdotoolSocketPath(candidates); + } + + internal static string? ResolveYdotoolSocketPath(IEnumerable candidates) + { // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- the explicit whitespace guard keeps socket-path resolution linear; the partial LINQ form only hoists this one guard while the try/catch + early-return stay in the body foreach (var candidate in candidates) { @@ -500,14 +514,23 @@ internal void RaiseSnapshotChangedForTests(LinuxCapabilitySnapshot snapshot) try { - if (File.Exists(candidate)) - { - return candidate; - } + using var socket = new Socket( + AddressFamily.Unix, + SocketType.Dgram, + ProtocolType.Unspecified + ); + using var timeout = new CancellationTokenSource( + s_ydotoolSocketConnectTimeout + ); + socket + .ConnectAsync(new UnixDomainSocketEndPoint(candidate), timeout.Token) + .GetAwaiter() + .GetResult(); + return candidate; } catch { - // Inaccessible socket path — skip it. + // Missing, stale, inaccessible, or non-datagram endpoint — skip it. } } diff --git a/tests/TypeWhisper.Linux.Tests/SystemCommandAvailabilityServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SystemCommandAvailabilityServiceTests.cs index 4213265ef..98c80a6fa 100644 --- a/tests/TypeWhisper.Linux.Tests/SystemCommandAvailabilityServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SystemCommandAvailabilityServiceTests.cs @@ -1,10 +1,146 @@ +using System.Net.Sockets; using TypeWhisper.Linux.Services; +using TypeWhisper.Tests; using Xunit; namespace TypeWhisper.Linux.Tests; public sealed class SystemCommandAvailabilityServiceTests { + [Fact] + public void IsCommandAvailable_RequiresExecutePermissionAndContinuesSearchingPath() + { + var originalPath = Environment.GetEnvironmentVariable("PATH"); + var tempDirectory = TestPaths.CreateTempDirectory("command-availability"); + var firstDirectory = Path.Join(tempDirectory, "first"); + var secondDirectory = Path.Join(tempDirectory, "second"); + var firstCandidate = Path.Join(firstDirectory, "fake-command"); + var secondCandidate = Path.Join(secondDirectory, "fake-command"); + + try + { + Directory.CreateDirectory(firstDirectory); + Directory.CreateDirectory(secondDirectory); + File.WriteAllText(firstCandidate, "not executed"); +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + File.SetUnixFileMode( + firstCandidate, + UnixFileMode.UserRead | UnixFileMode.UserWrite + ); +#pragma warning restore CA1416 + Environment.SetEnvironmentVariable("PATH", firstDirectory); + + Assert.False( + SystemCommandAvailabilityService.IsCommandAvailable("fake-command") + ); + +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + File.SetUnixFileMode( + firstCandidate, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + ); +#pragma warning restore CA1416 + + Assert.True( + SystemCommandAvailabilityService.IsCommandAvailable("fake-command") + ); + +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + File.SetUnixFileMode( + firstCandidate, + UnixFileMode.UserRead | UnixFileMode.UserWrite + ); +#pragma warning restore CA1416 + File.WriteAllText(secondCandidate, "also not executed"); +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + File.SetUnixFileMode( + secondCandidate, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.GroupExecute + ); +#pragma warning restore CA1416 + Environment.SetEnvironmentVariable( + "PATH", + string.Join(Path.PathSeparator, firstDirectory, secondDirectory) + ); + + Assert.True( + SystemCommandAvailabilityService.IsCommandAvailable("fake-command") + ); + } + finally + { + Environment.SetEnvironmentVariable("PATH", originalPath); + TestPaths.DeleteDirectory(tempDirectory); + } + } + + [Fact] + public void ResolveYdotoolSocketPath_DeadDatagramSocketIsUnavailable() + { + var tempDirectory = TestPaths.CreateTempDirectory("ydotool-dead"); + var boundSocketPath = Path.Join(tempDirectory, "bound.sock"); + var socketPath = Path.Join(tempDirectory, "ydotool.sock"); + + try + { + CreateStaleDatagramSocket(boundSocketPath, socketPath); + + Assert.Contains( + socketPath, + Directory.EnumerateFileSystemEntries(tempDirectory) + ); + + var resolved = SystemCommandAvailabilityService.ResolveYdotoolSocketPath( + [socketPath] + ); + + Assert.Null(resolved); + Assert.Contains( + socketPath, + Directory.EnumerateFileSystemEntries(tempDirectory) + ); + } + finally + { + TestPaths.DeleteDirectory(tempDirectory); + } + } + + [Fact] + public void ResolveYdotoolSocketPath_SkipsDeadCandidateAndReturnsLiveDatagramSocket() + { + var tempDirectory = TestPaths.CreateTempDirectory("ydotool-live"); + var boundSocketPath = Path.Join(tempDirectory, "bound.sock"); + var deadSocketPath = Path.Join(tempDirectory, "dead.sock"); + var liveSocketPath = Path.Join(tempDirectory, "live.sock"); + + try + { + CreateStaleDatagramSocket(boundSocketPath, deadSocketPath); + + using var liveSocket = new Socket( + AddressFamily.Unix, + SocketType.Dgram, + ProtocolType.Unspecified + ); + liveSocket.Bind(new UnixDomainSocketEndPoint(liveSocketPath)); + + var resolved = SystemCommandAvailabilityService.ResolveYdotoolSocketPath( + [deadSocketPath, liveSocketPath] + ); + + Assert.Equal(liveSocketPath, resolved); + } + finally + { + TestPaths.DeleteDirectory(tempDirectory); + } + } + [Fact] public void TryPreloadCuda12RuntimeLibraries_PartialLoadRemainsIncompleteAndRetriesMissingLibrary() { @@ -277,4 +413,18 @@ public void LinuxCapabilitySnapshot_WaylandXdotoolOnlyReportsXWayland() Assert.True(snapshot.HasAutomaticPasteTool); Assert.Equal("xdotool available (XWayland only)", snapshot.PasteStatus); } + + private static void CreateStaleDatagramSocket(string boundPath, string stalePath) + { + using var socket = new Socket( + AddressFamily.Unix, + SocketType.Dgram, + ProtocolType.Unspecified + ); + socket.Bind(new UnixDomainSocketEndPoint(boundPath)); + + // .NET removes the original bound path on dispose. Renaming the live inode + // first leaves a real, closed datagram endpoint at the test's stale path. + File.Move(boundPath, stalePath); + } } From 35765a833aca74401e69558630c878f67c552f69 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 20 Jul 2026 02:01:04 +0000 Subject: [PATCH 119/226] Harden migration, timer, and clipboard paths against stale-state races (audit tier 1) - LocalModelStorageService: verify target content before skipping a copy, so an unrelated pre-existing file can't stand in for the source and then be deleted; wrap post-commit cleanup walks in TryCleanUp so an unreadable source directory cannot surface as a failed migration. - SettingsService: serialize Load under the same gate as Update; Load writes Current and could clobber an in-flight Update. - ModelManagerService: guard the auto-unload timer with its own lock and retire superseded Elapsed callbacks by generation, so a dispatched callback cannot unload a model that was just loaded. - StreamingTranscriptState: compare a commit revision instead of the confirmed text, closing the ABA where stabilization lands on the same string. - DictationInsertionOrderGate: check cancellation before the fast path. - DictationShortcutSpecFactory: drop the cancel bind when swapping the final key for Escape yields the recording trigger itself. - TextInsertionService: treat a null clipboard read as unproven ownership and skip the restore rather than overwrite. - TransformSelectionService: treat a one-sided window id as changed identity. - Localization: CaptureSaveFailed now covers transcription failure too. --- .../Services/LocalModelStorageService.cs | 68 +++++++++-- .../Services/SettingsService.cs | 10 ++ .../Resources/Localization/de.json | 2 +- .../Resources/Localization/en.json | 2 +- .../Resources/Localization/es.json | 2 +- .../Resources/Localization/ru.json | 2 +- .../Services/DictationInsertionOrderGate.cs | 4 + .../DeSetup/DictationShortcutSpecFactory.cs | 21 +++- .../Services/ModelManagerService.cs | 113 ++++++++++++++---- .../Services/StreamingTranscriptState.cs | 11 +- .../Services/TextInsertionService.cs | 14 ++- .../Services/TransformSelectionService.cs | 8 ++ .../Services/LocalModelStorageServiceTests.cs | 58 +++++++++ .../DictationShortcutSpecFactoryTests.cs | 26 +++- .../TextInsertionServiceTests.cs | 27 +++++ .../TransformSelectionServiceTests.cs | 4 + 16 files changed, 327 insertions(+), 45 deletions(-) diff --git a/src/TypeWhisper.Core/Services/LocalModelStorageService.cs b/src/TypeWhisper.Core/Services/LocalModelStorageService.cs index d82aa0a9b..38401c71a 100644 --- a/src/TypeWhisper.Core/Services/LocalModelStorageService.cs +++ b/src/TypeWhisper.Core/Services/LocalModelStorageService.cs @@ -123,7 +123,8 @@ await Task.Run(() => // Settings already point at targetRoot, so this cleanup is best-effort: a failure or // interruption wastes disk space, never data — hence CancellationToken.None after the commit. await Task.Run( - () => DeletePluginAssetSourceContents(pluginAssetSourceRoot, targetRoot), + () => TryCleanUp(() => + DeletePluginAssetSourceContents(pluginAssetSourceRoot, targetRoot)), CancellationToken.None); } @@ -176,8 +177,8 @@ await Task.Run(() => // Best-effort cleanup after the commit above — see comment in the currentIsDefault branch. await Task.Run(() => { - DeleteModelRootSourceContents(sourceRoot, targetRoot); - DeletePluginAssetSourceContents(pluginAssetSourceRoot, targetRoot); + TryCleanUp(() => DeleteModelRootSourceContents(sourceRoot, targetRoot)); + TryCleanUp(() => DeletePluginAssetSourceContents(pluginAssetSourceRoot, targetRoot)); }, CancellationToken.None); } @@ -318,8 +319,7 @@ private static void DeletePluginAssetSourceContents(string assetSourceRoot, stri } // Files land at target only via an atomic same-directory rename, so a crash or I/O error - // mid-copy never leaves a partial file visible there — a later resume can trust - // File.Exists(target) to mean fully copied. + // mid-copy never leaves a partial file visible there. private static void CopyEntry(string source, string target, CancellationToken ct) { ct.ThrowIfCancellationRequested(); @@ -338,7 +338,13 @@ private static void CopyEntry(string source, string target, CancellationToken ct return; } - if (!File.Exists(source) || File.Exists(target)) + if (!File.Exists(source)) + return; + + // Nothing marks a pre-existing target as this migration's work, so content is the only + // proof. Skipping on anything weaker would let an unrelated file stand in for the + // source — DeleteMigratedEntry reads a present target as permission to delete it. + if (File.Exists(target) && FilesHaveIdenticalContent(source, target, ct)) return; var targetDir = Path.GetDirectoryName(target)!; @@ -349,7 +355,7 @@ private static void CopyEntry(string source, string target, CancellationToken ct try { File.Copy(source, stagingTarget); - File.Move(stagingTarget, target); + File.Move(stagingTarget, target, true); } catch { @@ -366,6 +372,37 @@ private static void CopyEntry(string source, string target, CancellationToken ct } } + private static bool FilesHaveIdenticalContent(string source, string target, CancellationToken ct) + { + try + { + using var sourceStream = File.OpenRead(source); + using var targetStream = File.OpenRead(target); + if (sourceStream.Length != targetStream.Length) + return false; + + var sourceBuffer = new byte[81920]; + var targetBuffer = new byte[81920]; + while (true) + { + ct.ThrowIfCancellationRequested(); + + var read = sourceStream.ReadAtLeast(sourceBuffer, sourceBuffer.Length, false); + if (read == 0) + return true; + + targetStream.ReadExactly(targetBuffer, 0, read); + if (!sourceBuffer.AsSpan(0, read).SequenceEqual(targetBuffer.AsSpan(0, read))) + return false; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Unproven match: fall through to a fresh copy rather than skip one. + return false; + } + } + // Deletes source only once its copy is confirmed at target. Runs only after the settings // commit, so a failure wastes disk space but cannot make the active model root incomplete. private static void DeleteMigratedEntry(string source, string target) @@ -389,6 +426,23 @@ private static void DeleteMigratedEntry(string source, string target) TryDeleteFile(source); } + // The per-entry delete helpers swallow their own I/O failures, but the directory walks + // around them do not — and cleanup runs after the settings commit, so an unreadable + // source directory must not surface as a failed migration. + private static void TryCleanUp(Action cleanUp) + { + try + { + cleanUp(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + System.Diagnostics.Trace.TraceWarning( + "Model storage migration cleanup failed: {0}", + ex.Message); + } + } + private static void TryDeleteFile(string path) { try diff --git a/src/TypeWhisper.Core/Services/SettingsService.cs b/src/TypeWhisper.Core/Services/SettingsService.cs index ed8d1f865..8dbbda11c 100644 --- a/src/TypeWhisper.Core/Services/SettingsService.cs +++ b/src/TypeWhisper.Core/Services/SettingsService.cs @@ -34,6 +34,16 @@ public SettingsService(string filePath) public event Action? SettingsChanged; public AppSettings Load() + { + // Load writes Current, so an unsynchronized Load could clobber what an + // in-flight Update just persisted. + lock (_gate) + { + return LoadLocked(); + } + } + + private AppSettings LoadLocked() { var result = TryLoadFrom(_filePath); if (result is not null) diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index 1c928b085..56641303b 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -452,7 +452,7 @@ "Notify.BodyPushToTalk": "Jetzt sprechen — loslassen zum Einfügen", "Notify.BodyToggle": "Jetzt sprechen — das Tastenkürzel erneut drücken zum Stoppen", "Overlay.Canceled": "Abgebrochen", - "Overlay.CaptureSaveFailed": "Aufnahme konnte nicht gespeichert werden.", + "Overlay.CaptureSaveFailed": "Aufnahme konnte nicht gespeichert oder transkribiert werden.", "Overlay.NoRecentTranscriptions": "Keine letzten Transkriptionen.", "Overlay.NoSpeech": "Keine Sprache erkannt", "Overlay.Processing": "Wird verarbeitet…", diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index 8afb6cde6..ff5e1a5b8 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -453,7 +453,7 @@ "Notify.BodyPushToTalk": "Speak now — release to insert", "Notify.BodyToggle": "Speak now — press the shortcut again to stop", "Overlay.Canceled": "Canceled", - "Overlay.CaptureSaveFailed": "Failed to save the recording.", + "Overlay.CaptureSaveFailed": "Failed to save or transcribe the recording.", "Overlay.NoRecentTranscriptions": "No recent transcriptions.", "Overlay.NoSpeech": "No speech detected", "Overlay.Processing": "Processing…", diff --git a/src/TypeWhisper.Linux/Resources/Localization/es.json b/src/TypeWhisper.Linux/Resources/Localization/es.json index ba90609f8..2337af3f5 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -452,7 +452,7 @@ "Notify.BodyPushToTalk": "Habla ahora — suelta para insertar", "Notify.BodyToggle": "Habla ahora — pulsa el atajo de nuevo para detener", "Overlay.Canceled": "Cancelado", - "Overlay.CaptureSaveFailed": "No se pudo guardar la grabación.", + "Overlay.CaptureSaveFailed": "No se pudo guardar ni transcribir la grabación.", "Overlay.NoRecentTranscriptions": "No hay transcripciones recientes.", "Overlay.NoSpeech": "No se detectó voz", "Overlay.Processing": "Procesando…", diff --git a/src/TypeWhisper.Linux/Resources/Localization/ru.json b/src/TypeWhisper.Linux/Resources/Localization/ru.json index cfa7a7397..18a8b5594 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -452,7 +452,7 @@ "Notify.BodyPushToTalk": "Говорите — отпустите, чтобы вставить", "Notify.BodyToggle": "Говорите — нажмите сочетание ещё раз, чтобы остановить", "Overlay.Canceled": "Отменено", - "Overlay.CaptureSaveFailed": "Не удалось сохранить запись.", + "Overlay.CaptureSaveFailed": "Не удалось сохранить или расшифровать запись.", "Overlay.NoRecentTranscriptions": "Нет недавних транскрипций.", "Overlay.NoSpeech": "Речь не обнаружена", "Overlay.Processing": "Обработка…", diff --git a/src/TypeWhisper.Linux/Services/DictationInsertionOrderGate.cs b/src/TypeWhisper.Linux/Services/DictationInsertionOrderGate.cs index 8db9386b1..f63d6dd17 100644 --- a/src/TypeWhisper.Linux/Services/DictationInsertionOrderGate.cs +++ b/src/TypeWhisper.Linux/Services/DictationInsertionOrderGate.cs @@ -62,6 +62,10 @@ internal void Reserve(int sessionId) /// internal async Task WaitForTurnAsync(int sessionId, CancellationToken cancellationToken) { + // Before the fast path too: an already-canceled session that happens to be the + // queue head would otherwise return normally and go on to insert. + cancellationToken.ThrowIfCancellationRequested(); + TaskCompletionSource tcs; lock (_lock) { diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs index 6f01be234..9bebaa44d 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs @@ -25,6 +25,7 @@ public static class DictationShortcutSpecFactory ? DefaultTrigger : settings.Current.ToggleHotkey; var gui = ResolveGuiCommand(); + var cancelTrigger = SwapKeyForCancel(trigger); return settings.Current.Mode switch { @@ -43,8 +44,8 @@ public static class DictationShortcutSpecFactory trigger, $"{gui} record start", $"{gui} record stop", - SwapKeyForCancel(trigger), - $"{gui} record cancel" + cancelTrigger, + cancelTrigger is null ? null : $"{gui} record cancel" ), _ => null }; @@ -67,7 +68,13 @@ private static string ResolveGuiCommand() return "typewhisper"; } - private static string SwapKeyForCancel(string trigger) + /// + /// Derives the cancel accelerator by swapping the trigger's final key for Escape, or returns + /// null when that yields the recording trigger itself (a trigger already ending in Escape, + /// e.g. Ctrl+Shift+Escape). Binding start and cancel to one accelerator would fire both + /// commands, so the cancel bind is dropped instead — writers skip it for a null trigger. + /// + private static string? SwapKeyForCancel(string trigger) { var parts = trigger.Split( '+', @@ -78,7 +85,13 @@ private static string SwapKeyForCancel(string trigger) return "Ctrl+Shift+Escape"; } + // Compare against the trigger rebuilt from the same parts so spacing and casing + // differences ("ctrl + shift + escape") can't hide a collision. + var normalizedTrigger = string.Join('+', parts); parts[^1] = "Escape"; - return string.Join('+', parts); + var cancel = string.Join('+', parts); + return string.Equals(cancel, normalizedTrigger, StringComparison.OrdinalIgnoreCase) + ? null + : cancel; } } diff --git a/src/TypeWhisper.Linux/Services/ModelManagerService.cs b/src/TypeWhisper.Linux/Services/ModelManagerService.cs index d80f36ae1..c339732b2 100644 --- a/src/TypeWhisper.Linux/Services/ModelManagerService.cs +++ b/src/TypeWhisper.Linux/Services/ModelManagerService.cs @@ -19,7 +19,12 @@ public sealed class ModelManagerService : INotifyPropertyChanged, IDisposable private readonly ISettingsService _settings; private TranscriptionAccelerationPreference? _activeModelAccelerationPreference; private string? _activeModelId; + // Guards _autoUnloadTimer, _autoUnloadGeneration and _disposed. Load/unload/acquire paths + // already hold _modelLock, but Dispose() runs on an arbitrary thread and must not block on + // that async lock — without its own gate it can race a lease's re-arm and leave a zombie timer. + private readonly Lock _timerGate = new(); private Timer? _autoUnloadTimer; + private int _autoUnloadGeneration; private bool _disposed; public ModelManagerService( @@ -42,7 +47,16 @@ public ModelManagerService( internal Func<(bool Success, string Message)> CudaRuntimePreflight { get; set; } /// Test seam: true while the idle auto-unload timer is armed and pending. - internal bool IsAutoUnloadArmed => _autoUnloadTimer is { Enabled: true }; + internal bool IsAutoUnloadArmed + { + get + { + lock (_timerGate) + { + return _autoUnloadTimer is { Enabled: true }; + } + } + } public string? ActiveModelId { @@ -99,13 +113,17 @@ public ITranscriptionEngine Engine public void Dispose() { - if (_disposed) + lock (_timerGate) { - return; + if (_disposed) + { + return; + } + + _disposed = true; + CancelAutoUnloadLocked(); } - _disposed = true; - CancelAutoUnload(); // _modelLock is intentionally NOT disposed: an outstanding TranscriptionLease or // fire-and-forget UnloadModelAsync may Release() after Dispose returns. SemaphoreSlim // only requires disposal when AvailableWaitHandle has been accessed (it has not). @@ -264,28 +282,36 @@ public async Task UnloadModelAsync() private void ScheduleAutoUnload() { - CancelAutoUnload(); - var seconds = _settings.Current.ModelAutoUnloadSeconds; - // Never arm after disposal: a lease outstanding when Dispose() runs re-arms here on - // its DisposeAsync, which would otherwise leave a zombie timer that fires plugin - // unloading during or after app teardown. - if (_disposed || seconds <= 0 || ActiveModelId is null) - { - return; - } + var armable = seconds > 0 && ActiveModelId is not null; - // System.Timers.Timer throws for intervals above int.MaxValue ms, and - // ModelAutoUnloadSeconds is a raw setting a corrupt or hand-edited config could push - // past that. Every load/lease path runs through here, so a throw must never be possible. - var intervalMs = Math.Min(seconds * 1000.0, int.MaxValue); - _autoUnloadTimer = new Timer(intervalMs) { AutoReset = false }; - _autoUnloadTimer.Elapsed += (_, _) => + lock (_timerGate) { - Debug.WriteLine($"Auto-unloading model after {seconds}s idle"); - UnloadModel(); - }; - _autoUnloadTimer.Start(); + CancelAutoUnloadLocked(); + + // Never arm after disposal: a lease outstanding when Dispose() runs re-arms here on + // its DisposeAsync, which would otherwise leave a zombie timer that fires plugin + // unloading during or after app teardown. + if (_disposed || !armable) + { + return; + } + + // Stop()/Dispose() cannot recall an Elapsed callback already dispatched to the + // thread pool, so a superseded timer can still fire after a newer model was loaded. + // Each callback carries the generation it was armed with; see + // UnloadIfGenerationCurrentAsync for where that is validated. + var generation = _autoUnloadGeneration; + + // System.Timers.Timer throws for intervals above int.MaxValue ms, and + // ModelAutoUnloadSeconds is a raw setting a corrupt or hand-edited config could push + // past that. Every load/lease path runs through here, so a throw must never be possible. + var intervalMs = Math.Min(seconds * 1000.0, int.MaxValue); + _autoUnloadTimer = new Timer(intervalMs) { AutoReset = false }; + _autoUnloadTimer.Elapsed += (_, _) => + _ = UnloadIfGenerationCurrentAsync(generation, seconds); + _autoUnloadTimer.Start(); + } } public bool CanDeleteModel(string modelId) @@ -842,8 +868,47 @@ private async Task UnloadModelCoreAsync() _activeModelAccelerationPreference = null; } + /// + /// Idle-timer unload. Checking the generation before taking _modelLock would not be + /// enough: a load or lease can win the lock in that gap, re-arm, and release — leaving this + /// already-validated callback to unload a model that was just loaded. Every re-arm happens + /// under _modelLock, so validating after acquiring it serializes check and unload. + /// + private async Task UnloadIfGenerationCurrentAsync(int generation, int idleSeconds) + { + await _modelLock.WaitAsync(); + try + { + lock (_timerGate) + { + if (_disposed || generation != _autoUnloadGeneration) + { + return; + } + } + + Debug.WriteLine($"Auto-unloading model after {idleSeconds}s idle"); + await UnloadModelCoreAsync(); + } + finally + { + _modelLock.Release(); + } + } + private void CancelAutoUnload() { + lock (_timerGate) + { + CancelAutoUnloadLocked(); + } + } + + private void CancelAutoUnloadLocked() + { + // Bump first: this retires any Elapsed callback already in flight from the timer + // being torn down, whether or not a replacement is armed afterwards. + _autoUnloadGeneration++; _autoUnloadTimer?.Stop(); _autoUnloadTimer?.Dispose(); _autoUnloadTimer = null; diff --git a/src/TypeWhisper.Linux/Services/StreamingTranscriptState.cs b/src/TypeWhisper.Linux/Services/StreamingTranscriptState.cs index 1ea6cc089..483979ad5 100644 --- a/src/TypeWhisper.Linux/Services/StreamingTranscriptState.cs +++ b/src/TypeWhisper.Linux/Services/StreamingTranscriptState.cs @@ -17,6 +17,10 @@ internal sealed class StreamingTranscriptState private string _confirmedText = ""; private string _lastDisplayedText = ""; private int _sessionVersion; + // Bumped on every _confirmedText commit. A value compare cannot tell "nobody committed" from + // "someone committed and stabilization landed back on the same string" — the ABA that would + // let a stale poll overwrite a newer result. + private int _commitRevision; public int StartSession() { @@ -52,6 +56,7 @@ out string displayText { displayText = ""; string confirmedSnapshot; + int revisionSnapshot; lock (_lock) { if (sessionVersion != _sessionVersion) @@ -60,6 +65,7 @@ out string displayText } confirmedSnapshot = _confirmedText; + revisionSnapshot = _commitRevision; } var text = rawText.Trim(); @@ -80,12 +86,13 @@ out string displayText lock (_lock) { // A version check alone cannot detect another poll committing within this same - // session; the value compare discards this stale result instead of clobbering it. - if (sessionVersion != _sessionVersion || _confirmedText != confirmedSnapshot) + // session; the revision compare discards this stale result instead of clobbering it. + if (sessionVersion != _sessionVersion || _commitRevision != revisionSnapshot) { return false; } + _commitRevision++; _confirmedText = stable; _lastDisplayedText = stable; displayText = stable; diff --git a/src/TypeWhisper.Linux/Services/TextInsertionService.cs b/src/TypeWhisper.Linux/Services/TextInsertionService.cs index 35bdafa8b..9b97b8bd6 100644 --- a/src/TypeWhisper.Linux/Services/TextInsertionService.cs +++ b/src/TypeWhisper.Linux/Services/TextInsertionService.cs @@ -61,6 +61,8 @@ public sealed class TextInsertionService "Clipboard preservation skipped: the previous clipboard offered a non-text format (e.g. an image or file list) that cannot be captured as plain text, so it was replaced and could not be restored."; private const string ClipboardRichRestoreSkippedMessage = "Clipboard preservation skipped: the previous clipboard also offered a richer, non-text format (e.g. HTML) that would be lost if restored as plain text, so it was left as-is instead of a lossy restore."; + private const string ClipboardUnprovableRestoreSkippedMessage = + "Clipboard preservation skipped: the clipboard no longer reads back as text — another app may have replaced it with an image or file list — so the previous text was not restored over it."; private const string ClipboardRichRestoreLossyMessage = "Clipboard preservation was lossy: the previous clipboard also offered a richer, non-text format (e.g. HTML) that could not be restored; only its plain-text content was restored."; private static readonly TimeSpan s_focusDelay = TimeSpan.FromMilliseconds(100); @@ -650,9 +652,17 @@ private async Task RestorePreviousClipboardAsync( // trailing newline. If another app replaced it meanwhile, restoring would // clobber the user's newer copy. var current = await _platform.TryGetClipboardTextAsync(); + if (current is null) + { + // Null is not proof the clipboard is still ours: another app may have replaced + // it with content serving no plain text (an image, a file list), or the read + // timed out. Unproven ownership is not permission to overwrite. + LogInsertionFallback(ClipboardUnprovableRestoreSkippedMessage); + return; + } + if ( - current is not null - && !string.Equals( + !string.Equals( current.TrimEnd('\n'), pastedText.TrimEnd('\n'), StringComparison.Ordinal diff --git a/src/TypeWhisper.Linux/Services/TransformSelectionService.cs b/src/TypeWhisper.Linux/Services/TransformSelectionService.cs index 41cf9745d..880a203a9 100644 --- a/src/TypeWhisper.Linux/Services/TransformSelectionService.cs +++ b/src/TypeWhisper.Linux/Services/TransformSelectionService.cs @@ -130,6 +130,14 @@ internal static bool HasSelectionTargetChanged( ); } + // Neither side offered a process name. A window id on exactly one side means identity + // appeared or vanished between capture and replace — usually the captured window + // closing — so treat it as changed rather than replacing into an unconfirmable window. + if (!string.IsNullOrEmpty(capturedWindowId) || !string.IsNullOrEmpty(currentWindowId)) + { + return true; + } + // No identity signal on either side — fail open rather than block a replacement we can't validate. return false; } diff --git a/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs index 5aa48986a..8b9402f2e 100644 --- a/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs @@ -97,6 +97,64 @@ public async Task MoveDownloadsAndUsePathAsync_MigratesAssetsAndSavesPath() Assert.Equal(Path.GetFullPath(target), settings.Current.LocalModelStoragePath); } + [Theory] + // Same byte length as the source, so a length-only check would wrongly accept it. + [InlineData("stale-x-weights")] + // Different length. + [InlineData("stale")] + public async Task MoveDownloadsAndUsePathAsync_ConflictingPreExistingTarget_IsReplacedNotTrusted( + string staleContent + ) + { + var source = Path.Join(_tempRoot, $"source-stale-{staleContent.Length}"); + var target = Path.Join(_tempRoot, $"target-stale-{staleContent.Length}"); + var modelDir = Path.Join(source, LocalModelStoragePaths.PluginDataFolderName, "com.typewhisper.whisper-cpp", "Models"); + Directory.CreateDirectory(modelDir); + const string sourceContent = "current weights"; + await File.WriteAllTextAsync(Path.Join(modelDir, "ggml-base.bin"), sourceContent); + + // A leftover from an earlier manual copy or an older install. Treating its presence — + // or its size — as "already migrated" would skip the copy and delete the real source. + var stalePath = Path.Join(target, LocalModelStoragePaths.PluginDataFolderName, "com.typewhisper.whisper-cpp", "Models", "ggml-base.bin"); + Directory.CreateDirectory(Path.GetDirectoryName(stalePath)!); + await File.WriteAllTextAsync(stalePath, staleContent); + + var settings = new FakeSettingsService(new AppSettings { LocalModelStoragePath = source }); + var service = new LocalModelStorageService(settings); + + await service.MoveDownloadsAndUsePathAsync(target); + + Assert.Equal(sourceContent, await File.ReadAllTextAsync(stalePath)); + } + + [Fact] + public async Task MoveDownloadsAndUsePathAsync_MatchingPreExistingTarget_SkipsRecopyAndCleansSource() + { + var source = Path.Join(_tempRoot, "source-resume"); + var target = Path.Join(_tempRoot, "target-resume"); + var modelDir = Path.Join(source, LocalModelStoragePaths.PluginDataFolderName, "com.typewhisper.whisper-cpp", "Models"); + Directory.CreateDirectory(modelDir); + var sourceModel = Path.Join(modelDir, "ggml-base.bin"); + const string content = "current weights"; + await File.WriteAllTextAsync(sourceModel, content); + + // An interrupted earlier run already copied this through; identical content is the + // proof that lets a resume skip re-copying it. + var alreadyCopied = Path.Join(target, LocalModelStoragePaths.PluginDataFolderName, "com.typewhisper.whisper-cpp", "Models", "ggml-base.bin"); + Directory.CreateDirectory(Path.GetDirectoryName(alreadyCopied)!); + await File.WriteAllTextAsync(alreadyCopied, content); + var copiedAt = File.GetLastWriteTimeUtc(alreadyCopied); + + var settings = new FakeSettingsService(new AppSettings { LocalModelStoragePath = source }); + var service = new LocalModelStorageService(settings); + + await service.MoveDownloadsAndUsePathAsync(target); + + Assert.Equal(content, await File.ReadAllTextAsync(alreadyCopied)); + Assert.Equal(copiedAt, File.GetLastWriteTimeUtc(alreadyCopied)); + Assert.False(File.Exists(sourceModel)); + } + [Fact] public async Task MoveDownloadsAndUsePathAsync_SaveFails_LeavesSourceIntact() { diff --git a/tests/TypeWhisper.Linux.Tests/DictationShortcutSpecFactoryTests.cs b/tests/TypeWhisper.Linux.Tests/DictationShortcutSpecFactoryTests.cs index bb4750126..7b45421b8 100644 --- a/tests/TypeWhisper.Linux.Tests/DictationShortcutSpecFactoryTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DictationShortcutSpecFactoryTests.cs @@ -61,6 +61,28 @@ public void Build_PushToTalk_ReturnsPressReleaseCancelSpecForCapableWriter(strin Assert.NotEqual(spec.Trigger, spec.OnCancelTrigger); } + [Theory] + [InlineData("Ctrl+Shift+Escape")] + [InlineData("Escape")] + [InlineData("ctrl + shift + escape")] + public void Build_PushToTalk_EscapeEndingTrigger_DropsCancelBindInsteadOfDuplicatingTrigger( + string trigger + ) + { + var settings = CreateSettings(RecordingMode.PushToTalk, trigger); + + var spec = Assert.IsType( + DictationShortcutSpecFactory.Build(settings, CreateWriter("hyprland")) + ); + + // Swapping the last key for Escape would reproduce the record trigger, so the cancel + // bind is dropped rather than firing both commands off one accelerator. + Assert.Null(spec.OnCancelTrigger); + Assert.Null(spec.OnCancelCommand); + Assert.EndsWith("record start", spec.OnPressCommand); + Assert.EndsWith("record stop", spec.OnReleaseCommand); + } + [Theory] [InlineData("gnome")] [InlineData("kde")] @@ -87,7 +109,7 @@ public void Build_Hybrid_ReturnsNullForEveryWriter(string writerId) Assert.Null(spec); } - private SettingsService CreateSettings(RecordingMode mode) + private SettingsService CreateSettings(RecordingMode mode, string trigger = "Ctrl+Shift+Space") { var settings = new SettingsService(Path.Join(_tempDir, "settings.json")); settings.Load(); @@ -95,7 +117,7 @@ private SettingsService CreateSettings(RecordingMode mode) settings.Current with { Mode = mode, - ToggleHotkey = "Ctrl+Shift+Space" + ToggleHotkey = trigger } ); return settings; diff --git a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs index 7f00482ae..78c10bbdb 100644 --- a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs @@ -630,6 +630,33 @@ public async Task InsertTextAsync_skips_restore_when_clipboard_no_longer_holds_o Assert.Equal("new text", platform.Clipboard); } + [Fact] + public async Task InsertTextAsync_skips_restore_when_ownership_read_cannot_prove_our_text() + { + // The ownership read comes back null — an image landed on the clipboard, or the read + // timed out. Either way ownership is unproven, which must not license a restore. + var platform = new FakeTextInsertionPlatform + { + Clipboard = "previous", + PasteSucceeds = true, + ClipboardReadResults = new Queue( + [ + "previous", // snapshot + "new text", // verify — serving + null // ownership check — clipboard no longer reads back as text + ] + ) + }; + var confirmation = new FakePasteConfirmationSource { Result = true }; + var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); + + var result = await sut.InsertTextAsync("new text"); + + Assert.Equal(InsertionResult.Pasted, result); + // No restore write happened: only the initial set. + Assert.Equal(1, platform.SetClipboardCount); + } + [Fact] public async Task InsertTextAsync_null_previous_clipboard_skips_wait_and_restore() { diff --git a/tests/TypeWhisper.Linux.Tests/TransformSelectionServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TransformSelectionServiceTests.cs index 4d84e63d3..a43ca9bff 100644 --- a/tests/TypeWhisper.Linux.Tests/TransformSelectionServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TransformSelectionServiceTests.cs @@ -50,6 +50,10 @@ public async Task CaptureSelectionForTransformAsync_UsesPlainCopyShortcut_ForNon [InlineData(null, "code", null, "firefox", true)] [InlineData(null, null, null, null, false)] [InlineData(null, "code", null, null, true)] + // Asymmetric window id with no process name on either side: the only identity signal + // appeared or vanished, so the target can no longer be confirmed. + [InlineData("123", null, null, null, true)] + [InlineData(null, null, "123", null, true)] public void HasSelectionTargetChanged_ReturnsExpectedResult( string? capturedWindowId, string? capturedProcessName, From 39e6fa817e00cd897d1d235edbc5607f2b6b8dfc Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 20 Jul 2026 02:27:05 +0000 Subject: [PATCH 120/226] Harden restore limits, API contracts, and startup socket failure handling - SettingsBackupService: cap restored bytes/entry count and extract through a bounded stream, counting bytes actually written rather than the archive's declared entry length. Read the manifest through the same cap. - App: a control-socket path failure during construction now fails closed with the same message and nonzero exit as Program's preflight probe, instead of surfacing as an unhandled exception. - HttpApiService: empty transcribe bodies return 400 "No audio data provided" instead of a misleading 413; over-capacity rejections no longer block accepts. - SwayShortcutWriter: warn about stale live bindings on the no-op removal paths, matching Hyprland. - ProfileService: UpdateProfile no longer commits when the id is unknown. - ControlSocketClient: drop the inaccurate "side-effect-free" doc claim. - ProcessRunnerTests: widen the caller-cancellation margin against the 1s timeout. --- .../Services/ProfileService.cs | 5 +- src/TypeWhisper.Linux/App.axaml.cs | 29 ++++++- .../Resources/Localization/de.json | 1 + .../Resources/Localization/en.json | 1 + .../Resources/Localization/es.json | 1 + .../Resources/Localization/ru.json | 1 + .../Hotkey/DeSetup/SwayShortcutWriter.cs | 11 ++- .../Services/HttpApiService.cs | 8 +- .../Services/Ipc/ControlSocketClient.cs | 7 +- .../Services/SettingsBackupService.cs | 75 ++++++++++++++++++- .../ProcessRunnerTests.cs | 4 +- 11 files changed, 126 insertions(+), 17 deletions(-) diff --git a/src/TypeWhisper.Core/Services/ProfileService.cs b/src/TypeWhisper.Core/Services/ProfileService.cs index a4ea49f8b..054b0df36 100644 --- a/src/TypeWhisper.Core/Services/ProfileService.cs +++ b/src/TypeWhisper.Core/Services/ProfileService.cs @@ -81,11 +81,12 @@ public void UpdateProfile(Profile profile) var updated = profile with { UpdatedAt = DateTime.UtcNow }; var newCache = new List(_cache); var idx = newCache.FindIndex(p => p.Id == profile.Id); - if (idx >= 0) + if (idx < 0) { - newCache[idx] = updated; + return; } + newCache[idx] = updated; CommitLocked(newCache); } } diff --git a/src/TypeWhisper.Linux/App.axaml.cs b/src/TypeWhisper.Linux/App.axaml.cs index 1fff5154c..2d72319f6 100644 --- a/src/TypeWhisper.Linux/App.axaml.cs +++ b/src/TypeWhisper.Linux/App.axaml.cs @@ -169,9 +169,31 @@ main.DataContext as MainWindowViewModel var sessionResults = services.GetRequiredService(); dictation.SessionCompleted += sessionResults.Record; + // Construction resolves the socket path. Failing here means we cannot tell + // whether another instance already owns it, and ownership uncertainty fails + // closed (ControlSocketServer.Start does the same) — a second instance would + // share settings, hotkeys, and runtime state with the first. + ControlSocketServer controlSocket; + try + { + controlSocket = services.GetRequiredService(); + } + catch (Exception ex) + { + Trace.WriteLine($"[App] Control socket path unavailable: {ex}"); + Console.Error.WriteLine( + "TypeWhisper could not verify that no other instance is running. Startup was canceled." + ); + ShuttingDown = true; + LinuxStartupNotification.NotifyComplete(); + // Nonzero, matching Program's preflight probe failure: this is a + // canceled startup, not the "already running" success path below. + _ = ShutdownAndExitAsync(services, desktop, 1); + return; + } + // The bind doubles as the single-instance guard; AddressAlreadyInUse means // a live peer got here first — shut this instance down cleanly. - var controlSocket = services.GetRequiredService(); try { controlSocket.Start(); @@ -496,7 +518,8 @@ private static void FireAndForget(Task task) => private static async Task ShutdownAndExitAsync( IServiceProvider services, - IClassicDesktopStyleApplicationLifetime desktop) + IClassicDesktopStyleApplicationLifetime desktop, + int exitCode = 0) { try { @@ -511,7 +534,7 @@ private static async Task ShutdownAndExitAsync( ClosePermitted = true; // Must call Shutdown explicitly: DictationOverlayWindow is always-shown // (backlog #16 Opacity workaround) so OnLastWindowClose never fires. - desktop.Shutdown(); + desktop.Shutdown(exitCode); } } diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index 56641303b..f474cd8c5 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -7,6 +7,7 @@ "About.BackupInvalidManifest": "Das Manifest der TypeWhisper-Sicherung ist ungültig oder nicht lesbar. Die Wiederherstellung wurde abgebrochen.", "About.BackupRestored": "Backup aus {0} Datei(en) wiederhergestellt. Einige wiederhergestellte Einstellungen erfordern möglicherweise einen Neustart der App.", "About.BackupStatusDefault": "Einstellungen, Profile, Textbausteine und Plugin-Daten sichern.", + "About.BackupTooLarge": "Diese Sicherung entpackt weit mehr Daten, als eine Einstellungssicherung je enthält, und wurde möglicherweise manipuliert. Die Wiederherstellung wurde abgebrochen.", "About.BackupUnsafePath": "Diese Sicherung enthält einen unsicheren Pfad und wurde möglicherweise manipuliert: {0}", "About.BackupUnsupportedPath": "Diese Sicherung enthält einen nicht unterstützten Pfad und wurde möglicherweise manipuliert: {0}", "About.CheckForUpdates": "Nach Updates suchen", diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index ff5e1a5b8..ccab95692 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -8,6 +8,7 @@ "About.BackupRestored": "Backup restored from {0} file(s). Some restored settings may require an app restart.", "About.BackupStaged": "Backup validated and staged from {0} file(s). Quit and reopen TypeWhisper to apply it.", "About.BackupStatusDefault": "Back up settings, profiles, snippets, and plugin data.", + "About.BackupTooLarge": "This backup expands to far more data than a settings backup ever contains and may have been tampered with. Restore was canceled.", "About.BackupUnsafePath": "This backup contains an unsafe path and may have been tampered with: {0}", "About.BackupUnsupportedPath": "This backup contains an unsupported path and may have been tampered with: {0}", "About.CheckForUpdates": "Check for Updates", diff --git a/src/TypeWhisper.Linux/Resources/Localization/es.json b/src/TypeWhisper.Linux/Resources/Localization/es.json index 2337af3f5..5a41d2aa8 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -7,6 +7,7 @@ "About.BackupInvalidManifest": "El manifiesto de la copia de seguridad de TypeWhisper no es válido o no se puede leer. Se canceló la restauración.", "About.BackupRestored": "Copia de seguridad restaurada desde {0} archivo(s). Es posible que algunos ajustes restaurados requieran reiniciar la aplicación.", "About.BackupStatusDefault": "Haz una copia de seguridad de ajustes, perfiles, fragmentos y datos de plugins.", + "About.BackupTooLarge": "Esta copia de seguridad se expande a muchos más datos de los que contiene una copia de ajustes y puede haber sido manipulada. Se canceló la restauración.", "About.BackupUnsafePath": "Esta copia de seguridad contiene una ruta no segura y puede haber sido manipulada: {0}", "About.BackupUnsupportedPath": "Esta copia de seguridad contiene una ruta no admitida y puede haber sido manipulada: {0}", "About.CheckForUpdates": "Buscar actualizaciones", diff --git a/src/TypeWhisper.Linux/Resources/Localization/ru.json b/src/TypeWhisper.Linux/Resources/Localization/ru.json index 18a8b5594..44c0eacfa 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -7,6 +7,7 @@ "About.BackupInvalidManifest": "Манифест резервной копии TypeWhisper повреждён или не читается. Восстановление отменено.", "About.BackupRestored": "Резервная копия восстановлена, файлов: {0}. Некоторые восстановленные настройки могут потребовать перезапуска приложения.", "About.BackupStatusDefault": "Резервное копирование настроек, профилей, сниппетов и данных плагинов.", + "About.BackupTooLarge": "Эта резервная копия распаковывается в гораздо больший объём данных, чем содержит копия настроек, и, возможно, была подменена. Восстановление отменено.", "About.BackupUnsafePath": "Эта резервная копия содержит небезопасный путь и, возможно, была подменена: {0}", "About.BackupUnsupportedPath": "Эта резервная копия содержит неподдерживаемый путь и, возможно, была подменена: {0}", "About.CheckForUpdates": "Проверить обновления", diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs index 9d504a4ea..7e19e157b 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs @@ -11,6 +11,9 @@ public sealed class SwayShortcutWriter : IDeShortcutWriter { private const int MaxWriteAttempts = 3; + private const string RemovalRequiresReloadWarning = + "Sway may still have the live binding. Run `swaymsg reload` (or restart Sway) to remove it."; + private static readonly TimeSpan s_reloadTimeout = TimeSpan.FromSeconds(10); private readonly Func< @@ -174,7 +177,8 @@ public async Task RemoveAsync(string shortcutId, Cancella return new DeShortcutWriteResult( true, "No sway config to update.", - [] + [], + RemovalRequiresReloadWarning ); } @@ -193,7 +197,8 @@ public async Task RemoveAsync(string shortcutId, Cancella return new DeShortcutWriteResult( true, "No Sway integration to remove.", - [] + [], + RemovalRequiresReloadWarning ); } @@ -208,7 +213,7 @@ public async Task RemoveAsync(string shortcutId, Cancella var reloaded = await ReloadAsync(ct).ConfigureAwait(false); var warning = reloaded ? null - : "Block removed, but `swaymsg reload` failed. Reload Sway manually to drop the live bindings."; + : RemovalRequiresReloadWarning; return new DeShortcutWriteResult( true, "Sway managed block removed.", diff --git a/src/TypeWhisper.Linux/Services/HttpApiService.cs b/src/TypeWhisper.Linux/Services/HttpApiService.cs index 9bf025c6c..a2e779082 100644 --- a/src/TypeWhisper.Linux/Services/HttpApiService.cs +++ b/src/TypeWhisper.Linux/Services/HttpApiService.cs @@ -275,7 +275,10 @@ private async Task ListenLoopAsync(CancellationToken ct) ); if (handlerTask is null) { - await RejectOverCapacityAsync(context, ct); + // Fire-and-forget like the admitted path: awaiting the rejection + // would let one slow client stall accepts. The method swallows its + // own exceptions and closes the response. + _ = RejectOverCapacityAsync(context, ct); } } catch (HttpListenerException) when (ct.IsCancellationRequested) @@ -517,9 +520,10 @@ private static string FormatAccelerationBackend(TranscriptionAccelerationBackend CancellationToken ct ) { + // Empty body — answer with the same contract ParseTranscribe would produce. if (request.ContentLength64 == 0) { - return (413, Serialize(new { error = "Request body too large" })); + return (400, Serialize(new { error = "No audio data provided" })); } var prepared = await PrepareTranscriptionRequestAsync(request, ct); diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketClient.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketClient.cs index 81198996d..f39b05afc 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketClient.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketClient.cs @@ -15,9 +15,10 @@ internal static class ControlSocketClient private const int TimeoutMillis = 2000; /// - /// Side-effect-free liveness probe: returns true if a server is bound to - /// . Used by argument-bearing launches (e.g. --minimized) - /// that must not trigger a toggle merely to check for a running instance. + /// Liveness probe: returns true if a server is bound to . + /// Never toggles recording state, so argument-bearing launches (e.g. --minimized) + /// can use it to check for a running instance. It may, however, unlink a socket path + /// confirmed stale under the ownership lock. /// public static bool IsLivePeer(string path) { diff --git a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs index 151696d31..5a28bb6db 100644 --- a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs +++ b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs @@ -46,6 +46,13 @@ public sealed class SettingsBackupService // manifest can't be materialized into memory before shape validation runs. private const long MaxManifestBytes = 64 * 1024; + // Path/extension validation says nothing about size: a decompression bomb made + // entirely of allowed paths would still fill the disk during staging. Cap the + // restored total and entry count well above any real settings backup and abort + // as soon as an entry would cross the line. + private const long MaxRestoreBytes = 512L * 1024 * 1024; + private const int MaxRestoreEntries = 50_000; + private const string ManifestApp = "TypeWhisper"; private const string ManifestKind = "settings-backup"; @@ -228,11 +235,17 @@ public SettingsBackupResult StageRestore(string sourceZipPath) continue; } + if (fileCount >= MaxRestoreEntries) + { + throw new InvalidDataException(Loc.Instance["About.BackupTooLarge"]); + } + var targetPath = GetSafeDestinationPath(contentDirectory, entry.FullName); Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); - entry.ExtractToFile(targetPath, true); + // Count the bytes actually written, not entry.Length — the declared + // length comes from the archive and a crafted one can understate it. + bytes += ExtractCapped(entry, targetPath, MaxRestoreBytes - bytes); fileCount++; - bytes += entry.Length; } WriteDurableJson( @@ -751,6 +764,47 @@ ref long bytes bytes += new FileInfo(path).Length; } + /// + /// Extracts one entry, aborting as soon as it would write more than + /// . Returns the bytes actually written. + /// A partial file is left behind; the caller discards the staging tree. + /// + private static long ExtractCapped( + ZipArchiveEntry entry, + string targetPath, + long remainingBytes + ) + { + long written = 0; + using (var source = entry.Open()) + using ( + var destination = new FileStream( + targetPath, + FileMode.Create, + FileAccess.Write, + FileShare.None + ) + ) + { + var buffer = new byte[81920]; + int read; + while ((read = source.Read(buffer, 0, buffer.Length)) > 0) + { + written += read; + if (written > remainingBytes) + { + throw new InvalidDataException(Loc.Instance["About.BackupTooLarge"]); + } + + destination.Write(buffer, 0, read); + } + } + + // Parity with ExtractToFile, which carries the archive timestamp across. + File.SetLastWriteTimeUtc(targetPath, entry.LastWriteTime.UtcDateTime); + return written; + } + private static void ValidateArchive(ZipArchive archive) { var manifestEntries = archive @@ -826,8 +880,23 @@ private static void ValidateManifest(ZipArchiveEntry manifestEntry) BackupManifest? manifest; try { + // The declared length above is archive-controlled and can understate the + // real size, so cap the bytes actually decompressed before deserializing. using var stream = manifestEntry.Open(); - manifest = JsonSerializer.Deserialize(stream); + using var buffer = new MemoryStream(); + var chunk = new byte[4096]; + int read; + while ((read = stream.Read(chunk, 0, chunk.Length)) > 0) + { + if (buffer.Length + read > MaxManifestBytes) + { + throw new InvalidDataException(Loc.Instance["About.BackupInvalidManifest"]); + } + + buffer.Write(chunk, 0, read); + } + + manifest = JsonSerializer.Deserialize(buffer.ToArray()); } catch (Exception ex) when (ex is JsonException or IOException or InvalidDataException) { diff --git a/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs b/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs index d5ca9369f..59d63267b 100644 --- a/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs @@ -272,7 +272,9 @@ public async Task RunAsync_caller_cancellation_wins_when_racing_private_timeout( ); processIds = await WaitForProcessIdsAsync(pidFile); - var callerDeadline = TimeSpan.FromMilliseconds(800) - stopwatch.Elapsed; + // Well clear of the 1s private timeout: on a loaded machine an 800ms + // target left too little margin and the timeout could win the race. + var callerDeadline = TimeSpan.FromMilliseconds(300) - stopwatch.Elapsed; if (callerDeadline > TimeSpan.Zero) { cts.CancelAfter(callerDeadline); From 251513c65fc52460571c0b8c3e89888d3739e129 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 20 Jul 2026 14:50:30 +0000 Subject: [PATCH 121/226] Clear ReSharper inspections across the solution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: ArgumentOutOfRangeException.ThrowIf* guards, ValueTask.AsTask before GetResult, cancellation token on the accept-loop Task.Run, await using for async-disposable services in tests, cached JsonSerializerOptions, and assorted if/return and const conversions. Everything else is suppressed with a reason: cases where var would not compile or would drop nullability, guard inversions that would duplicate a return or skip a cleanup stage, and test-guard WaitAsync calls with no ambient token. The libc DllImport keeps CA2101/SYSLIB1054 pragmas — LPUTF8Str is correct for POSIX pathnames and LibraryImport would require unsafe code in the test project. --- .../Services/AudioRecordingService.cs | 8 ++------ .../Services/DictationOrchestrator.cs | 1 + .../Services/HttpApiRequestParser.cs | 7 ++----- .../Services/Ipc/ControlSocketOwnership.cs | 2 ++ .../Services/Ipc/ControlSocketServer.cs | 14 ++++++------- .../Services/LinuxPreferencesService.cs | 2 ++ .../Services/LinuxSystemTtsProvider.cs | 1 + .../Services/ProcessRunner.cs | 1 + .../Services/RecordingNotificationService.cs | 1 + .../Services/SpeechFeedbackService.cs | 1 + .../SystemCommandAvailabilityService.cs | 1 + .../Services/TransformSelectionService.cs | 1 + .../Services/WatchFolderService.cs | 3 +++ .../ControlSocketOwnershipTests.cs | 11 ++++++++-- .../ControlSocketServerTests.cs | 4 ++-- .../LinuxSystemTtsProviderTests.cs | 13 +++--------- .../RecordingNotificationServiceTests.cs | 20 +++++++++---------- .../SettingsBackupServiceTests.cs | 7 +++---- .../SoundFeedbackServiceTests.cs | 1 + .../SystemCommandAvailabilityServiceTests.cs | 3 +++ .../WatchFolderServiceTests.cs | 13 +++++++++--- 21 files changed, 64 insertions(+), 51 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs index b37791ff2..30dec1d2b 100644 --- a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs @@ -369,12 +369,7 @@ internal async Task StopRecordingAsync( } var snapshot = SnapshotRecordedAudio(); - if (snapshot.SampleCount == 0) - { - return null; - } - - return BuildWavFromRecordedAudio(snapshot); + return snapshot.SampleCount == 0 ? null : BuildWavFromRecordedAudio(snapshot); } } @@ -534,6 +529,7 @@ int targetSampleRate var filterRadius = (int)Math.Ceiling(24 * ratio); var coefficientCount = filterRadius + 1; const int maxStackAllocatedCoefficientCount = 256; + // ReSharper disable once SuggestVarOrType_Elsewhere -- the explicit Span is the shared target type that unifies the stackalloc and heap arms. Span coefficients = coefficientCount <= maxStackAllocatedCoefficientCount ? stackalloc double[coefficientCount] : new double[coefficientCount]; diff --git a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs index a383675e5..0cf10c2c1 100644 --- a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs +++ b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs @@ -1114,6 +1114,7 @@ internal static void ReportShortSpeechDiscardOutcome( Action showFeedback ) { + // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault -- only discard outcomes are reportable; the default arm rejects the rest by design. var messageKey = discardReason switch { LinuxShortSpeechDecision.DiscardTooShort => "Overlay.TooShort", diff --git a/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs b/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs index 74b824679..5f20e9636 100644 --- a/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs +++ b/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs @@ -119,10 +119,7 @@ CancellationToken ct ) { ArgumentNullException.ThrowIfNull(input); - if (declaredLength < -1) - { - throw new ArgumentOutOfRangeException(nameof(declaredLength)); - } + ArgumentOutOfRangeException.ThrowIfLessThan(declaredLength, -1); if (maxBytes is < 0 or > int.MaxValue) { @@ -269,7 +266,7 @@ string boundary ) { var boundaryBytes = Encoding.UTF8.GetBytes("--" + boundary); - ReadOnlySpan doubleCrlf = "\r\n\r\n"u8; + var doubleCrlf = "\r\n\r\n"u8; var parts = new List(); var searchStart = 0; var bodySpan = body.Span; diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs index ca837d936..80728ec7a 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs @@ -63,6 +63,7 @@ internal static bool TryAcquire( { ownership = null; var lockPath = Path.Join(Path.GetDirectoryName(socketPath)!, "control.lock"); + // ReSharper disable once SuggestVarOrType_SimpleTypes -- OpenLockFile returns a non-nullable handle; the explicit nullable type is required for the `handle = null` ownership transfer below. SafeFileHandle? handle = OpenLockFile(lockPath); try { @@ -70,6 +71,7 @@ internal static bool TryAcquire( while (flock(handle, LockExclusive | LockNonBlocking) != 0) { var error = Marshal.GetLastPInvokeError(); + // ReSharper disable once ConvertIfStatementToSwitchStatement -- errno guard chain inside the retry loop; a switch would obscure the continue/return/throw split. if (error == ErrorInterrupted) { continue; diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs index 63e7de2a9..e3bac0300 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs @@ -150,6 +150,7 @@ public void Dispose() try { + // ReSharper disable once InvertIf -- inverting would early-return out of a multi-stage cleanup and skip the stages below. if (_bound && ownership is not null) { var cleanup = ownership.CleanupStaleSocket(); @@ -261,7 +262,7 @@ or ControlSocketCleanupResult.Removed cts = new CancellationTokenSource(); var token = cts.Token; - acceptLoop = Task.Run(() => AcceptLoopAsync(listener, token)); + acceptLoop = Task.Run(() => AcceptLoopAsync(listener, token), token); // Publish only after bind, chmod, listen, and accept-loop creation succeed. _ownership = ownership; @@ -761,14 +762,11 @@ public Task DispatchStart(Func start) _ = ObserveStartAsync(startTask, startCompletion); // A failure that settled synchronously is known before the response is committed. - if (startTask is { IsCompleted: true, IsCompletedSuccessfully: false }) - { - return Task.FromResult( + return startTask is { IsCompleted: true, IsCompletedSuccessfully: false } + ? Task.FromResult( JsonControlProtocol.SerializeError(JsonControlProtocol.ErrInternal) - ); - } - - return Task.FromResult(JsonControlProtocol.SerializeAction(prev, SnapshotState())); + ) + : Task.FromResult(JsonControlProtocol.SerializeAction(prev, SnapshotState())); } /// diff --git a/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs b/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs index 097c7166d..d14083e7c 100644 --- a/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs +++ b/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs @@ -76,6 +76,8 @@ internal LinuxPreferencesService( public LinuxPreferences Current { get; private set; } = LinuxPreferences.Default; + // ReSharper disable once UnusedMethodReturnValue.Global -- returns Current so callers that reload on demand get the fresh value. + // ReSharper disable once MemberCanBePrivate.Global -- public reload entry point mirroring ISettingsService.Load(); only the constructor calls it in-tree. public LinuxPreferences Load() { if (!File.Exists(_path)) diff --git a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs index b6ac58a30..901c7e8ed 100644 --- a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs +++ b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs @@ -99,6 +99,7 @@ public Task SpeakAsync(TtsSpeakRequest request, Cancellatio var language = NormalizeLanguageHint(request.Language); var args = BuildArguments(command, request.Text, language); + // ReSharper disable once SuggestVarOrType_Elsewhere -- the collection-expression arm has no natural type; `var` would not compile. IReadOnlyList? fallbackArgs = language is not null && args.Count > 1 ? BuildDefaultArguments(command, request.Text) : null; diff --git a/src/TypeWhisper.Linux/Services/ProcessRunner.cs b/src/TypeWhisper.Linux/Services/ProcessRunner.cs index ecbb40245..af913a477 100644 --- a/src/TypeWhisper.Linux/Services/ProcessRunner.cs +++ b/src/TypeWhisper.Linux/Services/ProcessRunner.cs @@ -242,6 +242,7 @@ await Task.WhenAll(stdoutTask, stderrTask) } catch (Exception ex) { + // ReSharper disable once InvertIf -- inverting would duplicate the `return ProcessRunResult.NotStarted(...)` tail. if (ct.IsCancellationRequested) { if (process is not null) diff --git a/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs b/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs index 8955e5cc7..60273b89f 100644 --- a/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs +++ b/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs @@ -256,6 +256,7 @@ private async Task DispatchLoopAsync() } } + // ReSharper disable once InvertIf -- last statement in the loop; inverting into a `continue` would obscure the signal-and-stop intent. if (completed is not null) { completed.TrySetResult(); diff --git a/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs b/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs index 2da404d2c..a1e6e8d51 100644 --- a/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs +++ b/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs @@ -493,6 +493,7 @@ private void ReleasePlaybackOwnership(PlaybackRequest playbackRequest) { lock (_lock) { + // ReSharper disable once InvertIf -- last statement in the lock; inverting would add a return inside the lock. if ( ReferenceEquals(_playbackRequest, playbackRequest) && playbackRequest.Version == Volatile.Read(ref _playbackVersion) diff --git a/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs index 5494694cc..73794a4a2 100644 --- a/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs +++ b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs @@ -524,6 +524,7 @@ internal void RaiseSnapshotChangedForTests(LinuxCapabilitySnapshot snapshot) ); socket .ConnectAsync(new UnixDomainSocketEndPoint(candidate), timeout.Token) + .AsTask() .GetAwaiter() .GetResult(); return candidate; diff --git a/src/TypeWhisper.Linux/Services/TransformSelectionService.cs b/src/TypeWhisper.Linux/Services/TransformSelectionService.cs index 880a203a9..b51d9410d 100644 --- a/src/TypeWhisper.Linux/Services/TransformSelectionService.cs +++ b/src/TypeWhisper.Linux/Services/TransformSelectionService.cs @@ -133,6 +133,7 @@ internal static bool HasSelectionTargetChanged( // Neither side offered a process name. A window id on exactly one side means identity // appeared or vanished between capture and replace — usually the captured window // closing — so treat it as changed rather than replacing into an unconfirmable window. + // ReSharper disable once ConvertIfStatementToReturnStatement -- collapsing to one return would strip the comment explaining the fail-open false branch. if (!string.IsNullOrEmpty(capturedWindowId) || !string.IsNullOrEmpty(currentWindowId)) { return true; diff --git a/src/TypeWhisper.Linux/Services/WatchFolderService.cs b/src/TypeWhisper.Linux/Services/WatchFolderService.cs index 89e864cee..335ee8751 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderService.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderService.cs @@ -210,8 +210,10 @@ private void StartRun( throw; } + // ReSharper disable once MethodSupportsCancellation -- the worker observes run.CancellationSource internally; passing the token to Task.Run would leave a Canceled task for StopCoreAsync to await. var queueWorker = Task.Run(() => ProcessQueueAsync(run)); // Periodic rescan catches files missed when the OS event buffer overflows. + // ReSharper disable once MethodSupportsCancellation -- the worker observes run.CancellationSource internally; passing the token to Task.Run would leave a Canceled task for StopCoreAsync to await. var rescanWorker = Task.Run(() => RescanLoopAsync(run)); run.SetWorkers(queueWorker, rescanWorker); @@ -250,6 +252,7 @@ private async Task StopCoreAsync() run.Watcher.Dispose(); try { + // ReSharper disable once MethodHasAsyncOverload -- CancelAsync would add a yield point between watcher teardown and worker cancellation that a concurrent Start could interleave with. run.CancellationSource.Cancel(); } catch (AggregateException ex) diff --git a/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs b/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs index f0cfe7841..581aa8496 100644 --- a/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs @@ -289,8 +289,15 @@ private static TaskCompletionSource NewGate() } // Test-only: simulates the pathname a crashed process leaves behind. DllImport - // (not LibraryImport) avoids requiring this test project to allow unsafe code. + // (not LibraryImport) avoids requiring unsafe code here, so SYSLIB1054 is declined. + // CA2101 wants Unicode marshaling, wrong for libc — POSIX pathnames are byte + // strings, so LPUTF8Str is the correct encoding. +#pragma warning disable SYSLIB1054, CA2101 // ReSharper disable once InconsistentNaming -- mirrors the native libc function. [DllImport("libc", SetLastError = true, CharSet = CharSet.Ansi)] - private static extern int link(string oldpath, string newpath); + private static extern int link( + [MarshalAs(UnmanagedType.LPUTF8Str)] string oldpath, + [MarshalAs(UnmanagedType.LPUTF8Str)] string newpath + ); +#pragma warning restore SYSLIB1054, CA2101 } diff --git a/tests/TypeWhisper.Linux.Tests/ControlSocketServerTests.cs b/tests/TypeWhisper.Linux.Tests/ControlSocketServerTests.cs index 085b644cc..dd9920a66 100644 --- a/tests/TypeWhisper.Linux.Tests/ControlSocketServerTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ControlSocketServerTests.cs @@ -11,7 +11,7 @@ public sealed class ControlSocketServerTests [Fact] public async Task Delayed_accepted_start_returns_starting_before_start_completes() { - var state = JsonControlProtocol.StateIdle; + const string state = JsonControlProtocol.StateIdle; var startEntered = NewSignal(); var releaseStart = NewSignal(); var coordinator = CreateCoordinator(() => state); @@ -114,7 +114,7 @@ public async Task Status_progresses_from_starting_to_recording_and_clears_after_ [Fact] public async Task Repeated_start_reuses_in_flight_correlation() { - var state = JsonControlProtocol.StateIdle; + const string state = JsonControlProtocol.StateIdle; var releaseStart = NewSignal(); var startInvocations = 0; var coordinator = CreateCoordinator(() => state); diff --git a/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs b/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs index ab4d5c37f..2f3d3de03 100644 --- a/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs @@ -498,6 +498,7 @@ public async Task Dispatcher_control_failure_still_completes_once_without_recurs string outcome ) { + // ReSharper disable once SuggestVarOrType_SimpleTypes -- the "throwing" arm is null, so the explicit nullable type carries nullability `var` would drop. ProcessRunResult? controlResult = outcome switch { "failed" => new ProcessRunResult(false, false, -1, "", "launch failed"), @@ -746,11 +747,7 @@ public static ControlledProcessRunner WithPendingResults(int resultCount) public Task WaitForInvocationAsync(int invocationNumber) { ArgumentOutOfRangeException.ThrowIfLessThan(invocationNumber, 1); - if (invocationNumber > _results.Length) - { - throw new ArgumentOutOfRangeException(nameof(invocationNumber)); - } - + ArgumentOutOfRangeException.ThrowIfGreaterThan(invocationNumber, _results.Length); return _results[invocationNumber - 1].Invoked.Task; } @@ -807,11 +804,7 @@ public async Task RunAsync( private ControlledResult GetResult(int invocationNumber) { ArgumentOutOfRangeException.ThrowIfLessThan(invocationNumber, 1); - if (invocationNumber > _results.Length) - { - throw new ArgumentOutOfRangeException(nameof(invocationNumber)); - } - + ArgumentOutOfRangeException.ThrowIfGreaterThan(invocationNumber, _results.Length); return _results[invocationNumber - 1]; } diff --git a/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs b/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs index eb59f295e..abb6a9515 100644 --- a/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs @@ -477,19 +477,17 @@ public Task RunAsync( ) { Invocations.Add(new Invocation(fileName, args.ToArray(), timeout)); - if (Invocations.Count == 1) + switch (Invocations.Count) { - FirstStarted.TrySetResult(); - return _firstCompletion.Task; + case 1: + FirstStarted.TrySetResult(); + return _firstCompletion.Task; + case 2: + SecondStarted.TrySetResult(); + return _secondCompletion.Task; + default: + return Task.FromResult(Success(41)); } - - if (Invocations.Count == 2) - { - SecondStarted.TrySetResult(); - return _secondCompletion.Task; - } - - return Task.FromResult(Success(41)); } public void CompleteFirst(uint id) diff --git a/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs index 844fbfc63..4775eff69 100644 --- a/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs @@ -14,6 +14,8 @@ namespace TypeWhisper.Linux.Tests; public sealed class SettingsBackupServiceTests : IDisposable { + private static readonly JsonSerializerOptions s_indentedJson = new() { WriteIndented = true }; + private readonly string _tempDir = TestPaths.CreateTempDirectory( "TypeWhisper.SettingsBackupServiceTests" ); @@ -855,10 +857,7 @@ private static void WriteProfiles(string path, params Profile[] profiles) { Write( path, - JsonSerializer.Serialize( - profiles, - new JsonSerializerOptions { WriteIndented = true } - ) + JsonSerializer.Serialize(profiles, s_indentedJson) ); } diff --git a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs index cf0ddd7ee..623e979b7 100644 --- a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs @@ -176,6 +176,7 @@ private static void AssertObservedCue(string source, string methodName, string c { var pattern = $@"public\s+void\s+{Regex.Escape(methodName)}\s*\(\s*\)\s*\{{\s*" + // ReSharper disable once UseRawString -- interpolated regex with `{{` brace escapes; a raw string would need `$$"""` and re-escaping. + $@"Observe\s*\(\s*PlayAsync\s*\(\s*""{Regex.Escape(cueFileName)}""\s*,\s*" + @"s_startCueTimeout\s*\)\s*\)\s*;\s*\}"; diff --git a/tests/TypeWhisper.Linux.Tests/SystemCommandAvailabilityServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SystemCommandAvailabilityServiceTests.cs index 98c80a6fa..93b8074bb 100644 --- a/tests/TypeWhisper.Linux.Tests/SystemCommandAvailabilityServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SystemCommandAvailabilityServiceTests.cs @@ -151,6 +151,7 @@ public void TryPreloadCuda12RuntimeLibraries_PartialLoadRemainsIncompleteAndRetr var calls = new List(); var cublasAttempts = 0; + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- kept adjacent to the call sites and captured state below. (IntPtr Handle, string? Error) LoadLibrary(string path) { calls.Add(path); @@ -208,6 +209,7 @@ public void TryPreloadCuda12RuntimeLibraries_CompleteLoadIsCached() var loadedHandles = new Dictionary(StringComparer.Ordinal); var calls = new List(); + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- kept adjacent to the call sites and captured state below. (IntPtr Handle, string? Error) LoadLibrary(string path) { calls.Add(path); @@ -258,6 +260,7 @@ public void TryPreloadCuda12RuntimeLibraries_FailedLoadReturnsFalseAndReportsErr var loadedHandles = new Dictionary(StringComparer.Ordinal); var calls = new List(); + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- kept adjacent to the call sites and captured state below. (IntPtr Handle, string? Error) LoadLibrary(string path) { calls.Add(path); diff --git a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs index 17b233453..a1f6f911b 100644 --- a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs @@ -1,3 +1,4 @@ +// ReSharper disable MethodSupportsCancellation -- every WaitAsync here uses only a test-guard timeout; there is no ambient cancellation token to pass. // ReSharper disable MethodHasAsyncOverload -- synchronous File.Read/WriteAllText is deliberate in these test assertions; the async overload would only add await noise with no benefit off the hot path. using System.Collections.Concurrent; using TypeWhisper.Linux.Services; @@ -36,7 +37,7 @@ public async Task Start_WhenSourceBasenamesCollide_CommitsDistinctExportsBeforeD File.WriteAllBytes(wavPath, [1, 2, 3]); File.WriteAllBytes(mp3Path, [4, 5, 6]); - using var service = new WatchFolderService(Path.Join(_tempDir, "data")); + await using var service = new WatchFolderService(Path.Join(_tempDir, "data")); var processed = await StartAndWaitForProcessedItemsAsync( service, expectedCount: 2, @@ -75,7 +76,7 @@ public async Task Start_WhenUserExportsExist_PreservesBytesAndAdvancesSuffix() var baseBytes = File.ReadAllBytes(baseOutputPath); var firstSuffixBytes = File.ReadAllBytes(firstSuffixPath); - using var service = new WatchFolderService(Path.Join(_tempDir, "data")); + await using var service = new WatchFolderService(Path.Join(_tempDir, "data")); var processed = await StartAndWaitForProcessedItemsAsync( service, expectedCount: 1, @@ -107,7 +108,7 @@ public async Task Start_WhenExportNameIsOccupiedByDirectory_AdvancesSuffix() File.WriteAllBytes(sourcePath, [1, 2, 3]); Directory.CreateDirectory(Path.Join(outputPath, "meeting.txt")); - using var service = new WatchFolderService(Path.Join(_tempDir, "data")); + await using var service = new WatchFolderService(Path.Join(_tempDir, "data")); var processed = await StartAndWaitForProcessedItemsAsync( service, expectedCount: 1, @@ -222,8 +223,10 @@ public async Task Restart_AfterBoundedDrain_UsesFreshRunAndLeavesOldQueuedWorkRe var retiredWorkersWereIncomplete = false; var waitCallCount = 0; + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- kept adjacent to the call sites and captured state below. Task WaitForWorkers(Task workers, TimeSpan timeout) { + // ReSharper disable once InvertIf -- the first-call special case reads better as the guard. if (Interlocked.Increment(ref waitCallCount) == 1) { retiredWorkers = workers; @@ -249,6 +252,7 @@ Task WaitForWorkers(Task workers, TimeSpan timeout) { var fileName = Path.GetFileName(request.FilePath); oldCalls.Enqueue(fileName); + // ReSharper disable once InvertIf -- inverting would duplicate the `return CreateResult(request)` tail. if (fileName == "a-blocked.wav") { oldEntered.TrySetResult(ct); @@ -380,8 +384,10 @@ public async Task SameFolderRestart_OldCompletionCannotOverlapOrPublishIntoNewRu Task? retiredWorkers = null; var waitCallCount = 0; + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- kept adjacent to the call sites and captured state below. Task WaitForWorkers(Task workers, TimeSpan timeout) { + // ReSharper disable once InvertIf -- the first-call special case reads better as the guard. if (Interlocked.Increment(ref waitCallCount) == 1) { retiredWorkers = workers; @@ -518,6 +524,7 @@ public async Task DisposeAsync_UsesBoundedStopAndPreventsRestart() Task? retiredWorkers = null; TimeSpan? requestedDeadline = null; + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- kept adjacent to the call sites and captured state below. Task WaitForWorkers(Task workers, TimeSpan timeout) { retiredWorkers = workers; From 9d5a19bb8befac73b5b4f7d6853687bf00b58170 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 20 Jul 2026 18:06:04 +0000 Subject: [PATCH 122/226] Make create-new writes atomically exclusive AtomicFileWrite documented that WriteAllBytesCreateNew/WriteAllTextCreateNew throw without changing an existing destination, but the guarantee was not real: File.Move's no-overwrite overload is a check followed by rename(2) on Unix, and rename silently clobbers. An 8-thread race on one destination produced multiple "successful" writers in 2432 of 4000 rounds, each destroying the previous one's content. That is reachable from RecorderFileNamer (two commits landing on the same one-second stem) and WatchFolderService exports, where it means a recording or export is silently lost. Publish with link(2) instead, which fails atomically with EEXIST. Reserving the destination up front was the other option and is worse: it publishes an empty file for the duration of the write, so readers observe a zero-byte destination and a crash leaves the name permanently burned. Once the link succeeds the write is committed, so removing the temporary name is cleanup only - throwing there would report failure for a write that happened, and RecorderFileNamer would retry and publish a duplicate. --- .../Services/AtomicFileWrite.cs | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/TypeWhisper.Core/Services/AtomicFileWrite.cs b/src/TypeWhisper.Core/Services/AtomicFileWrite.cs index bf3aa83a4..83ab66b75 100644 --- a/src/TypeWhisper.Core/Services/AtomicFileWrite.cs +++ b/src/TypeWhisper.Core/Services/AtomicFileWrite.cs @@ -1,3 +1,5 @@ +using System.Runtime.InteropServices; + namespace TypeWhisper.Core.Services; /// @@ -6,6 +8,66 @@ namespace TypeWhisper.Core.Services; /// public static class AtomicFileWrite { + private const int EEXIST = 17; + + // DllImport rather than LibraryImport: the latter's generated marshalling needs + // AllowUnsafeBlocks, which is not worth enabling project-wide for one call. CharSet.Ansi + // marshals as UTF-8 on Unix, which is what libc expects for paths. + [DllImport("libc", EntryPoint = "link", SetLastError = true, CharSet = CharSet.Ansi)] + private static extern int Link(string oldPath, string newPath); + + /// + /// Publishes a fully-written temporary file to without ever + /// replacing an existing destination, so the destination goes straight from absent to + /// complete. + /// + /// cannot do this on Unix: its no-overwrite + /// guarantee is a check followed by rename(2), which silently clobbers, so + /// concurrent callers all "succeed" and each destroys the previous one's content. + /// link(2) fails with EEXIST instead, atomically, which is exactly the + /// documented contract. Reserving the destination up front is not an option either — + /// that publishes an empty file for the duration of the write. + /// + /// + private static void PublishCreateNew(string tempPath, string path) + { + if (OperatingSystem.IsWindows()) + { + // MoveFileEx without MOVEFILE_REPLACE_EXISTING already fails when the destination + // exists, so the framework call is atomic here. + File.Move(tempPath, path); + return; + } + + if (Link(tempPath, path) == 0) + { + // Committed: the destination now names this content and was never visible in a + // partial state. Dropping the temporary name is cleanup only — throwing here would + // report failure for a write that succeeded, and callers that retry on IOException + // (RecorderFileNamer) would then publish a duplicate under the next free name. + try + { + File.Delete(tempPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort: an extra hard link is harmless, the content is already published. + } + + return; + } + + if (Marshal.GetLastPInvokeError() == EEXIST) + { + throw new IOException($"The file '{path}' already exists."); + } + + // Filesystems without hard-link support (some FUSE/exFAT mounts) report EPERM/EXDEV/ + // ENOSYS. Fall back to the framework move: weaker under concurrency, but the alternative + // is failing the write outright on those mounts. + File.Move(tempPath, path); + } + public static void WriteAllText(string path, string contents) { WriteCore(path, replaceExisting: true, tempPath => File.WriteAllText(tempPath, contents)); @@ -87,7 +149,7 @@ Action writeTemporaryFile } else { - File.Move(tempPath, path); + PublishCreateNew(tempPath, path); } } catch From 66c5c132b84488afe01445e0a047a573b84aed9c Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 20 Jul 2026 18:06:14 +0000 Subject: [PATCH 123/226] Store recordings in an owner-only directory The audio directory was created at the umask default (typically 0755), and AtomicFileWrite deliberately leaves new files umask-governed so exports stay readable to their consumers. Dictation recordings and their transcript sidecars were therefore readable by other local users on a shared host. Create it at 0700 rather than creating then chmodding, so a fresh install is never briefly group-readable, and still tighten it explicitly for directories left at 0755 by earlier versions. Creation failures stay fatal as before - only the hardening degrades. Verify the mode took: chmod is a silent no-op on mounts that carry no Unix modes (exFAT/NTFS), which is exactly the case where recordings stay exposed. EnsureDirectories runs before the boot trace exists and a desktop-entry launch shows no terminal, so the outcome is also recorded in the error log, which the About screen shows and bug-report exports include. That log is a bounded ring persisted across launches, so the entry is written once rather than on every startup. --- .../TypeWhisperEnvironment.cs | 74 ++++++++++++++++++- src/TypeWhisper.Linux/Program.cs | 10 +++ src/TypeWhisper.Linux/ServiceRegistrations.cs | 23 +++++- 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/TypeWhisper.Core/TypeWhisperEnvironment.cs b/src/TypeWhisper.Core/TypeWhisperEnvironment.cs index b47ca930d..21ec08723 100644 --- a/src/TypeWhisper.Core/TypeWhisperEnvironment.cs +++ b/src/TypeWhisper.Core/TypeWhisperEnvironment.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace TypeWhisper.Core; public static class TypeWhisperEnvironment @@ -15,14 +17,84 @@ public static class TypeWhisperEnvironment public static string PluginDataPath => Path.Join(BasePath, "PluginData"); public static string SettingsFilePath => Path.Join(BasePath, "settings.json"); + private const UnixFileMode DirMode0700 = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + /// + /// Whether is owner-only after the last . + /// False means recordings written there may be readable by other local users — the mount + /// ignored the mode (exFAT/NTFS) or the chmod failed. Surfaced at startup rather than made + /// fatal: refusing to run would strand users whose data directory lives on such a mount. + /// + public static bool AudioDirectoryIsOwnerOnly { get; private set; } = true; + public static void EnsureDirectories() { Directory.CreateDirectory(BasePath); Directory.CreateDirectory(ModelsPath); Directory.CreateDirectory(DataPath); Directory.CreateDirectory(LogsPath); - Directory.CreateDirectory(AudioPath); Directory.CreateDirectory(PluginsPath); Directory.CreateDirectory(PluginDataPath); + + // Recordings and their transcript sidecars are raw captures of the user's speech, so the + // directory is owner-only. Created at 0700 rather than created-then-chmodded so a fresh + // install is never briefly group/other-readable. Creation failures stay fatal like every + // other directory above: without this directory dictation and recording cannot work, so + // continuing would only defer the same failure to the first save. + if (OperatingSystem.IsWindows()) + { + Directory.CreateDirectory(AudioPath); + } + else + { + Directory.CreateDirectory(AudioPath, DirMode0700); + } + + // Only the hardening itself degrades to a warning — it covers directories left at 0755 by + // earlier versions, and mounts that cannot carry the mode at all. Files stay umask-governed + // by design (see AtomicFileWrite); the directory is the boundary that closes this. + AudioDirectoryIsOwnerOnly = TryMakeOwnerOnly(AudioPath); + } + + /// + /// Tightens an existing directory to 0700 and confirms it took. Returns false when + /// the owner-only boundary could not be established, so a caller can surface it. Never + /// throws: a mount that ignores modes must not stop the app from starting, but it must not + /// pass silently either. + /// + private static bool TryMakeOwnerOnly(string path) + { + if (OperatingSystem.IsWindows()) + { + return true; + } + + try + { + File.SetUnixFileMode(path, DirMode0700); + + // Verify rather than trust: the chmod can be a silent no-op on filesystems that do + // not carry Unix modes (a mounted exFAT/NTFS recordings folder), and that is exactly + // the case where the recordings stay readable to everyone. + const UnixFileMode exposed = + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + if ((File.GetUnixFileMode(path) & exposed) == 0) + { + return true; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Trace.WriteLine($"[TypeWhisperEnvironment] Could not secure '{path}': {ex.Message}"); + return false; + } + + Trace.WriteLine( + $"[TypeWhisperEnvironment] '{path}' is not owner-only; recordings stored there may be " + + "readable by other local users." + ); + return false; } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Program.cs b/src/TypeWhisper.Linux/Program.cs index c4e303d9f..42451aa3b 100644 --- a/src/TypeWhisper.Linux/Program.cs +++ b/src/TypeWhisper.Linux/Program.cs @@ -30,6 +30,16 @@ public static int Main(string[] args) BootTrace.Initialize(); BootTrace.Stage("EnsureDirectories"); + // EnsureDirectories runs before the boot trace exists, so re-report the one outcome that + // is a privacy boundary rather than a startup detail. + if (!TypeWhisperEnvironment.AudioDirectoryIsOwnerOnly) + { + BootTrace.Stage( + $"WARNING: '{TypeWhisperEnvironment.AudioPath}' is not owner-only; " + + "recordings stored there may be readable by other local users" + ); + } + // GNOME launches menu apps at nice 6 / ionice idle, which throttles cold start ~60× // for a CPU+IO-heavy .NET app. Restore defaults so menu launch matches terminal launch. var priorityResult = ProcessPriority.ResetToDefaults(); diff --git a/src/TypeWhisper.Linux/ServiceRegistrations.cs b/src/TypeWhisper.Linux/ServiceRegistrations.cs index 89200217b..80644091e 100644 --- a/src/TypeWhisper.Linux/ServiceRegistrations.cs +++ b/src/TypeWhisper.Linux/ServiceRegistrations.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using TypeWhisper.Core; using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; using TypeWhisper.Core.Services; using TypeWhisper.Linux.Services; using TypeWhisper.Linux.Services.ActiveWindow; @@ -31,7 +32,27 @@ public static void Register(IServiceCollection services) services.AddSingleton( new SettingsService(TypeWhisperEnvironment.SettingsFilePath) ); - services.AddSingleton(new ErrorLogService(dataPath)); + var errorLog = new ErrorLogService(dataPath); + // EnsureDirectories runs before any of this exists and can only write to the boot log, + // which a desktop-entry launch never shows. Repeat it here so the About screen and any + // exported diagnostics carry it too. + if (!TypeWhisperEnvironment.AudioDirectoryIsOwnerOnly) + { + var warning = + $"Recordings folder '{TypeWhisperEnvironment.AudioPath}' could not be made " + + "owner-only; recordings saved there may be readable by other users of this " + + "machine."; + + // The condition is a persistent property of the mount, not a one-off event, and the + // log is a bounded ring persisted across launches — appending every startup would + // evict real failures. One standing entry says the same thing. + if (!errorLog.Entries.Any(e => e.Message == warning)) + { + errorLog.AddEntry(warning, ErrorCategory.Recording); + } + } + + services.AddSingleton(errorLog); services.AddSingleton( new HistoryService( Path.Join(dataPath, "history.json"), From f6b570b0deeeb1fda9cfb8b96311f13126df5c88 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 20 Jul 2026 18:06:21 +0000 Subject: [PATCH 124/226] Give Google Cloud STT requests headroom beyond the audio limit The HttpClient timeout was 60s, the same value as MaxSyncSeconds. Those bound different things: MaxSyncSeconds caps the audio duration, while the timeout covers the whole round trip. A maximum-length clip therefore had no headroom at all - 60s of LINEAR16 is ~1.9 MB of PCM, ~2.6 MB once base64'd inline into the JSON body, which alone can take tens of seconds on a weak uplink before Google does any work. Raise it to 120s, matching the other cloud STT plugins here; Google was the outlier at 60 despite carrying the largest inline payload of the set. --- .../GoogleCloudSttPlugin.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs index d59c29354..5c78312ec 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs @@ -32,9 +32,15 @@ public sealed partial class GoogleCloudSttPlugin public GoogleCloudSttPlugin() : this(new HttpClientHandler()) { } + // Budget for the whole round trip — base64 PCM upload, Google's recognition, response read — + // not for the audio length. Matching MaxSyncSeconds left a near-limit clip zero headroom: 60s + // of LINEAR16 is ~1.9 MB PCM, ~2.6 MB once base64'd inline into the JSON body, which alone can + // take tens of seconds on a weak uplink. 120s matches the other cloud STT plugins here. + private static readonly TimeSpan s_requestTimeout = TimeSpan.FromSeconds(120); + // Test seam: lets a stub handler answer requests without hitting the network. internal GoogleCloudSttPlugin(HttpMessageHandler handler) => - _httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) }; + _httpClient = new HttpClient(handler) { Timeout = s_requestTimeout }; public string PluginId => "com.typewhisper.google-cloud-stt"; public string PluginName => "Google Cloud STT"; From e7eb1dc06d4450393e545b69ede7798262569473 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 20 Jul 2026 20:59:35 +0000 Subject: [PATCH 125/226] Tighten comments on the recording-safety changes Trim the explanatory comments added by the three preceding commits to the repo's norm: drop re-explanations of what the code already shows and details duplicated elsewhere in the file, keeping the why. Comment-only, no behavior change. --- .../GoogleCloudSttPlugin.cs | 7 ++-- .../Services/AtomicFileWrite.cs | 21 ++++++------ .../TypeWhisperEnvironment.cs | 32 ++++++++----------- src/TypeWhisper.Linux/Program.cs | 4 +-- src/TypeWhisper.Linux/ServiceRegistrations.cs | 10 +++--- 5 files changed, 32 insertions(+), 42 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs index 5c78312ec..76538f193 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs @@ -32,10 +32,9 @@ public sealed partial class GoogleCloudSttPlugin public GoogleCloudSttPlugin() : this(new HttpClientHandler()) { } - // Budget for the whole round trip — base64 PCM upload, Google's recognition, response read — - // not for the audio length. Matching MaxSyncSeconds left a near-limit clip zero headroom: 60s - // of LINEAR16 is ~1.9 MB PCM, ~2.6 MB once base64'd inline into the JSON body, which alone can - // take tens of seconds on a weak uplink. 120s matches the other cloud STT plugins here. + // Bounds the whole round trip, not the audio length — that is MaxSyncSeconds. Matching the two + // left a max-length clip no headroom for its ~2.6 MB base64 upload; 120s matches the other + // cloud STT plugins here. private static readonly TimeSpan s_requestTimeout = TimeSpan.FromSeconds(120); // Test seam: lets a stub handler answer requests without hitting the network. diff --git a/src/TypeWhisper.Core/Services/AtomicFileWrite.cs b/src/TypeWhisper.Core/Services/AtomicFileWrite.cs index 83ab66b75..c9701f2d6 100644 --- a/src/TypeWhisper.Core/Services/AtomicFileWrite.cs +++ b/src/TypeWhisper.Core/Services/AtomicFileWrite.cs @@ -17,34 +17,31 @@ public static class AtomicFileWrite private static extern int Link(string oldPath, string newPath); /// - /// Publishes a fully-written temporary file to without ever - /// replacing an existing destination, so the destination goes straight from absent to - /// complete. + /// Publishes a fully-written temporary file to , which goes + /// straight from absent to complete. /// /// cannot do this on Unix: its no-overwrite /// guarantee is a check followed by rename(2), which silently clobbers, so /// concurrent callers all "succeed" and each destroys the previous one's content. - /// link(2) fails with EEXIST instead, atomically, which is exactly the - /// documented contract. Reserving the destination up front is not an option either — - /// that publishes an empty file for the duration of the write. + /// link(2) fails with EEXIST atomically instead. Reserving the + /// destination up front is not an option either — that publishes an empty file for + /// the duration of the write. /// /// private static void PublishCreateNew(string tempPath, string path) { if (OperatingSystem.IsWindows()) { - // MoveFileEx without MOVEFILE_REPLACE_EXISTING already fails when the destination - // exists, so the framework call is atomic here. + // MoveFileEx without MOVEFILE_REPLACE_EXISTING already fails atomically here. File.Move(tempPath, path); return; } if (Link(tempPath, path) == 0) { - // Committed: the destination now names this content and was never visible in a - // partial state. Dropping the temporary name is cleanup only — throwing here would - // report failure for a write that succeeded, and callers that retry on IOException - // (RecorderFileNamer) would then publish a duplicate under the next free name. + // Already committed, so dropping the temporary name is cleanup only: throwing here + // would report failure for a write that succeeded, and callers that retry on + // IOException (RecorderFileNamer) would publish a duplicate. try { File.Delete(tempPath); diff --git a/src/TypeWhisper.Core/TypeWhisperEnvironment.cs b/src/TypeWhisper.Core/TypeWhisperEnvironment.cs index 21ec08723..38d506b92 100644 --- a/src/TypeWhisper.Core/TypeWhisperEnvironment.cs +++ b/src/TypeWhisper.Core/TypeWhisperEnvironment.cs @@ -22,9 +22,9 @@ public static class TypeWhisperEnvironment /// /// Whether is owner-only after the last . - /// False means recordings written there may be readable by other local users — the mount - /// ignored the mode (exFAT/NTFS) or the chmod failed. Surfaced at startup rather than made - /// fatal: refusing to run would strand users whose data directory lives on such a mount. + /// False means recordings there may be readable by other local users. Surfaced rather than + /// made fatal: refusing to run would strand users whose data directory is on a mount that + /// carries no Unix modes. /// public static bool AudioDirectoryIsOwnerOnly { get; private set; } = true; @@ -37,11 +37,9 @@ public static void EnsureDirectories() Directory.CreateDirectory(PluginsPath); Directory.CreateDirectory(PluginDataPath); - // Recordings and their transcript sidecars are raw captures of the user's speech, so the - // directory is owner-only. Created at 0700 rather than created-then-chmodded so a fresh - // install is never briefly group/other-readable. Creation failures stay fatal like every - // other directory above: without this directory dictation and recording cannot work, so - // continuing would only defer the same failure to the first save. + // Recordings and their transcript sidecars are raw captures of the user's speech, so this + // one is owner-only. Created at 0700 rather than created-then-chmodded so a fresh install + // is never briefly group-readable. Creation failures stay fatal like the directories above. if (OperatingSystem.IsWindows()) { Directory.CreateDirectory(AudioPath); @@ -51,17 +49,16 @@ public static void EnsureDirectories() Directory.CreateDirectory(AudioPath, DirMode0700); } - // Only the hardening itself degrades to a warning — it covers directories left at 0755 by - // earlier versions, and mounts that cannot carry the mode at all. Files stay umask-governed - // by design (see AtomicFileWrite); the directory is the boundary that closes this. + // Only the hardening degrades to a warning; it also tightens directories left at 0755 by + // earlier versions. Files stay umask-governed by design (see AtomicFileWrite) — the + // directory is the boundary that closes this. AudioDirectoryIsOwnerOnly = TryMakeOwnerOnly(AudioPath); } /// - /// Tightens an existing directory to 0700 and confirms it took. Returns false when - /// the owner-only boundary could not be established, so a caller can surface it. Never - /// throws: a mount that ignores modes must not stop the app from starting, but it must not - /// pass silently either. + /// Tightens a directory to 0700 and confirms it took, returning false when the + /// owner-only boundary could not be established. Never throws: a mount that ignores modes + /// must not stop startup, but it must not pass silently either. /// private static bool TryMakeOwnerOnly(string path) { @@ -74,9 +71,8 @@ private static bool TryMakeOwnerOnly(string path) { File.SetUnixFileMode(path, DirMode0700); - // Verify rather than trust: the chmod can be a silent no-op on filesystems that do - // not carry Unix modes (a mounted exFAT/NTFS recordings folder), and that is exactly - // the case where the recordings stay readable to everyone. + // Verify rather than trust: chmod is a silent no-op on filesystems carrying no Unix + // modes (exFAT/NTFS), which is exactly when recordings stay readable to everyone. const UnixFileMode exposed = UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; diff --git a/src/TypeWhisper.Linux/Program.cs b/src/TypeWhisper.Linux/Program.cs index 42451aa3b..7f9141f55 100644 --- a/src/TypeWhisper.Linux/Program.cs +++ b/src/TypeWhisper.Linux/Program.cs @@ -30,8 +30,8 @@ public static int Main(string[] args) BootTrace.Initialize(); BootTrace.Stage("EnsureDirectories"); - // EnsureDirectories runs before the boot trace exists, so re-report the one outcome that - // is a privacy boundary rather than a startup detail. + // EnsureDirectories runs before the boot trace exists, so re-report its one privacy- + // relevant outcome here. if (!TypeWhisperEnvironment.AudioDirectoryIsOwnerOnly) { BootTrace.Stage( diff --git a/src/TypeWhisper.Linux/ServiceRegistrations.cs b/src/TypeWhisper.Linux/ServiceRegistrations.cs index 80644091e..c493d597c 100644 --- a/src/TypeWhisper.Linux/ServiceRegistrations.cs +++ b/src/TypeWhisper.Linux/ServiceRegistrations.cs @@ -33,9 +33,8 @@ public static void Register(IServiceCollection services) new SettingsService(TypeWhisperEnvironment.SettingsFilePath) ); var errorLog = new ErrorLogService(dataPath); - // EnsureDirectories runs before any of this exists and can only write to the boot log, - // which a desktop-entry launch never shows. Repeat it here so the About screen and any - // exported diagnostics carry it too. + // EnsureDirectories can only reach the boot log, which a desktop-entry launch never shows. + // Repeat it here so the About screen and exported diagnostics carry it too. if (!TypeWhisperEnvironment.AudioDirectoryIsOwnerOnly) { var warning = @@ -43,9 +42,8 @@ public static void Register(IServiceCollection services) + "owner-only; recordings saved there may be readable by other users of this " + "machine."; - // The condition is a persistent property of the mount, not a one-off event, and the - // log is a bounded ring persisted across launches — appending every startup would - // evict real failures. One standing entry says the same thing. + // A standing property of the mount, not an event, and the log is a bounded ring + // persisted across launches — appending every startup would evict real failures. if (!errorLog.Entries.Any(e => e.Message == warning)) { errorLog.AddEntry(warning, ErrorCategory.Recording); From cf6f0226d213a1b7296718db9608e3fe5a7204c4 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 21 Jul 2026 11:58:36 +0000 Subject: [PATCH 126/226] Clear ~68% of ReSharper HINT-level findings across the plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps the pre-existing HINT-tier ReSharper findings in the plugin projects (the app code was already clean). All behaviour-preserving and build/test verified — full suite green (2456 tests), no regressions. Applied: - Removed redundant using directives. - Configured .editorconfig for the codebase's trailing-comma style, then added the missing commas ReSharper then wanted. - Renamed private/internal static fields to the s_ prefix the repo's .editorconfig naming rule requires (two test-visible internal fields kept PascalCase and suppressed, since s_ misrepresents an externally-referenced field). - Removed redundant `partial` on single-part classes. - Ran ReSharper's own cleanup engine (custom no-reformat profile) for the auto-property, redundancy, and object-creation-expression fixes. - Switched object lock fields to System.Threading.Lock per the editorconfig preference. - Applied the trivially-safe guard-clause InvertIf inversions. Suppressed with reason (false positives / not worth the risk): - The .Global unused/visibility family on plugins and the PluginSDK contracts: plugins are reflection-loaded and their members invoked through interfaces, so the analyzer cannot see the real consumers. - The remaining InvertIf suggestions: subjective nesting-style changes whose hand-applied negations the compiler cannot verify. Still outstanding (not yet addressed): the analyzer (CAxxxx/SYSLIBxxxx), nullable-contract, if-to-switch, and assorted single-instance findings. --- .editorconfig | 4 + plugins/Shared/Cuda/CudaRuntimeProvisioner.cs | 38 ++-- .../AssemblyAiPlugin.cs | 55 ++--- .../AssemblyAiStreamingSession.cs | 1 - .../CerebrasPlugin.cs | 30 +-- .../TypeWhisper.Plugin.Claude/ClaudePlugin.cs | 32 +-- .../CloudflareAsrPlugin.cs | 25 ++- .../TypeWhisper.Plugin.Cohere/CoherePlugin.cs | 10 +- .../DeepgramPlugin.cs | 53 ++--- .../DeepgramStreamingSession.cs | 5 +- .../ElevenLabsPlugin.cs | 63 +++--- .../ElevenLabsStreamingSession.cs | 4 + .../FileMemoryPlugin.cs | 9 +- .../FireworksPlugin.cs | 10 +- .../TypeWhisper.Plugin.Gemini/GeminiPlugin.cs | 40 ++-- .../GemmaLocalPlugin.cs | 69 +++--- .../TypeWhisper.Plugin.Gladia/GladiaPlugin.cs | 24 +- .../GladiaStreamingSession.cs | 2 - .../GoogleCloudSttPlugin.cs | 44 ++-- plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs | 95 ++++---- .../TypeWhisper.Plugin.Linear/LinearPlugin.cs | 107 ++++----- .../ObsidianPlugin.cs | 57 +++-- .../OpenAiChatGptClient.cs | 11 +- .../OpenAiFetchedModel.cs | 5 + .../TypeWhisper.Plugin.OpenAi/OpenAiJson.cs | 9 +- .../OpenAiOAuthSupport.cs | 14 +- .../TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs | 212 +++++++++--------- .../OpenAiRealtimeStreamingSession.cs | 20 +- .../OpenAiResponsesClient.cs | 8 +- .../OpenAiTtsSupport.cs | 6 +- .../OpenAiCompatiblePlugin.cs | 125 ++++++----- .../OpenAiVectorMemoryPlugin.cs | 14 +- .../OpenRouterPlugin.cs | 140 ++++++------ .../Qwen3SttPlugin.cs | 23 +- .../Reson8CustomModel.cs | 4 + .../TypeWhisper.Plugin.Reson8/Reson8Plugin.cs | 101 +++++---- .../Reson8StreamingSession.cs | 7 +- .../TypeWhisper.Plugin.Script/ScriptPlugin.cs | 46 ++-- .../SherpaCudaRuntimeInstaller.cs | 10 +- .../SherpaOnnxNativeRuntime.cs | 11 +- .../SherpaOnnxPlugin.cs | 73 +++--- .../SmallestAiPlugin.cs | 48 ++-- .../SmallestAiStreamingSession.cs | 5 +- .../TypeWhisper.Plugin.Soniox/SonioxPlugin.cs | 45 ++-- .../SonioxStreamingSession.cs | 6 +- .../SpeechmaticsPlugin.cs | 26 ++- .../SpeechmaticsStreamingSession.cs | 3 +- .../SupertonicAssetManager.cs | 3 +- .../SupertonicOnnxSynthesizer.cs | 1 - .../SupertonicPaths.cs | 1 - .../SupertonicTextProcessor.cs | 3 +- .../SupertonicTtsPlayback.cs | 2 +- .../SupertonicTtsPlugin.cs | 41 ++-- .../SupertonicVoiceStyle.cs | 1 - .../VoxtralPlugin.cs | 38 ++-- .../WebhookPlugin.cs | 29 ++- .../WhisperCppPlugin.cs | 57 ++--- .../WhisperCudaRuntimeInstaller.cs | 19 +- plugins/TypeWhisper.Plugin.Xai/XaiJson.cs | 9 +- plugins/TypeWhisper.Plugin.Xai/XaiPlugin.cs | 138 ++++++------ .../XaiResponsesClient.cs | 6 +- .../XaiStreamingSession.cs | 7 +- .../TypeWhisper.Plugin.Xai/XaiTtsSupport.cs | 2 +- .../Services/AtomicFileWrite.cs | 12 +- src/TypeWhisper.Core/TypeWhisper.Core.csproj | 2 + src/TypeWhisper.Linux/ServiceRegistrations.cs | 2 +- .../IPluginSettingsActivity.cs | 4 + .../IPluginSettingsProvider.cs | 6 +- .../ITranscriptionEnginePlugin.cs | 4 + .../ITtsProviderPlugin.cs | 4 + 70 files changed, 1163 insertions(+), 977 deletions(-) diff --git a/.editorconfig b/.editorconfig index 551d0b0bb..6d17702cd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,6 +3,10 @@ [*.cs] +# The codebase deliberately uses trailing commas in multiline lists (cleaner diffs); +# tell ReSharper that is the intended style so it stops flagging them for removal. +resharper_csharp_trailing_comma_in_multiline_lists = true + # IDE0066: Convert switch statement to expression dotnet_diagnostic.ide0066.severity = none diff --git a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs index c3c15965a..428b4c6b1 100644 --- a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs +++ b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs @@ -1,6 +1,10 @@ -using System.IO; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.IO.Compression; -using System.Net.Http; using System.Runtime.InteropServices; using System.Text.Json; using TypeWhisper.Plugins.Shared.Net; @@ -21,7 +25,7 @@ public enum CudaRuntimeProfile /// (cudart, cuBLAS/cuBLASLt, cuFFT, cuRAND, cuDNN). Required by /// sherpa-onnx's GPU build. /// - OnnxRuntimeCuda + OnnxRuntimeCuda, } /// @@ -55,34 +59,34 @@ public class CudaRuntimeProvisioner // already satisfies the wheel and (b) dlopen RTLD_GLOBAL so the no-rpath ORT // CUDA provider resolves their symbols. We deliberately do NOT enumerate every // companion .so a wheel ships — extraction pulls them all out flat, and they - // resolve via the libraries' $ORIGIN runpath (see Cudnn below). Listing exact + // resolve via the libraries' $ORIGIN runpath (see s_cudnn below). Listing exact // companions would couple us to a wheel's internal layout, which varies by // version (e.g. cuDNN 9.x adds/removes engine sub-libs). - private static readonly CudaWheel CudaRuntime = new( + private static readonly CudaWheel s_cudaRuntime = new( "nvidia-cuda-runtime-cu12", "12.9.79", RequiredSonames: ["libcudart.so.12"] ); - private static readonly CudaWheel Cublas = new( + private static readonly CudaWheel s_cublas = new( "nvidia-cublas-cu12", "12.9.2.10", RequiredSonames: ["libcublasLt.so.12", "libcublas.so.12"] ); - private static readonly CudaWheel Cufft = new( + private static readonly CudaWheel s_cufft = new( "nvidia-cufft-cu12", "11.4.1.4", RequiredSonames: ["libcufft.so.11"] ); - private static readonly CudaWheel Curand = new( + private static readonly CudaWheel s_curand = new( "nvidia-curand-cu12", "10.3.10.19", RequiredSonames: ["libcurand.so.10"] ); - private static readonly CudaWheel Cudnn = new( + private static readonly CudaWheel s_cudnn = new( "nvidia-cudnn-cu12", "9.22.0.52", // Only the dispatcher is required. It dlopens its engine sub-libraries @@ -102,16 +106,16 @@ public class CudaRuntimeProvisioner // Pinned to the CUDA 12.9.1 nvrtc that pairs with cudart 12.9.79; its // libnvrtc-builtins companion comes along in the flat extraction and resolves via // $ORIGIN. Only sherpa-onnx's ORT/cuDNN path needs this, not whisper.cpp. - private static readonly CudaWheel Nvrtc = new( + private static readonly CudaWheel s_nvrtc = new( "nvidia-cuda-nvrtc-cu12", "12.9.86", RequiredSonames: ["libnvrtc.so.12"] ); - private static readonly CudaWheel[] WhisperWheels = [CudaRuntime, Cublas]; + private static readonly CudaWheel[] s_whisperWheels = [s_cudaRuntime, s_cublas]; - private static readonly CudaWheel[] OnnxRuntimeWheels = - [CudaRuntime, Cublas, Cufft, Curand, Nvrtc, Cudnn]; + private static readonly CudaWheel[] s_onnxRuntimeWheels = + [s_cudaRuntime, s_cublas, s_cufft, s_curand, s_nvrtc, s_cudnn]; private static readonly string[] s_systemLibraryDirectories = BuildSystemLibraryDirectories(); @@ -124,7 +128,7 @@ private static string[] BuildSystemLibraryDirectories() var dirs = new List { "/usr/local/cuda/lib64", - "/usr/local/cuda/targets/x86_64-linux/lib" + "/usr/local/cuda/targets/x86_64-linux/lib", }; foreach (var minor in new[] { "9", "8", "7", "6", "5", "4", "3", "2", "1", "0" }) { @@ -138,7 +142,7 @@ private static string[] BuildSystemLibraryDirectories() private readonly HttpClient _httpClient; private readonly Action? _log; private readonly SemaphoreSlim _gate = new(1, 1); - private readonly object _preloadSync = new(); + private readonly Lock _preloadSync = new(); private readonly HashSet _preloaded = new(StringComparer.Ordinal); public CudaRuntimeProvisioner(string cacheRoot, HttpClient httpClient, Action? log = null) @@ -165,7 +169,7 @@ public static string DefaultCacheRoot() => ); private static CudaWheel[] WheelsFor(CudaRuntimeProfile profile) => - profile == CudaRuntimeProfile.WhisperCublas ? WhisperWheels : OnnxRuntimeWheels; + profile == CudaRuntimeProfile.WhisperCublas ? s_whisperWheels : s_onnxRuntimeWheels; /// /// True when every CUDA library the needs is @@ -651,7 +655,7 @@ private static bool LdConfigContains(string soname) RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); diff --git a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs index 050419bd7..9c1750b94 100644 --- a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs @@ -1,4 +1,9 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// ReSharper disable UnusedType.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -7,16 +12,14 @@ namespace TypeWhisper.Plugin.AssemblyAi; -public sealed partial class AssemblyAiPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class AssemblyAiPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware { private const string BaseUrl = "https://api.assemblyai.com"; private readonly HttpClient _httpClient = new(); private IPluginHostServices? _host; - private string? _apiKey; - private string? _selectedModelId; - private static readonly IReadOnlyList Models = + private static readonly IReadOnlyList s_models = [ new("universal-3-pro", "Universal-3 Pro"), new("universal-2", "Universal-2"), @@ -29,8 +32,8 @@ public sealed partial class AssemblyAiPlugin : ITranscriptionEnginePlugin, IPlug public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = await host.LoadSecretAsync("api-key"); - _selectedModelId = host.GetSetting("selectedModel") ?? Models[0].Id; + ApiKey = await host.LoadSecretAsync("api-key"); + SelectedModelId = host.GetSetting("selectedModel") ?? s_models[0].Id; host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); } @@ -42,11 +45,11 @@ public Task DeactivateAsync() public string ProviderId => "assemblyai"; public string ProviderDisplayName => "AssemblyAI"; - public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); + public bool IsConfigured => !string.IsNullOrEmpty(ApiKey); - public IReadOnlyList TranscriptionModels => Models; + public IReadOnlyList TranscriptionModels => s_models; - public string? SelectedModelId => _selectedModelId; + public string? SelectedModelId { get; private set; } public bool SupportsTranslation => false; public bool SupportsStreaming => true; @@ -55,14 +58,14 @@ public async Task StartStreamingAsync(string? language, Cance { if (!IsConfigured) throw new InvalidOperationException(Loc.L("Settings.NotConfiguredApiKeyRequired")); - return await AssemblyAiStreamingSession.ConnectAsync(_apiKey!, language, ct); + return await AssemblyAiStreamingSession.ConnectAsync(ApiKey!, language, ct); } public void SelectModel(string modelId) { - if (Models.All(m => m.Id != modelId)) + if (s_models.All(m => m.Id != modelId)) throw new ArgumentException($"Unknown model: {modelId}"); - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); } @@ -74,7 +77,7 @@ public async Task TranscribeAsync( CancellationToken ct ) { - if (!IsConfigured || _selectedModelId is null) + if (!IsConfigured || SelectedModelId is null) throw new InvalidOperationException( "Plugin not configured. API key and model required." ); @@ -87,7 +90,7 @@ CancellationToken ct private async Task UploadAudioAsync(byte[] wavAudio, CancellationToken ct) { using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v2/upload"); - request.Headers.Add("Authorization", _apiKey); + request.Headers.Add("Authorization", ApiKey); request.Content = new ByteArrayContent(wavAudio); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); @@ -113,7 +116,7 @@ CancellationToken ct var body = new Dictionary { ["audio_url"] = audioUrl, - ["speech_models"] = new[] { _selectedModelId! }, + ["speech_models"] = new[] { SelectedModelId! }, }; if (string.IsNullOrEmpty(language) || language == "auto") @@ -122,7 +125,7 @@ CancellationToken ct body["language_code"] = language; using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v2/transcript"); - request.Headers.Add("Authorization", _apiKey); + request.Headers.Add("Authorization", ApiKey); request.Content = new StringContent( JsonSerializer.Serialize(body), Encoding.UTF8, @@ -156,7 +159,7 @@ CancellationToken ct HttpMethod.Get, $"{BaseUrl}/v2/transcript/{transcriptId}" ); - request.Headers.Add("Authorization", _apiKey); + request.Headers.Add("Authorization", ApiKey); var response = await _httpClient.SendAsync(request, ct); var json = await response.Content.ReadAsStringAsync(ct); @@ -178,6 +181,7 @@ CancellationToken ct throw new InvalidOperationException($"AssemblyAI transcription failed: {error}"); } + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (status == "completed") { var text = root.GetProperty("text").GetString() ?? ""; @@ -199,7 +203,8 @@ CancellationToken ct throw new TimeoutException("AssemblyAI transcription timed out after 5 minutes"); } - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -212,7 +217,7 @@ public void SetLocalization(IPluginLocalization localization) => internal async Task SetApiKeyAsync(string apiKey) { - _apiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey; + ApiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey; if (_host is not null) { if (string.IsNullOrWhiteSpace(apiKey)) @@ -260,7 +265,7 @@ public IReadOnlyList GetSettingDefinitions() => "selectedModel", Loc.L("Settings.TranscriptionModel"), Description: Loc.L("Settings.ModelDescription"), - Options: Models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList() + Options: s_models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList() ), ]; @@ -268,8 +273,8 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "api-key" => ApiKey, + "selectedModel" => SelectedModelId, _ => null, } ); @@ -294,10 +299,10 @@ public async Task SetSettingValueAsync( public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); - var valid = await ValidateApiKeyAsync(_apiKey, ct); + var valid = await ValidateApiKeyAsync(ApiKey, ct); return valid ? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid")) : new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid")); diff --git a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs index 15e472bac..57d187e2f 100644 --- a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs @@ -1,4 +1,3 @@ -using System.IO; using System.Net.WebSockets; using System.Text; using System.Text.Json; diff --git a/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs b/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs index 3c7ceb762..073a7efe2 100644 --- a/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs @@ -1,4 +1,8 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Helpers; @@ -6,13 +10,12 @@ namespace TypeWhisper.Plugin.Cerebras; -public sealed partial class CerebrasPlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class CerebrasPlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware { private const string BaseUrl = "https://api.cerebras.ai"; private readonly HttpClient _httpClient; private IPluginHostServices? _host; - private string? _apiKey; private bool _streamResponses = true; public CerebrasPlugin() @@ -32,7 +35,7 @@ internal CerebrasPlugin(HttpClient httpClient) public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = await host.LoadSecretAsync("api-key"); + ApiKey = await host.LoadSecretAsync("api-key"); _streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true; host.Log(PluginLogLevel.Info, $"Activated (configured={IsAvailable})"); } @@ -44,10 +47,10 @@ public Task DeactivateAsync() } public string ProviderName => "Cerebras"; - public bool IsAvailable => !string.IsNullOrEmpty(_apiKey); + public bool IsAvailable => !string.IsNullOrEmpty(ApiKey); public IReadOnlyList SupportedModels { get; } = - [new PluginModelInfo("llama-4-scout-17b-16e-instruct", "Llama 4 Scout 17B")]; + [new("llama-4-scout-17b-16e-instruct", "Llama 4 Scout 17B")]; public async Task ProcessAsync( string systemPrompt, @@ -62,7 +65,7 @@ CancellationToken ct return await OpenAiChatHelper.SendChatCompletionAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, model, systemPrompt, userText, @@ -89,7 +92,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct var source = OpenAiChatHelper.SendChatCompletionStreamingAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, model, systemPrompt, userText, @@ -100,7 +103,8 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct yield return delta; } - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -113,7 +117,7 @@ public void SetLocalization(IPluginLocalization localization) => internal async Task SetApiKeyAsync(string apiKey) { - _apiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey; + ApiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey; if (_host is not null) { if (string.IsNullOrWhiteSpace(apiKey)) @@ -166,7 +170,7 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, + "api-key" => ApiKey, LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false", _ => null, @@ -201,10 +205,10 @@ private static bool ParseBool(string? value) => public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); - var valid = await ValidateApiKeyAsync(_apiKey, ct); + var valid = await ValidateApiKeyAsync(ApiKey, ct); return valid ? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid")) : new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid")); diff --git a/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs b/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs index 2f2cc5a1a..8e87a6957 100644 --- a/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs +++ b/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs @@ -1,4 +1,8 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -7,7 +11,7 @@ namespace TypeWhisper.Plugin.Claude; -public sealed partial class ClaudePlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class ClaudePlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware { private const string BaseUrl = "https://api.anthropic.com"; @@ -17,7 +21,6 @@ public sealed partial class ClaudePlugin : ILlmProviderPlugin, IPluginSettingsPr private readonly HttpClient _httpClient; private IPluginHostServices? _host; - private string? _apiKey; private bool _streamResponses = true; public ClaudePlugin() @@ -37,7 +40,7 @@ internal ClaudePlugin(HttpClient httpClient) public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = await host.LoadSecretAsync("api-key"); + ApiKey = await host.LoadSecretAsync("api-key"); _streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true; host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); } @@ -53,8 +56,8 @@ public Task DeactivateAsync() public IReadOnlyList SupportedModels { get; } = [ - new PluginModelInfo("claude-sonnet-4-20250514", "Claude Sonnet 4"), - new PluginModelInfo("claude-haiku-4-5-20251001", "Claude Haiku 4.5"), + new("claude-sonnet-4-20250514", "Claude Sonnet 4"), + new("claude-haiku-4-5-20251001", "Claude Haiku 4.5"), ]; public async Task ProcessAsync( @@ -82,7 +85,7 @@ CancellationToken ct using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/messages"); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - request.Headers.Add("x-api-key", _apiKey); + request.Headers.Add("x-api-key", ApiKey); request.Headers.Add("anthropic-version", AnthropicVersion); var response = await _httpClient.SendAsync(request, ct); @@ -140,7 +143,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/messages"); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - request.Headers.Add("x-api-key", _apiKey); + request.Headers.Add("x-api-key", ApiKey); request.Headers.Add("anthropic-version", AnthropicVersion); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); @@ -270,8 +273,9 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct } } - internal bool IsConfigured => !string.IsNullOrEmpty(_apiKey); - internal string? ApiKey => _apiKey; + internal bool IsConfigured => !string.IsNullOrEmpty(ApiKey); + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -288,7 +292,7 @@ internal async Task SetApiKeyAsync(string apiKey) // already trims, but a future direct caller could re-introduce // trailing whitespace that breaks the x-api-key header. var trimmed = apiKey?.Trim(); - _apiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed; + ApiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed; if (_host is not null) { if (string.IsNullOrEmpty(trimmed)) @@ -331,7 +335,7 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, + "api-key" => ApiKey, LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false", _ => null, @@ -368,12 +372,12 @@ private static bool ParseBool(string? value) => public Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return Task.FromResult( new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")) ); - var valid = ValidateApiKeyFormat(_apiKey); + var valid = ValidateApiKeyFormat(ApiKey); return Task.FromResult( valid ? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyFormatValid")) diff --git a/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs b/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs index e241c72f5..5f20186d5 100644 --- a/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs +++ b/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs @@ -1,4 +1,8 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedType.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -6,7 +10,7 @@ namespace TypeWhisper.Plugin.CloudflareAsr; -public sealed partial class CloudflareAsrPlugin +public sealed class CloudflareAsrPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware @@ -15,9 +19,8 @@ public sealed partial class CloudflareAsrPlugin private IPluginHostServices? _host; private string? _apiToken; private string? _accountId; - private string? _selectedModelId; - private static readonly IReadOnlyList Models = + private static readonly IReadOnlyList s_models = [ new("whisper", "Whisper (Cloudflare)"), ]; @@ -36,7 +39,7 @@ public async Task ActivateAsync(IPluginHostServices host) _apiToken = string.IsNullOrWhiteSpace(loadedToken) ? null : loadedToken.Trim(); var loadedAccount = await host.LoadSecretAsync("account-id"); _accountId = string.IsNullOrWhiteSpace(loadedAccount) ? null : loadedAccount.Trim(); - _selectedModelId = host.GetSetting("selectedModel") ?? Models[0].Id; + SelectedModelId = host.GetSetting("selectedModel") ?? s_models[0].Id; host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); } @@ -51,17 +54,17 @@ public Task DeactivateAsync() public bool IsConfigured => !string.IsNullOrEmpty(_apiToken) && !string.IsNullOrEmpty(_accountId); - public IReadOnlyList TranscriptionModels => Models; + public IReadOnlyList TranscriptionModels => s_models; - public string? SelectedModelId => _selectedModelId; + public string? SelectedModelId { get; private set; } public bool SupportsTranslation => false; public void SelectModel(string modelId) { - if (Models.All(m => m.Id != modelId)) + if (s_models.All(m => m.Id != modelId)) throw new ArgumentException($"Unknown model: {modelId}"); - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); } @@ -213,7 +216,7 @@ public IReadOnlyList GetSettingDefinitions() => "selectedModel", Loc.L("Settings.TranscriptionModel"), Description: Loc.L("Settings.ModelDescription"), - Options: Models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList() + Options: s_models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList() ), ]; @@ -223,7 +226,7 @@ public IReadOnlyList GetSettingDefinitions() => { "account-id" => _accountId, "api-token" => _apiToken, - "selectedModel" => _selectedModelId, + "selectedModel" => SelectedModelId, _ => null, } ); diff --git a/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs b/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs index d38d6cdd9..fa762b456 100644 --- a/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs +++ b/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs @@ -1,4 +1,8 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Helpers; @@ -6,7 +10,7 @@ namespace TypeWhisper.Plugin.Cohere; -public sealed partial class CoherePlugin : ILlmProviderPlugin, IDisposable, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class CoherePlugin : ILlmProviderPlugin, IDisposable, IPluginSettingsProvider, IPluginLocalizationAware { private const string BaseUrl = "https://api.cohere.com/compatibility"; private readonly HttpClient _httpClient; @@ -56,7 +60,7 @@ public void SetLocalization(IPluginLocalization localization) => internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; public IReadOnlyList SupportedModels { get; } = - [new PluginModelInfo("command-a-03-2025", "Command A") { IsRecommended = true }]; + [new("command-a-03-2025", "Command A") { IsRecommended = true }]; public async Task ProcessAsync( string systemPrompt, diff --git a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramPlugin.cs b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramPlugin.cs index c648a4095..e43bb45f9 100644 --- a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramPlugin.cs @@ -1,4 +1,9 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// ReSharper disable UnusedType.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -6,16 +11,14 @@ namespace TypeWhisper.Plugin.Deepgram; -public sealed partial class DeepgramPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class DeepgramPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware { private const string BaseUrl = "https://api.deepgram.com"; private readonly HttpClient _httpClient = new(); private IPluginHostServices? _host; - private string? _apiKey; - private string? _selectedModelId; - private static readonly IReadOnlyList Models = + private static readonly IReadOnlyList s_models = [ new("nova-3", "Nova-3"), new("nova-2", "Nova-2"), @@ -28,8 +31,8 @@ public sealed partial class DeepgramPlugin : ITranscriptionEnginePlugin, IPlugin public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = await host.LoadSecretAsync("api-key"); - _selectedModelId = host.GetSetting("selectedModel") ?? Models[0].Id; + ApiKey = await host.LoadSecretAsync("api-key"); + SelectedModelId = host.GetSetting("selectedModel") ?? s_models[0].Id; host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); } @@ -41,24 +44,24 @@ public Task DeactivateAsync() public string ProviderId => "deepgram"; public string ProviderDisplayName => "Deepgram"; - public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); + public bool IsConfigured => !string.IsNullOrEmpty(ApiKey); - public IReadOnlyList TranscriptionModels => Models; + public IReadOnlyList TranscriptionModels => s_models; - public string? SelectedModelId => _selectedModelId; + public string? SelectedModelId { get; private set; } public bool SupportsTranslation => false; public bool SupportsStreaming => true; public async Task StartStreamingAsync(string? language, CancellationToken ct) { - if (!IsConfigured || _selectedModelId is null) + if (!IsConfigured || SelectedModelId is null) throw new InvalidOperationException( "Plugin not configured. API key and model required." ); return await DeepgramStreamingSession.ConnectAsync( - _apiKey!, - _selectedModelId, + ApiKey!, + SelectedModelId, language, ct ); @@ -66,9 +69,9 @@ public async Task StartStreamingAsync(string? language, Cance public void SelectModel(string modelId) { - if (Models.All(m => m.Id != modelId)) + if (s_models.All(m => m.Id != modelId)) throw new ArgumentException($"Unknown model: {modelId}"); - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); } @@ -80,7 +83,7 @@ public async Task TranscribeAsync( CancellationToken ct ) { - if (!IsConfigured || _selectedModelId is null) + if (!IsConfigured || SelectedModelId is null) throw new InvalidOperationException( "Plugin not configured. API key and model required." ); @@ -90,10 +93,10 @@ CancellationToken ct ? "&detect_language=true" : $"&language={Uri.EscapeDataString(language)}"; var url = - $"{BaseUrl}/v1/listen?model={Uri.EscapeDataString(_selectedModelId)}&smart_format=true&punctuate=true{langParam}"; + $"{BaseUrl}/v1/listen?model={Uri.EscapeDataString(SelectedModelId)}&smart_format=true&punctuate=true{langParam}"; using var request = new HttpRequestMessage(HttpMethod.Post, url); - request.Headers.Authorization = new AuthenticationHeaderValue("Token", _apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Token", ApiKey); request.Content = new ByteArrayContent(wavAudio); request.Content.Headers.ContentType = new MediaTypeHeaderValue("audio/wav"); @@ -134,7 +137,7 @@ CancellationToken ct ); } - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } private IPluginLocalization? _injectedLocalization; @@ -148,7 +151,7 @@ public void SetLocalization(IPluginLocalization localization) => internal async Task SetApiKeyAsync(string apiKey) { - _apiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey; + ApiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey; if (_host is not null) { if (string.IsNullOrWhiteSpace(apiKey)) @@ -193,7 +196,7 @@ public IReadOnlyList GetSettingDefinitions() => "selectedModel", Loc.L("Settings.TranscriptionModel"), Description: Loc.L("Settings.ModelDescription"), - Options: Models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList() + Options: s_models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList() ), ]; @@ -201,8 +204,8 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "api-key" => ApiKey, + "selectedModel" => SelectedModelId, _ => null, } ); @@ -227,10 +230,10 @@ public async Task SetSettingValueAsync( public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); - var valid = await ValidateApiKeyAsync(_apiKey, ct); + var valid = await ValidateApiKeyAsync(ApiKey, ct); return valid ? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid")) : new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid")); diff --git a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs index 983d4b59e..ca54147e3 100644 --- a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs @@ -1,4 +1,3 @@ -using System.IO; using System.Net.WebSockets; using System.Text; using System.Text.Json; @@ -31,9 +30,9 @@ CancellationToken ct string.IsNullOrEmpty(language) || string.Equals(language, "auto", StringComparison.OrdinalIgnoreCase); var langParam = isUnspecified - ? (model.StartsWith("nova-3", StringComparison.OrdinalIgnoreCase) + ? model.StartsWith("nova-3", StringComparison.OrdinalIgnoreCase) ? "&language=multi" - : string.Empty) + : string.Empty : $"&language={Uri.EscapeDataString(language!)}"; var url = $"wss://api.deepgram.com/v1/listen?model={Uri.EscapeDataString(model)}&encoding=linear16&sample_rate=16000&interim_results=true&punctuate=true&smart_format=true{langParam}"; diff --git a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs index 7914fde47..e05f3cdd3 100644 --- a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs +++ b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs @@ -1,3 +1,8 @@ +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -12,14 +17,14 @@ public sealed class ElevenLabsPlugin : ITranscriptionEnginePlugin, IPluginSettin private const string ApiKeySecretName = "api-key"; private const string SelectedModelSettingName = "selectedModel"; - private static readonly char[] InvalidKeytermCharacters = ['<', '>', '{', '}', '[', ']', '\\']; + private static readonly char[] s_invalidKeytermCharacters = ['<', '>', '{', '}', '[', ']', '\\']; - private static readonly IReadOnlyList ModelEntries = + private static readonly IReadOnlyList s_modelEntries = [ new(DefaultModelId, "Scribe v2", "scribe_v2", "scribe_v2_realtime"), ]; - private static readonly IReadOnlyList Languages = + private static readonly IReadOnlyList s_languages = [ "af", "am", @@ -126,8 +131,6 @@ public sealed class ElevenLabsPlugin : ITranscriptionEnginePlugin, IPluginSettin private readonly HttpClient _httpClient; private IPluginHostServices? _host; - private string? _apiKey; - private string? _selectedModelId; public ElevenLabsPlugin() : this(CreateHttpClient()) { } @@ -144,8 +147,8 @@ internal ElevenLabsPlugin(HttpClient httpClient) public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = await host.LoadSecretAsync(ApiKeySecretName); - _selectedModelId = NormalizeModelId(host.GetSetting(SelectedModelSettingName)); + ApiKey = await host.LoadSecretAsync(ApiKeySecretName); + SelectedModelId = NormalizeModelId(host.GetSetting(SelectedModelSettingName)); host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); } @@ -157,23 +160,23 @@ public Task DeactivateAsync() public string ProviderId => "elevenlabs"; public string ProviderDisplayName => "ElevenLabs"; - public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); + public bool IsConfigured => !string.IsNullOrEmpty(ApiKey); public IReadOnlyList TranscriptionModels { get; } = - ModelEntries + s_modelEntries .Select(m => new PluginModelInfo(m.Id, m.DisplayName) { IsRecommended = true }) .ToList(); - public string? SelectedModelId => _selectedModelId; + public string? SelectedModelId { get; private set; } public bool SupportsTranslation => false; public bool SupportsStreaming => true; - public IReadOnlyList SupportedLanguages => Languages; + public IReadOnlyList SupportedLanguages => s_languages; public void SelectModel(string modelId) { var entry = ResolveModelEntry(modelId); - _selectedModelId = entry.Id; + SelectedModelId = entry.Id; _host?.SetSetting(SelectedModelSettingName, entry.Id); } @@ -185,14 +188,14 @@ public async Task TranscribeAsync( CancellationToken ct ) { - if (!IsConfigured || _selectedModelId is null) + if (!IsConfigured || SelectedModelId is null) throw new InvalidOperationException( "Plugin not configured. API key and model required." ); - var entry = ResolveModelEntry(_selectedModelId); + var entry = ResolveModelEntry(SelectedModelId); using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/speech-to-text"); - request.Headers.TryAddWithoutValidation("xi-api-key", _apiKey); + request.Headers.TryAddWithoutValidation("xi-api-key", ApiKey); using var form = new MultipartFormDataContent(); var audioContent = new ByteArrayContent(wavAudio); @@ -221,21 +224,22 @@ CancellationToken ct public async Task StartStreamingAsync(string? language, CancellationToken ct) { - if (!IsConfigured || _selectedModelId is null) + if (!IsConfigured || SelectedModelId is null) throw new InvalidOperationException( "Plugin not configured. API key and model required." ); - var entry = ResolveModelEntry(_selectedModelId); + var entry = ResolveModelEntry(SelectedModelId); return await ElevenLabsStreamingSession.ConnectAsync( - _apiKey!, + ApiKey!, entry.RealtimeModelId, NormalizeLanguage(language), ct ); } - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -250,9 +254,9 @@ internal async Task SetApiKeyAsync(string apiKey) { var normalized = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey.Trim(); var wasConfigured = IsConfigured; - var changed = !string.Equals(_apiKey, normalized, StringComparison.Ordinal); + var changed = !string.Equals(ApiKey, normalized, StringComparison.Ordinal); - _apiKey = normalized; + ApiKey = normalized; if (_host is not null) { if (normalized is null) @@ -298,6 +302,7 @@ internal static PluginTranscriptionResult ParseRestResponse( var duration = 0.0; var segments = new List(); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if ( root.TryGetProperty("words", out var wordsEl) && wordsEl.ValueKind == JsonValueKind.Array @@ -364,7 +369,7 @@ var part in prompt.Split( if ( term.Length == 0 || term.Length >= 50 - || term.IndexOfAny(InvalidKeytermCharacters) >= 0 + || term.IndexOfAny(s_invalidKeytermCharacters) >= 0 || term.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length > 5 || !seen.Add(term) ) @@ -393,7 +398,7 @@ public IReadOnlyList GetSettingDefinitions() => "selectedModel", Loc.L("Settings.TranscriptionModel"), Description: Loc.L("Settings.ModelDescription"), - Options: ModelEntries + Options: s_modelEntries .Select(m => new PluginSettingOption(m.Id, m.DisplayName)) .ToList() ), @@ -403,8 +408,8 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "api-key" => ApiKey, + "selectedModel" => SelectedModelId, _ => null, } ); @@ -429,10 +434,10 @@ public async Task SetSettingValueAsync( public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); - var valid = await ValidateApiKeyAsync(_apiKey, ct); + var valid = await ValidateApiKeyAsync(ApiKey, ct); return valid ? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid")) : new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid")); @@ -450,10 +455,10 @@ public void Dispose() : language; private static string NormalizeModelId(string? modelId) => - ModelEntries.Any(m => m.Id == modelId) ? modelId! : DefaultModelId; + s_modelEntries.Any(m => m.Id == modelId) ? modelId! : DefaultModelId; private static ElevenLabsModelEntry ResolveModelEntry(string modelId) => - ModelEntries.FirstOrDefault(m => m.Id == modelId) + s_modelEntries.FirstOrDefault(m => m.Id == modelId) ?? throw new ArgumentException($"Unknown model: {modelId}"); private static bool TryGetDouble(JsonElement element, string propertyName, out double value) diff --git a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs index 15f893f26..787e82dc0 100644 --- a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs @@ -1,3 +1,7 @@ +// ReSharper disable MemberCanBePrivate.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Diagnostics; using System.Net.WebSockets; using System.Text; diff --git a/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs b/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs index b12b4d3b2..6c7181aec 100644 --- a/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs +++ b/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs @@ -1,4 +1,3 @@ -using System.IO; using System.Text.Json; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Models; @@ -7,7 +6,7 @@ namespace TypeWhisper.Plugin.FileMemory; public sealed class FileMemoryPlugin : IMemoryStoragePlugin { - private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; private IPluginHostServices? _host; private string? _filePath; @@ -50,7 +49,7 @@ public async Task StoreAsync(string content, CancellationToken ct) var next = new List(current) { - new(content, DateTime.UtcNow) + new(content, DateTime.UtcNow), }; await SaveEntriesAsync(next, ct); _entries = next; @@ -188,7 +187,7 @@ private async Task> LoadEntriesAsync(CancellationToken ct) try { _entries = - JsonSerializer.Deserialize>(json, JsonOptions) + JsonSerializer.Deserialize>(json, s_jsonOptions) ?? throw new JsonException("The memory file contained null JSON."); _loadFailed = false; return _entries; @@ -248,7 +247,7 @@ private async Task SaveEntriesAsync(List entries, CancellationToken if (dir is not null && !Directory.Exists(dir)) Directory.CreateDirectory(dir); - var json = JsonSerializer.Serialize(entries, JsonOptions); + var json = JsonSerializer.Serialize(entries, s_jsonOptions); await File.WriteAllTextAsync(tempPath, json, ct); if (File.Exists(_filePath)) File.Replace(tempPath, _filePath, destinationBackupFileName: null); diff --git a/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs b/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs index fdefe2e39..7abe97cf5 100644 --- a/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs @@ -1,4 +1,8 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Helpers; @@ -6,7 +10,7 @@ namespace TypeWhisper.Plugin.Fireworks; -public sealed partial class FireworksPlugin +public sealed class FireworksPlugin : ILlmProviderPlugin, IDisposable, IPluginSettingsProvider, @@ -61,7 +65,7 @@ public void SetLocalization(IPluginLocalization localization) => public IReadOnlyList SupportedModels { get; } = [ - new PluginModelInfo( + new( "accounts/fireworks/models/llama4-scout-instruct-basic", "Llama 4 Scout" ) diff --git a/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs b/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs index 615895004..f8ceeb4cb 100644 --- a/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs @@ -1,4 +1,8 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Helpers; @@ -6,7 +10,7 @@ namespace TypeWhisper.Plugin.Gemini; -public sealed partial class GeminiPlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class GeminiPlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware { // Google's OpenAI-compatibility layer; endpoints are appended as /v1/... private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta/openai"; @@ -14,7 +18,6 @@ public sealed partial class GeminiPlugin : ILlmProviderPlugin, IPluginSettingsPr private readonly HttpClient _httpClient; private IPluginHostServices? _host; - private string? _apiKey; private bool _streamResponses = true; public GeminiPlugin() @@ -38,7 +41,7 @@ public async Task ActivateAsync(IPluginHostServices host) // otherwise reach the Bearer header with trailing whitespace and 401 // every request while IsAvailable still reports true. var loaded = await host.LoadSecretAsync("api-key"); - _apiKey = string.IsNullOrWhiteSpace(loaded) ? null : loaded.Trim(); + ApiKey = string.IsNullOrWhiteSpace(loaded) ? null : loaded.Trim(); _streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true; host.Log(PluginLogLevel.Info, $"Activated (configured={IsAvailable})"); } @@ -50,16 +53,16 @@ public Task DeactivateAsync() } public string ProviderName => "Google Gemini"; - public bool IsAvailable => !string.IsNullOrEmpty(_apiKey); + public bool IsAvailable => !string.IsNullOrEmpty(ApiKey); public IReadOnlyList SupportedModels { get; } = [ - new PluginModelInfo(DefaultModel, "Gemini 2.5 Flash") { IsRecommended = true }, - new PluginModelInfo("gemini-2.5-pro", "Gemini 2.5 Pro"), - new PluginModelInfo("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"), - new PluginModelInfo("gemma-4-27b-it", "Gemma 4 27B"), - new PluginModelInfo("gemma-4-12b-it", "Gemma 4 12B"), - new PluginModelInfo("gemma-4-4b-it", "Gemma 4 4B"), + new(DefaultModel, "Gemini 2.5 Flash") { IsRecommended = true }, + new("gemini-2.5-pro", "Gemini 2.5 Pro"), + new("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"), + new("gemma-4-27b-it", "Gemma 4 27B"), + new("gemma-4-12b-it", "Gemma 4 12B"), + new("gemma-4-4b-it", "Gemma 4 4B"), ]; public async Task ProcessAsync( @@ -75,7 +78,7 @@ CancellationToken ct return await OpenAiChatHelper.SendChatCompletionAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, model, systemPrompt, userText, @@ -102,7 +105,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct var source = OpenAiChatHelper.SendChatCompletionStreamingAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, model, systemPrompt, userText, @@ -113,7 +116,8 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct yield return delta; } - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -127,7 +131,7 @@ public void SetLocalization(IPluginLocalization localization) => internal async Task SetApiKeyAsync(string apiKey) { var trimmed = apiKey?.Trim(); - _apiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed; + ApiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed; if (_host is not null) { if (string.IsNullOrEmpty(trimmed)) @@ -180,7 +184,7 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, + "api-key" => ApiKey, LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false", _ => null, @@ -215,10 +219,10 @@ private static bool ParseBool(string? value) => public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); - var valid = await ValidateApiKeyAsync(_apiKey, ct); + var valid = await ValidateApiKeyAsync(ApiKey, ct); return valid ? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid")) : new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid")); diff --git a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs index 07a7d67e0..a92e3cbbf 100644 --- a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs @@ -1,6 +1,10 @@ +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// ReSharper disable UnusedType.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Diagnostics; -using System.IO; -using System.Net.Http; using LLama; using LLama.Common; using LLama.Sampling; @@ -11,7 +15,7 @@ namespace TypeWhisper.Plugin.GemmaLocal; public sealed class GemmaLocalPlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware { - private static readonly IReadOnlyList Models = + private static readonly IReadOnlyList s_models = [ new( "gemma4-e2b-it-q4", @@ -45,10 +49,8 @@ public sealed class GemmaLocalPlugin : ILlmProviderPlugin, IPluginSettingsProvid private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromHours(2) }; private readonly SemaphoreSlim _inferenceLock = new(1, 1); private IPluginHostServices? _host; - private string? _selectedModelId; private LLamaWeights? _weights; private LLamaContext? _context; - private string? _loadedModelId; private bool _streamResponses = true; private CancellationTokenSource? _startupCts; private Task? _startupTask; @@ -60,31 +62,32 @@ public sealed class GemmaLocalPlugin : ILlmProviderPlugin, IPluginSettingsProvid public Task ActivateAsync(IPluginHostServices host) { _host = host; - _selectedModelId = host.GetSetting("selectedModel"); + SelectedModelId = host.GetSetting("selectedModel"); _streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true; - host.Log(PluginLogLevel.Info, $"Activated (model={_selectedModelId})"); + host.Log(PluginLogLevel.Info, $"Activated (model={SelectedModelId})"); - // A persisted ID may name a model that no longer exists in Models + // A persisted ID may name a model that no longer exists in s_models // (e.g. after a release that drops a quant). IsModelDownloaded calls // GetModelDefinition, which throws — that would surface as a plugin // activation failure. Clear the stale setting instead. - if (!string.IsNullOrEmpty(_selectedModelId) - && Models.All(m => m.Id != _selectedModelId)) + if (!string.IsNullOrEmpty(SelectedModelId) + && s_models.All(m => m.Id != SelectedModelId)) { host.Log( PluginLogLevel.Warning, - $"Persisted model '{_selectedModelId}' is no longer available; clearing selection." + $"Persisted model '{SelectedModelId}' is no longer available; clearing selection." ); - _selectedModelId = null; + SelectedModelId = null; host.SetSetting("selectedModel", string.Empty); } // Auto-load previously selected model in background (don't block app startup). // Track the task + CTS so DeactivateAsync can cancel and await it instead of // letting it race back to life and recreate _weights/_context after teardown. - if (!string.IsNullOrEmpty(_selectedModelId) && IsModelDownloaded(_selectedModelId)) + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. + if (!string.IsNullOrEmpty(SelectedModelId) && IsModelDownloaded(SelectedModelId)) { - var modelId = _selectedModelId; + var modelId = SelectedModelId; _startupCts = new CancellationTokenSource(); var startupCt = _startupCts.Token; _startupTask = Task.Run(async () => @@ -161,7 +164,7 @@ public IReadOnlyList GetSettingDefinitions() => Key: "selectedModel", Label: Loc.L("Settings.Model"), Description: Loc.L("Settings.ModelDescription"), - Options: Models + Options: s_models .Select(m => new PluginSettingOption( m.Id, $"{m.DisplayName} ({m.SizeDescription})" @@ -180,7 +183,7 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "selectedModel" => _selectedModelId, + "selectedModel" => SelectedModelId, LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false", _ => null, } @@ -211,7 +214,7 @@ public async Task SetSettingValueAsync( await _inferenceLock.WaitAsync(ct).ConfigureAwait(false); try { - _selectedModelId = null; + SelectedModelId = null; _host?.SetSetting("selectedModel", string.Empty); UnloadModel(); } @@ -229,13 +232,13 @@ public async Task SetSettingValueAsync( public Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_selectedModelId)) + if (string.IsNullOrWhiteSpace(SelectedModelId)) return Task.FromResult( new PluginSettingsValidationResult(false, Loc.L("Settings.SelectModel")) ); return Task.FromResult( - _loadedModelId == _selectedModelId + LoadedModelId == SelectedModelId ? new PluginSettingsValidationResult(true, Loc.L("Settings.ModelReady")) : new PluginSettingsValidationResult(false, Loc.L("Settings.ModelSelectedNotLoaded")) ); @@ -253,6 +256,7 @@ internal async Task EnsureModelReadyAsync(string modelId, CancellationToken ct) var progress = new Progress(p => { var pct = (int)(p * 100); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (pct != lastPct && pct % 5 == 0) { lastPct = pct; @@ -267,10 +271,10 @@ internal async Task EnsureModelReadyAsync(string modelId, CancellationToken ct) } public string ProviderName => "Gemma 4 (Local)"; - public bool IsAvailable => _loadedModelId is not null; + public bool IsAvailable => LoadedModelId is not null; public IReadOnlyList SupportedModels { get; } = - Models + s_models .Select(m => new PluginModelInfo(m.Id, m.DisplayName) { SizeDescription = m.SizeDescription, @@ -364,8 +368,10 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct } } - internal string? SelectedModelId => _selectedModelId; - internal string? LoadedModelId => _loadedModelId; + internal string? SelectedModelId { get; private set; } + + internal string? LoadedModelId { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -375,12 +381,12 @@ public void SetLocalization(IPluginLocalization localization) => // injected at load so settings labels/validation resolve even when this // plugin is disabled (never activated, so _host is null). internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; - internal IReadOnlyList ModelDefinitions => Models; + internal IReadOnlyList ModelDefinitions => s_models; internal void SelectModel(string modelId) { _ = GetModelDefinition(modelId); - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); _host?.NotifyCapabilitiesChanged(); } @@ -456,6 +462,7 @@ CancellationToken ct bytesRead += read; var now = DateTime.UtcNow; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if ((now - lastReport).TotalMilliseconds > 250) { progress?.Report((double)bytesRead / totalBytes); @@ -510,7 +517,7 @@ internal Task LoadModelAsync(string modelId, CancellationToken ct) // If the user has switched models OR cleared the selection while we // were queued behind the lock, abort: a late finish here would // overwrite the newer state and load a model the user no longer wants. - if (_selectedModelId != modelId) + if (SelectedModelId != modelId) return; UnloadModel(); @@ -544,14 +551,14 @@ internal Task LoadModelAsync(string modelId, CancellationToken ct) // so the user can switch selections while we're loading. If // that happened, drop what we just loaded instead of letting // the late finish silently roll back their newer choice. - if (_selectedModelId != modelId) + if (SelectedModelId != modelId) { UnloadModel(); return; } - _loadedModelId = modelId; - _selectedModelId = modelId; + LoadedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); loaded = true; } @@ -576,7 +583,7 @@ internal void UnloadModel() _context = null; _weights?.Dispose(); _weights = null; - _loadedModelId = null; + LoadedModelId = null; } // Helpers @@ -610,7 +617,7 @@ private string GetModelFilePath(string modelId, string fileName) => Path.Join(GetModelDirectory(modelId), fileName); private static GemmaModelDefinition GetModelDefinition(string modelId) => - Models.FirstOrDefault(m => m.Id == modelId) + s_models.FirstOrDefault(m => m.Id == modelId) ?? throw new ArgumentException($"Unknown model: {modelId}"); private void Log(PluginLogLevel level, string message) diff --git a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs index 7bfee8268..43af29fc9 100644 --- a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs @@ -1,17 +1,19 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Models; namespace TypeWhisper.Plugin.Gladia; -public sealed partial class GladiaPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class GladiaPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware { private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(120) }; private IPluginHostServices? _host; private string? _apiKey; - private string? _selectedModelId; - private static readonly IReadOnlyList Models = + private static readonly IReadOnlyList s_models = [ new("default", "Gladia (Auto)"), ]; @@ -28,7 +30,7 @@ public async Task ActivateAsync(IPluginHostServices host) // with trailing whitespace while IsConfigured still reports true. var loaded = await host.LoadSecretAsync("api-key"); _apiKey = string.IsNullOrWhiteSpace(loaded) ? null : loaded.Trim(); - _selectedModelId = host.GetSetting("selectedModel") ?? Models[0].Id; + SelectedModelId = host.GetSetting("selectedModel") ?? s_models[0].Id; host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); } @@ -42,9 +44,9 @@ public Task DeactivateAsync() public string ProviderDisplayName => "Gladia"; public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); - public IReadOnlyList TranscriptionModels => Models; + public IReadOnlyList TranscriptionModels => s_models; - public string? SelectedModelId => _selectedModelId; + public string? SelectedModelId { get; private set; } public bool SupportsTranslation => false; @@ -60,9 +62,9 @@ public async Task StartStreamingAsync(string? language, Cance public void SelectModel(string modelId) { - if (Models.All(m => m.Id != modelId)) + if (s_models.All(m => m.Id != modelId)) throw new ArgumentException($"Unknown model: {modelId}"); - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); } @@ -127,7 +129,7 @@ public IReadOnlyList GetSettingDefinitions() => "selectedModel", Loc.L("Settings.TranscriptionModel"), Description: Loc.L("Settings.ModelDescription"), - Options: Models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList() + Options: s_models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList() ), ]; @@ -136,7 +138,7 @@ public IReadOnlyList GetSettingDefinitions() => key switch { "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "selectedModel" => SelectedModelId, _ => null, } ); diff --git a/plugins/TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs b/plugins/TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs index f7a6577b2..11dd241e9 100644 --- a/plugins/TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs @@ -1,6 +1,4 @@ using System.Diagnostics; -using System.IO; -using System.Net.Http; using System.Net.WebSockets; using System.Text; using System.Text.Json; diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs index 76538f193..5690c8171 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs @@ -1,5 +1,8 @@ +// ReSharper disable MemberCanBePrivate.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Buffers.Binary; -using System.Net.Http; using System.Text; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -16,7 +19,7 @@ namespace TypeWhisper.Plugin.GoogleCloudStt; // credential. That is a research spike, not a drop-in change, so it is parked: // leaving the plugin as-is until that cost is justified or a streaming reference // to follow exists. -public sealed partial class GoogleCloudSttPlugin +public sealed class GoogleCloudSttPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware @@ -27,7 +30,6 @@ public sealed partial class GoogleCloudSttPlugin private readonly HttpClient _httpClient; private IPluginHostServices? _host; private string? _apiKey; - private string? _selectedModelId; public GoogleCloudSttPlugin() : this(new HttpClientHandler()) { } @@ -49,7 +51,7 @@ public async Task ActivateAsync(IPluginHostServices host) { _host = host; _apiKey = await host.LoadSecretAsync("api-key"); - _selectedModelId = host.GetSetting("selectedModel") ?? "latest_long"; + SelectedModelId = host.GetSetting("selectedModel") ?? "latest_long"; host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); } @@ -64,16 +66,17 @@ public Task DeactivateAsync() public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); public IReadOnlyList TranscriptionModels { get; } = - [new PluginModelInfo("latest_long", "Google Cloud (Long)")]; + [new("latest_long", "Google Cloud (Long)")]; + + public string? SelectedModelId { get; private set; } - public string? SelectedModelId => _selectedModelId; public bool SupportsTranslation => false; public void SelectModel(string modelId) { if (modelId != "latest_long") throw new ArgumentException($"Unknown model: {modelId}"); - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); } @@ -158,7 +161,7 @@ private static (int Offset, int Length) LocatePcmData(byte[] wavAudio) } // Chunks are word-aligned: an odd body is followed by a pad byte. - var advance = (long)bodyOffset + chunkSize + (chunkSize & 1); + var advance = bodyOffset + chunkSize + (chunkSize & 1); if (advance <= offset || advance > data.Length) break; offset = (int)advance; @@ -185,19 +188,23 @@ private static PluginTranscriptionResult ParseResponse(string json, string reque foreach (var result in results.EnumerateArray()) { if ( - result.TryGetProperty("alternatives", out var alternatives) - && alternatives.ValueKind == JsonValueKind.Array + !result.TryGetProperty("alternatives", out var alternatives) + || alternatives.ValueKind != JsonValueKind.Array ) { - foreach (var alt in alternatives.EnumerateArray()) + continue; + } + + foreach (var alt in alternatives.EnumerateArray()) + { + if (!alt.TryGetProperty("transcript", out var transcript)) { - if (alt.TryGetProperty("transcript", out var transcript)) - { - if (sb.Length > 0) - sb.Append(' '); - sb.Append(transcript.GetString()); - } + continue; } + + if (sb.Length > 0) + sb.Append(' '); + sb.Append(transcript.GetString()); } } } @@ -223,6 +230,7 @@ out var secs } string? detectedLang = null; + // ReSharper disable once InvertIf -- inverting would duplicate the multi-argument return below; kept nested for clarity. if ( root.TryGetProperty("results", out var resultsForLang) && resultsForLang.ValueKind == JsonValueKind.Array @@ -316,7 +324,7 @@ public IReadOnlyList GetSettingDefinitions() => key switch { "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "selectedModel" => SelectedModelId, _ => null, } ); diff --git a/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs b/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs index d77a134f7..1feb5d70c 100644 --- a/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs @@ -1,4 +1,9 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable NotAccessedPositionalProperty.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -7,7 +12,7 @@ namespace TypeWhisper.Plugin.Groq; -public sealed partial class GroqPlugin +public sealed class GroqPlugin : ITranscriptionEnginePlugin, ILlmProviderPlugin, IPluginSettingsProvider, @@ -16,14 +21,11 @@ public sealed partial class GroqPlugin private const string BaseUrl = "https://api.groq.com/openai"; private readonly HttpClient _httpClient; private IPluginHostServices? _host; - private string? _apiKey; - private string? _selectedModelId; private string? _selectedApiModelName; - private string? _selectedLlmModelId; private List _fetchedLlmModels = []; private bool _streamResponses = true; - private static readonly IReadOnlyList TranscriptionModelEntries = + private static readonly IReadOnlyList s_transcriptionModelEntries = [ new("whisper-large-v3", "Whisper Large V3", "whisper-large-v3", SupportsTranslation: true), new( @@ -34,7 +36,7 @@ public sealed partial class GroqPlugin ), ]; - private static readonly IReadOnlyList FallbackLlmModels = + private static readonly IReadOnlyList s_fallbackLlmModels = [ new("llama-3.3-70b-versatile", "Llama 3.3 70B"), new("llama-3.1-8b-instant", "Llama 3.1 8B"), @@ -58,19 +60,19 @@ internal GroqPlugin(HttpClient httpClient) public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = await host.LoadSecretAsync("api-key"); - _selectedModelId = - host.GetSetting("selectedModel") ?? TranscriptionModelEntries[0].Id; - _selectedLlmModelId = host.GetSetting("selectedLlmModel"); + ApiKey = await host.LoadSecretAsync("api-key"); + SelectedModelId = + host.GetSetting("selectedModel") ?? s_transcriptionModelEntries[0].Id; + SelectedLlmModelId = host.GetSetting("selectedLlmModel"); _fetchedLlmModels = NormalizeFetchedLlmModels( host.GetSetting>("fetchedLlmModels") ?? [] ); _streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true; var selectedTranscription = - TranscriptionModelEntries.FirstOrDefault(m => m.Id == _selectedModelId) - ?? TranscriptionModelEntries[0]; - _selectedModelId = selectedTranscription.Id; + s_transcriptionModelEntries.FirstOrDefault(m => m.Id == SelectedModelId) + ?? s_transcriptionModelEntries[0]; + SelectedModelId = selectedTranscription.Id; _selectedApiModelName = selectedTranscription.ApiModelName; NormalizeSelectedLlmModel(); @@ -85,20 +87,20 @@ public Task DeactivateAsync() public string ProviderId => "groq"; public string ProviderDisplayName => "Groq"; - public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); + public bool IsConfigured => !string.IsNullOrEmpty(ApiKey); public IReadOnlyList TranscriptionModels { get; } = - TranscriptionModelEntries.Select(m => new PluginModelInfo(m.Id, m.DisplayName)).ToList(); + s_transcriptionModelEntries.Select(m => new PluginModelInfo(m.Id, m.DisplayName)).ToList(); - public string? SelectedModelId => _selectedModelId; + public string? SelectedModelId { get; private set; } public bool SupportsTranslation { get { - if (!IsConfigured || _selectedModelId is null) + if (!IsConfigured || SelectedModelId is null) return false; - var entry = TranscriptionModelEntries.FirstOrDefault(m => m.Id == _selectedModelId); + var entry = s_transcriptionModelEntries.FirstOrDefault(m => m.Id == SelectedModelId); return entry?.SupportsTranslation ?? false; } } @@ -106,9 +108,9 @@ public bool SupportsTranslation public void SelectModel(string modelId) { var entry = - TranscriptionModelEntries.FirstOrDefault(m => m.Id == modelId) + s_transcriptionModelEntries.FirstOrDefault(m => m.Id == modelId) ?? throw new ArgumentException($"Unknown model: {modelId}"); - _selectedModelId = modelId; + SelectedModelId = modelId; _selectedApiModelName = entry.ApiModelName; _host?.SetSetting("selectedModel", modelId); } @@ -129,7 +131,7 @@ CancellationToken ct return await OpenAiTranscriptionHelper.TranscribeAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, _selectedApiModelName, wavAudio, language, @@ -146,7 +148,7 @@ CancellationToken ct public IReadOnlyList SupportedModels => _fetchedLlmModels.Count > 0 ? _fetchedLlmModels.Select(m => new PluginModelInfo(m.Id, m.Id)).ToList() - : FallbackLlmModels; + : s_fallbackLlmModels; public async Task ProcessAsync( string systemPrompt, @@ -162,7 +164,7 @@ CancellationToken ct return await OpenAiChatHelper.SendChatCompletionAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, modelId, systemPrompt, userText, @@ -190,7 +192,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct var source = OpenAiChatHelper.SendChatCompletionStreamingAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, modelId, systemPrompt, userText, @@ -201,7 +203,8 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct yield return delta; } - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -211,16 +214,17 @@ public void SetLocalization(IPluginLocalization localization) => // injected at load so settings labels/validation resolve even when this // plugin is disabled (never activated, so _host is null). internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; - internal string? SelectedLlmModelId => _selectedLlmModelId; + internal string? SelectedLlmModelId { get; private set; } + internal IReadOnlyList FetchedLlmModels => _fetchedLlmModels; internal async Task SetApiKeyAsync(string apiKey) { var normalizedApiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey.Trim(); var wasConfigured = IsConfigured; - var changed = !string.Equals(_apiKey, normalizedApiKey, StringComparison.Ordinal); + var changed = !string.Equals(ApiKey, normalizedApiKey, StringComparison.Ordinal); - _apiKey = normalizedApiKey; + ApiKey = normalizedApiKey; if (_host is not null) { if (normalizedApiKey is null) @@ -231,9 +235,9 @@ internal async Task SetApiKeyAsync(string apiKey) if (changed) { _fetchedLlmModels = []; - _selectedLlmModelId = null; + SelectedLlmModelId = null; _host.SetSetting("fetchedLlmModels", _fetchedLlmModels); - _host.SetSetting("selectedLlmModel", _selectedLlmModelId); + _host.SetSetting("selectedLlmModel", SelectedLlmModelId); NormalizeSelectedLlmModel(); if (wasConfigured != IsConfigured) @@ -244,7 +248,7 @@ internal async Task SetApiKeyAsync(string apiKey) internal void SelectLlmModel(string modelId) { - _selectedLlmModelId = modelId; + SelectedLlmModelId = modelId; _host?.SetSetting("selectedLlmModel", modelId); } @@ -260,8 +264,8 @@ internal void SetFetchedLlmModels(List models) internal async Task?> FetchLlmModelsAsync(CancellationToken ct = default) { using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/models"); - if (!string.IsNullOrEmpty(_apiKey)) - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + if (!string.IsNullOrEmpty(ApiKey)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); try { @@ -338,7 +342,7 @@ internal static bool IsLlmModel(string id) internal string ResolveLlmModelId(string? requestedModel) => !string.IsNullOrWhiteSpace(requestedModel) ? requestedModel - : _selectedLlmModelId ?? SupportedModels[0].Id; + : SelectedLlmModelId ?? SupportedModels[0].Id; private void NormalizeSelectedLlmModel() { @@ -346,12 +350,12 @@ private void NormalizeSelectedLlmModel() SupportedModels.Select(m => m.Id), StringComparer.OrdinalIgnoreCase ); - if (_selectedLlmModelId is not null && availableIds.Contains(_selectedLlmModelId)) + if (SelectedLlmModelId is not null && availableIds.Contains(SelectedLlmModelId)) return; - _selectedLlmModelId = (SupportedModels.Count > 0 ? SupportedModels[0] : null)?.Id; - if (_selectedLlmModelId is not null) - _host?.SetSetting("selectedLlmModel", _selectedLlmModelId); + SelectedLlmModelId = (SupportedModels.Count > 0 ? SupportedModels[0] : null)?.Id; + if (SelectedLlmModelId is not null) + _host?.SetSetting("selectedLlmModel", SelectedLlmModelId); } private static List NormalizeFetchedLlmModels( @@ -383,7 +387,7 @@ public IReadOnlyList GetSettingDefinitions() => Key: "selectedModel", Label: Loc.L("Settings.TranscriptionModel"), Description: Loc.L("Settings.TranscriptionModelDescription"), - Options: TranscriptionModelEntries + Options: s_transcriptionModelEntries .Select(m => new PluginSettingOption(m.Id, m.DisplayName)) .ToList() ), @@ -409,9 +413,9 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, - "selectedModel" => _selectedModelId, - "selectedLlmModel" => _selectedLlmModelId, + "api-key" => ApiKey, + "selectedModel" => SelectedModelId, + "selectedLlmModel" => SelectedLlmModelId, LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false", _ => null, @@ -454,14 +458,15 @@ private static bool ParseBool(string? value) => public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); - var valid = await ValidateApiKeyAsync(_apiKey, ct); + var valid = await ValidateApiKeyAsync(ApiKey, ct); if (!valid) return new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid")); var models = await FetchLlmModelsAsync(ct); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (models is not null) { SetFetchedLlmModels(models); diff --git a/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs b/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs index fef980866..05dc46dc2 100644 --- a/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs @@ -1,4 +1,9 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// ReSharper disable UnusedType.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; @@ -9,7 +14,7 @@ namespace TypeWhisper.Plugin.Linear; -public sealed partial class LinearPlugin : IActionPlugin, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class LinearPlugin : IActionPlugin, IPluginSettingsProvider, IPluginLocalizationAware { private static readonly JsonSerializerOptions s_jsonOptions = new() { @@ -18,10 +23,6 @@ public sealed partial class LinearPlugin : IActionPlugin, IPluginSettingsProvide }; private readonly HttpClient _httpClient = new(); - private IPluginHostServices? _host; - private string? _apiKey; - private string? _defaultTeamId; - private string? _defaultProjectId; private List _cachedTeams = []; public string PluginId => "com.typewhisper.linear"; @@ -32,7 +33,8 @@ public sealed partial class LinearPlugin : IActionPlugin, IPluginSettingsProvide public string ActionName => "Create Linear Issue"; public string? ActionIcon => "\U0001F4CB"; - public IPluginHostServices? Host => _host; + public IPluginHostServices? Host { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -41,17 +43,19 @@ public void SetLocalization(IPluginLocalization localization) => // Prefer the host's localization once activated; fall back to the catalog // injected at load so settings labels/validation resolve even when this // plugin is disabled (never activated, so _host is null). - internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; - public string? ApiKey => _apiKey; - public string? DefaultTeamId => _defaultTeamId; - public string? DefaultProjectId => _defaultProjectId; + internal IPluginLocalization? Loc => Host?.Localization ?? _injectedLocalization; + public string? ApiKey { get; private set; } + + public string? DefaultTeamId { get; private set; } + + public string? DefaultProjectId { get; private set; } public async Task ActivateAsync(IPluginHostServices host) { - _host = host; - _apiKey = await host.LoadSecretAsync("api-key"); - _defaultTeamId = host.GetSetting("default-team-id"); - _defaultProjectId = host.GetSetting("default-project-id"); + Host = host; + ApiKey = await host.LoadSecretAsync("api-key"); + DefaultTeamId = host.GetSetting("default-team-id"); + DefaultProjectId = host.GetSetting("default-project-id"); var cachedTeamsJson = host.GetSetting("cached-teams"); if (!string.IsNullOrWhiteSpace(cachedTeamsJson)) { @@ -81,7 +85,7 @@ public async Task ActivateAsync(IPluginHostServices host) public Task DeactivateAsync() { - _host?.Log(PluginLogLevel.Info, "Linear plugin deactivated"); + Host?.Log(PluginLogLevel.Info, "Linear plugin deactivated"); return Task.CompletedTask; } @@ -91,13 +95,13 @@ public async Task ExecuteAsync( CancellationToken ct ) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new ActionResult( false, Loc.L("Settings.ApiKeyNotConfigured") ); - if (string.IsNullOrWhiteSpace(_defaultTeamId)) + if (string.IsNullOrWhiteSpace(DefaultTeamId)) return new ActionResult( false, Loc.L("Settings.DefaultTeamNotConfigured") @@ -129,41 +133,41 @@ CancellationToken ct } catch (Exception ex) { - _host?.Log(PluginLogLevel.Error, $"Failed to create Linear issue: {ex.Message}"); + Host?.Log(PluginLogLevel.Error, $"Failed to create Linear issue: {ex.Message}"); return new ActionResult(false, Loc.L("Settings.IssueCreateError", ex.Message)); } } public async Task SaveApiKeyAsync(string apiKey) { - if (_host is null) + if (Host is null) return; - _apiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey.Trim(); + ApiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey.Trim(); if (string.IsNullOrWhiteSpace(apiKey)) - await _host.DeleteSecretAsync("api-key"); + await Host.DeleteSecretAsync("api-key"); else - await _host.StoreSecretAsync("api-key", apiKey.Trim()); + await Host.StoreSecretAsync("api-key", apiKey.Trim()); - _host.NotifyCapabilitiesChanged(); - _host.Log(PluginLogLevel.Info, "Linear API key saved"); + Host.NotifyCapabilitiesChanged(); + Host.Log(PluginLogLevel.Info, "Linear API key saved"); } public void SaveDefaultTeamId(string teamId) { - _defaultTeamId = string.IsNullOrWhiteSpace(teamId) ? null : teamId.Trim(); - _host?.SetSetting("default-team-id", _defaultTeamId ?? ""); + DefaultTeamId = string.IsNullOrWhiteSpace(teamId) ? null : teamId.Trim(); + Host?.SetSetting("default-team-id", DefaultTeamId ?? ""); } public void SaveDefaultProjectId(string projectId) { - _defaultProjectId = string.IsNullOrWhiteSpace(projectId) ? null : projectId.Trim(); - _host?.SetSetting("default-project-id", _defaultProjectId ?? ""); + DefaultProjectId = string.IsNullOrWhiteSpace(projectId) ? null : projectId.Trim(); + Host?.SetSetting("default-project-id", DefaultProjectId ?? ""); } public async Task> FetchTeamsAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return []; const string query = """ @@ -202,7 +206,7 @@ public async Task> FetchTeamsAsync(CancellationToken ct = defau _cachedTeams = teams; try { - _host?.SetSetting("cached-teams", JsonSerializer.Serialize(teams, s_jsonOptions)); + Host?.SetSetting("cached-teams", JsonSerializer.Serialize(teams, s_jsonOptions)); } catch { @@ -213,7 +217,7 @@ public async Task> FetchTeamsAsync(CancellationToken ct = defau } catch (Exception ex) { - _host?.Log(PluginLogLevel.Warning, $"Failed to parse teams response: {ex.Message}"); + Host?.Log(PluginLogLevel.Warning, $"Failed to parse teams response: {ex.Message}"); return []; } } @@ -228,11 +232,11 @@ CancellationToken ct { ["title"] = title, ["description"] = description, - ["teamId"] = _defaultTeamId, + ["teamId"] = DefaultTeamId, }; - if (!string.IsNullOrWhiteSpace(_defaultProjectId)) - variables["projectId"] = _defaultProjectId; + if (!string.IsNullOrWhiteSpace(DefaultProjectId)) + variables["projectId"] = DefaultProjectId; const string mutation = """ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $projectId: String) { @@ -263,7 +267,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p if (!success) { - _host?.Log( + Host?.Log( PluginLogLevel.Warning, "Linear API returned success=false for issueCreate" ); @@ -274,12 +278,12 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p var url = issue.GetProperty("url").GetString(); var identifier = issue.GetProperty("identifier").GetString(); - _host?.Log(PluginLogLevel.Info, $"Created Linear issue {identifier}"); + Host?.Log(PluginLogLevel.Info, $"Created Linear issue {identifier}"); return url; } catch (Exception ex) { - _host?.Log( + Host?.Log( PluginLogLevel.Warning, $"Failed to parse issue creation response: {ex.Message}" ); @@ -305,7 +309,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p "https://api.linear.app/graphql" ); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); HttpResponseMessage response; try @@ -324,7 +328,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p // HttpClient.Timeout (not caller cancellation) surfaces as // TaskCanceledException — treat as transport failure. var fingerprint = ShortFingerprint(ex.ToString()); - _host?.Log( + Host?.Log( PluginLogLevel.Error, $"Linear API request timed out (sha256:{fingerprint})" ); @@ -333,7 +337,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p catch (HttpRequestException ex) { var fingerprint = ShortFingerprint(ex.ToString()); - _host?.Log( + Host?.Log( PluginLogLevel.Error, $"Linear API transport error: {ex.Message} (sha256:{fingerprint})" ); @@ -360,14 +364,14 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException) { var fp = ShortFingerprint(ex.ToString()); - _host?.Log( + Host?.Log( PluginLogLevel.Error, $"Linear API error {(int)response.StatusCode}; could not read body: {ex.Message} (sha256:{fp})" ); return null; } var fingerprint = ShortFingerprint(errorBody); - _host?.Log( + Host?.Log( PluginLogLevel.Error, $"Linear API error {(int)response.StatusCode} (body length={errorBody.Length}, sha256:{fingerprint})" ); @@ -386,7 +390,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException) { var fingerprint = ShortFingerprint(ex.ToString()); - _host?.Log( + Host?.Log( PluginLogLevel.Error, $"Linear API response read failed: {ex.Message} (sha256:{fingerprint})" ); @@ -397,6 +401,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p { using var doc = JsonDocument.Parse(responseJson); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (doc.RootElement.TryGetProperty("errors", out var errors)) { // GraphQL error arrays should contain { "message": "..." } objects, but @@ -421,14 +426,14 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p // reports without spilling user content into traces. var raw = errors.GetRawText(); var fingerprint = ShortFingerprint(raw); - _host?.Log( + Host?.Log( PluginLogLevel.Error, $"Linear GraphQL error: {{redacted:length={raw.Length}, sha256:{fingerprint}}}" ); } else { - _host?.Log(PluginLogLevel.Error, $"Linear GraphQL error: {errorMsg}"); + Host?.Log(PluginLogLevel.Error, $"Linear GraphQL error: {errorMsg}"); } return null; @@ -443,7 +448,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p // log the parse failure plus a short fingerprint (not the raw body — // it may echo user content), then return null so callers recover. var fingerprint = ShortFingerprint(responseJson); - _host?.Log( + Host?.Log( PluginLogLevel.Error, $"Linear API returned non-JSON body ({ex.Message}). Body length={responseJson.Length}, sha256:{fingerprint}" ); @@ -507,9 +512,9 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, - "default-team-id" => _defaultTeamId, - "default-project-id" => _defaultProjectId, + "api-key" => ApiKey, + "default-team-id" => DefaultTeamId, + "default-project-id" => DefaultProjectId, _ => null, } ); @@ -536,7 +541,7 @@ public async Task SetSettingValueAsync( public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); var teams = await FetchTeamsAsync(ct); diff --git a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs index 76a52519c..67e23ffea 100644 --- a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs @@ -1,4 +1,10 @@ -using System.IO; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable NotAccessedPositionalProperty.Global +// ReSharper disable UnusedMember.Global +// ReSharper disable UnusedType.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Text; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -6,9 +12,8 @@ namespace TypeWhisper.Plugin.Obsidian; -public sealed partial class ObsidianPlugin : IActionPlugin, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class ObsidianPlugin : IActionPlugin, IPluginSettingsProvider, IPluginLocalizationAware { - private IPluginHostServices? _host; private List _detectedVaults = []; public string PluginId => "com.typewhisper.obsidian"; @@ -19,7 +24,8 @@ public sealed partial class ObsidianPlugin : IActionPlugin, IPluginSettingsProvi public string ActionName => "Save to Obsidian"; public string? ActionIcon => "\ud83d\udcdd"; - internal IPluginHostServices? Host => _host; + internal IPluginHostServices? Host { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -28,11 +34,11 @@ public void SetLocalization(IPluginLocalization localization) => // Prefer the host's localization once activated; fall back to the catalog // injected at load so settings labels/validation resolve even when this // plugin is disabled (never activated, so _host is null). - internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; + internal IPluginLocalization? Loc => Host?.Localization ?? _injectedLocalization; public Task ActivateAsync(IPluginHostServices host) { - _host = host; + Host = host; _detectedVaults = DetectVaults(); return Task.CompletedTask; } @@ -45,10 +51,10 @@ public async Task ExecuteAsync( CancellationToken ct ) { - if (_host is null) + if (Host is null) return new ActionResult(false, Loc.L("Settings.PluginNotActivatedShort")); - var vaultPath = _host.GetSetting("vault-path"); + var vaultPath = Host.GetSetting("vault-path"); if (string.IsNullOrWhiteSpace(vaultPath)) return new ActionResult( false, @@ -58,9 +64,9 @@ CancellationToken ct if (!Directory.Exists(vaultPath)) return new ActionResult(false, Loc.L("Settings.VaultPathNotFound", vaultPath)); - var subfolder = _host.GetSetting("subfolder") ?? "TypeWhisper"; - var dailyNoteMode = _host.GetSetting("daily-note-mode"); - var filenameTemplate = _host.GetSetting("filename-template"); + var subfolder = Host.GetSetting("subfolder") ?? "TypeWhisper"; + var dailyNoteMode = Host.GetSetting("daily-note-mode"); + var filenameTemplate = Host.GetSetting("filename-template"); if (string.IsNullOrWhiteSpace(filenameTemplate)) filenameTemplate = "{{date}} {{time}} Transcription"; @@ -100,7 +106,7 @@ CancellationToken ct await File.WriteAllTextAsync(filePath, content, Encoding.UTF8, ct); } - _host.Log(PluginLogLevel.Info, $"Saved transcription to {filePath}"); + Host.Log(PluginLogLevel.Info, $"Saved transcription to {filePath}"); return new ActionResult(true, Loc.L("Settings.SavedTo", filename)); } @@ -223,9 +229,11 @@ internal static List DetectVaults() foreach (var vault in vaultsElement.EnumerateObject()) { + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (vault.Value.TryGetProperty("path", out var pathElement)) { var path = pathElement.GetString(); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (!string.IsNullOrEmpty(path) && Directory.Exists(path)) { var name = Path.GetFileName(path); @@ -251,6 +259,7 @@ private static string GetObsidianConfigPath() } var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (string.IsNullOrWhiteSpace(configHome)) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); @@ -298,16 +307,16 @@ public IReadOnlyList GetSettingDefinitions() => public Task GetSettingValueAsync(string key, CancellationToken ct = default) { - if (_host is null) + if (Host is null) return Task.FromResult(null); return Task.FromResult( key switch { - "vault-path" => _host.GetSetting("vault-path"), - "subfolder" => _host.GetSetting("subfolder") ?? "TypeWhisper", - "daily-note-mode" => _host.GetSetting("daily-note-mode") ? "true" : "false", - "filename-template" => _host.GetSetting("filename-template") + "vault-path" => Host.GetSetting("vault-path"), + "subfolder" => Host.GetSetting("subfolder") ?? "TypeWhisper", + "daily-note-mode" => Host.GetSetting("daily-note-mode") ? "true" : "false", + "filename-template" => Host.GetSetting("filename-template") ?? "{{date}} {{time}} Transcription", _ => null, } @@ -316,28 +325,28 @@ public IReadOnlyList GetSettingDefinitions() => public Task SetSettingValueAsync(string key, string? value, CancellationToken ct = default) { - if (_host is null) + if (Host is null) return Task.CompletedTask; switch (key) { case "vault-path": - _host.SetSetting("vault-path", value?.Trim() ?? string.Empty); + Host.SetSetting("vault-path", value?.Trim() ?? string.Empty); break; case "subfolder": - _host.SetSetting( + Host.SetSetting( "subfolder", string.IsNullOrWhiteSpace(value) ? "TypeWhisper" : value.Trim() ); break; case "daily-note-mode": - _host.SetSetting( + Host.SetSetting( "daily-note-mode", string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ); break; case "filename-template": - _host.SetSetting( + Host.SetSetting( "filename-template", string.IsNullOrWhiteSpace(value) ? "{{date}} {{time}} Transcription" @@ -351,12 +360,12 @@ public Task SetSettingValueAsync(string key, string? value, CancellationToken ct public Task ValidateAsync(CancellationToken ct = default) { - if (_host is null) + if (Host is null) return Task.FromResult( new PluginSettingsValidationResult(false, Loc.L("Settings.PluginNotActivated")) ); - var vaultPath = _host.GetSetting("vault-path"); + var vaultPath = Host.GetSetting("vault-path"); if (string.IsNullOrWhiteSpace(vaultPath)) return Task.FromResult( new PluginSettingsValidationResult(false, Loc.L("Settings.EnterVaultPath")) diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs index 5ebef51e2..36abf317f 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs @@ -1,4 +1,7 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -66,9 +69,9 @@ internal static Dictionary CreateRequestBody( role = "user", content = new[] { - new { type = "input_text", text = userText } - } - } + new { type = "input_text", text = userText }, + }, + }, }), ["store"] = OpenAiJson.Element(false), ["stream"] = OpenAiJson.Element(true), diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiFetchedModel.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiFetchedModel.cs index 5375446bd..88eeb6704 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiFetchedModel.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiFetchedModel.cs @@ -1,3 +1,8 @@ +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable NotAccessedPositionalProperty.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Text.Json.Serialization; namespace TypeWhisper.Plugin.OpenAi; diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiJson.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiJson.cs index 2134a1998..bd19d5503 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiJson.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiJson.cs @@ -1,4 +1,3 @@ -using System.Net.Http; using System.Text; using System.Text.Json; @@ -6,14 +5,14 @@ namespace TypeWhisper.Plugin.OpenAi; internal static class OpenAiJson { - private static readonly JsonSerializerOptions JsonOptions = new() + private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNamingPolicy = null + PropertyNamingPolicy = null, }; public static JsonElement Element(T value) => - JsonSerializer.SerializeToElement(value, JsonOptions).Clone(); + JsonSerializer.SerializeToElement(value, s_jsonOptions).Clone(); public static StringContent CreateJsonContent(IReadOnlyDictionary body) => - new(JsonSerializer.Serialize(body, JsonOptions), Encoding.UTF8, "application/json"); + new(JsonSerializer.Serialize(body, s_jsonOptions), Encoding.UTF8, "application/json"); } diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs index 9899d1176..02d2811ca 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs @@ -1,7 +1,9 @@ -using System.Diagnostics; -using System.IO; +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable MemberCanBePrivate.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net; -using System.Net.Http; using System.Net.Sockets; using System.Security.Cryptography; using System.Text; @@ -13,7 +15,7 @@ namespace TypeWhisper.Plugin.OpenAi; internal enum OpenAiAuthMode { ApiKey, - ChatGpt + ChatGpt, } internal static class OpenAiAuthModeExtensions @@ -94,7 +96,7 @@ public static async Task ExchangeAuthorizationCodeAsyn ["redirect_uri"] = RedirectUri, ["client_id"] = ClientId, ["code_verifier"] = pkce.Verifier, - }) + }), }; return await SendTokenRequestAsync(httpClient, request, ct); @@ -112,7 +114,7 @@ public static async Task RefreshTokenAsync( ["grant_type"] = "refresh_token", ["refresh_token"] = refreshToken, ["client_id"] = ClientId, - }) + }), }; return await SendTokenRequestAsync(httpClient, request, ct); diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs index 495e60694..308401e8f 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs @@ -1,8 +1,11 @@ +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.ComponentModel; using System.Diagnostics; using System.Globalization; -using System.IO; -using System.Net.Http; using System.Net.Http.Headers; using System.Net.Sockets; using System.Text.Json; @@ -44,28 +47,19 @@ public sealed class OpenAiPlugin private readonly Func _ttsPlaybackFactory; private readonly Func _ttsPlaybackAvailableProbe; private IPluginHostServices? _host; - private string? _apiKey; - private string? _selectedModelId; private string? _selectedApiModelName; private string _selectedResponseFormat = "verbose_json"; private string? _selectedVoiceId; - private string _ttsInstructions = ""; - private string _reasoningEffort = "medium"; private List _fetchedLlmModels = []; - private OpenAiAuthMode _authMode = OpenAiAuthMode.ApiKey; - private string? _selectedLlmModelId; private string? _oauthAccessToken; private string? _oauthRefreshToken; private string? _oauthIdToken; private string? _oauthAccountId; - private string? _oauthPlanType; private DateTimeOffset? _oauthExpiresAt; private bool _forgetChatGptLogin; - private string _temperatureMode = TemperatureModeProviderDefault; - private double _temperatureValue = 0.3; private bool _streamResponses = true; - private static readonly IReadOnlyList TranscriptionModelEntries = + private static readonly IReadOnlyList s_transcriptionModelEntries = [ new("whisper-1", "Whisper 1", "whisper-1", "verbose_json", SupportsTranslation: true), new( @@ -92,7 +86,7 @@ public sealed class OpenAiPlugin ), ]; - private static readonly IReadOnlyList FallbackLlmModels = + private static readonly IReadOnlyList s_fallbackLlmModels = [ new("gpt-5.5", "GPT-5.5"), new("gpt-4.1-nano", "GPT-4.1 Nano"), @@ -103,7 +97,7 @@ public sealed class OpenAiPlugin new("o4-mini", "o4-mini"), ]; - private static readonly IReadOnlyList ChatGptModels = + private static readonly IReadOnlyList s_chatGptModels = [ new("gpt-5.5", "GPT-5.5"), new("gpt-5.4", "GPT-5.4"), @@ -144,25 +138,25 @@ internal OpenAiPlugin( public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); + ApiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); _oauthAccessToken = NormalizeApiKey(await host.LoadSecretAsync(OAuthAccessTokenSecretName)); _oauthRefreshToken = NormalizeApiKey(await host.LoadSecretAsync(OAuthRefreshTokenSecretName)); _oauthIdToken = NormalizeApiKey(await host.LoadSecretAsync(OAuthIdTokenSecretName)); - _authMode = OpenAiAuthModeExtensions.Parse(host.GetSetting(AuthModeSettingName)); - _selectedLlmModelId = host.GetSetting(SelectedLlmModelSettingName); + AuthMode = OpenAiAuthModeExtensions.Parse(host.GetSetting(AuthModeSettingName)); + SelectedLlmModelId = host.GetSetting(SelectedLlmModelSettingName); _selectedVoiceId = NormalizeVoiceId(host.GetSetting(SelectedVoiceSettingName)); - _ttsInstructions = host.GetSetting(TtsInstructionsSettingName) ?? ""; - _reasoningEffort = NormalizeReasoningEffort(host.GetSetting(ReasoningEffortSettingName)); + TtsInstructions = host.GetSetting(TtsInstructionsSettingName) ?? ""; + ReasoningEffort = NormalizeReasoningEffort(host.GetSetting(ReasoningEffortSettingName)); _fetchedLlmModels = host.GetSetting>(FetchedLlmModelsSettingName) ?? []; _oauthAccountId = host.GetSetting(OAuthAccountIdSettingName); - _oauthPlanType = host.GetSetting(OAuthPlanTypeSettingName); + ChatGptPlanType = host.GetSetting(OAuthPlanTypeSettingName); _oauthExpiresAt = LoadExpiresAt(host); - _temperatureMode = NormalizeTemperatureMode(host.GetSetting(TemperatureModeSettingName)); - _temperatureValue = NormalizeTemperatureValue(host.GetSetting(TemperatureValueSettingName)); + TemperatureMode = NormalizeTemperatureMode(host.GetSetting(TemperatureModeSettingName)); + TemperatureValue = NormalizeTemperatureValue(host.GetSetting(TemperatureValueSettingName)); _streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true; SelectModelCore( - host.GetSetting(SelectedModelSettingName) ?? TranscriptionModelEntries[0].Id, + host.GetSetting(SelectedModelSettingName) ?? s_transcriptionModelEntries[0].Id, persist: false); NormalizeSelectedLlmModel(persist: false); host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); @@ -178,12 +172,12 @@ public Task DeactivateAsync() public string ProviderId => "openai"; public string ProviderDisplayName => "OpenAI / ChatGPT"; - public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); + public bool IsConfigured => !string.IsNullOrEmpty(ApiKey); public IReadOnlyList TranscriptionModels { get; } = - TranscriptionModelEntries.Select(m => new PluginModelInfo(m.Id, m.DisplayName)).ToList(); + s_transcriptionModelEntries.Select(m => new PluginModelInfo(m.Id, m.DisplayName)).ToList(); - public string? SelectedModelId => _selectedModelId; + public string? SelectedModelId { get; private set; } public bool SupportsTranslation => IsConfigured && SelectedModelEntry is { SupportsTranslation: true }; @@ -194,7 +188,7 @@ public Task DeactivateAsync() // user is in OAuth mode even with the realtime model selected. public bool SupportsStreaming => IsConfigured - && _authMode != OpenAiAuthMode.ChatGpt + && AuthMode != OpenAiAuthMode.ChatGpt && SelectedModelEntry is { SupportsStreaming: true }; public void SelectModel(string modelId) => SelectModelCore(modelId, persist: true); @@ -212,7 +206,8 @@ CancellationToken ct "Plugin not configured. API key and model required." ); - if (_selectedModelId == OpenAiRealtimeStreamingSession.ModelId) + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. + if (SelectedModelId == OpenAiRealtimeStreamingSession.ModelId) { if (translate) throw new InvalidOperationException( @@ -220,7 +215,7 @@ CancellationToken ct ); return await OpenAiRealtimeStreamingSession.TranscribeWavAsync( - _apiKey!, + ApiKey!, wavAudio, NormalizeLanguage(language), prompt, @@ -231,7 +226,7 @@ CancellationToken ct return await OpenAiTranscriptionHelper.TranscribeAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, _selectedApiModelName, wavAudio, NormalizeLanguage(language), @@ -244,20 +239,20 @@ CancellationToken ct public async Task StartStreamingAsync(string? language, CancellationToken ct) { - if (_authMode == OpenAiAuthMode.ChatGpt) + if (AuthMode == OpenAiAuthMode.ChatGpt) throw new InvalidOperationException( "OpenAI realtime streaming requires an API key. " + "ChatGPT login can't authenticate the realtime endpoint." ); if (!IsConfigured) throw new InvalidOperationException(Loc.L("Settings.ApiKeyNotConfigured")); - if (_selectedModelId != OpenAiRealtimeStreamingSession.ModelId) + if (SelectedModelId != OpenAiRealtimeStreamingSession.ModelId) throw new NotSupportedException( "Select GPT Realtime Whisper to use OpenAI realtime streaming." ); return await OpenAiRealtimeStreamingSession.ConnectAsync( - _apiKey!, + ApiKey!, NormalizeLanguage(language), prompt: null, useServerVad: true, @@ -269,18 +264,18 @@ public async Task StartStreamingAsync(string? language, Cance public string ProviderName => "OpenAI"; - public bool IsAvailable => _authMode switch + public bool IsAvailable => AuthMode switch { OpenAiAuthMode.ChatGpt => HasChatGptCredentials, _ => IsConfigured, }; public IReadOnlyList SupportedModels => - _authMode == OpenAiAuthMode.ChatGpt - ? ChatGptModels + AuthMode == OpenAiAuthMode.ChatGpt + ? s_chatGptModels : _fetchedLlmModels.Count > 0 ? _fetchedLlmModels.Select(model => new PluginModelInfo(model.Id, model.Id)).ToList() - : FallbackLlmModels; + : s_fallbackLlmModels; public async Task ProcessAsync( string systemPrompt, @@ -290,10 +285,10 @@ CancellationToken ct ) { var modelId = string.IsNullOrWhiteSpace(model) - ? _selectedLlmModelId ?? SupportedModels[0].Id + ? SelectedLlmModelId ?? SupportedModels[0].Id : model; - if (_authMode == OpenAiAuthMode.ChatGpt) + if (AuthMode == OpenAiAuthMode.ChatGpt) { var accessToken = await ValidOAuthAccessTokenAsync(ct); var client = new OpenAiChatGptClient(_httpClient, accessToken, _oauthAccountId); @@ -301,35 +296,36 @@ CancellationToken ct systemPrompt, userText, modelId, - SupportsReasoningEffort(modelId) ? _reasoningEffort : null, + SupportsReasoningEffort(modelId) ? ReasoningEffort : null, ct); } if (!IsConfigured) throw new InvalidOperationException(Loc.L("Settings.ApiKeyNotConfigured")); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (UsesResponsesApi(modelId)) { - var client = new OpenAiResponsesClient(_httpClient, BaseUrl, _apiKey!); + var client = new OpenAiResponsesClient(_httpClient, BaseUrl, ApiKey!); return await client.ProcessAsync( systemPrompt, userText, modelId, - SupportsReasoningEffort(modelId) ? MapApiReasoningEffort(_reasoningEffort) : null, + SupportsReasoningEffort(modelId) ? MapApiReasoningEffort(ReasoningEffort) : null, ct); } return await OpenAiChatHelper.SendChatCompletionAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, modelId, systemPrompt, userText, ct, maxOutputTokens: 2048, maxOutputTokenParameter: OutputTokenParameter(modelId), - reasoningEffort: SupportsReasoningEffort(modelId) ? _reasoningEffort : null, + reasoningEffort: SupportsReasoningEffort(modelId) ? ReasoningEffort : null, temperature: ResolvedTemperature(modelId) ); } @@ -342,7 +338,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct ) { var modelId = string.IsNullOrWhiteSpace(model) - ? _selectedLlmModelId ?? SupportedModels[0].Id + ? SelectedLlmModelId ?? SupportedModels[0].Id : model; // Self-gated per the C7 per-provider toggle. Also bulk-yield the @@ -350,7 +346,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct // a streaming reader so far (the shared helper). The other two stay // byte-identical to ProcessAsync — see the C7 Phase 3 doc's scope note. if (!_streamResponses - || _authMode == OpenAiAuthMode.ChatGpt + || AuthMode == OpenAiAuthMode.ChatGpt || UsesResponsesApi(modelId)) { yield return await ProcessAsync(systemPrompt, userText, modelId, ct); @@ -363,14 +359,14 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct var source = OpenAiChatHelper.SendChatCompletionStreamingAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, modelId, systemPrompt, userText, ct, maxOutputTokens: 2048, maxOutputTokenParameter: OutputTokenParameter(modelId), - reasoningEffort: SupportsReasoningEffort(modelId) ? _reasoningEffort : null, + reasoningEffort: SupportsReasoningEffort(modelId) ? ReasoningEffort : null, temperature: ResolvedTemperature(modelId) ); @@ -428,18 +424,23 @@ public async Task SpeakAsync(TtsSpeakRequest request, Cance // LLM model catalog - internal OpenAiAuthMode AuthMode => _authMode; + internal OpenAiAuthMode AuthMode { get; private set; } = OpenAiAuthMode.ApiKey; internal bool HasChatGptCredentials => !string.IsNullOrWhiteSpace(_oauthRefreshToken) || !string.IsNullOrWhiteSpace(_oauthAccessToken); - internal string? ChatGptPlanType => _oauthPlanType; - internal string? SelectedLlmModelId => _selectedLlmModelId; - internal string ReasoningEffort => _reasoningEffort; - internal string TtsInstructions => _ttsInstructions; - internal string TemperatureMode => _temperatureMode; - internal double TemperatureValue => _temperatureValue; + internal string? ChatGptPlanType { get; private set; } + + internal string? SelectedLlmModelId { get; private set; } + + internal string ReasoningEffort { get; private set; } = "medium"; + + internal string TtsInstructions { get; private set; } = ""; + + internal string TemperatureMode { get; private set; } = TemperatureModeProviderDefault; + + internal double TemperatureValue { get; private set; } = 0.3; internal static bool UsesResponsesApi(string modelId) { @@ -536,10 +537,10 @@ internal static double NormalizeTemperatureValue(double? value) internal async Task> RefreshAvailableLlmModelsAsync( CancellationToken ct = default) { - // ChatGPT-login mode uses the static ChatGptModels catalog and has no + // ChatGPT-login mode uses the static s_chatGptModels catalog and has no // /v1/models endpoint to refresh from — short-circuit to keep the // selection normalized without burning a (failing) HTTP call. - if (_authMode == OpenAiAuthMode.ChatGpt) + if (AuthMode == OpenAiAuthMode.ChatGpt) { NormalizeSelectedLlmModel(persist: true); return SupportedModels; @@ -568,7 +569,7 @@ internal async Task> FetchLlmModelsAsync( return []; using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); try { @@ -623,7 +624,7 @@ internal static bool IsChatModel(string id) "audio", "realtime", "gpt-image", - "-search" + "-search", ]; return !excludeSuffixes.Any(suffix => lowered.EndsWith(suffix, StringComparison.Ordinal)) && !excludeContains.Any(fragment => lowered.Contains(fragment, StringComparison.Ordinal)); @@ -631,10 +632,10 @@ internal static bool IsChatModel(string id) internal void SetAuthMode(OpenAiAuthMode mode) { - if (_authMode == mode) + if (AuthMode == mode) return; - _authMode = mode; + AuthMode = mode; _host?.SetSetting(AuthModeSettingName, mode.ToStorageValue()); NormalizeSelectedLlmModel(persist: true); _host?.NotifyCapabilitiesChanged(); @@ -645,26 +646,26 @@ internal void SelectLlmModel(string modelId) if (SupportedModels.All(model => !string.Equals(model.Id, modelId, StringComparison.Ordinal))) modelId = (SupportedModels.Count > 0 ? SupportedModels[0] : null)?.Id ?? modelId; - _selectedLlmModelId = modelId; + SelectedLlmModelId = modelId; _host?.SetSetting(SelectedLlmModelSettingName, modelId); } internal void SetReasoningEffort(string effort) { - _reasoningEffort = NormalizeReasoningEffort(effort); - _host?.SetSetting(ReasoningEffortSettingName, _reasoningEffort); + ReasoningEffort = NormalizeReasoningEffort(effort); + _host?.SetSetting(ReasoningEffortSettingName, ReasoningEffort); } internal void SetTemperatureMode(string? mode) { - _temperatureMode = NormalizeTemperatureMode(mode); - _host?.SetSetting(TemperatureModeSettingName, _temperatureMode); + TemperatureMode = NormalizeTemperatureMode(mode); + _host?.SetSetting(TemperatureModeSettingName, TemperatureMode); } internal void SetTemperatureValue(double value) { - _temperatureValue = NormalizeTemperatureValue(value); - _host?.SetSetting(TemperatureValueSettingName, _temperatureValue); + TemperatureValue = NormalizeTemperatureValue(value); + _host?.SetSetting(TemperatureValueSettingName, TemperatureValue); } // ChatGPT OAuth login @@ -680,7 +681,7 @@ internal async Task LoginWithChatGptInBrowserAsync(CancellationToken ct = defaul Process.Start(new ProcessStartInfo { FileName = authUri.ToString(), - UseShellExecute = true + UseShellExecute = true, }); var code = await server.WaitForCodeAsync(ct); @@ -720,7 +721,7 @@ internal async Task ClearChatGptLoginAsync() _oauthRefreshToken = null; _oauthIdToken = null; _oauthAccountId = null; - _oauthPlanType = null; + ChatGptPlanType = null; _oauthExpiresAt = null; if (_host is not null) @@ -737,7 +738,8 @@ internal async Task ClearChatGptLoginAsync() // API key / settings management - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -752,9 +754,9 @@ internal async Task SetApiKeyAsync(string apiKey) { var normalized = NormalizeApiKey(apiKey); var wasConfigured = IsConfigured; - var changed = !string.Equals(_apiKey, normalized, StringComparison.Ordinal); + var changed = !string.Equals(ApiKey, normalized, StringComparison.Ordinal); - _apiKey = normalized; + ApiKey = normalized; if (_host is not null) { if (normalized is null) @@ -769,8 +771,8 @@ internal async Task SetApiKeyAsync(string apiKey) internal void SetTtsInstructions(string instructions) { - _ttsInstructions = instructions.Trim(); - _host?.SetSetting(TtsInstructionsSettingName, _ttsInstructions); + TtsInstructions = instructions.Trim(); + _host?.SetSetting(TtsInstructionsSettingName, TtsInstructions); } internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken ct = default) @@ -798,13 +800,13 @@ public void Dispose() } private TranscriptionModelEntry? SelectedModelEntry => - TranscriptionModelEntries.FirstOrDefault(m => m.Id == _selectedModelId); + s_transcriptionModelEntries.FirstOrDefault(m => m.Id == SelectedModelId); private void SelectModelCore(string modelId, bool persist) { - var entry = TranscriptionModelEntries.FirstOrDefault(m => m.Id == modelId) - ?? TranscriptionModelEntries[0]; - _selectedModelId = entry.Id; + var entry = s_transcriptionModelEntries.FirstOrDefault(m => m.Id == modelId) + ?? s_transcriptionModelEntries[0]; + SelectedModelId = entry.Id; _selectedApiModelName = entry.ApiModelName; _selectedResponseFormat = entry.ResponseFormat; @@ -815,9 +817,9 @@ private void SelectModelCore(string modelId, bool persist) private HttpRequestMessage CreateTtsRequest(string text) { var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/audio/speech"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); request.Content = OpenAiJson.CreateJsonContent( - OpenAiTtsConfiguration.CreateRequestBody(text, SelectedVoiceId, _ttsInstructions)); + OpenAiTtsConfiguration.CreateRequestBody(text, SelectedVoiceId, TtsInstructions)); return request; } @@ -852,7 +854,7 @@ private async Task StoreOAuthTokensAsync(OpenAiOAuthTokenResponse tokens, string _oauthRefreshToken = effectiveRefreshToken; _oauthIdToken = tokens.IdToken; _oauthAccountId = metadata.AccountId; - _oauthPlanType = metadata.PlanType; + ChatGptPlanType = metadata.PlanType; _oauthExpiresAt = metadata.ExpiresAt; if (_host is null) @@ -866,7 +868,7 @@ private async Task StoreOAuthTokensAsync(OpenAiOAuthTokenResponse tokens, string else await _host.StoreSecretAsync(OAuthIdTokenSecretName, tokens.IdToken); _host.SetSetting(OAuthAccountIdSettingName, _oauthAccountId); - _host.SetSetting(OAuthPlanTypeSettingName, _oauthPlanType); + _host.SetSetting(OAuthPlanTypeSettingName, ChatGptPlanType); _host.SetSetting(OAuthExpiresAtSettingName, _oauthExpiresAt); NormalizeSelectedLlmModel(persist: true); _host.NotifyCapabilitiesChanged(); @@ -877,12 +879,12 @@ private async Task StoreOAuthTokensAsync(OpenAiOAuthTokenResponse tokens, string // When the model rejects temperature outright (e.g. GPT-5 with a // reasoning_effort set), honor that regardless of the user's mode — // sending the field would 400 the request. - var reasoningEffort = SupportsReasoningEffort(modelId) ? _reasoningEffort : null; + var reasoningEffort = SupportsReasoningEffort(modelId) ? ReasoningEffort : null; if (!SupportsCustomTemperature(modelId, reasoningEffort)) return null; - return _temperatureMode == TemperatureModeCustom - ? _temperatureValue + return TemperatureMode == TemperatureModeCustom + ? TemperatureValue : ChatCompletionTemperature(modelId, reasoningEffort); } @@ -892,17 +894,17 @@ private void NormalizeSelectedLlmModel(bool persist) if (available.Count == 0) return; - if (_selectedLlmModelId is null - || available.All(model => !string.Equals(model.Id, _selectedLlmModelId, StringComparison.Ordinal))) + if (SelectedLlmModelId is null + || available.All(model => !string.Equals(model.Id, SelectedLlmModelId, StringComparison.Ordinal))) { - _selectedLlmModelId = available[0].Id; + SelectedLlmModelId = available[0].Id; } // Persist even when the in-memory selection didn't change — this guards // against a stale-cleared setting where _selectedLlmModelId is still // valid but the persisted setting was lost. if (persist) - _host?.SetSetting(SelectedLlmModelSettingName, _selectedLlmModelId); + _host?.SetSetting(SelectedLlmModelSettingName, SelectedLlmModelId); } private static DateTimeOffset? LoadExpiresAt(IPluginHostServices host) @@ -974,7 +976,7 @@ public IReadOnlyList GetSettingDefinitions() => Key: SelectedModelSettingName, Label: Loc.L("Settings.TranscriptionModel"), Description: Loc.L("Settings.TranscriptionModelDescription"), - Options: TranscriptionModelEntries + Options: s_transcriptionModelEntries .Select(m => new PluginSettingOption(m.Id, m.DisplayName)) .ToList(), Kind: PluginSettingKind.Dropdown @@ -982,7 +984,7 @@ public IReadOnlyList GetSettingDefinitions() => new( Key: SelectedLlmModelSettingName, Label: Loc.L("Settings.LlmModel"), - Description: _authMode == OpenAiAuthMode.ChatGpt + Description: AuthMode == OpenAiAuthMode.ChatGpt ? Loc.L("Settings.LlmModelDescriptionChatGpt") : _fetchedLlmModels.Count > 0 ? Loc.L("Settings.LlmModelDescriptionFetched", _fetchedLlmModels.Count) @@ -1061,16 +1063,16 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - AuthModeSettingName => _authMode.ToStorageValue(), - ApiKeySecretName => _apiKey, - SelectedModelSettingName => _selectedModelId, - SelectedLlmModelSettingName => _selectedLlmModelId, - ReasoningEffortSettingName => _reasoningEffort, - TemperatureModeSettingName => _temperatureMode, - TemperatureValueSettingName => _temperatureValue.ToString( + AuthModeSettingName => AuthMode.ToStorageValue(), + ApiKeySecretName => ApiKey, + SelectedModelSettingName => SelectedModelId, + SelectedLlmModelSettingName => SelectedLlmModelId, + ReasoningEffortSettingName => ReasoningEffort, + TemperatureModeSettingName => TemperatureMode, + TemperatureValueSettingName => TemperatureValue.ToString( CultureInfo.InvariantCulture), SelectedVoiceSettingName => _selectedVoiceId, - TtsInstructionsSettingName => _ttsInstructions, + TtsInstructionsSettingName => TtsInstructions, ForgetChatGptLoginSettingName => _forgetChatGptLogin ? "true" : "false", LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false", _ => null, @@ -1141,16 +1143,16 @@ internal void SetStreamResponses(bool enabled) } public async Task ValidateAsync(CancellationToken ct = default) => - _authMode == OpenAiAuthMode.ChatGpt + AuthMode == OpenAiAuthMode.ChatGpt ? await ValidateChatGptAsync(ct) : await ValidateApiKeyModeAsync(ct); private async Task ValidateApiKeyModeAsync(CancellationToken ct) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); - var valid = await ValidateApiKeyAsync(_apiKey, ct); + var valid = await ValidateApiKeyAsync(ApiKey, ct); if (!valid) return new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid")); @@ -1239,9 +1241,9 @@ internal void SetStreamResponses(bool enabled) } private string ChatGptConnectedMessage() => - string.IsNullOrWhiteSpace(_oauthPlanType) + string.IsNullOrWhiteSpace(ChatGptPlanType) ? Loc.L("Settings.ChatGptLoginConnected") - : Loc.L("Settings.ChatGptLoginConnectedPlan", _oauthPlanType); + : Loc.L("Settings.ChatGptLoginConnectedPlan", ChatGptPlanType); private static bool ParseBool(string? value) => string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs index d30ec3f63..8fe72cb71 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs @@ -1,6 +1,10 @@ +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Buffers.Binary; using System.Diagnostics; -using System.IO; using System.Net.WebSockets; using System.Text; using System.Text.Json; @@ -103,7 +107,7 @@ internal static Uri BuildRealtimeUri() => internal static IReadOnlyDictionary CreateRealtimeHeaders(string apiKey) => new Dictionary { - ["Authorization"] = $"Bearer {apiKey}" + ["Authorization"] = $"Bearer {apiKey}", }; internal static ClientWebSocket CreateConfiguredWebSocket(string apiKey) @@ -118,7 +122,7 @@ internal static string CreateSessionUpdatePayload(string? language, string? prom { var transcription = new Dictionary { - ["model"] = ModelId + ["model"] = ModelId, }; if (!string.IsNullOrWhiteSpace(language)) @@ -161,9 +165,9 @@ internal static string CreateSessionUpdatePayload(string? language, string? prom }, ["transcription"] = transcription, ["turn_detection"] = turnDetection, - } - } - } + }, + }, + }, }; return JsonSerializer.Serialize(payload); @@ -303,6 +307,7 @@ private async Task ReceiveLoopAsync(CancellationToken ct) // keep looping until the server closes. Promote it to a // captured fault so the next SendAudioAsync / FinalizeAsync // throws and triggers batch fallback. + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (_collector.Error is { } providerError) { Interlocked.CompareExchange( @@ -362,7 +367,7 @@ internal static byte[] Resample16kPcmTo24k(ReadOnlySpan pcm16Audio) var lower = ReadSample(pcm16Audio, lowerIndex); var upper = ReadSample(pcm16Audio, upperIndex); var sample = (short)Math.Clamp( - (int)Math.Round(lower + ((upper - lower) * fraction)), + (int)Math.Round(lower + (upper - lower) * fraction), short.MinValue, short.MaxValue); BinaryPrimitives.WriteInt16LittleEndian(output.AsSpan(targetIndex * sizeof(short)), sample); @@ -557,6 +562,7 @@ public bool ApplyEvent(string json, out StreamingTranscriptEvent? transcriptEven private static string? ExtractErrorMessage(JsonElement root) { + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (root.TryGetProperty("error", out var error)) { if (error.ValueKind == JsonValueKind.Object) diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiResponsesClient.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiResponsesClient.cs index 97ffd7854..f4bd22106 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiResponsesClient.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiResponsesClient.cs @@ -1,4 +1,3 @@ -using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; using TypeWhisper.PluginSDK.Helpers; @@ -56,9 +55,9 @@ internal static Dictionary CreateRequestBody( role = "user", content = new[] { - new { type = "input_text", text = userText } - } - } + new { type = "input_text", text = userText }, + }, + }, }), ["store"] = OpenAiJson.Element(false), }; @@ -82,6 +81,7 @@ internal static string ParseResponse(string json) return text; } + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (root.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array) { diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiTtsSupport.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiTtsSupport.cs index 1be6d5d12..17d2ad1ab 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiTtsSupport.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiTtsSupport.cs @@ -1,7 +1,10 @@ +// ReSharper disable MemberCanBePrivate.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Buffers.Binary; using System.ComponentModel; using System.Diagnostics; -using System.IO; using System.Text.Json; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Models; @@ -132,6 +135,7 @@ public static ITtsPlaybackSession Create(byte[] pcm16Audio, int sampleRate) process = null; } + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (process is null) { TryDeleteFile(wavFilePath); diff --git a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs index a14f532d1..1a5eff5d7 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs @@ -1,4 +1,9 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable NotAccessedPositionalProperty.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -7,7 +12,7 @@ namespace TypeWhisper.Plugin.OpenAiCompatible; -public sealed partial class OpenAiCompatiblePlugin +public sealed class OpenAiCompatiblePlugin : ITranscriptionEnginePlugin, ILlmProviderPlugin, IPluginSettingsProvider, @@ -28,10 +33,6 @@ public sealed partial class OpenAiCompatiblePlugin private readonly HttpClient _httpClient; private IPluginHostServices? _host; - private string? _apiKey; - private string? _baseUrl; - private string? _selectedModelId; - private string? _selectedLlmModelId; private List _fetchedModels = []; private bool _streamResponses = true; private readonly List _additionalProfiles = []; @@ -54,10 +55,10 @@ internal OpenAiCompatiblePlugin(HttpClient httpClient) public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = await host.LoadSecretAsync("api-key"); - _baseUrl = host.GetSetting("baseUrl"); - _selectedModelId = host.GetSetting("selectedModel"); - _selectedLlmModelId = host.GetSetting("selectedLlmModel"); + ApiKey = await host.LoadSecretAsync("api-key"); + BaseUrl = host.GetSetting("baseUrl"); + SelectedModelId = host.GetSetting("selectedModel"); + SelectedLlmModelId = host.GetSetting("selectedLlmModel"); _streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true; var modelsJson = host.GetSetting("fetchedModels"); @@ -92,7 +93,7 @@ public Task DeactivateAsync() public string ProviderId => "openai-compatible"; public string ProviderDisplayName => "Custom Server"; - public bool IsConfigured => !string.IsNullOrEmpty(_baseUrl); + public bool IsConfigured => !string.IsNullOrEmpty(BaseUrl); public IReadOnlyList TranscriptionModels { @@ -100,18 +101,18 @@ public IReadOnlyList TranscriptionModels { var models = _fetchedModels.Select(m => new PluginModelInfo(m.Id, m.Id)).ToList(); - if (models.Count == 0 && !string.IsNullOrEmpty(_selectedModelId)) - return [new PluginModelInfo(_selectedModelId, _selectedModelId)]; + if (models.Count == 0 && !string.IsNullOrEmpty(SelectedModelId)) + return [new PluginModelInfo(SelectedModelId, SelectedModelId)]; return models; } } - public string? SelectedModelId => _selectedModelId; + public string? SelectedModelId { get; private set; } public void SelectModel(string modelId) { - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); } @@ -125,16 +126,16 @@ public async Task TranscribeAsync( CancellationToken ct ) { - if (string.IsNullOrEmpty(_baseUrl)) + if (string.IsNullOrEmpty(BaseUrl)) throw new InvalidOperationException(Loc.L("Settings.ServerUrlNotConfigured")); - if (string.IsNullOrEmpty(_selectedModelId)) + if (string.IsNullOrEmpty(SelectedModelId)) throw new InvalidOperationException(Loc.L("Settings.NoTranscriptionModelSelected")); return await OpenAiTranscriptionHelper.TranscribeAsync( _httpClient, - _baseUrl!, - _apiKey ?? "", - _selectedModelId!, + BaseUrl!, + ApiKey ?? "", + SelectedModelId!, wavAudio, language, translate, @@ -146,7 +147,7 @@ CancellationToken ct public string ProviderName => "OpenAI Compatible"; - public bool IsAvailable => IsConfigured && !string.IsNullOrEmpty(_selectedLlmModelId); + public bool IsAvailable => IsConfigured && !string.IsNullOrEmpty(SelectedLlmModelId); public IReadOnlyList SupportedModels { @@ -154,8 +155,8 @@ public IReadOnlyList SupportedModels { var models = _fetchedModels.Select(m => new PluginModelInfo(m.Id, m.Id)).ToList(); - if (models.Count == 0 && !string.IsNullOrEmpty(_selectedLlmModelId)) - return [new PluginModelInfo(_selectedLlmModelId, _selectedLlmModelId)]; + if (models.Count == 0 && !string.IsNullOrEmpty(SelectedLlmModelId)) + return [new PluginModelInfo(SelectedLlmModelId, SelectedLlmModelId)]; return models; } @@ -168,17 +169,17 @@ public async Task ProcessAsync( CancellationToken ct ) { - if (string.IsNullOrEmpty(_baseUrl)) + if (string.IsNullOrEmpty(BaseUrl)) throw new InvalidOperationException(Loc.L("Settings.ServerUrlNotConfigured")); - var modelId = !string.IsNullOrEmpty(model) ? model : _selectedLlmModelId ?? ""; + var modelId = !string.IsNullOrEmpty(model) ? model : SelectedLlmModelId ?? ""; if (string.IsNullOrEmpty(modelId)) throw new InvalidOperationException(Loc.L("Settings.NoLlmModelSelected")); return await OpenAiChatHelper.SendChatCompletionAsync( _httpClient, - _baseUrl!, - _apiKey ?? "", + BaseUrl!, + ApiKey ?? "", modelId, systemPrompt, userText, @@ -199,17 +200,17 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct yield break; } - if (string.IsNullOrEmpty(_baseUrl)) + if (string.IsNullOrEmpty(BaseUrl)) throw new InvalidOperationException(Loc.L("Settings.ServerUrlNotConfigured")); - var modelId = !string.IsNullOrEmpty(model) ? model : _selectedLlmModelId ?? ""; + var modelId = !string.IsNullOrEmpty(model) ? model : SelectedLlmModelId ?? ""; if (string.IsNullOrEmpty(modelId)) throw new InvalidOperationException(Loc.L("Settings.NoLlmModelSelected")); var source = OpenAiChatHelper.SendChatCompletionStreamingAsync( _httpClient, - _baseUrl!, - _apiKey ?? "", + BaseUrl!, + ApiKey ?? "", modelId, systemPrompt, userText, @@ -220,8 +221,10 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct yield return delta; } - internal string? BaseUrl => _baseUrl; - internal string? ApiKey => _apiKey; + internal string? BaseUrl { get; private set; } + + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -231,8 +234,9 @@ public void SetLocalization(IPluginLocalization localization) => // injected at load so settings labels/validation resolve even when this // plugin is disabled (never activated, so _host is null). internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; - internal string? SelectedTranscriptionModelId => _selectedModelId; - internal string? SelectedLlmModelId => _selectedLlmModelId; + internal string? SelectedTranscriptionModelId => SelectedModelId; + internal string? SelectedLlmModelId { get; private set; } + internal IReadOnlyList FetchedModels => _fetchedModels; internal void SetBaseUrl(string url) @@ -243,14 +247,14 @@ internal void SetBaseUrl(string url) var normalized = url.Trim().TrimEnd('/'); if (normalized.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) normalized = normalized[..^3]; - _baseUrl = normalized; + BaseUrl = normalized; _host?.SetSetting("baseUrl", normalized); _host?.NotifyCapabilitiesChanged(); } internal async Task SetApiKeyAsync(string key) { - _apiKey = string.IsNullOrWhiteSpace(key) ? null : key; + ApiKey = string.IsNullOrWhiteSpace(key) ? null : key; if (_host is not null) { if (string.IsNullOrWhiteSpace(key)) @@ -264,7 +268,7 @@ internal async Task SetApiKeyAsync(string key) internal void SelectLlmModel(string modelId) { - _selectedLlmModelId = modelId; + SelectedLlmModelId = modelId; _host?.SetSetting("selectedLlmModel", modelId); } @@ -298,14 +302,14 @@ internal void SetFetchedModels(List models, bool notifyCapabilitie // from "couldn't reach/parse the server." internal async Task?> FetchModelsAsync(CancellationToken ct = default) { - if (string.IsNullOrEmpty(_baseUrl)) + if (string.IsNullOrEmpty(BaseUrl)) return null; try { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}/v1/models"); - if (!string.IsNullOrEmpty(_apiKey)) - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/models"); + if (!string.IsNullOrEmpty(ApiKey)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); using var response = await _httpClient.SendAsync(request, ct); if (!response.IsSuccessStatusCode) @@ -338,14 +342,14 @@ internal void SetFetchedModels(List models, bool notifyCapabilitie internal async Task ValidateConnectionAsync(CancellationToken ct = default) { - if (string.IsNullOrEmpty(_baseUrl)) + if (string.IsNullOrEmpty(BaseUrl)) return false; try { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}/v1/models"); - if (!string.IsNullOrEmpty(_apiKey)) - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/models"); + if (!string.IsNullOrEmpty(ApiKey)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); using var response = await _httpClient.SendAsync(request, ct); return response.IsSuccessStatusCode; @@ -406,10 +410,10 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "baseUrl" => _baseUrl, - "api-key" => _apiKey, - "selectedModel" => _selectedModelId, - "selectedLlmModel" => _selectedLlmModelId, + "baseUrl" => BaseUrl, + "api-key" => ApiKey, + "selectedModel" => SelectedModelId, + "selectedLlmModel" => SelectedLlmModelId, LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false", _ => null, @@ -446,7 +450,7 @@ public async Task SetSettingValueAsync( public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_baseUrl)) + if (string.IsNullOrWhiteSpace(BaseUrl)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterBaseUrl")); var valid = await ValidateConnectionAsync(ct); @@ -456,9 +460,9 @@ public async Task SetSettingValueAsync( var models = await FetchModelsAsync(ct) ?? []; SetFetchedModels(models, notifyCapabilitiesChanged: false); - if (string.IsNullOrWhiteSpace(_selectedModelId) && models.Count > 0) + if (string.IsNullOrWhiteSpace(SelectedModelId) && models.Count > 0) SelectModel(models[0].Id); - if (string.IsNullOrWhiteSpace(_selectedLlmModelId) && models.Count > 0) + if (string.IsNullOrWhiteSpace(SelectedLlmModelId) && models.Count > 0) SelectLlmModel(models[0].Id); _host?.NotifyCapabilitiesChanged(); @@ -478,7 +482,7 @@ public async Task SetSettingValueAsync( // models clears the cache. public async Task RefreshModelCatalogAsync(CancellationToken ct = default) { - if (!string.IsNullOrEmpty(_baseUrl)) + if (!string.IsNullOrEmpty(BaseUrl)) { var models = await FetchModelsAsync(ct); if (models is not null && CatalogChanged(models, _fetchedModels)) @@ -511,15 +515,16 @@ private static bool CatalogChanged(List fetched, List new PluginSettingOption(m.Id, m.Id)).ToList(); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (models.Count == 0) { - if (!string.IsNullOrWhiteSpace(_selectedModelId)) - models.Add(new PluginSettingOption(_selectedModelId, _selectedModelId)); + if (!string.IsNullOrWhiteSpace(SelectedModelId)) + models.Add(new PluginSettingOption(SelectedModelId, SelectedModelId)); if ( - !string.IsNullOrWhiteSpace(_selectedLlmModelId) - && models.All(m => m.Value != _selectedLlmModelId) + !string.IsNullOrWhiteSpace(SelectedLlmModelId) + && models.All(m => m.Value != SelectedLlmModelId) ) - models.Add(new PluginSettingOption(_selectedLlmModelId, _selectedLlmModelId)); + models.Add(new PluginSettingOption(SelectedLlmModelId, SelectedLlmModelId)); } return models.Count > 0 ? models : null; @@ -542,7 +547,7 @@ private static bool CatalogChanged(List fetched, List GetCollectionDefinitions() => [ - new PluginCollectionDefinition( + new( Key: ProfilesCollectionKey, Label: Loc.L("Settings.ProfilesLabel"), Description: Loc.L("Settings.ProfilesDescription"), diff --git a/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs index b13a0846e..4db3a2629 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs @@ -1,5 +1,8 @@ -using System.IO; -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedType.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -13,7 +16,7 @@ public sealed class OpenAiVectorMemoryPlugin : IMemoryStoragePlugin, IPluginSett private const string EmbeddingModel = "text-embedding-3-small"; private const string EmbeddingUrl = "https://api.openai.com/v1/embeddings"; - private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; private readonly SemaphoreSlim _lock = new(1, 1); @@ -313,7 +316,7 @@ private async Task> LoadEntriesAsync(CancellationToken c { var json = await File.ReadAllTextAsync(_filePath, ct); _entries = - JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + JsonSerializer.Deserialize>(json, s_jsonOptions) ?? []; } catch (Exception ex) { @@ -341,7 +344,7 @@ private async Task SaveEntriesAsync(CancellationToken ct) if (dir is not null && !Directory.Exists(dir)) Directory.CreateDirectory(dir); - var json = JsonSerializer.Serialize(_entries, JsonOptions); + var json = JsonSerializer.Serialize(_entries, s_jsonOptions); // Write to a sibling temp file and atomically replace, so a crash // mid-write can't leave the vector store truncated. @@ -356,6 +359,7 @@ private async Task SaveEntriesAsync(CancellationToken ct) } catch { + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (File.Exists(tempPath)) { try { File.Delete(tempPath); } diff --git a/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs b/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs index 37b3b4e2f..018d367e4 100644 --- a/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs @@ -1,5 +1,9 @@ +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Globalization; -using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -31,24 +35,19 @@ public sealed class OpenRouterPlugin private const string LegacyFallbackDefaultLlmModelId = "openai/gpt-4o"; internal const string DefaultTranscriptionModelId = "openai/whisper-large-v3-turbo"; - private static readonly JsonSerializerOptions JsonOptions = new() + private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly HttpClient _httpClient; private IPluginHostServices? _host; - private string? _apiKey; - private string? _selectedTranscriptionModelId; - private string? _selectedLlmModelId; private bool _hasUserSelectedLlmModel; - private string _temperatureMode = TemperatureModeProviderDefault; - private double _temperatureValue = 0.3; private List _fetchedTranscriptionModels = []; private List _fetchedModels = []; private bool _streamResponses = true; - private static readonly IReadOnlyList FallbackTranscriptionModels = + private static readonly IReadOnlyList s_fallbackTranscriptionModels = [ new(DefaultTranscriptionModelId, "OpenAI: Whisper Large V3 Turbo") { IsRecommended = true }, new("openai/whisper-large-v3", "OpenAI: Whisper Large V3"), @@ -58,7 +57,7 @@ public sealed class OpenRouterPlugin new("google/chirp-3", "Google: Chirp 3"), ]; - private static readonly IReadOnlyList FallbackModels = + private static readonly IReadOnlyList s_fallbackModels = [ new(DefaultLlmModelId, DefaultLlmModelName) { IsRecommended = true }, new(LegacyFallbackDefaultLlmModelId, "OpenAI: GPT-4o"), @@ -67,7 +66,7 @@ public sealed class OpenRouterPlugin new("meta-llama/llama-3.3-70b-instruct", "Meta: Llama 3.3 70B"), ]; - private static readonly OpenRouterFetchedModel DefaultFetchedModel = + private static readonly OpenRouterFetchedModel s_defaultFetchedModel = new(DefaultLlmModelId, DefaultLlmModelName, "0", "0"); public OpenRouterPlugin() @@ -89,16 +88,16 @@ internal OpenRouterPlugin(HttpClient httpClient) public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); + ApiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); _fetchedTranscriptionModels = NormalizeFetchedTranscriptionModels( host.GetSetting>(FetchedTranscriptionModelsSettingName) ?? []); - _selectedTranscriptionModelId = host.GetSetting(SelectedTranscriptionModelSettingName); + SelectedModelId = host.GetSetting(SelectedTranscriptionModelSettingName); _fetchedModels = NormalizeFetchedModels( host.GetSetting>(FetchedModelsSettingName) ?? []); - _selectedLlmModelId = host.GetSetting(SelectedLlmModelSettingName); + SelectedLlmModelId = host.GetSetting(SelectedLlmModelSettingName); _hasUserSelectedLlmModel = host.GetSetting(UserSelectedLlmModelSettingName) == true; - _temperatureMode = NormalizeTemperatureMode(host.GetSetting(TemperatureModeSettingName)); - _temperatureValue = NormalizeTemperatureValue(host.GetSetting(TemperatureValueSettingName)); + TemperatureMode = NormalizeTemperatureMode(host.GetSetting(TemperatureModeSettingName)); + TemperatureValue = NormalizeTemperatureValue(host.GetSetting(TemperatureValueSettingName)); _streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true; NormalizeSelectedTranscriptionModel(persist: true); NormalizeSelectedLlmModel(persist: true); @@ -120,9 +119,10 @@ public Task DeactivateAsync() public IReadOnlyList TranscriptionModels => _fetchedTranscriptionModels.Count > 0 ? _fetchedTranscriptionModels.Select(model => new PluginModelInfo(model.Id, model.Name)).ToList() - : FallbackTranscriptionModels; + : s_fallbackTranscriptionModels; + + public string? SelectedModelId { get; private set; } - public string? SelectedModelId => _selectedTranscriptionModelId; public bool SupportsTranslation => false; public void SelectModel(string modelId) @@ -130,7 +130,7 @@ public void SelectModel(string modelId) if (TranscriptionModels.All(model => !string.Equals(model.Id, modelId, StringComparison.Ordinal))) throw new ArgumentException($"Unknown model: {modelId}"); - _selectedTranscriptionModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting(SelectedTranscriptionModelSettingName, modelId); } @@ -147,19 +147,19 @@ public async Task TranscribeAsync( if (!IsConfigured) throw new InvalidOperationException(Loc.L("Settings.NotConfiguredApiKeyRequired")); - var modelId = _selectedTranscriptionModelId ?? TranscriptionModels[0].Id; + var modelId = SelectedModelId ?? TranscriptionModels[0].Id; return await SendAudioTranscriptionAsync(modelId, wavAudio, NormalizeLanguage(language), ct); } // ILlmProviderPlugin public string ProviderName => "OpenRouter"; - public bool IsAvailable => !string.IsNullOrEmpty(_apiKey); + public bool IsAvailable => !string.IsNullOrEmpty(ApiKey); public IReadOnlyList SupportedModels => _fetchedModels.Count > 0 ? _fetchedModels.Select(model => new PluginModelInfo(model.Id, model.Name)).ToList() - : FallbackModels; + : s_fallbackModels; public async Task ProcessAsync(string systemPrompt, string userText, string model, CancellationToken ct) { @@ -167,7 +167,7 @@ public async Task ProcessAsync(string systemPrompt, string userText, str throw new InvalidOperationException(Loc.L("Settings.ApiKeyNotConfigured")); var modelId = string.IsNullOrWhiteSpace(model) - ? _selectedLlmModelId ?? SupportedModels[0].Id + ? SelectedLlmModelId ?? SupportedModels[0].Id : model; return await SendChatCompletionAsync(modelId, systemPrompt, userText, ct); @@ -189,7 +189,7 @@ public async IAsyncEnumerable ProcessStreamingAsync( throw new InvalidOperationException(Loc.L("Settings.ApiKeyNotConfigured")); var modelId = string.IsNullOrWhiteSpace(model) - ? _selectedLlmModelId ?? SupportedModels[0].Id + ? SelectedLlmModelId ?? SupportedModels[0].Id : model; // OpenRouter's batch body emits the same chat.completion shape as the @@ -199,13 +199,13 @@ public async IAsyncEnumerable ProcessStreamingAsync( var source = OpenAiChatHelper.SendChatCompletionStreamingAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, modelId, systemPrompt, userText, ct, maxOutputTokens: 2048, - temperature: _temperatureMode == TemperatureModeCustom ? _temperatureValue : (double?)null); + temperature: TemperatureMode == TemperatureModeCustom ? TemperatureValue : null); await foreach (var delta in source.WithCancellation(ct)) yield return delta; @@ -213,7 +213,8 @@ public async IAsyncEnumerable ProcessStreamingAsync( // API key / catalog management - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -224,18 +225,20 @@ public void SetLocalization(IPluginLocalization localization) => // plugin is disabled (never activated, so _host is null). internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; internal IReadOnlyList FetchedTranscriptionModels => _fetchedTranscriptionModels; - internal string? SelectedLlmModelId => _selectedLlmModelId; + internal string? SelectedLlmModelId { get; private set; } + internal IReadOnlyList FetchedModels => _fetchedModels; - internal string TemperatureMode => _temperatureMode; - internal double TemperatureValue => _temperatureValue; + internal string TemperatureMode { get; private set; } = TemperatureModeProviderDefault; + + internal double TemperatureValue { get; private set; } = 0.3; internal async Task SetApiKeyAsync(string apiKey) { var normalized = NormalizeApiKey(apiKey); var wasAvailable = IsAvailable; - var changed = !string.Equals(_apiKey, normalized, StringComparison.Ordinal); + var changed = !string.Equals(ApiKey, normalized, StringComparison.Ordinal); - _apiKey = normalized; + ApiKey = normalized; if (_host is not null) { if (normalized is null) @@ -273,7 +276,7 @@ internal void SelectLlmModel(string modelId) if (SupportedModels.All(model => !string.Equals(model.Id, modelId, StringComparison.Ordinal))) modelId = (SupportedModels.Count > 0 ? SupportedModels[0] : null)?.Id ?? modelId; - _selectedLlmModelId = modelId; + SelectedLlmModelId = modelId; _host?.SetSetting(SelectedLlmModelSettingName, modelId); _hasUserSelectedLlmModel = true; _host?.SetSetting(UserSelectedLlmModelSettingName, true); @@ -297,21 +300,21 @@ internal void SetFetchedTranscriptionModels(List models) internal void SetTemperatureMode(string? mode) { - _temperatureMode = NormalizeTemperatureMode(mode); - _host?.SetSetting(TemperatureModeSettingName, _temperatureMode); + TemperatureMode = NormalizeTemperatureMode(mode); + _host?.SetSetting(TemperatureModeSettingName, TemperatureMode); } internal void SetTemperatureValue(double value) { - _temperatureValue = NormalizeTemperatureValue(value); - _host?.SetSetting(TemperatureValueSettingName, _temperatureValue); + TemperatureValue = NormalizeTemperatureValue(value); + _host?.SetSetting(TemperatureValueSettingName, TemperatureValue); } internal async Task> FetchModelsAsync(CancellationToken ct = default) { using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/models"); - if (!string.IsNullOrWhiteSpace(_apiKey)) - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + if (!string.IsNullOrWhiteSpace(ApiKey)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); try { @@ -320,7 +323,7 @@ internal async Task> FetchModelsAsync(CancellationT return []; var json = await response.Content.ReadAsStringAsync(ct); - var decoded = JsonSerializer.Deserialize(json, JsonOptions); + var decoded = JsonSerializer.Deserialize(json, s_jsonOptions); // System.Text.Json happily deserializes `{}` or `{"data": null}` // into a record whose non-nullable Data field is null — the @@ -366,8 +369,8 @@ internal async Task> FetchTranscriptionModelsAsync( using var request = new HttpRequestMessage( HttpMethod.Get, $"{BaseUrl}/v1/models?output_modalities=transcription"); - if (!string.IsNullOrWhiteSpace(_apiKey)) - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + if (!string.IsNullOrWhiteSpace(ApiKey)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); try { @@ -376,7 +379,7 @@ internal async Task> FetchTranscriptionModelsAsync( return []; var json = await response.Content.ReadAsStringAsync(ct); - var decoded = JsonSerializer.Deserialize(json, JsonOptions); + var decoded = JsonSerializer.Deserialize(json, s_jsonOptions); var data = decoded?.Data ?? []; var models = data @@ -411,11 +414,11 @@ internal async Task> FetchTranscriptionModelsAsync( internal async Task FetchCreditsAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return null; using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/auth/key"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); try { @@ -509,11 +512,11 @@ private async Task SendChatCompletionAsync( ["max_tokens"] = 2048, }; - if (_temperatureMode == TemperatureModeCustom) - body["temperature"] = _temperatureValue; + if (TemperatureMode == TemperatureModeCustom) + body["temperature"] = TemperatureValue; using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/chat/completions"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); var response = await OpenAiApiHelper.SendWithErrorHandlingAsync(_httpClient, request, ct); @@ -558,7 +561,7 @@ private async Task SendAudioTranscriptionAsync( body["language"] = language; using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/audio/transcriptions"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); var response = await OpenAiApiHelper.SendWithErrorHandlingAsync(_httpClient, request, ct); @@ -602,15 +605,15 @@ private void NormalizeSelectedTranscriptionModel(bool persist) if (available.Count == 0) return; - if (_selectedTranscriptionModelId is not null - && available.Any(model => string.Equals(model.Id, _selectedTranscriptionModelId, StringComparison.Ordinal))) + if (SelectedModelId is not null + && available.Any(model => string.Equals(model.Id, SelectedModelId, StringComparison.Ordinal))) { return; } - _selectedTranscriptionModelId = available[0].Id; + SelectedModelId = available[0].Id; if (persist) - _host?.SetSetting(SelectedTranscriptionModelSettingName, _selectedTranscriptionModelId); + _host?.SetSetting(SelectedTranscriptionModelSettingName, SelectedModelId); } private void NormalizeSelectedLlmModel(bool persist) @@ -630,14 +633,15 @@ private void NormalizeSelectedLlmModel(bool persist) // review caught this — upstream's verbatim version triggered the // migration on any saved selection that predated the new // userSelectedLlmModel marker.) - if (string.IsNullOrWhiteSpace(_selectedLlmModelId) - || string.Equals(_selectedLlmModelId, LegacyFallbackDefaultLlmModelId, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrWhiteSpace(SelectedLlmModelId) + || string.Equals(SelectedLlmModelId, LegacyFallbackDefaultLlmModelId, StringComparison.OrdinalIgnoreCase)) { - _selectedLlmModelId = available[0].Id; + SelectedLlmModelId = available[0].Id; _hasUserSelectedLlmModel = false; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (persist) { - _host?.SetSetting(SelectedLlmModelSettingName, _selectedLlmModelId); + _host?.SetSetting(SelectedLlmModelSettingName, SelectedLlmModelId); _host?.SetSetting(UserSelectedLlmModelSettingName, false); } return; @@ -663,12 +667,12 @@ private void NormalizeSelectedLlmModel(bool persist) // back to the first available entry but leave the user-selection // flag set — the user is still in "I have a preference" mode, // we just can't honor their specific pick. - if (available.Any(model => string.Equals(model.Id, _selectedLlmModelId, StringComparison.Ordinal))) + if (available.Any(model => string.Equals(model.Id, SelectedLlmModelId, StringComparison.Ordinal))) return; - _selectedLlmModelId = available[0].Id; + SelectedLlmModelId = available[0].Id; if (persist) - _host?.SetSetting(SelectedLlmModelSettingName, _selectedLlmModelId); + _host?.SetSetting(SelectedLlmModelSettingName, SelectedLlmModelId); } private static List NormalizeFetchedModels(IEnumerable models) @@ -683,7 +687,7 @@ private static List NormalizeFetchedModels(IEnumerable NormalizeFetchedTranscriptionModels(IEnumerable models) => @@ -814,11 +818,11 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - ApiKeySecretName => _apiKey, - SelectedTranscriptionModelSettingName => _selectedTranscriptionModelId, - SelectedLlmModelSettingName => _selectedLlmModelId, - TemperatureModeSettingName => _temperatureMode, - TemperatureValueSettingName => _temperatureValue.ToString(CultureInfo.InvariantCulture), + ApiKeySecretName => ApiKey, + SelectedTranscriptionModelSettingName => SelectedModelId, + SelectedLlmModelSettingName => SelectedLlmModelId, + TemperatureModeSettingName => TemperatureMode, + TemperatureValueSettingName => TemperatureValue.ToString(CultureInfo.InvariantCulture), LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false", _ => null, @@ -875,10 +879,10 @@ private static bool ParseBool(string? value) => public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); - var valid = await ValidateApiKeyAsync(_apiKey, ct); + var valid = await ValidateApiKeyAsync(ApiKey, ct); if (!valid) return new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid")); diff --git a/plugins/TypeWhisper.Plugin.Qwen3Stt/Qwen3SttPlugin.cs b/plugins/TypeWhisper.Plugin.Qwen3Stt/Qwen3SttPlugin.cs index edf5242d2..faea7f05f 100644 --- a/plugins/TypeWhisper.Plugin.Qwen3Stt/Qwen3SttPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Qwen3Stt/Qwen3SttPlugin.cs @@ -1,12 +1,15 @@ -using System.Net.Http; -using System.Net.Http.Headers; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedType.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Helpers; using TypeWhisper.PluginSDK.Models; namespace TypeWhisper.Plugin.Qwen3Stt; -public sealed partial class Qwen3SttPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class Qwen3SttPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware { private const string DefaultBaseUrl = "http://localhost:8000"; private const string DefaultModel = "Qwen/Qwen3-ASR"; @@ -15,7 +18,6 @@ public sealed partial class Qwen3SttPlugin : ITranscriptionEnginePlugin, IPlugin private IPluginHostServices? _host; private string? _apiKey; private string? _baseUrl; - private string? _selectedModelId; public string PluginId => "com.typewhisper.qwen3-stt"; public string PluginName => "Qwen3 STT"; @@ -28,7 +30,7 @@ public async Task ActivateAsync(IPluginHostServices host) _baseUrl = host.GetSetting("baseUrl"); if (string.IsNullOrWhiteSpace(_baseUrl)) _baseUrl = DefaultBaseUrl; - _selectedModelId = host.GetSetting("selectedModel") ?? DefaultModel; + SelectedModelId = host.GetSetting("selectedModel") ?? DefaultModel; host.Log(PluginLogLevel.Info, $"Activated (baseUrl={_baseUrl}, configured={IsConfigured})"); } @@ -43,16 +45,17 @@ public Task DeactivateAsync() public bool IsConfigured => !string.IsNullOrEmpty(_baseUrl); public IReadOnlyList TranscriptionModels { get; } = - [new PluginModelInfo("Qwen/Qwen3-ASR", "Qwen3 ASR")]; + [new("Qwen/Qwen3-ASR", "Qwen3 ASR")]; + + public string? SelectedModelId { get; private set; } - public string? SelectedModelId => _selectedModelId; public bool SupportsTranslation => false; public void SelectModel(string modelId) { if (modelId != DefaultModel) throw new ArgumentException($"Unknown model: {modelId}"); - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); } @@ -74,7 +77,7 @@ CancellationToken ct var baseUrl = _baseUrl ?? DefaultBaseUrl; var apiKey = _apiKey ?? ""; - var model = _selectedModelId ?? DefaultModel; + var model = SelectedModelId ?? DefaultModel; return await OpenAiTranscriptionHelper.TranscribeAsync( _httpClient, @@ -159,7 +162,7 @@ public IReadOnlyList GetSettingDefinitions() => { "baseUrl" => _baseUrl, "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "selectedModel" => SelectedModelId, _ => null, } ); diff --git a/plugins/TypeWhisper.Plugin.Reson8/Reson8CustomModel.cs b/plugins/TypeWhisper.Plugin.Reson8/Reson8CustomModel.cs index 5f98567af..97eaf5be0 100644 --- a/plugins/TypeWhisper.Plugin.Reson8/Reson8CustomModel.cs +++ b/plugins/TypeWhisper.Plugin.Reson8/Reson8CustomModel.cs @@ -1,3 +1,7 @@ +// ReSharper disable NotAccessedPositionalProperty.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + namespace TypeWhisper.Plugin.Reson8; public sealed record Reson8CustomModel( diff --git a/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs b/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs index 8067cb11f..6ee5510b8 100644 --- a/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs +++ b/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs @@ -1,5 +1,9 @@ +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net; -using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -20,24 +24,20 @@ public sealed class Reson8Plugin : ITranscriptionEnginePlugin, IPluginSettingsPr private const string CustomAuthHeaderSettingName = "customAuthHeader"; private const string FetchedCustomModelsSettingName = "fetchedCustomModels"; - private static readonly IReadOnlyList Languages = + private static readonly IReadOnlyList s_languages = [ - "nl", "en", "fr", "de", "it", "pl", "pt", "es", "sv" + "nl", "en", "fr", "de", "it", "pl", "pt", "es", "sv", ]; - private static readonly JsonSerializerOptions JsonOptions = new() + private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly HttpClient _httpClient; private readonly SemaphoreSlim _apiKeyWriteLock = new(1, 1); private IPluginHostServices? _host; - private string? _apiKey; private string _selectedModelId = DefaultModelId; - private string _customBaseUrl = DefaultBaseUrl; - private string _customAuthHeader = DefaultAuthHeader; - private IReadOnlyList _fetchedCustomModels = []; public Reson8Plugin() : this(CreateHttpClient()) @@ -56,10 +56,10 @@ internal Reson8Plugin(HttpClient httpClient) public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); - _customBaseUrl = NormalizeBaseUrl(host.GetSetting(CustomBaseUrlSettingName)); - _customAuthHeader = NormalizeAuthHeader(host.GetSetting(CustomAuthHeaderSettingName)); - _fetchedCustomModels = host.GetSetting>(FetchedCustomModelsSettingName) ?? []; + ApiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); + CustomBaseUrl = NormalizeBaseUrl(host.GetSetting(CustomBaseUrlSettingName)); + CustomAuthHeader = NormalizeAuthHeader(host.GetSetting(CustomAuthHeaderSettingName)); + FetchedCustomModels = host.GetSetting>(FetchedCustomModelsSettingName) ?? []; _selectedModelId = NormalizeModelId(host.GetSetting(SelectedModelSettingName)); host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); } @@ -72,18 +72,22 @@ public Task DeactivateAsync() public string ProviderId => "reson8"; public string ProviderDisplayName => "Reson8"; - public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); + public bool IsConfigured => !string.IsNullOrEmpty(ApiKey); public IReadOnlyList TranscriptionModels => - [new PluginModelInfo(DefaultModelId, Loc.L("Settings.DefaultModel")), .. _fetchedCustomModels.Select(m => new PluginModelInfo(m.Id, m.Name))]; + [new(DefaultModelId, Loc.L("Settings.DefaultModel")), .. FetchedCustomModels.Select(m => new PluginModelInfo(m.Id, m.Name))]; public string? SelectedModelId => _selectedModelId; public bool SupportsTranslation => false; public bool SupportsStreaming => true; - public IReadOnlyList SupportedLanguages => Languages; + public IReadOnlyList SupportedLanguages => s_languages; + + internal string? ApiKey { get; private set; } + + internal string CustomBaseUrl { get; private set; } = DefaultBaseUrl; + + internal string CustomAuthHeader { get; private set; } = DefaultAuthHeader; + + internal IReadOnlyList FetchedCustomModels { get; private set; } = []; - internal string? ApiKey => _apiKey; - internal string CustomBaseUrl => _customBaseUrl; - internal string CustomAuthHeader => _customAuthHeader; - internal IReadOnlyList FetchedCustomModels => _fetchedCustomModels; private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -117,8 +121,8 @@ public async Task TranscribeAsync( var pcm16 = WavPcm16Extractor.ExtractPcm16(wavAudio); using var request = new HttpRequestMessage( HttpMethod.Post, - BuildPrerecordedUri(_customBaseUrl, _selectedModelId, NormalizeLanguage(language))); - AddAuthHeader(request, _apiKey!, _customAuthHeader); + BuildPrerecordedUri(CustomBaseUrl, _selectedModelId, NormalizeLanguage(language))); + AddAuthHeader(request, ApiKey!, CustomAuthHeader); request.Content = new ByteArrayContent(pcm16); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); @@ -194,9 +198,9 @@ public async Task StartStreamingAsync(string? language, Cance throw new InvalidOperationException(Loc.L("Settings.NotConfiguredApiKeyRequired")); return await Reson8StreamingSession.ConnectAsync( - _apiKey!, - _customBaseUrl, - _customAuthHeader, + ApiKey!, + CustomBaseUrl, + CustomAuthHeader, _selectedModelId, NormalizeLanguage(language), ct); @@ -214,8 +218,8 @@ public IReadOnlyList GetSettingDefinitions() => new( Key: SelectedModelSettingName, Label: Loc.L("Settings.Model"), - Description: _fetchedCustomModels.Count > 0 - ? Loc.L("Settings.CustomModelsLoaded", _fetchedCustomModels.Count) + Description: FetchedCustomModels.Count > 0 + ? Loc.L("Settings.CustomModelsLoaded", FetchedCustomModels.Count) : Loc.L("Settings.NoCustomModels"), Options: TranscriptionModels .Select(m => new PluginSettingOption(m.Id, m.DisplayName)) @@ -239,10 +243,10 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - ApiKeySecretName => _apiKey, + ApiKeySecretName => ApiKey, SelectedModelSettingName => _selectedModelId, - CustomBaseUrlSettingName => _customBaseUrl == DefaultBaseUrl ? null : _customBaseUrl, - CustomAuthHeaderSettingName => _customAuthHeader == DefaultAuthHeader ? null : _customAuthHeader, + CustomBaseUrlSettingName => CustomBaseUrl == DefaultBaseUrl ? null : CustomBaseUrl, + CustomAuthHeaderSettingName => CustomAuthHeader == DefaultAuthHeader ? null : CustomAuthHeader, _ => null, }); @@ -270,10 +274,10 @@ public async Task SetSettingValueAsync(string key, string? value, CancellationTo public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrEmpty(_apiKey)) + if (string.IsNullOrEmpty(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); - var valid = await ValidateApiKeyAsync(_apiKey, ct); + var valid = await ValidateApiKeyAsync(ApiKey, ct); if (!valid) return new PluginSettingsValidationResult(false, Loc.L("Settings.InvalidApiKey")); @@ -299,7 +303,7 @@ internal async Task SetApiKeyAsync(string apiKey) try { var wasConfigured = IsConfigured; - var changed = !string.Equals(_apiKey, normalized, StringComparison.Ordinal); + var changed = !string.Equals(ApiKey, normalized, StringComparison.Ordinal); if (!changed) return; @@ -317,7 +321,7 @@ internal async Task SetApiKeyAsync(string apiKey) // Update in-memory state only after the secret write/delete // succeeds, so a failing store leaves the plugin unconfigured (no // unsaved key) and a failing delete keeps the running key intact. - _apiKey = normalized; + ApiKey = normalized; if (wasConfigured == IsConfigured) hostToNotify = null; @@ -338,8 +342,8 @@ internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken c using var request = new HttpRequestMessage( HttpMethod.Post, - BuildPrerecordedUri(_customBaseUrl, DefaultModelId, language: null)); - AddAuthHeader(request, normalized, _customAuthHeader); + BuildPrerecordedUri(CustomBaseUrl, DefaultModelId, language: null)); + AddAuthHeader(request, normalized, CustomAuthHeader); request.Content = new ByteArrayContent([]); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); @@ -352,7 +356,6 @@ internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken c { return false; } - catch (OperationCanceledException) { throw; } catch (HttpRequestException) { return false; @@ -368,8 +371,8 @@ internal async Task> FetchCustomModelsAsync(Can if (!IsConfigured) return []; - using var request = new HttpRequestMessage(HttpMethod.Get, $"{_customBaseUrl}/v1/custom-model"); - AddAuthHeader(request, _apiKey!, _customAuthHeader); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{CustomBaseUrl}/v1/custom-model"); + AddAuthHeader(request, ApiKey!, CustomAuthHeader); try { @@ -378,7 +381,7 @@ internal async Task> FetchCustomModelsAsync(Can return []; var json = await response.Content.ReadAsStringAsync(ct); - return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + return JsonSerializer.Deserialize>(json, s_jsonOptions) ?? []; } catch (JsonException) { @@ -392,10 +395,10 @@ internal async Task> FetchCustomModelsAsync(Can internal void SetFetchedCustomModels(IReadOnlyList models) { - _fetchedCustomModels = models.ToArray(); - _host?.SetSetting(FetchedCustomModelsSettingName, _fetchedCustomModels); + FetchedCustomModels = models.ToArray(); + _host?.SetSetting(FetchedCustomModelsSettingName, FetchedCustomModels); - if (_selectedModelId != DefaultModelId && _fetchedCustomModels.All(m => m.Id != _selectedModelId)) + if (_selectedModelId != DefaultModelId && FetchedCustomModels.All(m => m.Id != _selectedModelId)) { _selectedModelId = DefaultModelId; _host?.SetSetting(SelectedModelSettingName, _selectedModelId); @@ -406,14 +409,14 @@ internal void SetFetchedCustomModels(IReadOnlyList models) internal void SetCustomBaseUrl(string? url) { - _customBaseUrl = NormalizeBaseUrl(url); - _host?.SetSetting(CustomBaseUrlSettingName, _customBaseUrl == DefaultBaseUrl ? null : _customBaseUrl); + CustomBaseUrl = NormalizeBaseUrl(url); + _host?.SetSetting(CustomBaseUrlSettingName, CustomBaseUrl == DefaultBaseUrl ? null : CustomBaseUrl); } internal void SetCustomAuthHeader(string? header) { - _customAuthHeader = NormalizeAuthHeader(header); - _host?.SetSetting(CustomAuthHeaderSettingName, _customAuthHeader == DefaultAuthHeader ? null : _customAuthHeader); + CustomAuthHeader = NormalizeAuthHeader(header); + _host?.SetSetting(CustomAuthHeaderSettingName, CustomAuthHeader == DefaultAuthHeader ? null : CustomAuthHeader); } internal static Uri BuildPrerecordedUri(string baseUrl, string? modelId, string? language) @@ -422,7 +425,7 @@ internal static Uri BuildPrerecordedUri(string baseUrl, string? modelId, string? { "encoding=pcm_s16le", "sample_rate=16000", - "channels=1" + "channels=1", }; if (!string.IsNullOrWhiteSpace(language)) @@ -586,7 +589,7 @@ public static byte[] ExtractPcm16(byte[] wavAudio) data = wavAudio.Skip(offset).Take(chunkSize).ToArray(); } - offset += chunkSize + (chunkSize % 2); + offset += chunkSize + chunkSize % 2; } if (data is null) diff --git a/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs b/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs index 907f4722e..3b1f33d03 100644 --- a/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.IO; using System.Net.WebSockets; using System.Text; using System.Text.Json; @@ -57,7 +56,7 @@ public static Uri BuildRealtimeUri(string baseUrl, string? modelId, string? lang var builder = new UriBuilder(baseUri) { Scheme = baseUri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ? "ws" : "wss", - Path = $"{basePath}/v1/speech-to-text/realtime" + Path = $"{basePath}/v1/speech-to-text/realtime", }; var query = new List @@ -65,7 +64,7 @@ public static Uri BuildRealtimeUri(string baseUrl, string? modelId, string? lang "encoding=pcm_s16le", "sample_rate=16000", "channels=1", - "include_interim=true" + "include_interim=true", }; if (!string.IsNullOrWhiteSpace(language) @@ -88,7 +87,7 @@ public static IReadOnlyDictionary CreateStreamingHeaders(string new Dictionary { [string.IsNullOrWhiteSpace(authHeader) ? Reson8Plugin.DefaultAuthHeader : authHeader.Trim()] = - Reson8Plugin.AuthHeaderValue(apiKey, authHeader) + Reson8Plugin.AuthHeaderValue(apiKey, authHeader), }; public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationToken ct) diff --git a/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs b/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs index 53b49fa52..e4443acdc 100644 --- a/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs @@ -1,6 +1,10 @@ +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Collections.ObjectModel; using System.Diagnostics; -using System.IO; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -85,10 +89,12 @@ public void Save(IEnumerable entries) } catch { - if (File.Exists(tempPath)) + if (!File.Exists(tempPath)) { - try { File.Delete(tempPath); } catch { /* best effort */ } + throw; } + + try { File.Delete(tempPath); } catch { /* best effort */ } throw; } } @@ -118,44 +124,52 @@ public void AddScript(ScriptEntry script) public void RemoveScript(Guid id) { var script = Scripts.FirstOrDefault(s => s.Id == id); - if (script is not null) + if (script is null) { - Scripts.Remove(script); - Save(); + return; } + + Scripts.Remove(script); + Save(); } public void UpdateScript(ScriptEntry updated) { for (var i = 0; i < Scripts.Count; i++) { - if (Scripts[i].Id == updated.Id) + if (Scripts[i].Id != updated.Id) { - Scripts[i] = updated; - Save(); - return; + continue; } + + Scripts[i] = updated; + Save(); + return; } } public void MoveUp(Guid id) { var index = IndexOf(id); - if (index > 0) + if (index <= 0) { - Scripts.Move(index, index - 1); - Save(); + return; } + + Scripts.Move(index, index - 1); + Save(); } public void MoveDown(Guid id) { var index = IndexOf(id); - if (index >= 0 && index < Scripts.Count - 1) + if (index < 0 || index >= Scripts.Count - 1) { - Scripts.Move(index, index + 1); - Save(); + return; } + + Scripts.Move(index, index + 1); + Save(); } public async Task RunScriptsAsync( diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaCudaRuntimeInstaller.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaCudaRuntimeInstaller.cs index 19677491e..03b58d81d 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaCudaRuntimeInstaller.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaCudaRuntimeInstaller.cs @@ -1,5 +1,7 @@ -using System.IO; -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Security.Cryptography; using SharpCompress.Readers; using TypeWhisper.Plugins.Shared.Net; @@ -46,13 +48,14 @@ internal class SherpaCudaRuntimeInstaller // use the CUDA execution provider, and it adds nothing but bulk. // internal (not private) so a regression test can assert the CUDA provider is // extracted here even though it must never be preloaded (see SherpaOnnxNativeRuntime). + // ReSharper disable once InconsistentNaming -- internal static field is part of the test-observable API; PascalCase intended. internal static readonly string[] CoreRuntimeFiles = [ "libsherpa-onnx-c-api.so", "libsherpa-onnx-cxx-api.so", "libonnxruntime.so", "libonnxruntime_providers_shared.so", - "libonnxruntime_providers_cuda.so" + "libonnxruntime_providers_cuda.so", ]; private readonly string _runtimeRoot; @@ -197,6 +200,7 @@ private Task DownloadAsync(string destination, IProgress? progress, Canc void OnBytesOnDisk(long onDisk) { var now = DateTime.UtcNow; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if ((now - lastReport).TotalMilliseconds > 250) { progress?.Report(Math.Min(1.0, (double)onDisk / ApproxDownloadBytes)); diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs index 407106918..b30fcebd1 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs @@ -1,4 +1,3 @@ -using System.IO; using System.Reflection; using System.Runtime.InteropServices; using SherpaOnnx; @@ -42,14 +41,15 @@ internal static class SherpaOnnxNativeRuntime // (→ CPU fallback) instead of a crash. // internal (not private) so a regression test can assert the CUDA provider is // never reintroduced here (see the §6 invariant in the comment above). + // ReSharper disable once InconsistentNaming -- internal static field is part of the test-observable API; PascalCase intended. internal static readonly string[] PreloadOrder = [ "libonnxruntime_providers_shared.so", "libonnxruntime.so", - "libsherpa-onnx-cxx-api.so" + "libsherpa-onnx-cxx-api.so", ]; - private static readonly object Sync = new(); + private static readonly Lock s_sync = new(); private static bool _resolverRegistered; private static string? _cudaRuntimeDirectory; @@ -61,7 +61,7 @@ internal static class SherpaOnnxNativeRuntime /// public static void RegisterResolver() { - lock (Sync) + lock (s_sync) { if (_resolverRegistered) return; @@ -84,7 +84,7 @@ public static void ConfigureCudaRuntime(string runtimeDirectory) if (string.IsNullOrWhiteSpace(runtimeDirectory)) throw new ArgumentException("Runtime directory is required.", nameof(runtimeDirectory)); - lock (Sync) + lock (s_sync) { if (!_resolverRegistered) { @@ -102,6 +102,7 @@ public static void ConfigureCudaRuntime(string runtimeDirectory) continue; var handle = dlopen(path, RtldNow | RtldGlobal); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (handle == IntPtr.Zero) { var error = Marshal.PtrToStringAnsi(dlerror()); diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs index cacfcafa2..5c4795266 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs @@ -1,6 +1,4 @@ using System.Diagnostics; -using System.IO; -using System.Net.Http; using System.Runtime.InteropServices; using System.Text.Json; using SherpaOnnx; @@ -18,7 +16,7 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP private const string CanaryRepo = "https://huggingface.co/csukuangfj/sherpa-onnx-nemo-canary-180m-flash-en-es-de-fr-int8/resolve/main"; - private static readonly IReadOnlyList CanarySupportedLanguages = + private static readonly IReadOnlyList s_canarySupportedLanguages = [ "en", "de", @@ -26,7 +24,7 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP "es", ]; - private static readonly IReadOnlyList Models = + private static readonly IReadOnlyList s_models = [ new( "parakeet-tdt-0.6b", @@ -37,10 +35,10 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP true, false, [ - new("encoder.int8.onnx", $"{ParakeetRepo}/encoder.int8.onnx", 652), - new("decoder.int8.onnx", $"{ParakeetRepo}/decoder.int8.onnx", 12), - new("joiner.int8.onnx", $"{ParakeetRepo}/joiner.int8.onnx", 6), - new("tokens.txt", $"{ParakeetRepo}/tokens.txt", 1), + new ModelFileDefinition("encoder.int8.onnx", $"{ParakeetRepo}/encoder.int8.onnx", 652), + new ModelFileDefinition("decoder.int8.onnx", $"{ParakeetRepo}/decoder.int8.onnx", 12), + new ModelFileDefinition("joiner.int8.onnx", $"{ParakeetRepo}/joiner.int8.onnx", 6), + new ModelFileDefinition("tokens.txt", $"{ParakeetRepo}/tokens.txt", 1), ] ), new( @@ -52,14 +50,14 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP false, true, [ - new("encoder.int8.onnx", $"{CanaryRepo}/encoder.int8.onnx", 127), - new("decoder.int8.onnx", $"{CanaryRepo}/decoder.int8.onnx", 71), - new("tokens.txt", $"{CanaryRepo}/tokens.txt", 1), + new ModelFileDefinition("encoder.int8.onnx", $"{CanaryRepo}/encoder.int8.onnx", 127), + new ModelFileDefinition("decoder.int8.onnx", $"{CanaryRepo}/decoder.int8.onnx", 71), + new ModelFileDefinition("tokens.txt", $"{CanaryRepo}/tokens.txt", 1), ] ), ]; - private readonly object _sync = new(); + private readonly Lock _sync = new(); // Drives the model-file downloads and the on-demand CUDA runtime fetches (the // ~224 MB sherpa tarball plus CUDA wheels up to ~685 MB). HttpClient.Timeout bounds @@ -70,7 +68,7 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP private readonly HttpClient _httpClient = new(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(30) }) { - Timeout = TimeSpan.FromHours(2) + Timeout = TimeSpan.FromHours(2), }; private IPluginHostServices? _host; private OfflineRecognizer? _recognizer; @@ -78,7 +76,6 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP private CudaRuntimeProvisioner? _cudaProvisioner; private string? _loadedModelId; private string? _loadedModelDir; - private string? _selectedModelId; private string _computeBackend = "cpu"; // The WIRED ORT native runtime, pinned to whichever loads first in the process @@ -93,10 +90,6 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP // Lets a first-load CUDA-recognizer failure pin "cuda" (the runtime is CUDA-capable) // rather than "cpu", so a later CPU↔CUDA recognizer swap doesn't read as restart-required. private bool _cudaOrtRuntimeWired; - private TranscriptionAccelerationPreference _accelerationPreference = - TranscriptionAccelerationPreference.Auto; - private TranscriptionAccelerationStatus _accelerationStatus = - new(TranscriptionAccelerationBackend.Cpu, "Using CPU"); private string _canarySrcLang = "en"; private string _canaryTgtLang = "en"; @@ -108,8 +101,9 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP public string ProviderId => "sherpa-onnx"; public string ProviderDisplayName => "Lokal (sherpa-onnx)"; public bool IsConfigured => true; - public string? SelectedModelId => _selectedModelId; - public bool SupportsTranslation => _selectedModelId == "canary-180m-flash"; + public string? SelectedModelId { get; private set; } + + public bool SupportsTranslation => SelectedModelId == "canary-180m-flash"; public bool SupportsModelDownload => true; public IReadOnlyList SupportedAccelerationBackends { get; } = @@ -127,12 +121,12 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP _cudaProvisioner?.IsProfileSatisfied(CudaRuntimeProfile.OnnxRuntimeCuda) == true && _cudaRuntimeInstaller?.IsInstalled == true; - public TranscriptionAccelerationPreference AccelerationPreference => _accelerationPreference; + public TranscriptionAccelerationPreference AccelerationPreference { get; private set; } = TranscriptionAccelerationPreference.Auto; - public TranscriptionAccelerationStatus AccelerationStatus => _accelerationStatus; + public TranscriptionAccelerationStatus AccelerationStatus { get; private set; } = new(TranscriptionAccelerationBackend.Cpu, "Using CPU"); public IReadOnlyList TranscriptionModels { get; } = - Models + s_models .Select(m => new PluginModelInfo(m.Id, m.DisplayName) { SizeDescription = m.SizeDescription, @@ -143,7 +137,7 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP .ToList(); public IReadOnlyList SupportedLanguages => - _selectedModelId == "canary-180m-flash" ? CanarySupportedLanguages : []; + SelectedModelId == "canary-180m-flash" ? s_canarySupportedLanguages : []; public Task ActivateAsync(IPluginHostServices host) { @@ -178,7 +172,7 @@ public Task DeactivateAsync() public void SelectModel(string modelId) { _ = GetModelDefinition(modelId); - _selectedModelId = modelId; + SelectedModelId = modelId; } public Task ConfigureComputeBackendAsync(string backend) @@ -218,7 +212,7 @@ public Task ConfigureComputeBackendAsync(string backend) public void SetAccelerationPreference(TranscriptionAccelerationPreference preference) { - _accelerationPreference = preference; + AccelerationPreference = preference; var desired = preference == TranscriptionAccelerationPreference.NvidiaCuda ? "cuda" : "cpu"; @@ -229,7 +223,7 @@ public void SetAccelerationPreference(TranscriptionAccelerationPreference prefer // otherwise overwrite it). The CUDA runtime is provisioned lazily on the // next LoadModelAsync. _ = ConfigureComputeBackendAsync(desired); - _accelerationStatus = _loadedNativeProvider is null + AccelerationStatus = _loadedNativeProvider is null ? CreatePendingAccelerationStatus(preference) // Pass the EFFECTIVE provider (_computeBackend) for the "active backend"; the // restart flag is derived from the wired runtime inside the helper. @@ -253,8 +247,8 @@ public Task DeleteModelAsync(string modelId, CancellationToken ct) if (_loadedModelId == modelId) UnloadRecognizerUnsafe(); - if (_selectedModelId == modelId) - _selectedModelId = null; + if (SelectedModelId == modelId) + SelectedModelId = null; } if (Directory.Exists(dir)) @@ -304,6 +298,7 @@ await ResilientDownloader.DownloadToFileAsync( { fileOnDisk = onDisk; var now = DateTime.UtcNow; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if ((now - lastReport).TotalMilliseconds > 250 && totalBytes > 0) { // Clamp: real on-disk sizes sum against an estimated total, so a @@ -421,15 +416,15 @@ await Task.Run( _loadedModelId = modelId; _loadedModelDir = dir; - _selectedModelId = modelId; + SelectedModelId = modelId; _canarySrcLang = "en"; _canaryTgtLang = "en"; // Restart is required only if the wired runtime is CPU-only (a // provisioning failure). A CUDA-wired runtime whose recognizer fell back // to CPU pins "cuda" above, so CUDA is reachable again by a reload — no // restart (matches CreateLoadedAccelerationStatus / the swap logic). - _accelerationStatus = cudaUnavailableDetail is null - ? CreateLoadedAccelerationStatus(activeProvider, _accelerationPreference) + AccelerationStatus = cudaUnavailableDetail is null + ? CreateLoadedAccelerationStatus(activeProvider, AccelerationPreference) : CreateCudaUnavailableStatus( cudaUnavailableDetail, requiresRestart: string.Equals( @@ -701,7 +696,7 @@ SherpaCudaRuntimeInstaller installer } private static ModelDefinition GetModelDefinition(string modelId) => - Models.FirstOrDefault(m => m.Id == modelId) + s_models.FirstOrDefault(m => m.Id == modelId) ?? throw new ArgumentException($"Unknown model: {modelId}"); private void UnloadRecognizer() @@ -794,12 +789,12 @@ TranscriptionAccelerationPreference preference ) => preference switch { - TranscriptionAccelerationPreference.NvidiaCuda => new( + TranscriptionAccelerationPreference.NvidiaCuda => new TranscriptionAccelerationStatus( TranscriptionAccelerationBackend.NvidiaCuda, "Preparing NVIDIA CUDA", "The GPU runtime downloads on the next model load." ), - _ => new( + _ => new TranscriptionAccelerationStatus( TranscriptionAccelerationBackend.Cpu, "Preparing CPU", "Will apply on next model load." @@ -853,7 +848,7 @@ private static string NormalizeCanaryLanguage(string? language) if (string.IsNullOrWhiteSpace(language) || language == "auto") return "en"; var normalized = language.Trim().ToLowerInvariant(); - return CanarySupportedLanguages.Contains(normalized) ? normalized : "en"; + return s_canarySupportedLanguages.Contains(normalized) ? normalized : "en"; } private static (string Text, string? DetectedLanguage) ParseCanaryResult(string rawText) @@ -872,6 +867,7 @@ private static (string Text, string? DetectedLanguage) ParseCanaryResult(string text = textNode.GetString()?.Trim() ?? string.Empty; string? lang = null; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (json.RootElement.TryGetProperty("lang", out var langNode)) { var parsed = langNode.GetString(); @@ -932,7 +928,7 @@ private static float[] DecodeWav(byte[] wavData) /// /// One-shot migration from the pre-plugin layout - /// (%LocalAppData%/TypeWhisper/Models/) into the per-plugin data + /// (%LocalAppData%/TypeWhisper/s_models/) into the per-plugin data /// directory. Best-effort: failures are logged and a stale source /// directory is left alone rather than blocking activation. /// @@ -949,7 +945,7 @@ private void MigrateModelFiles() if (!Directory.Exists(oldModelsDir)) return; - foreach (var model in Models) + foreach (var model in s_models) { var oldDir = Path.Join(oldModelsDir, model.Id); if (!Directory.Exists(oldDir)) @@ -969,6 +965,7 @@ private void MigrateModelFiles() var oldPath = Path.Join(oldDir, file.FileName); var newPath = Path.Join(newDir, file.FileName); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (File.Exists(oldPath) && !File.Exists(newPath)) { try diff --git a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs index e54f6e458..3ed75b6a7 100644 --- a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs @@ -1,5 +1,8 @@ -using System.Net; -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -14,22 +17,21 @@ public sealed class SmallestAiPlugin : ITranscriptionEnginePlugin, IPluginSettin private const string ApiKeySecretName = "api-key"; private const string DefaultModelId = "pulse"; - private static readonly IReadOnlyList Models = + private static readonly IReadOnlyList s_models = [ - new(DefaultModelId, "Pulse") + new(DefaultModelId, "Pulse"), ]; - private static readonly IReadOnlyList Languages = + private static readonly IReadOnlyList s_languages = [ "ar", "bn", "de", "en", "es", "fr", "gu", "hi", "it", "ja", "ka", "ko", "ml", "mr", "nl", "or", "pa", "pt", "ru", "ta", - "te", "yue", "zh", "multi-eu", "multi-indic", "multi-asian", "multi" + "te", "yue", "zh", "multi-eu", "multi-indic", "multi-asian", "multi", ]; private readonly HttpClient _httpClient; private readonly SemaphoreSlim _apiKeyWriteLock = new(1, 1); private IPluginHostServices? _host; - private string? _apiKey; private string _selectedModelId = DefaultModelId; public SmallestAiPlugin() @@ -51,7 +53,7 @@ internal SmallestAiPlugin(HttpClient httpClient) public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); + ApiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); _selectedModelId = DefaultModelId; host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); } @@ -66,16 +68,16 @@ public Task DeactivateAsync() public string ProviderId => "smallest-ai"; public string ProviderDisplayName => "Smallest AI"; - public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); - public IReadOnlyList TranscriptionModels => Models; + public bool IsConfigured => !string.IsNullOrEmpty(ApiKey); + public IReadOnlyList TranscriptionModels => s_models; public string? SelectedModelId => _selectedModelId; public bool SupportsTranslation => false; public bool SupportsStreaming => true; - public IReadOnlyList SupportedLanguages => Languages; + public IReadOnlyList SupportedLanguages => s_languages; public void SelectModel(string modelId) { - if (Models.All(model => !string.Equals(model.Id, modelId, StringComparison.Ordinal))) + if (s_models.All(model => !string.Equals(model.Id, modelId, StringComparison.Ordinal))) throw new ArgumentException($"Unknown model: {modelId}"); _selectedModelId = modelId; } @@ -94,7 +96,7 @@ public async Task TranscribeAsync( throw new InvalidOperationException(Loc.L("Settings.NotConfiguredApiKeyRequired")); using var request = new HttpRequestMessage(HttpMethod.Post, BuildPulseUri(language, includeWordTimestamps: true)); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); request.Content = CreateWavContent(wavAudio); using var response = await _httpClient.SendAsync(request, ct); @@ -114,12 +116,13 @@ public async Task StartStreamingAsync(string? language, Cance if (!IsConfigured) throw new InvalidOperationException(Loc.L("Settings.NotConfiguredApiKeyRequired")); - return await SmallestAiStreamingSession.ConnectAsync(_apiKey!, NormalizeLanguage(language), ct); + return await SmallestAiStreamingSession.ConnectAsync(ApiKey!, NormalizeLanguage(language), ct); } // Settings support - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -139,9 +142,9 @@ internal async Task SetApiKeyAsync(string apiKey) try { var wasConfigured = IsConfigured; - var changed = !string.Equals(_apiKey, normalized, StringComparison.Ordinal); + var changed = !string.Equals(ApiKey, normalized, StringComparison.Ordinal); - _apiKey = normalized; + ApiKey = normalized; if (_host is not null) { if (normalized is null) @@ -184,7 +187,6 @@ internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken c { return false; } - catch (OperationCanceledException) { throw; } catch (HttpRequestException) { return false; @@ -217,7 +219,7 @@ internal static PluginTranscriptionResult ParseTranscriptionResponse(string json return new PluginTranscriptionResult(text, language, duration, NoSpeechProbability: null) { - Segments = segments + Segments = segments, }; } @@ -334,11 +336,13 @@ private static string ExtractApiError(string json) internal static string ExtractApiError(JsonElement root) { + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (root.TryGetProperty("error", out var error)) { if (error.ValueKind == JsonValueKind.String) return error.GetString() ?? "Unknown error"; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (error.ValueKind == JsonValueKind.Object) { if (GetString(error, "message") is { } objectMessage) @@ -410,7 +414,7 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - ApiKeySecretName => _apiKey, + ApiKeySecretName => ApiKey, _ => null, } ); @@ -431,10 +435,10 @@ public async Task SetSettingValueAsync( public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); - var valid = await ValidateApiKeyAsync(_apiKey, ct); + var valid = await ValidateApiKeyAsync(ApiKey, ct); return valid ? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid")) : new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid")); diff --git a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs index f007b20bd..7eb4fca1f 100644 --- a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.IO; using System.Net.WebSockets; using System.Text; using System.Text.Json; @@ -43,7 +42,7 @@ public static Uri BuildStreamingUri(string? language, bool wordTimestamps) var query = new List { "encoding=linear16", - "sample_rate=16000" + "sample_rate=16000", }; var normalizedLanguage = SmallestAiPlugin.NormalizeLanguage(language); @@ -59,7 +58,7 @@ public static Uri BuildStreamingUri(string? language, bool wordTimestamps) public static IReadOnlyDictionary CreateStreamingHeaders(string apiKey) => new Dictionary { - ["Authorization"] = $"Bearer {apiKey}" + ["Authorization"] = $"Bearer {apiKey}", }; private static ClientWebSocket CreateConfiguredWebSocket(string apiKey) diff --git a/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs b/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs index 0e28779ae..37e0df6f6 100644 --- a/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs @@ -1,4 +1,7 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -20,13 +23,13 @@ public sealed class SonioxPlugin : ITranscriptionEnginePlugin, IPluginSettingsPr private const double MaxSubtitleSegmentDurationSeconds = 6.0; private const double SubtitleSegmentPauseSplitSeconds = 0.75; - private static readonly TimeSpan DefaultPollDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan s_defaultPollDelay = TimeSpan.FromSeconds(1); - private static readonly IReadOnlyList Models = + private static readonly IReadOnlyList s_models = [ new(DefaultModelId, "Soniox Async") { - IsRecommended = true + IsRecommended = true, }, ]; @@ -36,7 +39,6 @@ public sealed class SonioxPlugin : ITranscriptionEnginePlugin, IPluginSettingsPr private readonly SemaphoreSlim _apiKeyWriteLock = new(1, 1); private IPluginHostServices? _host; - private string? _apiKey; private string _selectedModelId = DefaultModelId; public SonioxPlugin() @@ -53,7 +55,7 @@ internal SonioxPlugin( throw new ArgumentOutOfRangeException(nameof(maxPollAttempts), "Poll attempts must be positive."); _httpClient = httpClient; - _pollDelay = pollDelay ?? DefaultPollDelay; + _pollDelay = pollDelay ?? s_defaultPollDelay; _maxPollAttempts = maxPollAttempts; } @@ -66,7 +68,7 @@ internal SonioxPlugin( public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); + ApiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); _selectedModelId = DefaultModelId; host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); } @@ -81,9 +83,9 @@ public Task DeactivateAsync() public string ProviderId => "soniox"; public string ProviderDisplayName => "Soniox"; - public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); + public bool IsConfigured => !string.IsNullOrEmpty(ApiKey); - public IReadOnlyList TranscriptionModels => Models; + public IReadOnlyList TranscriptionModels => s_models; public string? SelectedModelId => _selectedModelId; @@ -96,7 +98,7 @@ public async Task StartStreamingAsync(string? language, Cance if (!IsConfigured) throw new InvalidOperationException(Loc.L("Settings.NotConfiguredApiKeyRequired")); - return await SonioxStreamingSession.ConnectAsync(_apiKey!, language, ct); + return await SonioxStreamingSession.ConnectAsync(ApiKey!, language, ct); } public void SelectModel(string modelId) @@ -119,7 +121,7 @@ public async Task TranscribeAsync( // Snapshot the key once so a concurrent settings change can't swap it // out partway through the multi-request async flow below. - var apiKey = _apiKey; + var apiKey = ApiKey; if (string.IsNullOrEmpty(apiKey)) throw new InvalidOperationException(Loc.L("Settings.NotConfiguredApiKeyRequired")); @@ -155,7 +157,7 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, + "api-key" => ApiKey, _ => null, }); @@ -171,10 +173,10 @@ public async Task SetSettingValueAsync(string key, string? value, CancellationTo public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrEmpty(_apiKey)) + if (string.IsNullOrEmpty(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyRequired")); - var ok = await ValidateApiKeyAsync(_apiKey, ct); + var ok = await ValidateApiKeyAsync(ApiKey, ct); return ok ? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid")) : new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid")); @@ -182,7 +184,7 @@ public async Task SetSettingValueAsync(string key, string? value, CancellationTo // Settings support - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } private IPluginLocalization? _injectedLocalization; @@ -203,7 +205,7 @@ internal async Task SetApiKeyAsync(string apiKey) try { var wasConfigured = IsConfigured; - var changed = !string.Equals(_apiKey, normalized, StringComparison.Ordinal); + var changed = !string.Equals(ApiKey, normalized, StringComparison.Ordinal); if (!changed) return; @@ -220,7 +222,7 @@ internal async Task SetApiKeyAsync(string apiKey) // Update in-memory state after the persistence call succeeds so a // failing secret store leaves the live key untouched. - _apiKey = normalized; + ApiKey = normalized; if (wasConfigured == IsConfigured) hostToNotify = null; @@ -410,6 +412,7 @@ internal static PluginTranscriptionResult ParseTranscript( string? detectedLanguage = null; var transcriptCursor = 0; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (root.TryGetProperty("tokens", out var tokens) && tokens.ValueKind == JsonValueKind.Array) { @@ -445,7 +448,7 @@ internal static PluginTranscriptionResult ParseTranscript( return new PluginTranscriptionResult(text, detectedLanguage ?? fallbackLanguage, duration, NoSpeechProbability: null) { - Segments = BuildSubtitleSegments(segmentTokens) + Segments = BuildSubtitleSegments(segmentTokens), }; } @@ -509,7 +512,7 @@ private static bool ShouldStartNewSubtitleSegment( if (token.End - currentStart > MaxSubtitleSegmentDurationSeconds) return true; - var combinedNormalizedLength = NormalizeSubtitleText(currentText.ToString() + token.Text).Length; + var combinedNormalizedLength = NormalizeSubtitleText(currentText + token.Text).Length; return combinedNormalizedLength > MaxSubtitleSegmentCharacters; } @@ -533,9 +536,11 @@ private static string ResolveDisplayText(string transcriptText, string tokenText if (trimmedToken.Length == 0) return ""; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (transcriptText.Length > 0 && transcriptCursor <= transcriptText.Length) { var match = transcriptText.IndexOf(trimmedToken, transcriptCursor, StringComparison.Ordinal); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (match >= 0) { var end = match + trimmedToken.Length; @@ -635,7 +640,7 @@ private static string ExtractApiError(JsonElement root) { JsonValueKind.String => error.GetString(), JsonValueKind.Object => GetString(error, "message") ?? GetString(error, "detail"), - _ => null + _ => null, }; } diff --git a/plugins/TypeWhisper.Plugin.Soniox/SonioxStreamingSession.cs b/plugins/TypeWhisper.Plugin.Soniox/SonioxStreamingSession.cs index 1baa23ba1..3d73ee4ac 100644 --- a/plugins/TypeWhisper.Plugin.Soniox/SonioxStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Soniox/SonioxStreamingSession.cs @@ -1,5 +1,8 @@ +// ReSharper disable MemberCanBePrivate.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Diagnostics; -using System.IO; using System.Net.WebSockets; using System.Text; using System.Text.Json; @@ -153,6 +156,7 @@ internal static SonioxMessage ParseMessage(string json) && finEl.ValueKind == JsonValueKind.True; var tokens = new List(); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (root.TryGetProperty("tokens", out var tokensEl) && tokensEl.ValueKind == JsonValueKind.Array) { diff --git a/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsPlugin.cs b/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsPlugin.cs index 8623d9fb6..32e6e27c4 100644 --- a/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsPlugin.cs @@ -1,4 +1,7 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -7,16 +10,15 @@ namespace TypeWhisper.Plugin.Speechmatics; -public sealed partial class SpeechmaticsPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class SpeechmaticsPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware { private const string BaseUrl = "https://asr.api.speechmatics.com/v2"; private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromMinutes(5) }; private IPluginHostServices? _host; private string? _apiKey; - private string? _selectedModelId; - private static readonly IReadOnlyList Models = + private static readonly IReadOnlyList s_models = [ new("enhanced", "Speechmatics Enhanced"), ]; @@ -29,7 +31,7 @@ public async Task ActivateAsync(IPluginHostServices host) { _host = host; _apiKey = await host.LoadSecretAsync("api-key"); - _selectedModelId = host.GetSetting("selectedModel") ?? Models[0].Id; + SelectedModelId = host.GetSetting("selectedModel") ?? s_models[0].Id; host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); } @@ -43,9 +45,9 @@ public Task DeactivateAsync() public string ProviderDisplayName => "Speechmatics"; public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); - public IReadOnlyList TranscriptionModels => Models; + public IReadOnlyList TranscriptionModels => s_models; - public string? SelectedModelId => _selectedModelId; + public string? SelectedModelId { get; private set; } public bool SupportsTranslation => false; @@ -73,9 +75,9 @@ public async Task StartStreamingAsync(string? language, Cance public void SelectModel(string modelId) { - if (Models.All(m => m.Id != modelId)) + if (s_models.All(m => m.Id != modelId)) throw new ArgumentException($"Unknown model: {modelId}"); - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); } @@ -199,6 +201,7 @@ CancellationToken ct using var transcriptResponse = await _httpClient.SendAsync(transcriptRequest, ct); var transcriptJson = await transcriptResponse.Content.ReadAsStringAsync(ct); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (!transcriptResponse.IsSuccessStatusCode) { _host?.Log( @@ -235,6 +238,7 @@ private static PluginTranscriptionResult ParseTranscript(string json, JsonElemen { foreach (var result in results.EnumerateArray()) { + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if ( result.TryGetProperty("alternatives", out var alts) && alts.ValueKind == JsonValueKind.Array @@ -311,7 +315,7 @@ public IReadOnlyList GetSettingDefinitions() => "selectedModel", Loc.L("Settings.TranscriptionModel"), Description: Loc.L("Settings.ModelDescription"), - Options: Models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList() + Options: s_models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList() ), ]; @@ -320,7 +324,7 @@ public IReadOnlyList GetSettingDefinitions() => key switch { "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "selectedModel" => SelectedModelId, _ => null, } ); diff --git a/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsStreamingSession.cs b/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsStreamingSession.cs index 7e2d35a32..2ab5231f7 100644 --- a/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsStreamingSession.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.IO; using System.Net.WebSockets; using System.Text; using System.Text.Json; @@ -374,7 +373,7 @@ SpeechmaticsStreamingSession.SpeechmaticsMessage message } var completed = message.MessageType == "EndOfTranscript"; - var preview = (_final.ToString() + _partialTail).Trim(); + var preview = (_final + _partialTail).Trim(); return new SpeechmaticsStreamingSession.SpeechmaticsUpdate(preview, completed, FinalText); } } diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicAssetManager.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicAssetManager.cs index 90f2c9e0b..0d3a2873c 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicAssetManager.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicAssetManager.cs @@ -1,6 +1,4 @@ using System.Diagnostics; -using System.IO; -using System.Net.Http; using System.Text; namespace TypeWhisper.Plugin.SupertonicTts; @@ -88,6 +86,7 @@ public async Task DownloadMissingAssetsAsync(IProgress? progress, Cancel fileBytesRead += read; var now = DateTime.UtcNow; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if ((now - lastReport).TotalMilliseconds >= 250) { progress?.Report(ClampProgress((completedBytes + Math.Min(fileBytesRead, expectedBytes)) / (double)totalBytes)); diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicOnnxSynthesizer.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicOnnxSynthesizer.cs index 716d2d414..1476c5647 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicOnnxSynthesizer.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicOnnxSynthesizer.cs @@ -1,4 +1,3 @@ -using System.IO; using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.ML.OnnxRuntime; diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicPaths.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicPaths.cs index efde9abde..8f001229d 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicPaths.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicPaths.cs @@ -1,4 +1,3 @@ -using System.IO; namespace TypeWhisper.Plugin.SupertonicTts; diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTextProcessor.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTextProcessor.cs index aa622bd46..d16b8f4be 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTextProcessor.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTextProcessor.cs @@ -1,4 +1,3 @@ -using System.IO; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; @@ -12,7 +11,7 @@ internal sealed partial class SupertonicTextProcessor { "en", "ko", "ja", "ar", "bg", "cs", "da", "de", "el", "es", "et", "fi", "fr", "hi", "hr", "hu", "id", "it", "lt", "lv", "nl", "pl", "pt", "ro", "ru", "sk", "sl", - "sv", "tr", "uk", "vi" + "sv", "tr", "uk", "vi", }; private readonly long[] _indexer; diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlayback.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlayback.cs index 3deea50b4..fa431fe55 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlayback.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlayback.cs @@ -1,7 +1,6 @@ using System.Buffers.Binary; using System.ComponentModel; using System.Diagnostics; -using System.IO; using TypeWhisper.PluginSDK; namespace TypeWhisper.Plugin.SupertonicTts; @@ -80,6 +79,7 @@ public static ITtsPlaybackSession Create(float[] samples, int sampleRate) process = null; } + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (process is null) { TryDeleteFile(wavFilePath); diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs index 906d1267f..1c7fb7da5 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs @@ -1,6 +1,9 @@ +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Globalization; -using System.IO; -using System.Net.Http; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Models; @@ -20,7 +23,7 @@ public sealed class SupertonicTtsPlugin : ITtsProviderPlugin, IPluginSettingsPro internal const int MinDenoisingSteps = 1; internal const int MaxDenoisingSteps = 16; - private static readonly IReadOnlyList Voices = + private static readonly IReadOnlyList s_voices = [ new("M1", "M1"), new("M2", "M2"), @@ -43,13 +46,11 @@ public sealed class SupertonicTtsPlugin : ITtsProviderPlugin, IPluginSettingsPro private ISupertonicSynthesizer? _synthesizer; private IPluginHostServices? _host; private string _selectedVoiceId = DefaultVoiceId; - private bool _licenseAccepted; // Progress posts its callbacks asynchronously, so a late download tick can // race the post-download clear. The lock + done-latch make the clear authoritative: // once CompleteActivity runs, late progress reports are dropped. - private readonly object _activityLock = new(); - private double? _settingsProgress; + private readonly Lock _activityLock = new(); private bool _settingsActivityDone; private bool _disposed; @@ -89,11 +90,12 @@ private SupertonicTtsPlugin( public string ProviderId => "supertonic-tts"; public string ProviderDisplayName => "Supertonic TTS"; public bool IsConfigured => _assetManager?.AreAssetsReady ?? false; - public IReadOnlyList AvailableVoices => Voices; + public IReadOnlyList AvailableVoices => s_voices; public string? SelectedVoiceId => _selectedVoiceId; internal double Speed { get; private set; } = DefaultSpeed; internal int DenoisingSteps { get; private set; } = DefaultDenoisingSteps; - internal bool HasAcceptedModelLicense => _licenseAccepted; + internal bool HasAcceptedModelLicense { get; private set; } + internal bool AreAssetsReady => IsConfigured; private IPluginLocalization? _injectedLocalization; @@ -125,7 +127,8 @@ public string? SettingsSummary // IPluginSettingsActivity — surfaces the on-demand model download progress // in the host's generic settings UI (upstream showed it via the WPF // XaiSettingsView progress bar). - public double? SettingsProgress => _settingsProgress; + public double? SettingsProgress { get; private set; } + public event Action? SettingsActivityChanged; public Task ActivateAsync(IPluginHostServices host) @@ -136,7 +139,7 @@ public Task ActivateAsync(IPluginHostServices host) _selectedVoiceId = NormalizeVoiceId(host.GetSetting(SelectedVoiceSettingName)); Speed = NormalizeSpeed(host.GetSetting(SpeedSettingName) ?? DefaultSpeed); DenoisingSteps = NormalizeDenoisingSteps(host.GetSetting(DenoisingStepsSettingName) ?? DefaultDenoisingSteps); - _licenseAccepted = host.GetSetting(LicenseAcceptedSettingName).GetValueOrDefault(); + HasAcceptedModelLicense = host.GetSetting(LicenseAcceptedSettingName).GetValueOrDefault(); PersistSettings(); host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); return Task.CompletedTask; @@ -210,7 +213,7 @@ public IReadOnlyList GetSettingDefinitions() => Key: SelectedVoiceSettingName, Label: L("Settings.Voice"), Description: L("Settings.VoiceDescription"), - Options: Voices + Options: s_voices .Select(voice => new PluginSettingOption(voice.Id, voice.DisplayName)) .ToList() ), @@ -242,7 +245,7 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - LicenseAcceptedSettingName => _licenseAccepted ? "true" : "false", + LicenseAcceptedSettingName => HasAcceptedModelLicense ? "true" : "false", SelectedVoiceSettingName => _selectedVoiceId, SpeedSettingName => Speed.ToString("0.##", CultureInfo.InvariantCulture), DenoisingStepsSettingName => DenoisingSteps.ToString(CultureInfo.InvariantCulture), @@ -279,7 +282,7 @@ public Task SetSettingValueAsync(string key, string? value, CancellationToken ct if (IsConfigured) return new PluginSettingsValidationResult(true, L("Settings.Ready")); - if (!_licenseAccepted) + if (!HasAcceptedModelLicense) return new PluginSettingsValidationResult(false, L("Settings.AcceptLicense")); try @@ -315,7 +318,7 @@ or InvalidOperationException internal void SetLicenseAccepted(bool accepted) { - _licenseAccepted = accepted; + HasAcceptedModelLicense = accepted; _host?.SetSetting(LicenseAcceptedSettingName, accepted); } @@ -336,7 +339,7 @@ internal async Task DownloadAssetsAsync(IProgress? progress, Cancellatio if (_disposed) throw new ObjectDisposedException(nameof(SupertonicTtsPlugin)); - if (!_licenseAccepted) + if (!HasAcceptedModelLicense) throw new InvalidOperationException("The Supertonic 3 OpenRAIL-M license must be accepted before downloading model assets."); if (_assetManager is null) @@ -448,8 +451,8 @@ private static bool ParseBool(string? value) => private static string NormalizeVoiceId(string? voiceId) => !string.IsNullOrWhiteSpace(voiceId) - && Voices.Any(voice => string.Equals(voice.Id, voiceId.Trim(), StringComparison.OrdinalIgnoreCase)) - ? Voices.First(voice => string.Equals(voice.Id, voiceId.Trim(), StringComparison.OrdinalIgnoreCase)).Id + && s_voices.Any(voice => string.Equals(voice.Id, voiceId.Trim(), StringComparison.OrdinalIgnoreCase)) + ? s_voices.First(voice => string.Equals(voice.Id, voiceId.Trim(), StringComparison.OrdinalIgnoreCase)).Id : DefaultVoiceId; private void ReportActivity(string? message, double? progress) @@ -461,7 +464,7 @@ private void ReportActivity(string? message, double? progress) // A clear (null progress) is always allowed through. if (_settingsActivityDone && progress is not null) return; - _settingsProgress = progress; + SettingsProgress = progress; } SettingsActivityChanged?.Invoke(message); @@ -474,7 +477,7 @@ private void CompleteActivity() lock (_activityLock) { _settingsActivityDone = true; - _settingsProgress = null; + SettingsProgress = null; } SettingsActivityChanged?.Invoke(null); diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicVoiceStyle.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicVoiceStyle.cs index f6835d3ce..118a8db95 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicVoiceStyle.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicVoiceStyle.cs @@ -1,4 +1,3 @@ -using System.IO; using System.Text.Json; using Microsoft.ML.OnnxRuntime.Tensors; diff --git a/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs b/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs index c288312f7..6ec0fc7f1 100644 --- a/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs @@ -1,4 +1,8 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Helpers; @@ -6,7 +10,7 @@ namespace TypeWhisper.Plugin.Voxtral; -public sealed partial class VoxtralPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class VoxtralPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware { private const string BaseUrl = "https://api.mistral.ai"; private const string ModelId = "voxtral-mini-latest"; @@ -14,8 +18,6 @@ public sealed partial class VoxtralPlugin : ITranscriptionEnginePlugin, IPluginS private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; private IPluginHostServices? _host; - private string? _apiKey; - private string? _selectedModelId; public string PluginId => "com.typewhisper.voxtral"; public string PluginName => "Voxtral"; @@ -24,9 +26,9 @@ public sealed partial class VoxtralPlugin : ITranscriptionEnginePlugin, IPluginS public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = await host.LoadSecretAsync("api-key"); + ApiKey = await host.LoadSecretAsync("api-key"); var selectedModelId = host.GetSetting("selectedModel"); - _selectedModelId = selectedModelId == LegacyModelId ? ModelId : selectedModelId ?? ModelId; + SelectedModelId = selectedModelId == LegacyModelId ? ModelId : selectedModelId ?? ModelId; if (selectedModelId == LegacyModelId) { // A persistence failure must not fail activation; the in-memory migration suffices. @@ -50,12 +52,13 @@ public Task DeactivateAsync() public string ProviderId => "voxtral"; public string ProviderDisplayName => "Voxtral"; - public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); + public bool IsConfigured => !string.IsNullOrEmpty(ApiKey); public IReadOnlyList TranscriptionModels { get; } = - [new PluginModelInfo(ModelId, "Voxtral Mini (Mistral)")]; + [new(ModelId, "Voxtral Mini (Mistral)")]; + + public string? SelectedModelId { get; private set; } - public string? SelectedModelId => _selectedModelId; // Mistral documents no OpenAI-style translations endpoint; re-enable only with a documented implementation. public bool SupportsTranslation => false; @@ -66,7 +69,7 @@ public void SelectModel(string modelId) modelId = ModelId; if (modelId != ModelId) throw new ArgumentException($"Unknown model: {modelId}"); - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); } @@ -84,7 +87,7 @@ CancellationToken ct return await OpenAiTranscriptionHelper.TranscribeAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, ModelId, wavAudio, language, @@ -95,7 +98,8 @@ CancellationToken ct ); } - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -123,7 +127,7 @@ internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken c internal async Task SetApiKeyAsync(string apiKey) { - _apiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey.Trim(); + ApiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey.Trim(); if (_host is not null) { if (string.IsNullOrWhiteSpace(apiKey)) @@ -158,8 +162,8 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "api-key" => ApiKey, + "selectedModel" => SelectedModelId, _ => null, } ); @@ -184,10 +188,10 @@ public async Task SetSettingValueAsync( public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); - var valid = await ValidateApiKeyAsync(_apiKey, ct); + var valid = await ValidateApiKeyAsync(ApiKey, ct); return valid ? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid")) : new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid")); diff --git a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs index 36140ca09..320f64251 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs @@ -1,6 +1,10 @@ +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Collections.ObjectModel; -using System.IO; -using System.Net.Http; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -128,13 +132,13 @@ public sealed class WebhookService // and the EventBus delivery thread. SendWebhooksAsync only holds this // lock briefly to take a snapshot, so a slow disk write inside Save() // can't stall webhook deliveries. - private readonly object _webhooksLock = new(); + private readonly Lock _webhooksLock = new(); // Serializes the mutate-then-persist sequence so two overlapping saves // can't reorder writes — without this, thread A could snapshot first, // thread B could snapshot (including A's mutation) and write first, then // thread A would write its older snapshot last and clobber B's state on // disk while memory still reflects B's mutation. - private readonly object _saveLock = new(); + private readonly Lock _saveLock = new(); private bool _loadSucceeded; public ObservableCollection Webhooks { get; } = []; @@ -194,6 +198,7 @@ public void UpdateWebhook(WebhookConfig updated) { for (var i = 0; i < Webhooks.Count; i++) { + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (Webhooks[i].Id == updated.Id) { Webhooks[i] = updated; @@ -295,8 +300,8 @@ bool retryOnFailure var json = JsonSerializer.Serialize(payload, s_jsonOptions); var method = webhook.HttpMethod.Equals("PUT", StringComparison.OrdinalIgnoreCase) - ? System.Net.Http.HttpMethod.Put - : System.Net.Http.HttpMethod.Post; + ? HttpMethod.Put + : HttpMethod.Post; using var request = new HttpRequestMessage(method, webhook.Url); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); @@ -430,7 +435,6 @@ public sealed class WebhookPlugin IPluginLocalizationAware { private IDisposable? _subscription; - private IPluginHostServices? _host; private string? _dataDirectory; public string PluginId => "com.typewhisper.webhook"; @@ -441,7 +445,7 @@ public sealed class WebhookPlugin public Task ActivateAsync(IPluginHostServices host) { - _host = host; + Host = host; // Single canonical data dir: prefer the one set via SetDataDirectory // (called by the loader before ActivateAsync); fall back to the host's // value for hosts that don't drive IPluginDataLocationAware. Threading @@ -467,7 +471,8 @@ public Task DeactivateAsync() return Task.CompletedTask; } - public IPluginHostServices? Host => _host; + public IPluginHostServices? Host { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -476,7 +481,7 @@ public void SetLocalization(IPluginLocalization localization) => // Prefer the host's localization once activated; fall back to the catalog // injected at load so settings labels/validation resolve even when this // plugin is disabled (never activated, so _host is null). - internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; + internal IPluginLocalization? Loc => Host?.Localization ?? _injectedLocalization; private Task OnTranscriptionCompleted(TranscriptionCompletedEvent evt) => Service?.SendWebhooksAsync(evt) ?? Task.CompletedTask; @@ -496,7 +501,7 @@ private string ResolveDataDir() => public IReadOnlyList GetCollectionDefinitions() => [ - new PluginCollectionDefinition( + new( Key: "webhooks", Label: Loc.L("Settings.Webhooks"), Description: Loc.L("Settings.WebhooksDescription"), @@ -567,7 +572,7 @@ public Task> GetItemsAsync( } catch (Exception ex) { - _host?.Log(PluginLogLevel.Warning, $"Failed to load webhooks: {ex.Message}"); + Host?.Log(PluginLogLevel.Warning, $"Failed to load webhooks: {ex.Message}"); source = []; } } diff --git a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs index 8f06d31ef..75acc2747 100644 --- a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs +++ b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs @@ -1,6 +1,8 @@ +// ReSharper disable MemberCanBePrivate.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Globalization; -using System.IO; -using System.Net.Http; using System.Runtime.InteropServices; using System.Text; using TypeWhisper.Plugins.Shared.Cuda; @@ -27,7 +29,7 @@ public sealed class WhisperCppPlugin { private const string NoSpeechThresholdKey = "noSpeechThreshold"; private const float DefaultNoSpeechThreshold = 0.6f; - private static readonly IReadOnlyList Models = + private static readonly IReadOnlyList s_models = [ new( "tiny", @@ -220,13 +222,12 @@ public sealed class WhisperCppPlugin private readonly HttpClient _httpClient = new(new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromSeconds(30) }) { - Timeout = TimeSpan.FromHours(2) + Timeout = TimeSpan.FromHours(2), }; private IPluginHostServices? _host; private WhisperFactory? _factory; private CudaRuntimeProvisioner? _cudaProvisioner; private WhisperCudaRuntimeInstaller? _whisperCudaInstaller; - private string? _selectedModelId; private string? _loadedModelId; private string _computeBackend = "cpu"; private bool _runtimeLibraryOrderInitialized; @@ -246,10 +247,7 @@ public sealed class WhisperCppPlugin // an app restart. We short-circuit subsequent loads instead of re-entering // FromPath and re-throwing Whisper.net's cached failure. private bool _nativeRuntimeLoadFailed; - private TranscriptionAccelerationPreference _accelerationPreference = - TranscriptionAccelerationPreference.Auto; - private TranscriptionAccelerationStatus _accelerationStatus = - new(TranscriptionAccelerationBackend.Cpu, "Using CPU"); + private float _noSpeechThreshold = DefaultNoSpeechThreshold; public string PluginId => "com.typewhisper.whisper-cpp"; @@ -269,7 +267,8 @@ public void SetLocalization(IPluginLocalization localization) => // injected at load so settings labels/validation resolve even when this // plugin is disabled (never activated, so _host is null). internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; - public string? SelectedModelId => _selectedModelId; + public string? SelectedModelId { get; private set; } + public bool SupportsTranslation => true; public bool SupportsModelDownload => true; public IReadOnlyList SupportedLanguages => []; @@ -288,12 +287,12 @@ public void SetLocalization(IPluginLocalization localization) => _cudaProvisioner?.IsProfileSatisfied(CudaRuntimeProfile.WhisperCublas) == true && _whisperCudaInstaller?.IsInstalled == true; - public TranscriptionAccelerationPreference AccelerationPreference => _accelerationPreference; + public TranscriptionAccelerationPreference AccelerationPreference { get; private set; } = TranscriptionAccelerationPreference.Auto; - public TranscriptionAccelerationStatus AccelerationStatus => _accelerationStatus; + public TranscriptionAccelerationStatus AccelerationStatus { get; private set; } = new(TranscriptionAccelerationBackend.Cpu, "Using CPU"); public IReadOnlyList TranscriptionModels { get; } = - Models + s_models .Select(model => new PluginModelInfo(model.Id, model.DisplayName) { SizeDescription = model.SizeDescription, @@ -306,7 +305,7 @@ public void SetLocalization(IPluginLocalization localization) => public Task ActivateAsync(IPluginHostServices host) { _host = host; - _selectedModelId = host.GetSetting("selectedModel"); + SelectedModelId = host.GetSetting("selectedModel"); _noSpeechThreshold = ReadNoSpeechThreshold(host); // Create the CUDA provisioner/installer eagerly so IsCudaRuntimeProvisioned can @@ -357,7 +356,7 @@ public async Task DeactivateAsync() public void SelectModel(string modelId) { _ = GetModel(modelId); - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); } @@ -399,6 +398,7 @@ private bool TryConfigureComputeBackend(string backend) } _computeBackend = normalized; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (_factory is not null) { DisposeFactoryUnsafe(); @@ -423,9 +423,9 @@ public void SetAccelerationPreference(TranscriptionAccelerationPreference prefer // Always record the host's last requested preference so the SDK getter // reflects user intent, even when the runtime can't honour it yet. - _accelerationPreference = preference; + AccelerationPreference = preference; - _accelerationStatus = TryConfigureComputeBackend(backend) + AccelerationStatus = TryConfigureComputeBackend(backend) ? CreatePendingAccelerationStatus(preference) // Swap was rejected because the native runtime is already pinned. // Report the still-active backend with RequiresRestart=true so the UI @@ -687,7 +687,7 @@ public async Task LoadModelAsync(string modelId, IProgress? progress, Ca // The one-shot native loader is poisoned for the process; only a restart // can recover, so this is genuinely restart-required (the pin isn't even // set yet here — it's recorded only after a successful validation below). - _accelerationStatus = CreateCudaUnavailableStatus( + AccelerationStatus = CreateCudaUnavailableStatus( "The GPU runtime could not be loaded. Restart TypeWhisper to use CPU.", requiresRestart: true ); @@ -732,14 +732,14 @@ public async Task LoadModelAsync(string modelId, IProgress? progress, Ca _pinnedRuntimeBackend ??= appliedOrder; _runtimeLibraryOrderInitialized = true; _loadedModelId = modelId; - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); // Restart is required only if the process pinned the [Cpu] .so set (a // provisioning-failure downgrade). A GPU-context fallback pinned [Cuda], so CUDA // is reachable again by a reload — no restart (matches CreateLoadedAcceleration // Status / TryConfigureComputeBackend). - _accelerationStatus = cudaUnavailableDetail is null - ? CreateLoadedAccelerationStatus(_computeBackend, _accelerationPreference) + AccelerationStatus = cudaUnavailableDetail is null + ? CreateLoadedAccelerationStatus(_computeBackend, AccelerationPreference) : CreateCudaUnavailableStatus( cudaUnavailableDetail, requiresRestart: _pinnedRuntimeBackend == "cpu" @@ -838,6 +838,7 @@ float threshold continue; var segmentText = segment.Text.Trim(); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (segmentText.Length > 0) { if (text.Length > 0) @@ -862,7 +863,7 @@ public async Task UnloadModelAsync() { DisposeFactoryUnsafe(); _loadedModelId = null; - _selectedModelId = null; + SelectedModelId = null; } finally { @@ -882,9 +883,9 @@ public async Task DeleteModelAsync(string modelId, CancellationToken ct) _loadedModelId = null; } - if (_selectedModelId == modelId) + if (SelectedModelId == modelId) { - _selectedModelId = null; + SelectedModelId = null; _host?.SetSetting("selectedModel", ""); } @@ -1135,7 +1136,7 @@ out var parsed } private ModelDefinition GetModel(string modelId) => - Models.FirstOrDefault(model => model.Id == modelId) + s_models.FirstOrDefault(model => model.Id == modelId) ?? throw new ArgumentException($"Unknown model: {modelId}"); private string GetModelPath(string modelId) @@ -1270,17 +1271,17 @@ TranscriptionAccelerationPreference preference { return preference switch { - TranscriptionAccelerationPreference.NvidiaCuda => new( + TranscriptionAccelerationPreference.NvidiaCuda => new TranscriptionAccelerationStatus( TranscriptionAccelerationBackend.NvidiaCuda, "Preparing NVIDIA CUDA", "The GPU runtime downloads on the next model load." ), - TranscriptionAccelerationPreference.Cpu => new( + TranscriptionAccelerationPreference.Cpu => new TranscriptionAccelerationStatus( TranscriptionAccelerationBackend.Cpu, "Preparing CPU", "Will apply on next model load." ), - _ => new( + _ => new TranscriptionAccelerationStatus( TranscriptionAccelerationBackend.Cpu, "Preparing acceleration", "Will apply on next model load." diff --git a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCudaRuntimeInstaller.cs b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCudaRuntimeInstaller.cs index fb63e297f..8a2fb5aa6 100644 --- a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCudaRuntimeInstaller.cs +++ b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCudaRuntimeInstaller.cs @@ -1,6 +1,8 @@ -using System.IO; +// ReSharper disable MemberCanBePrivate.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.IO.Compression; -using System.Net.Http; using System.Security.Cryptography; using TypeWhisper.Plugins.Shared.Net; @@ -33,7 +35,7 @@ internal class WhisperCudaRuntimeInstaller private const string PackageId = "whisper.net.runtime.cuda.linux"; // The canonical, immutable package artifact on nuget.org's flat container. - internal static readonly string DownloadUrl = + internal static readonly string s_downloadUrl = $"https://api.nuget.org/v3-flatcontainer/{PackageId}/{RuntimeVersion}/" + $"{PackageId}.{RuntimeVersion}.nupkg"; @@ -52,7 +54,7 @@ internal class WhisperCudaRuntimeInstaller // The set Whisper.net's loader walks for the CUDA runtime (dependencies first, // then libwhisper.so). Also the completeness check for IsInstalled. - private static readonly string[] CoreRuntimeFiles = + private static readonly string[] s_coreRuntimeFiles = [ "libggml-base-whisper.so", "libggml-cpu-whisper.so", @@ -94,7 +96,7 @@ public WhisperCudaRuntimeInstaller( /// True when every required CUDA library has already been extracted. public bool IsInstalled => - CoreRuntimeFiles.All(file => File.Exists(Path.Join(NativeDirectory, file))); + s_coreRuntimeFiles.All(file => File.Exists(Path.Join(NativeDirectory, file))); /// /// Ensures the CUDA runtime is unpacked, downloading and extracting the @@ -151,7 +153,7 @@ public virtual async Task EnsureInstalledAsync(IProgress? progress, Canc if (!IsInstalled) { - var missing = CoreRuntimeFiles.Where( + var missing = s_coreRuntimeFiles.Where( f => !File.Exists(Path.Join(NativeDirectory, f)) ); throw new InvalidOperationException( @@ -221,6 +223,7 @@ CancellationToken ct void OnBytesOnDisk(long onDisk) { var now = DateTime.UtcNow; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if ((now - lastReport).TotalMilliseconds > 250) { progress?.Report(Math.Min(1.0, (double)onDisk / ApproxDownloadBytes)); @@ -230,7 +233,7 @@ void OnBytesOnDisk(long onDisk) return ResilientDownloader.DownloadToFileAsync( _httpClient, - DownloadUrl, + s_downloadUrl, destination, approxTotalBytes: ApproxDownloadBytes, idleTimeout: TimeSpan.FromSeconds(60), @@ -260,7 +263,7 @@ private void VerifySha256(string path) // the package's build/linux-x64/ prefix into the runtime directory. private void ExtractCoreRuntimeFiles(string nupkgPath) { - var wanted = new HashSet(CoreRuntimeFiles, StringComparer.Ordinal); + var wanted = new HashSet(s_coreRuntimeFiles, StringComparer.Ordinal); using var archive = ZipFile.OpenRead(nupkgPath); foreach (var entry in archive.Entries.Where(e => diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiJson.cs b/plugins/TypeWhisper.Plugin.Xai/XaiJson.cs index 0cac3d6f2..315a24f1d 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiJson.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiJson.cs @@ -1,4 +1,3 @@ -using System.Net.Http; using System.Text; using System.Text.Json; @@ -6,14 +5,14 @@ namespace TypeWhisper.Plugin.Xai; internal static class XaiJson { - private static readonly JsonSerializerOptions JsonOptions = new() + private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNamingPolicy = null + PropertyNamingPolicy = null, }; public static JsonElement Element(T value) => - JsonSerializer.SerializeToElement(value, JsonOptions).Clone(); + JsonSerializer.SerializeToElement(value, s_jsonOptions).Clone(); public static StringContent CreateJsonContent(IReadOnlyDictionary body) => - new(JsonSerializer.Serialize(body, JsonOptions), Encoding.UTF8, "application/json"); + new(JsonSerializer.Serialize(body, s_jsonOptions), Encoding.UTF8, "application/json"); } diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiPlugin.cs b/plugins/TypeWhisper.Plugin.Xai/XaiPlugin.cs index 5b2c77648..f29aad392 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiPlugin.cs @@ -1,4 +1,9 @@ -using System.Net.Http; +// ReSharper disable MemberCanBePrivate.Global +// ReSharper disable NotAccessedPositionalProperty.Global +// ReSharper disable UnusedMember.Global +// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces +// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. + using System.Net.Http.Headers; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -28,17 +33,17 @@ public sealed class XaiPlugin internal const string DefaultLlmModelId = "grok-4.3"; internal const string DefaultSttModelId = "grok-stt"; - private static readonly IReadOnlyList SttModels = + private static readonly IReadOnlyList s_sttModels = [ new(DefaultSttModelId, "Grok Speech to Text"), ]; - private static readonly IReadOnlyList FallbackLlmModels = + private static readonly IReadOnlyList s_fallbackLlmModels = [ new(DefaultLlmModelId, "Grok 4.3"), ]; - private static readonly IReadOnlyList Languages = + private static readonly IReadOnlyList s_languages = [ "ar", "cs", "da", "de", "en", "es", "fa", "fil", "fr", "hi", "id", "it", "ja", "ko", "mk", "ms", "nl", "pl", "pt", "ro", @@ -49,15 +54,9 @@ public sealed class XaiPlugin private readonly Func _ttsPlaybackFactory; private readonly Func _ttsPlaybackAvailableProbe; private IPluginHostServices? _host; - private string? _apiKey; - private string? _selectedModelId; - private string? _selectedLlmModelId; private List _fetchedLlmModels = []; private string? _selectedVoiceId; private List _fetchedVoices = []; - private string _customVoiceId = ""; - private bool _ttsLowLatency; - private bool _ttsTextNormalization; private bool _streamResponses = true; public XaiPlugin() @@ -86,17 +85,17 @@ internal XaiPlugin( public async Task ActivateAsync(IPluginHostServices host) { _host = host; - _apiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); - _selectedModelId = NormalizeSttModelId(host.GetSetting(SelectedModelSettingName)); - _selectedLlmModelId = host.GetSetting(SelectedLlmModelSettingName) ?? DefaultLlmModelId; + ApiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); + SelectedModelId = NormalizeSttModelId(host.GetSetting(SelectedModelSettingName)); + SelectedLlmModelId = host.GetSetting(SelectedLlmModelSettingName) ?? DefaultLlmModelId; _fetchedLlmModels = NormalizeFetchedLlmModels( host.GetSetting>(FetchedLlmModelsSettingName) ?? []); _selectedVoiceId = NormalizeVoiceId(host.GetSetting(SelectedVoiceSettingName)); _fetchedVoices = NormalizeFetchedVoices( host.GetSetting>(FetchedVoicesSettingName) ?? []); - _customVoiceId = host.GetSetting(CustomVoiceIdSettingName)?.Trim() ?? ""; - _ttsLowLatency = host.GetSetting(TtsLowLatencySettingName) ?? false; - _ttsTextNormalization = host.GetSetting(TtsTextNormalizationSettingName) ?? false; + CustomVoiceId = host.GetSetting(CustomVoiceIdSettingName)?.Trim() ?? ""; + TtsLowLatency = host.GetSetting(TtsLowLatencySettingName) ?? false; + TtsTextNormalization = host.GetSetting(TtsTextNormalizationSettingName) ?? false; _streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true; NormalizeSelectedLlmModel(persist: false); @@ -114,17 +113,18 @@ public Task DeactivateAsync() public string ProviderId => "xai"; public string ProviderDisplayName => "xAI / Grok"; - public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); - public IReadOnlyList TranscriptionModels => SttModels; - public string? SelectedModelId => _selectedModelId; + public bool IsConfigured => !string.IsNullOrEmpty(ApiKey); + public IReadOnlyList TranscriptionModels => s_sttModels; + public string? SelectedModelId { get; private set; } + public bool SupportsTranslation => false; public bool SupportsStreaming => true; - public IReadOnlyList SupportedLanguages => Languages; + public IReadOnlyList SupportedLanguages => s_languages; public void SelectModel(string modelId) { - _selectedModelId = NormalizeSttModelId(modelId); - _host?.SetSetting(SelectedModelSettingName, _selectedModelId); + SelectedModelId = NormalizeSttModelId(modelId); + _host?.SetSetting(SelectedModelSettingName, SelectedModelId); } public async Task TranscribeAsync( @@ -153,7 +153,7 @@ public async Task TranscribeAsync( form.Add(fileContent, "file", "audio.wav"); using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/stt"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); request.Content = form; var response = await OpenAiApiHelper.SendWithErrorHandlingAsync(_httpClient, request, ct); @@ -169,7 +169,7 @@ public async Task StartStreamingAsync(string? language, Cance // Run through the same normalization the batch TranscribeAsync uses // so a setting value like " de " or "auto" doesn't propagate into the // streaming URI as %20de%20 or language=auto. - return await XaiStreamingSession.ConnectAsync(_apiKey!, NormalizeLanguage(language), ct); + return await XaiStreamingSession.ConnectAsync(ApiKey!, NormalizeLanguage(language), ct); } // ILlmProviderPlugin @@ -180,7 +180,7 @@ public async Task StartStreamingAsync(string? language, Cance public IReadOnlyList SupportedModels => _fetchedLlmModels.Count > 0 ? _fetchedLlmModels.Select(m => new PluginModelInfo(m.Id, m.Id)).ToList() - : FallbackLlmModels; + : s_fallbackLlmModels; public async Task ProcessAsync(string systemPrompt, string userText, string model, CancellationToken ct) { @@ -188,9 +188,9 @@ public async Task ProcessAsync(string systemPrompt, string userText, str throw new InvalidOperationException(Loc.L("Settings.ApiKeyNotConfigured")); var modelId = string.IsNullOrWhiteSpace(model) - ? _selectedLlmModelId ?? SupportedModels[0].Id + ? SelectedLlmModelId ?? SupportedModels[0].Id : model; - var client = new XaiResponsesClient(_httpClient, BaseUrl, _apiKey!); + var client = new XaiResponsesClient(_httpClient, BaseUrl, ApiKey!); return await client.ProcessAsync(systemPrompt, userText, modelId, ct); } @@ -210,9 +210,9 @@ public async IAsyncEnumerable ProcessStreamingAsync( throw new InvalidOperationException(Loc.L("Settings.ApiKeyNotConfigured")); var modelId = string.IsNullOrWhiteSpace(model) - ? _selectedLlmModelId ?? SupportedModels[0].Id + ? SelectedLlmModelId ?? SupportedModels[0].Id : model; - var client = new XaiResponsesClient(_httpClient, BaseUrl, _apiKey!); + var client = new XaiResponsesClient(_httpClient, BaseUrl, ApiKey!); var source = client.ProcessStreamingAsync(systemPrompt, userText, modelId, ct); await foreach (var delta in source.WithCancellation(ct)) yield return delta; @@ -226,8 +226,8 @@ public async IAsyncEnumerable ProcessStreamingAsync( : XaiTtsConfiguration.FallbackVoices; public string? SelectedVoiceId => - !string.IsNullOrWhiteSpace(_customVoiceId) - ? _customVoiceId + !string.IsNullOrWhiteSpace(CustomVoiceId) + ? CustomVoiceId : _selectedVoiceId ?? XaiTtsConfiguration.DefaultVoiceId; public string? SettingsSummary @@ -237,7 +237,7 @@ public string? SettingsSummary var voice = AvailableVoices.FirstOrDefault(v => v.Id == SelectedVoiceId)?.DisplayName ?? SelectedVoiceId ?? XaiTtsConfiguration.DefaultVoiceId; - var latency = _ttsLowLatency ? "low latency" : "quality"; + var latency = TtsLowLatency ? "low latency" : "quality"; return $"Voice: {voice}; {latency}"; } } @@ -273,11 +273,11 @@ public async Task SpeakAsync(TtsSpeakRequest request, Cance text, SelectedVoiceId, NormalizeTtsLanguage(request.Language), - _ttsLowLatency, - _ttsTextNormalization); + TtsLowLatency, + TtsTextNormalization); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/tts"); - httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); httpRequest.Content = XaiJson.CreateJsonContent(body); var response = await OpenAiApiHelper.SendWithErrorHandlingAsync(_httpClient, httpRequest, ct); @@ -287,7 +287,8 @@ public async Task SpeakAsync(TtsSpeakRequest request, Cance // Settings support - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -297,20 +298,23 @@ public void SetLocalization(IPluginLocalization localization) => // injected at load so settings labels/validation resolve even when this // plugin is disabled (never activated, so _host is null). internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; - internal string? SelectedLlmModelId => _selectedLlmModelId; + internal string? SelectedLlmModelId { get; private set; } + internal IReadOnlyList FetchedLlmModels => _fetchedLlmModels; internal IReadOnlyList FetchedVoices => _fetchedVoices; - internal string CustomVoiceId => _customVoiceId; - internal bool TtsLowLatency => _ttsLowLatency; - internal bool TtsTextNormalization => _ttsTextNormalization; + internal string CustomVoiceId { get; private set; } = ""; + + internal bool TtsLowLatency { get; private set; } + + internal bool TtsTextNormalization { get; private set; } internal async Task SetApiKeyAsync(string apiKey) { var normalized = NormalizeApiKey(apiKey); var wasConfigured = IsConfigured; - var changed = !string.Equals(_apiKey, normalized, StringComparison.Ordinal); + var changed = !string.Equals(ApiKey, normalized, StringComparison.Ordinal); - _apiKey = normalized; + ApiKey = normalized; if (_host is not null) { if (normalized is null) @@ -328,7 +332,7 @@ internal void SelectLlmModel(string modelId) if (SupportedModels.All(model => !string.Equals(model.Id, modelId, StringComparison.Ordinal))) modelId = (SupportedModels.Count > 0 ? SupportedModels[0] : null)?.Id ?? modelId; - _selectedLlmModelId = modelId; + SelectedLlmModelId = modelId; _host?.SetSetting(SelectedLlmModelSettingName, modelId); _host?.NotifyCapabilitiesChanged(); } @@ -347,7 +351,7 @@ internal async Task> FetchLlmModelsAsync(CancellationToken return []; using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/models"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); try { @@ -375,7 +379,6 @@ internal async Task> FetchLlmModelsAsync(CancellationToken { return []; } - catch (OperationCanceledException) { throw; } catch (HttpRequestException) { return []; @@ -404,7 +407,6 @@ internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken c { return false; } - catch (OperationCanceledException) { throw; } catch (HttpRequestException) { return false; @@ -433,7 +435,7 @@ internal async Task> FetchVoicesAsync(CancellationToken ct return []; using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/tts/voices"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); try { @@ -462,7 +464,6 @@ internal async Task> FetchVoicesAsync(CancellationToken ct { return []; } - catch (OperationCanceledException) { throw; } catch (HttpRequestException) { return []; @@ -479,21 +480,21 @@ internal async Task> FetchVoicesAsync(CancellationToken ct internal void SetCustomVoiceId(string voiceId) { - _customVoiceId = voiceId.Trim(); - _host?.SetSetting(CustomVoiceIdSettingName, _customVoiceId); + CustomVoiceId = voiceId.Trim(); + _host?.SetSetting(CustomVoiceIdSettingName, CustomVoiceId); _host?.NotifyCapabilitiesChanged(); } internal void SetTtsLowLatency(bool enabled) { - _ttsLowLatency = enabled; + TtsLowLatency = enabled; _host?.SetSetting(TtsLowLatencySettingName, enabled); _host?.NotifyCapabilitiesChanged(); } internal void SetTtsTextNormalization(bool enabled) { - _ttsTextNormalization = enabled; + TtsTextNormalization = enabled; _host?.SetSetting(TtsTextNormalizationSettingName, enabled); _host?.NotifyCapabilitiesChanged(); } @@ -514,6 +515,7 @@ internal static PluginTranscriptionResult ParseSttResponse(string json, string? var duration = TryGetDouble(root, "duration", out var durationValue) ? durationValue : 0; var segments = new List(); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (root.TryGetProperty("words", out var wordsEl) && wordsEl.ValueKind == JsonValueKind.Array) { @@ -534,7 +536,7 @@ internal static PluginTranscriptionResult ParseSttResponse(string json, string? return new PluginTranscriptionResult(text, language ?? fallbackLanguage ?? "", duration) { - Segments = segments + Segments = segments, }; } @@ -554,12 +556,13 @@ private void NormalizeSelectedLlmModel(bool persist) if (available.Count == 0) return; - if (_selectedLlmModelId is null - || available.All(model => !string.Equals(model.Id, _selectedLlmModelId, StringComparison.Ordinal))) + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. + if (SelectedLlmModelId is null + || available.All(model => !string.Equals(model.Id, SelectedLlmModelId, StringComparison.Ordinal))) { - _selectedLlmModelId = available[0].Id; + SelectedLlmModelId = available[0].Id; if (persist) - _host?.SetSetting(SelectedLlmModelSettingName, _selectedLlmModelId); + _host?.SetSetting(SelectedLlmModelSettingName, SelectedLlmModelId); } } @@ -569,6 +572,7 @@ private void NormalizeSelectedVoice(bool persist) if (available.Count == 0) return; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (_selectedVoiceId is null || available.All(voice => !string.Equals(voice.Id, _selectedVoiceId, StringComparison.Ordinal))) { @@ -582,7 +586,7 @@ private void NormalizeSelectedVoice(bool persist) string.IsNullOrWhiteSpace(apiKey) ? null : apiKey.Trim(); private static string NormalizeSttModelId(string? modelId) => - SttModels.Any(model => model.Id == modelId) ? modelId! : DefaultSttModelId; + s_sttModels.Any(model => model.Id == modelId) ? modelId! : DefaultSttModelId; private static string? NormalizeVoiceId(string? voiceId) => string.IsNullOrWhiteSpace(voiceId) ? XaiTtsConfiguration.DefaultVoiceId : voiceId.Trim(); @@ -656,7 +660,7 @@ public IReadOnlyList GetSettingDefinitions() => Key: SelectedModelSettingName, Label: Loc.L("Settings.TranscriptionModel"), Description: Loc.L("Settings.TranscriptionModelDescription"), - Options: SttModels + Options: s_sttModels .Select(m => new PluginSettingOption(m.Id, m.DisplayName)) .ToList() ), @@ -711,15 +715,15 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - ApiKeySecretName => _apiKey, - SelectedModelSettingName => _selectedModelId, - SelectedLlmModelSettingName => _selectedLlmModelId, + ApiKeySecretName => ApiKey, + SelectedModelSettingName => SelectedModelId, + SelectedLlmModelSettingName => SelectedLlmModelId, LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false", SelectedVoiceSettingName => _selectedVoiceId, - CustomVoiceIdSettingName => _customVoiceId, - TtsLowLatencySettingName => _ttsLowLatency ? "true" : "false", - TtsTextNormalizationSettingName => _ttsTextNormalization ? "true" : "false", + CustomVoiceIdSettingName => CustomVoiceId, + TtsLowLatencySettingName => TtsLowLatency ? "true" : "false", + TtsTextNormalizationSettingName => TtsTextNormalization ? "true" : "false", _ => null, } ); @@ -760,10 +764,10 @@ public async Task SetSettingValueAsync(string key, string? value, CancellationTo public async Task ValidateAsync(CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(_apiKey)) + if (string.IsNullOrWhiteSpace(ApiKey)) return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey")); - var valid = await ValidateApiKeyAsync(_apiKey, ct); + var valid = await ValidateApiKeyAsync(ApiKey, ct); if (!valid) return new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid")); diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs b/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs index 584d03803..78e4385c8 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs @@ -1,6 +1,4 @@ -using System.Net.Http; using System.Net.Http.Headers; -using System.Linq; using System.Text; using System.Text.Json; using TypeWhisper.PluginSDK.Helpers; @@ -82,7 +80,7 @@ public async IAsyncEnumerable ProcessStreamingAsync( { 401 => "Invalid API key", 429 => "Rate limit reached, please wait", - _ => $"API error {(int)response.StatusCode}: {OpenAiApiHelper.ExtractErrorMessage(errorBody)}" + _ => $"API error {(int)response.StatusCode}: {OpenAiApiHelper.ExtractErrorMessage(errorBody)}", }; throw new InvalidOperationException(message); } @@ -192,6 +190,7 @@ public async IAsyncEnumerable ProcessStreamingAsync( private static string? ExtractErrorMessage(JsonElement element) { + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (element.TryGetProperty("error", out var error)) { if (error.ValueKind == JsonValueKind.Object @@ -219,6 +218,7 @@ public static string ParseResponse(string json) if (TryGetNonEmptyString(root, "output_text") is { } outputText) return outputText; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (root.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array) { var parts = new List(); diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs b/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs index d3c282dd4..9b8fd4515 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.IO; using System.Net.WebSockets; using System.Text; using System.Text.Json; @@ -73,7 +72,7 @@ public static Uri BuildStreamingUri(string? language, bool interimResults) public static IReadOnlyDictionary CreateStreamingHeaders(string apiKey) => new Dictionary { - ["Authorization"] = $"Bearer {apiKey}" + ["Authorization"] = $"Bearer {apiKey}", }; private static ClientWebSocket CreateConfiguredWebSocket(string apiKey) @@ -328,7 +327,7 @@ internal sealed class XaiTranscriptCollector "transcript.partial" => ApplyPartialEvent(root), "transcript.done" => ApplyDoneEvent(root), "error" => throw new InvalidOperationException(ExtractErrorMessage(root) ?? "Unknown xAI STT error"), - _ => null + _ => null, }; } @@ -415,6 +414,7 @@ public PluginTranscriptionResult FinalResult(string? fallbackLanguage) if (text.Equals(joined, StringComparison.Ordinal)) return null; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (text.StartsWith(joined, StringComparison.Ordinal) && text.Length > joined.Length && text[joined.Length] == ' ') @@ -457,6 +457,7 @@ private static bool GetBool(JsonElement root, string propertyName) => private static string? ExtractErrorMessage(JsonElement root) { + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (root.TryGetProperty("error", out var error)) { if (error.ValueKind == JsonValueKind.Object && GetString(error, "message") is { } objectMessage) diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiTtsSupport.cs b/plugins/TypeWhisper.Plugin.Xai/XaiTtsSupport.cs index ffbad4ef6..2d737f35d 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiTtsSupport.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiTtsSupport.cs @@ -1,7 +1,6 @@ using System.Buffers.Binary; using System.ComponentModel; using System.Diagnostics; -using System.IO; using System.Text.Json; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Models; @@ -128,6 +127,7 @@ public static ITtsPlaybackSession Create(byte[] pcm16Audio, int sampleRate) process = null; } + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (process is null) { TryDeleteFile(wavFilePath); diff --git a/src/TypeWhisper.Core/Services/AtomicFileWrite.cs b/src/TypeWhisper.Core/Services/AtomicFileWrite.cs index c9701f2d6..ded872e85 100644 --- a/src/TypeWhisper.Core/Services/AtomicFileWrite.cs +++ b/src/TypeWhisper.Core/Services/AtomicFileWrite.cs @@ -6,15 +6,15 @@ namespace TypeWhisper.Core.Services; /// Writes content so the destination ends up with either the complete old or complete new /// content, never a partial write. Failures throw. /// -public static class AtomicFileWrite +public static partial class AtomicFileWrite { + // ReSharper disable once InconsistentNaming -- POSIX errno macro name; PascalCase would obscure it. private const int EEXIST = 17; - // DllImport rather than LibraryImport: the latter's generated marshalling needs - // AllowUnsafeBlocks, which is not worth enabling project-wide for one call. CharSet.Ansi - // marshals as UTF-8 on Unix, which is what libc expects for paths. - [DllImport("libc", EntryPoint = "link", SetLastError = true, CharSet = CharSet.Ansi)] - private static extern int Link(string oldPath, string newPath); + // UTF-8 marshalling is what libc expects for paths. + [LibraryImport("libc", EntryPoint = "link", SetLastError = true, + StringMarshalling = StringMarshalling.Utf8)] + private static partial int Link(string oldPath, string newPath); /// /// Publishes a fully-written temporary file to , which goes diff --git a/src/TypeWhisper.Core/TypeWhisper.Core.csproj b/src/TypeWhisper.Core/TypeWhisper.Core.csproj index 7f23592a3..6be980b2f 100644 --- a/src/TypeWhisper.Core/TypeWhisper.Core.csproj +++ b/src/TypeWhisper.Core/TypeWhisper.Core.csproj @@ -6,6 +6,8 @@ latest TypeWhisper.Core TypeWhisper.Core + + true diff --git a/src/TypeWhisper.Linux/ServiceRegistrations.cs b/src/TypeWhisper.Linux/ServiceRegistrations.cs index c493d597c..318eab3b4 100644 --- a/src/TypeWhisper.Linux/ServiceRegistrations.cs +++ b/src/TypeWhisper.Linux/ServiceRegistrations.cs @@ -44,7 +44,7 @@ public static void Register(IServiceCollection services) // A standing property of the mount, not an event, and the log is a bounded ring // persisted across launches — appending every startup would evict real failures. - if (!errorLog.Entries.Any(e => e.Message == warning)) + if (errorLog.Entries.All(e => e.Message != warning)) { errorLog.AddEntry(warning, ErrorCategory.Recording); } diff --git a/src/TypeWhisper.PluginSDK/IPluginSettingsActivity.cs b/src/TypeWhisper.PluginSDK/IPluginSettingsActivity.cs index bf9861bc6..4e56f2cd7 100644 --- a/src/TypeWhisper.PluginSDK/IPluginSettingsActivity.cs +++ b/src/TypeWhisper.PluginSDK/IPluginSettingsActivity.cs @@ -1,3 +1,7 @@ +// ReSharper disable UnusedMemberInSuper.Global +// PluginSDK contract members are implemented by out-of-solution plugin projects and invoked by +// the host; the analyzer sees no in-solution caller, so these .Global inspections misfire. + // Public plugin-SDK surface. The per-item `disable once` directives below mark members // ReSharper/Qodana cannot see used from this project (they are consumed by external plugins/ // the host). Per-item, not file-level, so a genuinely-unused member added later still surfaces. diff --git a/src/TypeWhisper.PluginSDK/IPluginSettingsProvider.cs b/src/TypeWhisper.PluginSDK/IPluginSettingsProvider.cs index 736894e74..153d59ce4 100644 --- a/src/TypeWhisper.PluginSDK/IPluginSettingsProvider.cs +++ b/src/TypeWhisper.PluginSDK/IPluginSettingsProvider.cs @@ -1,3 +1,7 @@ +// ReSharper disable UnusedParameter.Global +// PluginSDK contract members are implemented by out-of-solution plugin projects and invoked by +// the host; the analyzer sees no in-solution caller, so these .Global inspections misfire. + // Public plugin-SDK surface. The per-item `disable once` directives below mark members // ReSharper/Qodana cannot see used from this project (they are consumed by external plugins/ // the host). Per-item, not file-level, so a genuinely-unused member added later still surfaces. @@ -45,7 +49,7 @@ public enum PluginSettingKind Secret, Dropdown, Boolean, - Multiline + Multiline, } /// diff --git a/src/TypeWhisper.PluginSDK/ITranscriptionEnginePlugin.cs b/src/TypeWhisper.PluginSDK/ITranscriptionEnginePlugin.cs index 0efe0943a..07d70a3ea 100644 --- a/src/TypeWhisper.PluginSDK/ITranscriptionEnginePlugin.cs +++ b/src/TypeWhisper.PluginSDK/ITranscriptionEnginePlugin.cs @@ -1,3 +1,7 @@ +// ReSharper disable UnusedMemberInSuper.Global +// PluginSDK contract members are implemented by out-of-solution plugin projects and invoked by +// the host; the analyzer sees no in-solution caller, so these .Global inspections misfire. + // Public plugin-SDK surface. The per-item `disable once` directives below mark members // ReSharper/Qodana cannot see used from this project (they are consumed by external plugins/ // the host). Per-item, not file-level, so a genuinely-unused member added later still surfaces. diff --git a/src/TypeWhisper.PluginSDK/ITtsProviderPlugin.cs b/src/TypeWhisper.PluginSDK/ITtsProviderPlugin.cs index e4e206763..7247b7d62 100644 --- a/src/TypeWhisper.PluginSDK/ITtsProviderPlugin.cs +++ b/src/TypeWhisper.PluginSDK/ITtsProviderPlugin.cs @@ -1,3 +1,7 @@ +// ReSharper disable UnusedMemberInSuper.Global +// PluginSDK contract members are implemented by out-of-solution plugin projects and invoked by +// the host; the analyzer sees no in-solution caller, so these .Global inspections misfire. + // Public plugin-SDK surface. The per-item `disable once` directives below mark members // ReSharper/Qodana cannot see used from this project (they are consumed by external plugins/ // the host). Per-item, not file-level, so a genuinely-unused member added later still surfaces. From 43f71f5d82ff1255ff2eef935592f3657efd708e Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 21 Jul 2026 08:26:29 -0400 Subject: [PATCH 127/226] Update plugin files to correct usage of WithCancellation and trim API keys; remove unnecessary null-conditional operators; add suppressions for performance and usage analysis; minor fixes in various plugin source files. Files affected include plugin.cs files in several plugins, various service and view model classes, and the solution references. --- TypeWhisper.slnx | 34 +++++++++++++++ .../CerebrasPlugin.cs | 2 +- .../TypeWhisper.Plugin.Claude/ClaudePlugin.cs | 2 +- .../CloudflareAsrPlugin.cs | 2 +- .../TypeWhisper.Plugin.Cohere/CoherePlugin.cs | 2 +- .../FireworksPlugin.cs | 2 +- .../TypeWhisper.Plugin.Gemini/GeminiPlugin.cs | 4 +- .../GemmaLocalPlugin.cs | 2 +- .../TypeWhisper.Plugin.Gladia/GladiaPlugin.cs | 2 +- plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs | 4 +- .../ObsidianPlugin.cs | 2 +- .../OpenAiOAuthSupport.cs | 42 ++++++++++--------- .../TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs | 2 +- .../OpenAiRealtimeStreamingSession.cs | 11 +++-- .../OpenAiCompatiblePlugin.cs | 10 ++--- .../OpenRouterPlugin.cs | 2 +- .../TypeWhisper.Plugin.Reson8/Reson8Plugin.cs | 21 +--------- .../TypeWhisper.Plugin.Script/ScriptPlugin.cs | 3 +- .../SherpaOnnxNativeRuntime.cs | 16 +++---- .../SupertonicTextProcessor.cs | 2 +- .../SupertonicTtsPlugin.cs | 2 +- .../WebhookPlugin.cs | 2 +- .../WhisperCppPlugin.cs | 7 +++- plugins/TypeWhisper.Plugin.Xai/XaiPlugin.cs | 11 +++-- .../XaiStreamingSession.cs | 4 -- .../Services/CleanupService.cs | 3 +- .../Services/IdeFileReferenceService.cs | 3 +- .../Services/LocalModelStorageService.cs | 11 ++--- .../Services/ActiveWindowService.cs | 3 +- .../Services/ApiDiscoveryFile.cs | 5 ++- .../Services/Hotkey/BackendSelector.cs | 3 +- .../Evdev/EvdevGlobalShortcutBackend.cs | 9 ++-- .../Services/Ipc/ControlSocketOwnership.cs | 2 +- .../Services/Localization/StrExtension.cs | 2 +- .../Services/Setup/PackageInstaller.cs | 3 +- .../Views/Sections/HistorySection.axaml.cs | 2 +- .../Views/Sections/ShortcutsSection.axaml.cs | 2 +- .../Views/Sections/SnippetsSection.axaml.cs | 2 +- .../Views/WelcomeWizard.axaml.cs | 2 +- 39 files changed, 137 insertions(+), 108 deletions(-) diff --git a/TypeWhisper.slnx b/TypeWhisper.slnx index 0f113e6c0..27f9f5455 100644 --- a/TypeWhisper.slnx +++ b/TypeWhisper.slnx @@ -11,4 +11,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs b/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs index 073a7efe2..b46ed458f 100644 --- a/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs @@ -99,7 +99,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct ct ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } diff --git a/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs b/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs index 8e87a6957..b454be0d1 100644 --- a/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs +++ b/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs @@ -291,7 +291,7 @@ internal async Task SetApiKeyAsync(string apiKey) // Trim defensively at the internal entry too: SetSettingValueAsync // already trims, but a future direct caller could re-introduce // trailing whitespace that breaks the x-api-key header. - var trimmed = apiKey?.Trim(); + var trimmed = apiKey.Trim(); ApiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed; if (_host is not null) { diff --git a/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs b/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs index 5f20186d5..672128884 100644 --- a/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs +++ b/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs @@ -183,7 +183,7 @@ internal async Task SetAccountIdAsync(string accountId) internal async Task SetApiTokenAsync(string apiToken) { - var trimmed = apiToken?.Trim(); + var trimmed = apiToken.Trim(); _apiToken = string.IsNullOrEmpty(trimmed) ? null : trimmed; if (_host is not null) { diff --git a/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs b/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs index fa762b456..851267fb0 100644 --- a/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs +++ b/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs @@ -109,7 +109,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct ct ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } diff --git a/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs b/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs index 7abe97cf5..84830178d 100644 --- a/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs @@ -121,7 +121,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct ct ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } diff --git a/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs b/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs index f8ceeb4cb..257f25371 100644 --- a/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs @@ -112,7 +112,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct ct ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } @@ -130,7 +130,7 @@ public void SetLocalization(IPluginLocalization localization) => internal async Task SetApiKeyAsync(string apiKey) { - var trimmed = apiKey?.Trim(); + var trimmed = apiKey.Trim(); ApiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed; if (_host is not null) { diff --git a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs index a92e3cbbf..92e7f01e1 100644 --- a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs @@ -511,7 +511,7 @@ internal Task LoadModelAsync(string modelId, CancellationToken ct) // The lock covers the full unload-then-load window so callers can't // observe a torn state (e.g. _weights set but _context still old). await _inferenceLock.WaitAsync(ct).ConfigureAwait(false); - var loaded = false; + bool loaded; try { // If the user has switched models OR cleared the selection while we diff --git a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs index 43af29fc9..394baa803 100644 --- a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs @@ -103,7 +103,7 @@ internal async Task SetApiKeyAsync(string apiKey) // Trim defensively at the internal entry too: SetSettingValueAsync // already trims, but a future direct caller could re-introduce // trailing whitespace that breaks the x-gladia-key header. - var trimmed = apiKey?.Trim(); + var trimmed = apiKey.Trim(); _apiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed; if (_host is not null) { diff --git a/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs b/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs index 1feb5d70c..e459b6c3a 100644 --- a/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs @@ -199,7 +199,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct ct ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } @@ -237,7 +237,7 @@ internal async Task SetApiKeyAsync(string apiKey) _fetchedLlmModels = []; SelectedLlmModelId = null; _host.SetSetting("fetchedLlmModels", _fetchedLlmModels); - _host.SetSetting("selectedLlmModel", SelectedLlmModelId); + _host.SetSetting("selectedLlmModel", SelectedLlmModelId); NormalizeSelectedLlmModel(); if (wasConfigured != IsConfigured) diff --git a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs index 67e23ffea..d1e0ef0d9 100644 --- a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs @@ -237,7 +237,7 @@ internal static List DetectVaults() if (!string.IsNullOrEmpty(path) && Directory.Exists(path)) { var name = Path.GetFileName(path); - vaults.Add(new ObsidianVaultInfo(name ?? vault.Name, path)); + vaults.Add(new ObsidianVaultInfo(name, path)); } } } diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs index 02d2811ca..f52e0db43 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs @@ -87,17 +87,15 @@ public static async Task ExchangeAuthorizationCodeAsyn OpenAiPkceCodes pkce, CancellationToken ct) { - using var request = new HttpRequestMessage(HttpMethod.Post, $"{Issuer}/oauth/token") + using var request = new HttpRequestMessage(HttpMethod.Post, $"{Issuer}/oauth/token"); + request.Content = new FormUrlEncodedContent(new Dictionary { - Content = new FormUrlEncodedContent(new Dictionary - { - ["grant_type"] = "authorization_code", - ["code"] = code, - ["redirect_uri"] = RedirectUri, - ["client_id"] = ClientId, - ["code_verifier"] = pkce.Verifier, - }), - }; + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = RedirectUri, + ["client_id"] = ClientId, + ["code_verifier"] = pkce.Verifier, + }); return await SendTokenRequestAsync(httpClient, request, ct); } @@ -107,15 +105,13 @@ public static async Task RefreshTokenAsync( string refreshToken, CancellationToken ct) { - using var request = new HttpRequestMessage(HttpMethod.Post, $"{Issuer}/oauth/token") + using var request = new HttpRequestMessage(HttpMethod.Post, $"{Issuer}/oauth/token"); + request.Content = new FormUrlEncodedContent(new Dictionary { - Content = new FormUrlEncodedContent(new Dictionary - { - ["grant_type"] = "refresh_token", - ["refresh_token"] = refreshToken, - ["client_id"] = ClientId, - }), - }; + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["client_id"] = ClientId, + }); return await SendTokenRequestAsync(httpClient, request, ct); } @@ -349,9 +345,15 @@ private async Task AcceptOnAnyListenerAsync(CancellationToken ct) private void StopListeners() { try { _v4Listener?.Stop(); } - catch { } + catch { //nada + } + try { _v6Listener?.Stop(); } - catch { } + catch + { + //nada + + } _v4Listener = null; _v6Listener = null; } diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs index 308401e8f..43374b32c 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs @@ -370,7 +370,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct temperature: ResolvedTemperature(modelId) ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs index 8fe72cb71..1b3e47599 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs @@ -175,7 +175,7 @@ internal static string CreateSessionUpdatePayload(string? language, string? prom internal static string CreateAudioAppendPayload(ReadOnlySpan pcm16Audio) { - var resampled = Resample16kPcmTo24k(pcm16Audio); + var resampled = Resample16KPcmTo24K(pcm16Audio); return JsonSerializer.Serialize(new Dictionary { ["type"] = "input_audio_buffer.append", @@ -349,7 +349,7 @@ private async Task WaitForCompletedTranscriptAsync(TimeSpan timeout, Cancellatio } } - internal static byte[] Resample16kPcmTo24k(ReadOnlySpan pcm16Audio) + internal static byte[] Resample16KPcmTo24K(ReadOnlySpan pcm16Audio) { var sourceSampleCount = pcm16Audio.Length / sizeof(short); if (sourceSampleCount == 0) @@ -418,7 +418,7 @@ public async ValueTask DisposeAsync() return; _disposed = true; - _receiveCts.Cancel(); + await _receiveCts.CancelAsync(); if (_ws.State == WebSocketState.Open) { @@ -450,7 +450,10 @@ public async ValueTask DisposeAsync() if (_receiveTask is not null) { try { await _receiveTask; } - catch { } + catch + { + //nada + } } _sendLock.Dispose(); diff --git a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs index 1a5eff5d7..0b9e2a704 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs @@ -217,7 +217,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct ct ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } @@ -842,7 +842,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct ct ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } @@ -854,15 +854,15 @@ private async Task LoadAdditionalProfilesAsync(IPluginHostServices host) var stored = host.GetSetting>(AdditionalProfilesSettingKey) ?? []; var seen = new HashSet(StringComparer.Ordinal); - foreach (var profile in stored.Where(p => p is not null)) + foreach (var profile in stored) { profile.Id = NormalizeProfileId(profile.Id, seen); profile.Name = string.IsNullOrWhiteSpace(profile.Name) ? "Custom Server" : profile.Name.Trim(); - profile.BaseUrl = NormalizeBaseUrl(profile.BaseUrl ?? ""); + profile.BaseUrl = NormalizeBaseUrl(profile.BaseUrl); profile.SelectedModelId = NullIfWhiteSpace(profile.SelectedModelId); profile.SelectedLlmModelId = NullIfWhiteSpace(profile.SelectedLlmModelId); - profile.FetchedModels = (profile.FetchedModels ?? []) + profile.FetchedModels = (profile.FetchedModels) .Where(m => !string.IsNullOrWhiteSpace(m.Id)) .ToList(); diff --git a/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs b/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs index 018d367e4..01729fee6 100644 --- a/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs @@ -207,7 +207,7 @@ public async IAsyncEnumerable ProcessStreamingAsync( maxOutputTokens: 2048, temperature: TemperatureMode == TemperatureModeCustom ? TemperatureValue : null); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } diff --git a/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs b/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs index 6ee5510b8..e9f7e0646 100644 --- a/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs +++ b/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs @@ -563,10 +563,6 @@ public static byte[] ExtractPcm16(byte[] wavAudio) } var offset = 12; - short audioFormat = 0; - short channels = 0; - int sampleRate = 0; - short bitsPerSample = 0; byte[]? data = null; while (offset + 8 <= wavAudio.Length) @@ -577,14 +573,7 @@ public static byte[] ExtractPcm16(byte[] wavAudio) if (chunkSize < 0 || offset + chunkSize > wavAudio.Length) break; - if (chunkId == "fmt " && chunkSize >= 16) - { - audioFormat = BitConverter.ToInt16(wavAudio, offset); - channels = BitConverter.ToInt16(wavAudio, offset + 2); - sampleRate = BitConverter.ToInt32(wavAudio, offset + 4); - bitsPerSample = BitConverter.ToInt16(wavAudio, offset + 14); - } - else if (chunkId == "data") + if (chunkId == "data") { data = wavAudio.Skip(offset).Take(chunkSize).ToArray(); } @@ -592,13 +581,7 @@ public static byte[] ExtractPcm16(byte[] wavAudio) offset += chunkSize + chunkSize % 2; } - if (data is null) - return wavAudio; - - if (audioFormat == 1 && channels == 1 && sampleRate == 16000 && bitsPerSample == 16) - return data; - - return data; + return data ?? wavAudio; } private static bool HasAscii(byte[] bytes, int offset, string value) diff --git a/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs b/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs index e4443acdc..28fb4db2f 100644 --- a/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs @@ -245,7 +245,8 @@ CancellationToken ct psi.Environment["TYPEWHISPER_LANGUAGE"] = context.SourceLanguage ?? ""; psi.Environment["TYPEWHISPER_PROFILE"] = context.ProfileName ?? ""; - using var process = new Process { StartInfo = psi }; + using var process = new Process(); + process.StartInfo = psi; process.Start(); // Create the 5s watchdog BEFORE the stdin write so a wedged child diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs index b30fcebd1..6a3f0bf90 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs @@ -50,8 +50,8 @@ internal static class SherpaOnnxNativeRuntime ]; private static readonly Lock s_sync = new(); - private static bool _resolverRegistered; - private static string? _cudaRuntimeDirectory; + private static bool s_resolverRegistered; + private static string? s_cudaRuntimeDirectory; /// /// Registers the import resolver once. Safe (and cheap) to call even on the @@ -63,14 +63,14 @@ public static void RegisterResolver() { lock (s_sync) { - if (_resolverRegistered) + if (s_resolverRegistered) return; NativeLibrary.SetDllImportResolver( typeof(OfflineRecognizer).Assembly, ResolveNativeLibrary ); - _resolverRegistered = true; + s_resolverRegistered = true; } } @@ -86,13 +86,13 @@ public static void ConfigureCudaRuntime(string runtimeDirectory) lock (s_sync) { - if (!_resolverRegistered) + if (!s_resolverRegistered) { NativeLibrary.SetDllImportResolver( typeof(OfflineRecognizer).Assembly, ResolveNativeLibrary ); - _resolverRegistered = true; + s_resolverRegistered = true; } foreach (var soname in PreloadOrder) @@ -116,7 +116,7 @@ public static void ConfigureCudaRuntime(string runtimeDirectory) // Point the resolver at the GPU dir only after every dependency loaded. // If a preload above threw, the resolver stays on the CPU runtime so an // Auto fallback gets a genuine CPU load rather than the half-wired GPU one. - _cudaRuntimeDirectory = runtimeDirectory; + s_cudaRuntimeDirectory = runtimeDirectory; } } @@ -126,7 +126,7 @@ private static IntPtr ResolveNativeLibrary( DllImportSearchPath? searchPath ) { - var runtimeDirectory = _cudaRuntimeDirectory; + var runtimeDirectory = s_cudaRuntimeDirectory; if (string.IsNullOrWhiteSpace(runtimeDirectory)) return IntPtr.Zero; // CPU path: let the default loader find the nuget runtime. diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTextProcessor.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTextProcessor.cs index d16b8f4be..1f75ebbe6 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTextProcessor.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTextProcessor.cs @@ -56,7 +56,7 @@ private static string PreprocessText(string text, string language) // Embed a deterministic lower-case tag so callers that pass "EN"/"En" // don't produce a different token sequence than "en". - language = (language ?? "").Trim().ToLowerInvariant(); + language = language.Trim().ToLowerInvariant(); text = text.Normalize(NormalizationForm.FormKD); text = RemoveEmojiCodePoints(text); diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs index 1c7fb7da5..0ee793da2 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs @@ -242,7 +242,7 @@ public IReadOnlyList GetSettingDefinitions() => ]; public Task GetSettingValueAsync(string key, CancellationToken ct = default) => - Task.FromResult( + Task.FromResult( key switch { LicenseAcceptedSettingName => HasAcceptedModelLicense ? "true" : "false", diff --git a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs index 320f64251..d447da166 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs @@ -99,7 +99,7 @@ public void Save(IEnumerable configs) } finally { - if (tempPath is not null && File.Exists(tempPath)) + if (File.Exists(tempPath)) { try { diff --git a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs index 75acc2747..b9d1425f9 100644 --- a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs +++ b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs @@ -1055,7 +1055,7 @@ public IReadOnlyList GetSettingDefinitions() => return Task.FromResult(null); var raw = _host?.GetSetting(NoSpeechThresholdKey); - return Task.FromResult(string.IsNullOrWhiteSpace(raw) ? null : raw); + return Task.FromResult(string.IsNullOrWhiteSpace(raw) ? null : raw); } public Task SetSettingValueAsync( @@ -1351,7 +1351,10 @@ private static void TryDeleteFile(string path) if (File.Exists(path)) File.Delete(path); } - catch { } + catch + { + //nada + } } private sealed record ModelDefinition( diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiPlugin.cs b/plugins/TypeWhisper.Plugin.Xai/XaiPlugin.cs index f29aad392..76aa3a79c 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiPlugin.cs @@ -214,7 +214,7 @@ public async IAsyncEnumerable ProcessStreamingAsync( : model; var client = new XaiResponsesClient(_httpClient, BaseUrl, ApiKey!); var source = client.ProcessStreamingAsync(systemPrompt, userText, modelId, ct); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } @@ -225,18 +225,17 @@ public async IAsyncEnumerable ProcessStreamingAsync( ? _fetchedVoices.Select(v => new PluginVoiceInfo(v.VoiceId, v.DisplayName, v.Language)).ToList() : XaiTtsConfiguration.FallbackVoices; - public string? SelectedVoiceId => + public string SelectedVoiceId => !string.IsNullOrWhiteSpace(CustomVoiceId) ? CustomVoiceId : _selectedVoiceId ?? XaiTtsConfiguration.DefaultVoiceId; - public string? SettingsSummary + public string SettingsSummary { get { var voice = AvailableVoices.FirstOrDefault(v => v.Id == SelectedVoiceId)?.DisplayName - ?? SelectedVoiceId - ?? XaiTtsConfiguration.DefaultVoiceId; + ?? SelectedVoiceId; var latency = TtsLowLatency ? "low latency" : "quality"; return $"Voice: {voice}; {latency}"; } @@ -588,7 +587,7 @@ private void NormalizeSelectedVoice(bool persist) private static string NormalizeSttModelId(string? modelId) => s_sttModels.Any(model => model.Id == modelId) ? modelId! : DefaultSttModelId; - private static string? NormalizeVoiceId(string? voiceId) => + private static string NormalizeVoiceId(string? voiceId) => string.IsNullOrWhiteSpace(voiceId) ? XaiTtsConfiguration.DefaultVoiceId : voiceId.Trim(); private static string? NormalizeLanguage(string? language) => diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs b/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs index 9b8fd4515..4490a9e84 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs @@ -297,7 +297,6 @@ public async ValueTask DisposeAsync() internal sealed class XaiTranscriptCollector { private readonly List _finals = []; - private string _interim = ""; private string? _doneText; private string? _detectedLanguage; private double _duration; @@ -357,7 +356,6 @@ public PluginTranscriptionResult FinalResult(string? fallbackLanguage) if (isFinal) { - _interim = ""; if (string.IsNullOrWhiteSpace(text)) return null; @@ -384,7 +382,6 @@ public PluginTranscriptionResult FinalResult(string? fallbackLanguage) return new StreamingTranscriptEvent(text, IsFinal: true); } - _interim = text; return new StreamingTranscriptEvent(text, IsFinal: false); } @@ -392,7 +389,6 @@ public PluginTranscriptionResult FinalResult(string? fallbackLanguage) { var text = GetString(root, "text")?.Trim() ?? ""; RememberMetadata(root); - _interim = ""; IsTerminal = true; if (string.IsNullOrWhiteSpace(text)) diff --git a/src/TypeWhisper.Core/Services/CleanupService.cs b/src/TypeWhisper.Core/Services/CleanupService.cs index 7db50eda6..eba3df366 100644 --- a/src/TypeWhisper.Core/Services/CleanupService.cs +++ b/src/TypeWhisper.Core/Services/CleanupService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; using TypeWhisper.Core.Models; @@ -35,7 +36,7 @@ public sealed partial class CleanupService }; // kept instance: injected as a DI/test seam by callers - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] + [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] // ReSharper disable once MemberCanBeMadeStatic.Global public string Clean(string text, CleanupLevel level) { diff --git a/src/TypeWhisper.Core/Services/IdeFileReferenceService.cs b/src/TypeWhisper.Core/Services/IdeFileReferenceService.cs index a344d5652..b079c5b6e 100644 --- a/src/TypeWhisper.Core/Services/IdeFileReferenceService.cs +++ b/src/TypeWhisper.Core/Services/IdeFileReferenceService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; namespace TypeWhisper.Core.Services; @@ -76,7 +77,7 @@ public static string ToAtReference(string spokenText) } // kept instance: injected as a DI/test seam by callers - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] + [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] // ReSharper disable once MemberCanBeMadeStatic.Global public string? TryFormatReferenceCommand(string spokenText) { diff --git a/src/TypeWhisper.Core/Services/LocalModelStorageService.cs b/src/TypeWhisper.Core/Services/LocalModelStorageService.cs index 38401c71a..f59a69936 100644 --- a/src/TypeWhisper.Core/Services/LocalModelStorageService.cs +++ b/src/TypeWhisper.Core/Services/LocalModelStorageService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Globalization; using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; @@ -437,7 +438,7 @@ private static void TryCleanUp(Action cleanUp) } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - System.Diagnostics.Trace.TraceWarning( + Trace.TraceWarning( "Model storage migration cleanup failed: {0}", ex.Message); } @@ -451,14 +452,14 @@ private static void TryDeleteFile(string path) } catch (IOException ex) { - System.Diagnostics.Trace.TraceWarning( + Trace.TraceWarning( "Could not delete migrated source file '{0}': {1}", path, ex.Message); } catch (UnauthorizedAccessException ex) { - System.Diagnostics.Trace.TraceWarning( + Trace.TraceWarning( "Could not delete migrated source file '{0}': {1}", path, ex.Message); @@ -497,14 +498,14 @@ private static void TryDeleteDirectoryIfEmpty(string path) } catch (IOException ex) { - System.Diagnostics.Trace.TraceWarning( + Trace.TraceWarning( "Could not delete empty model storage directory '{0}': {1}", path, ex.Message); } catch (UnauthorizedAccessException ex) { - System.Diagnostics.Trace.TraceWarning( + Trace.TraceWarning( "Could not delete empty model storage directory '{0}': {1}", path, ex.Message); diff --git a/src/TypeWhisper.Linux/Services/ActiveWindowService.cs b/src/TypeWhisper.Linux/Services/ActiveWindowService.cs index b0fb0e7eb..86b709d3c 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindowService.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindowService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; using TypeWhisper.Linux.Services.ActiveWindow; @@ -202,7 +203,7 @@ public IReadOnlyList GetRunningAppProcessNames() } // kept instance: injected as a DI/test seam by callers - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] + [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] // ReSharper disable once MemberCanBeMadeStatic.Global public string? GetActiveWindowId() { diff --git a/src/TypeWhisper.Linux/Services/ApiDiscoveryFile.cs b/src/TypeWhisper.Linux/Services/ApiDiscoveryFile.cs index 4097f58ca..2d20fa4da 100644 --- a/src/TypeWhisper.Linux/Services/ApiDiscoveryFile.cs +++ b/src/TypeWhisper.Linux/Services/ApiDiscoveryFile.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Json; @@ -44,7 +45,7 @@ private static string DirectoryPath private static string FilePath => Path.Join(DirectoryPath, FileName); // kept instance: injected as a DI/test seam by callers - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] + [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] // ReSharper disable once MemberCanBeMadeStatic.Global public void Write(int port, string token) { @@ -104,7 +105,7 @@ public void Write(int port, string token) } // kept instance: injected as a DI/test seam by callers - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] + [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] // ReSharper disable once MemberCanBeMadeStatic.Global public void Delete() { diff --git a/src/TypeWhisper.Linux/Services/Hotkey/BackendSelector.cs b/src/TypeWhisper.Linux/Services/Hotkey/BackendSelector.cs index 622994d5b..aa8144088 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/BackendSelector.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/BackendSelector.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using TypeWhisper.Core.Interfaces; using TypeWhisper.Linux.Services.Hotkey.Evdev; using TypeWhisper.Linux.Services.Hotkey.Portal; @@ -51,7 +52,7 @@ public IGlobalShortcutBackend Resolve() return _factory(); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2012:Use ValueTasks correctly", Justification = "Intentional fire-and-forget disposal of the throwaway portal probe instance; XdgPortalGlobalShortcutsBackend.DisposeAsync is a self-contained async ValueTask and awaiting it inside the synchronous factory is unnecessary.")] + [SuppressMessage("Usage", "CA2012:Use ValueTasks correctly", Justification = "Intentional fire-and-forget disposal of the throwaway portal probe instance; XdgPortalGlobalShortcutsBackend.DisposeAsync is a self-contained async ValueTask and awaiting it inside the synchronous factory is unnecessary.")] private static Func DefaultFactory( ISettingsService? settings, ISessionActivityMonitor? sessionActivityMonitor diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevGlobalShortcutBackend.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevGlobalShortcutBackend.cs index 0bd2dc9da..6d9a08ba4 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevGlobalShortcutBackend.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevGlobalShortcutBackend.cs @@ -1,5 +1,6 @@ using SharpHook.Native; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using TypeWhisper.Linux.Services.Localization; namespace TypeWhisper.Linux.Services.Hotkey.Evdev; @@ -302,7 +303,7 @@ private void AttachAllDevices_NoLock(long generation) } } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2012:Use ValueTasks correctly", Justification = "Intentional fire-and-forget disposal of a reader that failed to start; EvdevDeviceReader.DisposeAsync is a self-contained async ValueTask and awaiting here would needlessly block the attach path.")] + [SuppressMessage("Usage", "CA2012:Use ValueTasks correctly", Justification = "Intentional fire-and-forget disposal of a reader that failed to start; EvdevDeviceReader.DisposeAsync is a self-contained async ValueTask and awaiting here would needlessly block the attach path.")] private void TryAttach_NoLock(string path, long generation) { if ( @@ -407,7 +408,7 @@ private void OnDeviceCreated(object? sender, FileSystemEventArgs e) }); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2012:Use ValueTasks correctly", Justification = "Intentional fire-and-forget disposal of a removed reader; EvdevDeviceReader.DisposeAsync is a self-contained async ValueTask and must not block this FileSystemWatcher callback.")] + [SuppressMessage("Usage", "CA2012:Use ValueTasks correctly", Justification = "Intentional fire-and-forget disposal of a removed reader; EvdevDeviceReader.DisposeAsync is a self-contained async ValueTask and must not block this FileSystemWatcher callback.")] private void OnDeviceDeleted(object? sender, FileSystemEventArgs e) { IEvdevDeviceReader? reader; @@ -425,7 +426,7 @@ private void OnDeviceDeleted(object? sender, FileSystemEventArgs e) DispatchEdges(releases); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2012:Use ValueTasks correctly", Justification = "Intentional fire-and-forget disposal of stale readers pruned during rescan; EvdevDeviceReader.DisposeAsync is a self-contained async ValueTask and awaiting here is unnecessary.")] + [SuppressMessage("Usage", "CA2012:Use ValueTasks correctly", Justification = "Intentional fire-and-forget disposal of stale readers pruned during rescan; EvdevDeviceReader.DisposeAsync is a self-contained async ValueTask and awaiting here is unnecessary.")] private bool Rescan() { var added = false; @@ -541,7 +542,7 @@ bool pressed DispatchEdgeOutsideLock(dispatchEdge); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2012:Use ValueTasks correctly", Justification = "Intentional fire-and-forget disposal of the failed reader; EvdevDeviceReader.DisposeAsync is a self-contained async ValueTask and must not block this failure callback.")] + [SuppressMessage("Usage", "CA2012:Use ValueTasks correctly", Justification = "Intentional fire-and-forget disposal of the failed reader; EvdevDeviceReader.DisposeAsync is a self-contained async ValueTask and must not block this failure callback.")] private void OnReaderFailure(long generation, string path, Exception ex) { Trace.WriteLine($"[EvdevBackend] Reader {path} failed: {ex.Message}"); diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs index 80728ec7a..43656cca5 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs @@ -1,9 +1,9 @@ +using Microsoft.Win32.SafeHandles; using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Net.Sockets; using System.Runtime.InteropServices; -using Microsoft.Win32.SafeHandles; namespace TypeWhisper.Linux.Services.Ipc; diff --git a/src/TypeWhisper.Linux/Services/Localization/StrExtension.cs b/src/TypeWhisper.Linux/Services/Localization/StrExtension.cs index c24e24972..49e1c10f2 100644 --- a/src/TypeWhisper.Linux/Services/Localization/StrExtension.cs +++ b/src/TypeWhisper.Linux/Services/Localization/StrExtension.cs @@ -1,7 +1,7 @@ -using System.Globalization; using Avalonia.Data; using Avalonia.Data.Converters; using Avalonia.Markup.Xaml; +using System.Globalization; namespace TypeWhisper.Linux.Services.Localization; diff --git a/src/TypeWhisper.Linux/Services/Setup/PackageInstaller.cs b/src/TypeWhisper.Linux/Services/Setup/PackageInstaller.cs index 644c4755a..145706af7 100644 --- a/src/TypeWhisper.Linux/Services/Setup/PackageInstaller.cs +++ b/src/TypeWhisper.Linux/Services/Setup/PackageInstaller.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using TypeWhisper.Linux.Services.Hotkey.DeSetup; using TypeWhisper.Linux.Services.Localization; @@ -58,7 +59,7 @@ public PackageInstaller(IProcessRunner runner) /// detected so the user still sees what they need to install. /// // kept instance: invoked on the injected _installer service by callers - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] + [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "kept instance: injected as a DI/test seam")] // ReSharper disable once MemberCanBeMadeStatic.Global public string BuildSudoCommand(IReadOnlyList packages) { diff --git a/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml.cs index 84de77810..818225c60 100644 --- a/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml.cs @@ -1,9 +1,9 @@ -using System.Diagnostics; using Avalonia.Controls; using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.Platform.Storage; using Avalonia.Threading; +using System.Diagnostics; using TypeWhisper.Linux.Services.Localization; using TypeWhisper.Linux.ViewModels.Sections; diff --git a/src/TypeWhisper.Linux/Views/Sections/ShortcutsSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/ShortcutsSection.axaml.cs index f45f417c0..196b52082 100644 --- a/src/TypeWhisper.Linux/Views/Sections/ShortcutsSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/ShortcutsSection.axaml.cs @@ -1,6 +1,6 @@ -using System.Diagnostics; using Avalonia.Controls; using Avalonia.Input.Platform; +using System.Diagnostics; using TypeWhisper.Linux.ViewModels.Sections; namespace TypeWhisper.Linux.Views.Sections; diff --git a/src/TypeWhisper.Linux/Views/Sections/SnippetsSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/SnippetsSection.axaml.cs index 1e29c9b70..0747e15e9 100644 --- a/src/TypeWhisper.Linux/Views/Sections/SnippetsSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/SnippetsSection.axaml.cs @@ -1,7 +1,7 @@ -using System.Diagnostics; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Platform.Storage; +using System.Diagnostics; using TypeWhisper.Linux.Services.Localization; using TypeWhisper.Linux.ViewModels.Sections; diff --git a/src/TypeWhisper.Linux/Views/WelcomeWizard.axaml.cs b/src/TypeWhisper.Linux/Views/WelcomeWizard.axaml.cs index be8c9d763..ead72b20f 100644 --- a/src/TypeWhisper.Linux/Views/WelcomeWizard.axaml.cs +++ b/src/TypeWhisper.Linux/Views/WelcomeWizard.axaml.cs @@ -1,7 +1,7 @@ -using System.Diagnostics; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Threading; +using System.Diagnostics; using TypeWhisper.Linux.ViewModels; namespace TypeWhisper.Linux.Views; From 3c611ac6bd646f7046847f7fd1e86750954dc2e6 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 21 Jul 2026 12:48:58 +0000 Subject: [PATCH 128/226] Resolve the warning-level ReSharper findings across plugins Clears the 25 warning-tier findings (build/test verified, 2456 tests green). Fixed: - Removed redundant base interfaces (IDisposable / ITypeWhisperPlugin already implied by a derived interface). - Removed a redundant switch-expression arm. - Removed the dead write-only _oauthIdToken field (the id token is still persisted via the secret store). Suppressed with reason (false positives / semantically-wrong fix): - ReturnTypeCanBeNotNullable: the interfaces declare these members nullable, so the implementations match the contract by design. - AccessToDisposedClosure: the closures run within the using-scope (or the source is disposed after the captured resource). - InconsistentNaming on EstimatedSizeMB: MB (megabyte) is correct; the suggested Mb means megabit. - TypeWithSuspiciousEqualityIsUsedInRecord: config record identity is its Id. - An unused constructor parameter that disambiguates an overload. - A persisted record timestamp flagged as unread. Also swept more HINT-level mechanical findings via ReSharper's cleanup engine. --- plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs | 2 +- plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs | 1 - plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs | 1 + plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs | 1 + plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs | 1 + plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs | 6 ++---- .../OpenAiCompatiblePlugin.cs | 2 +- .../OpenAiVectorMemoryPlugin.cs | 1 + plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs | 2 ++ plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs | 4 +++- plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs | 1 + plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs | 1 + .../TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs | 3 +++ plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs | 1 + plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs | 6 +++--- 15 files changed, 22 insertions(+), 11 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs b/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs index 851267fb0..e2ab14d43 100644 --- a/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs +++ b/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs @@ -10,7 +10,7 @@ namespace TypeWhisper.Plugin.Cohere; -public sealed class CoherePlugin : ILlmProviderPlugin, IDisposable, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class CoherePlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware { private const string BaseUrl = "https://api.cohere.com/compatibility"; private readonly HttpClient _httpClient; diff --git a/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs b/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs index 84830178d..112cc4674 100644 --- a/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs @@ -12,7 +12,6 @@ namespace TypeWhisper.Plugin.Fireworks; public sealed class FireworksPlugin : ILlmProviderPlugin, - IDisposable, IPluginSettingsProvider, IPluginLocalizationAware { diff --git a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs index 92e7f01e1..6a2fe7d37 100644 --- a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs @@ -670,6 +670,7 @@ internal sealed record GemmaModelDefinition( string Id, string DisplayName, string SizeDescription, + // ReSharper disable once InconsistentNaming -- MB (megabyte) is the correct unit; the suggested Mb means megabit. int EstimatedSizeMB, bool IsRecommended, string DownloadUrl, diff --git a/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs b/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs index 05dc46dc2..b4cfc7496 100644 --- a/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs @@ -31,6 +31,7 @@ public sealed class LinearPlugin : IActionPlugin, IPluginSettingsProvider, IPlug public string ActionId => "create-linear-issue"; public string ActionName => "Create Linear Issue"; + // ReSharper disable once ReturnTypeCanBeNotNullable -- matches the interface contract, which declares this member nullable. public string? ActionIcon => "\U0001F4CB"; public IPluginHostServices? Host { get; private set; } diff --git a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs index d1e0ef0d9..97b588203 100644 --- a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs @@ -22,6 +22,7 @@ public sealed class ObsidianPlugin : IActionPlugin, IPluginSettingsProvider, IPl public string ActionId => "save-to-obsidian"; public string ActionName => "Save to Obsidian"; + // ReSharper disable once ReturnTypeCanBeNotNullable -- matches the interface contract, which declares this member nullable. public string? ActionIcon => "\ud83d\udcdd"; internal IPluginHostServices? Host { get; private set; } diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs index 43374b32c..0bb56c656 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs @@ -53,7 +53,6 @@ public sealed class OpenAiPlugin private List _fetchedLlmModels = []; private string? _oauthAccessToken; private string? _oauthRefreshToken; - private string? _oauthIdToken; private string? _oauthAccountId; private DateTimeOffset? _oauthExpiresAt; private bool _forgetChatGptLogin; @@ -141,7 +140,6 @@ public async Task ActivateAsync(IPluginHostServices host) ApiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); _oauthAccessToken = NormalizeApiKey(await host.LoadSecretAsync(OAuthAccessTokenSecretName)); _oauthRefreshToken = NormalizeApiKey(await host.LoadSecretAsync(OAuthRefreshTokenSecretName)); - _oauthIdToken = NormalizeApiKey(await host.LoadSecretAsync(OAuthIdTokenSecretName)); AuthMode = OpenAiAuthModeExtensions.Parse(host.GetSetting(AuthModeSettingName)); SelectedLlmModelId = host.GetSetting(SelectedLlmModelSettingName); _selectedVoiceId = NormalizeVoiceId(host.GetSetting(SelectedVoiceSettingName)); @@ -378,8 +376,10 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct public IReadOnlyList AvailableVoices => OpenAiTtsConfiguration.AvailableVoices; + // ReSharper disable once ReturnTypeCanBeNotNullable -- matches the interface contract, which declares this member nullable. public string? SelectedVoiceId => _selectedVoiceId ?? OpenAiTtsConfiguration.DefaultVoiceId; + // ReSharper disable once ReturnTypeCanBeNotNullable -- matches the interface contract, which declares this member nullable. public string? SettingsSummary { get @@ -719,7 +719,6 @@ internal async Task ClearChatGptLoginAsync() { _oauthAccessToken = null; _oauthRefreshToken = null; - _oauthIdToken = null; _oauthAccountId = null; ChatGptPlanType = null; _oauthExpiresAt = null; @@ -852,7 +851,6 @@ private async Task StoreOAuthTokensAsync(OpenAiOAuthTokenResponse tokens, string ? _oauthRefreshToken : tokens.RefreshToken; _oauthRefreshToken = effectiveRefreshToken; - _oauthIdToken = tokens.IdToken; _oauthAccountId = metadata.AccountId; ChatGptPlanType = metadata.PlanType; _oauthExpiresAt = metadata.ExpiresAt; diff --git a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs index 0b9e2a704..07a601c70 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs @@ -862,7 +862,7 @@ private async Task LoadAdditionalProfilesAsync(IPluginHostServices host) profile.BaseUrl = NormalizeBaseUrl(profile.BaseUrl); profile.SelectedModelId = NullIfWhiteSpace(profile.SelectedModelId); profile.SelectedLlmModelId = NullIfWhiteSpace(profile.SelectedLlmModelId); - profile.FetchedModels = (profile.FetchedModels) + profile.FetchedModels = profile.FetchedModels .Where(m => !string.IsNullOrWhiteSpace(m.Id)) .ToList(); diff --git a/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs index 4db3a2629..42f724646 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs @@ -385,5 +385,6 @@ public void Dispose() _lock.Dispose(); } + // ReSharper disable once NotAccessedPositionalProperty.Local -- CreatedAt is persisted metadata in the serialized entry shape, not dead code. private sealed record VectorMemoryEntry(string Content, float[] Embedding, DateTime CreatedAt); } diff --git a/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs b/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs index e9f7e0646..579e31718 100644 --- a/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs +++ b/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs @@ -75,6 +75,7 @@ public Task DeactivateAsync() public bool IsConfigured => !string.IsNullOrEmpty(ApiKey); public IReadOnlyList TranscriptionModels => [new(DefaultModelId, Loc.L("Settings.DefaultModel")), .. FetchedCustomModels.Select(m => new PluginModelInfo(m.Id, m.Name))]; + // ReSharper disable once ReturnTypeCanBeNotNullable -- matches the interface contract, which declares this member nullable. public string? SelectedModelId => _selectedModelId; public bool SupportsTranslation => false; public bool SupportsStreaming => true; @@ -165,6 +166,7 @@ public async Task TranscribeStreamingAsync( { var text = collector.ApplyEvent(evt); if (!string.IsNullOrWhiteSpace(text) && !onProgress(text)) + // ReSharper disable once AccessToDisposedClosure -- the closure runs only within the using-scope (or the source is disposed after the captured resource), so the access is safe. streamingCts.Cancel(); }; diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs index 5c4795266..a994b6b5d 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs @@ -9,7 +9,7 @@ namespace TypeWhisper.Plugin.SherpaOnnx; -public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEnginePlugin +public sealed class SherpaOnnxPlugin : ITranscriptionEnginePlugin { private const string ParakeetRepo = "https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/resolve/main"; @@ -998,6 +998,7 @@ private sealed record ModelDefinition( string Id, string DisplayName, string SizeDescription, + // ReSharper disable once InconsistentNaming -- MB (megabyte) is the correct unit; the suggested Mb means megabit. int EstimatedSizeMB, int LanguageCount, bool IsRecommended, @@ -1008,6 +1009,7 @@ IReadOnlyList Files private sealed record ModelFileDefinition( string FileName, string DownloadUrl, + // ReSharper disable once InconsistentNaming -- MB (megabyte) is the correct unit; the suggested Mb means megabit. int EstimatedSizeMB ); } diff --git a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs index 3ed75b6a7..5bf424b13 100644 --- a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs @@ -70,6 +70,7 @@ public Task DeactivateAsync() public string ProviderDisplayName => "Smallest AI"; public bool IsConfigured => !string.IsNullOrEmpty(ApiKey); public IReadOnlyList TranscriptionModels => s_models; + // ReSharper disable once ReturnTypeCanBeNotNullable -- matches the interface contract, which declares this member nullable. public string? SelectedModelId => _selectedModelId; public bool SupportsTranslation => false; public bool SupportsStreaming => true; diff --git a/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs b/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs index 37e0df6f6..0fcf975df 100644 --- a/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs @@ -87,6 +87,7 @@ public Task DeactivateAsync() public IReadOnlyList TranscriptionModels => s_models; + // ReSharper disable once ReturnTypeCanBeNotNullable -- matches the interface contract, which declares this member nullable. public string? SelectedModelId => _selectedModelId; public bool SupportsTranslation => false; diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs index 0ee793da2..c01fd4161 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs @@ -75,6 +75,7 @@ private SupertonicTtsPlugin( ISupertonicAssetManager? assetManager, Func synthesizerFactory, Func? playbackFactory, + // ReSharper disable once UnusedParameter.Local -- disambiguates the constructor overload; required by the signature even though unused in the body. bool useNullableAssetManagerOverload) { _injectedAssetManager = assetManager; @@ -91,6 +92,7 @@ private SupertonicTtsPlugin( public string ProviderDisplayName => "Supertonic TTS"; public bool IsConfigured => _assetManager?.AreAssetsReady ?? false; public IReadOnlyList AvailableVoices => s_voices; + // ReSharper disable once ReturnTypeCanBeNotNullable -- matches the interface contract, which declares this member nullable. public string? SelectedVoiceId => _selectedVoiceId; internal double Speed { get; private set; } = DefaultSpeed; internal int DenoisingSteps { get; private set; } = DefaultDenoisingSteps; @@ -107,6 +109,7 @@ public void SetLocalization(IPluginLocalization localization) => // plugin is disabled (never activated, so _host is null). internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; + // ReSharper disable once ReturnTypeCanBeNotNullable -- matches the interface contract, which declares this member nullable. public string? SettingsSummary { get diff --git a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs index d447da166..d66e2dec9 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs @@ -21,6 +21,7 @@ public sealed record WebhookConfig public string HttpMethod { get; init; } = "POST"; public Dictionary Headers { get; init; } = []; public bool IsEnabled { get; init; } = true; + // ReSharper disable once TypeWithSuspiciousEqualityIsUsedInRecord.Global -- config record identity is its Id; the collection members are never compared by value. public List ProfileFilter { get; init; } = []; } diff --git a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs index b9d1425f9..ffa67ca19 100644 --- a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs +++ b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs @@ -22,8 +22,7 @@ float NoSpeechProbability ); public sealed class WhisperCppPlugin - : ITypeWhisperPlugin, - ITranscriptionEnginePlugin, + : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware { @@ -417,7 +416,6 @@ public void SetAccelerationPreference(TranscriptionAccelerationPreference prefer var backend = preference switch { TranscriptionAccelerationPreference.NvidiaCuda => "cuda", - TranscriptionAccelerationPreference.Cpu => "cpu", _ => "cpu", }; @@ -787,6 +785,7 @@ CancellationToken ct async IAsyncEnumerable GetSegmentsAsync() { + // ReSharper disable once AccessToDisposedClosure -- GetSegmentsAsync is fully consumed within the await-using scope, so processor/audioStream stay alive throughout. await foreach (var segment in processor.ProcessAsync(audioStream, ct)) { yield return new WhisperCppTranscriptionSegment( @@ -1364,6 +1363,7 @@ private sealed record ModelDefinition( QuantizationType Quantization, string FileName, string SizeDescription, + // ReSharper disable once InconsistentNaming -- MB (megabyte) is the correct unit; the suggested Mb means megabit. long EstimatedSizeMB, int LanguageCount, bool IsRecommended From 980db63cf7cc21ada7e05e180936937bd661128d Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 21 Jul 2026 12:59:55 +0000 Subject: [PATCH 129/226] Fix the CA analyzer findings across plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behaviour-preserving analyzer fixes, build/test verified (2456 tests green): - CA1869: cache the per-call JsonSerializerOptions in static readonly fields (Claude, OpenAi, OpenAi OAuth client). - CA1870: use a cached SearchValues for the ElevenLabs keyterm scan. - CA1866: EndsWith(char) instead of EndsWith(string) for a single char. - CA1513: ObjectDisposedException.ThrowIf. - CA1822: mark instance-data-free members static (GetModel, ModelDefinitions, ValidateApiKeyFormat). - SYSLIB1054/CA2101: suppressed on the libc/libcuda P/Invokes with a reason — the interop is shared-compiled into several plugin projects, so LibraryImport would require AllowUnsafeBlocks in each, and CharSet.Ansi marshals as UTF-8 on Linux. --- plugins/Shared/Cuda/CudaRuntimeProvisioner.cs | 5 +++++ plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs | 15 ++++++--------- .../ElevenLabsPlugin.cs | 5 +++-- .../GemmaLocalPlugin.cs | 2 +- .../GoogleCloudSttPlugin.cs | 2 +- .../OpenAiOAuthSupport.cs | 7 ++++--- plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs | 7 +++++-- .../SherpaOnnxNativeRuntime.cs | 4 ++++ .../SupertonicTtsPlugin.cs | 3 +-- .../WhisperCppPlugin.cs | 2 +- 10 files changed, 31 insertions(+), 21 deletions(-) diff --git a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs index 428b4c6b1..e941aca39 100644 --- a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs +++ b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs @@ -794,6 +794,10 @@ internal static bool TryInitializeCudaDriver(out string? error) } } + // Kept as DllImport: this file is shared-compiled into several plugin projects, so + // LibraryImport's generated string marshalling would require AllowUnsafeBlocks in every + // consumer. CharSet.Ansi marshals as UTF-8 on Linux — correct for these libc/libcuda paths. +#pragma warning disable SYSLIB1054, CA2101 [DllImport("libcuda.so.1", EntryPoint = "cuInit")] private static extern int cuInit(uint flags); @@ -802,6 +806,7 @@ internal static bool TryInitializeCudaDriver(out string? error) [DllImport("libdl.so.2")] private static extern IntPtr dlerror(); +#pragma warning restore SYSLIB1054, CA2101 private sealed record CudaWheel( string Package, diff --git a/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs b/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs index b454be0d1..1496ca94c 100644 --- a/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs +++ b/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs @@ -19,6 +19,9 @@ public sealed class ClaudePlugin : ILlmProviderPlugin, IPluginSettingsProvider, // the stable version that covers the Messages API used here. private const string AnthropicVersion = "2023-06-01"; + private static readonly JsonSerializerOptions s_jsonOptions = + new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + private readonly HttpClient _httpClient; private IPluginHostServices? _host; private bool _streamResponses = true; @@ -78,10 +81,7 @@ CancellationToken ct messages = new[] { new { role = "user", content = userText } }, }; - var json = JsonSerializer.Serialize( - requestBody, - new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase } - ); + var json = JsonSerializer.Serialize(requestBody, s_jsonOptions); using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/messages"); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); @@ -136,10 +136,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct messages = new[] { new { role = "user", content = userText } }, }; - var json = JsonSerializer.Serialize( - requestBody, - new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase } - ); + var json = JsonSerializer.Serialize(requestBody, s_jsonOptions); using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/messages"); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); @@ -304,7 +301,7 @@ internal async Task SetApiKeyAsync(string apiKey) } } - internal bool ValidateApiKeyFormat(string apiKey) + internal static bool ValidateApiKeyFormat(string apiKey) { return !string.IsNullOrWhiteSpace(apiKey) && apiKey.StartsWith("sk-ant-"); } diff --git a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs index e05f3cdd3..910c0fca9 100644 --- a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs +++ b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs @@ -3,6 +3,7 @@ // Plugin types are instantiated by the host via reflection and invoked through plugin interfaces // and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. +using System.Buffers; using System.Net.Http.Headers; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -17,7 +18,7 @@ public sealed class ElevenLabsPlugin : ITranscriptionEnginePlugin, IPluginSettin private const string ApiKeySecretName = "api-key"; private const string SelectedModelSettingName = "selectedModel"; - private static readonly char[] s_invalidKeytermCharacters = ['<', '>', '{', '}', '[', ']', '\\']; + private static readonly SearchValues s_invalidKeytermCharacters = SearchValues.Create("<>{}[]\\"); private static readonly IReadOnlyList s_modelEntries = [ @@ -369,7 +370,7 @@ var part in prompt.Split( if ( term.Length == 0 || term.Length >= 50 - || term.IndexOfAny(s_invalidKeytermCharacters) >= 0 + || term.AsSpan().IndexOfAny(s_invalidKeytermCharacters) >= 0 || term.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length > 5 || !seen.Add(term) ) diff --git a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs index 6a2fe7d37..3ce64ac78 100644 --- a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs @@ -381,7 +381,7 @@ public void SetLocalization(IPluginLocalization localization) => // injected at load so settings labels/validation resolve even when this // plugin is disabled (never activated, so _host is null). internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; - internal IReadOnlyList ModelDefinitions => s_models; + internal static IReadOnlyList ModelDefinitions => s_models; internal void SelectModel(string modelId) { diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs index 5690c8171..67eb9ef27 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs @@ -216,7 +216,7 @@ private static PluginTranscriptionResult ParseResponse(string json, string reque { var billedStr = billedTime.GetString() ?? ""; if ( - billedStr.EndsWith("s") + billedStr.EndsWith('s') && double.TryParse( billedStr[..^1], System.Globalization.NumberStyles.Float, diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs index f52e0db43..4f4729188 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs @@ -52,6 +52,9 @@ internal static class OpenAiOAuthClient private const string AuthorizeOriginator = "opencode"; + private static readonly JsonSerializerOptions s_jsonReadOptions = + new() { PropertyNameCaseInsensitive = true }; + public static OpenAiPkceCodes GeneratePkceCodes() { var verifier = RandomOAuthString(64); @@ -145,9 +148,7 @@ private static async Task SendTokenRequestAsync( if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"OpenAI token request failed with status {(int)response.StatusCode}: {json}"); - return JsonSerializer.Deserialize( - json, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + return JsonSerializer.Deserialize(json, s_jsonReadOptions) ?? throw new InvalidOperationException("OpenAI token response could not be parsed."); } diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs index 0bb56c656..abba89ac4 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs @@ -43,6 +43,9 @@ public sealed class OpenAiPlugin private const string TemperatureModeProviderDefault = "providerDefault"; private const string TemperatureModeCustom = "custom"; + private static readonly JsonSerializerOptions s_jsonReadOptions = + new() { PropertyNameCaseInsensitive = true }; + private readonly HttpClient _httpClient; private readonly Func _ttsPlaybackFactory; private readonly Func _ttsPlaybackAvailableProbe; @@ -580,7 +583,7 @@ internal async Task> FetchLlmModelsAsync( var json = await response.Content.ReadAsStringAsync(ct); var decoded = JsonSerializer.Deserialize( json, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + s_jsonReadOptions); return decoded?.Data .Where(model => IsChatModel(model.Id)) @@ -703,7 +706,7 @@ internal async Task ImportExistingLoginAsync(string? authFilePath = null) var json = await File.ReadAllTextAsync(authFilePath); var store = JsonSerializer.Deserialize( json, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + s_jsonReadOptions) ?? throw new InvalidOperationException("Existing login file could not be parsed."); var tokens = new OpenAiOAuthTokenResponse( diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs index 6a3f0bf90..ee8a8345c 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs @@ -148,9 +148,13 @@ private static string ToSoFileName(string libraryName) return name; } + // Kept as DllImport (Linux-only libc interop): CharSet.Ansi marshals as UTF-8 here, and + // LibraryImport would need AllowUnsafeBlocks for the string marshalling for no real gain. +#pragma warning disable SYSLIB1054, CA2101 [DllImport("libdl.so.2", CharSet = CharSet.Ansi)] private static extern IntPtr dlopen(string fileName, int flags); [DllImport("libdl.so.2")] private static extern IntPtr dlerror(); +#pragma warning restore SYSLIB1054, CA2101 } diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs index c01fd4161..1bee3d0df 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs @@ -339,8 +339,7 @@ internal void SetDenoisingSteps(int steps) internal async Task DownloadAssetsAsync(IProgress? progress, CancellationToken ct) { - if (_disposed) - throw new ObjectDisposedException(nameof(SupertonicTtsPlugin)); + ObjectDisposedException.ThrowIf(_disposed, this); if (!HasAcceptedModelLicense) throw new InvalidOperationException("The Supertonic 3 OpenRAIL-M license must be accepted before downloading model assets."); diff --git a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs index ffa67ca19..202eda57a 100644 --- a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs +++ b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs @@ -1134,7 +1134,7 @@ out var parsed ); } - private ModelDefinition GetModel(string modelId) => + private static ModelDefinition GetModel(string modelId) => s_models.FirstOrDefault(model => model.Id == modelId) ?? throw new ArgumentException($"Unknown model: {modelId}"); From 7f9a8f1c2acc1615ad9a963f9ec50d925cd3f877 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 21 Jul 2026 13:15:40 +0000 Subject: [PATCH 130/226] Clear the remaining note-level ReSharper findings across plugins Suppresses the subjective/structural context-action findings with reasons (InvertIf, if->switch/return/ternary, foreach->query, MethodHasAsyncOverload Cancel->CancelAsync in teardown paths, MethodSupportsCancellation, merge-pattern, move-local-function, etc.) and applies the genuine mechanical ones: - Dictionary TryGetValue+ternary -> GetValueOrDefault. - Encoding.UTF8.GetBytes(literal) -> "..."u8.ToArray(). - Array.Empty() -> [] ; index [^1] ; cast -> lambda return type. - Closure -> method group; inline single-use temporaries; simplify a redundant conditional and a raw interpolated string. - Made a test-seam property init-only. Suppressed with reason where the "fix" is a false positive or cascade: - ClassNeverInstantiated on JSON-deserialized DTO records. - UseCollectionExpression that breaks DenseTensor overload resolution. - ConvertToConstant / ConvertToAutoProperty that would fight the s_ naming or the expression-bodied accessor. Build + full suite green (2456 tests). --- plugins/Shared/Cuda/CudaRuntimeProvisioner.cs | 4 +++- plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs | 3 ++- .../AssemblyAiStreamingSession.cs | 3 ++- .../DeepgramStreamingSession.cs | 3 ++- .../ElevenLabsStreamingSession.cs | 5 ++++- plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs | 4 ++++ .../TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs | 3 ++- plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs | 6 ++++-- plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs | 3 +++ plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs | 4 ++-- plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs | 2 +- .../OpenAiRealtimeStreamingSession.cs | 1 + plugins/TypeWhisper.Plugin.OpenAi/OpenAiTtsSupport.cs | 1 + .../OpenAiCompatiblePlugin.cs | 8 ++++---- plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs | 1 + plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs | 3 +++ .../TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs | 1 + plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs | 1 + .../SherpaCudaRuntimeInstaller.cs | 1 + plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs | 2 ++ .../SmallestAiStreamingSession.cs | 2 ++ .../TypeWhisper.Plugin.Soniox/SonioxStreamingSession.cs | 2 ++ .../TypeWhisper.Plugin.Speechmatics/SpeechmaticsPlugin.cs | 6 +++--- .../SpeechmaticsStreamingSession.cs | 2 ++ .../SupertonicOnnxSynthesizer.cs | 3 +++ .../SupertonicTextProcessor.cs | 1 + .../SupertonicTtsPlayback.cs | 1 + .../SupertonicTtsPlugin.cs | 5 ++--- plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs | 4 +++- plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs | 2 ++ .../WhisperCudaRuntimeInstaller.cs | 4 +++- plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs | 4 +++- plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs | 3 +++ plugins/TypeWhisper.Plugin.Xai/XaiTtsSupport.cs | 1 + 34 files changed, 75 insertions(+), 24 deletions(-) diff --git a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs index e941aca39..9dbf1489c 100644 --- a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs +++ b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs @@ -333,6 +333,7 @@ CancellationToken ct // The single linux x64 wheel: a manylinux build for x86_64. Excludes // win_amd64 and aarch64. The exact glibc tag (2_17 vs 2_27) varies per // package, so match on the platform family rather than a fixed tag. + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- local function kept near its point of use for readability. static bool IsLinuxX64Wheel(JsonElement entry) { if (entry.TryGetProperty("packagetype", out var pkgType) @@ -489,6 +490,7 @@ internal void ExtractSharedObjects(string wheelPath) { // Keep only the shared objects under nvidia//lib/, skipping // directory entries, Python stubs, headers, and metadata. + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- local function kept near its point of use for readability. static bool IsLibEntry(ZipArchiveEntry entry) => !entry.FullName.EndsWith('/') && entry.FullName.Contains("/lib/", StringComparison.Ordinal) @@ -605,7 +607,7 @@ private bool IsWheelSatisfied(CudaWheel wheel) // dev box with the CUDA toolkit installed would otherwise satisfy every wheel and skip // the download/extract/marker path under test. Null = production behavior (real system // dirs + ldconfig). Only consulted here; PreloadAll's path resolution is untouched. - internal Func? SystemLibraryProbeForTests { get; set; } + internal Func? SystemLibraryProbeForTests { get; init; } private bool IsResolvableOnSystem(string soname) { diff --git a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs index 9c1750b94..d53f66229 100644 --- a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs @@ -173,6 +173,7 @@ CancellationToken ct var root = doc.RootElement; var status = root.GetProperty("status").GetString(); + // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here. if (status == "error") { var error = root.TryGetProperty("error", out var errEl) @@ -188,7 +189,7 @@ CancellationToken ct var duration = root.TryGetProperty("audio_duration", out var durEl) ? durEl.GetDouble() : 0.0; - string? detectedLanguage = root.TryGetProperty("language_code", out var langEl) + var detectedLanguage = root.TryGetProperty("language_code", out var langEl) ? langEl.GetString() : null; return new PluginTranscriptionResult( diff --git a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs index 57d187e2f..f22b83ac4 100644 --- a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs @@ -59,7 +59,7 @@ public async Task FinalizeAsync(CancellationToken ct) { if (_ws.State != WebSocketState.Open) return; - var msg = Encoding.UTF8.GetBytes("""{"terminate_session":true}"""); + var msg = """{"terminate_session":true}"""u8.ToArray(); await _ws.SendAsync(msg, WebSocketMessageType.Text, true, ct); } @@ -125,6 +125,7 @@ private void ParseAndEmit(string json) public async ValueTask DisposeAsync() { + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _receiveCts.Cancel(); if (_ws.State == WebSocketState.Open) diff --git a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs index ca54147e3..518b8b79f 100644 --- a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs @@ -54,7 +54,7 @@ public async Task FinalizeAsync(CancellationToken ct) { if (_ws.State != WebSocketState.Open) return; - var msg = Encoding.UTF8.GetBytes("""{"type":"CloseStream"}"""); + var msg = """{"type":"CloseStream"}"""u8.ToArray(); await _ws.SendAsync(msg, WebSocketMessageType.Text, true, ct); } @@ -123,6 +123,7 @@ private void ParseAndEmit(string json) public async ValueTask DisposeAsync() { + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _receiveCts.Cancel(); if (_ws.State == WebSocketState.Open) diff --git a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs index 787e82dc0..db9537d43 100644 --- a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs @@ -94,7 +94,7 @@ public async Task FinalizeAsync(CancellationToken ct) // Always send a terminal commit so the server knows the audio // stream is done, even when the buffer happens to be empty // because SendAudioAsync just flushed an exact-chunk boundary. - var chunk = _audioBuffer.Length == 0 ? Array.Empty() : _audioBuffer.ToArray(); + var chunk = _audioBuffer.Length == 0 ? [] : _audioBuffer.ToArray(); _audioBuffer.SetLength(0); await SendAudioPayloadAsync(chunk, commit: true, ct); } @@ -165,6 +165,7 @@ out string? error return false; } + // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here. if (messageType is "partial_transcript") { var text = GetText(root); @@ -283,6 +284,7 @@ public async ValueTask DisposeAsync() await _sendLock.WaitAsync(CancellationToken.None); try { + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _receiveCts.Cancel(); if (_ws.State == WebSocketState.Open) @@ -316,6 +318,7 @@ await _ws.CloseAsync( } } + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _audioBuffer.Dispose(); } finally diff --git a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs index 3ce64ac78..da960eb1a 100644 --- a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs @@ -124,6 +124,7 @@ public async Task DeactivateAsync() { try { + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. startupCts.Cancel(); } catch (ObjectDisposedException) { } @@ -146,6 +147,7 @@ public async Task DeactivateAsync() // Acquire _inferenceLock so we can't dispose _context/_weights while // ProcessAsync is mid-inference. Mirrors the unload path in // SetSettingValueAsync and LoadModelAsync. + // ReSharper disable once MethodSupportsCancellation -- short teardown/unload path; adding a cancellation point offers no real value. await _inferenceLock.WaitAsync().ConfigureAwait(false); try { @@ -381,6 +383,7 @@ public void SetLocalization(IPluginLocalization localization) => // injected at load so settings labels/validation resolve even when this // plugin is disabled (never activated, so _host is null). internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization; + // ReSharper disable once ConvertToAutoPropertyWhenPossible -- expression-bodied accessor returning the shared static list; not an auto-property candidate. internal static IReadOnlyList ModelDefinitions => s_models; internal void SelectModel(string modelId) @@ -651,6 +654,7 @@ public void Dispose() // Mirror DeactivateAsync: serialize teardown with any in-flight // ProcessAsync so we don't dispose _context/_weights mid-inference. + // ReSharper disable once MethodSupportsCancellation -- short teardown/unload path; adding a cancellation point offers no real value. _inferenceLock.Wait(); try { diff --git a/plugins/TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs b/plugins/TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs index 11dd241e9..360f160cc 100644 --- a/plugins/TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs @@ -81,7 +81,7 @@ public async Task FinalizeAsync(CancellationToken ct) { try { - var stop = Encoding.UTF8.GetBytes("""{"type":"stop_recording"}"""); + var stop = """{"type":"stop_recording"}"""u8.ToArray(); await _ws.SendAsync(stop, WebSocketMessageType.Text, true, ct); } catch (Exception ex) when (ex is WebSocketException or OperationCanceledException) @@ -258,6 +258,7 @@ private void Emit(StreamingTranscriptEvent evt) public async ValueTask DisposeAsync() { + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _receiveCts.Cancel(); if (_ws.State == WebSocketState.Open) diff --git a/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs b/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs index b4cfc7496..be8a4f71d 100644 --- a/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs @@ -109,11 +109,10 @@ CancellationToken ct ); var title = ExtractTitle(input); - var description = input; try { - var issueUrl = await CreateIssueAsync(title, description, ct); + var issueUrl = await CreateIssueAsync(title, input, ct); if (issueUrl is not null) return new ActionResult( @@ -192,6 +191,7 @@ public async Task> FetchTeamsAsync(CancellationToken ct = defau var data = response.Value.GetProperty("data").GetProperty("teams").GetProperty("nodes"); var teams = new List(); + // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator -- explicit loop kept; the LINQ form switches enumerators. foreach (var node in data.EnumerateArray()) { teams.Add( @@ -362,6 +362,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p { throw; } + // ReSharper disable once MergeIntoLogicalPattern -- subjective style; kept as-is. catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException) { var fp = ShortFingerprint(ex.ToString()); @@ -388,6 +389,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p { throw; } + // ReSharper disable once MergeIntoLogicalPattern -- subjective style; kept as-is. catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException) { var fingerprint = ShortFingerprint(ex.ToString()); diff --git a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs index 97b588203..16161fa63 100644 --- a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs @@ -77,6 +77,7 @@ CancellationToken ct string filePath; string filename; + // ReSharper disable once TooWideLocalVariableScope -- declared with its siblings; both branches assign it before the shared use below. string content; if (dailyNoteMode) @@ -171,6 +172,7 @@ private static string SanitizeFilename(string filename) foreach (var c in filename) { + // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression -- subjective style; kept as an explicit if. if (Array.IndexOf(invalid, c) >= 0) sanitized.Append('_'); else @@ -203,6 +205,7 @@ private static string EnsureUniqueFilePath(string filePath) return candidate; } + // ReSharper disable once UseVerbatimString -- the mixed backslash/quote escapes read no better as a verbatim string. private static string EscapeYaml(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs index 4f4729188..d2cd65373 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs @@ -414,14 +414,14 @@ private static async Task SendHtmlAsync(Stream stream, string html, Cancellation """; private static string ErrorHtml(string message) => - $$""" + $""" TypeWhisper Login

Login failed

-

{{message}}

+

{message}

diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs index abba89ac4..5a805d188 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs @@ -913,7 +913,7 @@ private void NormalizeSelectedLlmModel(bool persist) try { var value = host.GetSetting(OAuthExpiresAtSettingName); - return value == default ? null : value; + return value; } catch { diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs index 1b3e47599..9bfebdd6b 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs @@ -568,6 +568,7 @@ public bool ApplyEvent(string json, out StreamingTranscriptEvent? transcriptEven // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (root.TryGetProperty("error", out var error)) { + // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here. if (error.ValueKind == JsonValueKind.Object) { if (GetString(error, "message") is { } message) diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiTtsSupport.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiTtsSupport.cs index 17d2ad1ab..2e74ec25e 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiTtsSupport.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiTtsSupport.cs @@ -204,6 +204,7 @@ private static byte[] BuildWav(byte[] pcm16Audio, int sampleRate) if (CommandExists("paplay")) return "paplay"; + // ReSharper disable once ConvertIfStatementToReturnStatement -- subjective style; kept as an explicit if. if (CommandExists("aplay")) return "aplay"; diff --git a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs index 07a601c70..a1c7c4709 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs @@ -537,12 +537,12 @@ private static bool CatalogChanged(List fetched, List AdditionalTranscriptionEngines => _additionalProfiles - .Select(p => (ITranscriptionEnginePlugin)new OpenAiCompatibleProfileRole(this, p.Id)) + .Select(ITranscriptionEnginePlugin (p) => new OpenAiCompatibleProfileRole(this, p.Id)) .ToList(); public IReadOnlyList AdditionalLlmProviders => _additionalProfiles - .Select(p => (ILlmProviderPlugin)new OpenAiCompatibleProfileRole(this, p.Id)) + .Select(ILlmProviderPlugin (p) => new OpenAiCompatibleProfileRole(this, p.Id)) .ToList(); public IReadOnlyList GetCollectionDefinitions() => @@ -926,12 +926,12 @@ CancellationToken ct } private static string? Get(PluginCollectionItem item, string key) => - item.Values.TryGetValue(key, out var value) ? value : null; + item.Values.GetValueOrDefault(key); private static string SecretKeyFor(string profileId) => $"api-key.{profileId}"; private string? GetProfileApiKey(string id) => - _additionalApiKeys.TryGetValue(id, out var key) ? key : null; + _additionalApiKeys.GetValueOrDefault(id); private OpenAiCompatibleProfile? FindAdditional(string id) => _additionalProfiles.FirstOrDefault(p => string.Equals(p.Id, id, StringComparison.Ordinal)); diff --git a/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs b/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs index 01729fee6..3c94601f4 100644 --- a/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs @@ -913,6 +913,7 @@ private static bool ParseBool(string? value) => private sealed record OpenRouterModelsResponse(List Data); + // ReSharper disable ClassNeverInstantiated.Local -- these records are populated by JSON deserialization of the models response. private sealed record OpenRouterApiModel( string Id, string Name, diff --git a/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs b/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs index 579e31718..b867ce54d 100644 --- a/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs +++ b/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs @@ -524,6 +524,8 @@ private static string NormalizeAuthHeader(string? header) => private void ThrowForApiError(HttpStatusCode statusCode, string json) { var message = ExtractApiError(json); + // ReSharper disable once ConvertSwitchStatementToSwitchExpression -- subjective style; the statement switch reads fine here. + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault -- the default arm intentionally covers the remaining enum values. switch (statusCode) { case HttpStatusCode.Unauthorized: @@ -591,6 +593,7 @@ private static bool HasAscii(byte[] bytes, int offset, string value) if (offset + value.Length > bytes.Length) return false; + // ReSharper disable once LoopCanBeConvertedToQuery -- explicit loop kept; clearer than the LINQ form here. for (var i = 0; i < value.Length; i++) { if (bytes[offset + i] != value[i]) diff --git a/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs b/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs index 3b1f33d03..9ff265b29 100644 --- a/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs @@ -191,6 +191,7 @@ public async ValueTask DisposeAsync() return; _disposed = true; + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _receiveCts.Cancel(); _flushConfirmed.TrySetResult(); diff --git a/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs b/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs index 28fb4db2f..33eac878e 100644 --- a/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs @@ -180,6 +180,7 @@ CancellationToken ct { var current = text; + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- explicit loop kept; the LINQ form switches enumerators and obscures the side effects. foreach (var script in Scripts.ToList()) { if (!script.IsEnabled) diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaCudaRuntimeInstaller.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaCudaRuntimeInstaller.cs index 03b58d81d..a1defb939 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaCudaRuntimeInstaller.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaCudaRuntimeInstaller.cs @@ -197,6 +197,7 @@ private Task DownloadAsync(string destination, IProgress? progress, Canc // report (the resume baseline jump) always fires. var lastReport = DateTime.MinValue; + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- local function kept near its point of use for readability. void OnBytesOnDisk(long onDisk) { var now = DateTime.UtcNow; diff --git a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs index 5bf424b13..80c71416b 100644 --- a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs @@ -313,6 +313,7 @@ private static bool IsApiError(JsonElement root) return true; } + // ReSharper disable once ConvertIfStatementToReturnStatement -- subjective style; kept as an explicit if. if (root.TryGetProperty("error", out var error) && error.ValueKind is JsonValueKind.Object or JsonValueKind.String) { @@ -340,6 +341,7 @@ internal static string ExtractApiError(JsonElement root) // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (root.TryGetProperty("error", out var error)) { + // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here. if (error.ValueKind == JsonValueKind.String) return error.GetString() ?? "Unknown error"; diff --git a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs index 7eb4fca1f..5224098ed 100644 --- a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs @@ -178,6 +178,7 @@ public async ValueTask DisposeAsync() return; _disposed = true; + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _receiveCts.Cancel(); _lastResponseReceived.TrySetResult(); @@ -268,6 +269,7 @@ internal sealed class SmallestAiTranscriptCollector } var transcript = GetString(root, "transcript")?.Trim() ?? ""; + // ReSharper disable once ConvertIfStatementToReturnStatement -- subjective style; kept as an explicit if. if (string.IsNullOrWhiteSpace(transcript)) return null; diff --git a/plugins/TypeWhisper.Plugin.Soniox/SonioxStreamingSession.cs b/plugins/TypeWhisper.Plugin.Soniox/SonioxStreamingSession.cs index 3d73ee4ac..21501212e 100644 --- a/plugins/TypeWhisper.Plugin.Soniox/SonioxStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Soniox/SonioxStreamingSession.cs @@ -160,6 +160,7 @@ internal static SonioxMessage ParseMessage(string json) if (root.TryGetProperty("tokens", out var tokensEl) && tokensEl.ValueKind == JsonValueKind.Array) { + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- explicit loop kept; the LINQ form switches enumerators and obscures the side effects. foreach (var tok in tokensEl.EnumerateArray()) { if (tok.ValueKind != JsonValueKind.Object) @@ -299,6 +300,7 @@ private void Emit(StreamingTranscriptEvent evt) public async ValueTask DisposeAsync() { + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _receiveCts.Cancel(); if (_ws.State == WebSocketState.Open) diff --git a/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsPlugin.cs b/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsPlugin.cs index 32e6e27c4..eb9d94c34 100644 --- a/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsPlugin.cs @@ -104,13 +104,11 @@ CancellationToken ct "Speechmatics does not support automatic language detection. Choose an explicit language for this profile." ); - var lang = normalized; - var config = JsonSerializer.Serialize( new { type = "transcription", - transcription_config = new { language = lang, operating_point = "enhanced" }, + transcription_config = new { language = normalized, operating_point = "enhanced" }, } ); @@ -187,6 +185,7 @@ CancellationToken ct var job = statusDoc.RootElement.GetProperty("job"); var status = job.GetProperty("status").GetString(); + // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here. if (status == "done") { using var transcriptRequest = new HttpRequestMessage( @@ -216,6 +215,7 @@ CancellationToken ct return ParseTranscript(transcriptJson, job); } + // ReSharper disable once MergeIntoLogicalPattern -- subjective style; kept as-is. if (status == "rejected" || status == "deleted") throw new InvalidOperationException($"Speechmatics job {jobId} {status}"); } diff --git a/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsStreamingSession.cs b/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsStreamingSession.cs index 2ab5231f7..f504f34c1 100644 --- a/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsStreamingSession.cs @@ -191,6 +191,7 @@ private async Task AwaitRecognitionStartedAsync(CancellationToken ct) ); var message = ParseMessage(json); + // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here. if (message.MessageType == "RecognitionStarted") return; if (message.MessageType == "Error") @@ -314,6 +315,7 @@ private void Emit(StreamingTranscriptEvent evt) public async ValueTask DisposeAsync() { + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _receiveCts.Cancel(); if (_ws.State == WebSocketState.Open) diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicOnnxSynthesizer.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicOnnxSynthesizer.cs index 1476c5647..fab5ae1bc 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicOnnxSynthesizer.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicOnnxSynthesizer.cs @@ -36,6 +36,7 @@ public SupertonicSynthesisResult Synthesize(SupertonicSynthesisRequest request, { var style = GetVoiceStyle(request.VoiceStylePath); var samples = new List(); + // ReSharper disable once MergeIntoLogicalPattern -- subjective style; kept as-is. var chunks = ChunkText(request.Text, request.Language == "ko" || request.Language == "ja" ? 120 : 300); foreach (var chunk in chunks) @@ -109,7 +110,9 @@ private float[] InferSingle( NamedOnnxValue.CreateFromTensor("text_emb", textEmbedding), NamedOnnxValue.CreateFromTensor("style_ttl", style.Ttl), NamedOnnxValue.CreateFromTensor("text_mask", features.TextMask), + // ReSharper disable once UseCollectionExpression -- explicit int[]/float[] keeps the DenseTensor constructor overload unambiguous. NamedOnnxValue.CreateFromTensor("latent_mask", new DenseTensor(latentMask, new[] { 1, 1, latentLength })), + // ReSharper disable once UseCollectionExpression -- explicit int[]/float[] keeps the DenseTensor constructor overload unambiguous. NamedOnnxValue.CreateFromTensor("total_step", new DenseTensor(new[] { (float)totalSteps }, new[] { 1 })), NamedOnnxValue.CreateFromTensor("current_step", new DenseTensor(new[] { (float)step }, new[] { 1 })), ]); diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTextProcessor.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTextProcessor.cs index 1f75ebbe6..0a60c8ba0 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTextProcessor.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTextProcessor.cs @@ -113,6 +113,7 @@ private static string RemoveEmojiCodePoints(string text) private static bool IsEmoji(int codePoint) => codePoint is >= 0x1F600 and <= 0x1F64F + // ReSharper disable once MergeIntoLogicalPattern -- subjective style; kept as-is. || codePoint is >= 0x1F300 and <= 0x1F5FF || codePoint is >= 0x1F680 and <= 0x1F6FF || codePoint is >= 0x1F700 and <= 0x1F77F diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlayback.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlayback.cs index fa431fe55..f53bbe988 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlayback.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlayback.cs @@ -156,6 +156,7 @@ private static byte[] BuildWav(float[] samples, int sampleRate) if (CommandExists("paplay")) return "paplay"; + // ReSharper disable once ConvertIfStatementToReturnStatement -- subjective style; kept as an explicit if. if (CommandExists("aplay")) return "aplay"; diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs index 1bee3d0df..047574acf 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs @@ -58,7 +58,7 @@ public SupertonicTtsPlugin() : this( assetManager: null, synthesizerFactory: assetRoot => new SupertonicOnnxSynthesizer(assetRoot), - playbackFactory: (samples, sampleRate) => SupertonicTtsPlaybackSession.Create(samples, sampleRate), + playbackFactory: SupertonicTtsPlaybackSession.Create, useNullableAssetManagerOverload: true) { } @@ -81,8 +81,7 @@ private SupertonicTtsPlugin( _injectedAssetManager = assetManager; _assetManager = assetManager; _synthesizerFactory = synthesizerFactory; - _playbackFactory = playbackFactory - ?? ((samples, sampleRate) => SupertonicTtsPlaybackSession.Create(samples, sampleRate)); + _playbackFactory = playbackFactory ?? SupertonicTtsPlaybackSession.Create; } public string PluginId => "com.typewhisper.supertonic-tts"; diff --git a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs index d66e2dec9..0a2912010 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs @@ -266,6 +266,7 @@ public async Task SendWebhooksAsync(TranscriptionCompletedEvent evt) lock (_webhooksLock) snapshot = Webhooks.ToList(); + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- explicit loop kept; the LINQ form switches enumerators and obscures the side effects. foreach (var webhook in snapshot) { if (!webhook.IsEnabled) @@ -681,7 +682,7 @@ Task Fail(string label, string reason) => } private static string? Get(PluginCollectionItem item, string key) => - item.Values.TryGetValue(key, out var value) ? value : null; + item.Values.GetValueOrDefault(key); private static bool TryGetBool(PluginCollectionItem item, string key, out bool value) { @@ -746,6 +747,7 @@ internal static string SerializeProfiles(IEnumerable profiles) => /// Parses multiline profile text; trims each entry and skips blank lines. internal static List ParseProfiles(string? text) { + // ReSharper disable once ConvertIfStatementToReturnStatement -- subjective style; kept as an explicit if. if (string.IsNullOrWhiteSpace(text)) return []; diff --git a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs index 202eda57a..6c574738b 100644 --- a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs +++ b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs @@ -783,9 +783,11 @@ CancellationToken ct await using var processor = builder.Build(); await using var audioStream = new MemoryStream(wavAudio, writable: false); + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- local function kept near its point of use for readability. async IAsyncEnumerable GetSegmentsAsync() { // ReSharper disable once AccessToDisposedClosure -- GetSegmentsAsync is fully consumed within the await-using scope, so processor/audioStream stay alive throughout. + // ReSharper disable once AccessToDisposedClosure -- the closure runs within the using-scope, so the captured resource is still alive. await foreach (var segment in processor.ProcessAsync(audioStream, ct)) { yield return new WhisperCppTranscriptionSegment( diff --git a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCudaRuntimeInstaller.cs b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCudaRuntimeInstaller.cs index 8a2fb5aa6..a50069002 100644 --- a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCudaRuntimeInstaller.cs +++ b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCudaRuntimeInstaller.cs @@ -35,6 +35,7 @@ internal class WhisperCudaRuntimeInstaller private const string PackageId = "whisper.net.runtime.cuda.linux"; // The canonical, immutable package artifact on nuget.org's flat container. + // ReSharper disable once ConvertToConstant.Global -- kept as static readonly; const would force a PascalCase rename off the s_ convention. internal static readonly string s_downloadUrl = $"https://api.nuget.org/v3-flatcontainer/{PackageId}/{RuntimeVersion}/" + $"{PackageId}.{RuntimeVersion}.nupkg"; @@ -220,6 +221,7 @@ CancellationToken ct // report (the resume baseline jump) always fires. var lastReport = DateTime.MinValue; + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- local function kept near its point of use for readability. void OnBytesOnDisk(long onDisk) { var now = DateTime.UtcNow; @@ -239,7 +241,7 @@ void OnBytesOnDisk(long onDisk) idleTimeout: TimeSpan.FromSeconds(60), allowResume: true, onBytesOnDisk: OnBytesOnDisk, - verifyComplete: path => VerifySha256(path), + verifyComplete: VerifySha256, ct ); } diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs b/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs index 78e4385c8..799835c4b 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs @@ -173,6 +173,7 @@ public async IAsyncEnumerable ProcessStreamingAsync( return null; } + // ReSharper disable once ConvertSwitchStatementToSwitchExpression -- subjective style; the statement switch reads fine here. switch (typeEl.GetString()) { case "error": @@ -230,6 +231,7 @@ public static string ParseResponse(string json) continue; } + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- explicit loop kept; the LINQ form switches enumerators and obscures the side effects. foreach (var contentItem in content.EnumerateArray()) { var type = TryGetNonEmptyString(contentItem, "type"); @@ -259,7 +261,7 @@ private static string JoinTextParts(IReadOnlyList parts) foreach (var part in parts.Where(static part => !string.IsNullOrEmpty(part))) { if (builder.Length > 0 - && !char.IsWhiteSpace(builder[builder.Length - 1]) + && !char.IsWhiteSpace(builder[^1]) && !char.IsWhiteSpace(part[0]) && !char.IsPunctuation(part[0])) { diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs b/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs index 4490a9e84..c11492026 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs @@ -233,6 +233,7 @@ public async ValueTask DisposeAsync() return; _disposed = true; + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _receiveCts.Cancel(); await _sendLock.WaitAsync(CancellationToken.None); @@ -354,6 +355,7 @@ public PluginTranscriptionResult FinalResult(string? fallbackLanguage) var speechFinal = GetBool(root, "speech_final"); RememberMetadata(root); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (isFinal) { if (string.IsNullOrWhiteSpace(text)) @@ -456,6 +458,7 @@ private static bool GetBool(JsonElement root, string propertyName) => // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (root.TryGetProperty("error", out var error)) { + // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here. if (error.ValueKind == JsonValueKind.Object && GetString(error, "message") is { } objectMessage) return objectMessage; if (error.ValueKind == JsonValueKind.String) diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiTtsSupport.cs b/plugins/TypeWhisper.Plugin.Xai/XaiTtsSupport.cs index 2d737f35d..e96b802b0 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiTtsSupport.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiTtsSupport.cs @@ -196,6 +196,7 @@ private static byte[] BuildWav(byte[] pcm16Audio, int sampleRate) if (CommandExists("paplay")) return "paplay"; + // ReSharper disable once ConvertIfStatementToReturnStatement -- subjective style; kept as an explicit if. if (CommandExists("aplay")) return "aplay"; From c462eacc6e8e478cbbb24dd14f8567e5ccf0b850 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 21 Jul 2026 13:27:14 +0000 Subject: [PATCH 131/226] Add trailing commas and clear the last ReSharper findings - Add the trailing commas ReSharper wants in multiline lists (the codebase's configured style), across plugins and tests. - Fix the last stragglers: a robust disable/restore block for the dual AccessToDisposedClosure on the WhisperCpp segment enumerator; reasoned suppressions for a pattern-binding inline, a DenseTensor collection expression (breaks overload resolution), and a ProcessStartInfo object initializer. Brings the whole-solution ReSharper HINT count to zero. Build clean; full suite green (one pre-existing flaky ProfileService concurrency test passes on retry). --- plugins/Shared/Cuda/CudaRuntimeProvisioner.cs | 1 + .../TypeWhisper.Plugin.Script/ScriptPlugin.cs | 1 + .../SupertonicOnnxSynthesizer.cs | 1 + .../WhisperCppPlugin.cs | 6 +- .../Services/CleanupService.cs | 12 ++-- .../Services/IdeFileReferenceService.cs | 4 +- .../Services/LocalModelStorageService.cs | 4 +- .../Services/ActiveWindowService.cs | 10 ++-- .../Services/ApiDiscoveryFile.cs | 2 +- .../Services/DictationOrchestrator.cs | 56 +++++++++---------- .../Evdev/EvdevGlobalShortcutBackend.cs | 2 +- .../Services/HttpApiService.cs | 42 +++++++------- .../Services/Ipc/ControlSocketOwnership.cs | 2 +- .../Services/Localization/StrExtension.cs | 2 +- .../Services/RecentTranscriptionsService.cs | 2 +- .../Services/Setup/PackageInstaller.cs | 4 +- .../Services/TransformSelectionService.cs | 10 ++-- .../Views/Sections/HistorySection.axaml.cs | 4 +- .../Views/Sections/SnippetsSection.axaml.cs | 4 +- .../ClaudePluginTests.cs | 8 +-- .../ElevenLabsPluginTests.cs | 6 +- .../GladiaPluginTests.cs | 4 +- .../GoogleCloudSttPluginTests.cs | 2 +- .../GroqPluginTests.cs | 10 ++-- .../OpenAiCompatiblePluginTests.cs | 18 +++--- .../OpenAiPluginTests.cs | 14 ++--- .../OpenRouterPluginTests.cs | 16 +++--- .../Reson8PluginTests.cs | 14 ++--- .../SharedHelperStreamingCohortTests.cs | 10 ++-- .../SonioxPluginTests.cs | 6 +- .../SpeechmaticsPluginTests.cs | 4 +- .../SupertonicTtsPluginTests.cs | 10 ++-- .../WhisperCppPluginTests.cs | 2 +- .../XaiPluginTests.cs | 16 +++--- 34 files changed, 157 insertions(+), 152 deletions(-) diff --git a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs index 9dbf1489c..8672501e6 100644 --- a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs +++ b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs @@ -611,6 +611,7 @@ private bool IsWheelSatisfied(CudaWheel wheel) private bool IsResolvableOnSystem(string soname) { + // ReSharper disable once InlineTemporaryVariable -- the pattern binding carries the non-null narrowing; inlining it reads worse. if (SystemLibraryProbeForTests is { } probe) return probe(soname); diff --git a/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs b/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs index 33eac878e..a8e8ae159 100644 --- a/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs @@ -228,6 +228,7 @@ CancellationToken ct { var (fileName, arguments) = ResolveShell(script); + // ReSharper disable once UseObjectOrCollectionInitializer -- the Environment entries are set after the core initializer for readability. var psi = new ProcessStartInfo { FileName = fileName, diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicOnnxSynthesizer.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicOnnxSynthesizer.cs index fab5ae1bc..e36791a71 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicOnnxSynthesizer.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicOnnxSynthesizer.cs @@ -114,6 +114,7 @@ private float[] InferSingle( NamedOnnxValue.CreateFromTensor("latent_mask", new DenseTensor(latentMask, new[] { 1, 1, latentLength })), // ReSharper disable once UseCollectionExpression -- explicit int[]/float[] keeps the DenseTensor constructor overload unambiguous. NamedOnnxValue.CreateFromTensor("total_step", new DenseTensor(new[] { (float)totalSteps }, new[] { 1 })), + // ReSharper disable once UseCollectionExpression -- explicit int[]/float[] keeps the DenseTensor constructor overload unambiguous. NamedOnnxValue.CreateFromTensor("current_step", new DenseTensor(new[] { (float)step }, new[] { 1 })), ]); latent = vectorOutputs.First(output => output.Name == "denoised_latent").AsTensor().ToArray(); diff --git a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs index 6c574738b..54bf41e4c 100644 --- a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs +++ b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs @@ -786,8 +786,9 @@ CancellationToken ct // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- local function kept near its point of use for readability. async IAsyncEnumerable GetSegmentsAsync() { - // ReSharper disable once AccessToDisposedClosure -- GetSegmentsAsync is fully consumed within the await-using scope, so processor/audioStream stay alive throughout. - // ReSharper disable once AccessToDisposedClosure -- the closure runs within the using-scope, so the captured resource is still alive. + // AccumulateSegmentsAsync awaits this enumerable to completion before the + // await-using scope exits, so processor/audioStream stay alive throughout. + // ReSharper disable AccessToDisposedClosure await foreach (var segment in processor.ProcessAsync(audioStream, ct)) { yield return new WhisperCppTranscriptionSegment( @@ -797,6 +798,7 @@ async IAsyncEnumerable GetSegmentsAsync() segment.NoSpeechProbability ); } + // ReSharper restore AccessToDisposedClosure } return await AccumulateSegmentsAsync(GetSegmentsAsync(), threshold); diff --git a/src/TypeWhisper.Core/Services/CleanupService.cs b/src/TypeWhisper.Core/Services/CleanupService.cs index eba3df366..faa4c55bc 100644 --- a/src/TypeWhisper.Core/Services/CleanupService.cs +++ b/src/TypeWhisper.Core/Services/CleanupService.cs @@ -32,7 +32,7 @@ public sealed partial class CleanupService "things", "to", "we", - "with" + "with", }; // kept instance: injected as a DI/test seam by callers @@ -51,7 +51,7 @@ public string Clean(string text, CleanupLevel level) // Medium/High LLM cleanup is intentionally not wired yet. Until a // provider-backed pass exists, degrade to deterministic cleanup. CleanupLevel.Medium or CleanupLevel.High => CleanLight(text), - _ => text + _ => text, }; } @@ -66,7 +66,7 @@ public static string GetLlmSystemPrompt(CleanupLevel level) nameof(level), level, "Only Medium and High cleanup use LLM prompts." - ) + ), }; } @@ -132,7 +132,7 @@ private static string ApplySpokenPunctuation(string text) "exclamation mark" or "exclamation point" => "!", "colon" => ":", "semicolon" => ";", - _ => match.Value + _ => match.Value, }; } ); @@ -149,7 +149,7 @@ private static bool ShouldApplySpokenPunctuation(string text, Match match, strin "comma" or "colon" or "semicolon" => previousWordCount >= 1 && hasWordAfter, "question mark" or "exclamation mark" or "exclamation point" => previousWordCount >= 1 && !hasWordAfter, - _ => false + _ => false, }; } @@ -287,7 +287,7 @@ private static int SpokenNumberToInt(string number) "seven" => 7, "eight" => 8, "nine" => 9, - _ => 0 + _ => 0, }; } diff --git a/src/TypeWhisper.Core/Services/IdeFileReferenceService.cs b/src/TypeWhisper.Core/Services/IdeFileReferenceService.cs index b079c5b6e..b52db1607 100644 --- a/src/TypeWhisper.Core/Services/IdeFileReferenceService.cs +++ b/src/TypeWhisper.Core/Services/IdeFileReferenceService.cs @@ -15,7 +15,7 @@ public sealed partial class IdeFileReferenceService "tag ", "file tag ", "file reference ", - "reference " + "reference ", ]; private static readonly string[] s_plainReferencePrefixes = ["file ", "open file "]; @@ -39,7 +39,7 @@ public sealed partial class IdeFileReferenceService ["json"] = "json", ["yaml"] = "yaml", ["yml"] = "yml", - ["env"] = "env" + ["env"] = "env", }; public static string ToFileReference(string spokenText) diff --git a/src/TypeWhisper.Core/Services/LocalModelStorageService.cs b/src/TypeWhisper.Core/Services/LocalModelStorageService.cs index f59a69936..7df415a70 100644 --- a/src/TypeWhisper.Core/Services/LocalModelStorageService.cs +++ b/src/TypeWhisper.Core/Services/LocalModelStorageService.cs @@ -26,8 +26,8 @@ public sealed class LocalModelStorageService "hf-cache", ".setup-complete", "python-embed.zip", - "get-pip.py" - ] + "get-pip.py", + ], }; private readonly ISettingsService _settings; diff --git a/src/TypeWhisper.Linux/Services/ActiveWindowService.cs b/src/TypeWhisper.Linux/Services/ActiveWindowService.cs index 86b709d3c..eec87c2a6 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindowService.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindowService.cs @@ -38,7 +38,7 @@ public sealed class ActiveWindowService : IActiveWindowService "waterfox", "zen", "zen-browser", - "zen-bin" + "zen-bin", }; private static readonly string[] s_browserAppNameHints = @@ -54,7 +54,7 @@ public sealed class ActiveWindowService : IActiveWindowService "firefox", "waterfox", "zen browser", - "zen" + "zen", ]; private readonly AtSpiUrlExtractor _atSpiUrlExtractor; @@ -521,7 +521,7 @@ private static bool CheckCommandAvailable(string command, string args) using var p = Process.Start( new ProcessStartInfo(command, args) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, } ); p?.WaitForExit(1000); @@ -548,7 +548,7 @@ private static int RunProcess(string fileName, string args, out string? output) using var p = Process.Start( new ProcessStartInfo(fileName, args) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, } ); if (p is null) @@ -594,7 +594,7 @@ private static int RunProcessWithInput(string fileName, string args, string inpu RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, - UseShellExecute = false + UseShellExecute = false, } ); if (p is null) diff --git a/src/TypeWhisper.Linux/Services/ApiDiscoveryFile.cs b/src/TypeWhisper.Linux/Services/ApiDiscoveryFile.cs index 2d20fa4da..45ea872f9 100644 --- a/src/TypeWhisper.Linux/Services/ApiDiscoveryFile.cs +++ b/src/TypeWhisper.Linux/Services/ApiDiscoveryFile.cs @@ -80,7 +80,7 @@ public void Write(int port, string token) // UnixCreateMode is Linux/macOS-only — guard to avoid PNSE on Windows. var options = new FileStreamOptions { - Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None + Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None, }; if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) diff --git a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs index 0cf10c2c1..6cf68e245 100644 --- a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs +++ b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs @@ -642,7 +642,7 @@ state with StatusText = Localization.Loc.Instance["Dictation.StatusRecording"], ActiveProfileName = null, ActiveAppName = null, - SessionStartedAtUtc = DateTime.UtcNow + SessionStartedAtUtc = DateTime.UtcNow, } ); @@ -796,7 +796,7 @@ state with "kde" => "kwin", "hyprland" => "hyprland", "sway" => "sway", - _ => "xdotool" + _ => "xdotool", }, "No active-window provider returned a snapshot" ); @@ -1123,7 +1123,7 @@ Action showFeedback nameof(discardReason), discardReason, "Only discard outcomes can be reported." - ) + ), }; var message = Localization.Loc.Instance[messageKey]; @@ -1291,7 +1291,7 @@ state with IsRecording = false, StatusText = Localization.Loc.Instance["Overlay.Canceled"], PartialText = null, - SessionStartedAtUtc = null + SessionStartedAtUtc = null, } ); StatusMessage?.Invoke(this, "Canceled"); @@ -1300,7 +1300,7 @@ state with { DurationSeconds = LinuxDictationShortSpeechPolicy.ComputeDurationSeconds( wav - ) + ), } ); _ = await TeardownStreamingSessionAsync( @@ -1322,7 +1322,7 @@ state with FeedbackIsError = false, IsRecording = false, StatusText = Localization.Loc.Instance["Overlay.Processing"], - SessionStartedAtUtc = null + SessionStartedAtUtc = null, } ); var duration = LinuxDictationShortSpeechPolicy.ComputeDurationSeconds(wav); @@ -1393,7 +1393,7 @@ await TeardownStreamingSessionAsync( recordingContext = recordingContext with { StreamingFinalText = streamingFinalText, - StreamingFaulted = streamingFaulted + StreamingFaulted = streamingFaulted, }; } @@ -1816,7 +1816,7 @@ context.StreamingProviderId is not null _models.PluginManager.EventBus.Publish( new TranscriptionFailedEvent { - ErrorMessage = ex.Message, ModelId = engineModelId, AppName = context.AppTitle + ErrorMessage = ex.Message, ModelId = engineModelId, AppName = context.AppTitle, } ); ReportStatus(context, $"Transcription failed: {ex.Message}"); @@ -1955,7 +1955,7 @@ out var spokenCommand ActiveAppName = context.AppTitle, ActiveAppProcessName = context.AppProcess, ProfileName = context.Profile?.Name, - AudioDurationSeconds = duration + AudioDurationSeconds = duration, }; var promptAction = ResolvePromptAction(context); @@ -2049,7 +2049,7 @@ out var spokenCommand status == "AI" ? "Processing prompt action…" : $"Processing {status}…" ); return Task.CompletedTask; - } + }, }, cancelToken ); @@ -2086,7 +2086,7 @@ out var spokenCommand ProfileName = context.Profile?.Name, AppName = context.AppTitle, AppProcessName = context.AppProcess, - Url = context.AppUrl + Url = context.AppUrl, } ); PublishSessionResult( @@ -2243,7 +2243,7 @@ await _insertionOrder.WaitForTurnAsync(context.SessionId, cancelToken) "Text insertion failed. Dictated text could not be copied or pasted.", InsertionResult.NoText when commandResult.CancelInsertion => "Dictation canceled.", - _ => "Done." + _ => "Done.", }; var isError = insertion @@ -2362,7 +2362,7 @@ or InsertionResult.CopiedToClipboard _models.PluginManager.EventBus.Publish( new TranscriptionFailedEvent { - ErrorMessage = ex.Message, ModelId = engineModelId, AppName = context.AppTitle + ErrorMessage = ex.Message, ModelId = engineModelId, AppName = context.AppTitle, } ); ReportStatus(context, $"Transcription failed: {ex.Message}"); @@ -2413,7 +2413,7 @@ CancellationToken token _models.PluginManager.EventBus.Publish( new LlmResponseTokenEvent { - AccumulatedText = accumulated, StepName = PostProcessingStepNames.Llm + AccumulatedText = accumulated, StepName = PostProcessingStepNames.Llm, }); }); @@ -2439,7 +2439,7 @@ CancellationToken token AccumulatedText = result, IsFinal = true, Faulted = pump.Faulted, - StepName = PostProcessingStepNames.Llm + StepName = PostProcessingStepNames.Llm, }); return result; @@ -2646,7 +2646,7 @@ state with ShowFeedback = false, FeedbackText = null, LlmResponseText = null, - PartialText = null + PartialText = null, } ); // Re-activate the window the command was issued from before typing the first @@ -2991,7 +2991,7 @@ string result InsertionResult.MissingClipboardTool => ClipboardToolMissingMessage(), InsertionResult.MissingPasteTool => $"Text insertion failed. {_commands.GetSnapshot().PasteToolInstallHint}", - _ => "Text insertion failed. Command result could not be inserted." + _ => "Text insertion failed. Command result could not be inserted.", }; var isError = insertion @@ -3051,7 +3051,7 @@ private PromptAction BuildTransientCommandAction(string id, string systemPrompt) Id = id, Name = "Spoken command", SystemPrompt = systemPrompt, - ProviderOverride = _settings.Current.SpokenCommandLlmProvider + ProviderOverride = _settings.Current.SpokenCommandLlmProvider, }; } @@ -3148,7 +3148,7 @@ private string ClipboardFallbackMessage() $"Copied to clipboard. {_commands.GetSnapshot().PasteToolInstallHint}", InsertionFailureReason.FocusFailed => "Copied to clipboard. Target window could not be focused for auto-paste — paste with Ctrl+V.", - _ => "Copied to clipboard (paste with Ctrl+V)." + _ => "Copied to clipboard (paste with Ctrl+V).", }; } @@ -3200,7 +3200,7 @@ CancellationToken cancelToken ActionId = actionPlugin.ActionId, Success = result.Success, Message = result.Message, - AppName = context.AppTitle + AppName = context.AppTitle, } ); @@ -3339,7 +3339,7 @@ private TranscriptionRecord BuildHistoryRecord( ProfileName = context.Profile?.Name, EngineUsed = engine, ModelUsed = modelUsed, - AudioFileName = Path.GetFileName(wavPath) + AudioFileName = Path.GetFileName(wavPath), }; } @@ -3384,7 +3384,7 @@ TextInsertionStatus insertionStatus CleanupLevelUsed = CleanupLevel.None, PromptActionApplied = true, IsSpokenCommand = true, - LlmCalls = context.Capture?.Calls ?? [] + LlmCalls = context.Capture?.Calls ?? [], } ); } @@ -3449,7 +3449,7 @@ private void AddHistoryRecord( pipelineResult, PostProcessingStepNames.Translation ), - LlmCalls = context.Capture?.Calls ?? [] + LlmCalls = context.Capture?.Calls ?? [], } ); } @@ -3473,7 +3473,7 @@ private static TextInsertionStatus ToTextInsertionStatus(InsertionResult inserti InsertionResult.MissingClipboardTool => TextInsertionStatus.MissingClipboardTool, InsertionResult.MissingPasteTool => TextInsertionStatus.MissingPasteTool, InsertionResult.Failed => TextInsertionStatus.Failed, - _ => TextInsertionStatus.Unknown + _ => TextInsertionStatus.Unknown, }; } @@ -3501,7 +3501,7 @@ private static bool WasPipelineStepSucceeded(PostProcessingResult result, string InsertionResult.MissingClipboardTool => ClipboardToolMissingMessage(), InsertionResult.MissingPasteTool => "Automatic paste tool is unavailable.", InsertionResult.Failed => "Text insertion failed.", - _ => null + _ => null, }; } @@ -3574,7 +3574,7 @@ state with IsRecording = false, ActiveProfileName = null, ActiveAppName = null, - SessionStartedAtUtc = null + SessionStartedAtUtc = null, } ); @@ -3695,7 +3695,7 @@ state with StatusText = Localization.Loc.Instance["Overlay.Ready"], ActiveProfileName = null, ActiveAppName = null, - SessionStartedAtUtc = null + SessionStartedAtUtc = null, } ); } @@ -4200,7 +4200,7 @@ out var partialText ElapsedSeconds = _recordingStart == default ? 0 - : Math.Max(0, (DateTime.UtcNow - _recordingStart).TotalSeconds) + : Math.Max(0, (DateTime.UtcNow - _recordingStart).TotalSeconds), } ); diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevGlobalShortcutBackend.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevGlobalShortcutBackend.cs index 6d9a08ba4..b03d30973 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevGlobalShortcutBackend.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevGlobalShortcutBackend.cs @@ -353,7 +353,7 @@ private void StartHotPlugWatcher_NoLock() { _watcher = new FileSystemWatcher(InputDir, "event*") { - NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime + NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, }; _watcher.Created += OnDeviceCreated; _watcher.Deleted += OnDeviceDeleted; diff --git a/src/TypeWhisper.Linux/Services/HttpApiService.cs b/src/TypeWhisper.Linux/Services/HttpApiService.cs index a2e779082..54b5b220b 100644 --- a/src/TypeWhisper.Linux/Services/HttpApiService.cs +++ b/src/TypeWhisper.Linux/Services/HttpApiService.cs @@ -71,7 +71,7 @@ public sealed class HttpApiService : IDisposable { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true, - WriteIndented = false + WriteIndented = false, }; private readonly AudioFileService _audioFiles; @@ -232,7 +232,7 @@ AppSettings settings activeBackend = FormatAccelerationBackend(status.ActiveBackend), displayText = status.DisplayText, detail = status.Detail, - requiresRestart = status.RequiresRestart + requiresRestart = status.RequiresRestart, }; } @@ -418,7 +418,7 @@ await HandleDeleteDictionaryTermAsync(request, ct), await HandlePutDictionaryCorrectionAsync(request, ct), ("/v1/dictionary/corrections", "DELETE") => await HandleDeleteDictionaryCorrectionAsync(request, ct), - _ => (404, Serialize(new { error = "Not found" })) + _ => (404, Serialize(new { error = "Not found" })), }; await WriteJsonAsync(response, statusCode, body, allowedOrigin, ct); @@ -473,7 +473,7 @@ _models.ActiveModelId is { } activeModelId apiVersion = "1.0", supportsStreaming = plugin?.SupportsStreaming ?? false, supportsTranslation = plugin?.SupportsTranslation ?? false, - acceleration = BuildAccelerationDto(plugin, _settings.Current) + acceleration = BuildAccelerationDto(plugin, _settings.Current), } ) ); @@ -484,7 +484,7 @@ private static string FormatAccelerationBackend(TranscriptionAccelerationBackend return backend switch { TranscriptionAccelerationBackend.NvidiaCuda => "nvidia-cuda", - _ => "cpu" + _ => "cpu", }; } @@ -507,7 +507,7 @@ private static string FormatAccelerationBackend(TranscriptionAccelerationBackend active = _models.ActiveModelId == id, status = _models.IsDownloaded(id) ? "ready" : engine.SupportsModelDownload ? "not_downloaded" - : "not_configured" + : "not_configured", }; }) ); @@ -724,7 +724,7 @@ CancellationToken ct VocabularyBooster = settings.VocabularyBoostingEnabled ? _vocabularyBoosting.Apply : null, - DictionaryCorrector = _dictionary.ApplyCorrections + DictionaryCorrector = _dictionary.ApplyCorrections, }, ct ); @@ -766,8 +766,8 @@ CancellationToken ct model = selectedModelId, segments = result.Segments.Select(segment => new { - text = segment.Text, start = segment.Start, end = segment.End - }) + text = segment.Text, start = segment.Start, end = segment.End, + }), } ) ); @@ -783,7 +783,7 @@ CancellationToken ct duration = result.DurationSeconds, noSpeechProbability = result.NoSpeechProbability, engine = engineProviderId, - model = selectedModelId + model = selectedModelId, } ) ); @@ -816,7 +816,7 @@ CancellationToken ct engine = record.EngineUsed, model = record.ModelUsed, profile = record.ProfileName, - words = record.WordCount + words = record.WordCount, }); return ( @@ -853,7 +853,7 @@ CancellationToken ct translationTarget = profile.TranslationTarget, selectedTask = profile.SelectedTask, modelOverride = profile.TranscriptionModelOverride, - promptActionId = profile.PromptActionId + promptActionId = profile.PromptActionId, }); return (200, Serialize(new { profiles })); @@ -929,7 +929,7 @@ CancellationToken ct durationSeconds = stored.DurationSeconds, engine = stored.EngineUsed, model = stored.ModelUsed, - message = stored.Message + message = stored.Message, } ) ); @@ -963,7 +963,7 @@ CancellationToken ct { state = _dictation.IsRecording ? "recording" : "idle", isRecording = _dictation.IsRecording, - activeModel = _models.ActiveModelId + activeModel = _models.ActiveModelId, } ) ); @@ -1061,9 +1061,9 @@ CancellationToken ct { corrections = corrections.Select(c => new { - original = c.Original, replacement = c.Replacement, caseSensitive = c.CaseSensitive + original = c.Original, replacement = c.Replacement, caseSensitive = c.CaseSensitive, }), - count = corrections.Count + count = corrections.Count, } ) ); @@ -1121,9 +1121,9 @@ CancellationToken ct { corrections = corrections.Select(c => new { - original = c.Original, replacement = c.Replacement, caseSensitive = c.CaseSensitive + original = c.Original, replacement = c.Replacement, caseSensitive = c.CaseSensitive, }), - count = corrections.Count + count = corrections.Count, } ) ); @@ -1172,9 +1172,9 @@ CancellationToken ct deleted, corrections = corrections.Select(c => new { - original = c.Original, replacement = c.Replacement, caseSensitive = c.CaseSensitive + original = c.Original, replacement = c.Replacement, caseSensitive = c.CaseSensitive, }), - count = corrections.Count + count = corrections.Count, } ) ); @@ -1247,7 +1247,7 @@ CancellationToken ct $"Ambiguous model '{requestedModel}': provided by multiple engines. " + "Specify the engine explicitly or use the full plugin-qualified model id." ), - _ => ModelManagerService.GetPluginModelId(matches[0].GetTranscriptionSelectionId(), requestedModel) + _ => ModelManagerService.GetPluginModelId(matches[0].GetTranscriptionSelectionId(), requestedModel), }; } diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs index 43656cca5..bca4cc85d 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs @@ -13,7 +13,7 @@ internal enum ControlSocketCleanupResult Removed, Live, Indeterminate, - OwnershipContended + OwnershipContended, } /// diff --git a/src/TypeWhisper.Linux/Services/Localization/StrExtension.cs b/src/TypeWhisper.Linux/Services/Localization/StrExtension.cs index 49e1c10f2..13c11adba 100644 --- a/src/TypeWhisper.Linux/Services/Localization/StrExtension.cs +++ b/src/TypeWhisper.Linux/Services/Localization/StrExtension.cs @@ -33,7 +33,7 @@ public override object ProvideValue(IServiceProvider serviceProvider) Source = Loc.Instance, Mode = BindingMode.OneWay, Converter = LocKeyConverter.Instance, - ConverterParameter = Key + ConverterParameter = Key, }; } } diff --git a/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs b/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs index 299c1d45f..c8b64e796 100644 --- a/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs +++ b/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs @@ -148,7 +148,7 @@ private string StatusTextFor(InsertionResult result) InsertionResult.MissingClipboardTool => ClipboardToolMissingMessage(), InsertionResult.MissingPasteTool => _commands.GetSnapshot().PasteToolInstallHint, InsertionResult.Failed => "Text insertion failed.", - _ => "Done." + _ => "Done.", }; } diff --git a/src/TypeWhisper.Linux/Services/Setup/PackageInstaller.cs b/src/TypeWhisper.Linux/Services/Setup/PackageInstaller.cs index 145706af7..037bfdf50 100644 --- a/src/TypeWhisper.Linux/Services/Setup/PackageInstaller.cs +++ b/src/TypeWhisper.Linux/Services/Setup/PackageInstaller.cs @@ -29,7 +29,7 @@ public sealed class PackageInstaller [ new("dnf", "dnf", ["install", "-y"]), new("apt", "apt-get", ["install", "-y"]), new("pacman", "pacman", ["-S", "--noconfirm"]), - new("zypper", "zypper", ["--non-interactive", "install"]) + new("zypper", "zypper", ["--non-interactive", "install"]), ]; private readonly IProcessRunner _runner; @@ -189,7 +189,7 @@ private static IEnumerable ReadOsReleaseManagerHints() "debian" or "ubuntu" or "linuxmint" or "pop" or "raspbian" => "apt", "arch" or "manjaro" or "endeavouros" or "garuda" or "cachyos" => "pacman", "opensuse" or "opensuse-leap" or "opensuse-tumbleweed" or "sles" or "suse" => "zypper", - _ => null + _ => null, }; } diff --git a/src/TypeWhisper.Linux/Services/TransformSelectionService.cs b/src/TypeWhisper.Linux/Services/TransformSelectionService.cs index b51d9410d..fc72f4433 100644 --- a/src/TypeWhisper.Linux/Services/TransformSelectionService.cs +++ b/src/TypeWhisper.Linux/Services/TransformSelectionService.cs @@ -207,7 +207,7 @@ state with StatusText = Localization.Loc.Instance["Overlay.TransformPrompt"], PartialText = selectedText, ActiveAppName = string.IsNullOrWhiteSpace(processName) ? windowTitle : processName, - SessionStartedAtUtc = DateTime.UtcNow + SessionStartedAtUtc = DateTime.UtcNow, } ); } @@ -233,7 +233,7 @@ state with ActiveAppName = string.IsNullOrWhiteSpace(session.ProcessName) ? session.WindowTitle : session.ProcessName, - SessionStartedAtUtc = null + SessionStartedAtUtc = null, } ); @@ -390,7 +390,7 @@ private async Task AbortReplacementAsync(string transformed) InsertionResult.MissingClipboardTool => ClipboardToolMissingMessage(), _ => "Focus changed while transforming, and the transformed text could not be copied. " - + "The original selection was left alone." + + "The original selection was left alone.", }; await ShowWarningAsync(message); } @@ -415,7 +415,7 @@ state with ShowFeedback = false, FeedbackText = null, IsRecording = false, - SessionStartedAtUtc = null + SessionStartedAtUtc = null, } ); } @@ -428,7 +428,7 @@ private void ShowFeedback(string message, bool isError) ShowFeedback = true, FeedbackIsError = isError, FeedbackText = message, - StatusText = message + StatusText = message, }); } diff --git a/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml.cs index 818225c60..722b30a62 100644 --- a/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml.cs @@ -101,8 +101,8 @@ private async void OnExport(object? sender, RoutedEventArgs e) new FilePickerFileType("Text") { Patterns = ["*.txt"] }, new FilePickerFileType("CSV") { Patterns = ["*.csv"] }, new FilePickerFileType("Markdown") { Patterns = ["*.md"] }, - new FilePickerFileType("JSON") { Patterns = ["*.json"] } - ] + new FilePickerFileType("JSON") { Patterns = ["*.json"] }, + ], } ); diff --git a/src/TypeWhisper.Linux/Views/Sections/SnippetsSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/SnippetsSection.axaml.cs index 0747e15e9..28eeeba4a 100644 --- a/src/TypeWhisper.Linux/Views/Sections/SnippetsSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/SnippetsSection.axaml.cs @@ -36,7 +36,7 @@ private async void OnExport(object? sender, RoutedEventArgs e) Title = Loc.Instance["Dialog.ExportSnippets"], SuggestedFileName = "typewhisper-snippets.json", DefaultExtension = "json", - FileTypeChoices = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }] + FileTypeChoices = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }], } ); @@ -73,7 +73,7 @@ private async void OnImport(object? sender, RoutedEventArgs e) { Title = Loc.Instance["Dialog.ImportSnippets"], AllowMultiple = false, - FileTypeFilter = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }] + FileTypeFilter = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }], } ); diff --git a/tests/TypeWhisper.PluginSystem.Tests/ClaudePluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/ClaudePluginTests.cs index d7de135a6..70a8a3395 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/ClaudePluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/ClaudePluginTests.cs @@ -49,7 +49,7 @@ public async Task ProcessStreamingAsync_StreamsContentBlockDeltasInOrder() Assert.Equal("https://api.anthropic.com/v1/messages", request.RequestUri?.ToString()); return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream") + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), }; }); @@ -79,7 +79,7 @@ public async Task ProcessStreamingAsync_ToggleOff_YieldsSingleBulkChunk() { Content = new StringContent( """{"content":[{"type":"text","text":"bulk"}]}""", - Encoding.UTF8, "application/json") + Encoding.UTF8, "application/json"), }); var host = new TestPluginHostServices { Secrets = { ["api-key"] = "sk-ant-test" } }; @@ -116,7 +116,7 @@ public async Task ProcessStreamingAsync_ThrowsOnErrorFrameAfterPartialDeltas() ""); var handler = new CapturingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream") + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), }); var host = new TestPluginHostServices { Secrets = { ["api-key"] = "sk-ant-test" } }; @@ -179,7 +179,7 @@ private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/ElevenLabsPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/ElevenLabsPluginTests.cs index 45b4970da..33f076d42 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/ElevenLabsPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/ElevenLabsPluginTests.cs @@ -18,7 +18,7 @@ public class ElevenLabsPluginTests { private static readonly JsonSerializerOptions s_manifestJsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; [Fact] @@ -243,7 +243,7 @@ private static HttpResponseMessage JsonResponse(string json) { return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(json, Encoding.UTF8, "application/json") + Content = new StringContent(json, Encoding.UTF8, "application/json"), }; } @@ -267,7 +267,7 @@ private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/GladiaPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/GladiaPluginTests.cs index 807a017fe..688523c1e 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/GladiaPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/GladiaPluginTests.cs @@ -11,7 +11,7 @@ public class GladiaPluginTests { private static readonly JsonSerializerOptions s_manifestJsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; [Fact] @@ -250,7 +250,7 @@ private sealed class TestHost : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/GoogleCloudSttPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/GoogleCloudSttPluginTests.cs index a0c7f0622..6113a40c2 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/GoogleCloudSttPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/GoogleCloudSttPluginTests.cs @@ -50,7 +50,7 @@ public async Task TranscribeAsync_AcceptsExactSixtySecondsWithExtendedHeader() .ReturnsAsync( new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent("{\"results\":[]}") + Content = new StringContent("{\"results\":[]}"), } ); diff --git a/tests/TypeWhisper.PluginSystem.Tests/GroqPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/GroqPluginTests.cs index cf3b94457..06b1f1b41 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/GroqPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/GroqPluginTests.cs @@ -18,7 +18,7 @@ public class GroqPluginTests { private static readonly JsonSerializerOptions s_manifestJsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; [Fact] @@ -82,7 +82,7 @@ public async Task ActivateAsync_RestoresFetchedModelsAndNormalizesStaleSelectedL new List { new("openai/gpt-oss-120b", "OpenAI"), - new("llama-3.1-8b-instant", "Meta") + new("llama-3.1-8b-instant", "Meta"), } ); host.SetSetting("selectedLlmModel", "whisper-large-v3"); @@ -300,7 +300,7 @@ public async Task ProcessStreamingAsync_StreamsDeltasInOrder_UsingSelectedModel( capturedBody = body; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream") + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), }; } ); @@ -359,7 +359,7 @@ private static HttpResponseMessage JsonResponse(string json) { return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(json, Encoding.UTF8, "application/json") + Content = new StringContent(json, Encoding.UTF8, "application/json"), }; } @@ -383,7 +383,7 @@ private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiCompatiblePluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiCompatiblePluginTests.cs index ea4404f99..b451c97c1 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenAiCompatiblePluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiCompatiblePluginTests.cs @@ -27,7 +27,7 @@ public async Task ProcessStreamingAsync_StreamsDeltas_AgainstOpenAiCompatibleSer capturedBody = body; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream") + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), }; }); @@ -56,7 +56,7 @@ public async Task ProcessStreamingAsync_ToggleOff_YieldsSingleBulkChunk() { Content = new StringContent( """{"choices":[{"message":{"content":"bulk"}}]}""", - Encoding.UTF8, "application/json") + Encoding.UTF8, "application/json"), }); var host = new TestPluginHostServices(); @@ -80,7 +80,7 @@ private static HttpClient ModelsClient() => { Content = new StringContent( """{"data":[{"id":"m1"},{"id":"m2"}]}""", - Encoding.UTF8, "application/json") + Encoding.UTF8, "application/json"), })); private static PluginCollectionItem ProfileItem( @@ -92,7 +92,7 @@ private static PluginCollectionItem ProfileItem( ["baseUrl"] = baseUrl, ["api-key"] = apiKey, ["selectedLlmModel"] = llmModel, - ["__id"] = id + ["__id"] = id, }); [Fact] @@ -186,7 +186,7 @@ public async Task SetItemsAsync_EndpointChange_RefetchesCatalog() : """{"data":[{"id":"x1"}]}"""; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(models, Encoding.UTF8, "application/json") + Content = new StringContent(models, Encoding.UTF8, "application/json"), }; }); using var httpClient = new HttpClient(handler); @@ -216,7 +216,7 @@ public async Task RefreshModelCatalogAsync_UpdatesProfileCatalog() // Reading the reassigned-below modelsJson is the point (see comment above): // each call returns the server's current model list. // ReSharper disable once AccessToModifiedClosure - Content = new StringContent(modelsJson, Encoding.UTF8, "application/json") + Content = new StringContent(modelsJson, Encoding.UTF8, "application/json"), }); using var httpClient = new HttpClient(handler); var sut = new OpenAiCompatiblePlugin(httpClient); @@ -249,12 +249,12 @@ public async Task ProcessStreamingAsync_ThroughProfile_StreamsDeltas() return path.EndsWith("/chat/completions", StringComparison.Ordinal) ? new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream") + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), } : new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( - """{"data":[{"id":"m1"}]}""", Encoding.UTF8, "application/json") + """{"data":[{"id":"m1"}]}""", Encoding.UTF8, "application/json"), }; }); using var httpClient = new HttpClient(handler); @@ -289,7 +289,7 @@ private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs index 70f36a2b9..f453f78ff 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs @@ -558,7 +558,7 @@ public async Task GetSettingDefinitions_ExposesAuthModeKeyModelsVoiceAndForgetTo "streamResponses", "selectedVoice", "ttsInstructions", - "forgetChatGptLogin" + "forgetChatGptLogin", ], keys); } @@ -680,7 +680,7 @@ public async Task SpeakAsync_PostsAudioSpeechRequestAndUsesPlaybackFactory() return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { - Content = new ByteArrayContent([0, 1, 2, 3]) + Content = new ByteArrayContent([0, 1, 2, 3]), }); }); @@ -712,7 +712,7 @@ public async Task SpeakAsync_SkipsNetworkRequestWhenNoPlayerAvailable() requestCount++; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { - Content = new ByteArrayContent([0, 1, 2, 3]) + Content = new ByteArrayContent([0, 1, 2, 3]), }); }); @@ -1048,7 +1048,7 @@ public void RealtimeExtractPcm16Data_HandlesOddSizedChunkBeforeData() 0x80, 0x3e, 0, 0, // sample rate 16000 0, 0x7d, 0, 0, // byte rate 2, 0, // block align - 16, 0 // bits per sample + 16, 0, // bits per sample }; var listData = "INFO"u8.ToArray(); // 4 bytes ("INFO") var oddListPayload = new byte[] { 1, 2, 3 }; // odd size triggers pad @@ -1248,7 +1248,7 @@ public async Task ProcessStreamingAsync_ChatCompletionsModel_StreamsDeltas() capturedBody = body; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream") + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), }); }); @@ -1290,7 +1290,7 @@ public async Task ProcessStreamingAsync_ToggleOff_YieldsSingleBulkChunk() private static HttpResponseMessage JsonResponse(string json) => new(HttpStatusCode.OK) { - Content = new StringContent(json, Encoding.UTF8, "application/json") + Content = new StringContent(json, Encoding.UTF8, "application/json"), }; private sealed class CapturingHandler( @@ -1311,7 +1311,7 @@ private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenRouterPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenRouterPluginTests.cs index 4d94c3982..d894031da 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenRouterPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenRouterPluginTests.cs @@ -70,7 +70,7 @@ public async Task ActivateAsync_RestoresFetchedTranscriptionModelsAndNormalizesS host.SetSetting("fetchedTranscriptionModels", new List { new("z/stt", "Zulu STT", "0.000002", "0"), - new("a/stt", "Alpha STT", "0", "0") + new("a/stt", "Alpha STT", "0", "0"), }); host.SetSetting("selectedTranscriptionModel", "missing/stt"); @@ -159,7 +159,7 @@ public async Task ActivateAsync_RestoresFetchedModelsAndNormalizesStaleSelection host.SetSetting("fetchedModels", new List { new("z/model", "Z Model", "0.000002", "0.000003"), - new("a/model", "A Model", "0", "0") + new("a/model", "A Model", "0", "0"), }); host.SetSetting("selectedLlmModel", "missing/model"); @@ -542,7 +542,7 @@ public async Task ProcessStreamingAsync_StreamsDeltas_OmitsTemperatureForProvide request.RequestUri?.ToString()); return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream") + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), }; }); @@ -575,7 +575,7 @@ public async Task ProcessStreamingAsync_StreamsWithCustomTemperature() { Content = new StringContent( "data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\ndata: [DONE]\n", - Encoding.UTF8, "text/event-stream") + Encoding.UTF8, "text/event-stream"), }; }); @@ -637,7 +637,7 @@ public async Task GetSettingDefinitions_ExposesApiKeyModelsAndTemperatureControl "selectedLlmModel", "llmTemperatureMode", "llmTemperatureValue", - "streamResponses" + "streamResponses", ], keys); } @@ -755,7 +755,7 @@ public async Task ValidateAsync_FetchesCatalogsAndCreditsAndPersistsSelection() ] } """), - _ => new HttpResponseMessage(HttpStatusCode.NotFound) + _ => new HttpResponseMessage(HttpStatusCode.NotFound), }; }); @@ -809,7 +809,7 @@ private static JsonElement LoadManifest() private static HttpResponseMessage JsonResponse(string json) => new(HttpStatusCode.OK) { - Content = new StringContent(json, Encoding.UTF8, "application/json") + Content = new StringContent(json, Encoding.UTF8, "application/json"), }; private sealed class CapturingHandler( @@ -830,7 +830,7 @@ private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/Reson8PluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/Reson8PluginTests.cs index 60f4f9bdb..06e9010b5 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/Reson8PluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/Reson8PluginTests.cs @@ -67,7 +67,7 @@ public async Task ActivateAsync_RestoresApiKeySettingsAndCustomModels() host.SetSetting("customAuthHeader", "X-Api-Key"); host.SetSetting("fetchedCustomModels", new[] { - new Reson8CustomModel("domain-model", "Domain Model", "Support vocabulary", 42) + new Reson8CustomModel("domain-model", "Domain Model", "Support vocabulary", 42), }); var sut = new Reson8Plugin(); @@ -121,7 +121,7 @@ public async Task SetApiKeyAsync_DoesNotConfigurePluginWhenStoreSecretFails() { var host = new TestPluginHostServices { - StoreSecretException = new InvalidOperationException("store failed") + StoreSecretException = new InvalidOperationException("store failed"), }; var sut = new Reson8Plugin(); await sut.ActivateAsync(host); @@ -195,7 +195,7 @@ public async Task ValidateApiKeyAsync_MatchesMacBehaviorAndOnlyTreatsUnauthorize HttpStatusCode.InternalServerError, HttpStatusCode.MethodNotAllowed, HttpStatusCode.Forbidden, - HttpStatusCode.Unauthorized + HttpStatusCode.Unauthorized, ]); var handler = new CapturingHandler((_, _) => JsonResponse("""{ "message": "probe" }""", statuses.Dequeue())); @@ -232,7 +232,7 @@ public async Task TranscribeAsync_PostsPcm16ToPrerecordedEndpointWithLanguageAnd host.SetSetting("selectedModel", "domain-model"); host.SetSetting("fetchedCustomModels", new[] { - new Reson8CustomModel("domain-model", "Domain Model", null, null) + new Reson8CustomModel("domain-model", "Domain Model", null, null), }); using var httpClient = new HttpClient(handler); @@ -287,7 +287,7 @@ public async Task TranscribeAsync_ThrowsActionableMessagesForKnownHttpErrors() HttpStatusCode.NotFound, HttpStatusCode.RequestEntityTooLarge, HttpStatusCode.TooManyRequests, - HttpStatusCode.InternalServerError + HttpStatusCode.InternalServerError, ]); var handler = new CapturingHandler((_, _) => JsonResponse("""{ "code": "ERR", "message": "details" }""", statuses.Dequeue())); @@ -433,7 +433,7 @@ private static HttpResponseMessage JsonResponse( HttpStatusCode statusCode = HttpStatusCode.OK) => new(statusCode) { - Content = new StringContent(json, Encoding.UTF8, "application/json") + Content = new StringContent(json, Encoding.UTF8, "application/json"), }; private sealed class CapturingHandler( @@ -454,7 +454,7 @@ private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/SharedHelperStreamingCohortTests.cs b/tests/TypeWhisper.PluginSystem.Tests/SharedHelperStreamingCohortTests.cs index 4e52280c0..de4da8a3c 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/SharedHelperStreamingCohortTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/SharedHelperStreamingCohortTests.cs @@ -90,7 +90,7 @@ public async Task ProcessStreamingAsync_ThrowsOnErrorFrameAfterPartialDeltas() ""); var handler = new CapturingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream") + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), }); var host = new TestPluginHostServices { Secrets = { ["api-key"] = "test-key" } }; @@ -239,7 +239,7 @@ private static void AssertStreamBody(string? body, string expectedModel) capturedUrl = request.RequestUri?.ToString(); return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream") + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), }; }); @@ -268,7 +268,7 @@ private static async Task AssertToggleOffYieldsBulk( { Content = new StringContent( """{"choices":[{"message":{"content":"bulk"}}]}""", - Encoding.UTF8, "application/json") + Encoding.UTF8, "application/json"), }); var host = new TestPluginHostServices { Secrets = { [secretKey] = "test-key" } }; @@ -294,7 +294,7 @@ private static async Task StreamCerebrasSseAsync(string sse, List chunks { var handler = new CapturingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream") + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), }); var host = new TestPluginHostServices { Secrets = { ["api-key"] = "test-key" } }; @@ -328,7 +328,7 @@ private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs index 64d361297..233986b96 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs @@ -315,7 +315,7 @@ public async Task SetApiKeyAsync_DoesNotConfigurePluginWhenStoreSecretFails() { var host = new TestPluginHostServices { - StoreSecretException = new InvalidOperationException("store failed") + StoreSecretException = new InvalidOperationException("store failed"), }; var sut = new SonioxPlugin(); await sut.ActivateAsync(host); @@ -643,7 +643,7 @@ private static JsonElement LoadManifest() private static HttpResponseMessage JsonResponse(string json, HttpStatusCode statusCode = HttpStatusCode.OK) => new(statusCode) { - Content = new StringContent(json, Encoding.UTF8, "application/json") + Content = new StringContent(json, Encoding.UTF8, "application/json"), }; private sealed class SonioxFlowHandler(Action inspectCreateBody) : HttpMessageHandler @@ -705,7 +705,7 @@ private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/SpeechmaticsPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/SpeechmaticsPluginTests.cs index a5a1f575e..ff78357d1 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/SpeechmaticsPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/SpeechmaticsPluginTests.cs @@ -10,7 +10,7 @@ public class SpeechmaticsPluginTests { private static readonly JsonSerializerOptions s_manifestJsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; [Fact] @@ -216,7 +216,7 @@ private sealed class TestHost : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/SupertonicTtsPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/SupertonicTtsPluginTests.cs index b1fb785fe..85d86cdb2 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/SupertonicTtsPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/SupertonicTtsPluginTests.cs @@ -125,13 +125,13 @@ public async Task AssetManager_DownloadsMissingFilesAtomicallyAndWritesSourceMet calls.Add(request.RequestUri!.ToString()); return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new ByteArrayContent("payload"u8.ToArray()) + Content = new ByteArrayContent("payload"u8.ToArray()), }; }); var files = new[] { new SupertonicAssetFile("onnx/a.onnx", "https://example.test/a.onnx", 1), - new SupertonicAssetFile("voice_styles/M1.json", "https://example.test/M1.json", 1) + new SupertonicAssetFile("voice_styles/M1.json", "https://example.test/M1.json", 1), }; using var httpClient = new HttpClient(handler); var sut = new SupertonicAssetManager(tempDir, httpClient, files, "https://example.test/LICENSE"); @@ -170,7 +170,7 @@ public async Task GetSettingDefinitions_ExposesLicenseVoiceSpeedAndSteps() SupertonicTtsPlugin.LicenseAcceptedSettingName, SupertonicTtsPlugin.SelectedVoiceSettingName, SupertonicTtsPlugin.SpeedSettingName, - SupertonicTtsPlugin.DenoisingStepsSettingName + SupertonicTtsPlugin.DenoisingStepsSettingName, ], keys); @@ -237,7 +237,7 @@ public async Task AssetManager_RejectsTruncatedDownloadAndLeavesNoPartialFile() { var response = new HttpResponseMessage(HttpStatusCode.OK) { - Content = new ByteArrayContent("short"u8.ToArray()) + Content = new ByteArrayContent("short"u8.ToArray()), }; response.Content.Headers.ContentLength = 4096; // server claims more than it sent return response; @@ -379,7 +379,7 @@ private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs index b1ea5e3c9..ee69592eb 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs @@ -634,7 +634,7 @@ private static void WriteParakeetModelFiles(string assetDir) Directory.CreateDirectory(dir); foreach (var f in new[] { - "encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt" + "encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt", }) File.WriteAllText(Path.Join(dir, f), "dummy"); } diff --git a/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs index be3a045fe..ceb63d0ea 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs @@ -171,7 +171,7 @@ public async Task ProcessStreamingAsync_StreamsResponsesApiDeltasInOrder() Assert.Equal("https://api.x.ai/v1/responses", request.RequestUri?.ToString()); return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream") + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), }; }); @@ -249,7 +249,7 @@ public async Task ProcessStreamingAsync_ThrowsOnResponseFailedFrameAfterPartialD ""); var handler = new CapturingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream") + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), }); var host = new TestPluginHostServices { Secrets = { ["api-key"] = "xai-key" } }; @@ -614,7 +614,7 @@ public async Task SpeakAsync_PostsPcmTtsRequestAndUsesPlaybackFactory() return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new ByteArrayContent([0, 1, 2, 3]) + Content = new ByteArrayContent([0, 1, 2, 3]), }; }); @@ -649,7 +649,7 @@ public async Task SpeakAsync_SkipsNetworkRequestWhenNoPlayerAvailable() requestCount++; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new ByteArrayContent([0, 1, 2, 3]) + Content = new ByteArrayContent([0, 1, 2, 3]), }; }); @@ -683,7 +683,7 @@ public async Task GetSettingDefinitions_ExposesApiKeyModelsVoiceAndTtsToggles() "selectedVoice", "customVoiceId", "ttsLowLatency", - "ttsTextNormalization" + "ttsTextNormalization", ], keys); } @@ -721,7 +721,7 @@ public async Task ValidateAsync_FetchesModelsAndVoicesWhenKeyIsValid() "https://api.x.ai/v1/tts/voices" => JsonResponse(""" { "voices": [ { "voice_id": "leo", "name": "Leo" } ] } """), - _ => new HttpResponseMessage(HttpStatusCode.NotFound) + _ => new HttpResponseMessage(HttpStatusCode.NotFound), }); var host = new TestPluginHostServices { Secrets = { ["api-key"] = "xai-key" } }; @@ -752,7 +752,7 @@ private static JsonElement LoadManifest() private static HttpResponseMessage JsonResponse(string json) => new(HttpStatusCode.OK) { - Content = new StringContent(json, Encoding.UTF8, "application/json") + Content = new StringContent(json, Encoding.UTF8, "application/json"), }; private sealed class CapturingHandler( @@ -773,7 +773,7 @@ private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; From 9c591231d964c4ce795d14a4135924e21c7352e0 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 21 Jul 2026 14:07:26 +0000 Subject: [PATCH 132/226] Address QA review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified each finding against current code; applied the still-valid ones and skipped those already handled (build clean, full suite green — one pre-existing flaky ProfileService concurrency test passes on retry). Applied: - ISettingsService.Update is now abstract instead of a default mutate(Current)+ Save fallback, which read and wrote in separate steps and could silently drop a concurrent update despite the atomicity the doc promises. The production impl already locks; the five test fakes get a simple Update. - HasSelectionTargetChanged now treats a window id present on exactly one side as a changed target even when the process names match — identity vanished/appeared (including the current target losing all identity), so the window can't be confirmed. Added the asymmetric-id test cases. - RequestBodyTooLargeException uses a { } body, matching the other exception type. - CliInstallState retains the launcher classification GetState computed (internal) so Install reuses it for the pre-copy foreign-entry check instead of re-reading the launcher file. - The no-recopy storage test stamps a deterministic old mtime so a stray re-copy (which sets "now") can't slip past the unchanged-timestamp assertion. Skipped (already implemented or intentional): the Sway/Hyprland reload warnings, the HttpApiService over-capacity fire-and-forget and zero-length 400, the IsLivePeer doc, ProfileService.UpdateProfile's early return, the ProcessRunner cancellation margin, and the SettingsBackup zip-bomb caps — all already present. The App control-socket resolution is already in a try/catch that deliberately fails closed; disabling IPC to continue would remove the single-instance guard. --- .../Interfaces/ISettingsService.cs | 17 ++++-------- .../Services/CliInstallService.cs | 19 +++++++++++--- .../Services/HttpApiRequestParser.cs | 2 +- .../Services/TransformSelectionService.cs | 26 ++++++++++--------- .../Services/LocalModelStorageServiceTests.cs | 13 +++++++++- .../AudioRecordingServiceTests.cs | 7 +++++ .../RecorderSectionViewModelTests.cs | 7 +++++ ...TargetAppCorrectionLearningServiceTests.cs | 7 +++++ .../TransformSelectionServiceTests.cs | 6 +++++ .../HistoryRetentionCoordinatorTests.cs | 7 +++++ 10 files changed, 81 insertions(+), 30 deletions(-) diff --git a/src/TypeWhisper.Core/Interfaces/ISettingsService.cs b/src/TypeWhisper.Core/Interfaces/ISettingsService.cs index c000bb51a..f1eb27ad7 100644 --- a/src/TypeWhisper.Core/Interfaces/ISettingsService.cs +++ b/src/TypeWhisper.Core/Interfaces/ISettingsService.cs @@ -18,20 +18,13 @@ public interface ISettingsService /// /// Atomically applies to the latest and persists the - /// result. Unlike Save(Current with { ... }), the read of the latest settings and the write happen - /// under the same synchronization, so two concurrent callers mutating disjoint properties cannot lose - /// each other's change. Implementations that add real locking to must apply - /// and persist under that same lock. + /// result. The read of the latest settings and the write must happen under the same synchronization as + /// , so two concurrent callers mutating disjoint properties cannot lose each other's + /// change — which is why this is abstract rather than a default mutate(Current) + Save + /// (that fallback would read and write in separate steps and silently drop a concurrent update). /// - // ReSharper disable once UnusedMemberInSuper.Global -- default interface method is a fallback for other implementers; the sole in-tree implementer overrides it. // ReSharper disable once UnusedMethodReturnValue.Global -- returns the applied settings for caller convenience/chaining; part of the public API contract. - AppSettings Update(Func mutate) - { - ArgumentNullException.ThrowIfNull(mutate); - var updated = mutate(Current); - Save(updated); - return updated; - } + AppSettings Update(Func mutate); event Action? SettingsChanged; } diff --git a/src/TypeWhisper.Linux/Services/CliInstallService.cs b/src/TypeWhisper.Linux/Services/CliInstallService.cs index e8eb09e1d..d003a1c39 100644 --- a/src/TypeWhisper.Linux/Services/CliInstallService.cs +++ b/src/TypeWhisper.Linux/Services/CliInstallService.cs @@ -11,7 +11,13 @@ public sealed record CliInstallState( string LauncherPath, bool LauncherDirectoryInPath, string StatusText -); +) +{ + // The launcher classification GetState already computed, so Install can reuse it for its + // pre-copy foreign-entry check instead of re-reading the launcher file. Internal: an + // implementation detail, not part of the public state contract. + internal CliInstallService.LauncherEntryClassification LauncherEntry { get; init; } +} public sealed class CliInstallService { @@ -67,7 +73,9 @@ public CliInstallState Install() var launcherDirectory = Path.GetDirectoryName(state.LauncherPath) ?? throw new InvalidOperationException("Missing CLI launcher directory."); - var launcherEntry = ClassifyLauncherEntry(state.LauncherPath, state.InstallPath); + // Reuse the classification GetState already computed — nothing has touched the launcher + // between GetState and here, so re-reading it would only repeat the same file probe. + var launcherEntry = state.LauncherEntry; if (launcherEntry == LauncherEntryClassification.Foreign) { return CreateState( @@ -249,7 +257,10 @@ LauncherEntryClassification launcherEntry launcherPath, inPath, status - ); + ) + { + LauncherEntry = launcherEntry, + }; } private static LauncherEntryClassification ClassifyLauncherEntry( @@ -412,7 +423,7 @@ private static void MarkExecutable(string path) } } - private enum LauncherEntryClassification + internal enum LauncherEntryClassification { Absent, Owned, diff --git a/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs b/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs index 5f20e9636..7dd36d524 100644 --- a/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs +++ b/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs @@ -624,5 +624,5 @@ private void TrackBytes(int read) } } - private sealed class RequestBodyTooLargeException : Exception; + private sealed class RequestBodyTooLargeException : Exception { } } diff --git a/src/TypeWhisper.Linux/Services/TransformSelectionService.cs b/src/TypeWhisper.Linux/Services/TransformSelectionService.cs index fc72f4433..f7879f508 100644 --- a/src/TypeWhisper.Linux/Services/TransformSelectionService.cs +++ b/src/TypeWhisper.Linux/Services/TransformSelectionService.cs @@ -114,13 +114,24 @@ internal static bool HasSelectionTargetChanged( { // Window id is the strongest signal (X11 only) — if both sides have one, trust it // even if process-name detection disagrees. - if (!string.IsNullOrEmpty(capturedWindowId) && !string.IsNullOrEmpty(currentWindowId)) + var capturedHasWindowId = !string.IsNullOrEmpty(capturedWindowId); + var currentHasWindowId = !string.IsNullOrEmpty(currentWindowId); + if (capturedHasWindowId && currentHasWindowId) { return !string.Equals(capturedWindowId, currentWindowId, StringComparison.Ordinal); } - // Wayland (and any X11 case missing an id on one side) falls back to process - // identity — the only cross-compositor signal ActiveWindowService exposes. + // A window id on exactly one side means identity appeared or vanished between capture and + // replace — usually the captured window closing, or detection dropping the id (including + // the current target losing all identity). Treat it as changed even when the process names + // agree: we can't confirm it's the same window, so don't replace into an unconfirmable one. + if (capturedHasWindowId != currentHasWindowId) + { + return true; + } + + // Neither side has a window id. Fall back to process identity — the only cross-compositor + // signal ActiveWindowService exposes (Wayland, or an X11 case missing an id on both sides). if (!string.IsNullOrEmpty(capturedProcessName) || !string.IsNullOrEmpty(currentProcessName)) { return !string.Equals( @@ -130,15 +141,6 @@ internal static bool HasSelectionTargetChanged( ); } - // Neither side offered a process name. A window id on exactly one side means identity - // appeared or vanished between capture and replace — usually the captured window - // closing — so treat it as changed rather than replacing into an unconfirmable window. - // ReSharper disable once ConvertIfStatementToReturnStatement -- collapsing to one return would strip the comment explaining the fail-open false branch. - if (!string.IsNullOrEmpty(capturedWindowId) || !string.IsNullOrEmpty(currentWindowId)) - { - return true; - } - // No identity signal on either side — fail open rather than block a replacement we can't validate. return false; } diff --git a/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs index 8b9402f2e..dbe95e147 100644 --- a/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs @@ -143,7 +143,11 @@ public async Task MoveDownloadsAndUsePathAsync_MatchingPreExistingTarget_SkipsRe var alreadyCopied = Path.Join(target, LocalModelStoragePaths.PluginDataFolderName, "com.typewhisper.whisper-cpp", "Models", "ggml-base.bin"); Directory.CreateDirectory(Path.GetDirectoryName(alreadyCopied)!); await File.WriteAllTextAsync(alreadyCopied, content); - var copiedAt = File.GetLastWriteTimeUtc(alreadyCopied); + // Stamp a deterministic old mtime: a stray re-copy sets "now", which is unambiguously + // different from this, whereas the file's just-written time could match within the + // filesystem's timestamp resolution and let a re-copy slip past the assertion. + var copiedAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(alreadyCopied, copiedAt); var settings = new FakeSettingsService(new AppSettings { LocalModelStoragePath = source }); var service = new LocalModelStorageService(settings); @@ -439,6 +443,13 @@ public void Save(AppSettings settings) SettingsChanged?.Invoke(settings); } + public AppSettings Update(Func mutate) + { + var updated = mutate(Current); + Save(updated); + return updated; + } + public event Action? SettingsChanged; } } diff --git a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs index b3d1d9974..bbadafd15 100644 --- a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs @@ -896,6 +896,13 @@ public void Save(AppSettings settings) SettingsChanged?.Invoke(settings); } + public AppSettings Update(Func mutate) + { + var updated = mutate(Current); + Save(updated); + return updated; + } + public event Action? SettingsChanged; } } diff --git a/tests/TypeWhisper.Linux.Tests/RecorderSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/RecorderSectionViewModelTests.cs index 589e0f00c..2c8946727 100644 --- a/tests/TypeWhisper.Linux.Tests/RecorderSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/RecorderSectionViewModelTests.cs @@ -250,6 +250,13 @@ public void Save(AppSettings settings) SettingsChanged?.Invoke(settings); } + public AppSettings Update(Func mutate) + { + var updated = mutate(Current); + Save(updated); + return updated; + } + public event Action? SettingsChanged; } } diff --git a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs index d2abf1736..35849b5db 100644 --- a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs @@ -1593,6 +1593,13 @@ public void Save(AppSettings settings) SettingsChanged?.Invoke(settings); } + public AppSettings Update(Func mutate) + { + var updated = mutate(Current); + Save(updated); + return updated; + } + public event Action? SettingsChanged; } diff --git a/tests/TypeWhisper.Linux.Tests/TransformSelectionServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TransformSelectionServiceTests.cs index a43ca9bff..6a252df12 100644 --- a/tests/TypeWhisper.Linux.Tests/TransformSelectionServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TransformSelectionServiceTests.cs @@ -54,6 +54,12 @@ public async Task CaptureSelectionForTransformAsync_UsesPlainCopyShortcut_ForNon // appeared or vanished, so the target can no longer be confirmed. [InlineData("123", null, null, null, true)] [InlineData(null, null, "123", null, true)] + // Asymmetric window id even when the process names match: identity vanished/appeared on one + // side, so a same-process match still can't confirm it is the same window. + [InlineData("123", "code", null, "code", true)] + [InlineData(null, "code", "123", "code", true)] + // Captured had a window id and the current target lost every identity signal. + [InlineData("123", "code", null, null, true)] public void HasSelectionTargetChanged_ReturnsExpectedResult( string? capturedWindowId, string? capturedProcessName, diff --git a/tests/TypeWhisper.PluginSystem.Tests/HistoryRetentionCoordinatorTests.cs b/tests/TypeWhisper.PluginSystem.Tests/HistoryRetentionCoordinatorTests.cs index 5f4fe3f6c..8c8c5aaa8 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/HistoryRetentionCoordinatorTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/HistoryRetentionCoordinatorTests.cs @@ -184,6 +184,13 @@ public void Save(AppSettings settings) Current = settings; SettingsChanged?.Invoke(settings); } + + public AppSettings Update(Func mutate) + { + var updated = mutate(Current); + Save(updated); + return updated; + } } private sealed class FakeHistoryService : IHistoryService From f80083320a07fe5c8bc9c535531d9a918bfc4610 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 21 Jul 2026 14:50:12 +0000 Subject: [PATCH 133/226] Fix flaky ProfileService concurrency tests under load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BlockingAfterCommitWriter holds the ProfileService lock and blocks until the test's finally (or Dispose) calls ReleaseFirst — which only runs after the orchestration's SpinUntil(s_testGuard) returns. Both the writer's release-wait and that SpinUntil used the same 5 s guard and started at roughly the same moment, so under heavy load (parallel test projects contending for CPU) the two timers raced: SpinUntil drifted toward its full 5 s and the writer's own 5 s wait expired first, throwing "The first committed writer was not released". Give the writer's release-wait its own 60 s backstop, decoupled from the orchestration guard. ReleaseFirst is always called via the finally/Dispose, so this only ever fires for a genuinely wedged test, never in the normal slow-under-load path. Validated: 12/12 green with all cores saturated by CPU burners (the load that previously triggered the failure), plus the full Core suite clean (468). --- .../Services/ProfileServiceTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs index 4d8f6db00..5c74fe18d 100644 --- a/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs @@ -8,6 +8,13 @@ public sealed class ProfileServiceTests : IDisposable { private static readonly TimeSpan s_testGuard = TimeSpan.FromSeconds(5); + // The blocking writer holds the ProfileService lock until the test's finally (or Dispose) + // calls ReleaseFirst — which happens AFTER the orchestration's SpinUntil(s_testGuard) returns. + // Its own release-wait must therefore outlast s_testGuard by a wide margin, or under heavy load + // (parallel test projects) the two 5 s timers race and the writer times out first. This is a + // pure backstop against a genuinely wedged test, not part of the timing contract. + private static readonly TimeSpan s_writerReleaseGuard = TimeSpan.FromSeconds(60); + private readonly string _filePath; private readonly ProfileService _sut; @@ -578,7 +585,7 @@ public void Write(string path, string contents) { AtomicFileWrite.WriteAllText(path, contents); _firstCommitted.TrySetResult(); - if (!_releaseFirst.Wait(s_testGuard)) + if (!_releaseFirst.Wait(s_writerReleaseGuard)) { throw new TimeoutException("The first committed writer was not released."); } From 18efb7b5197e37979743fdc5452d384ba02ede20 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 21 Jul 2026 16:23:45 +0000 Subject: [PATCH 134/226] Fix hotkey reconcile ordering, DE probe errors, and multipart delimiters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified each QA finding against current code before acting, then ran the Codex adversarial + standard review loop and a whole-solution ReSharper pass over the result. Build clean (0 warnings); full suite green (2461). Applied: - ReconcileDynamicHotkeys stamps each invocation with an Interlocked revision and skips the apply if a newer one has started. ActionsChanged (UI thread) and ProfilesChanged (HTTP worker) both snapshot outside reconcileLock, so an earlier reconcile could win the lock last and reinstate the state a newer one had already superseded — the hotkey change silently didn't take effect until the next event. Snapshotting stays outside the lock; reading under it inverts lock order against the service gates. - Sway/Hyprland ReadManagedBlockLinesAsync now lets permission and transient I/O failures propagate instead of mapping them to "not installed". Only the Exists-then-read delete race (FileNotFound/DirectoryNotFound) still reads as absent. RefreshDesktopIntegrationStateAsync already documents that an indeterminate probe must not erase a known stale/current state; the blanket catch was defeating exactly that. Both writers had identical code, so both are fixed. - Sway/Hyprland ReloadAsync check cancellation first, matching the ThrowIfCancellationRequested the method already enforces after RunAsync — the BinaryExists early return was the one path that swallowed it. - The multipart scanner validates delimiters instead of accepting any boundary match: RFC 2046 requires a preceding CRLF and a CRLF or "--" suffix, with SP/HTAB transport padding allowed before the CRLF, and a closing "--" must itself be followed by padding then CRLF or end of body. Invalid matches are skipped and the search continues rather than truncating the part. Without this, audio containing the boundary text truncated the file part and dropped every later form field. Padding is skipped at the consumer too, so it no longer lands in the header block. - Startup paths that cannot establish sole ownership of the control socket share a StartupCancellation.NotifyUnverifiedInstance helper; the message was duplicated verbatim across four sites. Each caller keeps its own exit behaviour. ReSharper (whole solution, HINT severity): 72 -> 0. - Trailing commas in multiline lists, 70 sites (the configured style). Most were pre-existing in files this change does not otherwise touch. - RequestBodyTooLargeException uses a semicolon body, matching the two other body-less exception types (RestoreInterruptionException, LogindAbsentException). This reverses the { } chosen in "Address QA review findings"; that form was the outlier, which is why the inspection flagged it. - Reasoned suppression for UnusedMemberInSuper.Global on ISettingsService.Update: every call site holds a concrete type today, but the interface member is what binds implementations to the atomicity contract documented above it. Skipped: the QA suggestion to treat a crash between the last commit move and the Committed journal write as a completed restore. No commit record means roll back is the correct transactional outcome, rollback there is complete and safe, and it is asserted by two existing tests. Inferring commit from the prepared/ artifacts being gone is not power-loss safe (renames are not fsynced against each other), so it would trade a guaranteed-consistent settings tree for a possibly mixed one. --- .../Interfaces/ISettingsService.cs | 1 + .../Services/SettingsService.cs | 12 +-- src/TypeWhisper.Linux/App.axaml.cs | 16 ++-- src/TypeWhisper.Linux/Program.cs | 17 +--- .../Services/CliInstallService.cs | 10 +-- .../Hotkey/DeSetup/HyprlandShortcutWriter.cs | 8 +- .../Hotkey/DeSetup/SwayShortcutWriter.cs | 8 +- .../Services/HttpApiRequestParser.cs | 78 ++++++++++++++++- .../Services/StartupCancellation.cs | 17 ++++ .../Services/LocalModelStorageServiceTests.cs | 2 +- .../Services/ProfileServiceTests.cs | 34 ++++---- .../AudioRecordingServiceTests.cs | 16 ++-- .../HttpApiRequestParserTests.cs | 85 +++++++++++++++++-- ...TargetAppCorrectionLearningServiceTests.cs | 22 ++--- .../TransformSelectionServiceTests.cs | 4 +- .../HistoryRetentionCoordinatorTests.cs | 18 ++-- 16 files changed, 258 insertions(+), 90 deletions(-) create mode 100644 src/TypeWhisper.Linux/Services/StartupCancellation.cs diff --git a/src/TypeWhisper.Core/Interfaces/ISettingsService.cs b/src/TypeWhisper.Core/Interfaces/ISettingsService.cs index f1eb27ad7..7e1c768b2 100644 --- a/src/TypeWhisper.Core/Interfaces/ISettingsService.cs +++ b/src/TypeWhisper.Core/Interfaces/ISettingsService.cs @@ -24,6 +24,7 @@ public interface ISettingsService /// (that fallback would read and write in separate steps and silently drop a concurrent update). /// // ReSharper disable once UnusedMethodReturnValue.Global -- returns the applied settings for caller convenience/chaining; part of the public API contract. + // ReSharper disable once UnusedMemberInSuper.Global -- callers hold concrete types today, but the interface member is what binds implementations to the atomicity contract above. AppSettings Update(Func mutate); event Action? SettingsChanged; diff --git a/src/TypeWhisper.Core/Services/SettingsService.cs b/src/TypeWhisper.Core/Services/SettingsService.cs index 8dbbda11c..fbd66a728 100644 --- a/src/TypeWhisper.Core/Services/SettingsService.cs +++ b/src/TypeWhisper.Core/Services/SettingsService.cs @@ -14,7 +14,7 @@ public sealed class SettingsService : ISettingsService { private static readonly JsonSerializerOptions s_jsonOptions = new() { - WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase + WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; private readonly Lock _gate = new(); @@ -178,13 +178,13 @@ private static AppSettings ApplyHistoryRetentionMigration(AppSettings settings, HistoryRetentionMinutes = (int)Math.Min( (long)legacyDays.Value * 24 * 60, int.MaxValue - ) + ), }, _ => settings with { HistoryRetentionMode = AppSettings.Default.HistoryRetentionMode, - HistoryRetentionMinutes = AppSettings.Default.HistoryRetentionMinutes - } + HistoryRetentionMinutes = AppSettings.Default.HistoryRetentionMinutes, + }, }; } @@ -216,7 +216,7 @@ private static AppSettings ApplyAccelerationMigration(AppSettings settings, stri { LocalModelAcceleration = AppSettings.NormalizeLocalModelAcceleration( settings.LocalModelAcceleration - ) + ), }; } @@ -228,7 +228,7 @@ private static AppSettings ApplyAccelerationMigration(AppSettings settings, stri { LocalModelAcceleration = AppSettings.NormalizeLocalModelAcceleration( settings.LocalModelAcceleration - ) + ), }; } diff --git a/src/TypeWhisper.Linux/App.axaml.cs b/src/TypeWhisper.Linux/App.axaml.cs index 2d72319f6..3335c9ea4 100644 --- a/src/TypeWhisper.Linux/App.axaml.cs +++ b/src/TypeWhisper.Linux/App.axaml.cs @@ -181,11 +181,8 @@ main.DataContext as MainWindowViewModel catch (Exception ex) { Trace.WriteLine($"[App] Control socket path unavailable: {ex}"); - Console.Error.WriteLine( - "TypeWhisper could not verify that no other instance is running. Startup was canceled." - ); + StartupCancellation.NotifyUnverifiedInstance(); ShuttingDown = true; - LinuxStartupNotification.NotifyComplete(); // Nonzero, matching Program's preflight probe failure: this is a // canceled startup, not the "already running" success path below. _ = ShutdownAndExitAsync(services, desktop, 1); @@ -271,14 +268,23 @@ main.DataContext as MainWindowViewModel // ProfilesChanged/ActionsChanged fire under their service gates, so a read-under-reconcileLock inverts // the lock order and deadlocks. var reconcileLock = new object(); + var reconcileRevision = 0L; void ReconcileDynamicHotkeys() { + var revision = Interlocked.Increment(ref reconcileRevision); var actionsSnapshot = promptActions.Actions; var profilesSnapshot = profileService.Profiles; IReadOnlyList rejections; lock (reconcileLock) { + // A reconcile that started later snapshotted at least as fresh a state, + // so applying this one behind it would reinstate what it superseded. + if (revision != Interlocked.Read(ref reconcileRevision)) + { + return; + } + rejections = hotkey.SetDynamicHotkeys( HotkeyService.ParsePromptActionHotkeys(actionsSnapshot), HotkeyService.ParseProfileHotkeys(profilesSnapshot) @@ -774,7 +780,7 @@ ISettingsService settings settings.Current with { SelectedMicrophoneDevice = resolved.Index, - SelectedMicrophoneDeviceId = resolved.PersistentId + SelectedMicrophoneDeviceId = resolved.PersistentId, } ); } diff --git a/src/TypeWhisper.Linux/Program.cs b/src/TypeWhisper.Linux/Program.cs index 7f9141f55..d628b3bb3 100644 --- a/src/TypeWhisper.Linux/Program.cs +++ b/src/TypeWhisper.Linux/Program.cs @@ -92,10 +92,7 @@ public static int Main(string[] args) if (!string.IsNullOrEmpty(probeError)) { Trace.WriteLine($"[Program] Control socket probe: {probeError}"); - Console.Error.WriteLine( - "TypeWhisper could not verify that no other instance is running. Startup was canceled." - ); - LinuxStartupNotification.NotifyComplete(); + StartupCancellation.NotifyUnverifiedInstance(); return 1; } @@ -109,10 +106,7 @@ public static int Main(string[] args) } else if (File.Exists(socketPath)) { - Console.Error.WriteLine( - "TypeWhisper could not verify that no other instance is running. Startup was canceled." - ); - LinuxStartupNotification.NotifyComplete(); + StartupCancellation.NotifyUnverifiedInstance(); return 1; } else @@ -124,10 +118,7 @@ public static int Main(string[] args) { Trace.WriteLine($"[Program] Control socket probe failed: {ex.Message}"); BootTrace.Stage($"control socket probe threw: {ex.GetType().Name}"); - Console.Error.WriteLine( - "TypeWhisper could not verify that no other instance is running. Startup was canceled." - ); - LinuxStartupNotification.NotifyComplete(); + StartupCancellation.NotifyUnverifiedInstance(); return 1; } @@ -223,7 +214,7 @@ public static AppBuilder BuildAvaloniaApp() // throws a per-frame SynchronizationLockException from GlxContext.RestoreContext.Dispose, // but only after rendering — transparency works and the log noise is filtered by // SuppressGlxRenderExceptionLogSink. EGL is the fallback if GLX init fails. - RenderingMode = [X11RenderingMode.Glx, X11RenderingMode.Egl, X11RenderingMode.Software] + RenderingMode = [X11RenderingMode.Glx, X11RenderingMode.Egl, X11RenderingMode.Software], } ) #if DEBUG diff --git a/src/TypeWhisper.Linux/Services/CliInstallService.cs b/src/TypeWhisper.Linux/Services/CliInstallService.cs index d003a1c39..042ab134d 100644 --- a/src/TypeWhisper.Linux/Services/CliInstallService.cs +++ b/src/TypeWhisper.Linux/Services/CliInstallService.cs @@ -128,7 +128,7 @@ public static IReadOnlyList BuildCliExamples(int port) $"typewhisper status --port {port}", $"typewhisper models --port {port}", $"typewhisper transcribe recording.wav --port {port}", - $"typewhisper transcribe recording.wav --language de --json --port {port}" + $"typewhisper transcribe recording.wav --language de --json --port {port}", ]; } @@ -141,7 +141,7 @@ public static IReadOnlyList BuildCurlExamples(int port) $"curl -H \"Authorization: Bearer $TYPEWHISPER_API_TOKEN\" http://localhost:{port}/v1/models", $"curl -H \"Authorization: Bearer $TYPEWHISPER_API_TOKEN\" -X POST http://localhost:{port}/v1/transcribe -F \"file=@recording.wav\"", $"curl -H \"Authorization: Bearer $TYPEWHISPER_API_TOKEN\" -X POST http://localhost:{port}/v1/dictation/start", - $"curl -H \"Authorization: Bearer $TYPEWHISPER_API_TOKEN\" -X POST http://localhost:{port}/v1/dictation/stop" + $"curl -H \"Authorization: Bearer $TYPEWHISPER_API_TOKEN\" -X POST http://localhost:{port}/v1/dictation/stop", ]; } @@ -215,7 +215,7 @@ private static string DefaultLauncherDirectory() "Release", "net10.0", CliFileName - ) + ), }; return candidates.Select(Path.GetFullPath).FirstOrDefault(IsCliAppHost); @@ -293,7 +293,7 @@ string installPath new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive, - AttributesToSkip = 0 + AttributesToSkip = 0, } ) .ToArray(); @@ -427,6 +427,6 @@ internal enum LauncherEntryClassification { Absent, Owned, - Foreign + Foreign, } } diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs index 39a2867a3..913f615c4 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs @@ -280,7 +280,7 @@ public static (string mods, string key) ToHyprlandBind(string trigger) "shift" => "SHIFT", "alt" => "ALT", "super" or "win" or "windows" or "cmd" or "meta" => "SUPER", - _ => parts[i].ToUpperInvariant() + _ => parts[i].ToUpperInvariant(), } ); } @@ -331,14 +331,18 @@ CancellationToken ct return SentinelBlock.ExtractBlockLines(existing); } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { + // Raced with a delete between the Exists probe and the read — not installed. return null; } + // Permission and transient I/O failures propagate: callers treat an + // indeterminate probe as "unknown" rather than erasing a known state. } private async Task ReloadAsync(CancellationToken ct) { + ct.ThrowIfCancellationRequested(); if (!DesktopDetector.BinaryExists("hyprctl")) { return false; diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs index 7e19e157b..b4bc3db8d 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs @@ -272,7 +272,7 @@ public static string ToSwayBind(string trigger) "shift" => "Shift", "alt" => "Alt", "super" or "win" or "windows" or "cmd" or "meta" => "Mod4", - _ => parts[i] + _ => parts[i], }; if (sb.Length > 0) { @@ -367,10 +367,13 @@ CancellationToken ct return SentinelBlock.ExtractBlockLines(existing); } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { + // Raced with a delete between the Exists probe and the read — not installed. return null; } + // Permission and transient I/O failures propagate: callers treat an + // indeterminate probe as "unknown" rather than erasing a known state. } private static string ResolveConfigPath() @@ -383,6 +386,7 @@ private static string ResolveConfigPath() private async Task ReloadAsync(CancellationToken ct) { + ct.ThrowIfCancellationRequested(); if (!DesktopDetector.BinaryExists("swaymsg")) { return false; diff --git a/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs b/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs index 7dd36d524..7b0463081 100644 --- a/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs +++ b/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs @@ -273,7 +273,7 @@ string boundary while (searchStart < bodySpan.Length) { - var boundaryStart = IndexOf(bodySpan, boundaryBytes, searchStart); + var boundaryStart = IndexOfDelimiter(bodySpan, boundaryBytes, searchStart); if (boundaryStart < 0) { break; @@ -289,7 +289,8 @@ string boundary break; } - var partHeaderStart = afterBoundary; + // Already validated as part of the delimiter; skipping it keeps the header block clean. + var partHeaderStart = SkipTransportPadding(bodySpan, afterBoundary); if ( partHeaderStart + 1 < bodySpan.Length && bodySpan[partHeaderStart] == (byte)'\r' @@ -306,7 +307,7 @@ string boundary } var partBodyStart = headerEnd + doubleCrlf.Length; - var nextBoundary = IndexOf(bodySpan, boundaryBytes, partBodyStart); + var nextBoundary = IndexOfDelimiter(bodySpan, boundaryBytes, partBodyStart); if (nextBoundary < 0) { break; @@ -529,6 +530,75 @@ private static TranscriptionTask ParseTask(string? value) return lower.Contains("webm") ? "webm" : null; } + /// + /// Finds the next real delimiter, skipping boundary-looking bytes inside a part body. + /// RFC 2046 requires a preceding CRLF and a CRLF or "--" suffix; without that check a + /// binary payload containing the boundary text truncates the part it belongs to. + /// + private static int IndexOfDelimiter( + ReadOnlySpan body, + ReadOnlySpan boundaryBytes, + int startIndex + ) + { + var from = startIndex; + while (from < body.Length) + { + var at = IndexOf(body, boundaryBytes, from); + if (at < 0) + { + return -1; + } + + if (IsDelimiterAt(body, boundaryBytes, at)) + { + return at; + } + + from = at + 1; + } + + return -1; + } + + private static bool IsDelimiterAt( + ReadOnlySpan body, + ReadOnlySpan boundaryBytes, + int at + ) + { + // Only the opening delimiter may sit at offset 0; every later one follows the CRLF + // that ends the preceding part. + if (at != 0 && (at < 2 || body[at - 2] != (byte)'\r' || body[at - 1] != (byte)'\n')) + { + return false; + } + + var after = at + boundaryBytes.Length; + if (after + 1 < body.Length && body[after] == (byte)'-' && body[after + 1] == (byte)'-') + { + // Closing delimiter — the epilogue after it still has to start on its own line. + after += 2; + } + + // RFC 2046 allows transport padding (SP/HTAB) between the boundary and its CRLF. + after = SkipTransportPadding(body, after); + return after >= body.Length + || (after + 1 < body.Length + && body[after] == (byte)'\r' + && body[after + 1] == (byte)'\n'); + } + + private static int SkipTransportPadding(ReadOnlySpan body, int index) + { + while (index < body.Length && (body[index] == (byte)' ' || body[index] == (byte)'\t')) + { + index++; + } + + return index; + } + private static int IndexOf( ReadOnlySpan haystack, ReadOnlySpan needle, @@ -624,5 +694,5 @@ private void TrackBytes(int read) } } - private sealed class RequestBodyTooLargeException : Exception { } + private sealed class RequestBodyTooLargeException : Exception; } diff --git a/src/TypeWhisper.Linux/Services/StartupCancellation.cs b/src/TypeWhisper.Linux/Services/StartupCancellation.cs new file mode 100644 index 000000000..21e2f2d10 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/StartupCancellation.cs @@ -0,0 +1,17 @@ +namespace TypeWhisper.Linux.Services; + +/// +/// Shared exit path for startups that cannot establish sole ownership of the control +/// socket. No window ever maps on these paths, so each must also clear the launcher's +/// busy cursor. +/// +internal static class StartupCancellation +{ + internal static void NotifyUnverifiedInstance() + { + Console.Error.WriteLine( + "TypeWhisper could not verify that no other instance is running. Startup was canceled." + ); + LinuxStartupNotification.NotifyComplete(); + } +} diff --git a/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs index dbe95e147..27bf8997a 100644 --- a/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs @@ -172,7 +172,7 @@ public async Task MoveDownloadsAndUsePathAsync_SaveFails_LeavesSourceIntact() // Source is already the active custom path so the general (non-default) branch runs. var settings = new FakeSettingsService(new AppSettings { LocalModelStoragePath = source }) { - ThrowOnSave = new IOException("save failed") + ThrowOnSave = new IOException("save failed"), }; var service = new LocalModelStorageService(settings); diff --git a/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs index 5c74fe18d..5a1b1d049 100644 --- a/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs @@ -39,7 +39,7 @@ public void ToggleProfileEnabled_MissingId_DoesNotWriteOrNotify() { Id = "existing", Name = "Existing", - IsEnabled = false + IsEnabled = false, }; new ProfileService(_filePath).AddProfile(original); var writes = 0; @@ -73,7 +73,7 @@ public async Task ToggleProfileEnabled_ConcurrentSameProfile_AppliesBothInversio { Id = "profile", Name = "Profile", - IsEnabled = false + IsEnabled = false, }; new ProfileService(_filePath).AddProfile(original); using var writer = new BlockingAfterCommitWriter(); @@ -105,7 +105,7 @@ public async Task ToggleProfileEnabled_ConcurrentSameProfile_AppliesBothInversio } }) { - IsBackground = true + IsBackground = true, }; secondThread.Start(); await secondCallerStarted.Task.WaitAsync(s_testGuard); @@ -153,14 +153,14 @@ public async Task ToggleProfileEnabled_ConcurrentDifferentProfiles_PreservesBoth Id = "profile-a", Name = "Profile A", IsEnabled = false, - Priority = 20 + Priority = 20, }; var profileB = new Profile { Id = "profile-b", Name = "Profile B", IsEnabled = false, - Priority = 10 + Priority = 10, }; var seed = new ProfileService(_filePath); seed.AddProfile(profileA); @@ -194,7 +194,7 @@ public async Task ToggleProfileEnabled_ConcurrentDifferentProfiles_PreservesBoth } }) { - IsBackground = true + IsBackground = true, }; secondThread.Start(); await secondCallerStarted.Task.WaitAsync(s_testGuard); @@ -250,7 +250,7 @@ public void PromptActionId_RoundTrips() { Id = Guid.NewGuid().ToString(), Name = "Test Profile", - PromptActionId = "prompt-123" + PromptActionId = "prompt-123", }; _sut.AddProfile(profile); @@ -267,7 +267,7 @@ public void PromptActionId_NullRoundTrips() { Id = Guid.NewGuid().ToString(), Name = "No Prompt", - PromptActionId = null + PromptActionId = null, }; _sut.AddProfile(profile); @@ -284,7 +284,7 @@ public void UpdateProfile_ChangesPromptActionId() { Id = Guid.NewGuid().ToString(), Name = "Test", - PromptActionId = null + PromptActionId = null, }; _sut.AddProfile(profile); @@ -302,7 +302,7 @@ public void HotkeyData_RoundTrips() { Id = Guid.NewGuid().ToString(), Name = "With Hotkey", - HotkeyData = "{\"key\":\"Ctrl+1\"}" + HotkeyData = "{\"key\":\"Ctrl+1\"}", }; _sut.AddProfile(profile); @@ -331,7 +331,7 @@ public void StylePreset_RoundTrips() { Id = Guid.NewGuid().ToString(), Name = "Email", - StylePreset = ProfileStylePreset.FormalEmail + StylePreset = ProfileStylePreset.FormalEmail, }; _sut.AddProfile(profile); @@ -390,7 +390,7 @@ public void HotkeyBehavior_RoundTrips() Id = Guid.NewGuid().ToString(), Name = "Selection", HotkeyData = "Ctrl+Shift+S", - HotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText + HotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText, }; _sut.AddProfile(profile); @@ -428,7 +428,7 @@ public void MatchProfile_ForcedEnabledProfile_ReturnsManualOverride() { Id = "forced", Name = "Forced", - ProcessNames = ["never-matches"] + ProcessNames = ["never-matches"], }; _sut.AddProfile(forced); @@ -452,7 +452,7 @@ public void MatchProfile_ForcedDisabledProfile_FallsThrough() { Id = "forced", Name = "Forced", - IsEnabled = false + IsEnabled = false, }); var result = _sut.MatchProfile(null, null, "forced"); @@ -484,7 +484,7 @@ public void MatchProfile_HotkeyOnlyProfileWithNoMatchers_IsExcludedFromGlobalFal { Id = "hotkey-only", Name = "Hotkey Only", - HotkeyData = "Ctrl+Alt+E" + HotkeyData = "Ctrl+Alt+E", }); var result = _sut.MatchProfile("some-app", null); @@ -502,7 +502,7 @@ public void MatchProfile_HotkeyOnlyProfile_StillForceMatchesByHotkey() { Id = "hotkey-only", Name = "Hotkey Only", - HotkeyData = "Ctrl+Alt+E" + HotkeyData = "Ctrl+Alt+E", }); var result = _sut.MatchProfile("some-app", null, "hotkey-only"); @@ -519,7 +519,7 @@ public void MatchProfile_EmptyMatcherProfileWithoutHotkey_RemainsGlobalFallback( _sut.AddProfile(new Profile { Id = "global", - Name = "Global" + Name = "Global", }); var result = _sut.MatchProfile("some-app", null); diff --git a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs index bbadafd15..e1e6f11b0 100644 --- a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs @@ -373,7 +373,7 @@ public void ApplyConfiguredMicrophone_WhenSavedIdentityIsMissing_UsesDefaultWith IReadOnlyList devices = [ new(staleIndex, "Replacement Mic", 1, false, "Replacement Mic|1"), - new(defaultIndex, "Current Default", 1, true, "Current Default|1") + new(defaultIndex, "Current Default", 1, true, "Current Default|1"), ]; var operations = new List(); using var service = CreateConfiguredDeviceService(devices, defaultIndex, operations); @@ -381,7 +381,7 @@ public void ApplyConfiguredMicrophone_WhenSavedIdentityIsMissing_UsesDefaultWith var originalSettings = AppSettings.Default with { SelectedMicrophoneDevice = staleIndex, - SelectedMicrophoneDeviceId = missingId + SelectedMicrophoneDeviceId = missingId, }; var settings = new FakeSettingsService(originalSettings); @@ -414,7 +414,7 @@ public void ApplyConfiguredMicrophone_WhenStoredIdentityIsAbsent_DoesNotTrustCac IReadOnlyList devices = [ new(staleIndex, "Cached Index Device", 1, false, "Cached Index Device|1"), - new(defaultIndex, "Current Default", 1, true, "Current Default|1") + new(defaultIndex, "Current Default", 1, true, "Current Default|1"), ]; var operations = new List(); using var service = CreateConfiguredDeviceService(devices, defaultIndex, operations); @@ -422,7 +422,7 @@ public void ApplyConfiguredMicrophone_WhenStoredIdentityIsAbsent_DoesNotTrustCac var originalSettings = AppSettings.Default with { SelectedMicrophoneDevice = staleIndex, - SelectedMicrophoneDeviceId = storedDeviceId + SelectedMicrophoneDeviceId = storedDeviceId, }; var settings = new FakeSettingsService(originalSettings); @@ -458,7 +458,7 @@ int expectedSaveCount [ new(4, "Replacement Mic", 1, false, "Replacement Mic|1"), new(intendedIndex, "Wanted Mic", 1, false, intendedId), - new(defaultIndex, "Current Default", 1, true, "Current Default|1") + new(defaultIndex, "Current Default", 1, true, "Current Default|1"), ]; var operations = new List(); using var service = CreateConfiguredDeviceService(devices, defaultIndex, operations); @@ -466,7 +466,7 @@ int expectedSaveCount AppSettings.Default with { SelectedMicrophoneDevice = storedIndex, - SelectedMicrophoneDeviceId = intendedId + SelectedMicrophoneDeviceId = intendedId, } ); @@ -496,7 +496,7 @@ public void ApplyConfiguredMicrophone_WhenStoredIdentityIsAmbiguous_UsesDefaultW [ new(7, "Identical Mic", 1, false, duplicateId), new(staleIndex, "Identical Mic", 1, false, duplicateId), - new(defaultIndex, "Current Default", 1, true, "Current Default|1") + new(defaultIndex, "Current Default", 1, true, "Current Default|1"), ]; var operations = new List(); using var service = CreateConfiguredDeviceService(devices, defaultIndex, operations); @@ -504,7 +504,7 @@ public void ApplyConfiguredMicrophone_WhenStoredIdentityIsAmbiguous_UsesDefaultW var originalSettings = AppSettings.Default with { SelectedMicrophoneDevice = staleIndex, - SelectedMicrophoneDeviceId = duplicateId + SelectedMicrophoneDeviceId = duplicateId, }; var settings = new FakeSettingsService(originalSettings); diff --git a/tests/TypeWhisper.Linux.Tests/HttpApiRequestParserTests.cs b/tests/TypeWhisper.Linux.Tests/HttpApiRequestParserTests.cs index c9a1cd75f..efcf4588d 100644 --- a/tests/TypeWhisper.Linux.Tests/HttpApiRequestParserTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HttpApiRequestParserTests.cs @@ -13,7 +13,7 @@ public class HttpApiRequestParserTests private static readonly JsonSerializerOptions s_jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; [Fact] @@ -39,7 +39,7 @@ public void ParseTranscribe_MultipartAudioSharesRequestBodyBackingArray() new NameValueCollection { ["await_download"] = "1" }, new Dictionary { - ["content-type"] = $"multipart/form-data; boundary={boundary}" + ["content-type"] = $"multipart/form-data; boundary={boundary}", }, body ); @@ -83,7 +83,7 @@ public void ParseTranscribe_ReadsRawBodyHeaders() ["x-response-format"] = "verbose_json", ["x-prompt"] = "Names", ["x-engine"] = "openai", - ["x-model"] = "gpt-4o-transcribe" + ["x-model"] = "gpt-4o-transcribe", }, new byte[] { 9, 8, 7 } ); @@ -101,6 +101,81 @@ public void ParseTranscribe_ReadsRawBodyHeaders() Assert.Equal("gpt-4o-transcribe", parsed.Model); } + [Fact] + public void ParseTranscribe_MultipartPayloadMayContainBoundaryLikeBytes() + { + const string boundary = "Boundary123"; + // Boundary text lacking the CRLF prefix and CRLF/"--" suffix a real delimiter carries. + var audio = new List { 1, 2 }; + audio.AddRange(Encoding.UTF8.GetBytes($"--{boundary}x")); + audio.AddRange("\r\n"u8.ToArray()); + audio.AddRange(Encoding.UTF8.GetBytes($"--{boundary}")); + audio.AddRange(" trailing"u8.ToArray()); + // A closing marker whose epilogue does not start on its own line is not a delimiter. + audio.AddRange("\r\n"u8.ToArray()); + audio.AddRange(Encoding.UTF8.GetBytes($"--{boundary}--junk")); + audio.AddRange([3, 4]); + var payload = audio.ToArray(); + + var body = Multipart( + boundary, + ("file", "audio.wav", "audio/wav", payload), + ("language", null, null, "de"u8.ToArray()) + ); + + var request = new HttpApiRequest( + "POST", + "/v1/transcribe", + new NameValueCollection(), + new Dictionary + { + ["content-type"] = $"multipart/form-data; boundary={boundary}", + }, + body + ); + + var parsed = HttpApiRequestParser.ParseTranscribe(request); + + Assert.Equal(payload, parsed.AudioData.ToArray()); + Assert.Equal("de", parsed.Language); + } + + [Fact] + public void ParseTranscribe_AcceptsMultipartTransportPaddingAfterDelimiters() + { + const string boundary = "Boundary123"; + // RFC 2046 permits SP/HTAB padding between a delimiter and its CRLF. + using var stream = new MemoryStream(); + Write(stream, $"--{boundary} \t\r\n"); + Write( + stream, + "Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n" + ); + Write(stream, "Content-Type: audio/wav\r\n\r\n"); + stream.Write([9, 8, 7]); + Write(stream, $"\r\n--{boundary} \r\n"); + Write(stream, "Content-Disposition: form-data; name=\"language\"\r\n\r\n"); + Write(stream, "de"); + Write(stream, $"\r\n--{boundary}-- \r\n"); + + var request = new HttpApiRequest( + "POST", + "/v1/transcribe", + new NameValueCollection(), + new Dictionary + { + ["content-type"] = $"multipart/form-data; boundary={boundary}", + }, + stream.ToArray() + ); + + var parsed = HttpApiRequestParser.ParseTranscribe(request); + + Assert.Equal([9, 8, 7], parsed.AudioData.ToArray()); + Assert.Equal("wav", parsed.FileExtension); + Assert.Equal("de", parsed.Language); + } + [Fact] public void ParseTranscribe_RejectsLanguageAndHintsTogether() { @@ -118,7 +193,7 @@ public void ParseTranscribe_RejectsLanguageAndHintsTogether() new NameValueCollection(), new Dictionary { - ["content-type"] = $"multipart/form-data; boundary={boundary}" + ["content-type"] = $"multipart/form-data; boundary={boundary}", }, body ); @@ -141,7 +216,7 @@ public void ParseTranscribe_RejectsMultipartWithoutFile() new NameValueCollection(), new Dictionary { - ["content-type"] = $"multipart/form-data; boundary={boundary}" + ["content-type"] = $"multipart/form-data; boundary={boundary}", }, body ); diff --git a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs index 35849b5db..c9f863cb6 100644 --- a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs @@ -70,7 +70,7 @@ public async Task Arm_WithNoFocusEventSeen_BootstrapsFocusAndStillLearns() // of silently skipping (the "first dictation is a dud" bug). var client = new FakeAtSpiEventClient { - CurrentFocusedElement = null, BootstrapResult = s_field + CurrentFocusedElement = null, BootstrapResult = s_field, }; using var service = CreateService(client, enabled: true); @@ -288,7 +288,7 @@ public async Task Arm_FallsBackToRecentSameAppElement_WhenFocusedElementHasNoTex var client = new FakeAtSpiEventClient { CurrentFocusedElement = pane, - TextProvider = e => e.Equals(s_field) ? "I deployed to kubernets today" : null + TextProvider = e => e.Equals(s_field) ? "I deployed to kubernets today" : null, }; client.RecentFocusedElements.AddRange([pane, s_field]); using var service = CreateService(client, enabled: true); @@ -315,7 +315,7 @@ public async Task Arm_IgnoresRecentElementsFromOtherApps() var client = new FakeAtSpiEventClient { CurrentFocusedElement = pane, - TextProvider = e => e.Equals(foreignField) ? "hello world" : null + TextProvider = e => e.Equals(foreignField) ? "hello world" : null, }; client.RecentFocusedElements.AddRange([pane, foreignField]); using var service = CreateService(client, enabled: true); @@ -446,7 +446,7 @@ public async Task Arm_PasswordField_LearnsNothing() { var client = new FakeAtSpiEventClient { - CurrentFocusedElement = s_field, PasswordResult = true + CurrentFocusedElement = s_field, PasswordResult = true, }; using var service = CreateService(client, enabled: true); @@ -475,7 +475,7 @@ public async Task Arm_FocusedPasswordField_DoesNotFallBackToSibling() { CurrentFocusedElement = password, PasswordProvider = e => e.Equals(password), - TextProvider = _ => "hunter2" + TextProvider = _ => "hunter2", }; client.RecentFocusedElements.AddRange([password, sibling]); using var service = CreateService(client, enabled: true); @@ -503,7 +503,7 @@ public async Task Arm_IndeterminateFocusedRole_DoesNotFallBackToSibling() { CurrentFocusedElement = pane, PasswordProvider = e => e.Equals(pane) ? null : false, - TextProvider = e => e.Equals(sibling) ? "hello world" : null + TextProvider = e => e.Equals(sibling) ? "hello world" : null, }; client.RecentFocusedElements.AddRange([pane, sibling]); using var service = CreateService(client, enabled: true); @@ -525,7 +525,7 @@ public async Task Commit_ElementBecomesPasswordDuringTracking_LearnsNothing() var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field, - PasswordProvider = _ => isPassword ? true : (bool?)false + PasswordProvider = _ => isPassword ? true : (bool?)false, }; using var service = CreateService(client, enabled: true); @@ -549,7 +549,7 @@ public async Task Arm_PasswordRoleIndeterminate_FailsClosed_LearnsNothing() // never proceed to read the field text. var client = new FakeAtSpiEventClient { - CurrentFocusedElement = s_field, PasswordResult = null + CurrentFocusedElement = s_field, PasswordResult = null, }; using var service = CreateService(client, enabled: true); @@ -873,7 +873,7 @@ public async Task Arm_DisabledDuringStartup_DoesNotReadTargetText() var startGate = new TaskCompletionSource(); var client = new FakeAtSpiEventClient { - CurrentFocusedElement = s_field, StartGate = startGate + CurrentFocusedElement = s_field, StartGate = startGate, }; var settings = new FakeSettingsService( AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } @@ -901,7 +901,7 @@ public async Task EnableThenDisable_WhileStartInFlight_EndsStoppedAndSubscribedQ var startGate = new TaskCompletionSource(); var client = new FakeAtSpiEventClient { - CurrentFocusedElement = s_field, StartGate = startGate + CurrentFocusedElement = s_field, StartGate = startGate, }; var settings = new FakeSettingsService( AppSettings.Default with { TargetAppCorrectionLearningEnabled = true } @@ -1246,7 +1246,7 @@ public async Task Commit_RaisesCorrectionsLearnedEvent_WithLearnedEntryAndSource var client = new FakeAtSpiEventClient { CurrentFocusedElement = s_field, - ExtentsToReturn = new AtSpiScreenRect(100, 200, 300, 40) + ExtentsToReturn = new AtSpiScreenRect(100, 200, 300, 40), }; using var service = CreateService(client, enabled: true); diff --git a/tests/TypeWhisper.Linux.Tests/TransformSelectionServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TransformSelectionServiceTests.cs index 6a252df12..d842d5f1f 100644 --- a/tests/TypeWhisper.Linux.Tests/TransformSelectionServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TransformSelectionServiceTests.cs @@ -11,7 +11,7 @@ public async Task CaptureSelectionForTransformAsync_UsesTerminalCopyShortcut_For var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - SelectionText = "selected text" + SelectionText = "selected text", }; var textInsertion = new TextInsertionService(platform); @@ -30,7 +30,7 @@ public async Task CaptureSelectionForTransformAsync_UsesPlainCopyShortcut_ForNon var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - SelectionText = "selected text" + SelectionText = "selected text", }; var textInsertion = new TextInsertionService(platform); diff --git a/tests/TypeWhisper.PluginSystem.Tests/HistoryRetentionCoordinatorTests.cs b/tests/TypeWhisper.PluginSystem.Tests/HistoryRetentionCoordinatorTests.cs index 8c8c5aaa8..227308a8f 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/HistoryRetentionCoordinatorTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/HistoryRetentionCoordinatorTests.cs @@ -14,7 +14,7 @@ public void Initialize_DurationModePurgesOnStartup() AppSettings.Default with { HistoryRetentionMode = HistoryRetentionMode.Duration, - HistoryRetentionMinutes = 60 + HistoryRetentionMinutes = 60, } ); @@ -33,7 +33,7 @@ public void SettingsChange_DurationModePurgesImmediately() AppSettings.Default with { HistoryRetentionMode = HistoryRetentionMode.Duration, - HistoryRetentionMinutes = 60 + HistoryRetentionMinutes = 60, } ); @@ -55,7 +55,7 @@ public void RecordsChanged_DurationModePurgesAfterHistoryWrite() AppSettings.Default with { HistoryRetentionMode = HistoryRetentionMode.Duration, - HistoryRetentionMinutes = 60 + HistoryRetentionMinutes = 60, } ); @@ -75,7 +75,7 @@ public void ForeverMode_DoesNotPurgeOnStartupOrEvents() var settings = new FakeSettingsService( AppSettings.Default with { - HistoryRetentionMode = HistoryRetentionMode.Forever + HistoryRetentionMode = HistoryRetentionMode.Forever, } ); @@ -95,7 +95,7 @@ public void UntilAppCloses_DoesNotClearImmediatelyWhenSelected() AppSettings.Default with { HistoryRetentionMode = HistoryRetentionMode.Duration, - HistoryRetentionMinutes = 60 + HistoryRetentionMinutes = 60, } ); @@ -106,7 +106,7 @@ AppSettings.Default with settings.Save( settings.Current with { - HistoryRetentionMode = HistoryRetentionMode.UntilAppCloses + HistoryRetentionMode = HistoryRetentionMode.UntilAppCloses, } ); @@ -121,7 +121,7 @@ public void HandleShutdown_UntilAppCloses_ClearsHistory() var settings = new FakeSettingsService( AppSettings.Default with { - HistoryRetentionMode = HistoryRetentionMode.UntilAppCloses + HistoryRetentionMode = HistoryRetentionMode.UntilAppCloses, } ); @@ -141,7 +141,7 @@ public void Initialize_UntilAppCloses_ClearsHistoryForCrashRecovery() var settings = new FakeSettingsService( AppSettings.Default with { - HistoryRetentionMode = HistoryRetentionMode.UntilAppCloses + HistoryRetentionMode = HistoryRetentionMode.UntilAppCloses, } ); @@ -159,7 +159,7 @@ public void SelfTriggeredRecordsChanged_DoesNotReenterPurge() AppSettings.Default with { HistoryRetentionMode = HistoryRetentionMode.Duration, - HistoryRetentionMinutes = 60 + HistoryRetentionMinutes = 60, } ); From f6c3558319228e6d4fe153792a65bcac5d4f5159 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 21 Jul 2026 16:51:38 +0000 Subject: [PATCH 135/226] Add 134 trailing commas the prior scan target never reported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects the record on 18efb7b, which claimed "ReSharper (whole solution, HINT severity): 72 -> 0". That number came from scanning TypeWhisper_scan.sln, which only analyses part of the tree, so its zero did not mean the solution was clean. Scanning TypeWhisper.slnx instead reports 134 ArrangeTrailingCommaInMultilineLists sites across 24 files. The two result sets are completely disjoint — no file appears in both — so neither solution file is a whole-solution scan on its own. The 70 fixed in 18efb7b and the 134 fixed here are the union of the two views. All 134 are the same mechanical edit as before: a trailing comma on the last element of a multiline object initializer, with-expression, argument list, switch expression, or enum body. .editorconfig sets resharper_csharp_trailing_comma_in_multiline_lists = true and documents the style as deliberate, so these are conformance fixes either way. Verified every changed line is a pure comma insertion: each + line is byte-identical to its - line plus a comma, 134 of 134. Both scan targets now report 0 at HINT severity. That is the union of two partial views agreeing, not proof of full coverage — why the two targets analyse disjoint file sets is still unexplained, so a third uncovered subset cannot be ruled out. Build clean (0 warnings); full suite green (2461). --- .../Services/DictionaryService.Csv.cs | 4 +- .../Services/DictionaryService.cs | 18 ++--- .../Services/ErrorLogService.cs | 6 +- .../Services/ProfileService.cs | 2 +- .../Services/PromptActionService.cs | 4 +- .../Services/SnippetService.cs | 4 +- .../Services/LinuxPreferencesService.cs | 2 +- .../Services/Plugins/PluginHostServices.cs | 6 +- .../Services/TextInsertionService.cs | 22 +++--- .../Services/WatchFolderService.cs | 8 +-- .../ResilientDownloaderTests.cs | 10 +-- .../LinuxPreferencesServiceTests.cs | 20 +++--- .../CudaRuntimeProvisionerTests.cs | 36 +++++----- .../ModelManagerServiceTests.cs | 70 +++++++++---------- .../OpenAiChatHelperTests.cs | 2 +- .../PluginEventBusTests.cs | 2 +- .../PluginEventsTests.cs | 4 +- .../PluginLoaderTests.cs | 4 +- .../PluginManagerTests.cs | 2 +- .../PluginManifestTests.cs | 10 +-- .../PluginRegistryServiceTests.cs | 18 ++--- .../ScriptCollectionSettingsTests.cs | 4 +- .../SmallestAiPluginTests.cs | 6 +- .../WebhookCollectionSettingsTests.cs | 4 +- 24 files changed, 134 insertions(+), 134 deletions(-) diff --git a/src/TypeWhisper.Core/Services/DictionaryService.Csv.cs b/src/TypeWhisper.Core/Services/DictionaryService.Csv.cs index 0276f6b38..6d426500f 100644 --- a/src/TypeWhisper.Core/Services/DictionaryService.Csv.cs +++ b/src/TypeWhisper.Core/Services/DictionaryService.Csv.cs @@ -137,7 +137,7 @@ public int ImportFromCsv(string csv) IsEnabled = row.Count <= 4 || ReadBool(row, 4), IsStarred = ReadBool(row, 5), Priority = ReadInt(row, 6), - Source = ReadSource(row, 7) + Source = ReadSource(row, 7), }; if (entryType == DictionaryEntryType.Correction) @@ -157,7 +157,7 @@ public int ImportFromCsv(string csv) IsEnabled = entry.IsEnabled, IsStarred = entry.IsStarred, Priority = entry.Priority, - Source = entry.Source + Source = entry.Source, }; imported++; continue; diff --git a/src/TypeWhisper.Core/Services/DictionaryService.cs b/src/TypeWhisper.Core/Services/DictionaryService.cs index 1d088877d..ccfee8b8e 100644 --- a/src/TypeWhisper.Core/Services/DictionaryService.cs +++ b/src/TypeWhisper.Core/Services/DictionaryService.cs @@ -269,7 +269,7 @@ public void SetTerms(IEnumerable terms, bool replaceExisting) newCache.Add( new DictionaryEntry { - Id = Guid.NewGuid().ToString(), EntryType = DictionaryEntryType.Term, Original = term + Id = Guid.NewGuid().ToString(), EntryType = DictionaryEntryType.Term, Original = term, } ); } @@ -370,7 +370,7 @@ bool caseSensitive { newCache[idx] = existing with { - Replacement = replacement, CaseSensitive = caseSensitive, IsEnabled = true + Replacement = replacement, CaseSensitive = caseSensitive, IsEnabled = true, }; } } @@ -384,7 +384,7 @@ bool caseSensitive Original = original, Replacement = replacement, CaseSensitive = caseSensitive, - Source = DictionaryEntrySource.Manual + Source = DictionaryEntrySource.Manual, } ); } @@ -459,7 +459,7 @@ public void LearnCorrection(string original, string replacement) Replacement = replacement, UsageCount = existing.UsageCount + 1, TimesCorrected = existing.TimesCorrected + 1, - LastCorrectedAt = DateTime.UtcNow + LastCorrectedAt = DateTime.UtcNow, }; } } @@ -474,7 +474,7 @@ public void LearnCorrection(string original, string replacement) Replacement = replacement, TimesCorrected = 1, LastCorrectedAt = DateTime.UtcNow, - Source = DictionaryEntrySource.CorrectionSuggestion + Source = DictionaryEntrySource.CorrectionSuggestion, } ); } @@ -543,7 +543,7 @@ public IReadOnlyList LearnCorrections( { Replacement = replacement, TimesCorrected = existing.TimesCorrected + 1, - LastCorrectedAt = DateTime.UtcNow + LastCorrectedAt = DateTime.UtcNow, }; newCache[idx] = updated; learned.Add( @@ -561,7 +561,7 @@ public IReadOnlyList LearnCorrections( Replacement = replacement, TimesCorrected = 1, LastCorrectedAt = DateTime.UtcNow, - Source = DictionaryEntrySource.AutoLearned + Source = DictionaryEntrySource.AutoLearned, }; newCache.Add(entry); learned.Add(new LearnedDictionaryCorrection(entry.Id, entry.Original, replacement)); @@ -649,7 +649,7 @@ public void ActivatePack(TermPack pack) .Terms.Where(t => !existingPackIds.Contains($"pack:{pack.Id}:{t}")) .Select(t => new DictionaryEntry { - Id = $"pack:{pack.Id}:{t}", EntryType = DictionaryEntryType.Term, Original = t + Id = $"pack:{pack.Id}:{t}", EntryType = DictionaryEntryType.Term, Original = t, }) .ToList(); @@ -737,7 +737,7 @@ private void IncrementUsageCounts(Dictionary deltas) { UsageCount = newCache[idx].UsageCount + delta, TimesApplied = newCache[idx].TimesApplied + delta, - LastUsedAt = now + LastUsedAt = now, }; changed = true; } diff --git a/src/TypeWhisper.Core/Services/ErrorLogService.cs b/src/TypeWhisper.Core/Services/ErrorLogService.cs index 545508a62..8ada3503e 100644 --- a/src/TypeWhisper.Core/Services/ErrorLogService.cs +++ b/src/TypeWhisper.Core/Services/ErrorLogService.cs @@ -90,13 +90,13 @@ public string ExportDiagnostics() os_version = Environment.OSVersion.VersionString, dotnet_version = Environment.Version.ToString(), locale = CultureInfo.CurrentCulture.Name, - timezone = TimeZoneInfo.Local.Id + timezone = TimeZoneInfo.Local.Id, }, error_count = snapshot.Count, errors = snapshot.Select(e => new { - timestamp = e.Timestamp.ToString("o"), category = e.Category, message = e.Message - }) + timestamp = e.Timestamp.ToString("o"), category = e.Category, message = e.Message, + }), }; return JsonSerializer.Serialize(report, s_jsonOptions); diff --git a/src/TypeWhisper.Core/Services/ProfileService.cs b/src/TypeWhisper.Core/Services/ProfileService.cs index 054b0df36..c5e5a485a 100644 --- a/src/TypeWhisper.Core/Services/ProfileService.cs +++ b/src/TypeWhisper.Core/Services/ProfileService.cs @@ -117,7 +117,7 @@ public void DeleteProfile(string id) var updated = newCache[idx] with { IsEnabled = !newCache[idx].IsEnabled, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, }; newCache[idx] = updated; CommitLocked(newCache); diff --git a/src/TypeWhisper.Core/Services/PromptActionService.cs b/src/TypeWhisper.Core/Services/PromptActionService.cs index 43bc541f7..cd9703f22 100644 --- a/src/TypeWhisper.Core/Services/PromptActionService.cs +++ b/src/TypeWhisper.Core/Services/PromptActionService.cs @@ -154,7 +154,7 @@ public void SeedPresets() "Reply", "\U0001F4AC", "Write a concise, professional reply to the following message. Match the tone of the original. Return only the reply text." - ) + ), }; var next = new List(_cache); @@ -169,7 +169,7 @@ public void SeedPresets() SystemPrompt = prompt, Icon = icon, IsPreset = true, - SortOrder = i + SortOrder = i, } ); } diff --git a/src/TypeWhisper.Core/Services/SnippetService.cs b/src/TypeWhisper.Core/Services/SnippetService.cs index 69f067800..06de208c0 100644 --- a/src/TypeWhisper.Core/Services/SnippetService.cs +++ b/src/TypeWhisper.Core/Services/SnippetService.cs @@ -278,7 +278,7 @@ private static string ExpandPlaceholders(string template, Func? clipboar "time" => now.ToString(format ?? "HH:mm"), "datetime" => now.ToString(format ?? "yyyy-MM-dd HH:mm"), "clipboard" => clipboardProvider?.Invoke() ?? "", - _ => match.Value + _ => match.Value, }; } ); @@ -317,7 +317,7 @@ private void IncrementUsageCounts(Dictionary increments) next[idx] = next[idx] with { UsageCount = next[idx].UsageCount + delta, - LastUsedAt = now + LastUsedAt = now, }; changed = true; } diff --git a/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs b/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs index d14083e7c..4a4b415cd 100644 --- a/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs +++ b/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs @@ -54,7 +54,7 @@ public sealed class LinuxPreferencesService { private static readonly JsonSerializerOptions s_jsonOptions = new() { - WriteIndented = true, PropertyNameCaseInsensitive = true + WriteIndented = true, PropertyNameCaseInsensitive = true, }; private readonly Action _atomicWrite; diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginHostServices.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginHostServices.cs index 41af8b527..ab5184a93 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginHostServices.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginHostServices.cs @@ -21,7 +21,7 @@ public sealed class PluginHostServices : IPluginHostServices private static readonly JsonSerializerOptions s_jsonOptions = new() { - WriteIndented = true, PropertyNameCaseInsensitive = true + WriteIndented = true, PropertyNameCaseInsensitive = true, }; private readonly IActiveWindowService _activeWindow; @@ -148,7 +148,7 @@ public Task StoreSecretAsync(string key, string value) var current = LoadSettings(); var next = new Dictionary(current) { - [$"{SecretPrefix}{key}"] = JsonSerializer.SerializeToElement(encrypted) + [$"{SecretPrefix}{key}"] = JsonSerializer.SerializeToElement(encrypted), }; SaveSettings(next); _settingsCache = next; @@ -227,7 +227,7 @@ public void SetSetting(string key, T value) var current = LoadSettings(); var next = new Dictionary(current) { - [key] = JsonSerializer.SerializeToElement(value, s_jsonOptions) + [key] = JsonSerializer.SerializeToElement(value, s_jsonOptions), }; SaveSettings(next); _settingsCache = next; diff --git a/src/TypeWhisper.Linux/Services/TextInsertionService.cs b/src/TypeWhisper.Linux/Services/TextInsertionService.cs index 9b97b8bd6..2fc7584fb 100644 --- a/src/TypeWhisper.Linux/Services/TextInsertionService.cs +++ b/src/TypeWhisper.Linux/Services/TextInsertionService.cs @@ -16,7 +16,7 @@ public enum InsertionResult MissingClipboardTool, MissingPasteTool, Failed, - ActionUnavailable + ActionUnavailable, } /// @@ -32,7 +32,7 @@ public enum InsertionFailureReason NoWaylandTypingTool, FocusFailed, PasteRetriesExhausted, - PartialTypingFailure + PartialTypingFailure, } public sealed record TextInsertionRequest( @@ -222,7 +222,7 @@ public async Task InsertTextAsync(TextInsertionRequest request) && string.IsNullOrEmpty(targetWindowTitle) && _platform.PrefersDirectTypingForUnknownTarget && IsAsciiSafe(text) - ) + ), }; if (shouldTypeDirectly) @@ -1012,7 +1012,7 @@ internal sealed class LinuxTextInsertionPlatform : ITextInsertionPlatform "STRING", "UTF8_STRING", "TEXT", - "COMPOUND_TEXT" + "COMPOUND_TEXT", ], StringComparer.OrdinalIgnoreCase ); @@ -1296,7 +1296,7 @@ public async Task SendPasteAsync(bool useTerminalShortcut = false) ? YdotoolBackend.TerminalPasteArgs() : YdotoolBackend.PasteArgs() ), - _ => false + _ => false, } ); } @@ -1389,7 +1389,7 @@ private async Task TypeSegmentAsync(InputBackend backend, string segment) null ) == 0, InputBackend.Ydotool => await RunYdotoolAsync(YdotoolBackend.TypeArgs(segment)), - _ => false + _ => false, }; } @@ -1404,7 +1404,7 @@ private async Task SendShiftEnterAsync(InputBackend backend) null ) == 0, InputBackend.Ydotool => await RunYdotoolAsync(YdotoolBackend.ShiftEnterArgs()), - _ => false + _ => false, }; } @@ -1422,7 +1422,7 @@ public async Task SendCopyAsync(bool useTerminalShortcut) InputBackend.Ydotool => await RunYdotoolAsync( useTerminalShortcut ? YdotoolBackend.TerminalCopyArgs() : YdotoolBackend.CopyArgs() ), - _ => false + _ => false, } ); } @@ -1439,7 +1439,7 @@ public async Task SendEnterAsync() null ) == 0, InputBackend.Ydotool => await RunYdotoolAsync(YdotoolBackend.EnterArgs()), - _ => false + _ => false, } ); } @@ -1615,7 +1615,7 @@ private static bool IsCommandAvailable(string command) { var psi = new ProcessStartInfo("xdotool", arguments) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; using var p = Process.Start(psi); if (p is null) @@ -1811,7 +1811,7 @@ private enum InputBackend None, Xdotool, Wtype, - Ydotool + Ydotool, } internal delegate Task ProcessRunnerWithEnv( diff --git a/src/TypeWhisper.Linux/Services/WatchFolderService.cs b/src/TypeWhisper.Linux/Services/WatchFolderService.cs index 335ee8751..3cc25d68d 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderService.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderService.cs @@ -13,7 +13,7 @@ public sealed class WatchFolderService : IDisposable, IAsyncDisposable private static readonly JsonSerializerOptions s_jsonOptions = new() { - WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true + WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, }; private readonly ConcurrentDictionary _activeFiles = new( @@ -190,7 +190,7 @@ private void StartRun( { NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size, - IncludeSubdirectories = false + IncludeSubdirectories = false, }; run = new WatchFolderRun( cancellationSource, @@ -578,7 +578,7 @@ CancellationToken ct ProcessedAtUtc = DateTime.UtcNow, OutputPath = outputPath, Success = true, - ErrorMessage = sourceDeletionError + ErrorMessage = sourceDeletionError, } ); } @@ -612,7 +612,7 @@ CancellationToken ct ProcessedAtUtc = DateTime.UtcNow, OutputPath = "", Success = false, - ErrorMessage = ex.Message + ErrorMessage = ex.Message, } ); } diff --git a/tests/TypeWhisper.Core.Tests/ResilientDownloaderTests.cs b/tests/TypeWhisper.Core.Tests/ResilientDownloaderTests.cs index ad521f96d..33eb4ea3f 100644 --- a/tests/TypeWhisper.Core.Tests/ResilientDownloaderTests.cs +++ b/tests/TypeWhisper.Core.Tests/ResilientDownloaderTests.cs @@ -161,7 +161,7 @@ public async Task MidStreamDrop_RetainsPartial_AndSecondCallResumes() var handler = new ScriptedHandler(body) { - WrapStream = slice => new FaultyStream(slice, k, FaultKind.Drop) + WrapStream = slice => new FaultyStream(slice, k, FaultKind.Drop), }; using var client = new HttpClient(handler); @@ -200,7 +200,7 @@ public async Task IdleStall_ThrowsStalled_AndRetainsPartial() var handler = new ScriptedHandler(body) { - WrapStream = slice => new FaultyStream(slice, k, FaultKind.Stall) + WrapStream = slice => new FaultyStream(slice, k, FaultKind.Stall), }; using var client = new HttpClient(handler); @@ -228,7 +228,7 @@ public async Task UserCancellation_ThrowsCanceled_NotStalled() var handler = new ScriptedHandler(body) { - WrapStream = slice => new FaultyStream(slice, 1000, FaultKind.Stall) + WrapStream = slice => new FaultyStream(slice, 1000, FaultKind.Stall), }; using var client = new HttpClient(handler); using var cts = new CancellationTokenSource(); @@ -259,7 +259,7 @@ public async Task TruncatedBody_ThrowsIncomplete_AndKeepsPartial() // Declares the full slice length but serves only k bytes then clean-EOFs. var handler = new ScriptedHandler(body) { - WrapStream = slice => new FaultyStream(slice, k, FaultKind.Truncate) + WrapStream = slice => new FaultyStream(slice, k, FaultKind.Truncate), }; using var client = new HttpClient(handler); @@ -330,7 +330,7 @@ public async Task NonResumableFailure_DeletesPartial() var handler = new ScriptedHandler(body) { - WrapStream = slice => new FaultyStream(slice, 2000, FaultKind.Drop) + WrapStream = slice => new FaultyStream(slice, 2000, FaultKind.Drop), }; using var client = new HttpClient(handler); diff --git a/tests/TypeWhisper.Linux.Tests/LinuxPreferencesServiceTests.cs b/tests/TypeWhisper.Linux.Tests/LinuxPreferencesServiceTests.cs index 031b8f57b..c65b730e3 100644 --- a/tests/TypeWhisper.Linux.Tests/LinuxPreferencesServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LinuxPreferencesServiceTests.cs @@ -11,7 +11,7 @@ public sealed class LinuxPreferencesServiceTests private static readonly TimeSpan s_testGuard = TimeSpan.FromSeconds(5); private static readonly JsonSerializerOptions s_jsonOptions = new() { - WriteIndented = true, PropertyNameCaseInsensitive = true + WriteIndented = true, PropertyNameCaseInsensitive = true, }; [Fact] @@ -26,7 +26,7 @@ public void Save_TempPathRoundTrip_PublishesCurrentBeforeChangedAndLeavesNoTempF LastUpdateCheckUtc = new DateTime(2026, 7, 18, 12, 34, 56, DateTimeKind.Utc), LastKnownLatestVersion = "1.2.3", LastKnownLatestUrl = "https://example.com/releases/1.2.3", - DismissedUpdateVersion = null + DismissedUpdateVersion = null, }; var service = new LinuxPreferencesService(path); var changedCount = 0; @@ -93,7 +93,7 @@ public async Task Update_ConcurrentDisjointMutations_UseLatestCommittedSnapshot( } }) { - IsBackground = true + IsBackground = true, }; secondThread.Start(); await secondCallerStarted.Task.WaitAsync(s_testGuard); @@ -130,7 +130,7 @@ public async Task Update_ConcurrentDisjointMutations_UseLatestCommittedSnapshot( ); var expected = new LinuxPreferences { - CloseToTray = true, DismissedUpdateVersion = "1.2.3" + CloseToTray = true, DismissedUpdateVersion = "1.2.3", }; Assert.Equal(expected, results[1]); Assert.Equal(expected, service.Current); @@ -149,12 +149,12 @@ public async Task Save_ConcurrentFullSnapshots_AreSerializedAndRemainWhole() { CloseToTray = true, LastKnownLatestVersion = "first", - LastKnownLatestUrl = "https://example.com/first" + LastKnownLatestUrl = "https://example.com/first", }; var second = new LinuxPreferences { CheckForUpdatesOnStartup = false, - DismissedUpdateVersion = "second" + DismissedUpdateVersion = "second", }; var secondCallerStarted = CreateCompletionSource(); var secondCompletion = CreateCompletionSource(); @@ -181,7 +181,7 @@ public async Task Save_ConcurrentFullSnapshots_AreSerializedAndRemainWhole() } }) { - IsBackground = true + IsBackground = true, }; secondThread.Start(); await secondCallerStarted.Task.WaitAsync(s_testGuard); @@ -223,7 +223,7 @@ public void Save_WhenRealAtomicStagingFails_PreservesDiskAndCacheAndThrows() { CloseToTray = true, LastKnownLatestVersion = "old", - LastKnownLatestUrl = "https://example.com/old" + LastKnownLatestUrl = "https://example.com/old", }; using var failurePath = new MaximumFileNameTestPath(Serialize(oldPreferences)); var service = new LinuxPreferencesService(failurePath.FilePath); @@ -232,7 +232,7 @@ public void Save_WhenRealAtomicStagingFails_PreservesDiskAndCacheAndThrows() service.Changed += _ => changedCount++; var replacement = oldPreferences with { - CloseToTray = false, LastKnownLatestVersion = "new" + CloseToTray = false, LastKnownLatestVersion = "new", }; Assert.ThrowsAny(() => service.Save(replacement)); @@ -251,7 +251,7 @@ public void Save_WhenInjectedWriterFails_PreservesDiskAndCacheAndThrowsSameExcep var path = Path.Join(directory.Path, "linux-preferences.json"); var oldPreferences = new LinuxPreferences { - CloseToTray = true, DismissedUpdateVersion = "old" + CloseToTray = true, DismissedUpdateVersion = "old", }; new LinuxPreferencesService(path).Save(oldPreferences); var before = File.ReadAllBytes(path); diff --git a/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs b/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs index c2d16bcba..d2d87a262 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs @@ -37,7 +37,7 @@ public async Task DownloadAndExtract_ColdCache_DownloadsExtractsAndWritesMarkers using var _ = http; var provisioner = new CudaRuntimeProvisioner(temp.Path, http) { - SystemLibraryProbeForTests = _ => false + SystemLibraryProbeForTests = _ => false, }; await provisioner.DownloadAndExtractAsync( @@ -73,7 +73,7 @@ public async Task DownloadAndExtract_WarmCache_IsSatisfied_MakesNoSecondRequest( using var _ = http; var provisioner = new CudaRuntimeProvisioner(temp.Path, http) { - SystemLibraryProbeForTests = _ => false + SystemLibraryProbeForTests = _ => false, }; await provisioner.DownloadAndExtractAsync( @@ -96,7 +96,7 @@ public async Task DownloadAndExtract_MarkerDeleted_ReDownloadsThatWheelOnly() using var _ = http; var provisioner = new CudaRuntimeProvisioner(temp.Path, http) { - SystemLibraryProbeForTests = _ => false + SystemLibraryProbeForTests = _ => false, }; await provisioner.DownloadAndExtractAsync( @@ -123,7 +123,7 @@ public async Task DownloadAndExtract_WhenSystemProvidesLibraries_DownloadsNothin var provisioner = new CudaRuntimeProvisioner(temp.Path, http) { // Every soname resolvable on the "system" → no wheel is missing. - SystemLibraryProbeForTests = _ => true + SystemLibraryProbeForTests = _ => true, }; var progress = new RecordingProgress(); @@ -193,14 +193,14 @@ public async Task DownloadAndExtract_FailsClosed_WhenPyPiOmitsSha256() Wheel(CublasPackage, CublasVersion, [ ("nvidia/cublas/lib/libcublas.so.12", 16), - ("nvidia/cublas/lib/libcublasLt.so.12", 16) - ], nullSha: true) + ("nvidia/cublas/lib/libcublasLt.so.12", 16), + ], nullSha: true), }; var handler = new FakePyPiHandler(fixtures); using var http = new HttpClient(handler); var provisioner = new CudaRuntimeProvisioner(temp.Path, http) { - SystemLibraryProbeForTests = _ => false + SystemLibraryProbeForTests = _ => false, }; await Assert.ThrowsAsync(() => @@ -223,14 +223,14 @@ public async Task DownloadAndExtract_FailsClosed_WhenNoManylinuxWheel() Wheel(CublasPackage, CublasVersion, [ ("nvidia/cublas/lib/libcublas.so.12", 16), - ("nvidia/cublas/lib/libcublasLt.so.12", 16) - ], noManylinux: true) + ("nvidia/cublas/lib/libcublasLt.so.12", 16), + ], noManylinux: true), }; var handler = new FakePyPiHandler(fixtures); using var http = new HttpClient(handler); var provisioner = new CudaRuntimeProvisioner(temp.Path, http) { - SystemLibraryProbeForTests = _ => false + SystemLibraryProbeForTests = _ => false, }; await Assert.ThrowsAsync(() => @@ -257,13 +257,13 @@ public async Task DownloadAndExtract_ProgressAdvancesByActualBytes_WhenSizeOmitt CublasPackage, CublasVersion, [ ("nvidia/cublas/lib/libcublas.so.12", 200_000), - ("nvidia/cublas/lib/libcublasLt.so.12", 100_000) + ("nvidia/cublas/lib/libcublasLt.so.12", 100_000), ]); var handler = new FakePyPiHandler([cudart, cublas]); using var http = new HttpClient(handler); var provisioner = new CudaRuntimeProvisioner(temp.Path, http) { - SystemLibraryProbeForTests = _ => false + SystemLibraryProbeForTests = _ => false, }; var progress = new RecordingProgress(); @@ -290,7 +290,7 @@ public async Task DownloadAndExtract_TwoConcurrentCalls_GateSerializes_SingleDow using var _ = http; var provisioner = new CudaRuntimeProvisioner(temp.Path, http) { - SystemLibraryProbeForTests = _ => false + SystemLibraryProbeForTests = _ => false, }; var a = provisioner.DownloadAndExtractAsync( @@ -315,8 +315,8 @@ private static (FakePyPiHandler Handler, HttpClient Http) WhisperCublasFixture() Wheel(CublasPackage, CublasVersion, [ ("nvidia/cublas/lib/libcublas.so.12", 16), - ("nvidia/cublas/lib/libcublasLt.so.12", 16) - ]) + ("nvidia/cublas/lib/libcublasLt.so.12", 16), + ]), }; var handler = new FakePyPiHandler(fixtures); return (handler, new HttpClient(handler)); @@ -345,7 +345,7 @@ private static WheelFixture Wheel( Zip = BuildWheelZip(entries), OmitSize = omitSize, NullSha = nullSha, - NoManylinux = noManylinux + NoManylinux = noManylinux, }; private static byte[] BuildWheelZip(params (string Path, int Bytes)[] entries) @@ -430,7 +430,7 @@ protected override Task SendAsync( return Task.FromResult( new HttpResponseMessage(HttpStatusCode.OK) { - Content = new ByteArrayContent(fixture.Zip) + Content = new ByteArrayContent(fixture.Zip), }); } @@ -438,7 +438,7 @@ protected override Task SendAsync( private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK) { - Content = new StringContent(json, Encoding.UTF8, "application/json") + Content = new StringContent(json, Encoding.UTF8, "application/json"), }; private static string BuildPyPiJson(WheelFixture w) diff --git a/tests/TypeWhisper.PluginSystem.Tests/ModelManagerServiceTests.cs b/tests/TypeWhisper.PluginSystem.Tests/ModelManagerServiceTests.cs index 380e191aa..1d7a19c7a 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/ModelManagerServiceTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/ModelManagerServiceTests.cs @@ -37,7 +37,7 @@ public void Engine_WithoutActiveModel_DoesNotFallbackToArbitraryConfiguredPlugin SelectedModelId = ModelManagerService.GetPluginModelId( "com.typewhisper.sherpa-onnx", "parakeet" - ) + ), } ); @@ -672,7 +672,7 @@ public async Task LoadModelAsync_AppliesSavedAccelerationPreferenceBeforeLoading .Returns(new AppSettings { SelectedModelId = fullModelId, - LocalModelAcceleration = savedPreference + LocalModelAcceleration = savedPreference, }); var fake = new FakeTranscriptionPlugin(pluginId, true, null, true); @@ -683,7 +683,7 @@ public async Task LoadModelAsync_AppliesSavedAccelerationPreferenceBeforeLoading // doesn't throw, then verify the plugin saw the right resolved value. CudaRuntimePreflight = () => savedPreference == AppSettings.LocalModelAccelerationNvidiaCuda ? (true, "preflight ok") - : (false, "no cuda") + : (false, "no cuda"), }; await sut.LoadModelAsync(fullModelId); @@ -706,14 +706,14 @@ public async Task LoadModelAsync_AutoPreferenceWithoutGpu_ResolvesToCpu_EvenWhen .Returns(new AppSettings { SelectedModelId = fullModelId, - LocalModelAcceleration = AppSettings.LocalModelAccelerationAuto + LocalModelAcceleration = AppSettings.LocalModelAccelerationAuto, }); var fake = new FakeTranscriptionPlugin(pluginId, true, null, true); var sut = new ModelManagerService(CreatePluginManager(fake), _settings.Object) { // Simulates "CUDA libs present, GPU absent" — preflight reports failure. - CudaRuntimePreflight = () => (false, "No NVIDIA GPU/driver detected.") + CudaRuntimePreflight = () => (false, "No NVIDIA GPU/driver detected."), }; await sut.LoadModelAsync(fullModelId); @@ -733,13 +733,13 @@ public async Task LoadModelAsync_AutoPreferenceResolvesViaPreflight_PluginSeesRe .Returns(new AppSettings { SelectedModelId = fullModelId, - LocalModelAcceleration = AppSettings.LocalModelAccelerationAuto + LocalModelAcceleration = AppSettings.LocalModelAccelerationAuto, }); var fake = new FakeTranscriptionPlugin(pluginId, true, null, true); var sut = new ModelManagerService(CreatePluginManager(fake), _settings.Object) { - CudaRuntimePreflight = () => (true, "preflight ok") + CudaRuntimePreflight = () => (true, "preflight ok"), }; await sut.LoadModelAsync(fullModelId); @@ -763,13 +763,13 @@ public async Task LoadModelAsync_NvidiaCudaPreferenceWithoutCuda_Throws() .Returns(new AppSettings { SelectedModelId = fullModelId, - LocalModelAcceleration = AppSettings.LocalModelAccelerationNvidiaCuda + LocalModelAcceleration = AppSettings.LocalModelAccelerationNvidiaCuda, }); var fake = new FakeTranscriptionPlugin(pluginId, true, null, true); var sut = new ModelManagerService(CreatePluginManager(fake), _settings.Object) { - CudaRuntimePreflight = () => (false, "CUDA 12 runtime libraries are not installed.") + CudaRuntimePreflight = () => (false, "CUDA 12 runtime libraries are not installed."), }; var ex = await Assert.ThrowsAsync( @@ -794,12 +794,12 @@ public async Task LoadModelAsync_NvidiaCudaPreference_SelfProvisioningPlugin_NoS .Returns(new AppSettings { SelectedModelId = fullModelId, - LocalModelAcceleration = AppSettings.LocalModelAccelerationNvidiaCuda + LocalModelAcceleration = AppSettings.LocalModelAccelerationNvidiaCuda, }); var fake = new FakeTranscriptionPlugin(pluginId, true, null, true) { - ProvisionsCudaRuntimeOnDemand = true + ProvisionsCudaRuntimeOnDemand = true, }; var preflightCalls = 0; var sut = new ModelManagerService(CreatePluginManager(fake), _settings.Object) @@ -808,7 +808,7 @@ public async Task LoadModelAsync_NvidiaCudaPreference_SelfProvisioningPlugin_NoS { preflightCalls++; return (false, "CUDA 12 runtime libraries are not installed."); - } + }, }; await sut.LoadModelAsync(fullModelId); @@ -835,16 +835,16 @@ public async Task LoadModelAsync_AutoPreference_SelfProvisioningPlugin_NoSystemC .Returns(new AppSettings { SelectedModelId = fullModelId, - LocalModelAcceleration = AppSettings.LocalModelAccelerationAuto + LocalModelAcceleration = AppSettings.LocalModelAccelerationAuto, }); var fake = new FakeTranscriptionPlugin(pluginId, true, null, true) { - ProvisionsCudaRuntimeOnDemand = true + ProvisionsCudaRuntimeOnDemand = true, }; var sut = new ModelManagerService(CreatePluginManager(fake), _settings.Object) { - CudaRuntimePreflight = () => (false, "CUDA 12 runtime libraries are not installed.") + CudaRuntimePreflight = () => (false, "CUDA 12 runtime libraries are not installed."), }; await sut.LoadModelAsync(fullModelId); @@ -867,12 +867,12 @@ public async Task LoadModelAsync_AutoPreferenceOnCpuOnlyPlugin_ResolvesToCpu_NoP .Returns(new AppSettings { SelectedModelId = fullModelId, - LocalModelAcceleration = AppSettings.LocalModelAccelerationAuto + LocalModelAcceleration = AppSettings.LocalModelAccelerationAuto, }); var fake = new FakeTranscriptionPlugin(pluginId, true, null, true) { - SupportedAccelerationBackends = [TranscriptionAccelerationBackend.Cpu] + SupportedAccelerationBackends = [TranscriptionAccelerationBackend.Cpu], }; var preflightCalls = 0; var sut = new ModelManagerService(CreatePluginManager(fake), _settings.Object) @@ -881,7 +881,7 @@ public async Task LoadModelAsync_AutoPreferenceOnCpuOnlyPlugin_ResolvesToCpu_NoP { preflightCalls++; return (false, "should not be called"); - } + }, }; await sut.LoadModelAsync(fullModelId); @@ -907,12 +907,12 @@ public async Task LoadModelAsync_NvidiaCudaPreferenceOnCpuOnlyPlugin_LoadsWithCp .Returns(new AppSettings { SelectedModelId = fullModelId, - LocalModelAcceleration = AppSettings.LocalModelAccelerationNvidiaCuda + LocalModelAcceleration = AppSettings.LocalModelAccelerationNvidiaCuda, }); var fake = new FakeTranscriptionPlugin(pluginId, true, null, true) { - SupportedAccelerationBackends = [TranscriptionAccelerationBackend.Cpu] + SupportedAccelerationBackends = [TranscriptionAccelerationBackend.Cpu], }; var preflightCalls = 0; var sut = new ModelManagerService(CreatePluginManager(fake), _settings.Object) @@ -921,7 +921,7 @@ public async Task LoadModelAsync_NvidiaCudaPreferenceOnCpuOnlyPlugin_LoadsWithCp { preflightCalls++; return (false, "should not be called"); - } + }, }; await sut.LoadModelAsync(fullModelId); @@ -943,7 +943,7 @@ public async Task EnsureModelLoadedAsync_PreferenceChange_TriggersReload() var currentSettings = new AppSettings { SelectedModelId = fullModelId, - LocalModelAcceleration = AppSettings.LocalModelAccelerationCpu + LocalModelAcceleration = AppSettings.LocalModelAccelerationCpu, }; // currentSettings is reassigned below to simulate a preference change; the // Setup lambda intentionally reads the latest value each time s.Current is read. @@ -955,7 +955,7 @@ public async Task EnsureModelLoadedAsync_PreferenceChange_TriggersReload() { // Make the preflight always succeed so the explicit-NvidiaCuda case // can take the load path (rather than throwing). - CudaRuntimePreflight = () => (true, "ok") + CudaRuntimePreflight = () => (true, "ok"), }; await sut.EnsureModelLoadedAsync(fullModelId); @@ -965,7 +965,7 @@ public async Task EnsureModelLoadedAsync_PreferenceChange_TriggersReload() currentSettings = currentSettings with { - LocalModelAcceleration = AppSettings.LocalModelAccelerationNvidiaCuda + LocalModelAcceleration = AppSettings.LocalModelAccelerationNvidiaCuda, }; await sut.EnsureModelLoadedAsync(fullModelId); @@ -986,13 +986,13 @@ public async Task EnsureModelLoadedAsync_PreferenceUnchanged_DoesNotReload() .Returns(new AppSettings { SelectedModelId = fullModelId, - LocalModelAcceleration = AppSettings.LocalModelAccelerationCpu + LocalModelAcceleration = AppSettings.LocalModelAccelerationCpu, }); var fake = new FakeTranscriptionPlugin(pluginId, true, null, true); var sut = new ModelManagerService(CreatePluginManager(fake), _settings.Object) { - CudaRuntimePreflight = () => (false, "no cuda") + CudaRuntimePreflight = () => (false, "no cuda"), }; await sut.EnsureModelLoadedAsync(fullModelId); @@ -1011,15 +1011,15 @@ public async Task ClearCudaRuntimeCacheAsync_ClearsEveryProvisioningEngine_AndSk var sherpa = new FakeTranscriptionPlugin("com.typewhisper.sherpa-onnx", true, null) { - ProvisionsCudaRuntimeOnDemand = true + ProvisionsCudaRuntimeOnDemand = true, }; var whisper = new FakeTranscriptionPlugin("com.typewhisper.whisper-cpp", true, null) { - ProvisionsCudaRuntimeOnDemand = true + ProvisionsCudaRuntimeOnDemand = true, }; var cloud = new FakeTranscriptionPlugin("com.typewhisper.openai", true, null) { - ProvisionsCudaRuntimeOnDemand = false + ProvisionsCudaRuntimeOnDemand = false, }; var sut = new ModelManagerService( CreatePluginManager(sherpa, whisper, cloud), @@ -1045,11 +1045,11 @@ public async Task ClearCudaRuntimeCacheAsync_AttemptsAllEngines_ThenThrowsAggreg var failing = new FakeTranscriptionPlugin("com.typewhisper.sherpa-onnx", true, null) { ProvisionsCudaRuntimeOnDemand = true, - ClearCudaRuntimeError = "permission denied" + ClearCudaRuntimeError = "permission denied", }; var succeeding = new FakeTranscriptionPlugin("com.typewhisper.whisper-cpp", true, null) { - ProvisionsCudaRuntimeOnDemand = true + ProvisionsCudaRuntimeOnDemand = true, }; var sut = new ModelManagerService( CreatePluginManager(failing, succeeding), @@ -1080,7 +1080,7 @@ private ModelManagerService CreateServiceWithLoadableModel( .Returns(new AppSettings { SelectedModelId = fullModelId, - ModelAutoUnloadSeconds = modelAutoUnloadSeconds + ModelAutoUnloadSeconds = modelAutoUnloadSeconds, }); var fake = new FakeTranscriptionPlugin( @@ -1111,7 +1111,7 @@ out FakeTranscriptionPlugin newPlugin .Returns(new AppSettings { SelectedModelId = oldModelId, - ModelAutoUnloadSeconds = 60 + ModelAutoUnloadSeconds = 60, }); var oldPlugin = new FakeTranscriptionPlugin(oldPluginId, true, null, true); @@ -1121,7 +1121,7 @@ out FakeTranscriptionPlugin newPlugin _settings.Object ) { - CudaRuntimePreflight = () => (false, "CUDA not available in test") + CudaRuntimePreflight = () => (false, "CUDA not available in test"), }; return service; } @@ -1167,7 +1167,7 @@ public FakeTranscriptionPlugin( TranscriptionModels = [ new PluginModelInfo("parakeet", "Parakeet"), - new PluginModelInfo("whisper", "Whisper") + new PluginModelInfo("whisper", "Whisper"), ]; } diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiChatHelperTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiChatHelperTests.cs index 2a8d3bd5e..1feae88c4 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenAiChatHelperTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiChatHelperTests.cs @@ -16,7 +16,7 @@ public void SendChatCompletionAsync_PreservesLegacySevenParameterOverload() typeof(string), typeof(string), typeof(string), - typeof(CancellationToken) + typeof(CancellationToken), }; var method = typeof(OpenAiChatHelper).GetMethod( diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginEventBusTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginEventBusTests.cs index fe14fa63d..0933b6abe 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginEventBusTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginEventBusTests.cs @@ -223,7 +223,7 @@ public async Task TranscriptionCompletedEvent_FullPayload() Text = "Hello world", DetectedLanguage = "en", DurationSeconds = 3.5, - ModelId = "whisper-large-v3" + ModelId = "whisper-large-v3", } ); diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginEventsTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginEventsTests.cs index 7caec8570..636625449 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginEventsTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginEventsTests.cs @@ -36,7 +36,7 @@ public void TranscriptionCompletedEvent_RequiredAndOptionalFields() Text = "Hello", DetectedLanguage = "en", DurationSeconds = 2.3, - ModelId = "whisper-1" + ModelId = "whisper-1", }; Assert.Equal("Hello", evt.Text); @@ -61,7 +61,7 @@ public void TranscriptionFailedEvent_RequiredAndOptionalFields() var evt = new TranscriptionFailedEvent { ErrorMessage = "API timeout", - ModelId = "groq-whisper" + ModelId = "groq-whisper", }; Assert.Equal("API timeout", evt.ErrorMessage); diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginLoaderTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginLoaderTests.cs index a546a27f7..8fea6fde2 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginLoaderTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginLoaderTests.cs @@ -51,7 +51,7 @@ public void DiscoverAndLoad_MultipleNonExistentDirectories_ReturnsEmpty() var result = _loader.DiscoverAndLoad([ Path.Join(_tempDir, "a"), Path.Join(_tempDir, "b"), - Path.Join(_tempDir, "c") + Path.Join(_tempDir, "c"), ]); Assert.Empty(result); } @@ -89,7 +89,7 @@ public void DiscoverAndLoad_ManifestWithMissingAssembly_ReturnsEmpty() Name = "No Assembly", Version = "1.0.0", AssemblyName = "NonExistent.dll", - PluginClass = "NonExistent.Plugin" + PluginClass = "NonExistent.Plugin", }; File.WriteAllText( diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs index 29c369a10..8285a653c 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs @@ -104,7 +104,7 @@ public async Task InitializeAsync_PersistsEnabledState_RespectedFromSettings() { var customSettings = new AppSettings { - PluginEnabledState = new Dictionary { ["com.test.plugin"] = true } + PluginEnabledState = new Dictionary { ["com.test.plugin"] = true }, }; _settings.Setup(s => s.Current).Returns(customSettings); diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginManifestTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginManifestTests.cs index 79de52ccc..bc2e5c47d 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginManifestTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginManifestTests.cs @@ -7,7 +7,7 @@ public class PluginManifestTests { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; [Fact] @@ -77,7 +77,7 @@ public void Serialize_RoundTrip() Description = "Test roundtrip", AssemblyName = "RT.dll", PluginClass = "RT.Plugin", - MinHostVersion = "2.0.0" + MinHostVersion = "2.0.0", }; var json = JsonSerializer.Serialize(original, s_jsonOptions); @@ -144,7 +144,7 @@ public void Record_Equality() Name = "Eq", Version = "1.0.0", AssemblyName = "Eq.dll", - PluginClass = "Eq.Plugin" + PluginClass = "Eq.Plugin", }; var b = new PluginManifest @@ -153,7 +153,7 @@ public void Record_Equality() Name = "Eq", Version = "1.0.0", AssemblyName = "Eq.dll", - PluginClass = "Eq.Plugin" + PluginClass = "Eq.Plugin", }; Assert.Equal(a, b); @@ -168,7 +168,7 @@ public void Record_With_CreatesModifiedCopy() Name = "Original", Version = "1.0.0", AssemblyName = "With.dll", - PluginClass = "With.Plugin" + PluginClass = "With.Plugin", }; var modified = original with { Name = "Modified", Version = "2.0.0" }; diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginRegistryServiceTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginRegistryServiceTests.cs index 0302adfd8..232dd50fa 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginRegistryServiceTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginRegistryServiceTests.cs @@ -49,8 +49,8 @@ public async Task FetchRegistryAsync_DeserializesPlugins() Description = "A test plugin", Size = 1024L, DownloadUrl = "https://example.com/plugin.zip", - RequiresApiKey = false - } + RequiresApiKey = false, + }, }; var json = JsonSerializer.Serialize(plugins); @@ -80,8 +80,8 @@ public async Task FetchRegistryAsync_CachesResults() Description = "D", Size = 100L, DownloadUrl = "u", - RequiresApiKey = false - } + RequiresApiKey = false, + }, }; var json = JsonSerializer.Serialize(plugins); @@ -99,7 +99,7 @@ public async Task FetchRegistryAsync_CachesResults() callCount++; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(json) + Content = new StringContent(json), }; }); @@ -128,7 +128,7 @@ public async Task FetchRegistryAsync_FiltersIncompatibleVersions() Description = "D", Size = 100L, DownloadUrl = "u", - RequiresApiKey = false + RequiresApiKey = false, }, new { @@ -140,8 +140,8 @@ public async Task FetchRegistryAsync_FiltersIncompatibleVersions() Description = "D", Size = 100L, DownloadUrl = "u", - RequiresApiKey = false - } + RequiresApiKey = false, + }, }; var json = JsonSerializer.Serialize(plugins); @@ -181,7 +181,7 @@ public void GetInstallState_NotInstalled_WhenPluginNotLoaded() Author = "A", Description = "D", Size = 100, - DownloadUrl = "u" + DownloadUrl = "u", }; Assert.Equal(PluginInstallState.NotInstalled, service.GetInstallState(registryPlugin)); diff --git a/tests/TypeWhisper.PluginSystem.Tests/ScriptCollectionSettingsTests.cs b/tests/TypeWhisper.PluginSystem.Tests/ScriptCollectionSettingsTests.cs index 4b15abffb..0ea87f678 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/ScriptCollectionSettingsTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/ScriptCollectionSettingsTests.cs @@ -45,7 +45,7 @@ public async Task SetItems_ThenGetItems_RoundTripsAndWritesJson() CollectionKey, [ Item("First", "echo hello", "bash"), - Item("Second", "echo world", "sh", "false") + Item("Second", "echo world", "sh", "false"), ] ); @@ -269,7 +269,7 @@ private static PluginCollectionItem Item( ["name"] = name, ["command"] = command, ["shell"] = shell, - ["enabled"] = enabled + ["enabled"] = enabled, }; if (id is not null) { diff --git a/tests/TypeWhisper.PluginSystem.Tests/SmallestAiPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/SmallestAiPluginTests.cs index db61acfc2..d19dfb7a0 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/SmallestAiPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/SmallestAiPluginTests.cs @@ -96,7 +96,7 @@ public async Task SetApiKeyAsync_SerializesConcurrentSecretWrites() { var host = new TestPluginHostServices { - SecretWriteDelay = TimeSpan.FromMilliseconds(30) + SecretWriteDelay = TimeSpan.FromMilliseconds(30), }; var sut = new SmallestAiPlugin(); await sut.ActivateAsync(host); @@ -358,7 +358,7 @@ private static JsonElement LoadLocalization(string language) private static HttpResponseMessage JsonResponse(string json, HttpStatusCode statusCode = HttpStatusCode.OK) => new(statusCode) { - Content = new StringContent(json, Encoding.UTF8, "application/json") + Content = new StringContent(json, Encoding.UTF8, "application/json"), }; private sealed class CapturingHandler( @@ -379,7 +379,7 @@ private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary _settings = []; diff --git a/tests/TypeWhisper.PluginSystem.Tests/WebhookCollectionSettingsTests.cs b/tests/TypeWhisper.PluginSystem.Tests/WebhookCollectionSettingsTests.cs index 82b85ee75..10ebc992a 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/WebhookCollectionSettingsTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/WebhookCollectionSettingsTests.cs @@ -45,7 +45,7 @@ public async Task SetItems_ThenGetItems_RoundTripsAndWritesJson() CollectionKey, [ Item("Hook A", "https://a.example/x"), - Item("Hook B", "http://b.example/y", "PUT", enabled: "false") + Item("Hook B", "http://b.example/y", "PUT", enabled: "false"), ] ); @@ -311,7 +311,7 @@ private static PluginCollectionItem Item( ["method"] = method, ["headers"] = headers, ["profiles"] = profiles, - ["enabled"] = enabled + ["enabled"] = enabled, }; if (id is not null) { From ec14e81bf0bd3751a50a9637f507c6844ce4d15a Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 21 Jul 2026 17:11:32 +0000 Subject: [PATCH 136/226] Add the remaining 714 trailing commas and correct the ReSharper record Fixes the last ArrangeTrailingCommaInMultilineLists findings and corrects the counts claimed in 18efb7b ("72 -> 0") and f6c3558 (both scan targets at 0). Both zeros were false. Root cause: jb inspectcode keeps a persistent per-solution analysis cache in ~/.local/share/JetBrains/InspectCode. Earlier runs had populated it, so every subsequent scan of this working copy replayed cached per-file results and only re-analysed recently touched files. It reported a confident zero while hundreds of findings sat unexamined. The apparent "disjoint coverage" between TypeWhisper.sln and TypeWhisper.slnx was the same artifact: two different cache states, not two different scopes. Run against identical source in a fresh directory the two targets are byte-identical (918 findings, 220 files, both), so the solution file never mattered. Real numbers, each from a scan with --caches-home pointed at a clean directory: f800833 (before this branch's work) 918 18efb7b -72 f6c3558 -134 this commit -714 current tree 0 All 714 are the same mechanical edit: a trailing comma on the last element of a multiline initializer, with-expression, argument list, switch expression, or enum body, per resharper_csharp_trailing_comma_in_multiline_lists = true in .editorconfig. 703 are a pure append; the other 11 insert the comma before an existing trailing // comment, leaving the comment text untouched. Verified: fresh-cache scan reports 0 with executionSuccessful true; every changed line confirmed to be a comma insertion and nothing else; build clean (0 warnings); full suite green (2461). Note for future runs: jb inspectcode must be given --caches-home=, or ~/.local/share/JetBrains/InspectCode cleared first. Without that its output is meaningless on any repo it has scanned before. --- src/TypeWhisper.Cli/Models/CliOptions.cs | 2 +- src/TypeWhisper.Cli/Output/JsonFormatting.cs | 2 +- src/TypeWhisper.Cli/Program.cs | 2 +- src/TypeWhisper.Core/Models/CleanupLevel.cs | 2 +- .../Models/DictionaryEntrySource.cs | 2 +- .../Models/DictionaryEntryType.cs | 2 +- src/TypeWhisper.Core/Models/DiffSegment.cs | 2 +- src/TypeWhisper.Core/Models/ErrorLogEntry.cs | 2 +- .../Models/HistoryRetentionMode.cs | 2 +- src/TypeWhisper.Core/Models/IndustryPreset.cs | 2 +- .../LocalModelStorageUnavailableReason.cs | 2 +- src/TypeWhisper.Core/Models/MatchKind.cs | 2 +- src/TypeWhisper.Core/Models/ModelStatus.cs | 2 +- .../Models/ModelStatusType.cs | 2 +- .../Models/OverlayPosition.cs | 2 +- src/TypeWhisper.Core/Models/OverlayWidget.cs | 2 +- .../Models/ProfileHotkeyBehavior.cs | 2 +- .../Models/ProfileStylePreset.cs | 2 +- .../Models/RecentTranscriptionSource.cs | 2 +- src/TypeWhisper.Core/Models/RecordingMode.cs | 2 +- .../Models/SnippetTriggerMode.cs | 2 +- src/TypeWhisper.Core/Models/TermPack.cs | 32 ++-- .../Models/TextInsertionStatus.cs | 2 +- .../Models/TextInsertionStrategy.cs | 2 +- .../Models/TranscriptionTask.cs | 2 +- .../Models/TranslationModelInfo.cs | 8 +- .../Services/AppFormatterService.cs | 4 +- .../Services/CorrectionSuggestionService.cs | 4 +- .../Services/DetectionFailureTracker.cs | 2 +- .../Services/DeveloperFormattingService.cs | 4 +- .../Services/FirstRunDefaults.cs | 4 +- .../Services/HistoryInsightsService.cs | 2 +- .../Services/HistoryService.Export.cs | 2 +- .../Services/ProfileStylePresetService.cs | 4 +- .../Services/WhisperHallucinationFilter.cs | 2 +- .../Cli/CommandLineParser.cs | 2 +- .../Cli/Commands/RecordCommand.cs | 2 +- src/TypeWhisper.Linux/DiffKindConverters.cs | 4 +- .../AccessibilityBusActivationService.cs | 2 +- .../Services/ActiveWindow/AtSpiEventClient.cs | 8 +- .../ActiveWindow/AtSpiUrlExtractor.cs | 10 +- .../ActiveWindow/GnomeWindowCallsProvider.cs | 4 +- .../ActiveWindow/ProviderProcessRunner.cs | 4 +- src/TypeWhisper.Linux/Services/AppVersion.cs | 2 +- .../Services/AudioDuckingService.cs | 2 +- .../Services/AudioFileService.cs | 4 +- .../Services/AudioPlaybackService.cs | 2 +- .../Services/AudioRecordingService.cs | 2 +- .../BrowserAccessibilitySetupHelper.cs | 8 +- .../Services/DictationToggleGate.cs | 2 +- .../Services/FileTranscriptionProcessor.cs | 6 +- .../Services/GnomeWindowCallsSetupHelper.cs | 4 +- .../Services/HistoryRetentionCoordinator.cs | 2 +- .../Hotkey/DeSetup/DesktopDetector.cs | 4 +- .../DeSetup/DictationShortcutSpecFactory.cs | 2 +- .../Hotkey/DeSetup/GnomeShortcutWriter.cs | 4 +- .../Hotkey/Evdev/InputAccessSetupHelper.cs | 2 +- .../Services/Hotkey/Evdev/LinuxKeyMap.cs | 4 +- .../Evdev/LogindSessionActivityMonitor.cs | 6 +- .../Hotkey/SharpHookGlobalShortcutBackend.cs | 2 +- .../Services/Hotkey/ShortcutDispatcher.cs | 4 +- .../Services/Hotkey/ShortcutMatcher.cs | 2 +- .../Services/HotkeyService.cs | 26 +-- .../Services/Ipc/ControlSocketServer.cs | 4 +- .../Services/Ipc/JsonControlProtocol.cs | 2 +- .../LearnedCorrectionsNotificationService.cs | 4 +- .../LinuxDictationReadbackLanguagePolicy.cs | 4 +- .../LinuxDictationShortSpeechPolicy.cs | 2 +- .../LinuxLiveTranscriptionStartupPolicy.cs | 2 +- .../Services/LinuxSystemTtsProvider.cs | 2 +- .../Services/Localization/Loc.cs | 4 +- .../Services/MemoryService.cs | 2 +- .../Services/ModelManagerService.cs | 8 +- .../Plugins/PluginLocalityClassifier.cs | 2 +- .../Services/Plugins/PluginManager.cs | 8 +- .../Services/Plugins/PluginRegistryService.cs | 2 +- .../Services/Plugins/RegistryPlugin.cs | 2 +- .../Services/ProcessPriority.cs | 4 +- .../Services/ProcessRunner.cs | 2 +- .../Services/PromptProcessingService.cs | 2 +- .../Services/RecordingNotificationService.cs | 6 +- .../Services/SettingsBackupService.cs | 22 +-- .../Services/Setup/ISetupTask.cs | 4 +- .../SpokenCommand/SpokenCommandIntent.cs | 6 +- .../SpokenCommand/SpokenCommandKeyphrase.cs | 2 +- .../SpokenCommand/SpokenCommandText.cs | 2 +- .../StreamingTranscriptionCoordinator.cs | 2 +- .../SystemCommandAvailabilityService.cs | 10 +- .../Services/TranslationService.cs | 10 +- .../Services/TrayIconService.cs | 4 +- .../Services/UpdateCheckService.cs | 10 +- .../Services/WatchFolderExportBuilder.cs | 2 +- .../Services/WatchFolderModels.cs | 4 +- .../ViewModels/DictationOverlayViewModel.cs | 4 +- .../ViewModels/MainWindowViewModel.cs | 2 +- .../Sections/AboutSectionViewModel.cs | 2 +- .../Sections/AdvancedSectionViewModel.cs | 6 +- .../Sections/AppearanceSectionViewModel.cs | 8 +- .../Sections/DashboardSectionViewModel.cs | 4 +- .../Sections/DictationSectionViewModel.cs | 18 +- .../Sections/DictionarySectionViewModel.cs | 10 +- .../FileTranscriptionQueueItemStatus.cs | 2 +- .../FileTranscriptionSectionViewModel.cs | 4 +- .../Sections/HistorySectionViewModel.cs | 6 +- .../Sections/PluginCollectionViewModels.cs | 2 +- .../Sections/PluginsSectionViewModel.cs | 14 +- .../Sections/ProfilesSectionViewModel.cs | 16 +- .../Sections/PromptsSectionViewModel.cs | 6 +- .../Sections/ShortcutsSectionViewModel.cs | 16 +- .../Sections/SnippetsSectionViewModel.cs | 8 +- .../ViewModels/WelcomeWizardViewModel.cs | 8 +- .../Views/DictationOverlayWindow.axaml.cs | 2 +- .../Views/Sections/AboutSection.axaml.cs | 6 +- .../Views/Sections/DictationSection.axaml.cs | 2 +- .../Views/Sections/DictionarySection.axaml.cs | 4 +- .../FileTranscriptionSection.axaml.cs | 4 +- .../Helpers/OpenAiApiHelper.cs | 2 +- .../Helpers/OpenAiChatHelper.cs | 6 +- .../Helpers/OpenAiTranscriptionHelper.cs | 2 +- .../Models/PluginLogLevel.cs | 2 +- .../TranscriptionAccelerationBackend.cs | 2 +- .../TranscriptionAccelerationPreference.cs | 2 +- .../Models/TtsPurpose.cs | 2 +- .../Models/ErrorCategoryGuardTests.cs | 2 +- .../DictionaryServiceCorrectionsTests.cs | 12 +- .../Services/DictionaryServiceTests.cs | 68 ++++---- .../Services/HistoryInsightsServiceTests.cs | 4 +- .../Services/HistoryServiceTests.cs | 22 +-- .../Services/MatchProfileCascadeTests.cs | 2 +- .../Services/PostProcessingPipelineTests.cs | 58 +++---- .../Services/PromptActionServiceTests.cs | 34 ++-- .../Services/SettingsServiceTests.cs | 14 +- .../Services/SnippetServiceTests.cs | 50 +++--- .../Services/SubtitleExporterTests.cs | 8 +- .../VocabularyBoostingServiceTests.cs | 28 ++-- .../AppInsertionStrategyRowTests.cs | 4 +- .../AppearanceSectionViewModelTests.cs | 6 +- .../AudioDuckingServiceTests.cs | 2 +- .../ControlSocketOwnershipTests.cs | 2 +- .../DashboardSectionViewModelTests.cs | 2 +- ...ctationOrchestratorDiscardFeedbackTests.cs | 2 +- ...OrchestratorPromptActionResolutionTests.cs | 6 +- .../DictationShortcutSpecFactoryTests.cs | 4 +- .../DictionarySectionViewModelTests.cs | 10 +- .../EvdevDeviceReaderTests.cs | 4 +- .../EvdevGlobalShortcutBackendTests.cs | 8 +- .../FileTranscriptionSectionViewModelTests.cs | 4 +- .../GnomeShortcutWriterTests.cs | 2 +- .../HistorySectionViewModelTests.cs | 8 +- .../HotkeyServiceTests.cs | 90 +++++----- .../HttpApiAccelerationDtoTests.cs | 12 +- .../HttpApiCorrectionsDtoTests.cs | 2 +- .../HttpApiLocalFileDtoTests.cs | 2 +- .../InputAccessSetupHelperTests.cs | 4 +- ...earnedCorrectionsFeedbackPresenterTests.cs | 2 +- ...nuxDictationReadbackLanguagePolicyTests.cs | 2 +- ...inuxLiveTranscriptionStartupPolicyTests.cs | 32 ++-- .../LinuxSystemTtsProviderTests.cs | 6 +- .../LocalizationResourcesTests.cs | 4 +- .../MediaPauseServiceTests.cs | 2 +- .../PluginCollectionSettingsViewModelTests.cs | 2 +- .../PluginRegistryServiceTests.cs | 12 +- .../ProcessRunnerTests.cs | 4 +- .../ProfilesSectionViewModelTests.cs | 24 +-- .../PromptProcessingServiceTests.cs | 10 +- .../PromptsSectionViewModelTests.cs | 24 +-- .../RecentTranscriptionStoreTests.cs | 6 +- .../RecordingNotificationServiceTests.cs | 30 ++-- .../SentinelBlockTests.cs | 2 +- .../SettingsBackupServiceTests.cs | 2 +- .../ShortcutDispatcherTests.cs | 4 +- .../ShortcutMatcherTests.cs | 2 +- .../ShortcutsSectionViewModelTests.cs | 32 ++-- .../SnippetsSectionViewModelTests.cs | 6 +- .../SoundFeedbackServiceTests.cs | 6 +- .../SpeechFeedbackServiceTests.cs | 6 +- .../SpokenCommandActionMatcherTests.cs | 8 +- .../StreamingTranscriptionCoordinatorTests.cs | 10 +- .../TestPluginManagerFactory.cs | 2 +- .../TextInsertionServiceTests.cs | 156 +++++++++--------- .../WatchFolderExportBuilderTests.cs | 2 +- 181 files changed, 714 insertions(+), 714 deletions(-) diff --git a/src/TypeWhisper.Cli/Models/CliOptions.cs b/src/TypeWhisper.Cli/Models/CliOptions.cs index 4a5b41fd4..6ba37306e 100644 --- a/src/TypeWhisper.Cli/Models/CliOptions.cs +++ b/src/TypeWhisper.Cli/Models/CliOptions.cs @@ -183,7 +183,7 @@ public static CliOptions Parse(string[] args) Prompt = prompt, Engine = engine, Model = model, - AwaitDownload = awaitDownload + AwaitDownload = awaitDownload, }; } diff --git a/src/TypeWhisper.Cli/Output/JsonFormatting.cs b/src/TypeWhisper.Cli/Output/JsonFormatting.cs index 23f99982d..362ae1d00 100644 --- a/src/TypeWhisper.Cli/Output/JsonFormatting.cs +++ b/src/TypeWhisper.Cli/Output/JsonFormatting.cs @@ -28,7 +28,7 @@ public static string Prop(JsonElement el, string name) JsonValueKind.Number => value.ToString(), JsonValueKind.True => "true", JsonValueKind.False => "false", - _ => "" + _ => "", }; } diff --git a/src/TypeWhisper.Cli/Program.cs b/src/TypeWhisper.Cli/Program.cs index 1627e1531..df35d5365 100644 --- a/src/TypeWhisper.Cli/Program.cs +++ b/src/TypeWhisper.Cli/Program.cs @@ -57,7 +57,7 @@ private static async Task Main(string[] args) "status" => await StatusCommand.RunAsync(api, options.Json), "models" => await ModelsCommand.RunAsync(api, options.Json), "transcribe" => await TranscribeCommand.RunAsync(api, options), - _ => ConsoleOutput.Error($"Unknown command: {options.Command}") + _ => ConsoleOutput.Error($"Unknown command: {options.Command}"), }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Models/CleanupLevel.cs b/src/TypeWhisper.Core/Models/CleanupLevel.cs index e03f8ff3a..ad7ee444b 100644 --- a/src/TypeWhisper.Core/Models/CleanupLevel.cs +++ b/src/TypeWhisper.Core/Models/CleanupLevel.cs @@ -6,5 +6,5 @@ public enum CleanupLevel None, Light, Medium, - High + High, } diff --git a/src/TypeWhisper.Core/Models/DictionaryEntrySource.cs b/src/TypeWhisper.Core/Models/DictionaryEntrySource.cs index 7b16bc8f3..0087300e8 100644 --- a/src/TypeWhisper.Core/Models/DictionaryEntrySource.cs +++ b/src/TypeWhisper.Core/Models/DictionaryEntrySource.cs @@ -7,5 +7,5 @@ public enum DictionaryEntrySource Manual, Import, CorrectionSuggestion, - AutoLearned + AutoLearned, } diff --git a/src/TypeWhisper.Core/Models/DictionaryEntryType.cs b/src/TypeWhisper.Core/Models/DictionaryEntryType.cs index cc2059db6..d62bcab95 100644 --- a/src/TypeWhisper.Core/Models/DictionaryEntryType.cs +++ b/src/TypeWhisper.Core/Models/DictionaryEntryType.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum DictionaryEntryType { Term, - Correction + Correction, } diff --git a/src/TypeWhisper.Core/Models/DiffSegment.cs b/src/TypeWhisper.Core/Models/DiffSegment.cs index 035a5ab3c..fad784ca5 100644 --- a/src/TypeWhisper.Core/Models/DiffSegment.cs +++ b/src/TypeWhisper.Core/Models/DiffSegment.cs @@ -10,7 +10,7 @@ public enum DiffKind Added, /// Present in the raw text but not the final text. - Removed + Removed, } /// diff --git a/src/TypeWhisper.Core/Models/ErrorLogEntry.cs b/src/TypeWhisper.Core/Models/ErrorLogEntry.cs index 973a66839..6e817a94b 100644 --- a/src/TypeWhisper.Core/Models/ErrorLogEntry.cs +++ b/src/TypeWhisper.Core/Models/ErrorLogEntry.cs @@ -17,7 +17,7 @@ public static ErrorLogEntry Create(string message, string category = ErrorCatego { return new ErrorLogEntry { - Id = Guid.NewGuid().ToString("N"), Timestamp = DateTime.UtcNow, Message = message, Category = category + Id = Guid.NewGuid().ToString("N"), Timestamp = DateTime.UtcNow, Message = message, Category = category, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Models/HistoryRetentionMode.cs b/src/TypeWhisper.Core/Models/HistoryRetentionMode.cs index e111282e2..e74d42585 100644 --- a/src/TypeWhisper.Core/Models/HistoryRetentionMode.cs +++ b/src/TypeWhisper.Core/Models/HistoryRetentionMode.cs @@ -5,5 +5,5 @@ public enum HistoryRetentionMode { Duration, Forever, - UntilAppCloses + UntilAppCloses, } diff --git a/src/TypeWhisper.Core/Models/IndustryPreset.cs b/src/TypeWhisper.Core/Models/IndustryPreset.cs index 3e1301582..5b8cc7bf2 100644 --- a/src/TypeWhisper.Core/Models/IndustryPreset.cs +++ b/src/TypeWhisper.Core/Models/IndustryPreset.cs @@ -33,7 +33,7 @@ public sealed record IndustryPreset(string Id, string Name, string Description, "Legal", "Contract, compliance, and litigation terms.", "legal" - ) + ), ]; public static string[] MergeIntoEnabledPackIds(string[] enabledPackIds, string presetId) diff --git a/src/TypeWhisper.Core/Models/LocalModelStorageUnavailableReason.cs b/src/TypeWhisper.Core/Models/LocalModelStorageUnavailableReason.cs index 80c5423a7..06a965bd1 100644 --- a/src/TypeWhisper.Core/Models/LocalModelStorageUnavailableReason.cs +++ b/src/TypeWhisper.Core/Models/LocalModelStorageUnavailableReason.cs @@ -14,5 +14,5 @@ public enum LocalModelStorageUnavailableReason NotWritable, /// The chosen target folder is nested inside the current storage folder. - NestedUnderCurrentFolder + NestedUnderCurrentFolder, } diff --git a/src/TypeWhisper.Core/Models/MatchKind.cs b/src/TypeWhisper.Core/Models/MatchKind.cs index 49c3ed857..593dd508b 100644 --- a/src/TypeWhisper.Core/Models/MatchKind.cs +++ b/src/TypeWhisper.Core/Models/MatchKind.cs @@ -8,5 +8,5 @@ public enum MatchKind App, Global, ManualOverride, - NoMatch + NoMatch, } diff --git a/src/TypeWhisper.Core/Models/ModelStatus.cs b/src/TypeWhisper.Core/Models/ModelStatus.cs index d7696fba7..11f2527a1 100644 --- a/src/TypeWhisper.Core/Models/ModelStatus.cs +++ b/src/TypeWhisper.Core/Models/ModelStatus.cs @@ -23,7 +23,7 @@ public static ModelStatus DownloadingModel(double progress, double? bytesPerSeco { return new ModelStatus { - Type = ModelStatusType.Downloading, Progress = progress, BytesPerSecond = bytesPerSecond + Type = ModelStatusType.Downloading, Progress = progress, BytesPerSecond = bytesPerSecond, }; } diff --git a/src/TypeWhisper.Core/Models/ModelStatusType.cs b/src/TypeWhisper.Core/Models/ModelStatusType.cs index 30df24e9c..341bc024e 100644 --- a/src/TypeWhisper.Core/Models/ModelStatusType.cs +++ b/src/TypeWhisper.Core/Models/ModelStatusType.cs @@ -7,5 +7,5 @@ public enum ModelStatusType Downloading, Loading, Ready, - Error + Error, } diff --git a/src/TypeWhisper.Core/Models/OverlayPosition.cs b/src/TypeWhisper.Core/Models/OverlayPosition.cs index f2fc0b27d..7a356e027 100644 --- a/src/TypeWhisper.Core/Models/OverlayPosition.cs +++ b/src/TypeWhisper.Core/Models/OverlayPosition.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum OverlayPosition { Top, - Bottom + Bottom, } diff --git a/src/TypeWhisper.Core/Models/OverlayWidget.cs b/src/TypeWhisper.Core/Models/OverlayWidget.cs index 5a1af6686..d00d97ac2 100644 --- a/src/TypeWhisper.Core/Models/OverlayWidget.cs +++ b/src/TypeWhisper.Core/Models/OverlayWidget.cs @@ -10,5 +10,5 @@ public enum OverlayWidget Clock, Profile, HotkeyMode, - AppName + AppName, } diff --git a/src/TypeWhisper.Core/Models/ProfileHotkeyBehavior.cs b/src/TypeWhisper.Core/Models/ProfileHotkeyBehavior.cs index 0028f3e69..8691d6087 100644 --- a/src/TypeWhisper.Core/Models/ProfileHotkeyBehavior.cs +++ b/src/TypeWhisper.Core/Models/ProfileHotkeyBehavior.cs @@ -14,5 +14,5 @@ namespace TypeWhisper.Core.Models; public enum ProfileHotkeyBehavior { StartDictation, - ProcessSelectedText + ProcessSelectedText, } diff --git a/src/TypeWhisper.Core/Models/ProfileStylePreset.cs b/src/TypeWhisper.Core/Models/ProfileStylePreset.cs index fd9cca40d..59d3c51d5 100644 --- a/src/TypeWhisper.Core/Models/ProfileStylePreset.cs +++ b/src/TypeWhisper.Core/Models/ProfileStylePreset.cs @@ -10,5 +10,5 @@ public enum ProfileStylePreset CasualMessage, Developer, TerminalSafe, - MeetingNotes + MeetingNotes, } diff --git a/src/TypeWhisper.Core/Models/RecentTranscriptionSource.cs b/src/TypeWhisper.Core/Models/RecentTranscriptionSource.cs index 53857ff9d..4d880b769 100644 --- a/src/TypeWhisper.Core/Models/RecentTranscriptionSource.cs +++ b/src/TypeWhisper.Core/Models/RecentTranscriptionSource.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum RecentTranscriptionSource { Session, - History + History, } diff --git a/src/TypeWhisper.Core/Models/RecordingMode.cs b/src/TypeWhisper.Core/Models/RecordingMode.cs index 9cff360b1..0a41d7a2d 100644 --- a/src/TypeWhisper.Core/Models/RecordingMode.cs +++ b/src/TypeWhisper.Core/Models/RecordingMode.cs @@ -5,5 +5,5 @@ public enum RecordingMode { Toggle, PushToTalk, - Hybrid + Hybrid, } diff --git a/src/TypeWhisper.Core/Models/SnippetTriggerMode.cs b/src/TypeWhisper.Core/Models/SnippetTriggerMode.cs index 92d0081e7..461fa4d28 100644 --- a/src/TypeWhisper.Core/Models/SnippetTriggerMode.cs +++ b/src/TypeWhisper.Core/Models/SnippetTriggerMode.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum SnippetTriggerMode { Anywhere, - ExactPhrase + ExactPhrase, } diff --git a/src/TypeWhisper.Core/Models/TermPack.cs b/src/TypeWhisper.Core/Models/TermPack.cs index feb394f56..678bb1de5 100644 --- a/src/TypeWhisper.Core/Models/TermPack.cs +++ b/src/TypeWhisper.Core/Models/TermPack.cs @@ -38,7 +38,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "SvelteKit", "Vercel", "Netlify", - "Supabase" + "Supabase", ] ), new( @@ -65,7 +65,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Moq", "CommunityToolkit", "Avalonia", - "Orleans" + "Orleans", ] ), new( @@ -87,7 +87,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "ArgoCD", "Pulumi", "Vault", - "Consul" + "Consul", ] ), new( @@ -109,7 +109,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Pandas", "NumPy", "Scikit-learn", - "RAG" + "RAG", ] ), new( @@ -131,7 +131,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Accessibility", "Responsive", "Breakpoint", - "Viewport" + "Viewport", ] ), new( @@ -153,7 +153,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Sprite", "Tilemap", "NavMesh", - "GameLoop" + "GameLoop", ] ), new( @@ -174,7 +174,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Room", "Firebase", "TestFlight", - "CocoaPods" + "CocoaPods", ] ), new( @@ -196,7 +196,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "SIEM", "SOC", "Ransomware", - "Phishing" + "Phishing", ] ), // These packs originated upstream with German display names and German @@ -221,7 +221,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Supabase", "PlanetScale", "Prisma", - "Drizzle" + "Drizzle", ] ), new( @@ -243,7 +243,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Orthopedics", "Neurology", "Pediatrics", - "Radiology" + "Radiology", ] ), new( @@ -265,7 +265,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Civil law", "Arbitration", "Data protection", - "Warranty" + "Warranty", ] ), new( @@ -287,7 +287,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Cryptocurrency", "Blockchain", "Fintech", - "Liquidity" + "Liquidity", ] ), new( @@ -309,7 +309,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Limiter", "Chorus", "Phaser", - "Arpeggiator" + "Arpeggiator", ] ), new( @@ -366,7 +366,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "ARM", "PITI", "Disclosure", - "Zoning" + "Zoning", ] ), new( @@ -423,9 +423,9 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Grasshopper", "RFI", "Schematic design", - "Construction documents" + "Construction documents", ] - ) + ), ]; public static TermPack? FindById(string id) diff --git a/src/TypeWhisper.Core/Models/TextInsertionStatus.cs b/src/TypeWhisper.Core/Models/TextInsertionStatus.cs index d854f9d99..f0ef74dc6 100644 --- a/src/TypeWhisper.Core/Models/TextInsertionStatus.cs +++ b/src/TypeWhisper.Core/Models/TextInsertionStatus.cs @@ -17,5 +17,5 @@ public enum TextInsertionStatus // Appended after Failed to preserve the persisted numeric ordinals of the // members above: history.json serializes this enum by value (no string // converter), so inserting mid-enum would reinterpret existing records. - ActionUnavailable + ActionUnavailable, } diff --git a/src/TypeWhisper.Core/Models/TextInsertionStrategy.cs b/src/TypeWhisper.Core/Models/TextInsertionStrategy.cs index f4dadea3e..e30bd1cd4 100644 --- a/src/TypeWhisper.Core/Models/TextInsertionStrategy.cs +++ b/src/TypeWhisper.Core/Models/TextInsertionStrategy.cs @@ -6,5 +6,5 @@ public enum TextInsertionStrategy Auto, ClipboardPaste, DirectTyping, - CopyOnly + CopyOnly, } diff --git a/src/TypeWhisper.Core/Models/TranscriptionTask.cs b/src/TypeWhisper.Core/Models/TranscriptionTask.cs index 09a13e65e..9d7ef627c 100644 --- a/src/TypeWhisper.Core/Models/TranscriptionTask.cs +++ b/src/TypeWhisper.Core/Models/TranscriptionTask.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum TranscriptionTask { Transcribe, - Translate + Translate, } diff --git a/src/TypeWhisper.Core/Models/TranslationModelInfo.cs b/src/TypeWhisper.Core/Models/TranslationModelInfo.cs index e7311411c..863f40c0a 100644 --- a/src/TypeWhisper.Core/Models/TranslationModelInfo.cs +++ b/src/TypeWhisper.Core/Models/TranslationModelInfo.cs @@ -52,7 +52,7 @@ public sealed record TranslationModelInfo new("ar", "العربية"), new("hi", "हिन्दी"), new("vi", "Tiếng Việt"), - new("id", "Bahasa Indonesia") + new("id", "Bahasa Indonesia"), ]; // The OPUS-MT models that actually exist (confirmed Xenova ONNX exports). The @@ -102,7 +102,7 @@ public sealed record TranslationModelInfo Pair("en", "hu"), Pair("en", "id"), // Direct non-English pairs - Pair("de", "es") + Pair("de", "es"), ]; // Distinct target languages across every model pair — the targets we can @@ -196,8 +196,8 @@ private static TranslationModelInfo Pair(string src, string tgt, string? repoOve $"{Hf}/opus-mt-{repo}/resolve/main/onnx/decoder_model_quantized.onnx" ), new TranslationFileInfo("tokenizer.json", $"{Hf}/opus-mt-{repo}/resolve/main/tokenizer.json"), - new TranslationFileInfo("config.json", $"{Hf}/opus-mt-{repo}/resolve/main/config.json") - ] + new TranslationFileInfo("config.json", $"{Hf}/opus-mt-{repo}/resolve/main/config.json"), + ], }; } } diff --git a/src/TypeWhisper.Core/Services/AppFormatterService.cs b/src/TypeWhisper.Core/Services/AppFormatterService.cs index d08ceea1d..100068099 100644 --- a/src/TypeWhisper.Core/Services/AppFormatterService.cs +++ b/src/TypeWhisper.Core/Services/AppFormatterService.cs @@ -30,7 +30,7 @@ public static class AppFormatterService ["cmd"] = "code", ["powershell"] = "code", ["pwsh"] = "code", - ["cursor"] = "code" + ["cursor"] = "code", }; /// @@ -48,7 +48,7 @@ public static string Format(string text, string? processName) return format switch { "markdown" => FormatAsMarkdown(text), - _ => text // code + plaintext = passthrough + _ => text, // code + plaintext = passthrough }; } diff --git a/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs b/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs index 55ba14f95..065a69063 100644 --- a/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs +++ b/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs @@ -63,8 +63,8 @@ string correctedText [ new CorrectionSuggestion { - Original = original, Replacement = replacement, Confidence = Math.Round(confidence, 2) - } + Original = original, Replacement = replacement, Confidence = Math.Round(confidence, 2), + }, ]; } diff --git a/src/TypeWhisper.Core/Services/DetectionFailureTracker.cs b/src/TypeWhisper.Core/Services/DetectionFailureTracker.cs index b32a6335f..713e2f1a3 100644 --- a/src/TypeWhisper.Core/Services/DetectionFailureTracker.cs +++ b/src/TypeWhisper.Core/Services/DetectionFailureTracker.cs @@ -97,7 +97,7 @@ private static string AugmentReason(string compositor, string reason) "hyprland" or "sway" => $"{reason}. Compositor command failed unexpectedly.", "xdotool" => $"{reason}. xdotool only works on X11/XWayland — install a Wayland-native compositor for better detection.", - _ => reason + _ => reason, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Services/DeveloperFormattingService.cs b/src/TypeWhisper.Core/Services/DeveloperFormattingService.cs index 54c08a80d..fbfe8d860 100644 --- a/src/TypeWhisper.Core/Services/DeveloperFormattingService.cs +++ b/src/TypeWhisper.Core/Services/DeveloperFormattingService.cs @@ -32,7 +32,7 @@ private static readonly (Regex Pattern, string Replacement)[] s_symbolReplacemen (SemicolonRegex(), ";"), (CommaRegex(), ","), (UnderscoreRegex(), "_"), - (EqualsRegex(), "=") + (EqualsRegex(), "="), ]; public static string Format(string text) @@ -119,7 +119,7 @@ private static string ReplaceRepeated(string text, Regex regex, string replaceme "camel" => words[0] + string.Concat(words.Skip(1).Select(ToTitleInvariant)), "snake" => string.Join('_', words), "kebab" => string.Join('-', words), - _ => null + _ => null, }; } diff --git a/src/TypeWhisper.Core/Services/FirstRunDefaults.cs b/src/TypeWhisper.Core/Services/FirstRunDefaults.cs index 209a72aad..974531c15 100644 --- a/src/TypeWhisper.Core/Services/FirstRunDefaults.cs +++ b/src/TypeWhisper.Core/Services/FirstRunDefaults.cs @@ -66,7 +66,7 @@ public static PromptAction CreateAutoCleanupAction() IsPreset = false, IsEnabled = false, SortOrder = 0, - ProviderOverride = null + ProviderOverride = null, }; } @@ -85,7 +85,7 @@ public static Profile CreateAutoFormatProfile() PromptActionId = AutoCleanupActionId, HotkeyData = "Ctrl + Alt + E", HotkeyBehavior = ProfileHotkeyBehavior.StartDictation, - StylePreset = ProfileStylePreset.Raw + StylePreset = ProfileStylePreset.Raw, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Services/HistoryInsightsService.cs b/src/TypeWhisper.Core/Services/HistoryInsightsService.cs index c27e0fc24..d42de0587 100644 --- a/src/TypeWhisper.Core/Services/HistoryInsightsService.cs +++ b/src/TypeWhisper.Core/Services/HistoryInsightsService.cs @@ -79,7 +79,7 @@ or TextInsertionStatus.MissingPasteTool ), PromptActionAppliedCount = records.Count(record => record.PromptActionApplied), TranslationAppliedCount = records.Count(record => record.TranslationApplied), - TopApps = topApps + TopApps = topApps, }; } } diff --git a/src/TypeWhisper.Core/Services/HistoryService.Export.cs b/src/TypeWhisper.Core/Services/HistoryService.Export.cs index 7f173c1ea..5268969a5 100644 --- a/src/TypeWhisper.Core/Services/HistoryService.Export.cs +++ b/src/TypeWhisper.Core/Services/HistoryService.Export.cs @@ -129,7 +129,7 @@ public string ExportToJson(IReadOnlyList records) profile = r.ProfileName, insertion_status = r.InsertionStatus.ToString(), insertion_failure_reason = r.InsertionFailureReason, - words = r.WordCount + words = r.WordCount, }); return JsonSerializer.Serialize(data, s_jsonOptions); diff --git a/src/TypeWhisper.Core/Services/ProfileStylePresetService.cs b/src/TypeWhisper.Core/Services/ProfileStylePresetService.cs index 06997af6f..2f40ee498 100644 --- a/src/TypeWhisper.Core/Services/ProfileStylePresetService.cs +++ b/src/TypeWhisper.Core/Services/ProfileStylePresetService.cs @@ -45,7 +45,7 @@ public static ProfileStyleSettings Resolve(ProfileStylePreset preset) CleanupLevel.Medium, true ), - _ => Settings(ProfileStylePreset.Raw, CleanupLevel.None) + _ => Settings(ProfileStylePreset.Raw, CleanupLevel.None), }; } @@ -63,7 +63,7 @@ private static ProfileStyleSettings Settings( CleanupLevel = cleanupLevel, SmartFormattingEnabled = smartFormatting, DeveloperFormattingEnabled = developerFormatting, - TerminalSafe = terminalSafe + TerminalSafe = terminalSafe, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Services/WhisperHallucinationFilter.cs b/src/TypeWhisper.Core/Services/WhisperHallucinationFilter.cs index 73ef1961c..289568d6c 100644 --- a/src/TypeWhisper.Core/Services/WhisperHallucinationFilter.cs +++ b/src/TypeWhisper.Core/Services/WhisperHallucinationFilter.cs @@ -35,7 +35,7 @@ public static class WhisperHallucinationFilter "bye", "bye bye", "goodbye", - "you" + "you", }; /// diff --git a/src/TypeWhisper.Linux/Cli/CommandLineParser.cs b/src/TypeWhisper.Linux/Cli/CommandLineParser.cs index a965f9fa4..26466c18d 100644 --- a/src/TypeWhisper.Linux/Cli/CommandLineParser.cs +++ b/src/TypeWhisper.Linux/Cli/CommandLineParser.cs @@ -19,7 +19,7 @@ internal enum CliActionKind Status, /// Args didn't parse; the driver should print usage and exit non-zero. - Invalid + Invalid, } /// Result of parsing the command line. diff --git a/src/TypeWhisper.Linux/Cli/Commands/RecordCommand.cs b/src/TypeWhisper.Linux/Cli/Commands/RecordCommand.cs index ae3178d93..4b6101e56 100644 --- a/src/TypeWhisper.Linux/Cli/Commands/RecordCommand.cs +++ b/src/TypeWhisper.Linux/Cli/Commands/RecordCommand.cs @@ -19,7 +19,7 @@ public static int Run(string verb) "stop" => JsonControlProtocol.CmdRecordStop, "toggle" => JsonControlProtocol.CmdRecordToggle, "cancel" => JsonControlProtocol.CmdRecordCancel, - _ => null + _ => null, }; if (cmd is null) { diff --git a/src/TypeWhisper.Linux/DiffKindConverters.cs b/src/TypeWhisper.Linux/DiffKindConverters.cs index e1f94fdd5..a9cc38bd3 100644 --- a/src/TypeWhisper.Linux/DiffKindConverters.cs +++ b/src/TypeWhisper.Linux/DiffKindConverters.cs @@ -23,7 +23,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture { DiffKind.Added => s_added, DiffKind.Removed => s_removed, - _ => s_unchanged + _ => s_unchanged, }; public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => @@ -69,7 +69,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture { "Background" => local ? s_localBackground : s_networkBackground, "Border" => local ? s_localBorder : s_networkBorder, - _ => local ? s_localForeground : s_networkForeground + _ => local ? s_localForeground : s_networkForeground, }; } diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs index 2274e1d62..9e5e4c992 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs @@ -117,7 +117,7 @@ private async Task SetPropertyAsync(string property, bool value, Cancellat StatusInterface, property, "b", - value ? "true" : "false" + value ? "true" : "false", ], timeout: s_timeout, ct: ct diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs index c5f5764bd..a5ea5e92f 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -1245,7 +1245,7 @@ private async Task TryStartAsync() // body arg, so Arg0="focused" lets the bus daemon filter to focus changes // for us instead of waking us for every state change session-wide. The // in-handler detail/detail1 checks below stay as defense in depth. - Arg0 = FocusedStateName + Arg0 = FocusedStateName, }, s_readSignal, HandleStateChanged, @@ -1258,7 +1258,7 @@ private async Task TryStartAsync() { Type = MessageType.Signal, Interface = EventObjectInterface, - Member = "TextChanged" + Member = "TextChanged", }, s_readSignal, HandleTextChanged, @@ -1278,7 +1278,7 @@ private async Task TryStartAsync() Sender = "org.freedesktop.DBus", Interface = "org.freedesktop.DBus", Member = "NameOwnerChanged", - Arg0 = RegistryBusName + Arg0 = RegistryBusName, }, s_readNameOwnerChanged, HandleRegistryOwnerChanged, @@ -1688,7 +1688,7 @@ int end "org.freedesktop.DBus.Error.UnknownMethod", "org.freedesktop.DBus.Error.ServiceUnknown", // app's a11y bridge went away "org.freedesktop.DBus.Error.NoReply", // app busy / not responding - "org.freedesktop.DBus.Error.Disconnected" + "org.freedesktop.DBus.Error.Disconnected", ]; // at-spi2-core 2.52 (Ubuntu/Mint) answers a property Get for an interface the element does not diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs index 455d7e012..de8f43899 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs @@ -52,7 +52,7 @@ public sealed partial class AtSpiUrlExtractor "opera", "zen", "zen-browser", - "zen-bin" + "zen-bin", }; private static readonly TimeSpan s_cacheTtl = TimeSpan.FromSeconds(10); @@ -685,7 +685,7 @@ params string[] signatureAndArgs destination, path, @interface, - method + method, }; args.AddRange(signatureAndArgs); @@ -716,7 +716,7 @@ private static bool CheckCommandAvailable(string command, string args) using var p = Process.Start( new ProcessStartInfo(command, args) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, } ); p?.WaitForExit(1000); @@ -737,7 +737,7 @@ private static int RunProcess(string fileName, string args, out string? output) using var p = Process.Start( new ProcessStartInfo(fileName, args) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, } ); if (p is null) @@ -779,7 +779,7 @@ private static int RunProcess(string fileName, IReadOnlyList args, out s { var startInfo = new ProcessStartInfo(fileName) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; foreach (var arg in args) { diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/GnomeWindowCallsProvider.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/GnomeWindowCallsProvider.cs index 52ab2b494..31995219c 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/GnomeWindowCallsProvider.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/GnomeWindowCallsProvider.cs @@ -25,7 +25,7 @@ public sealed class GnomeWindowCallsProvider : IActiveWindowProvider private static readonly (string Path, string Interface)[] s_endpoints = [ ("/org/gnome/Shell/Extensions/Windows", "org.gnome.Shell.Extensions.Windows"), - ("/org/gnome/Shell/Extensions/WindowsExt", "org.gnome.Shell.Extensions.WindowsExt") + ("/org/gnome/Shell/Extensions/WindowsExt", "org.gnome.Shell.Extensions.WindowsExt"), ]; public string Name => "gnome-window-calls"; @@ -162,7 +162,7 @@ public bool IsApplicable() { JsonValueKind.Number => idProp.GetInt64().ToString(), JsonValueKind.String => idProp.GetString(), - _ => null + _ => null, }; } diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/ProviderProcessRunner.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/ProviderProcessRunner.cs index 102d547a3..f853a3a36 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/ProviderProcessRunner.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/ProviderProcessRunner.cs @@ -18,7 +18,7 @@ CancellationToken ct { var psi = new ProcessStartInfo(fileName, args) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; return RunAsync(psi, ct); } @@ -36,7 +36,7 @@ CancellationToken ct { var psi = new ProcessStartInfo(fileName) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; foreach (var a in args) { diff --git a/src/TypeWhisper.Linux/Services/AppVersion.cs b/src/TypeWhisper.Linux/Services/AppVersion.cs index a0830b614..c80a0ef84 100644 --- a/src/TypeWhisper.Linux/Services/AppVersion.cs +++ b/src/TypeWhisper.Linux/Services/AppVersion.cs @@ -107,7 +107,7 @@ private static int CompareIdentifier(string a, string b) // Numeric identifiers rank below alphanumeric (SemVer §11.4). (true, _) => -1, (_, true) => 1, - _ => string.CompareOrdinal(a, b) + _ => string.CompareOrdinal(a, b), }; } diff --git a/src/TypeWhisper.Linux/Services/AudioDuckingService.cs b/src/TypeWhisper.Linux/Services/AudioDuckingService.cs index ba9ace70d..34176ffe4 100644 --- a/src/TypeWhisper.Linux/Services/AudioDuckingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioDuckingService.cs @@ -162,7 +162,7 @@ private ProcessRunResult SetSinkInputVolume(string inputId, string[] volumes) var arguments = new List(2 + volumes.Length) { "set-sink-input-volume", - inputId + inputId, }; arguments.AddRange(volumes); return RunPactl(arguments); diff --git a/src/TypeWhisper.Linux/Services/AudioFileService.cs b/src/TypeWhisper.Linux/Services/AudioFileService.cs index 53c3eb3df..346c96d09 100644 --- a/src/TypeWhisper.Linux/Services/AudioFileService.cs +++ b/src/TypeWhisper.Linux/Services/AudioFileService.cs @@ -18,7 +18,7 @@ public sealed class AudioFileService ".mkv", ".avi", ".mov", - ".webm" + ".webm", }; private readonly SystemCommandAvailabilityService _commands; @@ -65,7 +65,7 @@ public async Task LoadAudioAsWavAsync( $"-v error -i \"{filePath}\" -vn -ac 1 -ar 16000 -f wav pipe:1" ) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; process.Start(); diff --git a/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs b/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs index 902ff1b13..5790711ce 100644 --- a/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs +++ b/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs @@ -92,7 +92,7 @@ public void Play(string audioFileName) channelCount = Channels, sampleFormat = SampleFormat.Float32, suggestedLatency = outputInfo.defaultLowOutputLatency, - hostApiSpecificStreamInfo = IntPtr.Zero + hostApiSpecificStreamInfo = IntPtr.Zero, }; _stream = new PaStream( diff --git a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs index 30dec1d2b..1be5f896e 100644 --- a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs @@ -938,7 +938,7 @@ PaStream.Callback callback channelCount = Channels, sampleFormat = SampleFormat.Float32, suggestedLatency = inputInfo.defaultLowInputLatency, - hostApiSpecificStreamInfo = IntPtr.Zero + hostApiSpecificStreamInfo = IntPtr.Zero, }; return new PaStream( diff --git a/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs b/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs index d57556476..9aaf96693 100644 --- a/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs @@ -49,7 +49,7 @@ public sealed partial class BrowserAccessibilitySetupHelper "microsoft-edge.desktop", "brave-browser.desktop", "vivaldi-stable.desktop", - "opera.desktop" + "opera.desktop", ]; private static readonly string[] s_firefoxLauncherNames = @@ -61,13 +61,13 @@ public sealed partial class BrowserAccessibilitySetupHelper "io.gitlab.librewolf-community.desktop", "zen.desktop", "app.zen_browser.zen.desktop", - "io.github.zen_browser.zen.desktop" + "io.github.zen_browser.zen.desktop", ]; private static readonly string[] s_systemLauncherDirectories = [ "/usr/share/applications", - "/var/lib/flatpak/exports/share/applications" + "/var/lib/flatpak/exports/share/applications", ]; /// @@ -561,7 +561,7 @@ private static IEnumerable EnumerateFirefoxProfileDirs() Path.Join(home, ".var", "app", "app.zen_browser.zen", ".zen"), Path.Join(home, ".var", "app", "io.github.zen_browser.zen", ".zen"), Path.Join(home, ".zen"), Path.Join(home, ".var", "app", "io.gitlab.librewolf-community", ".librewolf"), - Path.Join(home, ".librewolf") + Path.Join(home, ".librewolf"), }; foreach (var root in roots) { diff --git a/src/TypeWhisper.Linux/Services/DictationToggleGate.cs b/src/TypeWhisper.Linux/Services/DictationToggleGate.cs index 68ba4474e..72723c54b 100644 --- a/src/TypeWhisper.Linux/Services/DictationToggleGate.cs +++ b/src/TypeWhisper.Linux/Services/DictationToggleGate.cs @@ -4,7 +4,7 @@ internal enum DictationStopGateResult { Acquired, PendingStartupCompletion, - Busy + Busy, } /// diff --git a/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs b/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs index 8e94938b9..6d55c8ba4 100644 --- a/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs +++ b/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs @@ -119,7 +119,7 @@ CancellationToken cancellationToken segment.Start, segment.End )) - .ToArray() + .ToArray(), }; var pipelineResult = await pipeline.ProcessAsync( @@ -129,7 +129,7 @@ CancellationToken cancellationToken VocabularyBooster = currentSettings.VocabularyBoostingEnabled ? vocabularyBoosting.Apply : null, - DictionaryCorrector = dictionary.ApplyCorrections + DictionaryCorrector = dictionary.ApplyCorrections, }, cancellationToken ); @@ -206,7 +206,7 @@ CancellationToken cancellationToken $"Ambiguous transcription model '{options.ModelId}': provided by multiple engines. " + "Specify the engine explicitly or use the full plugin-qualified model id." ), - _ => ModelManagerService.GetPluginModelId(matches[0].GetTranscriptionSelectionId(), options.ModelId) + _ => ModelManagerService.GetPluginModelId(matches[0].GetTranscriptionSelectionId(), options.ModelId), }; } } diff --git a/src/TypeWhisper.Linux/Services/GnomeWindowCallsSetupHelper.cs b/src/TypeWhisper.Linux/Services/GnomeWindowCallsSetupHelper.cs index 7d336178b..71db5b47e 100644 --- a/src/TypeWhisper.Linux/Services/GnomeWindowCallsSetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/GnomeWindowCallsSetupHelper.cs @@ -20,7 +20,7 @@ public sealed class GnomeWindowCallsSetupHelper private static readonly (string Path, string Interface)[] s_endpoints = [ ("/org/gnome/Shell/Extensions/Windows", "org.gnome.Shell.Extensions.Windows"), - ("/org/gnome/Shell/Extensions/WindowsExt", "org.gnome.Shell.Extensions.WindowsExt") + ("/org/gnome/Shell/Extensions/WindowsExt", "org.gnome.Shell.Extensions.WindowsExt"), ]; // kept instance: injected as a DI/test seam by callers @@ -104,7 +104,7 @@ public bool TryOpenInstallPage() using var p = Process.Start( new ProcessStartInfo("xdg-open", ExtensionInstallUrl) { - UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true + UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, } ); return p is not null; diff --git a/src/TypeWhisper.Linux/Services/HistoryRetentionCoordinator.cs b/src/TypeWhisper.Linux/Services/HistoryRetentionCoordinator.cs index 57110ef2f..b1baee6f3 100644 --- a/src/TypeWhisper.Linux/Services/HistoryRetentionCoordinator.cs +++ b/src/TypeWhisper.Linux/Services/HistoryRetentionCoordinator.cs @@ -117,6 +117,6 @@ private enum HistoryRetentionTrigger Startup, SettingsChanged, HistoryChanged, - Shutdown + Shutdown, } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DesktopDetector.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DesktopDetector.cs index e3b101739..686406ad8 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DesktopDetector.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DesktopDetector.cs @@ -98,7 +98,7 @@ public static string DisplayName(string? id = null) "kde" => "KDE Plasma", "hyprland" => "Hyprland", "sway" => "Sway", - _ => RawXdgFallback() + _ => RawXdgFallback(), }; } @@ -176,7 +176,7 @@ private static string RawXdgFallback() "Pantheon" => "Pantheon", "Budgie" => "Budgie", "Deepin" => "Deepin", - _ => tokens[^1] + _ => tokens[^1], }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs index 9bebaa44d..b50541fb0 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs @@ -47,7 +47,7 @@ public static class DictationShortcutSpecFactory cancelTrigger, cancelTrigger is null ? null : $"{gui} record cancel" ), - _ => null + _ => null, }; } diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs index c53057f57..e44ae11a0 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs @@ -113,7 +113,7 @@ public async Task WriteAsync(DeShortcutSpec spec, Cancell var (key, value) in new[] { ("name", spec.DisplayName), ("command", spec.OnPressCommand), - ("binding", FormatGnomeAccel(spec.Trigger)) + ("binding", FormatGnomeAccel(spec.Trigger)), } ) { @@ -475,7 +475,7 @@ public static string FormatGnomeAccel(string trigger) "shift" => "Shift", "alt" => "Alt", "super" or "win" or "windows" or "cmd" or "meta" => "Super", - _ => null + _ => null, }; if (modifier is null) { diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs index 774d3d393..52910b8ed 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs @@ -35,7 +35,7 @@ public sealed class InputAccessSetupHelper private static readonly string[] s_seatManagerDirectoryPaths = [ "/run/systemd/seats", - "/run/elogind/seats" + "/run/elogind/seats", ]; // System config dir holding the udev rule. Always /etc in production. Tests diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LinuxKeyMap.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LinuxKeyMap.cs index bcd179aef..5fcae929f 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LinuxKeyMap.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LinuxKeyMap.cs @@ -33,7 +33,7 @@ public static ModifierMask ToModifier(int linuxCode) KeyRightalt => ModifierMask.RightAlt, KeyLeftmeta => ModifierMask.LeftMeta, KeyRightmeta => ModifierMask.RightMeta, - _ => ModifierMask.None + _ => ModifierMask.None, }; } @@ -141,7 +141,7 @@ public static bool IsModifier(int linuxCode) KeyLeftmeta => KeyCode.VcLeftMeta, KeyRightmeta => KeyCode.VcRightMeta, - _ => null + _ => null, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LogindSessionActivityMonitor.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LogindSessionActivityMonitor.cs index 09bd8c60b..2749c4230 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LogindSessionActivityMonitor.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LogindSessionActivityMonitor.cs @@ -195,7 +195,7 @@ private static bool IndicatesLogindAbsent(Exception ex) dbus.ErrorName is "org.freedesktop.DBus.Error.ServiceUnknown" or "org.freedesktop.DBus.Error.NameHasNoOwner" or "org.freedesktop.DBus.Error.FileNotFound", - _ => false + _ => false, }; } @@ -332,7 +332,7 @@ string sessionPath Interface = PropertiesInterface, Path = sessionPath, Member = "PropertiesChanged", - Arg0 = SessionInterface + Arg0 = SessionInterface, }, s_readPropertiesChanged, HandlePropertiesChanged, @@ -355,7 +355,7 @@ bool locked Sender = LoginService, Interface = SessionInterface, Path = sessionPath, - Member = member + Member = member, }, locked ? s_readLockSignal : s_readUnlockSignal, locked ? HandleLockSignal : HandleUnlockSignal, diff --git a/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs b/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs index 32675a727..2eadcdd83 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs @@ -203,7 +203,7 @@ private static ModifierMask NormalizeMask(KeyCode key, ModifierMask mask) KeyCode.VcRightAlt => ModifierMask.RightAlt, KeyCode.VcLeftMeta => ModifierMask.LeftMeta, KeyCode.VcRightMeta => ModifierMask.RightMeta, - _ => ModifierMask.None + _ => ModifierMask.None, }; return modBit == ModifierMask.None ? mask : mask & ~modBit; } diff --git a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs index 3a2891957..ce90208ad 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs @@ -306,7 +306,7 @@ private void HandleRelease(KeyCode key, ModifierMask mods, GlobalShortcutSet set { _pendingSelectionWorkflows[key] = releasedWorkflow with { - TriggerReleased = true + TriggerReleased = true, }; } @@ -543,7 +543,7 @@ private enum SelectionWorkflowKind PromptPalette, PromptAction, ProfileTextProcessing, - TransformSelection + TransformSelection, } private readonly record struct PendingSelectionWorkflow( diff --git a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutMatcher.cs b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutMatcher.cs index c0305a54f..c5dcaa3c2 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutMatcher.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutMatcher.cs @@ -16,7 +16,7 @@ internal enum ShortcutMatchKind TransformSelection, Cancel, PromptAction, - Profile + Profile, } /// diff --git a/src/TypeWhisper.Linux/Services/HotkeyService.cs b/src/TypeWhisper.Linux/Services/HotkeyService.cs index 28f091564..07c3f3175 100644 --- a/src/TypeWhisper.Linux/Services/HotkeyService.cs +++ b/src/TypeWhisper.Linux/Services/HotkeyService.cs @@ -12,7 +12,7 @@ public enum HotkeyCandidateValidationStatus CollidesWithFixedBinding, CollidesWithPromptAction, CollidesWithProfile, - MissingEnabledPromptAction + MissingEnabledPromptAction, } public sealed record HotkeyCandidateValidationResult( @@ -552,7 +552,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithFixedBinding, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -567,7 +567,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithPromptAction, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -581,7 +581,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithProfile, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -623,7 +623,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.MissingEnabledPromptAction, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -637,7 +637,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithFixedBinding, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -651,7 +651,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithPromptAction, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -666,7 +666,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithProfile, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -1213,7 +1213,7 @@ KeyCode.VcLeftAlt or KeyCode.VcRightAlt => ModifierMask.LeftAlt | ModifierMask.RightAlt, KeyCode.VcLeftMeta or KeyCode.VcRightMeta => ModifierMask.LeftMeta | ModifierMask.RightMeta, - _ => ModifierMask.None + _ => ModifierMask.None, }; } @@ -1296,7 +1296,7 @@ private static string FormatHotkey(KeyCode key, ModifierMask mods) KeyCode.VcRightAlt => "Right Alt", KeyCode.VcLeftMeta => "Left Meta", KeyCode.VcRightMeta => "Right Meta", - _ => null + _ => null, }; if (sideSpecific is not null) { @@ -1419,7 +1419,7 @@ private static bool TryParseHotkey(string text, out KeyCode? key, out ModifierMa "right" => KeyCode.VcRight, "up" => KeyCode.VcUp, "down" => KeyCode.VcDown, - _ => (KeyCode?)null + _ => (KeyCode?)null, }; if (named is not null) { @@ -1467,7 +1467,7 @@ private static bool TryParseSideSpecificSingleModifier(string token, out KeyCode "right alt" => KeyCode.VcRightAlt, "left meta" or "left super" or "left win" => KeyCode.VcLeftMeta, "right meta" or "right super" or "right win" => KeyCode.VcRightMeta, - _ => KeyCode.VcUndefined + _ => KeyCode.VcUndefined, }; return key != KeyCode.VcUndefined; } @@ -1478,6 +1478,6 @@ private enum HotkeyBinding PromptPalette, RecentTranscriptions, CopyLastTranscription, - TransformSelection + TransformSelection, } } diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs index e3bac0300..2626900d5 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs @@ -576,7 +576,7 @@ await writer JsonControlProtocol.CmdRecordCancel => await HandleCancelAsync() .ConfigureAwait(false), JsonControlProtocol.CmdStatus => HandleStatus(), - _ => JsonControlProtocol.SerializeError(JsonControlProtocol.ErrUnknownCommand) + _ => JsonControlProtocol.SerializeError(JsonControlProtocol.ErrUnknownCommand), }; await writer.WriteLineAsync(response).ConfigureAwait(false); @@ -684,7 +684,7 @@ private string HandleStatus() Backend = _hotkey?.ActiveBackendId, SupportsPressRelease = _hotkey?.ActiveBackendSupportsPressRelease ?? false, ActiveBinding = _hotkey?.CurrentHotkeyString, - Mode = _settings?.Current.Mode.ToString() + Mode = _settings?.Current.Mode.ToString(), }; return JsonControlProtocol.SerializeStatus(response); } diff --git a/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs b/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs index eb6e272a0..1aa1f471c 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs @@ -54,7 +54,7 @@ internal static class JsonControlProtocol // the documented response shape (camelCase would not match the spec). PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false + WriteIndented = false, }; public static string SerializeError(string code) diff --git a/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs b/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs index 5809463d0..2e3081511 100644 --- a/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs +++ b/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs @@ -534,7 +534,7 @@ private async Task EnsureConnectedAsync() Sender = NotificationsService, Interface = NotificationsInterface, Path = NotificationsPath, - Member = "ActionInvoked" + Member = "ActionInvoked", }, s_readActionInvoked, HandleActionInvoked, @@ -549,7 +549,7 @@ private async Task EnsureConnectedAsync() Sender = NotificationsService, Interface = NotificationsInterface, Path = NotificationsPath, - Member = "NotificationClosed" + Member = "NotificationClosed", }, s_readClosed, HandleClosed, diff --git a/src/TypeWhisper.Linux/Services/LinuxDictationReadbackLanguagePolicy.cs b/src/TypeWhisper.Linux/Services/LinuxDictationReadbackLanguagePolicy.cs index 2cd797ad1..3fa4b3689 100644 --- a/src/TypeWhisper.Linux/Services/LinuxDictationReadbackLanguagePolicy.cs +++ b/src/TypeWhisper.Linux/Services/LinuxDictationReadbackLanguagePolicy.cs @@ -61,7 +61,7 @@ target is not null { FinalLanguage.TranslatedToTarget => target, FinalLanguage.Rewritten => null, - _ => engineTranslatedToEnglish ? "en" : sourceLanguage + _ => engineTranslatedToEnglish ? "en" : sourceLanguage, }; } @@ -103,6 +103,6 @@ private enum FinalLanguage { Unchanged, // No post-processing step changed the language. TranslatedToTarget, // Translation step ran and changed the language. - Rewritten // Prompt/plugin rewrote into an unknown language. + Rewritten, // Prompt/plugin rewrote into an unknown language. } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/LinuxDictationShortSpeechPolicy.cs b/src/TypeWhisper.Linux/Services/LinuxDictationShortSpeechPolicy.cs index 207523e7c..97a145c21 100644 --- a/src/TypeWhisper.Linux/Services/LinuxDictationShortSpeechPolicy.cs +++ b/src/TypeWhisper.Linux/Services/LinuxDictationShortSpeechPolicy.cs @@ -4,7 +4,7 @@ internal enum LinuxShortSpeechDecision { DiscardTooShort, DiscardNoSpeech, - Transcribe + Transcribe, } internal static class LinuxDictationShortSpeechPolicy diff --git a/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs b/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs index e3e1df619..9a7810966 100644 --- a/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs +++ b/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs @@ -7,7 +7,7 @@ internal enum LiveTranscriptionMode { None, Polling, - Streaming + Streaming, } // Selects the live-transcription mode for the recording loop. Ported from diff --git a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs index 901c7e8ed..fca292fa4 100644 --- a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs +++ b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs @@ -152,7 +152,7 @@ private static IReadOnlyList BuildArguments( { "espeak" or "espeak-ng" => ["-v", language, text], "spd-say" => ["--wait", "-l", language, text], - _ => BuildDefaultArguments(command, text) + _ => BuildDefaultArguments(command, text), }; } diff --git a/src/TypeWhisper.Linux/Services/Localization/Loc.cs b/src/TypeWhisper.Linux/Services/Localization/Loc.cs index 91d0d503e..cb1a4089f 100644 --- a/src/TypeWhisper.Linux/Services/Localization/Loc.cs +++ b/src/TypeWhisper.Linux/Services/Localization/Loc.cs @@ -25,7 +25,7 @@ public sealed class Loc : INotifyPropertyChanged private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary> _strings = []; @@ -193,7 +193,7 @@ private static List BuildUiLanguageOptions(List codes) ["ru"] = "Русский", ["ja"] = "日本語", ["zh"] = "中文", - ["ko"] = "한국어" + ["ko"] = "한국어", }; var options = new List { new(null, "Auto (System)") }; diff --git a/src/TypeWhisper.Linux/Services/MemoryService.cs b/src/TypeWhisper.Linux/Services/MemoryService.cs index c90db2876..a26d6f980 100644 --- a/src/TypeWhisper.Linux/Services/MemoryService.cs +++ b/src/TypeWhisper.Linux/Services/MemoryService.cs @@ -138,7 +138,7 @@ string userPrompt ProviderId = providerId, ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = null + InjectedMemoryContext = null, }; capture.Add(provenance); return provenance; diff --git a/src/TypeWhisper.Linux/Services/ModelManagerService.cs b/src/TypeWhisper.Linux/Services/ModelManagerService.cs index c339732b2..aa655bdd7 100644 --- a/src/TypeWhisper.Linux/Services/ModelManagerService.cs +++ b/src/TypeWhisper.Linux/Services/ModelManagerService.cs @@ -581,7 +581,7 @@ public void MigrateSettings() ), "plugin:com.typewhisper.voxtral:mistral-whisper" => GetPluginModelId("com.typewhisper.voxtral", "voxtral-mini-latest"), - _ => modelId + _ => modelId, }; } @@ -596,7 +596,7 @@ public void MigrateSettings() { "plugin:com.typewhisper.voxtral:mistral-whisper" => GetPluginModelId("com.typewhisper.voxtral", "voxtral-mini-latest"), - _ => modelId + _ => modelId, }; } @@ -608,7 +608,7 @@ private static TranscriptionAccelerationPreference GetAccelerationPreference(str AppSettings.LocalModelAccelerationNvidiaCuda => TranscriptionAccelerationPreference.NvidiaCuda, AppSettings.LocalModelAccelerationCpu => TranscriptionAccelerationPreference.Cpu, - _ => TranscriptionAccelerationPreference.Auto + _ => TranscriptionAccelerationPreference.Auto, }; } @@ -1111,7 +1111,7 @@ public async Task TranscribeAsync( Text = result.Text, DetectedLanguage = result.DetectedLanguage, Duration = result.DurationSeconds, - NoSpeechProbability = result.NoSpeechProbability + NoSpeechProbability = result.NoSpeechProbability, }; } } diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs index 1e7972e5a..b4b916d6e 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs @@ -24,7 +24,7 @@ public static class PluginLocalityClassifier "com.typewhisper.file-memory", "com.typewhisper.obsidian", "com.typewhisper.script", - "com.typewhisper.webhook" + "com.typewhisper.webhook", ]; public static bool IsLocal(PluginManifest manifest) => diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs index f061063cf..32581c1ae 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs @@ -19,7 +19,7 @@ public sealed class PluginManager : IDisposable private static readonly HashSet s_defaultEnabledPluginIds = new(StringComparer.Ordinal) { "com.typewhisper.whisper-cpp", // offline transcription (recommended default) - "com.typewhisper.sherpa-onnx" // offline transcription + "com.typewhisper.sherpa-onnx", // offline transcription }; private readonly HashSet _activatedPlugins = []; @@ -579,8 +579,8 @@ private static string ResolveErrorCategory(LoadedPlugin plugin) { ITranscriptionEnginePlugin => ErrorCategory.Transcription, ILlmProviderPlugin => ErrorCategory.Prompt, - _ => ErrorCategory.Plugin - } + _ => ErrorCategory.Plugin, + }, }; } @@ -757,7 +757,7 @@ private async Task MigrateApiKeysAsync() current with { GroqApiKey = migratedGroq ? "" : current.GroqApiKey, - OpenAiApiKey = migratedOpenAi ? "" : current.OpenAiApiKey + OpenAiApiKey = migratedOpenAi ? "" : current.OpenAiApiKey, } ); } diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs index 2828d1fb7..c04749141 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs @@ -48,7 +48,7 @@ public sealed class PluginRegistryService "com.typewhisper.qwen3-stt", "com.typewhisper.obsidian", "com.typewhisper.linear", - "com.typewhisper.openai-compatible" + "com.typewhisper.openai-compatible", }; private readonly HttpClient _httpClient; diff --git a/src/TypeWhisper.Linux/Services/Plugins/RegistryPlugin.cs b/src/TypeWhisper.Linux/Services/Plugins/RegistryPlugin.cs index 60a3146f3..4e0b384eb 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/RegistryPlugin.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/RegistryPlugin.cs @@ -35,5 +35,5 @@ public enum PluginInstallState UpdateAvailable, // ReSharper disable once UnusedMember.Global member of the JsonStringEnumConverter-serialized install-state vocabulary (PluginInstallState); kept for completeness, not currently produced in-tree - Bundled + Bundled, } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/ProcessPriority.cs b/src/TypeWhisper.Linux/Services/ProcessPriority.cs index 9e5e2cfaa..135ebd611 100644 --- a/src/TypeWhisper.Linux/Services/ProcessPriority.cs +++ b/src/TypeWhisper.Linux/Services/ProcessPriority.cs @@ -21,7 +21,7 @@ public static string ResetToDefaults() var results = new List { Run("renice", $"-n 0 -p {pid}"), - Run("ionice", $"-c 2 -n 4 -p {pid}") + Run("ionice", $"-c 2 -n 4 -p {pid}"), }; return string.Join("; ", results); @@ -39,7 +39,7 @@ private static string Run(string file, string args) RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); if (p is null) diff --git a/src/TypeWhisper.Linux/Services/ProcessRunner.cs b/src/TypeWhisper.Linux/Services/ProcessRunner.cs index af913a477..b91a12f83 100644 --- a/src/TypeWhisper.Linux/Services/ProcessRunner.cs +++ b/src/TypeWhisper.Linux/Services/ProcessRunner.cs @@ -94,7 +94,7 @@ public async Task RunAsync( RedirectStandardError = true, RedirectStandardInput = standardInput is not null, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, }; foreach (var arg in args) { diff --git a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs index 30c971c98..9594a36a5 100644 --- a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs +++ b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs @@ -228,7 +228,7 @@ public async Task ProcessSystemPromptAsync( ProviderId = providerId, ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = injectedMemoryContext + InjectedMemoryContext = injectedMemoryContext, }; capture.Add(provenance); return provenance; diff --git a/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs b/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs index 60273b89f..ce01f7e42 100644 --- a/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs +++ b/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs @@ -103,7 +103,7 @@ public static string BodyFor(RecordingMode mode) { RecordingMode.Toggle => Loc.Instance["Notify.BodyToggle"], RecordingMode.PushToTalk => Loc.Instance["Notify.BodyPushToTalk"], - _ => Loc.Instance["Notify.BodyHybrid"] + _ => Loc.Instance["Notify.BodyHybrid"], }; } @@ -281,7 +281,7 @@ uint replaceId "TypeWhisper", replaceId.ToString(), ResolveIconPath(), presentation.Summary, presentation.Body, "[]", // actions "{}", // hints - presentation.ExpireTimeout.ToString() + presentation.ExpireTimeout.ToString(), ], timeout: s_callTimeout ) @@ -317,7 +317,7 @@ await _runner [ "call", "--session", "--dest", "org.freedesktop.Notifications", "--object-path", "/org/freedesktop/Notifications", "--method", - "org.freedesktop.Notifications.CloseNotification", id.ToString() + "org.freedesktop.Notifications.CloseNotification", id.ToString(), ], timeout: s_callTimeout ) diff --git a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs index 5a28bb6db..e982803cb 100644 --- a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs +++ b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs @@ -15,7 +15,7 @@ internal enum StartupRestoreStatus Applied, PriorGenerationRestored, LockUnavailable, - UnresolvedFailure + UnresolvedFailure, } internal sealed record StartupRestoreResult( @@ -60,7 +60,7 @@ public sealed class SettingsBackupService [ "settings.json", "settings.json.bak", - "linux-preferences.json" + "linux-preferences.json", ]; private static readonly string[] s_backupDirectoryRoots = ["Data", "PluginData"]; @@ -78,7 +78,7 @@ public sealed class SettingsBackupService private static readonly JsonSerializerOptions s_transactionJsonOptions = new() { WriteIndented = true, - Converters = { new JsonStringEnumConverter() } + Converters = { new JsonStringEnumConverter() }, }; private readonly string _basePath; @@ -133,7 +133,7 @@ public SettingsBackupResult CreateBackup(string destinationZipPath) kind = ManifestKind, createdUtc = DateTimeOffset.UtcNow, includes = s_manifestIncludes, - excludes = s_manifestExcludes + excludes = s_manifestExcludes, }; var manifestEntry = archive.CreateEntry(ManifestEntryName, CompressionLevel.Optimal); using (var writer = new StreamWriter(manifestEntry.Open())) @@ -254,7 +254,7 @@ public SettingsBackupResult StageRestore(string sourceZipPath) { Version = PendingStateVersion, FileCount = fileCount, - UncompressedBytes = bytes + UncompressedBytes = bytes, } ); @@ -369,7 +369,7 @@ private StartupRestoreResult ApplyPendingRestoreUnderLock() ), RestoreJournalPhase.Committed => FinishCommittedTransaction(), RestoreJournalPhase.RolledBack => FinishRolledBackTransaction(), - _ => throw new InvalidDataException("The settings restore journal phase is invalid.") + _ => throw new InvalidDataException("The settings restore journal phase is invalid."), }; } @@ -379,7 +379,7 @@ private StartupRestoreResult ApplyPendingRestoreUnderLock() .Select(relativePath => new RestoreJournalItem { RelativePath = relativePath, - OriginallyExisted = File.Exists(GetLiveTargetPath(relativePath)) + OriginallyExisted = File.Exists(GetLiveTargetPath(relativePath)), }) .ToArray(); @@ -397,7 +397,7 @@ private StartupRestoreResult ApplyPendingRestoreUnderLock() { Version = JournalVersion, Phase = RestoreJournalPhase.Prepared, - Items = items + Items = items, }; try @@ -563,7 +563,7 @@ private void MarkUncommittedRequestRolledBackBestEffort(RestoreJournalItem[] ite { Version = JournalVersion, Phase = RestoreJournalPhase.RolledBack, - Items = items + Items = items, } ); TryCleanupPendingDirectory(); @@ -683,7 +683,7 @@ RestoreJournalPhase phase { Version = journal.Version, Phase = phase, - Items = journal.Items + Items = journal.Items, }; } @@ -1005,7 +1005,7 @@ private enum RestoreJournalPhase { Prepared, Committed, - RolledBack + RolledBack, } private sealed class PendingState diff --git a/src/TypeWhisper.Linux/Services/Setup/ISetupTask.cs b/src/TypeWhisper.Linux/Services/Setup/ISetupTask.cs index 59f564cbb..2303f4389 100644 --- a/src/TypeWhisper.Linux/Services/Setup/ISetupTask.cs +++ b/src/TypeWhisper.Linux/Services/Setup/ISetupTask.cs @@ -7,7 +7,7 @@ namespace TypeWhisper.Linux.Services.Setup; public enum SetupTaskSeverity { Required, - Recommended + Recommended, } /// @@ -25,7 +25,7 @@ public enum SetupTaskStatusKind Working, /// The last action failed; the user can retry or fall back to the manual command. - Failed + Failed, } /// diff --git a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandIntent.cs b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandIntent.cs index c05ec9342..0b178e1a4 100644 --- a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandIntent.cs +++ b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandIntent.cs @@ -17,7 +17,7 @@ public static class SpokenCommandIntent private static readonly HashSet s_selectionReferents = new(StringComparer.OrdinalIgnoreCase) { - "this", "that", "it", "these", "those", "them", "selection", "highlighted", "selected" + "this", "that", "it", "these", "those", "them", "selection", "highlighted", "selected", }; private static readonly string[] s_selectionPhrases = @@ -32,7 +32,7 @@ public static class SpokenCommandIntent "translate", "shorten", "lengthen", "summarize", "summarise", "rewrite", "rephrase", "reword", "reformat", "format", "fix", "correct", "proofread", "simplify", "condense", "expand", "capitalize", "capitalise", "uppercase", "lowercase", "bold", "italicize", "italicise", - "punctuate" + "punctuate", }; // A command that opens with one of these asks for new text from scratch ("write an email", @@ -42,7 +42,7 @@ public static class SpokenCommandIntent // demoting those to create would hijack a legitimate invocation of that saved action. private static readonly HashSet s_leadingCreationVerbs = new(StringComparer.OrdinalIgnoreCase) { - "write", "draft", "compose", "create", "generate" + "write", "draft", "compose", "create", "generate", }; public static bool RefersToSelection(string command) diff --git a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandKeyphrase.cs b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandKeyphrase.cs index 909f9886c..ece602578 100644 --- a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandKeyphrase.cs +++ b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandKeyphrase.cs @@ -45,7 +45,7 @@ public static bool TryStrip(string rawText, string keyphrase, out string command { <= 3 => 0, <= 6 => 1, - _ => 2 + _ => 2, }; var tokens = Tokenize(rawText); diff --git a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandText.cs b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandText.cs index 0fa240ab6..bea7290bc 100644 --- a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandText.cs +++ b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandText.cs @@ -15,7 +15,7 @@ internal static class SpokenCommandText public static readonly IReadOnlySet LeadingFillers = new HashSet(StringComparer.OrdinalIgnoreCase) { - "please", "pls", "kindly", "just", "can", "could", "would", "you" + "please", "pls", "kindly", "just", "can", "could", "would", "you", }; // Splits on whitespace and keeps only alphanumerics per token, dropping empties. Casing is diff --git a/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs b/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs index 4fa46061a..1425b153f 100644 --- a/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs +++ b/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs @@ -195,7 +195,7 @@ public async Task StartAsync(CancellationToken ct) var channel = Channel.CreateBounded(new BoundedChannelOptions(ChannelCapacity) { - FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false + FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false, }); var handler = OnTranscriptReceived; diff --git a/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs index 73794a4a2..292cb0303 100644 --- a/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs +++ b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs @@ -37,12 +37,12 @@ public sealed partial class SystemCommandAvailabilityService "/usr/local/cuda-12.1/lib64", "/usr/local/cuda-12.1/targets/x86_64-linux/lib", "/usr/local/cuda-12.0/lib64", - "/usr/local/cuda-12.0/targets/x86_64-linux/lib" + "/usr/local/cuda-12.0/targets/x86_64-linux/lib", ]; private static readonly string[] s_requiredCuda12RuntimeLibraries = [ "libcudart.so.12", - "libcublas.so.12" + "libcublas.so.12", ]; private static readonly Lock s_cudaPreloadLock = new(); @@ -349,7 +349,7 @@ public async Task RunCudaBenchmarkAsync( RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); @@ -598,7 +598,7 @@ private static LinuxCapabilitySnapshot BuildSnapshot() RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); if (p is null) @@ -721,7 +721,7 @@ private static bool FindInLdCache(string libraryName) RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); diff --git a/src/TypeWhisper.Linux/Services/TranslationService.cs b/src/TypeWhisper.Linux/Services/TranslationService.cs index c534cdf66..191731652 100644 --- a/src/TypeWhisper.Linux/Services/TranslationService.cs +++ b/src/TypeWhisper.Linux/Services/TranslationService.cs @@ -113,7 +113,7 @@ string userPrompt ProviderId = providerId, ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = null + InjectedMemoryContext = null, }; capture.Add(provenance); return provenance; @@ -263,7 +263,7 @@ private static LoadedTranslationModel LoadModel(string modelDir) { GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL, InterOpNumThreads = 1, - IntraOpNumThreads = Environment.ProcessorCount + IntraOpNumThreads = Environment.ProcessorCount, }; var encoder = new InferenceSession( @@ -299,7 +299,7 @@ private static string RunInference(LoadedTranslationModel model, string text) using var encoderResults = model.Encoder.Run([ NamedOnnxValue.CreateFromTensor("input_ids", inputIdsTensor), - NamedOnnxValue.CreateFromTensor("attention_mask", attentionMask) + NamedOnnxValue.CreateFromTensor("attention_mask", attentionMask), ]); var encoderHidden = @@ -321,7 +321,7 @@ encoderResults[0].Value as DenseTensor { NamedOnnxValue.CreateFromTensor("input_ids", decoderInputIds), NamedOnnxValue.CreateFromTensor("encoder_attention_mask", attentionMask), - NamedOnnxValue.CreateFromTensor("encoder_hidden_states", encoderHidden) + NamedOnnxValue.CreateFromTensor("encoder_hidden_states", encoderHidden), }; using var decoderResults = model.Decoder.Run(decoderInputs); @@ -383,7 +383,7 @@ private static void RegisterOnnxRuntimeResolver() var rid = RuntimeInformation.ProcessArchitecture switch { Architecture.Arm64 => "linux-arm64", - _ => "linux-x64" + _ => "linux-x64", }; var candidate = Path.Join( diff --git a/src/TypeWhisper.Linux/Services/TrayIconService.cs b/src/TypeWhisper.Linux/Services/TrayIconService.cs index 5acab0070..a04e53725 100644 --- a/src/TypeWhisper.Linux/Services/TrayIconService.cs +++ b/src/TypeWhisper.Linux/Services/TrayIconService.cs @@ -58,7 +58,7 @@ public void Initialize() { _trayIcon = new TrayIcon { - ToolTipText = "TypeWhisper", IsVisible = true, Menu = BuildMenu(), Icon = LoadIcon() + ToolTipText = "TypeWhisper", IsVisible = true, Menu = BuildMenu(), Icon = LoadIcon(), }; _trayIcon.Clicked += (_, _) => ShowSettingsRequested?.Invoke(this, EventArgs.Empty); @@ -110,7 +110,7 @@ internal bool ProbeTrayAvailable() "--method", "org.freedesktop.DBus.Properties.Get", "org.kde.StatusNotifierWatcher", - "IsStatusNotifierHostRegistered" + "IsStatusNotifierHostRegistered", ], timeout: TimeSpan.FromSeconds(2) ) diff --git a/src/TypeWhisper.Linux/Services/UpdateCheckService.cs b/src/TypeWhisper.Linux/Services/UpdateCheckService.cs index 06e932f76..d030c73dc 100644 --- a/src/TypeWhisper.Linux/Services/UpdateCheckService.cs +++ b/src/TypeWhisper.Linux/Services/UpdateCheckService.cs @@ -101,7 +101,7 @@ public async Task CheckOnStartupAsync(CancellationToken cancellationToken = defa LatestVersion = known, ReleaseUrl = string.IsNullOrWhiteSpace(_prefs.Current.LastKnownLatestUrl) ? ReleasesPage - : _prefs.Current.LastKnownLatestUrl + : _prefs.Current.LastKnownLatestUrl, } ); } @@ -140,7 +140,7 @@ public async Task CheckAsync(CancellationToken cancellationTo Checked = true, Faulted = true, CurrentVersion = current, - Error = "No published release was found." + Error = "No published release was found.", }; } else @@ -151,7 +151,7 @@ public async Task CheckAsync(CancellationToken cancellationTo UpdateAvailable = AppVersion.Compare(current, latest) < 0, CurrentVersion = current, LatestVersion = latest, - ReleaseUrl = string.IsNullOrWhiteSpace(latestUrl) ? ReleasesPage : latestUrl + ReleaseUrl = string.IsNullOrWhiteSpace(latestUrl) ? ReleasesPage : latestUrl, }; } } @@ -166,7 +166,7 @@ public async Task CheckAsync(CancellationToken cancellationTo Debug.WriteLine($"[UpdateCheckService] Check failed: {ex.Message}"); result = new UpdateCheckResult { - Checked = true, Faulted = true, CurrentVersion = current, Error = ex.Message + Checked = true, Faulted = true, CurrentVersion = current, Error = ex.Message, }; } @@ -180,7 +180,7 @@ preferences with { LastUpdateCheckUtc = DateTime.UtcNow, LastKnownLatestVersion = result.LatestVersion, - LastKnownLatestUrl = result.ReleaseUrl + LastKnownLatestUrl = result.ReleaseUrl, } ); } diff --git a/src/TypeWhisper.Linux/Services/WatchFolderExportBuilder.cs b/src/TypeWhisper.Linux/Services/WatchFolderExportBuilder.cs index 142277770..91392728a 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderExportBuilder.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderExportBuilder.cs @@ -17,7 +17,7 @@ DateTime date WatchFolderOutputFormat.PlainText => new WatchFolderExportArtifact("txt", result.Text), WatchFolderOutputFormat.Srt => BuildSubtitle("srt", result), WatchFolderOutputFormat.Vtt => BuildSubtitle("vtt", result), - _ => BuildMarkdown(result, fileName, engineName, date) + _ => BuildMarkdown(result, fileName, engineName, date), }; } diff --git a/src/TypeWhisper.Linux/Services/WatchFolderModels.cs b/src/TypeWhisper.Linux/Services/WatchFolderModels.cs index 17aaf4860..7011784a1 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderModels.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderModels.cs @@ -7,7 +7,7 @@ public enum WatchFolderOutputFormat Markdown, PlainText, Srt, - Vtt + Vtt, } public sealed record WatchFolderOptions( @@ -68,7 +68,7 @@ public static string ToStoredValue(WatchFolderOutputFormat format) WatchFolderOutputFormat.PlainText => "txt", WatchFolderOutputFormat.Srt => "srt", WatchFolderOutputFormat.Vtt => "vtt", - _ => "md" + _ => "md", }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs b/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs index 7acd1db95..5ba2a38e6 100644 --- a/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs @@ -323,11 +323,11 @@ private string ResolveText(OverlayWidget widget) RecordingMode.Toggle => Loc.Instance["Common.ModeToggle"], RecordingMode.PushToTalk => Loc.Instance["Common.ModePushToTalk"], RecordingMode.Hybrid => Loc.Instance["Common.ModeHybrid"], - _ => "" + _ => "", }, OverlayWidget.AppName => ActiveAppName ?? "", // Indicator, Waveform and None render no text; handled by the default arm. - _ => "" + _ => "", }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/ViewModels/MainWindowViewModel.cs b/src/TypeWhisper.Linux/ViewModels/MainWindowViewModel.cs index 78d408ed4..1fb471b76 100644 --- a/src/TypeWhisper.Linux/ViewModels/MainWindowViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/MainWindowViewModel.cs @@ -95,7 +95,7 @@ AboutSectionViewModel about new NavItem("Nav.General", Symbol.Settings, General, false), new NavItem("Nav.Appearance", Symbol.Color, Appearance, false), new NavItem("Nav.Advanced", Symbol.AppsSettings, Advanced, false), - new NavItem("Nav.About", Symbol.Info, About, false) + new NavItem("Nav.About", Symbol.Info, About, false), ]; SelectedItem = NavItems.First(i => i.Content is DashboardSectionViewModel); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs index 7f7b486a4..79380c216 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs @@ -282,7 +282,7 @@ private void RebuildCategoryFilters() var desired = new List { - new(null, Loc.Instance["About.ErrorFilterAll"]) + new(null, Loc.Instance["About.ErrorFilterAll"]), }; desired.AddRange(present.Select(c => new CategoryFilterOption(c, FormatCategory(c)))); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs index 88ade66c4..67f40a117 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs @@ -127,7 +127,7 @@ value is null new(30, Loc.Instance["Advanced.AutoUnload30Seconds"]), new(60, Loc.Instance["Advanced.AutoUnload1Minute"]), new(300, Loc.Instance["Advanced.AutoUnload5Minutes"]), - new(900, Loc.Instance["Advanced.AutoUnload15Minutes"]) + new(900, Loc.Instance["Advanced.AutoUnload15Minutes"]), ]; public IReadOnlyList HistoryRetentionOptions { get; } = @@ -137,7 +137,7 @@ value is null new(HistoryRetentionMode.Duration, 30 * 24 * 60, Loc.Instance["Advanced.Retention30Days"]), new(HistoryRetentionMode.Duration, 90 * 24 * 60, Loc.Instance["Advanced.Retention90Days"]), new(HistoryRetentionMode.Forever, null, Loc.Instance["Advanced.RetentionForever"]), - new(HistoryRetentionMode.UntilAppCloses, null, Loc.Instance["Advanced.RetentionUntilAppCloses"]) + new(HistoryRetentionMode.UntilAppCloses, null, Loc.Instance["Advanced.RetentionUntilAppCloses"]), ]; public bool CanUseSpokenFeedback => _speechFeedback.IsAvailable; @@ -314,7 +314,7 @@ _settings.Current with { HistoryRetentionMode = value.Mode, HistoryRetentionMinutes = - value.Minutes ?? _settings.Current.HistoryRetentionMinutes + value.Minutes ?? _settings.Current.HistoryRetentionMinutes, } ); } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AppearanceSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AppearanceSectionViewModel.cs index c9d406c1c..8d6fd5367 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AppearanceSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AppearanceSectionViewModel.cs @@ -61,7 +61,7 @@ public AppearanceSectionViewModel(ISettingsService settings) public IReadOnlyList OverlayPositions { get; } = [ new(OverlayPosition.Top, "Appearance.PositionTop"), - new(OverlayPosition.Bottom, "Appearance.PositionBottom") + new(OverlayPosition.Bottom, "Appearance.PositionBottom"), ]; public IReadOnlyList OverlayWidgets { get; } = @@ -73,7 +73,7 @@ public AppearanceSectionViewModel(ISettingsService settings) new(OverlayWidget.Clock, "Appearance.WidgetClock"), new(OverlayWidget.Profile, "Appearance.WidgetProfile"), new(OverlayWidget.HotkeyMode, "Appearance.WidgetHotkeyMode"), - new(OverlayWidget.AppName, "Appearance.WidgetAppName") + new(OverlayWidget.AppName, "Appearance.WidgetAppName"), ]; public string PreviewBubbleAutoHideSecondsText => @@ -181,10 +181,10 @@ private string SampleText(OverlayWidget? widget) RecordingMode.Toggle => Loc.Instance["Common.ModeToggle"], RecordingMode.PushToTalk => Loc.Instance["Common.ModePushToTalk"], RecordingMode.Hybrid => Loc.Instance["Common.ModeHybrid"], - _ => "" + _ => "", }, OverlayWidget.AppName => Loc.Instance["Appearance.SampleAppName"], - _ => "" + _ => "", }; } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs index 9060fdc29..7c282ac1a 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs @@ -14,7 +14,7 @@ public enum TimeRange { Weekly, Month, - AllTime + AllTime, } private const double ManualTypingWordsPerMinute = 40.0; @@ -168,7 +168,7 @@ private void Refresh() { TimeRange.Weekly => now.AddDays(-7), TimeRange.Month => now.AddDays(-30), - _ => DateTime.MinValue + _ => DateTime.MinValue, }; var records = _history.Records.Where(r => r.Timestamp >= cutoff).ToList(); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs index 4c6608186..99c1c1868 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs @@ -277,7 +277,7 @@ IAccessibilityBusActivation a11yBus [ new(AppSettings.LocalModelAccelerationAuto, Loc.Instance["Dictation.AccelerationAuto"]), new(AppSettings.LocalModelAccelerationCpu, Loc.Instance["Dictation.AccelerationCpu"]), - new(AppSettings.LocalModelAccelerationNvidiaCuda, Loc.Instance["Dictation.AccelerationNvidiaCuda"]) + new(AppSettings.LocalModelAccelerationNvidiaCuda, Loc.Instance["Dictation.AccelerationNvidiaCuda"]), ]; public ObservableCollection LanguageChoices { get; } = @@ -294,7 +294,7 @@ IAccessibilityBusActivation a11yBus new("cs", "Čeština"), new("sv", "Svenska"), new("da", "Dansk"), - new("fi", "Suomi") + new("fi", "Suomi"), ]; public ObservableCollection TranslationTargetOptions { get; } = []; @@ -304,7 +304,7 @@ IAccessibilityBusActivation a11yBus new(CleanupLevel.None, Loc.Instance["Dictation.CleanupNone"]), new(CleanupLevel.Light, Loc.Instance["Dictation.CleanupLight"]), new(CleanupLevel.Medium, Loc.Instance["Dictation.CleanupMedium"]), - new(CleanupLevel.High, Loc.Instance["Dictation.CleanupHigh"]) + new(CleanupLevel.High, Loc.Instance["Dictation.CleanupHigh"]), ]; public ObservableCollection InsertionStrategyOptions { get; } = @@ -312,7 +312,7 @@ IAccessibilityBusActivation a11yBus new(TextInsertionStrategy.Auto, Loc.Instance["Dictation.AccelerationAuto"]), new(TextInsertionStrategy.ClipboardPaste, Loc.Instance["Dictation.StrategyClipboardPaste"]), new(TextInsertionStrategy.DirectTyping, Loc.Instance["Dictation.StrategyDirectTyping"]), - new(TextInsertionStrategy.CopyOnly, Loc.Instance["Dictation.StrategyCopyOnly"]) + new(TextInsertionStrategy.CopyOnly, Loc.Instance["Dictation.StrategyCopyOnly"]), ]; public ObservableCollection AppInsertionStrategies { get; } = []; @@ -454,7 +454,7 @@ public string AccelerationStatusText : Loc.Instance["Dictation.AccelCudaNotVisible"], AppSettings.LocalModelAccelerationNvidiaCuda => Loc.Instance["Dictation.AccelCudaReady"], - _ => Loc.Instance["Dictation.AccelAutoStatus"] + _ => Loc.Instance["Dictation.AccelAutoStatus"], }; } @@ -818,7 +818,7 @@ private void RefreshModelState() status.Progress.ToString("P0") ), ModelStatusType.Error => FormatModelStatusError(status.ErrorMessage), - _ => Loc.Instance["Dictation.StatusNotReady"] + _ => Loc.Instance["Dictation.StatusNotReady"], }; OnPropertyChanged(nameof(CanDeleteSelectedModel)); OnPropertyChanged(nameof(CanUseCuda)); @@ -938,7 +938,7 @@ public async Task ChangeModelStorageAsync(string? folderPath) LocalModelStorageUnavailableReason.NestedUnderCurrentFolder => Loc.Instance.GetString( "Dictation.ModelStorageNestedUnderCurrent", ex.Path, ex.CurrentPath ?? string.Empty), - _ => Loc.Instance.GetString("Dictation.ModelStorageChangeFailed", ex.Message) + _ => Loc.Instance.GetString("Dictation.ModelStorageChangeFailed", ex.Message), }; } catch (Exception ex) @@ -1325,7 +1325,7 @@ partial void OnSelectedDeviceChanged(AudioInputDevice? value) _settings.Save( _settings.Current with { - SelectedMicrophoneDevice = value.Index, SelectedMicrophoneDeviceId = value.PersistentId + SelectedMicrophoneDevice = value.Index, SelectedMicrophoneDeviceId = value.PersistentId, } ); } @@ -1636,7 +1636,7 @@ partial void OnAudioDuckingLevelChanged(double value) _settings.Save( _settings.Current with { - AudioDuckingLevel = (float)Math.Clamp(value, MinDuckingLevel, MaxDuckingLevel) + AudioDuckingLevel = (float)Math.Clamp(value, MinDuckingLevel, MaxDuckingLevel), } ); OnPropertyChanged(nameof(AudioDuckingReductionPercent)); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictionarySectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictionarySectionViewModel.cs index cd5424da5..32e14e3da 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictionarySectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictionarySectionViewModel.cs @@ -92,7 +92,7 @@ public DictionarySectionViewModel(IDictionaryService dict, ISettingsService sett { 1 => Loc.Instance["Dictionary.EmptyTitleTerms"], 2 => Loc.Instance["Dictionary.EmptyTitleCorrections"], - _ => Loc.Instance["Dictionary.EmptyTitleAll"] + _ => Loc.Instance["Dictionary.EmptyTitleAll"], }; public string EmptyStateSubtitle => @@ -100,7 +100,7 @@ public DictionarySectionViewModel(IDictionaryService dict, ISettingsService sett { 1 => Loc.Instance["Dictionary.EmptySubtitleTerms"], 2 => Loc.Instance["Dictionary.EmptySubtitleCorrections"], - _ => Loc.Instance["Dictionary.EmptySubtitleAll"] + _ => Loc.Instance["Dictionary.EmptySubtitleAll"], }; public bool IsNewTypeCorrection @@ -210,7 +210,7 @@ private void SetTab(object? tab) string stringValue when int.TryParse(stringValue, out var parsed) => parsed, // Leave the current tab unchanged for any other value; the // [ObservableProperty] setter's equality guard makes this a no-op. - _ => SelectedTab + _ => SelectedTab, }; } @@ -239,7 +239,7 @@ private void AddEntry() : NewReplacement.Trim(), CaseSensitive = CaseSensitive, IsEnabled = true, - Priority = Math.Clamp(NewPriority, 0, 999) + Priority = Math.Clamp(NewPriority, 0, 999), } ); @@ -326,7 +326,7 @@ private void Refresh() 1 => entries.Where(entry => entry.EntryType == DictionaryEntryType.Term), 2 => entries.Where(entry => entry.EntryType == DictionaryEntryType.Correction), 3 => [], - _ => entries + _ => entries, }; if (!string.IsNullOrWhiteSpace(SearchText)) diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionQueueItemStatus.cs b/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionQueueItemStatus.cs index b6d7c8297..cc6f05700 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionQueueItemStatus.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionQueueItemStatus.cs @@ -8,5 +8,5 @@ public enum FileTranscriptionQueueItemStatus Completed, Cancelled, Error, - Unsupported + Unsupported, } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionSectionViewModel.cs index 98788e61d..24fd9ab72 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionSectionViewModel.cs @@ -723,7 +723,7 @@ _settings.Current with FileTranscriptionEngineOverride = CleanSettingValue( FileTranscriptionEngineOverride ), - FileTranscriptionModelOverride = CleanSettingValue(FileTranscriptionModelOverride) + FileTranscriptionModelOverride = CleanSettingValue(FileTranscriptionModelOverride), } ); } @@ -747,7 +747,7 @@ _settings.Current with WatchFolderDeleteSource = WatchFolderDeleteSource, WatchFolderLanguage = string.IsNullOrWhiteSpace(WatchFolderLanguage) ? "auto" - : WatchFolderLanguage + : WatchFolderLanguage, } ); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs index 413898cf3..831a286c2 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs @@ -93,7 +93,7 @@ public string BuildExportContent(string extension) ".csv" => _history.ExportToCsv(visibleRecords), ".md" => _history.ExportToMarkdown(visibleRecords), ".json" => _history.ExportToJson(visibleRecords), - _ => _history.ExportToText(visibleRecords) + _ => _history.ExportToText(visibleRecords), }; } @@ -193,7 +193,7 @@ internal void AddTermFromHistory(HistoryRecordRow record) Id = Guid.NewGuid().ToString(), EntryType = DictionaryEntryType.Term, Original = term, - Source = DictionaryEntrySource.Manual + Source = DictionaryEntrySource.Manual, } ); } @@ -718,7 +718,7 @@ public LlmCallDisplay(LlmCallProvenance call) "Cleanup" => Loc.Instance["History.Inspect.StageCleanup"], "Translation" => Loc.Instance["History.Inspect.StageTranslation"], "Memory" => Loc.Instance["History.Inspect.StageMemory"], - _ => Loc.Instance["History.Inspect.StagePromptAction"] + _ => Loc.Instance["History.Inspect.StagePromptAction"], }; public string ProviderModelLabel => $"{_call.ProviderName} · {_call.ModelId}"; diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/PluginCollectionViewModels.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PluginCollectionViewModels.cs index c3c54e2d2..e4a8bafd6 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PluginCollectionViewModels.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PluginCollectionViewModels.cs @@ -65,7 +65,7 @@ private void AddItem() { PluginSettingKind.Boolean => "true", PluginSettingKind.Dropdown when field.Options is { Count: > 0 } => field.Options[0].Value, - _ => string.Empty + _ => string.Empty, }; } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs index 11382ab6c..60d69024c 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs @@ -29,7 +29,7 @@ public partial class PluginsSectionViewModel : ObservableObject "com.typewhisper.soniox", "com.typewhisper.speechmatics", "com.typewhisper.voxtral", - "com.typewhisper.whisper-cpp" + "com.typewhisper.whisper-cpp", ]; private static readonly HashSet s_llmPluginIds = @@ -42,7 +42,7 @@ public partial class PluginsSectionViewModel : ObservableObject "com.typewhisper.gemma-local", "com.typewhisper.groq", "com.typewhisper.openai-compatible", - "com.typewhisper.openrouter" + "com.typewhisper.openrouter", ]; private static readonly HashSet s_actionPluginIds = @@ -50,18 +50,18 @@ public partial class PluginsSectionViewModel : ObservableObject "com.typewhisper.linear", "com.typewhisper.obsidian", "com.typewhisper.script", - "com.typewhisper.webhook" + "com.typewhisper.webhook", ]; private static readonly HashSet s_memoryPluginIds = [ "com.typewhisper.file-memory", - "com.typewhisper.openai-vector-memory" + "com.typewhisper.openai-vector-memory", ]; private static readonly HashSet s_utilityPluginIds = [ - "com.typewhisper.openai-compatible" + "com.typewhisper.openai-compatible", ]; private readonly IErrorLogService? _errorLog; @@ -718,7 +718,7 @@ public static PluginCategoryInfo Resolve(string? rawCategory) ), "action" => new PluginCategoryInfo("action", Loc.Instance["Plugins.CategoryAction"], 3), "memory" => new PluginCategoryInfo("memory", Loc.Instance["Plugins.CategoryMemory"], 4), - _ => new PluginCategoryInfo("utility", Loc.Instance["Plugins.CategoryUtility"], 5) + _ => new PluginCategoryInfo("utility", Loc.Instance["Plugins.CategoryUtility"], 5), }; } @@ -732,7 +732,7 @@ private static string Normalize(string? rawCategory) "post-processing", "action" => "action", "memory" => "memory", - _ => "utility" + _ => "utility", }; } } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs index c4ef77b23..c2c1187e5 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs @@ -206,13 +206,13 @@ BrowserAccessibilitySetupHelper browserSetup new(ProfileStylePreset.CasualMessage, Loc.Instance["Profiles.StylePresetCasualMessage"]), new(ProfileStylePreset.Developer, Loc.Instance["Profiles.StylePresetDeveloper"]), new(ProfileStylePreset.TerminalSafe, Loc.Instance["Profiles.StylePresetTerminalSafe"]), - new(ProfileStylePreset.MeetingNotes, Loc.Instance["Profiles.StylePresetMeetingNotes"]) + new(ProfileStylePreset.MeetingNotes, Loc.Instance["Profiles.StylePresetMeetingNotes"]), ]; public ObservableCollection HotkeyBehaviorOptions { get; } = [ new(ProfileHotkeyBehavior.StartDictation, Loc.Instance["Profiles.HotkeyBehaviorStartDictation"]), - new(ProfileHotkeyBehavior.ProcessSelectedText, Loc.Instance["Profiles.HotkeyBehaviorProcessSelectedText"]) + new(ProfileHotkeyBehavior.ProcessSelectedText, Loc.Instance["Profiles.HotkeyBehaviorProcessSelectedText"]), ]; public ObservableCollection CleanupOverrideOptions { get; } = @@ -221,7 +221,7 @@ BrowserAccessibilitySetupHelper browserSetup new(CleanupLevel.None, Loc.Instance["Profiles.CleanupNone"]), new(CleanupLevel.Light, Loc.Instance["Profiles.CleanupLight"]), new(CleanupLevel.Medium, Loc.Instance["Profiles.CleanupMedium"]), - new(CleanupLevel.High, Loc.Instance["Profiles.CleanupHigh"]) + new(CleanupLevel.High, Loc.Instance["Profiles.CleanupHigh"]), ]; public ObservableCollection ProcessNameChips { get; } = []; @@ -327,7 +327,7 @@ SelectedProfile is null [ new(null, Loc.Instance["Profiles.UseGlobalDefault"]), new(true, Loc.Instance["Common.Enabled"]), - new(false, Loc.Instance["Common.Disabled"]) + new(false, Loc.Instance["Common.Disabled"]), ]; public TranslationTargetOption? SelectedTranslationTargetOption @@ -600,7 +600,7 @@ private void AddProfile() IsEnabled = true, Priority = 0, ProcessNames = [], - UrlPatterns = [] + UrlPatterns = [], }; _profiles.AddProfile(profile); @@ -635,7 +635,7 @@ private void SaveProfile() Loc.Instance["Profiles.HotkeyMalformed"], HotkeyCandidateValidationStatus.MissingEnabledPromptAction => Loc.Instance["Profiles.HotkeyPromptActionRequired"], - _ => Loc.Instance["Profiles.HotkeyCollision"] + _ => Loc.Instance["Profiles.HotkeyCollision"], }; return; } @@ -662,7 +662,7 @@ private void SaveProfile() CleanupLevelOverride = EditCleanupLevelOverride, DeveloperFormattingOverride = EditDeveloperFormattingOverride, Priority = EditPriority, - IsEnabled = EditIsEnabled + IsEnabled = EditIsEnabled, }; var selectedId = SelectedProfile.Id; @@ -687,7 +687,7 @@ private void DuplicateProfile() // so a copied hotkey would be silently dead. HotkeyData = null, CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, }; _profiles.AddProfile(duplicate); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs index 36ca42cec..ee7c1537a 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs @@ -327,7 +327,7 @@ private void SaveAction() { HotkeyCandidateValidationStatus.Malformed => Loc.Instance["Prompts.HotkeyMalformed"], - _ => Loc.Instance["Prompts.HotkeyCollision"] + _ => Loc.Instance["Prompts.HotkeyCollision"], }; return; } @@ -348,7 +348,7 @@ private void SaveAction() HotkeyKey = hotkeyValidation.NormalizedHotkey, IsManualOnly = EditIsManualOnly, IsEnabled = true, - SortOrder = _prompts.Actions.Count + SortOrder = _prompts.Actions.Count, }; if (!TryMutate(() => _prompts.AddAction(action), "add a prompt action")) @@ -384,7 +384,7 @@ existing with ProviderOverride = EditProviderOverride, TargetActionPluginId = EditTargetActionPluginId, HotkeyKey = hotkeyValidation.NormalizedHotkey, - IsManualOnly = EditIsManualOnly + IsManualOnly = EditIsManualOnly, } ), "update a prompt action" diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs index 8ba308583..ae56930dd 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs @@ -14,7 +14,7 @@ internal enum ManagedDesktopIntegrationState Unknown, Absent, Current, - Stale + Stale, } // MVVM Toolkit [ObservableProperty] generates the OnChanged(value) partial hooks; the @@ -296,7 +296,7 @@ private async Task RefreshKeyboardAccessAsync() // times per second. The orchestrator is idempotent so it's safe, // just noisy. "Sway" => "bindsym --no-repeat $mod+space exec typewhisper record start", - _ => "" + _ => "", }; // ReSharper disable once MemberCanBeMadeStatic.Global @@ -306,7 +306,7 @@ private async Task RefreshKeyboardAccessAsync() { "Hyprland" => "bindr = CTRL SHIFT, SPACE, exec, typewhisper record stop", "Sway" => "bindsym --release $mod+space exec typewhisper record stop", - _ => "" + _ => "", }; // ReSharper disable once MemberCanBeMadeStatic.Global @@ -316,7 +316,7 @@ private async Task RefreshKeyboardAccessAsync() { "Hyprland" => Loc.Instance["Shortcuts.PushToTalkSnippetHintHyprland"], "Sway" => Loc.Instance["Shortcuts.PushToTalkSnippetHintSway"], - _ => "" + _ => "", }; // DesktopDetector normalizes edge cases like "ubuntu:GNOME". @@ -344,7 +344,7 @@ public string DesktopName "XFCE" => Loc.Instance["Shortcuts.DesktopInstructionsXfce"], "Cinnamon" => Loc.Instance["Shortcuts.DesktopInstructionsCinnamon"], "MATE" => Loc.Instance["Shortcuts.DesktopInstructionsMate"], - _ => Loc.Instance["Shortcuts.DesktopInstructionsGeneric"] + _ => Loc.Instance["Shortcuts.DesktopInstructionsGeneric"], }; private IDeShortcutWriter? ActiveWriter @@ -616,7 +616,7 @@ private string GetModeDisplayName() RecordingMode.Toggle => Loc.Instance["Common.ModeToggle"], RecordingMode.PushToTalk => Loc.Instance["Common.ModePushToTalk"], RecordingMode.Hybrid => Loc.Instance["Common.ModeHybrid"], - _ => "" + _ => "", }; } @@ -662,7 +662,7 @@ private void ApplyCopyLastTranscriptionHotkey() _settings.Save( _settings.Current with { - CopyLastTranscriptionHotkey = _hotkey.CurrentCopyLastTranscriptionHotkeyString + CopyLastTranscriptionHotkey = _hotkey.CurrentCopyLastTranscriptionHotkeyString, } ); StatusMessage = string.IsNullOrWhiteSpace( @@ -944,7 +944,7 @@ partial void OnModeChanged(RecordingMode value) RecordingMode.Toggle => Loc.Instance["Shortcuts.ModeToggleStatus"], RecordingMode.PushToTalk => Loc.Instance["Shortcuts.ModePushToTalkStatus"], RecordingMode.Hybrid => Loc.Instance["Shortcuts.ModeHybridStatus"], - _ => "" + _ => "", }; OnPropertyChanged(nameof(ShowCapabilityMismatch)); OnPropertyChanged(nameof(IntegrationPreview)); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs index bb08f4d79..ba68d4226 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs @@ -85,7 +85,7 @@ public SnippetsSectionViewModel(ISnippetService snippets, IDictionaryService dic public IReadOnlyList TriggerModeOptions { get; } = [ new(SnippetTriggerMode.Anywhere, Loc.Instance["Snippets.TriggerModeAnywhere"]), - new(SnippetTriggerMode.ExactPhrase, Loc.Instance["Snippets.TriggerModeExactPhrase"]) + new(SnippetTriggerMode.ExactPhrase, Loc.Instance["Snippets.TriggerModeExactPhrase"]), ]; public void Dispose() @@ -173,7 +173,7 @@ private void SaveSnippet() IsEnabled = existing?.IsEnabled ?? true, UsageCount = existing?.UsageCount ?? 0, LastUsedAt = existing?.LastUsedAt, - CreatedAt = existing?.CreatedAt ?? DateTime.UtcNow + CreatedAt = existing?.CreatedAt ?? DateTime.UtcNow, }; if (existing is null) @@ -327,7 +327,7 @@ private string BuildConflictWarning(string trigger) ), { EntryType: DictionaryEntryType.Correction, - Replacement: { Length: > 0 } replacement + Replacement: { Length: > 0 } replacement, } => Loc.Instance.GetString( "Snippets.ConflictCorrectionReplacement", conflict.Original, @@ -337,7 +337,7 @@ private string BuildConflictWarning(string trigger) "Snippets.ConflictCorrection", conflict.Original ), - _ => "" + _ => "", }; } diff --git a/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs b/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs index b2b35e8e1..52079ac06 100644 --- a/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs @@ -557,7 +557,7 @@ private async Task NextAsync() _settings.Current with { SelectedMicrophoneDevice = SelectedMic.Index, - SelectedMicrophoneDeviceId = SelectedMic.PersistentId + SelectedMicrophoneDeviceId = SelectedMic.PersistentId, } ); } @@ -598,7 +598,7 @@ _settings.Current with EnabledPackIds = IndustryPreset.MergeIntoEnabledPackIds( _settings.Current.EnabledPackIds, SelectedIndustryPresetId - ) + ), } ); } @@ -1018,7 +1018,7 @@ public SetupTaskRow(ISetupTask source) SetupTaskStatusKind.Satisfied => "ok", SetupTaskStatusKind.Failed => "error", SetupTaskStatusKind.Working => "busy", - _ => "missing" + _ => "missing", }; public string StatusGlyph => Kind switch @@ -1026,7 +1026,7 @@ public SetupTaskRow(ISetupTask source) SetupTaskStatusKind.Satisfied => "✓", SetupTaskStatusKind.Failed => "!", SetupTaskStatusKind.Working => "…", - _ => "•" + _ => "•", }; public void Apply(SetupTaskState state) diff --git a/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs b/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs index 41e3a61fd..76fa2204d 100644 --- a/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs +++ b/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs @@ -340,7 +340,7 @@ private void OnDragSaveTimerTick(object? sender, EventArgs e) _settings.Save(_settings.Current with { OverlayCustomLeft = (double)pos.X, - OverlayCustomTop = (double)pos.Y + OverlayCustomTop = (double)pos.Y, }); } } diff --git a/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs index 433c2fb49..de8ef0841 100644 --- a/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs @@ -35,7 +35,7 @@ private async void OnExportDiagnostics(object? sender, RoutedEventArgs e) Title = Loc.Instance["Dialog.ExportDiagnostics"], SuggestedFileName = "typewhisper-diagnostics.json", DefaultExtension = "json", - FileTypeChoices = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }] + FileTypeChoices = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }], } ); @@ -75,7 +75,7 @@ private async void OnBackupSettings(object? sender, RoutedEventArgs e) $"typewhisper-settings-backup-{DateTime.Now:yyyyMMdd-HHmmss}.zip", DefaultExtension = "zip", FileTypeChoices = - [new FilePickerFileType("Zip archive") { Patterns = ["*.zip"] }] + [new FilePickerFileType("Zip archive") { Patterns = ["*.zip"] }], } ); @@ -119,7 +119,7 @@ private async void OnRestoreSettings(object? sender, RoutedEventArgs e) Title = Loc.Instance["Dialog.RestoreSettings"], AllowMultiple = false, FileTypeFilter = - [new FilePickerFileType("Zip archive") { Patterns = ["*.zip"] }] + [new FilePickerFileType("Zip archive") { Patterns = ["*.zip"] }], } ); diff --git a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs index be4cbcef3..7db8612c4 100644 --- a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs @@ -73,7 +73,7 @@ private async void OnChangeModelStorage(object? sender, RoutedEventArgs e) new FolderPickerOpenOptions { Title = "Choose model storage folder", - AllowMultiple = false + AllowMultiple = false, } ); diff --git a/src/TypeWhisper.Linux/Views/Sections/DictionarySection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/DictionarySection.axaml.cs index 770a29600..8b69ff5d3 100644 --- a/src/TypeWhisper.Linux/Views/Sections/DictionarySection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/DictionarySection.axaml.cs @@ -36,7 +36,7 @@ private async void OnExport(object? sender, RoutedEventArgs e) Title = Loc.Instance["Dialog.ExportDictionary"], SuggestedFileName = "typewhisper-dictionary.csv", DefaultExtension = "csv", - FileTypeChoices = [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }] + FileTypeChoices = [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }], } ); @@ -77,7 +77,7 @@ private async void OnImport(object? sender, RoutedEventArgs e) { Title = Loc.Instance["Dialog.ImportDictionary"], AllowMultiple = false, - FileTypeFilter = [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }] + FileTypeFilter = [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }], } ); diff --git a/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs index ca6462b9d..c5b81a662 100644 --- a/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs @@ -137,7 +137,7 @@ private async Task ExportTextAsync( Title = Loc.Instance["Dialog.ExportText"], SuggestedFileName = $"{baseName}.txt", DefaultExtension = "txt", - FileTypeChoices = [new FilePickerFileType("Text") { Patterns = ["*.txt"] }] + FileTypeChoices = [new FilePickerFileType("Text") { Patterns = ["*.txt"] }], } ); @@ -189,7 +189,7 @@ DataContext is not FileTranscriptionSectionViewModel viewModel Title = $"Export {label}", SuggestedFileName = $"{baseName}.{extension}", DefaultExtension = extension, - FileTypeChoices = [new FilePickerFileType(label) { Patterns = [$"*.{extension}"] }] + FileTypeChoices = [new FilePickerFileType(label) { Patterns = [$"*.{extension}"] }], } ); diff --git a/src/TypeWhisper.PluginSDK/Helpers/OpenAiApiHelper.cs b/src/TypeWhisper.PluginSDK/Helpers/OpenAiApiHelper.cs index 071c5fdf2..b77b64546 100644 --- a/src/TypeWhisper.PluginSDK/Helpers/OpenAiApiHelper.cs +++ b/src/TypeWhisper.PluginSDK/Helpers/OpenAiApiHelper.cs @@ -51,7 +51,7 @@ CancellationToken ct 401 => "Invalid API key", 413 => "Audio too large (max 25 MB)", 429 => "Rate limit reached, please wait", - _ => $"API error {(int)response.StatusCode}: {ExtractErrorMessage(errorBody)}" + _ => $"API error {(int)response.StatusCode}: {ExtractErrorMessage(errorBody)}", }; throw new InvalidOperationException(message); } diff --git a/src/TypeWhisper.PluginSDK/Helpers/OpenAiChatHelper.cs b/src/TypeWhisper.PluginSDK/Helpers/OpenAiChatHelper.cs index 97b70a9ae..130bda555 100644 --- a/src/TypeWhisper.PluginSDK/Helpers/OpenAiChatHelper.cs +++ b/src/TypeWhisper.PluginSDK/Helpers/OpenAiChatHelper.cs @@ -168,7 +168,7 @@ public static async IAsyncEnumerable SendChatCompletionStreamingAsync( { 401 => "Invalid API key", 429 => "Rate limit reached, please wait", - _ => $"API error {(int)response.StatusCode}: {OpenAiApiHelper.ExtractErrorMessage(errorBody)}" + _ => $"API error {(int)response.StatusCode}: {OpenAiApiHelper.ExtractErrorMessage(errorBody)}", }; throw new InvalidOperationException(message); } @@ -382,8 +382,8 @@ bool stream ["model"] = model, ["messages"] = new object[] { - new { role = "system", content = systemPrompt }, new { role = "user", content = userText } - } + new { role = "system", content = systemPrompt }, new { role = "user", content = userText }, + }, }; if (temperature is not null) diff --git a/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs b/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs index c0cfc7bfd..9a084d7fe 100644 --- a/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs +++ b/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs @@ -96,7 +96,7 @@ internal static PluginTranscriptionResult ParseTranscriptionResponse(string json { return new PluginTranscriptionResult(text.Trim(), language, duration, minNoSpeechProb) { - Segments = segments + Segments = segments, }; } diff --git a/src/TypeWhisper.PluginSDK/Models/PluginLogLevel.cs b/src/TypeWhisper.PluginSDK/Models/PluginLogLevel.cs index bd04d40b8..0771c776e 100644 --- a/src/TypeWhisper.PluginSDK/Models/PluginLogLevel.cs +++ b/src/TypeWhisper.PluginSDK/Models/PluginLogLevel.cs @@ -14,5 +14,5 @@ public enum PluginLogLevel // ReSharper disable once UnusedMember.Global Warning, // ReSharper disable once UnusedMember.Global - Error + Error, } diff --git a/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationBackend.cs b/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationBackend.cs index 4eef1b9f2..03b25fbc7 100644 --- a/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationBackend.cs +++ b/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationBackend.cs @@ -12,5 +12,5 @@ public enum TranscriptionAccelerationBackend // ReSharper disable once UnusedMember.Global Cpu, // ReSharper disable once UnusedMember.Global - NvidiaCuda + NvidiaCuda, } diff --git a/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationPreference.cs b/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationPreference.cs index 0b12b94c0..d21e00a84 100644 --- a/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationPreference.cs +++ b/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationPreference.cs @@ -14,5 +14,5 @@ public enum TranscriptionAccelerationPreference // ReSharper disable once UnusedMember.Global Cpu, // ReSharper disable once UnusedMember.Global - NvidiaCuda + NvidiaCuda, } diff --git a/src/TypeWhisper.PluginSDK/Models/TtsPurpose.cs b/src/TypeWhisper.PluginSDK/Models/TtsPurpose.cs index aeae8cafa..6fb303cbc 100644 --- a/src/TypeWhisper.PluginSDK/Models/TtsPurpose.cs +++ b/src/TypeWhisper.PluginSDK/Models/TtsPurpose.cs @@ -20,5 +20,5 @@ public enum TtsPurpose /// User explicitly requested the text to be read aloud. // ReSharper disable once UnusedMember.Global - ManualReadback + ManualReadback, } diff --git a/tests/TypeWhisper.Core.Tests/Models/ErrorCategoryGuardTests.cs b/tests/TypeWhisper.Core.Tests/Models/ErrorCategoryGuardTests.cs index bac9ad083..f48b1d1b5 100644 --- a/tests/TypeWhisper.Core.Tests/Models/ErrorCategoryGuardTests.cs +++ b/tests/TypeWhisper.Core.Tests/Models/ErrorCategoryGuardTests.cs @@ -37,7 +37,7 @@ public void Category_constants_are_distinct_and_lowercase() ErrorCategory.Prompt, ErrorCategory.Plugin, ErrorCategory.Insertion, - ErrorCategory.Detection + ErrorCategory.Detection, ]; Assert.Equal(all.Length, all.Distinct().Count()); diff --git a/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceCorrectionsTests.cs b/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceCorrectionsTests.cs index 01c1e5010..616643cf6 100644 --- a/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceCorrectionsTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceCorrectionsTests.cs @@ -32,7 +32,7 @@ public void GetCorrections_ReturnsEnabledOnly() EntryType = DictionaryEntryType.Correction, Original = "teh", Replacement = "the", - IsEnabled = true + IsEnabled = true, }); _sut.AddEntry(new DictionaryEntry { @@ -40,13 +40,13 @@ public void GetCorrections_ReturnsEnabledOnly() EntryType = DictionaryEntryType.Correction, Original = "recieve", Replacement = "receive", - IsEnabled = false + IsEnabled = false, }); _sut.AddEntry(new DictionaryEntry { Id = "3", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", }); var corrections = _sut.GetCorrections(); @@ -107,7 +107,7 @@ public void DeleteTerm_Match() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "FooCorp" + Original = "FooCorp", }); var deleted = _sut.DeleteTerm("foocorp"); @@ -123,7 +123,7 @@ public void DeleteTerm_NoMatch_ReturnsFalse() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "FooCorp" + Original = "FooCorp", }); var deleted = _sut.DeleteTerm("BarCorp"); @@ -140,7 +140,7 @@ public void DeleteTerm_LeavesCorrectionsAlone() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "teh" + Original = "teh", }); var deleted = _sut.DeleteTerm("teh"); diff --git a/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceTests.cs index bde2778d3..b93b4adb5 100644 --- a/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceTests.cs @@ -36,7 +36,7 @@ public void AddEntry_AppearsInEntries() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); @@ -52,7 +52,7 @@ public void DeleteEntry_RemovesFromEntries() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); @@ -69,7 +69,7 @@ public void DeleteEntries_BatchRemove() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "A" + Original = "A", } ); _sut.AddEntry( @@ -77,7 +77,7 @@ public void DeleteEntries_BatchRemove() { Id = "2", EntryType = DictionaryEntryType.Term, - Original = "B" + Original = "B", } ); _sut.AddEntry( @@ -85,7 +85,7 @@ public void DeleteEntries_BatchRemove() { Id = "3", EntryType = DictionaryEntryType.Term, - Original = "C" + Original = "C", } ); @@ -115,7 +115,7 @@ public void ActivatePack_AllowsSameTermInDifferentSources() { Id = "existing", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); @@ -175,7 +175,7 @@ public void DeactivatePack_RemovesPackTerms() { Id = "manual", EntryType = DictionaryEntryType.Term, - Original = "TypeScript" + Original = "TypeScript", } ); @@ -194,7 +194,7 @@ public void ApplyCorrections_ReplacesText() Id = "1", EntryType = DictionaryEntryType.Correction, Original = "kubernets", - Replacement = "Kubernetes" + Replacement = "Kubernetes", } ); @@ -211,7 +211,7 @@ public void PreviewCorrections_ReplacesText() Id = "1", EntryType = DictionaryEntryType.Correction, Original = "kubernets", - Replacement = "Kubernetes" + Replacement = "Kubernetes", } ); @@ -228,7 +228,7 @@ public void PreviewCorrections_DoesNotUpdateUsageMetadata() Id = "1", EntryType = DictionaryEntryType.Correction, Original = "kubernets", - Replacement = "Kubernetes" + Replacement = "Kubernetes", } ); @@ -251,7 +251,7 @@ public void PreviewCorrections_DoesNotPersistAcrossInstances() Id = "1", EntryType = DictionaryEntryType.Correction, Original = "kubernets", - Replacement = "Kubernetes" + Replacement = "Kubernetes", } ); @@ -270,7 +270,7 @@ public void ApplyCorrections_UpdatesUsageMetadata() Id = "1", EntryType = DictionaryEntryType.Correction, Original = "kubernets", - Replacement = "Kubernetes" + Replacement = "Kubernetes", } ); @@ -291,7 +291,7 @@ public void ApplyCorrections_DoesNotUpdateUsageMetadata_WhenWordBoundaryDoesNotM Id = "1", EntryType = DictionaryEntryType.Correction, Original = "test", - Replacement = "exam" + Replacement = "exam", } ); @@ -311,7 +311,7 @@ public void ApplyCorrections_PrefersHigherPriorityCorrection() Id = "low", EntryType = DictionaryEntryType.Correction, Original = "type whisper", - Replacement = "Type Whisper" + Replacement = "Type Whisper", } ); _sut.AddEntry( @@ -321,7 +321,7 @@ public void ApplyCorrections_PrefersHigherPriorityCorrection() EntryType = DictionaryEntryType.Correction, Original = "type whisper", Replacement = "TypeWhisper", - Priority = 10 + Priority = 10, } ); @@ -338,7 +338,7 @@ public void GetTermsForPrompt_ReturnsCommaSeparated() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); _sut.AddEntry( @@ -346,7 +346,7 @@ public void GetTermsForPrompt_ReturnsCommaSeparated() { Id = "2", EntryType = DictionaryEntryType.Term, - Original = "Vue" + Original = "Vue", } ); @@ -362,7 +362,7 @@ public void SetTerms_AppendsNormalizedTerms_WhenReplaceExistingFalse() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); @@ -379,7 +379,7 @@ public void SetTerms_ReplacesExistingTerms_WhenReplaceExistingTrue() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); _sut.AddEntry( @@ -388,7 +388,7 @@ public void SetTerms_ReplacesExistingTerms_WhenReplaceExistingTrue() Id = "2", EntryType = DictionaryEntryType.Correction, Original = "teh", - Replacement = "the" + Replacement = "the", } ); @@ -406,7 +406,7 @@ public void RemoveAllTerms_KeepsCorrections() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); _sut.AddEntry( @@ -415,7 +415,7 @@ public void RemoveAllTerms_KeepsCorrections() Id = "2", EntryType = DictionaryEntryType.Correction, Original = "teh", - Replacement = "the" + Replacement = "the", } ); @@ -473,7 +473,7 @@ public void LearnCorrection_DoesNotOverwriteUserAuthoredEntry(DictionaryEntrySou EntryType = DictionaryEntryType.Correction, Original = "kubernets", Replacement = "Kubernetes", - Source = source + Source = source, } ); @@ -489,7 +489,7 @@ public void LearnCorrections_AddsNewCorrectionsAsAutoLearnedAndReturnsIds() { var learned = _sut.LearnCorrections([ new CorrectionSuggestion("teh", "the"), - new CorrectionSuggestion("recieve", "receive") + new CorrectionSuggestion("recieve", "receive"), ]); Assert.Equal(2, learned.Count); @@ -527,7 +527,7 @@ DictionaryEntrySource source EntryType = DictionaryEntryType.Correction, Original = "teh", Replacement = "the", - Source = source + Source = source, } ); @@ -590,7 +590,7 @@ public void LearnCorrections_WithinBatchDuplicateOriginals_FirstWins() { var learned = _sut.LearnCorrections([ new CorrectionSuggestion("teh", "the"), - new CorrectionSuggestion("TEH", "thee") + new CorrectionSuggestion("TEH", "thee"), ]); Assert.Single(learned); @@ -604,7 +604,7 @@ public void UndoLearnedCorrections_RemovesOnlyListedIdsAndLeavesTheRest() { var learned = _sut.LearnCorrections([ new CorrectionSuggestion("teh", "the"), - new CorrectionSuggestion("recieve", "receive") + new CorrectionSuggestion("recieve", "receive"), ]); _sut.AddEntry( new DictionaryEntry @@ -613,7 +613,7 @@ public void UndoLearnedCorrections_RemovesOnlyListedIdsAndLeavesTheRest() EntryType = DictionaryEntryType.Correction, Original = "seperate", Replacement = "separate", - Source = DictionaryEntrySource.Manual + Source = DictionaryEntrySource.Manual, } ); @@ -668,7 +668,7 @@ public void ExportToCsv_IncludesMetadataAndEscapesFields() CaseSensitive = true, IsStarred = true, Priority = 7, - Source = DictionaryEntrySource.CorrectionSuggestion + Source = DictionaryEntrySource.CorrectionSuggestion, } ); @@ -726,7 +726,7 @@ public void ImportFromCsv_UpdatesExistingCorrectionByOriginalIgnoringCase() Original = "wispr", Replacement = "Wispr", Priority = 5, - Source = DictionaryEntrySource.Manual + Source = DictionaryEntrySource.Manual, } ); @@ -762,7 +762,7 @@ public void ImportFromCsv_ExactDuplicateCorrectionIsNoOp() IsStarred = true, UsageCount = 12, Priority = 5, - Source = DictionaryEntrySource.Manual + Source = DictionaryEntrySource.Manual, } ); @@ -809,7 +809,7 @@ public void ImportFromCsv_SkipsDuplicatesAndInvalidCorrections() { Id = "existing", EntryType = DictionaryEntryType.Term, - Original = "TypeWhisper" + Original = "TypeWhisper", } ); @@ -844,7 +844,7 @@ public void UpdateEntry_ModifiesEntry() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); @@ -865,7 +865,7 @@ public void EntriesChanged_FiresOnModification() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); _sut.DeleteEntry("1"); diff --git a/tests/TypeWhisper.Core.Tests/Services/HistoryInsightsServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/HistoryInsightsServiceTests.cs index bf558f448..65e834339 100644 --- a/tests/TypeWhisper.Core.Tests/Services/HistoryInsightsServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/HistoryInsightsServiceTests.cs @@ -40,7 +40,7 @@ public void Build_ComputesAveragesAndTopApps() true, promptApplied: true, translationApplied: true - ) + ), }; var result = _sut.Build(records); @@ -108,7 +108,7 @@ private static TranscriptionRecord Record( SnippetApplied = snippetApplied, DictionaryCorrectionApplied = dictionaryApplied, PromptActionApplied = promptApplied, - TranslationApplied = translationApplied + TranslationApplied = translationApplied, }; } } diff --git a/tests/TypeWhisper.Core.Tests/Services/HistoryServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/HistoryServiceTests.cs index 9270ae3a2..1ce97cfc3 100644 --- a/tests/TypeWhisper.Core.Tests/Services/HistoryServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/HistoryServiceTests.cs @@ -43,7 +43,7 @@ public void ModelUsed_PersistsCorrectly() RawText = "hello", FinalText = "hello", EngineUsed = "plugin:com.test:model-1", - ModelUsed = "plugin:com.test:model-1" + ModelUsed = "plugin:com.test:model-1", }; _sut.AddRecord(record); @@ -61,7 +61,7 @@ public void ModelUsed_NullByDefault() Id = Guid.NewGuid().ToString(), Timestamp = DateTime.UtcNow, RawText = "test", - FinalText = "test" + FinalText = "test", }; _sut.AddRecord(record); @@ -81,7 +81,7 @@ public void InsertionMetadata_PersistsCorrectly() RawText = "hello", FinalText = "hello", InsertionStatus = TextInsertionStatus.MissingPasteTool, - InsertionFailureReason = "Automatic paste tool is unavailable." + InsertionFailureReason = "Automatic paste tool is unavailable.", }; _sut.AddRecord(record); @@ -100,7 +100,7 @@ public void PendingCorrectionSuggestions_PersistCorrectly() Id = Guid.NewGuid().ToString(), Timestamp = DateTime.UtcNow, RawText = "hello", - FinalText = "hello" + FinalText = "hello", }; _sut.AddRecord(record); @@ -130,8 +130,8 @@ public void ExportToMarkdown_FormatsCorrectly() FinalText = "Hello, world!", AppProcessName = "notepad", DurationSeconds = 2.5, - Language = "en" - } + Language = "en", + }, }; var result = _sut.ExportToMarkdown(records); @@ -155,8 +155,8 @@ public void ExportToCsv_EscapesLabelsAppTextAndLanguage() FinalText = "Hello, \"world\"", AppProcessName = "browser, tab", DurationSeconds = 1.5, - Language = "en,us" - } + Language = "en,us", + }, }; var result = _sut.ExportToCsv(records); @@ -180,8 +180,8 @@ public void ExportToJson_ProducesValidJson() AppProcessName = "code", DurationSeconds = 1.0, Language = "en", - InsertionStatus = TextInsertionStatus.Pasted - } + InsertionStatus = TextInsertionStatus.Pasted, + }, }; var result = _sut.ExportToJson(records); @@ -259,7 +259,7 @@ private static TranscriptionRecord CreateRecord( CreatedAt = createdAt, RawText = "test", FinalText = "test", - AudioFileName = audioFileName + AudioFileName = audioFileName, }; } } \ No newline at end of file diff --git a/tests/TypeWhisper.Core.Tests/Services/MatchProfileCascadeTests.cs b/tests/TypeWhisper.Core.Tests/Services/MatchProfileCascadeTests.cs index 76d90ae26..974a7a576 100644 --- a/tests/TypeWhisper.Core.Tests/Services/MatchProfileCascadeTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/MatchProfileCascadeTests.cs @@ -161,7 +161,7 @@ int priority IsEnabled = true, Priority = priority, ProcessNames = processNames, - UrlPatterns = urlPatterns + UrlPatterns = urlPatterns, }; } } \ No newline at end of file diff --git a/tests/TypeWhisper.Core.Tests/Services/PostProcessingPipelineTests.cs b/tests/TypeWhisper.Core.Tests/Services/PostProcessingPipelineTests.cs index 2798886a4..bbe90eef9 100644 --- a/tests/TypeWhisper.Core.Tests/Services/PostProcessingPipelineTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/PostProcessingPipelineTests.cs @@ -143,7 +143,7 @@ public async Task ProcessAsync_DictionaryCorrections_Applied() { var options = new PipelineOptions { - DictionaryCorrector = text => text.Replace("teh", "the") + DictionaryCorrector = text => text.Replace("teh", "the"), }; var result = await _sut.ProcessAsync("teh quick fox", options); @@ -155,7 +155,7 @@ public async Task ProcessAsync_SnippetExpansion_Applied() { var options = new PipelineOptions { - SnippetExpander = text => text.Replace("brb", "be right back") + SnippetExpander = text => text.Replace("brb", "be right back"), }; var result = await _sut.ProcessAsync("brb", options); @@ -169,7 +169,7 @@ public async Task ProcessAsync_ReturnsStepChangeMetadata() { CleanupHandler = (text, _) => Task.FromResult(text.Trim()), SnippetExpander = text => text.Replace("brb", "be right back"), - DictionaryCorrector = text => text + DictionaryCorrector = text => text, }; var result = await _sut.ProcessAsync(" brb ", options); @@ -185,7 +185,7 @@ public async Task ProcessAsync_LlmHandler_Applied() { var options = new PipelineOptions { - LlmHandler = (text, _) => Task.FromResult(text.ToUpperInvariant()) + LlmHandler = (text, _) => Task.FromResult(text.ToUpperInvariant()), }; var result = await _sut.ProcessAsync("hello", options); @@ -198,7 +198,7 @@ public async Task ProcessAsync_RequiredLlmHandlerFailure_Throws() var options = new PipelineOptions { LlmHandler = (_, _) => throw new InvalidOperationException("LLM failed"), - RequireLlmSuccess = true + RequireLlmSuccess = true, }; var ex = await Assert.ThrowsAsync(() => @@ -224,7 +224,7 @@ public async Task ProcessAsync_Translation_Applied() { TranslationHandler = (text, _, tgt, _) => Task.FromResult($"[{tgt}] {text}"), TranslationTarget = "fr", - DetectedLanguage = "en" + DetectedLanguage = "en", }; var result = await _sut.ProcessAsync("hello", options); @@ -244,7 +244,7 @@ public async Task ProcessAsync_Translation_UsesDetectedLanguageWhenEffectiveLang }, TranslationTarget = "it", EffectiveSourceLanguage = "it", - DetectedLanguage = "en" + DetectedLanguage = "en", }; var result = await _sut.ProcessAsync("ciao mondo", options); @@ -260,7 +260,7 @@ public async Task ProcessAsync_Translation_SkippedWhenSameLanguage() { TranslationHandler = (text, _, tgt, _) => Task.FromResult($"[{tgt}] {text}"), TranslationTarget = "en", - DetectedLanguage = "en" + DetectedLanguage = "en", }; var result = await _sut.ProcessAsync("hello", options); @@ -283,7 +283,7 @@ public async Task ProcessAsync_PriorityOrdering_PluginsBeforeLlm() executionOrder.Add("Plugin100"); return Task.FromResult(text + "+P100"); } - ) + ), ], LlmHandler = (text, _) => { @@ -304,7 +304,7 @@ public async Task ProcessAsync_PriorityOrdering_PluginsBeforeLlm() { executionOrder.Add("Dictionary"); return text + "+DICT"; - } + }, }; var result = await _sut.ProcessAsync("start", options); @@ -330,7 +330,7 @@ public async Task ProcessAsync_Cleanup_RunsBeforeLlmAndSnippets() executionOrder.Add("Plugin100"); return Task.FromResult(text + "+P100"); } - ) + ), ], CleanupHandler = (text, _) => { @@ -346,7 +346,7 @@ public async Task ProcessAsync_Cleanup_RunsBeforeLlmAndSnippets() { executionOrder.Add("Snippets"); return text + "+SNP"; - } + }, }; var result = await _sut.ProcessAsync("start", options); @@ -387,8 +387,8 @@ public async Task ProcessAsync_MultiplePlugins_SortedByPriority() executionOrder.Add("Plugin400"); return Task.FromResult(text + "+P400"); } - ) - ] + ), + ], }; var result = await _sut.ProcessAsync("start", options); @@ -414,7 +414,7 @@ public async Task ProcessAsync_PluginBetweenLlmAndSnippets() executionOrder.Add("Plugin400"); return Task.FromResult(text); } - ) + ), ], LlmHandler = (text, _) => { @@ -425,7 +425,7 @@ public async Task ProcessAsync_PluginBetweenLlmAndSnippets() { executionOrder.Add("Snippets"); return text; - } + }, }; await _sut.ProcessAsync("test", options); @@ -444,9 +444,9 @@ public async Task ProcessAsync_ErrorResilience_ContinuesAfterFailure() new PluginPostProcessor( 100, (_, _) => throw new InvalidOperationException("Plugin failed") - ) + ), ], - DictionaryCorrector = text => text + "+DICT" + DictionaryCorrector = text => text + "+DICT", }; var result = await _sut.ProcessAsync("hello", options); @@ -473,7 +473,7 @@ public async Task ProcessAsync_InternalCleanupCancellation_ContinuesAfterFailure await Task.Delay(Timeout.Infinite, privateCts.Token); return text; }, - SnippetExpander = text => text + "+SNIPPET" + SnippetExpander = text => text + "+SNIPPET", }; var result = await _sut.ProcessAsync("hello", options, CancellationToken.None); @@ -503,9 +503,9 @@ public async Task ProcessAsync_InternalPluginTaskCancellation_ContinuesAfterFail null, unrelatedCts.Token ) - ) + ), ], - DictionaryCorrector = text => text + "+DICT" + DictionaryCorrector = text => text + "+DICT", }; var result = await _sut.ProcessAsync("hello", options, CancellationToken.None); @@ -517,7 +517,7 @@ public async Task ProcessAsync_InternalPluginTaskCancellation_ContinuesAfterFail { Name: "Plugin(100)", Succeeded: false, - ErrorMessage: "Simulated internal HTTP timeout" + ErrorMessage: "Simulated internal HTTP timeout", } ); } @@ -533,7 +533,7 @@ public async Task ProcessAsync_Translation_UsesAutoWhenSourceUnknown() sourceLanguage = src; return Task.FromResult(text); }, - TranslationTarget = "fr" + TranslationTarget = "fr", }; await _sut.ProcessAsync("bonjour", options); @@ -572,8 +572,8 @@ public async Task ProcessAsync_StepCancelsCallerTokenThenThrows_Propagates() throw new OperationCanceledException(cts.Token); // ReSharper restore AccessToDisposedClosure } - ) - ] + ), + ], }; await Assert.ThrowsAsync(() => @@ -596,7 +596,7 @@ public async Task ProcessAsync_StatusCallback_CalledForLlmAndTranslation() { statusCalls.Add(status); return Task.CompletedTask; - } + }, }; await _sut.ProcessAsync("test", options); @@ -623,7 +623,7 @@ public async Task ProcessAsync_TranslationAlwaysLast() return Task.FromResult(text); }, TranslationTarget = "fr", - DetectedLanguage = "en" + DetectedLanguage = "en", }; await _sut.ProcessAsync("test", options); @@ -648,7 +648,7 @@ public async Task ProcessAsync_VocabularyBoosting_RunsBeforeDictionary() { executionOrder.Add("Dictionary"); return text.Replace("TypeWhisper", "TYPEWHISPER"); - } + }, }; var result = await _sut.ProcessAsync("type whisper", options); @@ -673,7 +673,7 @@ public async Task ProcessAsync_OutlookFormatting_DoesNotEmitHtmlTags() var options = new PipelineOptions { AppFormatter = AppFormatterService.Format, - TargetProcessName = "OUTLOOK" + TargetProcessName = "OUTLOOK", }; var result = await _sut.ProcessAsync("- one\n- two", options); diff --git a/tests/TypeWhisper.Core.Tests/Services/PromptActionServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/PromptActionServiceTests.cs index 88b95232c..29191a359 100644 --- a/tests/TypeWhisper.Core.Tests/Services/PromptActionServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/PromptActionServiceTests.cs @@ -32,7 +32,7 @@ public void AddAction_PersistsAndLoads() Id = "1", Name = "Test Prompt", SystemPrompt = "Do something", - Icon = "\U0001F680" + Icon = "\U0001F680", } ); @@ -51,7 +51,7 @@ public void UpdateAction_PersistsChanges() { Id = "1", Name = "Original", - SystemPrompt = "Original prompt" + SystemPrompt = "Original prompt", } ); @@ -60,7 +60,7 @@ public void UpdateAction_PersistsChanges() { Id = "1", Name = "Updated", - SystemPrompt = "Updated prompt" + SystemPrompt = "Updated prompt", } ); @@ -78,7 +78,7 @@ public void DeleteAction_RemovesFromStorage() { Id = "1", Name = "A", - SystemPrompt = "a" + SystemPrompt = "a", } ); _sut.AddAction( @@ -86,7 +86,7 @@ public void DeleteAction_RemovesFromStorage() { Id = "2", Name = "B", - SystemPrompt = "b" + SystemPrompt = "b", } ); @@ -130,7 +130,7 @@ public void EnabledActions_FiltersAndSorts() Name = "C", SystemPrompt = "c", SortOrder = 2, - IsEnabled = true + IsEnabled = true, } ); _sut.AddAction( @@ -140,7 +140,7 @@ public void EnabledActions_FiltersAndSorts() Name = "A", SystemPrompt = "a", SortOrder = 0, - IsEnabled = true + IsEnabled = true, } ); _sut.AddAction( @@ -150,7 +150,7 @@ public void EnabledActions_FiltersAndSorts() Name = "B", SystemPrompt = "b", SortOrder = 1, - IsEnabled = false + IsEnabled = false, } ); @@ -169,7 +169,7 @@ public void Reorder_UpdatesSortOrder() Id = "1", Name = "First", SystemPrompt = "a", - SortOrder = 0 + SortOrder = 0, } ); _sut.AddAction( @@ -178,7 +178,7 @@ public void Reorder_UpdatesSortOrder() Id = "2", Name = "Second", SystemPrompt = "b", - SortOrder = 1 + SortOrder = 1, } ); _sut.AddAction( @@ -187,7 +187,7 @@ public void Reorder_UpdatesSortOrder() Id = "3", Name = "Third", SystemPrompt = "c", - SortOrder = 2 + SortOrder = 2, } ); @@ -211,7 +211,7 @@ public void ActionsChanged_FiresOnAdd() { Id = "1", Name = "Test", - SystemPrompt = "test" + SystemPrompt = "test", } ); @@ -228,7 +228,7 @@ public void ProviderOverride_PersistsCorrectly() Name = "With Provider", SystemPrompt = "test", ProviderOverride = "plugin:com.test:model-1", - ModelOverride = "model-1" + ModelOverride = "model-1", } ); @@ -248,7 +248,7 @@ public void TargetActionPluginId_PersistsCorrectly() Name = "With Target", SystemPrompt = "test", TargetActionPluginId = "com.test.linear", - HotkeyKey = "Ctrl+Shift+L" + HotkeyKey = "Ctrl+Shift+L", } ); @@ -266,7 +266,7 @@ public void TargetActionPluginId_NullByDefault() { Id = "1", Name = "Normal", - SystemPrompt = "test" + SystemPrompt = "test", } ); @@ -285,7 +285,7 @@ public void IsManualOnly_PersistsCorrectly() Id = "1", Name = "Manual", SystemPrompt = "test", - IsManualOnly = true + IsManualOnly = true, } ); @@ -326,7 +326,7 @@ public void AddAction_WhenSaveFails_ThrowsWithoutChangingCacheFileOrEvent() { Id = "new", Name = "New", - SystemPrompt = "Do not persist" + SystemPrompt = "Do not persist", } ) ); diff --git a/tests/TypeWhisper.Core.Tests/Services/SettingsServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/SettingsServiceTests.cs index 3ca823e7b..ef62802d8 100644 --- a/tests/TypeWhisper.Core.Tests/Services/SettingsServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/SettingsServiceTests.cs @@ -10,7 +10,7 @@ public sealed class SettingsServiceTests : IDisposable private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; private readonly string _filePath; @@ -64,8 +64,8 @@ public void SaveAndLoad_RoundTrips() AppInsertionStrategies = new Dictionary { ["kitty"] = TextInsertionStrategy.DirectTyping, - ["firefox"] = TextInsertionStrategy.ClipboardPaste - } + ["firefox"] = TextInsertionStrategy.ClipboardPaste, + }, }; sut.Save(settings); @@ -227,8 +227,8 @@ public async Task Update_ConcurrentDisjointMutations_AllSurvive() current.AppInsertionStrategies, StringComparer.OrdinalIgnoreCase) { - [$"app{idx}"] = TextInsertionStrategy.DirectTyping - } + [$"app{idx}"] = TextInsertionStrategy.DirectTyping, + }, }); }); } @@ -289,7 +289,7 @@ public void SaveAndLoad_RoundTripsMinuteBasedRetention() AppSettings.Default with { HistoryRetentionMode = HistoryRetentionMode.Duration, - HistoryRetentionMinutes = 60 + HistoryRetentionMinutes = 60, } ); @@ -306,7 +306,7 @@ public void SaveAndLoad_RoundTripsUntilAppClosesMode() sut.Save( AppSettings.Default with { - HistoryRetentionMode = HistoryRetentionMode.UntilAppCloses + HistoryRetentionMode = HistoryRetentionMode.UntilAppCloses, } ); diff --git a/tests/TypeWhisper.Core.Tests/Services/SnippetServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/SnippetServiceTests.cs index f272475d0..760bd36e3 100644 --- a/tests/TypeWhisper.Core.Tests/Services/SnippetServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/SnippetServiceTests.cs @@ -32,7 +32,7 @@ public void AddSnippet_WithTags_PersistsAndLoads() Id = "1", Trigger = "mfg", Replacement = "Mit freundlichen Grüßen", - Tags = "E-Mail,Gruß" + Tags = "E-Mail,Gruß", } ); @@ -49,7 +49,7 @@ public void ApplySnippets_ClipboardPlaceholder_ExpandsFromProvider() { Id = "1", Trigger = "link", - Replacement = "Siehe: {clipboard}" + Replacement = "Siehe: {clipboard}", } ); @@ -65,7 +65,7 @@ public void ApplySnippets_ClipboardPlaceholder_EmptyWhenNoProvider() { Id = "1", Trigger = "link", - Replacement = "Siehe: {clipboard}" + Replacement = "Siehe: {clipboard}", } ); @@ -81,7 +81,7 @@ public void ApplySnippets_CustomDateFormat_ExpandsCorrectly() { Id = "1", Trigger = "heute", - Replacement = "{date:dd.MM.yyyy}" + Replacement = "{date:dd.MM.yyyy}", } ); @@ -97,7 +97,7 @@ public void ApplySnippets_CustomTimeFormat_ExpandsCorrectly() { Id = "1", Trigger = "uhr", - Replacement = "{time:HH:mm:ss}" + Replacement = "{time:HH:mm:ss}", } ); @@ -114,7 +114,7 @@ public void ApplySnippets_StandardPlaceholders_StillWork() { Id = "1", Trigger = "datum", - Replacement = "{date}" + Replacement = "{date}", } ); _sut.AddSnippet( @@ -122,7 +122,7 @@ public void ApplySnippets_StandardPlaceholders_StillWork() { Id = "2", Trigger = "zeit", - Replacement = "{time}" + Replacement = "{time}", } ); _sut.AddSnippet( @@ -130,7 +130,7 @@ public void ApplySnippets_StandardPlaceholders_StillWork() { Id = "3", Trigger = "tag", - Replacement = "{day}" + Replacement = "{day}", } ); _sut.AddSnippet( @@ -138,7 +138,7 @@ public void ApplySnippets_StandardPlaceholders_StillWork() { Id = "4", Trigger = "jahr", - Replacement = "{year}" + Replacement = "{year}", } ); @@ -171,7 +171,7 @@ public void AllTags_ReturnsDistinctSortedTags() Id = "1", Trigger = "a", Replacement = "A", - Tags = "Code,E-Mail" + Tags = "Code,E-Mail", } ); _sut.AddSnippet( @@ -180,7 +180,7 @@ public void AllTags_ReturnsDistinctSortedTags() Id = "2", Trigger = "b", Replacement = "B", - Tags = "E-Mail,Datum" + Tags = "E-Mail,Datum", } ); _sut.AddSnippet( @@ -189,7 +189,7 @@ public void AllTags_ReturnsDistinctSortedTags() Id = "3", Trigger = "c", Replacement = "C", - Tags = "" + Tags = "", } ); @@ -209,7 +209,7 @@ public void ExportToJson_ReturnsValidJson() Id = "1", Trigger = "mfg", Replacement = "Grüße", - Tags = "E-Mail" + Tags = "E-Mail", } ); _sut.AddSnippet( @@ -217,7 +217,7 @@ public void ExportToJson_ReturnsValidJson() { Id = "2", Trigger = "sig", - Replacement = "Signatur\nZeile 2" + Replacement = "Signatur\nZeile 2", } ); @@ -236,7 +236,7 @@ public void ImportFromJson_AddsSnippets() { Id = "1", Trigger = "existing", - Replacement = "Existing" + Replacement = "Existing", } ); @@ -260,7 +260,7 @@ public void ImportFromJson_SkipsDuplicateTriggers() { Id = "1", Trigger = "mfg", - Replacement = "Grüße" + Replacement = "Grüße", } ); @@ -284,7 +284,7 @@ public void ApplySnippets_MultilineReplacement_Works() { Id = "1", Trigger = "sig", - Replacement = "Mit freundlichen Grüßen\nMarco Mustermann\nTypeWhisper GmbH" + Replacement = "Mit freundlichen Grüßen\nMarco Mustermann\nTypeWhisper GmbH", } ); @@ -305,7 +305,7 @@ public void ApplySnippets_ConsumesTrailingPunctuation(string input, string expec { Id = "1", Trigger = "mfg", - Replacement = "Mit freundlichen Grüßen" + Replacement = "Mit freundlichen Grüßen", } ); @@ -322,7 +322,7 @@ public void ApplySnippets_ExactPhraseTrigger_ReplacesWholeUtteranceOnly() Id = "1", Trigger = "sig", Replacement = "Signature", - TriggerMode = SnippetTriggerMode.ExactPhrase + TriggerMode = SnippetTriggerMode.ExactPhrase, } ); @@ -339,7 +339,7 @@ public void ApplySnippets_ProfileScopedSnippet_OnlyAppliesToMatchingProfile() Id = "1", Trigger = "sig", Replacement = "Profile signature", - ProfileIds = ["profile-1"] + ProfileIds = ["profile-1"], } ); @@ -356,7 +356,7 @@ public void ApplySnippets_GlobalSnippet_AppliesWhenProfileIsActive() { Id = "1", Trigger = "sig", - Replacement = "Global signature" + Replacement = "Global signature", } ); @@ -371,7 +371,7 @@ public void ApplySnippets_UpdatesLastUsedAt() { Id = "1", Trigger = "sig", - Replacement = "Signature" + Replacement = "Signature", } ); @@ -414,7 +414,7 @@ public void UpdateSnippet_WithTags_PersistsChanges() Id = "1", Trigger = "mfg", Replacement = "Grüße", - Tags = "Alt" + Tags = "Alt", } ); _sut.UpdateSnippet( @@ -423,7 +423,7 @@ public void UpdateSnippet_WithTags_PersistsChanges() Id = "1", Trigger = "mfg", Replacement = "Grüße", - Tags = "Neu" + Tags = "Neu", } ); @@ -449,7 +449,7 @@ public void AddSnippet_WhenSaveFails_ThrowsWithoutChangingCacheFileOrEvent() { Id = "new", Trigger = "new", - Replacement = "Do not persist" + Replacement = "Do not persist", } ) ); diff --git a/tests/TypeWhisper.Core.Tests/Services/SubtitleExporterTests.cs b/tests/TypeWhisper.Core.Tests/Services/SubtitleExporterTests.cs index 1d9c4c25f..213dc2dd3 100644 --- a/tests/TypeWhisper.Core.Tests/Services/SubtitleExporterTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/SubtitleExporterTests.cs @@ -10,7 +10,7 @@ public void ToSrt_SegmentPast24Hours_UsesTotalElapsedHours() { var segments = new List { - new("late cue", 90001.5, 90002.5) + new("late cue", 90001.5, 90002.5), }; var srt = SubtitleExporter.ToSrt(segments); @@ -23,7 +23,7 @@ public void ToWebVtt_SegmentPast24Hours_UsesTotalElapsedHours() { var segments = new List { - new("late cue", 90001.5, 90002.5) + new("late cue", 90001.5, 90002.5), }; var vtt = SubtitleExporter.ToWebVtt(segments); @@ -36,7 +36,7 @@ public void ToSrt_SegmentUnder24Hours_FormatsNormally() { var segments = new List { - new("normal cue", 3661.25, 3662.25) + new("normal cue", 3661.25, 3662.25), }; var srt = SubtitleExporter.ToSrt(segments); @@ -49,7 +49,7 @@ public void ToWebVtt_SegmentUnder24Hours_FormatsNormally() { var segments = new List { - new("normal cue", 3661.25, 3662.25) + new("normal cue", 3661.25, 3662.25), }; var vtt = SubtitleExporter.ToWebVtt(segments); diff --git a/tests/TypeWhisper.Core.Tests/Services/VocabularyBoostingServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/VocabularyBoostingServiceTests.cs index 942b3d1c6..2f56c91b2 100644 --- a/tests/TypeWhisper.Core.Tests/Services/VocabularyBoostingServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/VocabularyBoostingServiceTests.cs @@ -15,7 +15,7 @@ public void Apply_ExactTermAlreadyPresent_LeavesTextUnchanged() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "TypeWhisper" + Original = "TypeWhisper", } ); @@ -32,7 +32,7 @@ public void Apply_SingleWordTerm_RewritesSimilarRecognition() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "Parakeet" + Original = "Parakeet", } ); @@ -49,7 +49,7 @@ public void Apply_MultiWordWindow_RewritesToStoredTerm() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "TypeWhisper" + Original = "TypeWhisper", } ); @@ -66,7 +66,7 @@ public void Apply_LowSimilarity_DoesNotRewrite() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "Parakeet" + Original = "Parakeet", } ); @@ -83,13 +83,13 @@ public void Apply_AmbiguousMatchWithinMargin_DoesNotRewrite() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "Parakeet" + Original = "Parakeet", }, new DictionaryEntry { Id = "manual-2", EntryType = DictionaryEntryType.Term, - Original = "Parakeat" + Original = "Parakeat", } ); @@ -106,13 +106,13 @@ public void Apply_LongerTerm_WinsOverShorterOverlap() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "Visual Studio" + Original = "Visual Studio", }, new DictionaryEntry { Id = "manual-2", EntryType = DictionaryEntryType.Term, - Original = "Studio" + Original = "Studio", } ); @@ -129,13 +129,13 @@ public void Apply_ManualTerm_WinsOverPackVariant() { Id = "pack:dotnet:typewhisper", EntryType = DictionaryEntryType.Term, - Original = "typewhisper" + Original = "typewhisper", }, new DictionaryEntry { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "TypeWhisper" + Original = "TypeWhisper", } ); @@ -153,7 +153,7 @@ public void Apply_DisabledTerms_AreIgnored() Id = "manual-1", EntryType = DictionaryEntryType.Term, Original = "TypeWhisper", - IsEnabled = false + IsEnabled = false, } ); @@ -171,7 +171,7 @@ public void Apply_CorrectionEntries_AreIgnoredAsBoostSource() Id = "manual-1", EntryType = DictionaryEntryType.Correction, Original = "type whisper", - Replacement = "TypeWhisper" + Replacement = "TypeWhisper", } ); @@ -188,7 +188,7 @@ public void Apply_HyphenAndWhitespaceNormalization_RewritesToStoredForm() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "Type-Whisper" + Original = "Type-Whisper", } ); @@ -206,7 +206,7 @@ public void Apply_TermWithReplacement_UsesCanonicalReplacementAsOutput() Id = "manual-1", EntryType = DictionaryEntryType.Term, Original = "Type visped.", - Replacement = "TypeWhisper" + Replacement = "TypeWhisper", } ); diff --git a/tests/TypeWhisper.Linux.Tests/AppInsertionStrategyRowTests.cs b/tests/TypeWhisper.Linux.Tests/AppInsertionStrategyRowTests.cs index 857b45cc7..bf7c97853 100644 --- a/tests/TypeWhisper.Linux.Tests/AppInsertionStrategyRowTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AppInsertionStrategyRowTests.cs @@ -11,7 +11,7 @@ public sealed class AppInsertionStrategyRowTests new(TextInsertionStrategy.Auto, "Auto"), new(TextInsertionStrategy.ClipboardPaste, "Clipboard paste"), new(TextInsertionStrategy.DirectTyping, "Direct typing"), - new(TextInsertionStrategy.CopyOnly, "Copy only") + new(TextInsertionStrategy.CopyOnly, "Copy only"), ]; [Fact] @@ -25,7 +25,7 @@ public void SelectedStrategyOption_UpdatesStrategyAndNotifiesChange() () => changeCount++ ) { SelectedStrategyOption = s_options.First(option => option.Value == TextInsertionStrategy.DirectTyping - ) + ), }; Assert.Equal(TextInsertionStrategy.DirectTyping, sut.Strategy); diff --git a/tests/TypeWhisper.Linux.Tests/AppearanceSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/AppearanceSectionViewModelTests.cs index 82f26a652..9ad34c9e1 100644 --- a/tests/TypeWhisper.Linux.Tests/AppearanceSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AppearanceSectionViewModelTests.cs @@ -54,7 +54,7 @@ public void IsOverlayPositionCustomized_RequiresBothFields( AppSettings.Default with { OverlayCustomLeft = left, - OverlayCustomTop = top + OverlayCustomTop = top, }); var sut = new AppearanceSectionViewModel(settings.Object); @@ -69,7 +69,7 @@ public void ResetOverlayPositionCommand_ClearsBothFields() AppSettings.Default with { OverlayCustomLeft = 120.0, - OverlayCustomTop = 80.0 + OverlayCustomTop = 80.0, }); var sut = new AppearanceSectionViewModel(settings.Object); @@ -95,7 +95,7 @@ public void Refresh_PropagatesIsOverlayPositionCustomized() var updated = AppSettings.Default with { OverlayCustomLeft = 250.0, - OverlayCustomTop = 150.0 + OverlayCustomTop = 150.0, }; settings.SetupGet(s => s.Current).Returns(updated); settings.Raise(s => s.SettingsChanged += null, updated); diff --git a/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs index d8ab62aa7..14ee464a0 100644 --- a/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs @@ -178,7 +178,7 @@ Sink Input #7 0, string.Empty, "forced timeout" - ) + ), }; runner.RespondWith( (fileName, args) => fileName == "pactl" && args.SequenceEqual(list), diff --git a/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs b/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs index 581aa8496..3ea23e8d1 100644 --- a/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs @@ -109,7 +109,7 @@ public async Task RefusedClientCleanup_CannotUnlinkBoundOwnerBeforeListen() var request = new JsonControlProtocol.Request { Version = JsonControlProtocol.CurrentVersion, - Command = JsonControlProtocol.CmdStatus + Command = JsonControlProtocol.CmdStatus, }; Assert.False( ControlSocketClient.TrySendJson( diff --git a/tests/TypeWhisper.Linux.Tests/DashboardSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/DashboardSectionViewModelTests.cs index 7177c6b86..992aa52ad 100644 --- a/tests/TypeWhisper.Linux.Tests/DashboardSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DashboardSectionViewModelTests.cs @@ -148,7 +148,7 @@ private static TranscriptionRecord CreateRecord( SnippetApplied = snippetApplied, DictionaryCorrectionApplied = dictionaryApplied, PromptActionApplied = promptApplied, - TranslationApplied = translationApplied + TranslationApplied = translationApplied, }; } } \ No newline at end of file diff --git a/tests/TypeWhisper.Linux.Tests/DictationOrchestratorDiscardFeedbackTests.cs b/tests/TypeWhisper.Linux.Tests/DictationOrchestratorDiscardFeedbackTests.cs index 14cffb41f..1878c8a23 100644 --- a/tests/TypeWhisper.Linux.Tests/DictationOrchestratorDiscardFeedbackTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DictationOrchestratorDiscardFeedbackTests.cs @@ -66,7 +66,7 @@ public void Discard_reasons_resolve_their_distinct_localized_messages() var discardReason in new[] { LinuxShortSpeechDecision.DiscardTooShort, - LinuxShortSpeechDecision.DiscardNoSpeech + LinuxShortSpeechDecision.DiscardNoSpeech, } ) { diff --git a/tests/TypeWhisper.Linux.Tests/DictationOrchestratorPromptActionResolutionTests.cs b/tests/TypeWhisper.Linux.Tests/DictationOrchestratorPromptActionResolutionTests.cs index 4dad27f21..0b63e64fb 100644 --- a/tests/TypeWhisper.Linux.Tests/DictationOrchestratorPromptActionResolutionTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DictationOrchestratorPromptActionResolutionTests.cs @@ -19,7 +19,7 @@ public void ResolveAutoPromptAction_ReturnsActionWhenNotManualOnly() { Id = "auto", Name = "Auto", - SystemPrompt = "x" + SystemPrompt = "x", }; var resolved = DictationOrchestrator.ResolveAutoPromptAction("auto", [action]); @@ -35,7 +35,7 @@ public void ResolveAutoPromptAction_ReturnsNullWhenManualOnly() Id = "manual", Name = "Manual", SystemPrompt = "x", - IsManualOnly = true + IsManualOnly = true, }; var resolved = DictationOrchestrator.ResolveAutoPromptAction("manual", [action]); @@ -58,7 +58,7 @@ public void ResolveAutoPromptAction_ReturnsNullWhenIdDoesNotMatch() { Id = "other", Name = "Other", - SystemPrompt = "x" + SystemPrompt = "x", }; var resolved = DictationOrchestrator.ResolveAutoPromptAction("missing", [action]); diff --git a/tests/TypeWhisper.Linux.Tests/DictationShortcutSpecFactoryTests.cs b/tests/TypeWhisper.Linux.Tests/DictationShortcutSpecFactoryTests.cs index 7b45421b8..7d7ac8ce5 100644 --- a/tests/TypeWhisper.Linux.Tests/DictationShortcutSpecFactoryTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DictationShortcutSpecFactoryTests.cs @@ -117,7 +117,7 @@ private SettingsService CreateSettings(RecordingMode mode, string trigger = "Ctr settings.Current with { Mode = mode, - ToggleHotkey = trigger + ToggleHotkey = trigger, } ); return settings; @@ -131,7 +131,7 @@ private static IDeShortcutWriter CreateWriter(string writerId) "sway" => new SwayShortcutWriter(), "gnome" => new GnomeShortcutWriter(), "kde" => new KdeShortcutWriter(), - _ => throw new ArgumentOutOfRangeException(nameof(writerId), writerId, null) + _ => throw new ArgumentOutOfRangeException(nameof(writerId), writerId, null), }; } } diff --git a/tests/TypeWhisper.Linux.Tests/DictionarySectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/DictionarySectionViewModelTests.cs index 162d02a9c..b302a4781 100644 --- a/tests/TypeWhisper.Linux.Tests/DictionarySectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DictionarySectionViewModelTests.cs @@ -59,7 +59,7 @@ public void EntryControls_UpdateStarredAndPriority() EntryType = DictionaryEntryType.Correction, Original = "wispr", Replacement = "Wispr", - Priority = 1 + Priority = 1, }; dictionary.AddEntry(entry); var sut = CreateViewModel(dictionary); @@ -86,22 +86,22 @@ public void Refresh_SortsStarredAndHighPriorityFirst() { Id = "low", EntryType = DictionaryEntryType.Term, - Original = "alpha" + Original = "alpha", }, new DictionaryEntry { Id = "priority", EntryType = DictionaryEntryType.Term, Original = "beta", - Priority = 5 + Priority = 5, }, new DictionaryEntry { Id = "starred", EntryType = DictionaryEntryType.Term, Original = "gamma", - IsStarred = true - } + IsStarred = true, + }, ]); var sut = CreateViewModel(dictionary); diff --git a/tests/TypeWhisper.Linux.Tests/EvdevDeviceReaderTests.cs b/tests/TypeWhisper.Linux.Tests/EvdevDeviceReaderTests.cs index 8c45048cb..074bddd07 100644 --- a/tests/TypeWhisper.Linux.Tests/EvdevDeviceReaderTests.cs +++ b/tests/TypeWhisper.Linux.Tests/EvdevDeviceReaderTests.cs @@ -38,7 +38,7 @@ public async Task SynDropped_ReconcilesInDeterministicOrderAndResumesStream() { var device = new FakeInputDevice { - Snapshot = Bitmap(LinuxKeyMap.KeyLeftctrl, 30) // LeftCtrl + KEY_A. + Snapshot = Bitmap(LinuxKeyMap.KeyLeftctrl, 30), // LeftCtrl + KEY_A. }; var events = new EventLog(); var failure = NewFailureSignal(); @@ -68,7 +68,7 @@ public async Task SynDropped_ReconcilesInDeterministicOrderAndResumesStream() new KeyEdge(LinuxKeyMap.KeyLeftshift, false), new KeyEdge(LinuxKeyMap.KeyLeftctrl, true), // Presses: modifier before terminal. new KeyEdge(30, true), - new KeyEdge(30, false) + new KeyEdge(30, false), ], events.Snapshot() ); diff --git a/tests/TypeWhisper.Linux.Tests/EvdevGlobalShortcutBackendTests.cs b/tests/TypeWhisper.Linux.Tests/EvdevGlobalShortcutBackendTests.cs index f954db289..e1c4b157c 100644 --- a/tests/TypeWhisper.Linux.Tests/EvdevGlobalShortcutBackendTests.cs +++ b/tests/TypeWhisper.Linux.Tests/EvdevGlobalShortcutBackendTests.cs @@ -20,7 +20,7 @@ public async Task SameModifierHeldByTwoReaders_OneReleaseKeepsRemainingChordUsab var shortcuts = DefaultShortcuts() with { - DictationModifiers = ModifierMask.LeftCtrl + DictationModifiers = ModifierMask.LeftCtrl, }; Assert.True((await backend.RegisterAsync(shortcuts, CancellationToken.None)).Success); var readers = factory.Readers; @@ -46,7 +46,7 @@ public async Task ReaderFailure_SubtractsOnlyFailedReaderState() var shortcuts = DefaultShortcuts() with { - DictationModifiers = ModifierMask.LeftCtrl + DictationModifiers = ModifierMask.LeftCtrl, }; Assert.True((await backend.RegisterAsync(shortcuts, CancellationToken.None)).Success); var readers = factory.Readers; @@ -77,7 +77,7 @@ public async Task ReaderFailure_ReleasesSoleModifierAndPreventsFalseLaterChord() { DictationModifiers = ModifierMask.LeftCtrl, PromptPaletteKey = KeyCode.VcLeftControl, - PromptPaletteModifiers = ModifierMask.None + PromptPaletteModifiers = ModifierMask.None, }; Assert.True((await backend.RegisterAsync(shortcuts, CancellationToken.None)).Success); var readers = factory.Readers; @@ -110,7 +110,7 @@ public async Task ReaderFailure_ReleasesHeldDictationKeyUsingPressTimeMode() var pushToTalk = DefaultShortcuts() with { DictationModifiers = ModifierMask.None, - Mode = RecordingMode.PushToTalk + Mode = RecordingMode.PushToTalk, }; Assert.True((await backend.RegisterAsync(pushToTalk, CancellationToken.None)).Success); var readers = factory.Readers; diff --git a/tests/TypeWhisper.Linux.Tests/FileTranscriptionSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/FileTranscriptionSectionViewModelTests.cs index 8c9583026..969d9512e 100644 --- a/tests/TypeWhisper.Linux.Tests/FileTranscriptionSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/FileTranscriptionSectionViewModelTests.cs @@ -45,7 +45,7 @@ public void ClearQueue_RemovesTerminalItems_KeepsActiveAndQueued() new[] { FileTranscriptionQueueItemStatus.Queued, - FileTranscriptionQueueItemStatus.Transcribing + FileTranscriptionQueueItemStatus.Transcribing, }, remaining); Assert.False(vm.HasClearableItems); @@ -139,7 +139,7 @@ private SettingsService CreateSettingsWithPoisonedWatchFolder(bool autoStart) settings.Current with { WatchFolderPath = Path.Join(poisonedParent, "watch-folder"), - WatchFolderAutoStart = autoStart + WatchFolderAutoStart = autoStart, } ); return settings; diff --git a/tests/TypeWhisper.Linux.Tests/GnomeShortcutWriterTests.cs b/tests/TypeWhisper.Linux.Tests/GnomeShortcutWriterTests.cs index d9f3f5cfc..98819d20b 100644 --- a/tests/TypeWhisper.Linux.Tests/GnomeShortcutWriterTests.cs +++ b/tests/TypeWhisper.Linux.Tests/GnomeShortcutWriterTests.cs @@ -547,7 +547,7 @@ private enum MutationMode { None, AfterFirstGet, - AfterEveryGet + AfterEveryGet, } private sealed class StatefulGSettingsRunner : IProcessRunner diff --git a/tests/TypeWhisper.Linux.Tests/HistorySectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/HistorySectionViewModelTests.cs index 6e6362d85..a345d162f 100644 --- a/tests/TypeWhisper.Linux.Tests/HistorySectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HistorySectionViewModelTests.cs @@ -126,7 +126,7 @@ private SettingsService CreateSettingsService( AppSettings.Default with { AutoAddDictionaryCorrections = autoAddCorrections, - CaptureLlmProvenance = captureProvenance + CaptureLlmProvenance = captureProvenance, } ); return settings; @@ -165,7 +165,7 @@ private static TranscriptionRecord CreateRecord( FinalText = finalText, DurationSeconds = 2.4, AppProcessName = "test", - LlmCalls = llmCalls ?? [] + LlmCalls = llmCalls ?? [], }; } @@ -186,7 +186,7 @@ private static LlmCallProvenance CreateCall( ProviderId = "com.test.provider", ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = injectedContext + InjectedMemoryContext = injectedContext, }; } @@ -203,7 +203,7 @@ public void InspectorCalls_ProjectsProvenanceWithLabels() CreateCall( "Cleanup", injectedContext: "remembered fact" - ) + ), ] ); history.AddRecord(record); diff --git a/tests/TypeWhisper.Linux.Tests/HotkeyServiceTests.cs b/tests/TypeWhisper.Linux.Tests/HotkeyServiceTests.cs index 8a82e3800..b7fd76e87 100644 --- a/tests/TypeWhisper.Linux.Tests/HotkeyServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HotkeyServiceTests.cs @@ -85,8 +85,8 @@ public void ValidatePromptActionHotkeyCandidate_RejectsEnabledPromptActionCollis Id = "other", Name = "Other", SystemPrompt = "x", - HotkeyKey = "Alt+F8" - } + HotkeyKey = "Alt+F8", + }, }; var result = hotkey.ValidatePromptActionHotkeyCandidate( @@ -112,8 +112,8 @@ public void ValidatePromptActionHotkeyCandidate_UsesCrossSideModifierPrefixForPr { Id = "other", Name = "Other", - HotkeyData = "Right Ctrl" - } + HotkeyData = "Right Ctrl", + }, }; var result = hotkey.ValidatePromptActionHotkeyCandidate( @@ -140,7 +140,7 @@ public void ValidateCandidates_AllowOwnUnchangedBindingAndIgnoreDisabledOthers() Id = "edited-action", Name = "Edited", SystemPrompt = "x", - HotkeyKey = "alt+f8" + HotkeyKey = "alt+f8", }, new PromptAction { @@ -148,8 +148,8 @@ public void ValidateCandidates_AllowOwnUnchangedBindingAndIgnoreDisabledOthers() Name = "Disabled", SystemPrompt = "x", HotkeyKey = "Alt+F8", - IsEnabled = false - } + IsEnabled = false, + }, }; var profiles = new[] { @@ -157,15 +157,15 @@ public void ValidateCandidates_AllowOwnUnchangedBindingAndIgnoreDisabledOthers() { Id = "edited-profile", Name = "Edited", - HotkeyData = "Meta+F9" + HotkeyData = "Meta+F9", }, new Profile { Id = "disabled-profile", Name = "Disabled", HotkeyData = "Meta+F9", - IsEnabled = false - } + IsEnabled = false, + }, }; var actionResult = hotkey.ValidatePromptActionHotkeyCandidate( @@ -223,7 +223,7 @@ public void ValidateProfileHotkeyCandidate_RequiresUsableSelectedTextDestination Id = "disabled", Name = "Disabled", SystemPrompt = "x", - IsEnabled = false + IsEnabled = false, }; var enabled = disabled with { Id = "enabled", Name = "Enabled", IsEnabled = true }; @@ -297,7 +297,7 @@ public async Task Initialize_RecordsRequiresToggleModeFromBackend() null, true, null - ) + ), }; using var hotkey = new HotkeyService(new BackendSelector(() => backend)); @@ -319,7 +319,7 @@ public async Task PushShortcuts_FailedRegistration_RaisesHookFailed() "boom", false, null - ) + ), }; using var hotkey = new HotkeyService(new BackendSelector(() => backend)); string? observed = null; @@ -396,7 +396,7 @@ public async Task SetPromptActionHotkeys_DropsEntryCollidingWithDictation() "keeper", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt - ) + ), ] ); @@ -425,7 +425,7 @@ public async Task SetPromptActionHotkeys_KeepsFirstDuplicateAndDropsSecond() "second", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt - ) + ), ] ); @@ -461,7 +461,7 @@ public async Task SetPromptActionHotkeys_DropsIntraBatchPrefixCollision() "ctrl-chord", KeyCode.VcF12, ModifierMask.LeftCtrl - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -512,7 +512,7 @@ public async Task TrySetHotkeyFromString_RejectsChordAlreadyBoundToPromptAction( "alpha", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -536,7 +536,7 @@ public async Task SetPromptActionHotkeys_AcceptsUnchangedListWithoutSelfConflict var entries = new[] { new PromptActionHotkey("alpha", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt), - new PromptActionHotkey("beta", KeyCode.VcT, ModifierMask.LeftCtrl | ModifierMask.LeftAlt) + new PromptActionHotkey("beta", KeyCode.VcT, ModifierMask.LeftCtrl | ModifierMask.LeftAlt), }; hotkey.SetPromptActionHotkeys(entries); @@ -559,7 +559,7 @@ public void ParsePromptActionHotkeys_SkipsDisabledOrUnparseableActions() Id = "enabled", Name = "E", SystemPrompt = "x", - HotkeyKey = "Ctrl+Alt+R" + HotkeyKey = "Ctrl+Alt+R", }, new PromptAction { @@ -567,21 +567,21 @@ public void ParsePromptActionHotkeys_SkipsDisabledOrUnparseableActions() Name = "D", SystemPrompt = "x", IsEnabled = false, - HotkeyKey = "Ctrl+Alt+T" + HotkeyKey = "Ctrl+Alt+T", }, new PromptAction { Id = "no-hotkey", Name = "N", - SystemPrompt = "x" + SystemPrompt = "x", }, new PromptAction { Id = "bad", Name = "B", SystemPrompt = "x", - HotkeyKey = "Not+a+real+combo" - } + HotkeyKey = "Not+a+real+combo", + }, ] ); @@ -605,7 +605,7 @@ public async Task SetProfileHotkeys_RaisesProfileDictationToggleRequestedWithId( KeyCode.VcE, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -633,7 +633,7 @@ public async Task SetProfileHotkeys_RaisesProfileTextProcessingRequestedWithId() KeyCode.VcS, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.ProcessSelectedText - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -666,7 +666,7 @@ public async Task SetProfileHotkeys_DropsEntryCollidingWithDictation() KeyCode.VcE, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -702,7 +702,7 @@ [new PromptActionHotkey("action", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierM KeyCode.VcE, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -732,7 +732,7 @@ public async Task SetProfileHotkeys_KeepsFirstDuplicateAndDropsSecond() KeyCode.VcE, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.ProcessSelectedText - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -780,7 +780,7 @@ await Task.WhenAll( foreach (var snapshot in new[] { actionFirstBackend.LastSet, - profileFirstBackend.LastSet + profileFirstBackend.LastSet, }) { Assert.NotNull(snapshot); @@ -808,7 +808,7 @@ [new PromptActionHotkey("action", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierM KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -829,7 +829,7 @@ public async Task DynamicHotkeys_IncrementalResultMatchesFreshCombinedReconcilia PromptActionHotkey[] actions = [ new("action-winner", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt), - new("action-only", KeyCode.VcT, ModifierMask.LeftCtrl | ModifierMask.LeftAlt) + new("action-only", KeyCode.VcT, ModifierMask.LeftCtrl | ModifierMask.LeftAlt), ]; ProfileHotkey[] profiles = [ @@ -844,7 +844,7 @@ public async Task DynamicHotkeys_IncrementalResultMatchesFreshCombinedReconcilia KeyCode.VcE, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.ProcessSelectedText - ) + ), ]; var incrementalBackend = new TestShortcutBackend(); var freshBackend = new TestShortcutBackend(); @@ -905,7 +905,7 @@ public async Task SetDynamicHotkeys_ReturnsIdentifyingMessageForEveryRejection() "", KeyCode.VcT, ModifierMask.LeftCtrl | ModifierMask.LeftAlt - ) + ), ], [ new ProfileHotkey( @@ -919,7 +919,7 @@ public async Task SetDynamicHotkeys_ReturnsIdentifyingMessageForEveryRejection() KeyCode.VcE, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.ProcessSelectedText - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -980,7 +980,7 @@ public async Task DynamicHotkeys_DefensivelySnapshotsRetainedCandidates() KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.StartDictation - ) + ), }; hotkey.SetProfileHotkeys(profiles); @@ -1009,33 +1009,33 @@ public void ParseProfileHotkeys_SkipsDisabledBlankUnparseable_AndCarriesBehavior Id = "dictate", Name = "Dictate", HotkeyData = "Ctrl+Alt+E", - HotkeyBehavior = ProfileHotkeyBehavior.StartDictation + HotkeyBehavior = ProfileHotkeyBehavior.StartDictation, }, new Profile { Id = "selection", Name = "Selection", HotkeyData = "Ctrl+Alt+S", - HotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText + HotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText, }, new Profile { Id = "disabled", Name = "Disabled", IsEnabled = false, - HotkeyData = "Ctrl+Alt+T" + HotkeyData = "Ctrl+Alt+T", }, new Profile { Id = "no-hotkey", - Name = "None" + Name = "None", }, new Profile { Id = "bad", Name = "Bad", - HotkeyData = "Not+a+real+combo" - } + HotkeyData = "Not+a+real+combo", + }, ] ); @@ -1064,7 +1064,7 @@ public async Task TrySetHotkeyFromString_RejectsChordAlreadyBoundToProfile() KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -1308,7 +1308,7 @@ public async Task SetPromptActionHotkeys_DropsEntryThatPrefixesExistingChord() "keeper", KeyCode.VcR, ModifierMask.LeftAlt | ModifierMask.LeftMeta - ) + ), ] ); @@ -1338,7 +1338,7 @@ [new PromptActionHotkey("action", KeyCode.VcF10, ModifierMask.LeftMeta)], KeyCode.VcF11, ModifierMask.LeftMeta, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); hotkey.IsCancelShortcutEnabled = true; @@ -1435,7 +1435,7 @@ [new PromptActionHotkey("action", KeyCode.VcF10, ModifierMask.LeftMeta)], KeyCode.VcF11, ModifierMask.LeftMeta, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); hotkey.Initialize(); diff --git a/tests/TypeWhisper.Linux.Tests/HttpApiAccelerationDtoTests.cs b/tests/TypeWhisper.Linux.Tests/HttpApiAccelerationDtoTests.cs index 2c5a9a77d..6232bb5e8 100644 --- a/tests/TypeWhisper.Linux.Tests/HttpApiAccelerationDtoTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HttpApiAccelerationDtoTests.cs @@ -25,11 +25,11 @@ public void BuildAccelerationDto_WhisperCppCpuPreference_ReflectsCpuStatus() AccelerationStatus = new TranscriptionAccelerationStatus( TranscriptionAccelerationBackend.Cpu, "Using CPU" - ) + ), }; var settings = AppSettings.Default with { - LocalModelAcceleration = AppSettings.LocalModelAccelerationCpu + LocalModelAcceleration = AppSettings.LocalModelAccelerationCpu, }; var dto = HttpApiService.BuildAccelerationDto(plugin, settings); @@ -51,11 +51,11 @@ public void BuildAccelerationDto_AutoPreferenceWithCpuLoaded_IncludesDetail() TranscriptionAccelerationBackend.Cpu, "Using CPU", "CUDA not available; falling back to CPU." - ) + ), }; var settings = AppSettings.Default with { - LocalModelAcceleration = AppSettings.LocalModelAccelerationAuto + LocalModelAcceleration = AppSettings.LocalModelAccelerationAuto, }; var dto = HttpApiService.BuildAccelerationDto(plugin, settings); @@ -78,11 +78,11 @@ public void BuildAccelerationDto_RequiresRestart_PropagatesFlag() "Using CPU", "Process is pinned to CPU. Restart to switch to NVIDIA CUDA.", RequiresRestart: true - ) + ), }; var settings = AppSettings.Default with { - LocalModelAcceleration = AppSettings.LocalModelAccelerationNvidiaCuda + LocalModelAcceleration = AppSettings.LocalModelAccelerationNvidiaCuda, }; var dto = HttpApiService.BuildAccelerationDto(plugin, settings); diff --git a/tests/TypeWhisper.Linux.Tests/HttpApiCorrectionsDtoTests.cs b/tests/TypeWhisper.Linux.Tests/HttpApiCorrectionsDtoTests.cs index b0a3167a0..2575a330b 100644 --- a/tests/TypeWhisper.Linux.Tests/HttpApiCorrectionsDtoTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HttpApiCorrectionsDtoTests.cs @@ -9,7 +9,7 @@ public class HttpApiCorrectionsDtoTests private static readonly JsonSerializerOptions s_options = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; [Fact] diff --git a/tests/TypeWhisper.Linux.Tests/HttpApiLocalFileDtoTests.cs b/tests/TypeWhisper.Linux.Tests/HttpApiLocalFileDtoTests.cs index be442b81f..8f50e3b5a 100644 --- a/tests/TypeWhisper.Linux.Tests/HttpApiLocalFileDtoTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HttpApiLocalFileDtoTests.cs @@ -9,7 +9,7 @@ public class HttpApiLocalFileDtoTests private static readonly JsonSerializerOptions s_options = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; [Fact] diff --git a/tests/TypeWhisper.Linux.Tests/InputAccessSetupHelperTests.cs b/tests/TypeWhisper.Linux.Tests/InputAccessSetupHelperTests.cs index 16593057a..92ace588b 100644 --- a/tests/TypeWhisper.Linux.Tests/InputAccessSetupHelperTests.cs +++ b/tests/TypeWhisper.Linux.Tests/InputAccessSetupHelperTests.cs @@ -159,7 +159,7 @@ private static int RunManualWriteBlock() RedirectStandardInput = true, RedirectStandardError = true, RedirectStandardOutput = true, - UseShellExecute = false + UseShellExecute = false, }; // Shim dir first so sudo/udevadm/usermod resolve to our stubs, then real // coreutils (head/cat) from the standard bin dirs. @@ -354,7 +354,7 @@ public async Task InstallAsync_passes_a_bounded_timeout_and_recovers_when_it_fir var runner = new FakeProcessRunner { // Model a stalled polkit prompt that outlives the timeout window. - Default = new ProcessRunResult(true, true, -1, string.Empty, string.Empty) + Default = new ProcessRunResult(true, true, -1, string.Empty, string.Empty), }; var helper = new InputAccessSetupHelper(runner); diff --git a/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs b/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs index c3feb04e8..53999c6b8 100644 --- a/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs @@ -52,7 +52,7 @@ public void ShowLearned_MultipleCorrections_UsesCountFormat() presenter.ShowLearned( [ Correction("1", "a", "A"), - Correction("2", "b", "B") + Correction("2", "b", "B"), ]); var feedback = Assert.Single(emitted); diff --git a/tests/TypeWhisper.Linux.Tests/LinuxDictationReadbackLanguagePolicyTests.cs b/tests/TypeWhisper.Linux.Tests/LinuxDictationReadbackLanguagePolicyTests.cs index 7ba78f11b..0b731da9b 100644 --- a/tests/TypeWhisper.Linux.Tests/LinuxDictationReadbackLanguagePolicyTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LinuxDictationReadbackLanguagePolicyTests.cs @@ -361,7 +361,7 @@ public void Resolve_IgnoresLanguagePreservingSteps() new(PostProcessingStepNames.Formatting, Changed: true), new(PostProcessingStepNames.Cleanup, Changed: true), new(PostProcessingStepNames.Dictionary, Changed: true), - new(PostProcessingStepNames.Snippets, Changed: true) + new(PostProcessingStepNames.Snippets, Changed: true), ]; var language = LinuxDictationReadbackLanguagePolicy.Resolve( diff --git a/tests/TypeWhisper.Linux.Tests/LinuxLiveTranscriptionStartupPolicyTests.cs b/tests/TypeWhisper.Linux.Tests/LinuxLiveTranscriptionStartupPolicyTests.cs index dd7dc1fd0..cf8e79fbd 100644 --- a/tests/TypeWhisper.Linux.Tests/LinuxLiveTranscriptionStartupPolicyTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LinuxLiveTranscriptionStartupPolicyTests.cs @@ -48,7 +48,7 @@ public void Select_CloudPluginWithoutOptIn_ReturnsNone() var settings = AppSettings.Default with { LiveTranscriptionEnabled = true, - OnlineAsrBatchLiveTranscriptionEnabled = false + OnlineAsrBatchLiveTranscriptionEnabled = false, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -56,7 +56,7 @@ public void Select_CloudPluginWithoutOptIn_ReturnsNone() new FakeTranscriptionEnginePlugin { SupportsModelDownload = false, - SupportsStreaming = false + SupportsStreaming = false, }); Assert.Equal(LiveTranscriptionMode.None, mode); @@ -68,7 +68,7 @@ public void Select_CloudPluginWithOptIn_ReturnsPolling() var settings = AppSettings.Default with { LiveTranscriptionEnabled = true, - OnlineAsrBatchLiveTranscriptionEnabled = true + OnlineAsrBatchLiveTranscriptionEnabled = true, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -76,7 +76,7 @@ public void Select_CloudPluginWithOptIn_ReturnsPolling() new FakeTranscriptionEnginePlugin { SupportsModelDownload = false, - SupportsStreaming = false + SupportsStreaming = false, }); Assert.Equal(LiveTranscriptionMode.Polling, mode); @@ -91,7 +91,7 @@ public void Select_StreamingCapableCloudPluginWithoutOptIn_ReturnsNone() { LiveTranscriptionEnabled = true, LiveTranscriptionStreamingEnabled = false, - OnlineAsrBatchLiveTranscriptionEnabled = false + OnlineAsrBatchLiveTranscriptionEnabled = false, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -99,7 +99,7 @@ public void Select_StreamingCapableCloudPluginWithoutOptIn_ReturnsNone() new FakeTranscriptionEnginePlugin { SupportsModelDownload = false, - SupportsStreaming = true + SupportsStreaming = true, }); Assert.Equal(LiveTranscriptionMode.None, mode); @@ -111,7 +111,7 @@ public void Select_WhenStreamingCapableAndOptedIn_ReturnsStreaming() var settings = AppSettings.Default with { LiveTranscriptionEnabled = true, - LiveTranscriptionStreamingEnabled = true + LiveTranscriptionStreamingEnabled = true, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -119,7 +119,7 @@ public void Select_WhenStreamingCapableAndOptedIn_ReturnsStreaming() new FakeTranscriptionEnginePlugin { SupportsModelDownload = false, - SupportsStreaming = true + SupportsStreaming = true, }); Assert.Equal(LiveTranscriptionMode.Streaming, mode); @@ -131,7 +131,7 @@ public void Select_WhenStreamingCapableButOptedOut_FallsThroughToPolling() var settings = AppSettings.Default with { LiveTranscriptionEnabled = true, - LiveTranscriptionStreamingEnabled = false + LiveTranscriptionStreamingEnabled = false, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -139,7 +139,7 @@ public void Select_WhenStreamingCapableButOptedOut_FallsThroughToPolling() new FakeTranscriptionEnginePlugin { SupportsModelDownload = true, - SupportsStreaming = true + SupportsStreaming = true, }); Assert.Equal(LiveTranscriptionMode.Polling, mode); @@ -152,7 +152,7 @@ public void Select_WhenStreamingNotCapableButOptedIn_FallsThroughToPolling() { LiveTranscriptionEnabled = true, LiveTranscriptionStreamingEnabled = true, - OnlineAsrBatchLiveTranscriptionEnabled = true + OnlineAsrBatchLiveTranscriptionEnabled = true, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -160,7 +160,7 @@ public void Select_WhenStreamingNotCapableButOptedIn_FallsThroughToPolling() new FakeTranscriptionEnginePlugin { SupportsModelDownload = false, - SupportsStreaming = false + SupportsStreaming = false, }); Assert.Equal(LiveTranscriptionMode.Polling, mode); @@ -175,7 +175,7 @@ public void Select_WhenStreamingWinsOverLocalModel_ReturnsStreaming() var settings = AppSettings.Default with { LiveTranscriptionEnabled = true, - LiveTranscriptionStreamingEnabled = true + LiveTranscriptionStreamingEnabled = true, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -183,7 +183,7 @@ public void Select_WhenStreamingWinsOverLocalModel_ReturnsStreaming() new FakeTranscriptionEnginePlugin { SupportsModelDownload = true, - SupportsStreaming = true + SupportsStreaming = true, }); Assert.Equal(LiveTranscriptionMode.Streaming, mode); @@ -196,7 +196,7 @@ public void Select_WhenLiveTranscriptionDisabled_BeatsEverything() { LiveTranscriptionEnabled = false, LiveTranscriptionStreamingEnabled = true, - OnlineAsrBatchLiveTranscriptionEnabled = true + OnlineAsrBatchLiveTranscriptionEnabled = true, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -204,7 +204,7 @@ public void Select_WhenLiveTranscriptionDisabled_BeatsEverything() new FakeTranscriptionEnginePlugin { SupportsModelDownload = true, - SupportsStreaming = true + SupportsStreaming = true, }); Assert.Equal(LiveTranscriptionMode.None, mode); diff --git a/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs b/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs index 2f3d3de03..97c08a11b 100644 --- a/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs @@ -254,7 +254,7 @@ public async Task Localized_invocation_does_not_retry_without_voice_rejection(st { "success" => Success(), "not-started" => new ProcessRunResult(false, false, -1, "", "launch failed"), - _ => throw new ArgumentOutOfRangeException(nameof(outcome)) + _ => throw new ArgumentOutOfRangeException(nameof(outcome)), }; var runner = ControlledProcessRunner.WithImmediateResult(result); using var provider = CreateProvider("spd-say", runner); @@ -504,7 +504,7 @@ string outcome "failed" => new ProcessRunResult(false, false, -1, "", "launch failed"), "timed-out" => new ProcessRunResult(true, true, -1, "", ""), "throwing" => null, - _ => throw new ArgumentOutOfRangeException(nameof(outcome)) + _ => throw new ArgumentOutOfRangeException(nameof(outcome)), }; var runner = ControlledProcessRunner.WithPendingResults(2); using var provider = CreateProvider("spd-say", runner); @@ -592,7 +592,7 @@ public async Task Failed_runner_results_end_session_and_complete_once(string fai "not-started" => new ProcessRunResult(false, false, -1, "", "launch failed"), "non-zero" => new ProcessRunResult(true, false, 23, "", "failed"), "timed-out" => new ProcessRunResult(true, true, -1, "", ""), - _ => throw new ArgumentOutOfRangeException(nameof(failure)) + _ => throw new ArgumentOutOfRangeException(nameof(failure)), }; var runner = ControlledProcessRunner.WithImmediateResult(result); using var provider = CreateProvider("espeak", runner); diff --git a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs index 1bc52b64e..81f54d9fe 100644 --- a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs @@ -35,7 +35,7 @@ public void CanonicalCatalog_HasNativeDictationDisclosuresWithoutObsoleteEvdevCl "Shortcuts.NativeDictationOwnershipActive", "Shortcuts.NativeDictationInstallDeferred", "Shortcuts.NativeDictationRemovalActive", - "Shortcuts.NativeDictationRemovalDeferred" + "Shortcuts.NativeDictationRemovalDeferred", }; foreach (var key in disclosureKeys) @@ -57,7 +57,7 @@ public void CanonicalCatalog_HasDesktopIntegrationStaleAndRefreshMessages() "Shortcuts.DesktopIntegrationStale", "Shortcuts.DesktopIntegrationStaleHint", "Shortcuts.DesktopIntegrationStaleUnsupported", - "Shortcuts.RefreshDesktopIntegrationOn" + "Shortcuts.RefreshDesktopIntegrationOn", }; foreach (var key in keys) diff --git a/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs b/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs index b15b85eb0..8be00125e 100644 --- a/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs @@ -117,7 +117,7 @@ public void ResumeMedia_retains_timed_out_player_even_with_zero_exit_code() 0, string.Empty, "forced timeout" - ) + ), }; runner.RespondWith( (fileName, args) => fileName == "playerctl" && args.SequenceEqual(status), diff --git a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs index 1cc3825e6..7b220d502 100644 --- a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs @@ -368,7 +368,7 @@ private static PluginCollectionDefinition ThingsDefinition() [ new PluginSettingDefinition("name", "Name", Kind: PluginSettingKind.Text), new PluginSettingDefinition("enabled", "Enabled", Kind: PluginSettingKind.Boolean), - new PluginSettingDefinition("__id", "__id", Kind: PluginSettingKind.Text) + new PluginSettingDefinition("__id", "__id", Kind: PluginSettingKind.Text), ], "name", "Add thing" diff --git a/tests/TypeWhisper.Linux.Tests/PluginRegistryServiceTests.cs b/tests/TypeWhisper.Linux.Tests/PluginRegistryServiceTests.cs index b72cd9f48..5bf1c8d62 100644 --- a/tests/TypeWhisper.Linux.Tests/PluginRegistryServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PluginRegistryServiceTests.cs @@ -50,7 +50,7 @@ public async Task FetchRegistryAsync_DeserializesAndFiltersLinuxCompatiblePlugin Description = "A Linux-compatible plugin", Size = 1024L, DownloadUrl = "https://example.com/plugin.zip", - RequiresApiKey = false + RequiresApiKey = false, }, new { @@ -61,8 +61,8 @@ public async Task FetchRegistryAsync_DeserializesAndFiltersLinuxCompatiblePlugin Description = "A Windows-only plugin entry for this test", Size = 1024L, DownloadUrl = "https://example.com/live-transcript.zip", - RequiresApiKey = false - } + RequiresApiKey = false, + }, }; var json = JsonSerializer.Serialize(plugins); @@ -90,8 +90,8 @@ public async Task FetchRegistryAsync_CachesResults() Description = "D", Size = 100L, DownloadUrl = "u", - RequiresApiKey = false - } + RequiresApiKey = false, + }, }; var json = JsonSerializer.Serialize(plugins); @@ -110,7 +110,7 @@ public async Task FetchRegistryAsync_CachesResults() callCount++; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(json) + Content = new StringContent(json), }; }); diff --git a/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs b/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs index 59d63267b..6300a4d86 100644 --- a/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs @@ -22,7 +22,7 @@ public async Task RunAsync_returns_success_when_descendant_holds_stdout_open() "-c", "sleep 30 & child=$!; printf '%s' \"$child\" > \"$1\"; exit 0", "process-runner-test", - pidFile + pidFile, ], timeout: TimeSpan.FromSeconds(2) ); @@ -314,7 +314,7 @@ public async Task RunAsync_caller_cancellation_wins_during_post_exit_output_drai "-c", "sleep 30 & child=$!; printf '%s %s' \"$$\" \"$child\" > \"$1\"; exit 0", "process-runner-test", - pidFile + pidFile, ], timeout: TimeSpan.FromSeconds(30), ct: cts.Token diff --git a/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs index b23498cb0..be50ee766 100644 --- a/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs @@ -185,7 +185,7 @@ public void SaveProfile_MalformedBindingDoesNotUpdateAndShowsFeedback() new BrowserAccessibilitySetupHelper() ) { - EditHotkeyData = "Ctrl+NoSuchKey" + EditHotkeyData = "Ctrl+NoSuchKey", }; sut.SaveProfileCommand.Execute(null); @@ -210,7 +210,7 @@ public void SaveProfile_CrossDynamicPrefixCollisionDoesNotUpdate() Id = "action", Name = "Action", SystemPrompt = "x", - HotkeyKey = "Right Ctrl" + HotkeyKey = "Right Ctrl", } ); var sut = new ProfilesSectionViewModel( @@ -224,7 +224,7 @@ public void SaveProfile_CrossDynamicPrefixCollisionDoesNotUpdate() new BrowserAccessibilitySetupHelper() ) { - EditHotkeyData = "Ctrl+Alt+E" + EditHotkeyData = "Ctrl+Alt+E", }; sut.SaveProfileCommand.Execute(null); @@ -256,7 +256,7 @@ bool addDisabledAction Id = "disabled", Name = "Disabled", SystemPrompt = "x", - IsEnabled = false + IsEnabled = false, } ); } @@ -274,7 +274,7 @@ bool addDisabledAction { EditHotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText, EditPromptActionId = promptActionId, - EditHotkeyData = "Meta+F9" + EditHotkeyData = "Meta+F9", }; sut.SaveProfileCommand.Execute(null); @@ -300,7 +300,7 @@ public void SaveProfile_SelectedTextBindingWithEnabledActionPersistsCanonicalCho { Id = "enabled", Name = "Enabled", - SystemPrompt = "x" + SystemPrompt = "x", } ); var sut = new ProfilesSectionViewModel( @@ -316,7 +316,7 @@ public void SaveProfile_SelectedTextBindingWithEnabledActionPersistsCanonicalCho { EditHotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText, EditPromptActionId = "enabled", - EditHotkeyData = " super + f9 " + EditHotkeyData = " super + f9 ", }; sut.SaveProfileCommand.Execute(null); @@ -357,7 +357,7 @@ public void SaveProfile_StartDictationAcceptsValidOrBlankBinding( ) { EditHotkeyBehavior = ProfileHotkeyBehavior.StartDictation, - EditHotkeyData = draft + EditHotkeyData = draft, }; sut.SaveProfileCommand.Execute(null); @@ -380,7 +380,7 @@ public async Task ActivateLiveContext_AppliesOneSnapshotAndTracksMatchedProfile( IsEnabled = true, Priority = 10, ProcessNames = ["firefox"], - UrlPatterns = [] + UrlPatterns = [], } ); @@ -580,7 +580,7 @@ public void RefreshPromptActionOptions_ExcludesManualOnlyActions() { Id = "auto", Name = "Auto", - SystemPrompt = "a" + SystemPrompt = "a", } ); promptActions.AddAction( @@ -589,7 +589,7 @@ public void RefreshPromptActionOptions_ExcludesManualOnlyActions() Id = "manual", Name = "Manual", SystemPrompt = "m", - IsManualOnly = true + IsManualOnly = true, } ); @@ -660,7 +660,7 @@ private static Profile CreateEditableProfile(string? hotkeyData = null) { Id = "profile", Name = "Profile", - HotkeyData = hotkeyData + HotkeyData = hotkeyData, }; } diff --git a/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs index 1857c20a2..02d8f6dcc 100644 --- a/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs @@ -53,7 +53,7 @@ public async Task ProcessAsync_UsesDefaultProvider_WhenNoOverrideIsSet() { Id = "prompt", Name = "Rewrite", - SystemPrompt = "Rewrite this" + SystemPrompt = "Rewrite this", }, "hello", ct: CancellationToken.None @@ -82,7 +82,7 @@ public async Task ProcessAsync_UsesPromptOverride_WhenProvided() [defaultProvider, overrideProvider], [ CreateLoadedPlugin(defaultProvider.PluginId, defaultProvider), - CreateLoadedPlugin(overrideProvider.PluginId, overrideProvider) + CreateLoadedPlugin(overrideProvider.PluginId, overrideProvider), ] ); var settings = CreateSettings( @@ -101,7 +101,7 @@ public async Task ProcessAsync_UsesPromptOverride_WhenProvided() Id = "prompt", Name = "Rewrite", SystemPrompt = "Rewrite this", - ProviderOverride = "plugin:com.test.override:model-b" + ProviderOverride = "plugin:com.test.override:model-b", }, "hello", ct: CancellationToken.None @@ -134,7 +134,7 @@ public async Task ProcessAsync_FallsBackToFirstAvailableProvider_WhenNoDefaultIs { Id = "prompt", Name = "Rewrite", - SystemPrompt = "Rewrite this" + SystemPrompt = "Rewrite this", }, "hello", ct: CancellationToken.None @@ -462,7 +462,7 @@ private LoadedPlugin CreateLoadedPlugin( Version = plugin.PluginVersion, AssemblyName = "fake.dll", PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name, - IsLocal = isLocal + IsLocal = isLocal, }, plugin, new PluginAssemblyLoadContext(pluginDir), diff --git a/tests/TypeWhisper.Linux.Tests/PromptsSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/PromptsSectionViewModelTests.cs index 56fb3b2c8..961cff343 100644 --- a/tests/TypeWhisper.Linux.Tests/PromptsSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PromptsSectionViewModelTests.cs @@ -93,7 +93,7 @@ public void SaveAction_PersistsHotkeyAndManualOnlyForExistingAction() { Id = "existing", Name = "Existing", - SystemPrompt = "x" + SystemPrompt = "x", } ); using var pluginManager = TestPluginManagerFactory.Create(); @@ -121,7 +121,7 @@ public void OnSelectedActionChanged_PopulatesHotkeyAndManualOnlyFromAction() Name = "Existing", SystemPrompt = "x", HotkeyKey = "Ctrl+Alt+R", - IsManualOnly = true + IsManualOnly = true, } ); using var pluginManager = TestPluginManagerFactory.Create(); @@ -189,7 +189,7 @@ public void SaveAction_MalformedExistingDraftDoesNotUpdate() Id = "existing", Name = "Existing", SystemPrompt = "x", - HotkeyKey = "Alt+F8" + HotkeyKey = "Alt+F8", }; var prompts = new Mock(); prompts.SetupGet(service => service.Actions).Returns([existing]); @@ -221,7 +221,7 @@ public void SaveAction_CrossDynamicPrefixCollisionDoesNotPersist() { Id = "profile", Name = "Profile", - HotkeyData = "Right Ctrl" + HotkeyData = "Right Ctrl", } ); var prompts = new PromptActionService(Path.Join(_tempDir, "prompt-actions.json")); @@ -258,7 +258,7 @@ public void SelectedEditProvider_UpdatesProviderOverride() [provider], loadedPlugins: [ - TestPluginManagerFactory.CreateLoadedPlugin(_tempDir, provider.PluginId, provider) + TestPluginManagerFactory.CreateLoadedPlugin(_tempDir, provider.PluginId, provider), ] ); var settings = TestPluginManagerFactory.CreateSettings(new AppSettings()); @@ -288,7 +288,7 @@ public void SelectedSpokenCommandProvider_PersistsToSettings() [provider], loadedPlugins: [ - TestPluginManagerFactory.CreateLoadedPlugin(_tempDir, provider.PluginId, provider) + TestPluginManagerFactory.CreateLoadedPlugin(_tempDir, provider.PluginId, provider), ] ); var settings = TestPluginManagerFactory.CreateSettings(new AppSettings()); @@ -327,13 +327,13 @@ public void SelectedEditProvider_IgnoresTransientSelectionChangesDuringProviderR [provider], loadedPlugins: [ - TestPluginManagerFactory.CreateLoadedPlugin(_tempDir, provider.PluginId, provider) + TestPluginManagerFactory.CreateLoadedPlugin(_tempDir, provider.PluginId, provider), ] ); var settings = TestPluginManagerFactory.CreateSettings(new AppSettings()); var sut = new PromptsSectionViewModel(prompts, _profiles, _hotkeys, pluginManager, settings.Object) { - EditProviderOverride = "plugin:com.typewhisper.openai:gpt-4.1-mini" + EditProviderOverride = "plugin:com.typewhisper.openai:gpt-4.1-mini", }; // Simulate the guard flag that the view-model sets while it rebuilds // the provider list — a null selection during that window must not @@ -385,7 +385,7 @@ public void CommandModeEnabled_TogglePersistsToSettings() var sut = new PromptsSectionViewModel(prompts, _profiles, _hotkeys, pluginManager, settings.Object) { - CommandModeEnabled = true + CommandModeEnabled = true, }; Assert.True(sut.CommandModeEnabled); @@ -405,7 +405,7 @@ public void CommandKeyphrase_TrimmedValuePersistsNormalizedOnce() var sut = new PromptsSectionViewModel(prompts, _profiles, _hotkeys, pluginManager, settings.Object) { - CommandKeyphrase = " Jarvis " + CommandKeyphrase = " Jarvis ", }; // The re-entrant normalization must land the trimmed value and persist it exactly once. @@ -428,7 +428,7 @@ public void CommandKeyphrase_BlankValueFallsBackToDefaultAndPersists() var sut = new PromptsSectionViewModel(prompts, _profiles, _hotkeys, pluginManager, settings.Object) { - CommandKeyphrase = " " + CommandKeyphrase = " ", }; Assert.Equal(AppSettings.DefaultCommandKeyphrase, sut.CommandKeyphrase); @@ -447,7 +447,7 @@ public void CommandKeyphrase_UnchangedNormalizedValueHitsNoOpGuard() var sut = new PromptsSectionViewModel(prompts, _profiles, _hotkeys, pluginManager, settings.Object) { // Whitespace that normalizes back to the already-saved value: no persist. - CommandKeyphrase = " Jarvis " + CommandKeyphrase = " Jarvis ", }; Assert.Equal("Jarvis", sut.CommandKeyphrase); diff --git a/tests/TypeWhisper.Linux.Tests/RecentTranscriptionStoreTests.cs b/tests/TypeWhisper.Linux.Tests/RecentTranscriptionStoreTests.cs index d7ea25440..9adf51305 100644 --- a/tests/TypeWhisper.Linux.Tests/RecentTranscriptionStoreTests.cs +++ b/tests/TypeWhisper.Linux.Tests/RecentTranscriptionStoreTests.cs @@ -20,8 +20,8 @@ public void MergedEntries_prefers_session_entry_over_history_duplicate() Id = "same", Timestamp = timestamp.AddSeconds(-1), RawText = "raw", - FinalText = "history text" - } + FinalText = "history text", + }, }; var entries = store.MergedEntries(history, 10); @@ -72,7 +72,7 @@ public void PaletteViewModel_filters_text_and_subtitle() "Browser", "firefox", RecentTranscriptionSource.Session - ) + ), }; var sut = new RecentTranscriptionsPaletteViewModel(entries, _ => { }) { SearchQuery = "firefox" }; diff --git a/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs b/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs index abb6a9515..7cff7b27e 100644 --- a/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs @@ -24,7 +24,7 @@ public async Task Recording_processing_and_success_replace_one_notification_in_p AppSettings.Default with { Mode = RecordingMode.PushToTalk, - PreviewBubbleAutoHideMilliseconds = terminalExpiry + PreviewBubbleAutoHideMilliseconds = terminalExpiry, } ); service.Initialize(); @@ -34,7 +34,7 @@ AppSettings.Default with { IsOverlayVisible = true, IsRecording = true, - StatusText = Loc.Instance["Dictation.StatusRecording"] + StatusText = Loc.Instance["Dictation.StatusRecording"], } ); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -44,7 +44,7 @@ AppSettings.Default with new DictationOverlayState { IsOverlayVisible = true, - StatusText = processing + StatusText = processing, } ); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -54,7 +54,7 @@ AppSettings.Default with new DictationOverlayState { ShowFeedback = true, - FeedbackText = success + FeedbackText = success, } ); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -90,7 +90,7 @@ bool isError var settings = AppSettings.Default with { PreviewBubbleAutoHideMilliseconds = - AppSettings.MaxPreviewBubbleAutoHideMilliseconds + 500 + AppSettings.MaxPreviewBubbleAutoHideMilliseconds + 500, }; var (source, runner, service) = CreateSut(settings); service.Initialize(); @@ -104,7 +104,7 @@ bool isError ShowFeedback = true, FeedbackIsError = isError, FeedbackText = feedbackText, - IsRecording = false + IsRecording = false, } ); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -130,7 +130,7 @@ public async Task Non_presentation_changes_are_deduplicated_while_recording_and_ IsRecording = true, PartialText = "one", ActiveProfileName = "Profile A", - ActiveAppName = "Editor" + ActiveAppName = "Editor", }; source.Raise(recording); @@ -141,7 +141,7 @@ recording with PartialText = "one two", ActiveProfileName = "Profile B", ActiveAppName = "Terminal", - SessionStartedAtUtc = DateTime.UtcNow + SessionStartedAtUtc = DateTime.UtcNow, } ); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -149,7 +149,7 @@ recording with var processing = new DictationOverlayState { IsOverlayVisible = true, - StatusText = Loc.Instance["Overlay.Processing"] + StatusText = Loc.Instance["Overlay.Processing"], }; source.Raise(processing); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -158,7 +158,7 @@ processing with { PartialText = "ignored preview", ActiveProfileName = "Profile C", - ActiveAppName = "Browser" + ActiveAppName = "Browser", } ); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -185,7 +185,7 @@ public async Task Hidden_and_zero_duration_terminal_feedback_close_the_owned_not var zeroSettings = AppSettings.Default with { - PreviewBubbleAutoHideMilliseconds = -100 + PreviewBubbleAutoHideMilliseconds = -100, }; var (zeroSource, zeroRunner, zeroService) = CreateSut(zeroSettings); zeroService.Initialize(); @@ -196,7 +196,7 @@ public async Task Hidden_and_zero_duration_terminal_feedback_close_the_owned_not new DictationOverlayState { ShowFeedback = true, - FeedbackText = "Finished" + FeedbackText = "Finished", } ); await zeroService.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -224,7 +224,7 @@ public async Task Slow_initial_notify_coalesces_pending_states_to_latest_termina new DictationOverlayState { IsOverlayVisible = true, - StatusText = Loc.Instance["Overlay.Processing"] + StatusText = Loc.Instance["Overlay.Processing"], } ); const string terminal = "Dictation inserted"; @@ -232,7 +232,7 @@ public async Task Slow_initial_notify_coalesces_pending_states_to_latest_termina new DictationOverlayState { ShowFeedback = true, - FeedbackText = terminal + FeedbackText = terminal, } ); Assert.Single(runner.Invocations); @@ -284,7 +284,7 @@ public async Task Enabled_dispose_closes_owned_notification_and_ignores_later_st new DictationOverlayState { IsOverlayVisible = true, - StatusText = Loc.Instance["Overlay.Processing"] + StatusText = Loc.Instance["Overlay.Processing"], } ); diff --git a/tests/TypeWhisper.Linux.Tests/SentinelBlockTests.cs b/tests/TypeWhisper.Linux.Tests/SentinelBlockTests.cs index 8265a53bd..f6d2102dd 100644 --- a/tests/TypeWhisper.Linux.Tests/SentinelBlockTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SentinelBlockTests.cs @@ -187,7 +187,7 @@ public void ExtractBlockLines_WellFormedBlock_ReturnsInnerLines() var managed = new[] { "bind = CTRL SHIFT, SPACE, exec, typewhisper record start", - "bindr = CTRL SHIFT, SPACE, exec, typewhisper record stop" + "bindr = CTRL SHIFT, SPACE, exec, typewhisper record stop", }; var input = "bind = SUPER, q, killactive\n" diff --git a/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs index 4775eff69..6fb9022e3 100644 --- a/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs @@ -849,7 +849,7 @@ private static Profile CreateProfile(string id, string name) Id = id, Name = name, CreatedAt = DateTime.UnixEpoch, - UpdatedAt = DateTime.UnixEpoch + UpdatedAt = DateTime.UnixEpoch, }; } diff --git a/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs b/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs index 0eefa7a2b..80a6351dd 100644 --- a/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs @@ -310,7 +310,7 @@ public void TransformSelection_WaitsForTriggerAndCtrlShiftAltMetaReleased() Set(RecordingMode.Toggle) with { TransformSelectionKey = KeyCode.VcT, - TransformSelectionModifiers = allShortcutModifiers + TransformSelectionModifiers = allShortcutModifiers, } ); var transform = 0; @@ -346,7 +346,7 @@ public void ResetState_DropsPendingTransformSelection() Set(RecordingMode.Toggle) with { TransformSelectionKey = KeyCode.VcT, - TransformSelectionModifiers = ModifierMask.LeftAlt + TransformSelectionModifiers = ModifierMask.LeftAlt, } ); var transform = 0; diff --git a/tests/TypeWhisper.Linux.Tests/ShortcutMatcherTests.cs b/tests/TypeWhisper.Linux.Tests/ShortcutMatcherTests.cs index f08470b68..6a09ed899 100644 --- a/tests/TypeWhisper.Linux.Tests/ShortcutMatcherTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ShortcutMatcherTests.cs @@ -61,7 +61,7 @@ public void Match_PromptActionTakesPriorityOverDictation() "alpha", KeyCode.VcSpace, ModifierMask.LeftCtrl | ModifierMask.LeftShift - ) + ), ], [] ); diff --git a/tests/TypeWhisper.Linux.Tests/ShortcutsSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/ShortcutsSectionViewModelTests.cs index fc88eb033..a7412ea28 100644 --- a/tests/TypeWhisper.Linux.Tests/ShortcutsSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ShortcutsSectionViewModelTests.cs @@ -84,7 +84,7 @@ public void ApplyTransformSelectionHotkey_BlankInputClearsBinding() var sut = new ShortcutsSectionViewModel(hotkey, settings) { - TransformSelectionHotkeyText = "" + TransformSelectionHotkeyText = "", }; sut.ApplyTransformSelectionHotkeyCommand.Execute(null); @@ -104,7 +104,7 @@ public void ApplyTransformSelectionHotkey_RejectsCollisionWithPromptPalette() var sut = new ShortcutsSectionViewModel(hotkey, settings) { - TransformSelectionHotkeyText = "Ctrl+Shift+P" + TransformSelectionHotkeyText = "Ctrl+Shift+P", }; sut.ApplyTransformSelectionHotkeyCommand.Execute(null); @@ -160,7 +160,7 @@ public async Task RefreshSectionState_AfterStartupMismatchShowsStaleBannerWithou using var hotkey = TestShortcutBackend.CreateHotkeyService(); var writer = new FakeDeShortcutWriter { - InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space") + InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space"), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); hotkey.SetNativeDictationBindingActive(true); @@ -298,7 +298,7 @@ public async Task ImmediateRefreshFromRestartStaleStateReestablishesSuppressionA Assert.True(hotkey.TrySetPromptPaletteHotkeyFromString("Ctrl+Alt+P")); var writer = new FakeDeShortcutWriter { - InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space") + InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space"), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); await sut.RefreshDesktopIntegrationStateAsync(CancellationToken.None); @@ -343,7 +343,7 @@ bool warning "Shortcut refreshed.", [], warning ? "Live apply failed." : null - ) + ), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); await sut.RefreshDesktopIntegrationStateAsync(CancellationToken.None); @@ -369,7 +369,7 @@ public async Task RefreshFailurePreservesPriorSuppressionAndStaleBanner(bool thr { InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space"), WriteResult = new DeShortcutWriteResult(false, "Write failed.", []), - WriteException = throws ? new InvalidOperationException("boom") : null + WriteException = throws ? new InvalidOperationException("boom") : null, }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); await sut.RefreshDesktopIntegrationStateAsync(CancellationToken.None); @@ -395,7 +395,7 @@ public async Task LateOldHotkeyProbeCannotOverwriteNewerStaleResult() IsInstalledHandler = (spec, _) => spec.Trigger == "Ctrl+Shift+Space" ? oldProbeGate.Task - : Task.FromResult(false) + : Task.FromResult(false), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); var oldProbe = sut.RefreshDesktopIntegrationStateAsync(CancellationToken.None); @@ -425,7 +425,7 @@ public async Task LatePreRefreshProbeCannotRestoreStaleAfterSuccessfulRefresh() InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space"), // ReSharper disable once AccessToModifiedClosure -- the test deliberately flips blockProbe after setup so the next probe blocks on probeGate. IsInstalledHandler = (_, _) => - blockProbe ? probeGate.Task : Task.FromResult(false) + blockProbe ? probeGate.Task : Task.FromResult(false), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); await sut.RefreshDesktopIntegrationStateAsync(CancellationToken.None); @@ -454,7 +454,7 @@ public async Task LatePreRemovalProbeCannotRestoreCurrentAfterSuccessfulRemoval( var writer = new FakeDeShortcutWriter { InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space"), - IsInstalledHandler = (_, _) => probeGate.Task + IsInstalledHandler = (_, _) => probeGate.Task, }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); var oldProbe = sut.RefreshDesktopIntegrationStateAsync(CancellationToken.None); @@ -558,7 +558,7 @@ bool hasWarning "Shortcut installed.", [], hasWarning ? "Live apply failed." : null - ) + ), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); @@ -584,7 +584,7 @@ public async Task SetupAutomatically_FailureOrExceptionKeepsPreexistingSuppressi var writer = new FakeDeShortcutWriter { WriteResult = new DeShortcutWriteResult(false, "Write failed.", []), - WriteException = throws ? new InvalidOperationException("boom") : null + WriteException = throws ? new InvalidOperationException("boom") : null, }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); @@ -640,7 +640,7 @@ bool hasWarning "Shortcut removed.", [], hasWarning ? "Reload required." : null - ) + ), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); @@ -666,7 +666,7 @@ public async Task RemoveIntegration_FailureOrExceptionKeepsPreexistingSuppressio var writer = new FakeDeShortcutWriter { RemoveResult = new DeShortcutWriteResult(false, "Remove failed.", []), - RemoveException = throws ? new InvalidOperationException("boom") : null + RemoveException = throws ? new InvalidOperationException("boom") : null, }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); @@ -746,7 +746,7 @@ public async Task RefreshNativeDictationBindingState_ProbeErrorFailsOpen() hotkey.SetNativeDictationBindingActive(true); var writer = new FakeDeShortcutWriter { - IsInstalledException = new InvalidOperationException("boom") + IsInstalledException = new InvalidOperationException("boom"), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); @@ -764,7 +764,7 @@ public async Task RefreshNativeDictationBindingState_CancellationFailsOpenAndPro hotkey.SetNativeDictationBindingActive(true); var writer = new FakeDeShortcutWriter { - IsInstalledException = new OperationCanceledException() + IsInstalledException = new OperationCanceledException(), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); @@ -802,7 +802,7 @@ settings.Current with { Mode = RecordingMode.Toggle, ToggleHotkey = toggleHotkey, - WaylandEvdevHotkeysEnabled = true + WaylandEvdevHotkeysEnabled = true, } ); return settings; diff --git a/tests/TypeWhisper.Linux.Tests/SnippetsSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/SnippetsSectionViewModelTests.cs index 1f7571fe6..cfb6a12b2 100644 --- a/tests/TypeWhisper.Linux.Tests/SnippetsSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SnippetsSectionViewModelTests.cs @@ -62,7 +62,7 @@ public void SaveSnippet_WhenEditing_PreservesUsageMetadata() TriggerMode = SnippetTriggerMode.Anywhere, UsageCount = 4, LastUsedAt = lastUsedAt, - CreatedAt = DateTime.UtcNow.AddDays(-10) + CreatedAt = DateTime.UtcNow.AddDays(-10), }; service.AddSnippet(existing); var sut = CreateViewModel(service); @@ -105,7 +105,7 @@ public void ConflictWarning_ShowsDictionaryTermMatch() { Id = "term-1", EntryType = DictionaryEntryType.Term, - Original = "Kubernetes" + Original = "Kubernetes", } ); var sut = CreateViewModel(CreateSnippetService(), dictionary); @@ -129,7 +129,7 @@ public void ConflictWarning_ShowsDictionaryCorrectionMatch() Id = "correction-1", EntryType = DictionaryEntryType.Correction, Original = "wispr", - Replacement = "Wispr" + Replacement = "Wispr", } ); var sut = CreateViewModel(CreateSnippetService(), dictionary); diff --git a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs index 623e979b7..54a7e9f3e 100644 --- a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs @@ -118,7 +118,7 @@ RunnerOutcome outcome RunnerOutcome.Exception => ControlledProcessRunner.WithException( new InvalidOperationException("fake runner failure") ), - _ => throw new ArgumentOutOfRangeException(nameof(outcome), outcome, null) + _ => throw new ArgumentOutOfRangeException(nameof(outcome), outcome, null), }; var sut = new SoundFeedbackService(runner, "fake-player", sounds.Path); @@ -138,7 +138,7 @@ public void Source_has_no_direct_process_path_and_observes_every_fire_and_forget @"\bProcess\s*\.\s*Start\b", @"\bProcessStartInfo\b", @"\bWaitForExit(?:Async)?\b", - @"\bnew\s+Process\s*\(" + @"\bnew\s+Process\s*\(", ]; foreach (var pattern in directProcessPatterns) @@ -206,7 +206,7 @@ public enum RunnerOutcome { NotStarted, TimedOut, - Exception + Exception, } private sealed class ControlledProcessRunner : IProcessRunner diff --git a/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs index b384b9941..0344877f0 100644 --- a/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs @@ -66,7 +66,7 @@ public async Task SpeakAutomaticTranscription_substitutes_configured_language_wh { Language = "de", SpokenFeedbackEnabled = true, - SpokenFeedbackProviderId = "cloud" + SpokenFeedbackProviderId = "cloud", } ); var plugin = new FakeTtsProvider("cloud", "Cloud Voice", true); @@ -94,7 +94,7 @@ public async Task SpeakAutomaticTranscription_keeps_explicit_request_language() { Language = "de", SpokenFeedbackEnabled = true, - SpokenFeedbackProviderId = "cloud" + SpokenFeedbackProviderId = "cloud", } ); var plugin = new FakeTtsProvider("cloud", "Cloud Voice", true); @@ -123,7 +123,7 @@ public async Task SpeakAutomaticTranscription_skips_configured_language_fallback { Language = "de", SpokenFeedbackEnabled = true, - SpokenFeedbackProviderId = "cloud" + SpokenFeedbackProviderId = "cloud", } ); var plugin = new FakeTtsProvider("cloud", "Cloud Voice", true); diff --git a/tests/TypeWhisper.Linux.Tests/SpokenCommandActionMatcherTests.cs b/tests/TypeWhisper.Linux.Tests/SpokenCommandActionMatcherTests.cs index 8973adafb..97aeccd0f 100644 --- a/tests/TypeWhisper.Linux.Tests/SpokenCommandActionMatcherTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SpokenCommandActionMatcherTests.cs @@ -10,7 +10,7 @@ public sealed class SpokenCommandActionMatcherTests [ new() { Id = "clean", Name = "Clean up email", SystemPrompt = "..." }, new() { Id = "auto", Name = "Auto Clean Up Text", SystemPrompt = "..." }, - new() { Id = "formal", Name = "Make Formal", SystemPrompt = "..." } + new() { Id = "formal", Name = "Make Formal", SystemPrompt = "..." }, ]; [Theory] @@ -59,7 +59,7 @@ public void Match_DoesNotMatchSingleWordNameMerelyMentioned() // A create command that only mentions the word must not hijack a single-word "Email" action. var actions = new PromptAction[] { - new() { Id = "email", Name = "Email", SystemPrompt = "..." } + new() { Id = "email", Name = "Email", SystemPrompt = "..." }, }; Assert.Null(SpokenCommandActionMatcher.Match("draft an email to Bob", actions)); @@ -70,7 +70,7 @@ public void Match_MatchesSingleWordNameWhenItLeadsTheCommand() { var actions = new PromptAction[] { - new() { Id = "email", Name = "Email", SystemPrompt = "..." } + new() { Id = "email", Name = "Email", SystemPrompt = "..." }, }; var matched = SpokenCommandActionMatcher.Match("email this to the team", actions); @@ -84,7 +84,7 @@ public void Match_MatchesSingleWordNameAfterLeadingFiller() // A leading politeness filler ("please") must not hide an explicit single-word invocation. var actions = new PromptAction[] { - new() { Id = "email", Name = "Email", SystemPrompt = "..." } + new() { Id = "email", Name = "Email", SystemPrompt = "..." }, }; var matched = SpokenCommandActionMatcher.Match("please email this to the team", actions); diff --git a/tests/TypeWhisper.Linux.Tests/StreamingTranscriptionCoordinatorTests.cs b/tests/TypeWhisper.Linux.Tests/StreamingTranscriptionCoordinatorTests.cs index e5bb9a935..99dafdcf2 100644 --- a/tests/TypeWhisper.Linux.Tests/StreamingTranscriptionCoordinatorTests.cs +++ b/tests/TypeWhisper.Linux.Tests/StreamingTranscriptionCoordinatorTests.cs @@ -17,7 +17,7 @@ public async Task AcceptAudioFrame_BeforeStartAsync_QueuesInPendingBuffer() TaskCreationOptions.RunContinuationsAsynchronously); var plugin = new FakePlugin { - OnStartStreaming = _ => connectTcs.Task + OnStartStreaming = _ => connectTcs.Task, }; await using var coord = new StreamingTranscriptionCoordinator( @@ -261,7 +261,7 @@ public async Task Fault_OnConnectException_PropagatesViaOnFault() var plugin = new FakePlugin { OnStartStreaming = _ => Task.FromException( - new HttpRequestException("auth failed (simulated)")) + new HttpRequestException("auth failed (simulated)")), }; await using var coord = new StreamingTranscriptionCoordinator( @@ -795,7 +795,7 @@ public async Task Dispose_AfterFault_DoesNotThrow() var plugin = new FakePlugin { OnStartStreaming = _ => Task.FromException( - new HttpRequestException("simulated")) + new HttpRequestException("simulated")), }; var coord = new StreamingTranscriptionCoordinator( plugin, null, 1, (_, _) => { }, _ => { }); @@ -857,7 +857,7 @@ public async Task Dispose_WhileConnectPending_PluginHonorsCancellation_DoesNotFa { await Task.Delay(Timeout.Infinite, ct); throw new InvalidOperationException("unreachable"); - }, ct) + }, ct), }; var coord = new StreamingTranscriptionCoordinator( @@ -890,7 +890,7 @@ public async Task Dispose_WhileConnectPending_DisposesLateArrivingSession() { // Deliberately ignore cancellation — simulate a misbehaving plugin // or a native WebSocket that resolves just before honoring cancel. - OnStartStreaming = _ => connectTcs.Task + OnStartStreaming = _ => connectTcs.Task, }; var coord = new StreamingTranscriptionCoordinator( diff --git a/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs b/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs index ae0be20e9..4b752bdf7 100644 --- a/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs +++ b/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs @@ -80,7 +80,7 @@ ITypeWhisperPlugin plugin Name = plugin.PluginName, Version = plugin.PluginVersion, AssemblyName = "fake.dll", - PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name + PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name, }, plugin, new PluginAssemblyLoadContext(pluginDir), diff --git a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs index 78c10bbdb..f3eb8819b 100644 --- a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs @@ -15,7 +15,7 @@ public async Task InsertTextAsync_successful_auto_paste_restores_previous_clipbo var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService(platform); @@ -33,7 +33,7 @@ public async Task InsertTextAsync_retries_failed_paste_before_fallback() var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = false + PasteSucceeds = false, }; var confirmation = new FakePasteConfirmationSource { Result = true }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -56,7 +56,7 @@ public async Task InsertTextAsync_successful_retry_restores_previous_clipboard() var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteResults = new Queue([false, false, true]) + PasteResults = new Queue([false, false, true]), }; var sut = new TextInsertionService(platform); @@ -81,9 +81,9 @@ public async Task InsertTextAsync_verifies_clipboard_serves_before_paste_and_ret [ "previous", // snapshot of the user's clipboard "previous", // verify attempt 1 — wl-copy not serving yet - "new text" // verify attempt 2 — serving + "new text", // verify attempt 2 — serving ] - ) + ), }; var sut = new TextInsertionService(platform); @@ -111,9 +111,9 @@ public async Task InsertTextAsync_verify_failure_resets_clipboard_once_then_proc [ "previous", // snapshot "previous", "previous", "previous", "previous", // verify pass 1 — all stale - "new text" // verify pass 2 after the re-set — serving + "new text", // verify pass 2 after the re-set — serving ] - ) + ), }; var sut = new TextInsertionService(platform); @@ -140,9 +140,9 @@ public async Task InsertTextAsync_verify_never_serves_skips_paste_and_falls_back [ "previous", // snapshot "previous", "previous", "previous", "previous", // verify pass 1 - "previous", "previous", "previous", "previous" // verify pass 2 after re-set + "previous", "previous", "previous", "previous", // verify pass 2 after re-set ] - ) + ), }; var sut = new TextInsertionService(platform); @@ -160,7 +160,7 @@ public async Task InsertTextAsync_confirmed_paste_restores_immediately_without_f var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var confirmation = new FakePasteConfirmationSource { Result = true }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -188,12 +188,12 @@ public async Task InsertTextAsync_arms_paste_watch_before_sending_ctrl_v() { Clipboard = "previous", PasteSucceeds = true, - OnPasteSent = () => order.Add("ctrl-v") + OnPasteSent = () => order.Add("ctrl-v"), }; var confirmation = new FakePasteConfirmationSource { Result = true, - OnBeginWatch = () => order.Add("begin-watch") + OnBeginWatch = () => order.Add("begin-watch"), }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -215,13 +215,13 @@ public async Task InsertTextAsync_text_changed_during_paste_is_latched_and_confi var client = new FakeAtSpiEventClient { CurrentFocusedElement = targetElement, - TextByElement = { [targetElement] = "Prefix new text suffix" } + TextByElement = { [targetElement] = "Prefix new text suffix" }, }; var platform = new FakeTextInsertionPlatform { Clipboard = "previous", PasteSucceeds = true, - OnPasteSent = () => client.RaiseTextChanged(targetElement) + OnPasteSent = () => client.RaiseTextChanged(targetElement), }; var sut = new TextInsertionService( platform, @@ -255,13 +255,13 @@ public async Task InsertTextAsync_unrelated_same_bus_text_change_does_not_confir var client = new FakeAtSpiEventClient { CurrentFocusedElement = focusedElement, - TextByElement = { [unrelatedElement] = "Background log count: 17" } + TextByElement = { [unrelatedElement] = "Background log count: 17" }, }; var platform = new FakeTextInsertionPlatform { Clipboard = "previous", PasteSucceeds = true, - OnPasteSent = () => client.RaiseTextChanged(unrelatedElement) + OnPasteSent = () => client.RaiseTextChanged(unrelatedElement), }; var sut = new TextInsertionService( platform, @@ -289,13 +289,13 @@ public async Task InsertTextAsync_unknown_bus_unverified_text_change_does_not_co var client = new FakeAtSpiEventClient { CurrentFocusedElement = null, - TextByElement = { [changedElement] = "Background log count: 17" } + TextByElement = { [changedElement] = "Background log count: 17" }, }; var platform = new FakeTextInsertionPlatform { Clipboard = "previous", PasteSucceeds = true, - OnPasteSent = () => client.RaiseTextChanged(changedElement) + OnPasteSent = () => client.RaiseTextChanged(changedElement), }; var sut = new TextInsertionService( platform, @@ -319,7 +319,7 @@ public async Task InsertTextAsync_unreadable_same_bus_text_change_remains_indete { Clipboard = "previous", PasteSucceeds = true, - OnPasteSent = () => client.RaiseTextChanged(targetElement) + OnPasteSent = () => client.RaiseTextChanged(targetElement), }; var sut = new TextInsertionService( platform, @@ -345,13 +345,13 @@ public async Task InsertTextAsync_password_element_text_change_is_never_read() { CurrentFocusedElement = targetElement, TextByElement = { [targetElement] = "Prefix new text suffix" }, - PasswordRoleByElement = { [targetElement] = true } + PasswordRoleByElement = { [targetElement] = true }, }; var platform = new FakeTextInsertionPlatform { Clipboard = "previous", PasteSucceeds = true, - OnPasteSent = () => client.RaiseTextChanged(targetElement) + OnPasteSent = () => client.RaiseTextChanged(targetElement), }; var sut = new TextInsertionService( platform, @@ -380,8 +380,8 @@ public async Task InsertTextAsync_watch_keeps_listening_after_unverified_text_ch TextByElement = { [unrelatedElement] = "Background log count: 17", - [targetElement] = "Prefix new text suffix" - } + [targetElement] = "Prefix new text suffix", + }, }; var platform = new FakeTextInsertionPlatform { @@ -391,7 +391,7 @@ public async Task InsertTextAsync_watch_keeps_listening_after_unverified_text_ch { client.RaiseTextChanged(unrelatedElement); client.RaiseTextChanged(targetElement); - } + }, }; var sut = new TextInsertionService( platform, @@ -420,7 +420,7 @@ public async Task InsertTextAsync_auto_enter_waits_for_confirmed_paste_without_f Clipboard = "previous", PasteSucceeds = true, OnPasteSent = () => order.Add("paste"), - OnEnterSent = () => order.Add("enter") + OnEnterSent = () => order.Add("enter"), }; var confirmation = new FakePasteConfirmationSource { @@ -429,7 +429,7 @@ public async Task InsertTextAsync_auto_enter_waits_for_confirmed_paste_without_f { order.Add("gate"); Assert.False(platform.EnterSent); - } + }, }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -460,7 +460,7 @@ public async Task InsertTextAsync_auto_enter_indeterminate_gate_delays_once_befo order.Add("floor"); } }, - OnEnterSent = () => order.Add("enter") + OnEnterSent = () => order.Add("enter"), }; var confirmation = new FakePasteConfirmationSource { Result = null }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -499,7 +499,7 @@ public async Task InsertTextAsync_auto_enter_without_confirmer_delays_once_even_ order.Add("floor"); } }, - OnEnterSent = () => order.Add("enter") + OnEnterSent = () => order.Add("enter"), }; var sut = new TextInsertionService(platform); @@ -525,7 +525,7 @@ public async Task InsertTextAsync_paste_watch_acquires_and_releases_text_changed var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService( platform, @@ -545,7 +545,7 @@ public async Task InsertTextAsync_without_confirmer_uses_floor_delay_then_restor var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService(platform); @@ -564,7 +564,7 @@ public async Task InsertTextAsync_indeterminate_confirmation_uses_floor_delay_th var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var confirmation = new FakePasteConfirmationSource { SourceNotRunning = true }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -586,7 +586,7 @@ public async Task InsertTextAsync_watch_timeout_uses_floor_delay_then_restores() var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var confirmation = new FakePasteConfirmationSource { Result = null }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -615,9 +615,9 @@ public async Task InsertTextAsync_skips_restore_when_clipboard_no_longer_holds_o [ "previous", // snapshot "new text", // verify — serving - "user copied meanwhile" // ownership check before restore + "user copied meanwhile", // ownership check before restore ] - ) + ), }; var confirmation = new FakePasteConfirmationSource { Result = true }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -643,9 +643,9 @@ public async Task InsertTextAsync_skips_restore_when_ownership_read_cannot_prove [ "previous", // snapshot "new text", // verify — serving - null // ownership check — clipboard no longer reads back as text + null, // ownership check — clipboard no longer reads back as text ] - ) + ), }; var confirmation = new FakePasteConfirmationSource { Result = true }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -667,7 +667,7 @@ public async Task InsertTextAsync_null_previous_clipboard_skips_wait_and_restore { Clipboard = null, ClipboardHasNonTextFormats = false, - PasteSucceeds = true + PasteSucceeds = true, }; var confirmation = new FakePasteConfirmationSource { Result = true }; var errorLog = new RecordingErrorLogService(); @@ -691,7 +691,7 @@ public async Task InsertTextAsync_nontext_previous_clipboard_logs_unrestorable_d { Clipboard = null, ClipboardHasNonTextFormats = true, - PasteSucceeds = true + PasteSucceeds = true, }; var errorLog = new RecordingErrorLogService(); var sut = new TextInsertionService(platform, errorLog); @@ -712,7 +712,7 @@ public async Task InsertTextAsync_richer_previous_clipboard_skips_lossy_restore_ { Clipboard = "previous", ClipboardHasNonTextFormats = true, - PasteSucceeds = true + PasteSucceeds = true, }; var confirmation = new FakePasteConfirmationSource { Result = true }; var errorLog = new RecordingErrorLogService(); @@ -748,7 +748,7 @@ public async Task InsertTextAsync_focus_failure_falls_back_to_clipboard() { Clipboard = "previous", ActiveWindowId = "other", - ActivateSucceeds = false + ActivateSucceeds = false, }; var sut = new TextInsertionService(platform); @@ -775,7 +775,7 @@ public async Task InsertTextAsync_partial_typing_failure_does_not_retry_via_clip Clipboard = "previous", TypeSucceeds = false, TypeFailureReason = InsertionFailureReason.PartialTypingFailure, - LastTypingDeliveredPartialText = true + LastTypingDeliveredPartialText = true, }; var sut = new TextInsertionService(platform); @@ -801,7 +801,7 @@ public async Task InsertTextAsync_partial_delivery_with_structural_reason_still_ Clipboard = "previous", TypeSucceeds = false, TypeFailureReason = InsertionFailureReason.YdotoolSocketUnreachable, - LastTypingDeliveredPartialText = true + LastTypingDeliveredPartialText = true, }; var sut = new TextInsertionService(platform); @@ -827,7 +827,7 @@ public async Task InsertTextAsync_direct_typing_failure_reason_survives_paste_fa TypeSucceeds = false, TypeFailureReason = InsertionFailureReason.YdotoolSocketUnreachable, PasteSucceeds = false, - PasteFailureReason = InsertionFailureReason.NoWaylandTypingTool + PasteFailureReason = InsertionFailureReason.NoWaylandTypingTool, }; var sut = new TextInsertionService(platform); @@ -846,7 +846,7 @@ public async Task InsertTextAsync_terminal_multiline_focus_failure_fails_closed_ { Clipboard = "previous", ActiveWindowId = "other", - ActivateSucceeds = false + ActivateSucceeds = false, }; var sut = new TextInsertionService(platform); @@ -876,9 +876,9 @@ public async Task InsertTextAsync_terminal_multiline_verify_failure_fails_closed [ "previous", // snapshot "previous", "previous", "previous", "previous", // verify pass 1 - "previous", "previous", "previous", "previous" // verify pass 2 after re-set + "previous", "previous", "previous", "previous", // verify pass 2 after re-set ] - ) + ), }; var sut = new TextInsertionService(platform); @@ -903,7 +903,7 @@ public async Task InsertTextAsync_terminal_multiline_fail_closed_keeps_staged_te { Clipboard = null, ActiveWindowId = "other", - ActivateSucceeds = false + ActivateSucceeds = false, }; var sut = new TextInsertionService(platform); @@ -924,7 +924,7 @@ public async Task InsertTextAsync_terminal_multiline_nontext_clipboard_keeps_sta Clipboard = null, ClipboardHasNonTextFormats = true, ActiveWindowId = "other", - ActivateSucceeds = false + ActivateSucceeds = false, }; var errorLog = new RecordingErrorLogService(); var sut = new TextInsertionService(platform, errorLog); @@ -956,9 +956,9 @@ public async Task InsertTextAsync_terminal_multiline_fail_closed_keeps_newer_cli ClipboardReadResults = new Queue( [ "previous", // snapshot before staging - "user-copied-this-later" // ownership check during fail-closed restore + "user-copied-this-later", // ownership check during fail-closed restore ] - ) + ), }; var sut = new TextInsertionService(platform); @@ -977,7 +977,7 @@ public async Task InsertTextAsync_missing_clipboard_tool_returns_specific_result var platform = new FakeTextInsertionPlatform { ClipboardSetAvailable = false, - PasteAvailable = true + PasteAvailable = true, }; var sut = new TextInsertionService(platform); @@ -993,7 +993,7 @@ public async Task InsertTextAsync_missing_paste_tool_returns_specific_result_whe var platform = new FakeTextInsertionPlatform { ClipboardSetAvailable = true, - PasteAvailable = false + PasteAvailable = false, }; var sut = new TextInsertionService(platform); @@ -1010,7 +1010,7 @@ public async Task InsertTextAsync_missing_paste_tool_allows_copy_only() { Clipboard = "previous", ClipboardSetAvailable = true, - PasteAvailable = false + PasteAvailable = false, }; var sut = new TextInsertionService(platform); @@ -1027,7 +1027,7 @@ public async Task InsertTextAsync_codex_window_uses_direct_typing() var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService(platform); @@ -1113,7 +1113,7 @@ string text var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService(platform); @@ -1154,7 +1154,7 @@ public async Task InsertTextAsync_terminal_multiline_without_clipboard_tool_fail var platform = new FakeTextInsertionPlatform { ClipboardSetAvailable = false, - PasteAvailable = true + PasteAvailable = true, }; var sut = new TextInsertionService(platform); @@ -1175,7 +1175,7 @@ public async Task InsertTextAsync_terminal_multiline_when_paste_chord_fails_fail var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = false + PasteSucceeds = false, }; var sut = new TextInsertionService(platform); @@ -1237,7 +1237,7 @@ string windowTitle var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService(platform); @@ -1260,7 +1260,7 @@ public async Task InsertTextAsync_clipboard_paste_strategy_overrides_terminal_di var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService(platform); @@ -1307,7 +1307,7 @@ public async Task InsertTextAsync_unknown_target_with_ascii_text_types_directly( { Clipboard = "previous", PasteSucceeds = true, - PrefersDirectTypingForUnknownTarget = true + PrefersDirectTypingForUnknownTarget = true, }; var sut = new TextInsertionService(platform); @@ -1340,7 +1340,7 @@ string text { Clipboard = "previous", PasteSucceeds = true, - PrefersDirectTypingForUnknownTarget = true + PrefersDirectTypingForUnknownTarget = true, }; var sut = new TextInsertionService(platform); @@ -1365,7 +1365,7 @@ public async Task InsertTextAsync_unknown_target_ascii_safe_check_allows_tab_and var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PrefersDirectTypingForUnknownTarget = true + PrefersDirectTypingForUnknownTarget = true, }; var sut = new TextInsertionService(platform); @@ -1424,7 +1424,7 @@ public async Task InsertTextAsync_empty_text_with_auto_enter_requires_paste_tool var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteAvailable = false + PasteAvailable = false, }; var sut = new TextInsertionService(platform); @@ -1442,7 +1442,7 @@ public async Task CaptureSelectedTextAsync_returns_selection_and_restores_clipbo var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - SelectionText = "the selected text" + SelectionText = "the selected text", }; var sut = new TextInsertionService(platform); @@ -1461,7 +1461,7 @@ public async Task CaptureSelectedTextAsync_uses_terminal_copy_shortcut_for_termi var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - SelectionText = "the selected text" + SelectionText = "the selected text", }; var sut = new TextInsertionService(platform); @@ -1498,7 +1498,7 @@ public async Task CaptureSelectedTextAsync_returns_empty_when_copy_leaves_clipbo var platform = new FakeTextInsertionPlatform { Clipboard = "stale clipboard content", - SelectionText = null + SelectionText = null, }; var sut = new TextInsertionService(platform); @@ -1517,7 +1517,7 @@ public async Task CaptureSelectedTextAsync_retries_copy_until_selection_lands() { Clipboard = "previous", SelectionText = "the selected text", - CopyLandsOnAttempt = 3 + CopyLandsOnAttempt = 3, }; var sut = new TextInsertionService(platform); @@ -1537,7 +1537,7 @@ public async Task CaptureSelectedTextAsync_returns_empty_when_copy_never_lands() var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - SelectionText = null + SelectionText = null, }; var sut = new TextInsertionService(platform); @@ -1553,7 +1553,7 @@ public async Task CaptureSelectedTextAsync_returns_empty_when_copy_fails() var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - CopySucceeds = false + CopySucceeds = false, }; var sut = new TextInsertionService(platform); @@ -1572,7 +1572,7 @@ public async Task CaptureSelectedTextAsync_preserves_nontext_clipboard_when_no_s { Clipboard = null, ClipboardHasNonTextFormats = true, - SelectionText = null + SelectionText = null, }; var errorLog = new RecordingErrorLogService(); var sut = new TextInsertionService(platform, errorLog); @@ -1593,7 +1593,7 @@ public async Task CaptureSelectedTextAsync_richer_clipboard_restores_plain_text_ { Clipboard = "previous", ClipboardHasNonTextFormats = true, - SelectionText = "the selected text" + SelectionText = "the selected text", }; var errorLog = new RecordingErrorLogService(); var sut = new TextInsertionService(platform, errorLog); @@ -1855,7 +1855,7 @@ public async Task LinuxTextInsertionPlatform_Xdotool_TerminalPasteUsesCtrlShiftV ["keydown", "--clearmodifiers", "Shift_L"], ["key", "v"], ["keyup", "Shift_L"], - ["keyup", "Control_L"] + ["keyup", "Control_L"], ], runner.Calls.Select(call => call.Arguments).ToArray() ); @@ -2361,7 +2361,7 @@ public async Task LinuxTextInsertionPlatform_Wtype_TypesNewlineAsShiftEnter() [ ["--", "line one"], ["-M", "shift", "-k", "Return", "-m", "shift"], - ["--", "line two"] + ["--", "line two"], ], runner.Calls.Select(c => c.Arguments).ToArray() ); @@ -2490,7 +2490,7 @@ public async Task LinuxTextInsertionPlatform_Ydotool_TypesNewlineAsShiftEnter() // LEFTSHIFT(42)+ENTER(28) press/release pairs, with an inter-event delay so the // Shift modifier reliably registers before Enter. ["key", "--key-delay", "25", "42:1", "28:1", "28:0", "42:0"], - ["type", "--key-delay", "2", "--key-hold", "2", "--", "line two"] + ["type", "--key-delay", "2", "--key-hold", "2", "--", "line two"], ], runner.Calls.Select(c => c.Arguments).ToArray() ); @@ -2513,7 +2513,7 @@ public async Task LinuxTextInsertionPlatform_Xdotool_TypesNewlineAsShiftEnter() [ ["type", "--clearmodifiers", "--delay", "8", "--", "line one"], ["key", "--clearmodifiers", "shift+Return"], - ["type", "--clearmodifiers", "--delay", "8", "--", "line two"] + ["type", "--clearmodifiers", "--delay", "8", "--", "line two"], ], runner.Calls.Select(c => c.Arguments).ToArray() ); @@ -2538,7 +2538,7 @@ public async Task LinuxTextInsertionPlatform_ParagraphBreak_EmitsTwoShiftEntersA ["--", "first"], ["-M", "shift", "-k", "Return", "-m", "shift"], ["-M", "shift", "-k", "Return", "-m", "shift"], - ["--", "second"] + ["--", "second"], ], runner.Calls.Select(c => c.Arguments).ToArray() ); @@ -2560,7 +2560,7 @@ public async Task LinuxTextInsertionPlatform_CrlfNewline_NormalizedToSingleShift [ ["--", "a"], ["-M", "shift", "-k", "Return", "-m", "shift"], - ["--", "b"] + ["--", "b"], ], runner.Calls.Select(c => c.Arguments).ToArray() ); @@ -2645,7 +2645,7 @@ private static FakeProcessRunner CreateTimedOutProcessRunner() -1, string.Empty, string.Empty - ) + ), }; } diff --git a/tests/TypeWhisper.Linux.Tests/WatchFolderExportBuilderTests.cs b/tests/TypeWhisper.Linux.Tests/WatchFolderExportBuilderTests.cs index b19757e63..8c27b572d 100644 --- a/tests/TypeWhisper.Linux.Tests/WatchFolderExportBuilderTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WatchFolderExportBuilderTests.cs @@ -43,7 +43,7 @@ public void Build_creates_subtitle_exports() { var result = Result("caption") with { - Segments = [new TranscriptionSegment("caption", 1.2, 3.4)] + Segments = [new TranscriptionSegment("caption", 1.2, 3.4)], }; var srt = WatchFolderExportBuilder.Build( From 8e46178565431d225050b344280f1612477eeab3 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 21 Jul 2026 15:26:36 -0400 Subject: [PATCH 137/226] Refactor WAV PCM extraction: handle non-standard sizes, support streamed WAV, improve format validation, update file paths for clarity, add tests for PCM extraction robustness, and handle listener stops gracefully. --- .../ObsidianPlugin.cs | 8 +- .../OpenAiOAuthSupport.cs | 10 +-- .../TypeWhisper.Plugin.Reson8/Reson8Plugin.cs | 47 ++++++++-- .../SherpaOnnxPlugin.cs | 2 +- .../SupertonicTtsPlugin.cs | 5 ++ .../Reson8PluginTests.cs | 87 ++++++++++++++++++- 6 files changed, 142 insertions(+), 17 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs index 16161fa63..41bc8972e 100644 --- a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs @@ -240,7 +240,13 @@ internal static List DetectVaults() // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (!string.IsNullOrEmpty(path) && Directory.Exists(path)) { - var name = Path.GetFileName(path); + // Path.GetFileName yields "" for a trailing-separator path; trim + // separators first, then fall back to the vault key so the picker + // never shows a blank display name. + var name = Path.GetFileName( + path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (string.IsNullOrEmpty(name)) + name = vault.Name; vaults.Add(new ObsidianVaultInfo(name, path)); } } diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs index d2cd65373..170ed4f2b 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs @@ -346,15 +346,11 @@ private async Task AcceptOnAnyListenerAsync(CancellationToken ct) private void StopListeners() { try { _v4Listener?.Stop(); } - catch { //nada - } + catch { /* Listener may already be stopped or disposed. */ } try { _v6Listener?.Stop(); } - catch - { - //nada - - } + catch { /* Listener may already be stopped or disposed. */ } + _v4Listener = null; _v6Listener = null; } diff --git a/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs b/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs index b867ce54d..33ba48a06 100644 --- a/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs +++ b/plugins/TypeWhisper.Plugin.Reson8/Reson8Plugin.cs @@ -567,25 +567,60 @@ public static byte[] ExtractPcm16(byte[] wavAudio) } var offset = 12; + short audioFormat = 0; + short channels = 0; + var sampleRate = 0; + short bitsPerSample = 0; byte[]? data = null; while (offset + 8 <= wavAudio.Length) { var chunkId = Encoding.ASCII.GetString(wavAudio, offset, 4); - var chunkSize = BitConverter.ToInt32(wavAudio, offset + 4); + var chunkSize = BitConverter.ToUInt32(wavAudio, offset + 4); offset += 8; - if (chunkSize < 0 || offset + chunkSize > wavAudio.Length) - break; + var remaining = wavAudio.Length - offset; if (chunkId == "data") { - data = wavAudio.Skip(offset).Take(chunkSize).ToArray(); + // A non-seekable muxer (ffmpeg's `-f wav pipe:1`) can't backfill + // the data size and writes 0xFFFFFFFF; treat any size past the + // buffer end as "everything remaining". + var dataLength = chunkSize > (uint)remaining ? remaining : (int)chunkSize; + data = wavAudio.Skip(offset).Take(dataLength).ToArray(); + offset += dataLength + dataLength % 2; + continue; + } + + // Any other chunk claiming more than the buffer holds means a + // truncated or corrupt file, so stop scanning. + if (chunkSize > (uint)remaining) + break; + + var size = (int)chunkSize; + if (chunkId == "fmt " && size >= 16) + { + audioFormat = BitConverter.ToInt16(wavAudio, offset); + channels = BitConverter.ToInt16(wavAudio, offset + 2); + sampleRate = BitConverter.ToInt32(wavAudio, offset + 4); + bitsPerSample = BitConverter.ToInt16(wavAudio, offset + 14); } - offset += chunkSize + chunkSize % 2; + offset += size + size % 2; + } + + if (data is null) + return wavAudio; + + // The endpoints advertise the body as raw pcm_s16le/16 kHz/mono, so any + // other format would be mislabeled and transcribed as noise; reject it. + if (audioFormat != 1 || channels != 1 || sampleRate != 16000 || bitsPerSample != 16) + { + throw new NotSupportedException( + "Reson8 requires 16-bit little-endian PCM, 16 kHz, mono audio, but received " + + $"format={audioFormat}, channels={channels}, sampleRate={sampleRate}, bitsPerSample={bitsPerSample}."); } - return data ?? wavAudio; + return data; } private static bool HasAscii(byte[] bytes, int offset, string value) diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs index a994b6b5d..0a1cb9523 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs @@ -928,7 +928,7 @@ private static float[] DecodeWav(byte[] wavData) /// /// One-shot migration from the pre-plugin layout - /// (%LocalAppData%/TypeWhisper/s_models/) into the per-plugin data + /// (%LocalAppData%/TypeWhisper/Models/) into the per-plugin data /// directory. Best-effort: failures are logged and a stale source /// directory is left alone rather than blocking activation. /// diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs index 047574acf..f264bb53a 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/SupertonicTtsPlugin.cs @@ -353,6 +353,11 @@ internal async Task DownloadAssetsAsync(IProgress? progress, Cancellatio await _downloadLock.WaitAsync(ct); try { + // Dispose() sets _disposed before taking this lock, so a download that + // only acquired the lock after teardown began must bail out here rather + // than touch the now-disposed asset manager. + ObjectDisposedException.ThrowIf(_disposed, this); + if (_assetManager.AreAssetsReady) return; diff --git a/tests/TypeWhisper.PluginSystem.Tests/Reson8PluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/Reson8PluginTests.cs index 06e9010b5..31e8913b1 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/Reson8PluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/Reson8PluginTests.cs @@ -384,6 +384,48 @@ public void StreamingCollector_ThrowsActionableMessageForApiErrors() Assert.Contains("Invalid API key", ex.Message); } + [Fact] + public void ExtractPcm16_ReturnsDataPayload_ForStandard16kMonoPcm16Wav() + { + var pcm = new byte[] { 0x01, 0x00, 0xFF, 0xFF }; + + Assert.Equal(pcm, WavPcm16Extractor.ExtractPcm16(BuildPcm16Wav(pcm))); + } + + [Fact] + public void ExtractPcm16_PassesThroughNonWavPayloadUnchanged() + { + var raw = new byte[] { 0x01, 0x02, 0x03, 0x04 }; + + Assert.Equal(raw, WavPcm16Extractor.ExtractPcm16(raw)); + } + + [Fact] + public void ExtractPcm16_ReturnsDataPayload_ForStreamedWavWithPlaceholderSizes() + { + // Even with ffmpeg's placeholder sizes, the extractor must recover the + // PCM payload rather than fall back to shipping the whole container. + var pcm = new byte[] { 0x01, 0x00, 0xFF, 0xFF }; + + Assert.Equal(pcm, WavPcm16Extractor.ExtractPcm16(BuildStreamedWav(pcm))); + } + + [Theory] + [InlineData((short)1, (short)2, 16000, (short)16)] // stereo + [InlineData((short)1, (short)1, 48000, (short)16)] // wrong sample rate + [InlineData((short)3, (short)1, 16000, (short)32)] // IEEE float + [InlineData((short)1, (short)1, 16000, (short)8)] // 8-bit depth + public void ExtractPcm16_RejectsUnsupportedFormats_RatherThanMislabelingPayload( + short audioFormat, + short channels, + int sampleRate, + short bitsPerSample) + { + var wav = BuildWav(audioFormat, channels, sampleRate, bitsPerSample, [0x01, 0x00, 0xFF, 0xFF]); + + Assert.Throws(() => WavPcm16Extractor.ExtractPcm16(wav)); + } + private static JsonElement LoadManifest() { var basePath = Path.GetFullPath(AppContext.BaseDirectory); @@ -406,8 +448,17 @@ private static JsonElement LoadLocalization(string language) return doc.RootElement.Clone(); } - private static byte[] BuildPcm16Wav(byte[] pcm) + private static byte[] BuildPcm16Wav(byte[] pcm) => + BuildWav(audioFormat: 1, channels: 1, sampleRate: 16000, bitsPerSample: 16, pcm); + + private static byte[] BuildWav( + short audioFormat, + short channels, + int sampleRate, + short bitsPerSample, + byte[] pcm) { + var blockAlign = (short)(channels * bitsPerSample / 8); using var stream = new MemoryStream(); using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true); writer.Write("RIFF"u8.ToArray()); @@ -415,14 +466,46 @@ private static byte[] BuildPcm16Wav(byte[] pcm) writer.Write("WAVE"u8.ToArray()); writer.Write("fmt "u8.ToArray()); writer.Write(16); + writer.Write(audioFormat); + writer.Write(channels); + writer.Write(sampleRate); + writer.Write(sampleRate * blockAlign); + writer.Write(blockAlign); + writer.Write(bitsPerSample); + writer.Write("data"u8.ToArray()); + writer.Write(pcm.Length); + writer.Write(pcm); + writer.Flush(); + return stream.ToArray(); + } + + // Mirrors ffmpeg's `-f wav pipe:1` output: RIFF and data chunk sizes are the + // 0xFFFFFFFF placeholder a non-seekable muxer can't backfill, with a LIST/INFO + // metadata chunk sitting between fmt and data. + private static byte[] BuildStreamedWav(byte[] pcm) + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true); + writer.Write("RIFF"u8.ToArray()); + writer.Write(uint.MaxValue); + writer.Write("WAVE"u8.ToArray()); + writer.Write("fmt "u8.ToArray()); + writer.Write(16); writer.Write((short)1); writer.Write((short)1); writer.Write(16000); writer.Write(16000 * 2); writer.Write((short)2); writer.Write((short)16); + var software = "Lavf62.12.102\0"u8.ToArray(); // 14 bytes, keeps the chunk even + writer.Write("LIST"u8.ToArray()); + writer.Write(4 + 4 + 4 + software.Length); // "INFO" + "ISFT" + size + data + writer.Write("INFO"u8.ToArray()); + writer.Write("ISFT"u8.ToArray()); + writer.Write(software.Length); + writer.Write(software); writer.Write("data"u8.ToArray()); - writer.Write(pcm.Length); + writer.Write(uint.MaxValue); writer.Write(pcm); writer.Flush(); return stream.ToArray(); From 53ee9bf4dd83c75bd9d322ea4ec55bda6e04b6a9 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Wed, 22 Jul 2026 10:32:52 -0400 Subject: [PATCH 138/226] Implement detachAfterExit parameter in ProcessRunner and related tests to handle daemonized processes; update ProcessRunner to short-circuit read draining for detached processes to improve efficiency. --- .../Services/ProcessRunner.cs | 39 ++++++-- .../Services/TextInsertionService.cs | 5 +- ...ompositorShortcutWriterConcurrencyTests.cs | 1 + .../FakeProcessRunner.cs | 1 + .../GnomeShortcutWriterTests.cs | 1 + .../InputAccessSetupHelperTests.cs | 6 +- .../LinuxSystemTtsProviderTests.cs | 1 + .../ProcessRunnerTests.cs | 91 ++++++++++++++++++- .../RecordingNotificationServiceTests.cs | 1 + .../SoundFeedbackServiceTests.cs | 1 + .../YdotoolSetupHelperTests.cs | 3 +- 11 files changed, 134 insertions(+), 16 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/ProcessRunner.cs b/src/TypeWhisper.Linux/Services/ProcessRunner.cs index b91a12f83..386e121a6 100644 --- a/src/TypeWhisper.Linux/Services/ProcessRunner.cs +++ b/src/TypeWhisper.Linux/Services/ProcessRunner.cs @@ -50,6 +50,14 @@ public interface IProcessRunner /// When set, the process tree is killed if it outlives the window and the result is flagged /// . /// + /// + /// Set only for commands that fork a persistent descendant which keeps the redirected + /// stdout/stderr pipes open after the parent exits (wl-copy/xclip spawn a daemon to serve + /// the clipboard selection). Their output is uninteresting, so the run abandons the read a + /// short grace after the parent exits instead of blocking the full timeout for an EOF that + /// never comes. Leave false for any command whose output is parsed — that path drains up to + /// the remaining timeout so no valid output is discarded. + /// /// Cancels the run; the process tree is killed on cancellation. Task RunAsync( string fileName, @@ -57,6 +65,7 @@ Task RunAsync( IReadOnlyDictionary? environment = null, string? standardInput = null, TimeSpan? timeout = null, + bool detachAfterExit = false, CancellationToken ct = default ); } @@ -75,6 +84,7 @@ public async Task RunAsync( IReadOnlyDictionary? environment = null, string? standardInput = null, TimeSpan? timeout = null, + bool detachAfterExit = false, CancellationToken ct = default ) { @@ -171,7 +181,26 @@ await standardInputWriter! } var exitCode = process.ExitCode; - if (timeout is not { } timeoutLimit) + + // How long to drain the reads now that the process has exited (a descendant may still + // hold the pipe): the short grace when the caller opted into detachment, otherwise the + // remaining timeout so valid parent output isn't dropped, or unbounded when neither. + TimeSpan? drainLimit; + if (detachAfterExit) + { + drainLimit = s_minimumDrainGrace; + } + else if (timeout is { } timeoutLimit) + { + var remaining = timeoutLimit - timeoutStopwatch!.Elapsed; + drainLimit = remaining > s_minimumDrainGrace ? remaining : s_minimumDrainGrace; + } + else + { + drainLimit = null; + } + + if (drainLimit is not { } drainWindow) { var standardOutput = await stdoutTask.ConfigureAwait(false); var standardError = await stderrTask.ConfigureAwait(false); @@ -185,14 +214,8 @@ await standardInputWriter! ); } - var remaining = timeoutLimit - timeoutStopwatch!.Elapsed; - // Preserve the lifecycle deadline when time remains, but allow a small - // post-exit grace so a process exiting at the deadline can flush normal - // redirected output. The total run may therefore exceed the limit by at - // most 250 ms when the process exits at deadline-minus-epsilon. - var drainLimit = remaining > s_minimumDrainGrace ? remaining : s_minimumDrainGrace; using var drainCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - drainCts.CancelAfter(drainLimit); + drainCts.CancelAfter(drainWindow); try { await Task.WhenAll(stdoutTask, stderrTask) diff --git a/src/TypeWhisper.Linux/Services/TextInsertionService.cs b/src/TypeWhisper.Linux/Services/TextInsertionService.cs index 2fc7584fb..45b856295 100644 --- a/src/TypeWhisper.Linux/Services/TextInsertionService.cs +++ b/src/TypeWhisper.Linux/Services/TextInsertionService.cs @@ -1224,7 +1224,10 @@ public async Task SetClipboardTextAsync(string text) fileName, args, standardInput: text, - timeout: s_clipboardOperationTimeout + timeout: s_clipboardOperationTimeout, + // wl-copy/xclip leave a selection-serving daemon holding our stdout pipe; without + // this every clipboard write would block the full timeout (~5 s) draining it. + detachAfterExit: true ).ConfigureAwait(false); // ReSharper disable once InvertIf -- early-return guard clause; inverting would nest the happy path if (result.TimedOut) diff --git a/tests/TypeWhisper.Linux.Tests/CompositorShortcutWriterConcurrencyTests.cs b/tests/TypeWhisper.Linux.Tests/CompositorShortcutWriterConcurrencyTests.cs index c852f99e4..4c2d84b8e 100644 --- a/tests/TypeWhisper.Linux.Tests/CompositorShortcutWriterConcurrencyTests.cs +++ b/tests/TypeWhisper.Linux.Tests/CompositorShortcutWriterConcurrencyTests.cs @@ -433,6 +433,7 @@ public Task RunAsync( IReadOnlyDictionary? environment = null, string? standardInput = null, TimeSpan? timeout = null, + bool detachAfterExit = false, CancellationToken ct = default ) { diff --git a/tests/TypeWhisper.Linux.Tests/FakeProcessRunner.cs b/tests/TypeWhisper.Linux.Tests/FakeProcessRunner.cs index f2fe13cd2..5a6cd2326 100644 --- a/tests/TypeWhisper.Linux.Tests/FakeProcessRunner.cs +++ b/tests/TypeWhisper.Linux.Tests/FakeProcessRunner.cs @@ -29,6 +29,7 @@ public Task RunAsync( IReadOnlyDictionary? environment = null, string? standardInput = null, TimeSpan? timeout = null, + bool detachAfterExit = false, CancellationToken ct = default ) { diff --git a/tests/TypeWhisper.Linux.Tests/GnomeShortcutWriterTests.cs b/tests/TypeWhisper.Linux.Tests/GnomeShortcutWriterTests.cs index 98819d20b..9c1778a81 100644 --- a/tests/TypeWhisper.Linux.Tests/GnomeShortcutWriterTests.cs +++ b/tests/TypeWhisper.Linux.Tests/GnomeShortcutWriterTests.cs @@ -583,6 +583,7 @@ public Task RunAsync( IReadOnlyDictionary? environment = null, string? standardInput = null, TimeSpan? timeout = null, + bool detachAfterExit = false, CancellationToken ct = default ) { diff --git a/tests/TypeWhisper.Linux.Tests/InputAccessSetupHelperTests.cs b/tests/TypeWhisper.Linux.Tests/InputAccessSetupHelperTests.cs index 92ace588b..aa9f21fee 100644 --- a/tests/TypeWhisper.Linux.Tests/InputAccessSetupHelperTests.cs +++ b/tests/TypeWhisper.Linux.Tests/InputAccessSetupHelperTests.cs @@ -566,6 +566,7 @@ public async Task RunAsync( IReadOnlyDictionary? environment = null, string? standardInput = null, TimeSpan? timeout = null, + bool detachAfterExit = false, CancellationToken ct = default ) { @@ -580,7 +581,7 @@ public async Task RunAsync( new Dictionary { ["PATH"] = commandPath }, standardInput, timeout, - ct + ct: ct ); LastPrivilegedResult = result; return result; @@ -609,6 +610,7 @@ public Task RunAsync( IReadOnlyDictionary? environment = null, string? standardInput = null, TimeSpan? timeout = null, + bool detachAfterExit = false, CancellationToken ct = default ) { @@ -617,7 +619,7 @@ public Task RunAsync( File.WriteAllText(InputAccessSetupHelper.UdevRulePath, _foreignContent); } - return _inner.RunAsync(fileName, args, environment, standardInput, timeout, ct); + return _inner.RunAsync(fileName, args, environment, standardInput, timeout, ct: ct); } } } diff --git a/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs b/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs index 97c08a11b..a89451b87 100644 --- a/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs @@ -767,6 +767,7 @@ public async Task RunAsync( IReadOnlyDictionary? environment = null, string? standardInput = null, TimeSpan? timeout = null, + bool detachAfterExit = false, CancellationToken ct = default ) { diff --git a/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs b/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs index 6300a4d86..cf80e7e01 100644 --- a/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs @@ -8,8 +8,16 @@ public sealed class ProcessRunnerTests { private static readonly TimeSpan s_testGuard = TimeSpan.FromSeconds(5); + // Upper bound on a detached drain: it must abandon near the 250 ms grace, not wait the full + // timeout. Held between that grace and the 2 s timeout the descendant test uses so a regression + // back to full-timeout draining trips it. + private static readonly TimeSpan s_maxPostExitDrain = TimeSpan.FromSeconds(1); + + // Mirrors ProcessRunner's private post-exit drain grace (250 ms). + private static readonly TimeSpan s_postExitGrace = TimeSpan.FromMilliseconds(250); + [Fact] - public async Task RunAsync_returns_success_when_descendant_holds_stdout_open() + public async Task RunAsync_with_detachAfterExit_abandons_promptly_when_descendant_holds_stdout_open() { var pidFile = NewPidFile(); int? childProcessId = null; @@ -24,7 +32,8 @@ public async Task RunAsync_returns_success_when_descendant_holds_stdout_open() "process-runner-test", pidFile, ], - timeout: TimeSpan.FromSeconds(2) + timeout: TimeSpan.FromSeconds(2), + detachAfterExit: true ); childProcessId = await WaitForProcessIdAsync(pidFile); @@ -32,8 +41,9 @@ public async Task RunAsync_returns_success_when_descendant_holds_stdout_open() stopwatch.Stop(); Assert.True( - stopwatch.Elapsed < s_testGuard, - $"ProcessRunner did not bound the output drain; elapsed {stopwatch.Elapsed}." + stopwatch.Elapsed < s_maxPostExitDrain, + "detachAfterExit waited the full timeout draining a descendant-held pipe instead of " + + $"abandoning after the post-exit grace; elapsed {stopwatch.Elapsed}." ); Assert.True(result.Succeeded); Assert.True(result.Started); @@ -55,6 +65,79 @@ public async Task RunAsync_returns_success_when_descendant_holds_stdout_open() } } + [Fact] + public async Task RunAsync_by_default_preserves_parent_output_behind_a_short_lived_descendant() + { + // Parent prints then exits, but a descendant holds the stdout pipe open past the 250 ms + // grace yet within the timeout. Without detachAfterExit the run must keep draining until the + // pipe closes, so the buffered parent output is captured rather than discarded. + var stopwatch = Stopwatch.StartNew(); + var runTask = new ProcessRunner().RunAsync( + "/bin/bash", + ["-c", "printf '%s' parent-output; sleep 0.7 & exit 0"], + timeout: TimeSpan.FromSeconds(5) + ); + + var result = await runTask.WaitAsync(s_testGuard); + stopwatch.Stop(); + + Assert.True(result.Succeeded); + Assert.False(result.TimedOut); + Assert.Equal(0, result.ExitCode); + Assert.Equal("parent-output", result.StandardOutput); + // The descendant held the pipe ~700 ms; if the default had short-drained at the grace it + // would have abandoned the read (and lost the output) well before that. + Assert.True( + stopwatch.Elapsed > s_postExitGrace, + $"The default drain abandoned before the descendant released the pipe; elapsed {stopwatch.Elapsed}." + ); + } + + [Fact] + public async Task RunAsync_with_detachAfterExit_abandons_promptly_even_without_a_timeout() + { + // detachAfterExit must honor the short grace independently of a lifecycle timeout: with no + // timeout the run would otherwise wait for EOF forever behind the daemon-held pipe. + var pidFile = NewPidFile(); + int? childProcessId = null; + try + { + var stopwatch = Stopwatch.StartNew(); + var runTask = new ProcessRunner().RunAsync( + "/bin/bash", + [ + "-c", + "sleep 30 & child=$!; printf '%s' \"$child\" > \"$1\"; exit 0", + "process-runner-test", + pidFile, + ], + detachAfterExit: true + ); + childProcessId = await WaitForProcessIdAsync(pidFile); + + var result = await runTask.WaitAsync(s_testGuard); + stopwatch.Stop(); + + Assert.True( + stopwatch.Elapsed < s_maxPostExitDrain, + "detachAfterExit ignored its grace without a timeout and waited on the descendant; " + + $"elapsed {stopwatch.Elapsed}." + ); + Assert.True(result.Succeeded); + Assert.False(result.TimedOut); + Assert.Equal(0, result.ExitCode); + } + finally + { + if (childProcessId is { } leakedProcessId) + { + TryKillProcess(leakedProcessId); + } + + File.Delete(pidFile); + } + } + [Fact] public async Task RunAsync_captures_all_output_from_fast_process() { diff --git a/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs b/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs index 7cff7b27e..4ed0e4f2a 100644 --- a/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs @@ -473,6 +473,7 @@ public Task RunAsync( IReadOnlyDictionary? environment = null, string? standardInput = null, TimeSpan? timeout = null, + bool detachAfterExit = false, CancellationToken ct = default ) { diff --git a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs index 54a7e9f3e..ed9cfa466 100644 --- a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs @@ -241,6 +241,7 @@ public Task RunAsync( IReadOnlyDictionary? environment = null, string? standardInput = null, TimeSpan? timeout = null, + bool detachAfterExit = false, CancellationToken ct = default ) { diff --git a/tests/TypeWhisper.Linux.Tests/YdotoolSetupHelperTests.cs b/tests/TypeWhisper.Linux.Tests/YdotoolSetupHelperTests.cs index 247fbfe16..8ea4619e4 100644 --- a/tests/TypeWhisper.Linux.Tests/YdotoolSetupHelperTests.cs +++ b/tests/TypeWhisper.Linux.Tests/YdotoolSetupHelperTests.cs @@ -626,6 +626,7 @@ public async Task RunAsync( IReadOnlyDictionary? environment = null, string? standardInput = null, TimeSpan? timeout = null, + bool detachAfterExit = false, CancellationToken ct = default ) { @@ -640,7 +641,7 @@ public async Task RunAsync( new Dictionary { ["PATH"] = commandPath }, standardInput, timeout, - ct + ct: ct ); LastPrivilegedResult = result; return result; From 4105ac35c05ac4b24a5c660ab5dc6ca04c999238 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 12:16:17 +0000 Subject: [PATCH 139/226] Guard the autostart toggle behind desktop-entry ownership Existence of ~/.config/autostart/typewhisper.desktop was treated as ownership: enabling rewrote the file unconditionally and disabling deleted it, so a distro-shipped or user-customized entry (custom Exec, environment, startup delay, Hidden=true) could be silently destroyed. StartupService now stamps entries it creates with an X-TypeWhisper-Managed=true marker line and only rewrites or deletes a file that carries that exact line or byte-for-byte matches the legacy generated content; anything else is preserved and the operation returns a refusal result instead of touching the file. The toggle ViewModel applies the operation result - resyncing the switch under a reentrancy guard and surfacing a localized explanation (all four locales) - rather than assuming success, and catches expected IO failures so they surface as status text instead of escaping into the dispatcher. The status text relocalizes on language change and wraps in the UI. --- .../Resources/Localization/de.json | 1 + .../Resources/Localization/en.json | 1 + .../Resources/Localization/es.json | 1 + .../Resources/Localization/ru.json | 1 + .../Services/StartupService.cs | 143 ++++++-- .../Sections/GeneralSectionViewModel.cs | 35 +- .../Views/Sections/GeneralSection.axaml | 3 +- .../LocalizationResourcesTests.cs | 13 + .../StartupServiceTests.cs | 310 ++++++++++++++++++ 9 files changed, 471 insertions(+), 37 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/StartupServiceTests.cs diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index f474cd8c5..6080de11a 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -372,6 +372,7 @@ "FileTranscription.WatchingForNewFiles": "Wartet auf neue Dateien", "General.ApiExamples": "API-Beispiele", "General.Autostart": "Beim Systemstart automatisch starten", + "General.AutostartEntryPreserved": "TypeWhisper hat den fremden oder angepassten Autostart-Eintrag unter {0} unangetastet gelassen und wird ihn weder überschreiben noch löschen.", "General.AutostartHint": "TypeWhisper startet automatisch, wenn Sie sich anmelden.", "General.BearerToken": "Bearer-Token", "General.CliBundledTarget": "Mitgeliefert: {0} | Ziel: {1}", diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index ccab95692..3308fed71 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -373,6 +373,7 @@ "FileTranscription.WatchingForNewFiles": "Watching for new files", "General.ApiExamples": "API examples", "General.Autostart": "Start automatically at system startup", + "General.AutostartEntryPreserved": "TypeWhisper left the foreign or customized autostart entry at {0} untouched and will not overwrite or delete it.", "General.AutostartHint": "TypeWhisper starts automatically when you log in.", "General.BearerToken": "Bearer token", "General.CliBundledTarget": "Bundled: {0} | Target: {1}", diff --git a/src/TypeWhisper.Linux/Resources/Localization/es.json b/src/TypeWhisper.Linux/Resources/Localization/es.json index 5a41d2aa8..f7b625342 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -372,6 +372,7 @@ "FileTranscription.WatchingForNewFiles": "Esperando archivos nuevos", "General.ApiExamples": "Ejemplos de API", "General.Autostart": "Iniciar automáticamente al arrancar el sistema", + "General.AutostartEntryPreserved": "TypeWhisper dejó intacta la entrada de inicio automático ajena o personalizada en {0} y no la sobrescribirá ni la eliminará.", "General.AutostartHint": "TypeWhisper se inicia automáticamente cuando inicias sesión.", "General.BearerToken": "Token Bearer", "General.CliBundledTarget": "Incluido: {0} | Destino: {1}", diff --git a/src/TypeWhisper.Linux/Resources/Localization/ru.json b/src/TypeWhisper.Linux/Resources/Localization/ru.json index 44c0eacfa..a24bf8b49 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -372,6 +372,7 @@ "FileTranscription.WatchingForNewFiles": "Наблюдение за новыми файлами", "General.ApiExamples": "Примеры API", "General.Autostart": "Запускать автоматически при старте системы", + "General.AutostartEntryPreserved": "TypeWhisper оставил сторонний или изменённый элемент автозапуска в {0} нетронутым и не будет его перезаписывать или удалять.", "General.AutostartHint": "TypeWhisper запускается автоматически при входе в систему.", "General.BearerToken": "Bearer-токен", "General.CliBundledTarget": "Встроенный: {0} | Цель: {1}", diff --git a/src/TypeWhisper.Linux/Services/StartupService.cs b/src/TypeWhisper.Linux/Services/StartupService.cs index 0f3d1c8d9..6f3dff7c4 100644 --- a/src/TypeWhisper.Linux/Services/StartupService.cs +++ b/src/TypeWhisper.Linux/Services/StartupService.cs @@ -1,15 +1,18 @@ using System.Diagnostics; +using TypeWhisper.Linux.Services.Localization; namespace TypeWhisper.Linux.Services; +public sealed record StartupOperationResult(bool Success, bool IsEnabled, string StatusText); + /// -/// XDG Autostart integration. Writes ~/.config/autostart/typewhisper.desktop -/// to enable, deletes it to disable. Freedesktop-compliant, works across -/// GNOME / KDE / XFCE / most other desktops. +/// XDG Autostart integration. Manages ~/.config/autostart/typewhisper.desktop +/// only when TypeWhisper can prove ownership from its contents. /// public static class StartupService { private const string DesktopFileName = "typewhisper.desktop"; + private const string ManagedLine = "X-TypeWhisper-Managed=true"; private static string AutostartDir { @@ -30,15 +33,101 @@ private static string AutostartDir private static string DesktopFilePath => Path.Join(AutostartDir, DesktopFileName); - public static bool IsEnabled => File.Exists(DesktopFilePath); + public static bool IsEnabled => + File.Exists(DesktopFilePath) && IsOwnedByTypeWhisper(DesktopFilePath); - public static void Enable() + public static StartupOperationResult Enable() { Directory.CreateDirectory(AutostartDir); - var execPath = - Process.GetCurrentProcess().MainModule?.FileName - ?? throw new InvalidOperationException("Cannot determine executable path."); + var execPath = ResolveExecutablePath(); + var iconPath = ResolveIconPath(); + var content = BuildDesktopFile(execPath, iconPath, includeManagedMarker: true); + + if (File.Exists(DesktopFilePath) && !IsOwnedByTypeWhisper(DesktopFilePath)) + { + return RefusedResult(); + } + + File.WriteAllText(DesktopFilePath, content); + return SuccessResult(isEnabled: true); + } + + public static StartupOperationResult Disable() + { + if (!File.Exists(DesktopFilePath)) + { + return SuccessResult(isEnabled: false); + } + + if (!IsOwnedByTypeWhisper(DesktopFilePath)) + { + return RefusedResult(); + } + + File.Delete(DesktopFilePath); + return SuccessResult(isEnabled: false); + } + + internal static string BuildDesktopFile( + string execPath, + string iconPath, + bool includeManagedMarker + ) + { + var content = + "[Desktop Entry]\n" + + "Type=Application\n" + + "Name=TypeWhisper\n" + + "GenericName=Voice-to-text dictation\n" + + $"Exec=\"{execPath}\" --minimized\n" + + $"Icon={iconPath}\n" + + "Terminal=false\n" + + "Categories=Utility;Accessibility;\n" + + "X-GNOME-Autostart-enabled=true"; + return includeManagedMarker ? $"{content}\n{ManagedLine}" : content; + } + + private static bool IsOwnedByTypeWhisper(string target) + { + string contents; + try + { + contents = File.ReadAllText(target); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return false; + } + + var lines = contents.Split('\n').Select(line => line.TrimEnd('\r')); + if (lines.Contains(ManagedLine, StringComparer.Ordinal)) + { + return true; + } + + try + { + var legacyContent = BuildDesktopFile( + ResolveExecutablePath(), + ResolveIconPath(), + includeManagedMarker: false + ); + return string.Equals(contents, legacyContent, StringComparison.Ordinal); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return false; + } + } + private static string ResolveExecutablePath() + { + return Process.GetCurrentProcess().MainModule?.FileName + ?? throw new InvalidOperationException("Cannot determine executable path."); + } + + private static string ResolveIconPath() + { // Prefer an absolute path to the bundled PNG so the entry works even // when no icon theme on the system defines "typewhisper". Falls back // to the theme name if the PNG is missing for any reason. @@ -48,30 +137,24 @@ public static void Enable() iconPath = Path.Join(AppContext.BaseDirectory, "Resources", "typewhisper-64.png"); } - if (!File.Exists(iconPath)) - { - iconPath = "typewhisper"; - } + return File.Exists(iconPath) ? iconPath : "typewhisper"; + } - var content = $""" - [Desktop Entry] - Type=Application - Name=TypeWhisper - GenericName=Voice-to-text dictation - Exec="{execPath}" --minimized - Icon={iconPath} - Terminal=false - Categories=Utility;Accessibility; - X-GNOME-Autostart-enabled=true - """; - File.WriteAllText(DesktopFilePath, content); + private static StartupOperationResult SuccessResult(bool isEnabled) + { + return new StartupOperationResult( + true, + isEnabled, + Loc.Instance["General.AutostartHint"] + ); } - public static void Disable() + private static StartupOperationResult RefusedResult() { - if (File.Exists(DesktopFilePath)) - { - File.Delete(DesktopFilePath); - } + return new StartupOperationResult( + false, + false, + Loc.Instance.GetString("General.AutostartEntryPreserved", DesktopFilePath) + ); } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs index 834e2a3f9..74e0d6afa 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs @@ -15,6 +15,8 @@ public partial class GeneralSectionViewModel : ObservableObject private readonly LinuxPreferencesService _linuxPrefs; private readonly ISettingsService _settings; private readonly TrayIconService _tray; + private bool _updatingStartWithSystem; + private bool _autostartStatusIsHint = true; [ObservableProperty] private string _apiBearerToken = ""; @@ -46,6 +48,9 @@ public partial class GeneralSectionViewModel : ObservableObject [ObservableProperty] private bool _startWithSystem; + [ObservableProperty] + private string _autostartStatusText = Loc.Instance["General.AutostartHint"]; + [ObservableProperty] private string? _uiLanguage; @@ -63,9 +68,16 @@ TrayIconService tray _linuxPrefs = linuxPrefs; _tray = tray; Refresh(settings.Current); - StartWithSystem = StartupService.IsEnabled; + _startWithSystem = StartupService.IsEnabled; CloseToTray = _linuxPrefs.Current.CloseToTray; _settings.SettingsChanged += Refresh; + Loc.Instance.LanguageChanged += (_, _) => + { + if (_autostartStatusIsHint) + { + AutostartStatusText = Loc.Instance["General.AutostartHint"]; + } + }; _api.StateChanged += () => ApiStatusText = _api.StatusText; ApiStatusText = _api.StatusText; RefreshCliState(); @@ -178,18 +190,29 @@ partial void OnUiLanguageChanged(string? value) partial void OnStartWithSystemChanged(bool value) { - if (value == StartupService.IsEnabled) + if (_updatingStartWithSystem) { return; } - if (value) + _updatingStartWithSystem = true; + try + { + var result = value ? StartupService.Enable() : StartupService.Disable(); + AutostartStatusText = result.StatusText; + _autostartStatusIsHint = result.Success; + StartWithSystem = result.IsEnabled; + } + catch (Exception ex) + when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { - StartupService.Enable(); + AutostartStatusText = ex.Message; + _autostartStatusIsHint = false; + StartWithSystem = StartupService.IsEnabled; } - else + finally { - StartupService.Disable(); + _updatingStartWithSystem = false; } } diff --git a/src/TypeWhisper.Linux/Views/Sections/GeneralSection.axaml b/src/TypeWhisper.Linux/Views/Sections/GeneralSection.axaml index 6f3ddda91..9eb497ed2 100644 --- a/src/TypeWhisper.Linux/Views/Sections/GeneralSection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/GeneralSection.axaml @@ -78,8 +78,9 @@ BorderBrush="#1AFFFFFF" BorderThickness="0,1,0,0" Padding="18,12"> - diff --git a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs index 81f54d9fe..440a68766 100644 --- a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs @@ -26,6 +26,19 @@ public void CanonicalCatalogLoadsAndIsNonEmpty() Assert.DoesNotContain(en, kv => string.IsNullOrWhiteSpace(kv.Value)); } + [Fact] + public void CanonicalCatalog_HasAutostartPreservationMessageWithPathPlaceholder() + { + var en = Load(CanonicalLanguage); + + Assert.True( + en.TryGetValue("General.AutostartEntryPreserved", out var value), + "Missing canonical key: General.AutostartEntryPreserved" + ); + Assert.False(string.IsNullOrWhiteSpace(value)); + Assert.Contains("{0}", value, StringComparison.Ordinal); + } + [Fact] public void CanonicalCatalog_HasNativeDictationDisclosuresWithoutObsoleteEvdevClaims() { diff --git a/tests/TypeWhisper.Linux.Tests/StartupServiceTests.cs b/tests/TypeWhisper.Linux.Tests/StartupServiceTests.cs new file mode 100644 index 000000000..bf3a5d049 --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/StartupServiceTests.cs @@ -0,0 +1,310 @@ +// ReSharper disable MethodHasAsyncOverload -- synchronous file operations keep the assertions direct. +using System.Diagnostics; +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Localization; +using TypeWhisper.Tests; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class StartupServiceTests : IDisposable +{ + private const string ManagedLine = "X-TypeWhisper-Managed=true"; + private readonly string? _originalXdgConfigHome = Environment.GetEnvironmentVariable( + "XDG_CONFIG_HOME" + ); + private readonly string _tempDir = TestPaths.CreateTempDirectory("startup-service"); + + public StartupServiceTests() + { + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", _tempDir); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", _originalXdgConfigHome); + try + { + TestPaths.DeleteDirectory(_tempDir); + } + catch + { + // Best-effort cleanup for temp test directories. + } + } + + [Fact] + public void BuildDesktopFile_pins_canonical_and_legacy_bytes() + { + const string execPath = "/opt/typewhisper/TypeWhisper.Linux"; + const string iconPath = "/opt/typewhisper/Resources/typewhisper-128.png"; + const string expectedLegacy = + "[Desktop Entry]\n" + + "Type=Application\n" + + "Name=TypeWhisper\n" + + "GenericName=Voice-to-text dictation\n" + + "Exec=\"/opt/typewhisper/TypeWhisper.Linux\" --minimized\n" + + "Icon=/opt/typewhisper/Resources/typewhisper-128.png\n" + + "Terminal=false\n" + + "Categories=Utility;Accessibility;\n" + + "X-GNOME-Autostart-enabled=true"; + const string expectedCanonical = expectedLegacy + "\n" + ManagedLine; + + Assert.Equal( + expectedCanonical, + StartupService.BuildDesktopFile(execPath, iconPath, includeManagedMarker: true) + ); + Assert.Equal( + expectedLegacy, + StartupService.BuildDesktopFile(execPath, iconPath, includeManagedMarker: false) + ); + Assert.False(expectedCanonical.EndsWith('\n')); + } + + [Fact] + public void Enable_installs_a_marker_bearing_entry_when_missing() + { + var result = StartupService.Enable(); + + Assert.True(result.Success); + Assert.True(result.IsEnabled); + Assert.StartsWith( + _tempDir + Path.DirectorySeparatorChar, + TargetPath, + StringComparison.Ordinal + ); + Assert.True(File.Exists(TargetPath)); + Assert.Equal( + CurrentDesktopContent(includeManagedMarker: true), + File.ReadAllText(TargetPath) + ); + Assert.True(StartupService.IsEnabled); + Assert.Equal(Loc.Instance["General.AutostartHint"], result.StatusText); + Assert.False(string.IsNullOrWhiteSpace(result.StatusText)); + } + + [Fact] + public void Enable_refuses_and_preserves_a_foreign_entry() + { + const string foreignContent = + "[Desktop Entry]\n" + + "Type=Application\n" + + "Name=Distro Helper\n" + + "Exec=/usr/libexec/distro-helper --session\n" + + "Hidden=true"; + WriteTarget(foreignContent); + var originalBytes = File.ReadAllBytes(TargetPath); + + var result = StartupService.Enable(); + + Assert.False(result.Success); + Assert.False(result.IsEnabled); + Assert.True(File.Exists(TargetPath)); + Assert.Equal(originalBytes, File.ReadAllBytes(TargetPath)); + Assert.False(StartupService.IsEnabled); + AssertRefusalStatus(result); + } + + [Fact] + public void Disable_refuses_and_preserves_a_foreign_entry() + { + const string foreignContent = + "[Desktop Entry]\nName=Session Agent\nExec=/usr/bin/session-agent\nHidden=true"; + WriteTarget(foreignContent); + var originalBytes = File.ReadAllBytes(TargetPath); + + var result = StartupService.Disable(); + + Assert.False(result.Success); + Assert.False(result.IsEnabled); + Assert.True(File.Exists(TargetPath)); + Assert.Equal(originalBytes, File.ReadAllBytes(TargetPath)); + Assert.False(StartupService.IsEnabled); + AssertRefusalStatus(result); + } + + [Fact] + public void Customized_legacy_entry_is_unowned_and_preserved_by_both_operations() + { + var legacyContent = CurrentDesktopContent(includeManagedMarker: false); + var customizedContents = new[] + { + legacyContent + "\nHidden=true", + legacyContent.Replace( + "Name=TypeWhisper", + "name=typewhisper", + StringComparison.Ordinal + ), + legacyContent + "\n" + }; + + foreach (var customizedContent in customizedContents) + { + WriteTarget(customizedContent); + var originalBytes = File.ReadAllBytes(TargetPath); + + Assert.False(StartupService.IsEnabled); + + var enableResult = StartupService.Enable(); + + Assert.False(enableResult.Success); + Assert.False(enableResult.IsEnabled); + Assert.Equal(originalBytes, File.ReadAllBytes(TargetPath)); + AssertRefusalStatus(enableResult); + + var disableResult = StartupService.Disable(); + + Assert.False(disableResult.Success); + Assert.False(disableResult.IsEnabled); + Assert.Equal(originalBytes, File.ReadAllBytes(TargetPath)); + AssertRefusalStatus(disableResult); + } + } + + [Fact] + public void Marker_owned_stale_entry_updates_and_removes_normally() + { + const string staleContent = + "[Desktop Entry]\n" + + "Type=Application\n" + + "Name=TypeWhisper\n" + + "Exec=\"/opt/typewhisper-old/typewhisper\" --minimized\n" + + "Icon=typewhisper-old\n" + + ManagedLine; + WriteTarget(staleContent); + + Assert.True(StartupService.IsEnabled); + + var enableResult = StartupService.Enable(); + + Assert.True(enableResult.Success); + Assert.True(enableResult.IsEnabled); + Assert.Equal( + CurrentDesktopContent(includeManagedMarker: true), + File.ReadAllText(TargetPath) + ); + Assert.True(StartupService.IsEnabled); + + var disableResult = StartupService.Disable(); + + Assert.True(disableResult.Success); + Assert.False(disableResult.IsEnabled); + Assert.False(File.Exists(TargetPath)); + Assert.False(StartupService.IsEnabled); + } + + [Fact] + public void Exact_legacy_entry_migrates_on_enable_and_can_be_removed_directly() + { + var legacyContent = CurrentDesktopContent(includeManagedMarker: false); + WriteTarget(legacyContent); + + Assert.True(StartupService.IsEnabled); + + var enableResult = StartupService.Enable(); + + Assert.True(enableResult.Success); + Assert.True(enableResult.IsEnabled); + Assert.Equal( + CurrentDesktopContent(includeManagedMarker: true), + File.ReadAllText(TargetPath) + ); + + WriteTarget(legacyContent); + Assert.True(StartupService.IsEnabled); + + var disableResult = StartupService.Disable(); + + Assert.True(disableResult.Success); + Assert.False(disableResult.IsEnabled); + Assert.False(File.Exists(TargetPath)); + Assert.False(StartupService.IsEnabled); + } + + [Fact] + public void Disable_is_a_successful_no_op_when_entry_is_missing() + { + var result = StartupService.Disable(); + + Assert.True(result.Success); + Assert.False(result.IsEnabled); + Assert.False(File.Exists(TargetPath)); + Assert.Equal(Loc.Instance["General.AutostartHint"], result.StatusText); + } + + [Fact] + public void IsEnabled_requires_exact_marker_line_or_exact_legacy_content() + { + Assert.False(StartupService.IsEnabled); + + WriteTarget("[Desktop Entry]\nName=Foreign\nExec=/usr/bin/foreign"); + Assert.False(StartupService.IsEnabled); + + var markerLookalikes = new[] + { + $"[Desktop Entry]\n#{ManagedLine}", + $"[Desktop Entry]\nPrefix-{ManagedLine}", + $"[Desktop Entry]\n{ManagedLine}-extra", + "[Desktop Entry]\nX-TypeWhisper-Managed=false" + }; + foreach (var markerLookalike in markerLookalikes) + { + WriteTarget(markerLookalike); + Assert.False(StartupService.IsEnabled); + } + + WriteTarget($"[Desktop Entry]\r\nName=Old TypeWhisper\r\n{ManagedLine}\r\n"); + Assert.True(StartupService.IsEnabled); + + WriteTarget(CurrentDesktopContent(includeManagedMarker: false)); + Assert.True(StartupService.IsEnabled); + + WriteTarget(CurrentDesktopContent(includeManagedMarker: true)); + Assert.True(StartupService.IsEnabled); + } + + private string TargetPath => Path.Join(_tempDir, "autostart", "typewhisper.desktop"); + + private void WriteTarget(string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(TargetPath)!); + File.WriteAllText(TargetPath, content); + } + + private static string CurrentDesktopContent(bool includeManagedMarker) + { + var execPath = + Process.GetCurrentProcess().MainModule?.FileName + ?? throw new InvalidOperationException("Cannot determine executable path."); + var iconPath = Path.Join(AppContext.BaseDirectory, "Resources", "typewhisper-128.png"); + if (!File.Exists(iconPath)) + { + iconPath = Path.Join(AppContext.BaseDirectory, "Resources", "typewhisper-64.png"); + } + + if (!File.Exists(iconPath)) + { + iconPath = "typewhisper"; + } + + return StartupService.BuildDesktopFile(execPath, iconPath, includeManagedMarker); + } + + private void AssertRefusalStatus(StartupOperationResult result) + { + Assert.Equal( + Loc.Instance.GetString("General.AutostartEntryPreserved", TargetPath), + result.StatusText + ); + Assert.Contains(TargetPath, result.StatusText, StringComparison.Ordinal); + Assert.Contains("left", result.StatusText, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "foreign or customized", + result.StatusText, + StringComparison.OrdinalIgnoreCase + ); + Assert.Contains("untouched", result.StatusText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("enabled", result.StatusText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("success", result.StatusText, StringComparison.OrdinalIgnoreCase); + } +} From 1996b722a0aa885815d4c142cdccae2b9bd173c1 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 12:19:06 +0000 Subject: [PATCH 140/226] Keep the watch-folder queue alive through provider cancellations ProcessFileAsync rethrew every OperationCanceledException regardless of which token caused it, and the queue loop only expected cancellation from its own run token. A transcription provider's private timeout or cancellation therefore faulted the unobserved queue worker permanently while the watcher stayed live and IsRunning kept reporting true - the service looked healthy but processed nothing until restart. The OCE catch now filters on the run token, so provider-private cancellations fall through to the failure handler and are recorded as one file failure (failed fingerprint + history entry) while the queue continues. The queue worker is additionally wrapped in an observer: any unexpected fault or premature exit marks the run failed via CAS, cancels the run, tears down the watcher so the inotify handle cannot leak or keep firing on a dead run, clears current-processing state, and raises StateChanged - IsRunning now reflects worker health instead of mere run existence. Start() still stops any prior run first, so a failed run never blocks restart. --- .../Services/WatchFolderService.cs | 101 ++++++- .../WatchFolderServiceTests.cs | 260 ++++++++++++++++++ 2 files changed, 358 insertions(+), 3 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/WatchFolderService.cs b/src/TypeWhisper.Linux/Services/WatchFolderService.cs index 3cc25d68d..424316339 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderService.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderService.cs @@ -80,7 +80,16 @@ public string? CurrentlyProcessing } } - public bool IsRunning => _currentRun is not null; + public bool IsRunning + { + get + { + var run = _currentRun; + return run is not null + && run.WorkerFailure is null + && !run.CancellationSource.IsCancellationRequested; + } + } internal WatchFolderRun? CurrentRun => _currentRun; @@ -212,10 +221,11 @@ private void StartRun( // ReSharper disable once MethodSupportsCancellation -- the worker observes run.CancellationSource internally; passing the token to Task.Run would leave a Canceled task for StopCoreAsync to await. var queueWorker = Task.Run(() => ProcessQueueAsync(run)); + var observedQueueWorker = ObserveQueueWorkerAsync(run, queueWorker); // Periodic rescan catches files missed when the OS event buffer overflows. // ReSharper disable once MethodSupportsCancellation -- the worker observes run.CancellationSource internally; passing the token to Task.Run would leave a Canceled task for StopCoreAsync to await. var rescanWorker = Task.Run(() => RescanLoopAsync(run)); - run.SetWorkers(queueWorker, rescanWorker); + run.SetWorkers(observedQueueWorker, rescanWorker); lock (_stateGate) { @@ -451,6 +461,84 @@ private async Task ProcessQueueAsync(WatchFolderRun run) } } + private async Task ObserveQueueWorkerAsync(WatchFolderRun run, Task queueWorker) + { + try + { + await queueWorker.ConfigureAwait(false); + } + catch (Exception ex) + { + Debug.WriteLine($"WatchFolder queue worker stopped with an error: {ex}"); + if (run.CancellationSource.IsCancellationRequested) + { + return; + } + + MarkQueueWorkerFailed(run, ex); + return; + } + + if (!run.CancellationSource.IsCancellationRequested) + { + MarkQueueWorkerFailed( + run, + new InvalidOperationException("Watch-folder queue worker stopped unexpectedly.") + ); + } + } + + private void MarkQueueWorkerFailed(WatchFolderRun run, Exception failure) + { + if (!run.TrySetWorkerFailure(failure)) + { + return; + } + + try + { + run.CancellationSource.Cancel(); + } + catch (AggregateException ex) + { + Debug.WriteLine($"WatchFolder cancellation callback failed: {ex}"); + } + + // Release the watcher so a failed run does not leak its inotify handle or keep firing + // event callbacks while the service reports itself stopped. + try + { + run.Watcher.EnableRaisingEvents = false; + } + catch (ObjectDisposedException) + { + // A concurrent Stop can dispose the watcher while the failure is being recorded. + } + + run.Watcher.Dispose(); + + var isCurrent = false; + lock (_stateGate) + { + if (ReferenceEquals(_currentRun, run)) + { + _watchPath = null; + if (ReferenceEquals(_currentlyProcessingRun, run)) + { + _currentlyProcessing = null; + _currentlyProcessingRun = null; + } + + isCurrent = true; + } + } + + if (isCurrent && ReferenceEquals(_currentRun, run)) + { + OnStateChanged(); + } + } + private async Task RescanLoopAsync(WatchFolderRun run) { var ct = run.CancellationSource.Token; @@ -582,7 +670,7 @@ CancellationToken ct } ); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } @@ -1012,6 +1100,7 @@ private void ThrowIfDisposed() internal sealed class WatchFolderRun { private int _cancellationSourceDisposed; + private Exception? _workerFailure; internal WatchFolderRun( CancellationTokenSource cancellationSource, @@ -1048,6 +1137,7 @@ internal Func< internal Lock FailedFingerprintsGate { get; } = new(); internal HashSet FailedFingerprints { get; } = new(StringComparer.OrdinalIgnoreCase); + internal Exception? WorkerFailure => Volatile.Read(ref _workerFailure); internal Task WorkerCompletion { get; private set; } = Task.CompletedTask; internal Task RetiredCleanup { get; private set; } = Task.CompletedTask; @@ -1061,6 +1151,11 @@ internal void SetRetiredCleanup(Task retiredCleanup) RetiredCleanup = retiredCleanup; } + internal bool TrySetWorkerFailure(Exception failure) + { + return Interlocked.CompareExchange(ref _workerFailure, failure, null) is null; + } + internal void DisposeCancellationSource() { if (Interlocked.Exchange(ref _cancellationSourceDisposed, 1) == 0) diff --git a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs index a1f6f911b..11f18f980 100644 --- a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs @@ -128,6 +128,266 @@ public async Task Start_WhenExportNameIsOccupiedByDirectory_AdvancesSuffix() Assert.True(Directory.Exists(Path.Join(outputPath, "meeting.txt"))); } + [Fact] + public async Task ProviderCancellation_WithLiveRun_RecordsFailureAndContinuesQueue() + { + var watchPath = Path.Join(_tempDir, "provider-cancellation-watch"); + var outputPath = Path.Join(_tempDir, "provider-cancellation-output"); + var dataPath = Path.Join(_tempDir, "provider-cancellation-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + var timeoutPath = Path.Join(watchPath, "a-timeout.wav"); + var nextPath = Path.Join(watchPath, "b-next.wav"); + File.WriteAllBytes(timeoutPath, [1, 2, 3]); + File.WriteAllBytes(nextPath, [4, 5, 6]); + + using var privateCancellation = new CancellationTokenSource(); + var timeoutEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var releaseTimeout = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var twoItemsProcessed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var calls = new ConcurrentQueue(); + var processed = new ConcurrentQueue(); + var service = new WatchFolderService(dataPath); + WatchFolderService.WatchFolderRun? originalRun = null; + service.FileProcessed += (_, item) => + { + processed.Enqueue(item); + if (processed.Count >= 2) + { + twoItemsProcessed.TrySetResult(true); + } + }; + + try + { + service.Start( + CreateOptions(watchPath, outputPath), + async (request, ct) => + { + var fileName = Path.GetFileName(request.FilePath); + calls.Enqueue(fileName); + if (fileName == "a-timeout.wav") + { + timeoutEntered.TrySetResult(ct); + await releaseTimeout.Task; + // ReSharper disable once AccessToDisposedClosure -- the finally awaits StopAsync/WorkerCompletion, so this callback finishes before the `using var privateCancellation` is disposed at scope end. + throw new OperationCanceledException( + "Provider request timed out.", + privateCancellation.Token + ); + } + + return CreateResult(request); + } + ); + + originalRun = service.CurrentRun; + Assert.NotNull(originalRun); + var runToken = await timeoutEntered.Task.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.False(runToken.IsCancellationRequested); + + privateCancellation.Cancel(); + releaseTimeout.TrySetResult(true); + await twoItemsProcessed.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.Equal(["a-timeout.wav", "b-next.wav"], calls); + Assert.Equal(2, service.History.Count); + Assert.Equal(2, processed.Count); + + var failedItem = Assert.Single(service.History, item => !item.Success); + Assert.Equal("a-timeout.wav", failedItem.FileName); + Assert.Empty(failedItem.OutputPath); + Assert.False(string.IsNullOrWhiteSpace(failedItem.ErrorMessage)); + Assert.Contains("timed out", failedItem.ErrorMessage, StringComparison.OrdinalIgnoreCase); + + var failedEvent = Assert.Single(processed, item => !item.Success); + Assert.Same(failedItem, failedEvent); + var failedFingerprint = Assert.Single(originalRun.FailedFingerprints); + Assert.StartsWith( + $"{Path.GetFullPath(timeoutPath)}|", + failedFingerprint, + StringComparison.Ordinal + ); + + var successfulItem = Assert.Single(service.History, item => item.Success); + Assert.Equal("b-next.wav", successfulItem.FileName); + Assert.Equal(Path.Join(outputPath, "b-next.txt"), successfulItem.OutputPath); + Assert.True(File.Exists(successfulItem.OutputPath)); + Assert.True(File.Exists(timeoutPath)); + Assert.False(File.Exists(Path.Join(outputPath, "a-timeout.txt"))); + + Assert.True(service.IsRunning); + Assert.False(runToken.IsCancellationRequested); + Assert.Same(originalRun, service.CurrentRun); + Assert.Null(originalRun.WorkerFailure); + Assert.False(originalRun.WorkerCompletion.IsFaulted); + Assert.False(originalRun.WorkerCompletion.IsCompleted); + } + finally + { + privateCancellation.Cancel(); + releaseTimeout.TrySetResult(true); + if (service.CurrentRun is not null) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (originalRun is not null) + { + await originalRun.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + + [Fact] + public async Task RunTokenCancellation_StopsQueueWithoutWorkerFailure() + { + var watchPath = Path.Join(_tempDir, "run-cancellation-watch"); + var outputPath = Path.Join(_tempDir, "run-cancellation-output"); + var dataPath = Path.Join(_tempDir, "run-cancellation-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + var sourcePath = Path.Join(watchPath, "canceled.wav"); + File.WriteAllBytes(sourcePath, [1, 2, 3]); + + var entered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var release = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var processed = new ConcurrentQueue(); + var service = new WatchFolderService(dataPath); + WatchFolderService.WatchFolderRun? run = null; + service.FileProcessed += (_, item) => processed.Enqueue(item); + + try + { + service.Start( + CreateOptions(watchPath, outputPath), + async (request, ct) => + { + entered.TrySetResult(ct); + await release.Task.WaitAsync(ct); + return CreateResult(request); + } + ); + + run = service.CurrentRun; + Assert.NotNull(run); + var handlerToken = await entered.Task.WaitAsync(TimeSpan.FromSeconds(15)); + run.CancellationSource.Cancel(); + await run.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.True(handlerToken.IsCancellationRequested); + Assert.Empty(service.History); + Assert.Empty(processed); + Assert.Empty(run.FailedFingerprints); + Assert.Null(run.WorkerFailure); + Assert.True(run.WorkerCompletion.IsCompletedSuccessfully); + Assert.True(File.Exists(sourcePath)); + Assert.False(File.Exists(Path.Join(outputPath, "canceled.txt"))); + Assert.False(File.Exists(Path.Join(dataPath, "watch-folder-processed.json"))); + + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + finally + { + release.TrySetResult(true); + if (service.CurrentRun is not null) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (run is not null) + { + await run.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + + [Fact] + public async Task QueueWorkerFault_IsObservedAndMarksCurrentRunUnhealthy() + { + var watchPath = Path.Join(_tempDir, "worker-fault-watch"); + var outputPath = Path.Join(_tempDir, "worker-fault-output"); + var dataPath = Path.Join(_tempDir, "worker-fault-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + + var healthTransition = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var processed = new ConcurrentQueue(); + var service = new WatchFolderService(dataPath); + WatchFolderService.WatchFolderRun? run = null; + service.FileProcessed += (_, item) => processed.Enqueue(item); + service.StateChanged += (_, _) => + { + // ReSharper disable once AccessToModifiedClosure -- the handler deliberately reads the current `run` (assigned after Start) to correlate the transition with the active run. + var observedRun = run; + if ( + observedRun is not null + && ReferenceEquals(service.CurrentRun, observedRun) + && !service.IsRunning + ) + { + healthTransition.TrySetResult(true); + } + }; + + try + { + service.Start(CreateOptions(watchPath, outputPath), TranscribeAsync); + run = service.CurrentRun; + Assert.NotNull(run); + + run.PendingFiles.Enqueue("\0"); + await healthTransition.Task.WaitAsync(TimeSpan.FromSeconds(15)); + await run.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.Same(run, service.CurrentRun); + Assert.False(service.IsRunning); + Assert.Null(service.WatchPath); + Assert.Null(service.CurrentlyProcessing); + Assert.True(run.CancellationSource.IsCancellationRequested); + Assert.IsAssignableFrom(run.WorkerFailure); + Assert.True(run.WorkerCompletion.IsCompletedSuccessfully); + Assert.Empty(service.History); + Assert.Empty(processed); + Assert.Empty(run.FailedFingerprints); + Assert.Empty(Directory.EnumerateFiles(outputPath)); + Assert.False(File.Exists(Path.Join(dataPath, "watch-folder-processed.json"))); + + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + Assert.Null(service.CurrentRun); + } + finally + { + if (service.CurrentRun is not null) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (run is not null) + { + await run.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + [Fact] public async Task StopAsync_InFlightHandler_AwaitsWorkerBeforeReturning() { From e9c9d281d6ee3b6a27ddc8b54aac4d98876ffb38 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 12:46:49 +0000 Subject: [PATCH 141/226] Use case-sensitive path identity in the watch-folder service Every path-identity structure in WatchFolderService - the active-files map, processed-fingerprint set, queued-files map, and failed-fingerprint set - was keyed with StringComparer.OrdinalIgnoreCase. On Linux's case-sensitive filesystems, two genuinely distinct files differing only by case collapsed into one identity: one was suppressed as already active or queued, and processed/failed fingerprints (which embed the full path) falsely matched across case-distinct paths, silently dropping or skipping real files. All four comparers now use StringComparer.Ordinal, the correct identity for this Linux-only service. No migration is needed for persisted fingerprints: loading old entries into an ordinal set is strictly more precise. The JSON serializer's PropertyNameCaseInsensitive option is config parsing, not path identity, and is deliberately unchanged. --- .../Services/WatchFolderService.cs | 8 +- .../WatchFolderServiceTests.cs | 180 ++++++++++++++++++ 2 files changed, 184 insertions(+), 4 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/WatchFolderService.cs b/src/TypeWhisper.Linux/Services/WatchFolderService.cs index 424316339..0d3b51f25 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderService.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderService.cs @@ -17,14 +17,14 @@ public sealed class WatchFolderService : IDisposable, IAsyncDisposable }; private readonly ConcurrentDictionary _activeFiles = new( - StringComparer.OrdinalIgnoreCase + StringComparer.Ordinal ); private readonly List _history = []; private readonly string _historyPath; private readonly SemaphoreSlim _lifecycleGate = new(1, 1); private readonly Lock _persistenceGate = new(); - private readonly HashSet _processedFingerprints = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _processedFingerprints = new(StringComparer.Ordinal); private readonly string _processedFingerprintsPath; private readonly Lock _stateGate = new(); private readonly Func _waitForWorkers; @@ -1132,11 +1132,11 @@ internal Func< internal ConcurrentQueue PendingFiles { get; } = []; internal ConcurrentDictionary QueuedFiles { get; } = new( - StringComparer.OrdinalIgnoreCase + StringComparer.Ordinal ); internal Lock FailedFingerprintsGate { get; } = new(); - internal HashSet FailedFingerprints { get; } = new(StringComparer.OrdinalIgnoreCase); + internal HashSet FailedFingerprints { get; } = new(StringComparer.Ordinal); internal Exception? WorkerFailure => Volatile.Read(ref _workerFailure); internal Task WorkerCompletion { get; private set; } = Task.CompletedTask; internal Task RetiredCleanup { get; private set; } = Task.CompletedTask; diff --git a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs index 11f18f980..aee025497 100644 --- a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs @@ -60,6 +60,186 @@ public async Task Start_WhenSourceBasenamesCollide_CommitsDistinctExportsBeforeD Assert.False(File.Exists(mp3Path)); } + [Fact] + public async Task Start_WithCaseDistinctFileNames_ProcessesBothFiles() + { + var watchPath = Path.Join(_tempDir, "case-distinct-watch"); + var outputPath = Path.Join(_tempDir, "case-distinct-output"); + var dataPath = Path.Join(_tempDir, "case-distinct-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + var upperCasePath = Path.Join(watchPath, "Meeting.wav"); + var lowerCasePath = Path.Join(watchPath, "meeting.wav"); + File.WriteAllBytes(upperCasePath, [1, 2, 3]); + File.WriteAllBytes(lowerCasePath, [4, 5, 6]); + File.SetLastWriteTimeUtc(upperCasePath, DateTime.UtcNow.AddMinutes(-1)); + File.SetLastWriteTimeUtc(lowerCasePath, File.GetLastWriteTimeUtc(upperCasePath)); + + var twoItemsProcessed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var processed = new ConcurrentQueue(); + var service = new WatchFolderService(dataPath); + WatchFolderService.WatchFolderRun? run = null; + service.FileProcessed += (_, item) => + { + processed.Enqueue(item); + if (processed.Count >= 2) + { + twoItemsProcessed.TrySetResult(true); + } + }; + + try + { + service.Start(CreateOptions(watchPath, outputPath), TranscribeAsync); + run = service.CurrentRun; + Assert.NotNull(run); + + await twoItemsProcessed.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.Equal(2, processed.Count); + Assert.Equal(2, service.History.Count); + Assert.All(processed, item => Assert.True(item.Success, item.ErrorMessage)); + Assert.Equal( + ["Meeting.wav", "meeting.wav"], + service.History.Select(item => item.FileName).Order(StringComparer.Ordinal) + ); + Assert.Equal( + [Path.Join(outputPath, "Meeting.txt"), Path.Join(outputPath, "meeting.txt")], + processed.Select(item => item.OutputPath).Order(StringComparer.Ordinal) + ); + Assert.Equal( + "Transcribed Meeting.wav", + File.ReadAllText(Path.Join(outputPath, "Meeting.txt")) + ); + Assert.Equal( + "Transcribed meeting.wav", + File.ReadAllText(Path.Join(outputPath, "meeting.txt")) + ); + } + finally + { + if (service.CurrentRun is not null) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (run is not null) + { + await run.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + + [Fact] + public async Task FailedFingerprint_ForCaseDistinctPath_DoesNotSuppressOtherFile() + { + var watchPath = Path.Join(_tempDir, "case-distinct-failure-watch"); + var outputPath = Path.Join(_tempDir, "case-distinct-failure-output"); + var dataPath = Path.Join(_tempDir, "case-distinct-failure-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + var upperCasePath = Path.Join(watchPath, "Meeting.wav"); + var lowerCasePath = Path.Join(watchPath, "meeting.wav"); + File.WriteAllBytes(upperCasePath, [1, 2, 3]); + File.SetLastWriteTimeUtc(upperCasePath, DateTime.UtcNow.AddMinutes(-1)); + + var failureRecorded = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var lowerCaseProcessed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var service = new WatchFolderService(dataPath); + WatchFolderService.WatchFolderRun? run = null; + service.FileProcessed += (_, item) => + { + if ( + !item.Success + && string.Equals(item.FileName, "Meeting.wav", StringComparison.Ordinal) + ) + { + failureRecorded.TrySetResult(item); + } + else if ( + item.Success + && string.Equals(item.FileName, "meeting.wav", StringComparison.Ordinal) + ) + { + lowerCaseProcessed.TrySetResult(item); + } + }; + + try + { + service.Start( + CreateOptions(watchPath, outputPath), + (request, ct) => + { + ct.ThrowIfCancellationRequested(); + if ( + string.Equals( + Path.GetFileName(request.FilePath), + "Meeting.wav", + StringComparison.Ordinal + ) + ) + { + throw new InvalidOperationException("Upper-case path failed."); + } + + return Task.FromResult(CreateResult(request)); + } + ); + run = service.CurrentRun; + Assert.NotNull(run); + + var failedItem = await failureRecorded.Task.WaitAsync(TimeSpan.FromSeconds(15)); + var failedFingerprint = Assert.Single(run.FailedFingerprints); + Assert.StartsWith( + $"{Path.GetFullPath(upperCasePath)}|", + failedFingerprint, + StringComparison.Ordinal + ); + + var stagedPath = Path.Join(_tempDir, "meeting-staged.wav"); + File.WriteAllBytes(stagedPath, [4, 5, 6]); + File.SetLastWriteTimeUtc(stagedPath, File.GetLastWriteTimeUtc(upperCasePath)); + File.Move(stagedPath, lowerCasePath); + + var successfulItem = await lowerCaseProcessed.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.False(failedItem.Success); + Assert.Equal("Meeting.wav", failedItem.FileName); + Assert.True(successfulItem.Success, successfulItem.ErrorMessage); + Assert.Equal("meeting.wav", successfulItem.FileName); + Assert.Equal(Path.Join(outputPath, "meeting.txt"), successfulItem.OutputPath); + Assert.Equal(2, service.History.Count); + Assert.Single(service.History, item => !item.Success); + Assert.Single(service.History, item => item.Success); + Assert.Equal(failedFingerprint, Assert.Single(run.FailedFingerprints)); + Assert.True(File.Exists(successfulItem.OutputPath)); + Assert.False(File.Exists(Path.Join(outputPath, "Meeting.txt"))); + } + finally + { + if (service.CurrentRun is not null) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (run is not null) + { + await run.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + [Fact] public async Task Start_WhenUserExportsExist_PreservesBytesAndAdvancesSuffix() { From 5d806790a4dde09897c84fb5e4a4526ea08a05a7 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 12:49:07 +0000 Subject: [PATCH 142/226] Verify bundled plugin deployments by content, gated by stat manifests The deployer's freshness check compared only file length and whether the source mtime was newer, so a same-length content change with an equal or older packaged timestamp was treated as current; repair copied files in place without pruning removed ones, and a mid-copy failure left a mixed version that startup would load. Deployment now stages a complete copy under the destination root, verifies the staged content hash against the source, and commits with an atomic rename plus backup/rollback; interrupted deployments are recovered on the next run (abandoned stages deleted, orphaned backups restored). Freshness is decided by a three-digest stamp - a SHA256 content fingerprint plus stat manifests (path, length, mtime) of source and destination. Steady state compares stat manifests only; content is re-hashed just when a stat digest moves, and a moved-but-identical tree refreshes the stamp instead of redeploying, so a no-op startup does no payload reads. A post-commit verification refuses to bless a destination mutated between commit and stamp finalization. Full-tree replacement prunes files removed from the bundle. The stat-gating blind spot (a changed file with identical length and mtime on both sides) is an accepted trade-off; PA35 tracks the publish-time bundle-version identity that would close it. --- .../Services/BundledPluginDeployer.cs | 465 +++++++++++++++- .../BundledPluginDeployerTests.cs | 514 ++++++++++++++++++ 2 files changed, 955 insertions(+), 24 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/BundledPluginDeployerTests.cs diff --git a/src/TypeWhisper.Linux/Services/BundledPluginDeployer.cs b/src/TypeWhisper.Linux/Services/BundledPluginDeployer.cs index 939b3100e..dc7302bd4 100644 --- a/src/TypeWhisper.Linux/Services/BundledPluginDeployer.cs +++ b/src/TypeWhisper.Linux/Services/BundledPluginDeployer.cs @@ -1,4 +1,7 @@ +using System.Buffers.Binary; using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; using TypeWhisper.Core; namespace TypeWhisper.Linux.Services; @@ -11,6 +14,10 @@ namespace TypeWhisper.Linux.Services; /// public sealed class BundledPluginDeployer { + private const string StampFileName = ".typewhisper-bundle.sha256"; + private const string ScratchDirectoryName = ".typewhisper-deploy"; + private const string BackupDirectoryName = "backup"; + // ReSharper disable once UnusedMethodReturnValue.Global -- returns the count of synced plugins for callers that want it; the current caller ignores it. public static int DeployIfMissing() { @@ -23,20 +30,43 @@ public static int DeployIfMissing() return 0; } - var destRoot = TypeWhisperEnvironment.PluginsPath; + return DeployIfMissing(source, TypeWhisperEnvironment.PluginsPath); + } + + internal static int DeployIfMissing( + string sourceRoot, + string destRoot, + Action? copyFile = null, + Action? afterCommit = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(destRoot); + + copyFile ??= static (source, destination) => File.Copy(source, destination); Directory.CreateDirectory(destRoot); var deployed = 0; - foreach (var pluginDir in Directory.GetDirectories(source)) + foreach ( + var pluginDir in Directory + .GetDirectories(sourceRoot) + .OrderBy(Path.GetFileName, StringComparer.Ordinal) + ) { var name = Path.GetFileName(pluginDir); var dest = Path.Join(destRoot, name); try { - if (NeedsRepairOrUpdate(pluginDir, dest)) + if (string.Equals(name, ScratchDirectoryName, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Bundled plugin name is reserved: {ScratchDirectoryName}" + ); + } + + if (DeployPlugin(pluginDir, destRoot, dest, name, copyFile, afterCommit)) { - CopyDirectory(pluginDir, dest, true); Trace.WriteLine( $"[BundledPluginDeployer] Synced bundled plugin {name} → {dest}" ); @@ -63,47 +93,434 @@ public static int DeployIfMissing() return Directory.Exists(candidate) ? candidate : null; } - private static bool NeedsRepairOrUpdate(string src, string dst) + private static bool DeployPlugin( + string source, + string destRoot, + string dest, + string pluginName, + Action copyFile, + Action? afterCommit + ) { - if (!Directory.Exists(dst)) + var scratchRoot = Path.Join(destRoot, ScratchDirectoryName); + var pluginScratch = Path.Join(scratchRoot, pluginName); + var backup = Path.Join(pluginScratch, BackupDirectoryName); + RecoverInterruptedDeployment(dest, pluginScratch, backup); + + Fingerprints? sourceFingerprints = null; + if (TryReadStamp(dest, out var stamp)) + { + var sourceStat = ComputeStatDigest(source); + var refreshStamp = false; + var deploy = false; + + if (!DigestsEqual(sourceStat, stamp.SourceStat)) + { + sourceFingerprints = ComputeFingerprints(source); + sourceStat = sourceFingerprints.Stat; + if (!DigestsEqual(sourceFingerprints.Content, stamp.Content)) + { + deploy = true; + } + else + { + refreshStamp = true; + } + } + + if (!deploy) + { + var destStat = ComputeStatDigest(dest); + if (!DigestsEqual(destStat, stamp.DestStat)) + { + var destFingerprints = ComputeFingerprints(dest); + destStat = destFingerprints.Stat; + if (!DigestsEqual(destFingerprints.Content, stamp.Content)) + { + deploy = true; + } + else + { + refreshStamp = true; + } + } + + if (!deploy) + { + if (refreshStamp) + { + WriteStamp(dest, new DeploymentStamp(stamp.Content, sourceStat, destStat)); + } + + RemoveEmptyDirectory(pluginScratch); + RemoveEmptyDirectory(scratchRoot); + return false; + } + } + } + + sourceFingerprints ??= ComputeFingerprints(source); + Directory.CreateDirectory(pluginScratch); + var stage = CreateStageDirectory(pluginScratch); + try { + CopyDirectory(source, stage, copyFile); + + var stagedFingerprints = ComputeFingerprints(stage); + if (!DigestsEqual(sourceFingerprints.Content, stagedFingerprints.Content)) + { + throw new IOException("Bundled plugin changed while it was being staged."); + } + + WriteStamp( + stage, + new DeploymentStamp( + sourceFingerprints.Content, + sourceFingerprints.Stat, + stagedFingerprints.Stat + ) + ); + CommitStage(stage, dest, backup); + afterCommit?.Invoke(dest); + + var destStat = ComputeStatDigest(dest); + if (!DigestsEqual(destStat, stagedFingerprints.Stat)) + { + var committedFingerprints = ComputeFingerprints(dest); + if (!DigestsEqual(sourceFingerprints.Content, committedFingerprints.Content)) + { + throw new IOException("Bundled plugin changed while it was being committed."); + } + + destStat = committedFingerprints.Stat; + } + + WriteStamp( + dest, + new DeploymentStamp( + sourceFingerprints.Content, + sourceFingerprints.Stat, + destStat + ) + ); + TryDeleteDirectory(backup); return true; } + finally + { + TryDeleteDirectory(stage); + RemoveEmptyDirectory(pluginScratch); + RemoveEmptyDirectory(scratchRoot); + } + } + + private static void RecoverInterruptedDeployment( + string dest, + string pluginScratch, + string backup + ) + { + if (!Directory.Exists(pluginScratch)) + { + return; + } + + foreach ( + var abandonedStage in Directory + .GetDirectories(pluginScratch, "stage-*", SearchOption.TopDirectoryOnly) + .OrderBy(path => path, StringComparer.Ordinal) + ) + { + Directory.Delete(abandonedStage, recursive: true); + } + + if (Directory.Exists(backup)) + { + if (Directory.Exists(dest)) + { + Directory.Delete(backup, recursive: true); + } + else + { + Directory.Move(backup, dest); + } + } + } + + private static string CreateStageDirectory(string pluginScratch) + { + string stage; + do + { + stage = Path.Join(pluginScratch, $"stage-{Guid.NewGuid():N}"); + } while (Directory.Exists(stage) || File.Exists(stage)); + + Directory.CreateDirectory(stage); + return stage; + } + + private static void CommitStage(string stage, string dest, string backup) + { + if (!Directory.Exists(dest)) + { + Directory.Move(stage, dest); + return; + } - foreach (var srcFile in Directory.GetFiles(src, "*", SearchOption.AllDirectories)) + Directory.Move(dest, backup); + try { - var relativePath = Path.GetRelativePath(src, srcFile); - var dstFile = Path.Join(dst, relativePath); - if (!File.Exists(dstFile)) + Directory.Move(stage, dest); + } + catch (Exception commitException) + { + try + { + Directory.Move(backup, dest); + } + catch (Exception rollbackException) { - return true; + throw new AggregateException( + "Failed to commit the bundled plugin and restore its previous deployment.", + commitException, + rollbackException + ); } - var srcInfo = new FileInfo(srcFile); - var dstInfo = new FileInfo(dstFile); + throw; + } + } + + private static bool TryReadStamp(string dest, out DeploymentStamp stamp) + { + stamp = null!; + if (!Directory.Exists(dest)) + { + return false; + } + + var stampPath = Path.Join(dest, StampFileName); + if (!File.Exists(stampPath)) + { + return false; + } + + var values = new Dictionary(StringComparer.Ordinal); + foreach (var line in File.ReadAllLines(stampPath)) + { + var separator = line.IndexOf('='); if ( - srcInfo.Length != dstInfo.Length - || srcInfo.LastWriteTimeUtc > dstInfo.LastWriteTimeUtc + separator <= 0 + || !TryParseDigest(line[(separator + 1)..], out var digest) + || !values.TryAdd(line[..separator], digest) + ) + { + return false; + } + } + + if ( + values.Count != 3 + || !values.TryGetValue("content", out var content) + || !values.TryGetValue("sourceStat", out var sourceStat) + || !values.TryGetValue("destStat", out var destStat) + ) + { + return false; + } + + stamp = new DeploymentStamp(content, sourceStat, destStat); + return true; + } + + private static bool TryParseDigest(string value, out byte[] digest) + { + digest = []; + if (value.Length != SHA256.HashSizeInBytes * 2) + { + return false; + } + + try + { + digest = Convert.FromHexString(value); + return true; + } + catch (FormatException) + { + return false; + } + } + + private static void WriteStamp(string root, DeploymentStamp stamp) + { + File.WriteAllText( + Path.Join(root, StampFileName), + string.Join( + Environment.NewLine, + $"content={Convert.ToHexString(stamp.Content)}", + $"sourceStat={Convert.ToHexString(stamp.SourceStat)}", + $"destStat={Convert.ToHexString(stamp.DestStat)}" + ) + ); + } + + private static byte[] ComputeStatDigest(string root) + { + var files = GetFingerprintFiles(root); + using var statDigest = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + AppendStatManifest(statDigest, files); + return statDigest.GetHashAndReset(); + } + + private static Fingerprints ComputeFingerprints(string root) + { + var files = GetFingerprintFiles(root); + using var content = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + using var stat = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + AppendStatManifest(stat, files); + + foreach (var file in files) + { + content.AppendData([1]); + content.AppendData(Encoding.UTF8.GetBytes(file.RelativePath)); + content.AppendData([0]); + + using var stream = File.OpenRead(file.FullPath); + content.AppendData(SHA256.HashData(stream)); + } + + return new Fingerprints(content.GetHashAndReset(), stat.GetHashAndReset()); + } + + private static FingerprintFile[] GetFingerprintFiles(string root) + { + return Directory + .GetFiles(root, "*", SearchOption.AllDirectories) + .Where(file => + !string.Equals(Path.GetFileName(file), StampFileName, StringComparison.Ordinal) ) + .Select(file => + { + var info = new FileInfo(file); + return new FingerprintFile( + file, + NormalizeRelativePath(Path.GetRelativePath(root, file)), + info.Length, + info.LastWriteTimeUtc.Ticks + ); + }) + .OrderBy(file => file.RelativePath, StringComparer.Ordinal) + .ToArray(); + } + + private static void AppendStatManifest( + IncrementalHash digest, + IReadOnlyList files + ) + { + Span stats = stackalloc byte[sizeof(long) * 2]; + foreach (var file in files) + { + digest.AppendData([1]); + digest.AppendData(Encoding.UTF8.GetBytes(file.RelativePath)); + digest.AppendData([0]); + BinaryPrimitives.WriteInt64LittleEndian(stats[..sizeof(long)], file.Length); + BinaryPrimitives.WriteInt64LittleEndian(stats[sizeof(long)..], file.LastWriteTicks); + digest.AppendData(stats); + } + } + + private static bool DigestsEqual(byte[] left, byte[] right) + { + return CryptographicOperations.FixedTimeEquals(left, right); + } + + private static string NormalizeRelativePath(string path) + { + var normalized = path.Replace(Path.DirectorySeparatorChar, '/'); + return Path.AltDirectorySeparatorChar == Path.DirectorySeparatorChar + ? normalized + : normalized.Replace(Path.AltDirectorySeparatorChar, '/'); + } + + private static void CopyDirectory( + string source, + string destination, + Action copyFile + ) + { + foreach ( + var file in Directory + .GetFiles(source) + .OrderBy(Path.GetFileName, StringComparer.Ordinal) + ) + { + if (string.Equals(Path.GetFileName(file), StampFileName, StringComparison.Ordinal)) { - return true; + continue; } + + copyFile(file, Path.Join(destination, Path.GetFileName(file))); } - return false; + foreach ( + var subdirectory in Directory + .GetDirectories(source) + .OrderBy(Path.GetFileName, StringComparer.Ordinal) + ) + { + var destinationSubdirectory = Path.Join( + destination, + Path.GetFileName(subdirectory) + ); + Directory.CreateDirectory(destinationSubdirectory); + CopyDirectory(subdirectory, destinationSubdirectory, copyFile); + } } - private static void CopyDirectory(string src, string dst, bool overwrite) + private static void TryDeleteDirectory(string path) { - Directory.CreateDirectory(dst); - foreach (var file in Directory.GetFiles(src)) + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (Exception ex) { - File.Copy(file, Path.Join(dst, Path.GetFileName(file)), overwrite); + Trace.WriteLine( + $"[BundledPluginDeployer] Failed to clean deployment scratch {path}: {ex.Message}" + ); } + } - foreach (var sub in Directory.GetDirectories(src)) + private static void RemoveEmptyDirectory(string path) + { + try + { + if (Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any()) + { + Directory.Delete(path); + } + } + catch (Exception ex) { - CopyDirectory(sub, Path.Join(dst, Path.GetFileName(sub)), overwrite); + Trace.WriteLine( + $"[BundledPluginDeployer] Failed to clean deployment scratch {path}: {ex.Message}" + ); } } -} \ No newline at end of file + + private sealed record DeploymentStamp(byte[] Content, byte[] SourceStat, byte[] DestStat); + + private sealed record Fingerprints(byte[] Content, byte[] Stat); + + private sealed record FingerprintFile( + string FullPath, + string RelativePath, + long Length, + long LastWriteTicks + ); +} diff --git a/tests/TypeWhisper.Linux.Tests/BundledPluginDeployerTests.cs b/tests/TypeWhisper.Linux.Tests/BundledPluginDeployerTests.cs new file mode 100644 index 000000000..5c4bbc519 --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/BundledPluginDeployerTests.cs @@ -0,0 +1,514 @@ +using TypeWhisper.Linux.Services; +using TypeWhisper.Tests; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class BundledPluginDeployerTests +{ + private const string PluginName = "sample-plugin"; + private const string StampFileName = ".typewhisper-bundle.sha256"; + private const string ScratchDirectoryName = ".typewhisper-deploy"; + + [Fact] + public void DeployIfMissing_MissingDestination_DeploysCompleteTreeAndStamp() + { + var root = TestPaths.CreateTempDirectory("bundled-plugin-fresh"); + try + { + var sourceRoot = Path.Join(root, "bundle"); + var destRoot = Path.Join(root, "installed"); + var sourcePlugin = CreateBundle(sourceRoot); + var destPlugin = Path.Join(destRoot, PluginName); + + var deployed = BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot); + + Assert.Equal(1, deployed); + AssertSourceFilesMatch(sourcePlugin, destPlugin); + Assert.True(Directory.Exists(Path.Join(destPlugin, "empty"))); + Assert.True(File.Exists(Path.Join(destPlugin, StampFileName))); + AssertNoScratch(destRoot); + } + finally + { + TestPaths.DeleteDirectory(root); + } + } + + [Fact] + public void DeployIfMissing_MatchingStamp_IsNoOpWithoutCopyOrRewrite() + { + var root = TestPaths.CreateTempDirectory("bundled-plugin-current"); + try + { + var sourceRoot = Path.Join(root, "bundle"); + var destRoot = Path.Join(root, "installed"); + _ = CreateBundle(sourceRoot); + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + + var destPlugin = Path.Join(destRoot, PluginName); + var livePath = Path.Join(destPlugin, "current.dll"); + var stampPath = Path.Join(destPlugin, StampFileName); + var liveBefore = File.ReadAllBytes(livePath); + var stampBefore = File.ReadAllBytes(stampPath); + var copyCount = 0; + + var deployed = BundledPluginDeployer.DeployIfMissing( + sourceRoot, + destRoot, + (source, destination) => + { + copyCount++; + File.Copy(source, destination); + } + ); + + Assert.Equal(0, deployed); + Assert.Equal(0, copyCount); + Assert.Equal(liveBefore, File.ReadAllBytes(livePath)); + Assert.Equal(stampBefore, File.ReadAllBytes(stampPath)); + AssertNoScratch(destRoot); + } + finally + { + TestPaths.DeleteDirectory(root); + } + } + + [Fact] + public void DeployIfMissing_InvalidOrMissingStamp_RedeploysAndRestoresStamp() + { + var root = TestPaths.CreateTempDirectory("bundled-plugin-invalid-stamp"); + try + { + var sourceRoot = Path.Join(root, "bundle"); + var destRoot = Path.Join(root, "installed"); + var sourcePlugin = CreateBundle(sourceRoot); + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + + var destPlugin = Path.Join(destRoot, PluginName); + var sourceDll = Path.Join(sourcePlugin, "current.dll"); + var destDll = Path.Join(destPlugin, "current.dll"); + var stampPath = Path.Join(destPlugin, StampFileName); + var validStamp = File.ReadAllText(stampPath); + AssertStructuredStamp(validStamp); + + File.WriteAllText(stampPath, "not-a-fingerprint"); + + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + Assert.Equal(File.ReadAllBytes(sourceDll), File.ReadAllBytes(destDll)); + Assert.Equal(validStamp, File.ReadAllText(stampPath)); + AssertNoScratch(destRoot); + + File.WriteAllText(stampPath, new string('z', 64)); + + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + Assert.Equal(File.ReadAllBytes(sourceDll), File.ReadAllBytes(destDll)); + Assert.Equal(validStamp, File.ReadAllText(stampPath)); + AssertNoScratch(destRoot); + + File.Delete(stampPath); + + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + Assert.Equal(File.ReadAllBytes(sourceDll), File.ReadAllBytes(destDll)); + Assert.Equal(validStamp, File.ReadAllText(stampPath)); + AssertNoScratch(destRoot); + } + finally + { + TestPaths.DeleteDirectory(root); + } + } + + [Fact] + public void DeployIfMissing_SteadyState_DoesNotReadFileContents() + { + var root = TestPaths.CreateTempDirectory("bundled-plugin-stat-gated"); + try + { + var sourceRoot = Path.Join(root, "bundle"); + var destRoot = Path.Join(root, "installed"); + var sourcePlugin = CreateBundle(sourceRoot); + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + + var sourceDll = Path.Join(sourcePlugin, "current.dll"); + var destDll = Path.Join(destRoot, PluginName, "current.dll"); + var sourceMtime = File.GetLastWriteTimeUtc(sourceDll); + var destMtime = File.GetLastWriteTimeUtc(destDll); + var sourceReplacement = "source-v2!"u8.ToArray(); + var destReplacement = "dest-dmg!!"u8.ToArray(); + Assert.Equal(File.ReadAllBytes(sourceDll).Length, sourceReplacement.Length); + Assert.Equal(File.ReadAllBytes(destDll).Length, destReplacement.Length); + + File.WriteAllBytes(sourceDll, sourceReplacement); + File.SetLastWriteTimeUtc(sourceDll, sourceMtime); + File.WriteAllBytes(destDll, destReplacement); + File.SetLastWriteTimeUtc(destDll, destMtime); + + var deployed = BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot); + + Assert.Equal(0, deployed); + Assert.Equal(destReplacement, File.ReadAllBytes(destDll)); + AssertNoScratch(destRoot); + } + finally + { + TestPaths.DeleteDirectory(root); + } + } + + [Fact] + public void DeployIfMissing_TouchedButIdenticalContent_RefreshesStampWithoutRedeploy() + { + var root = TestPaths.CreateTempDirectory("bundled-plugin-touched-source"); + try + { + var sourceRoot = Path.Join(root, "bundle"); + var destRoot = Path.Join(root, "installed"); + var sourcePlugin = CreateBundle(sourceRoot); + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + + var sourceDll = Path.Join(sourcePlugin, "current.dll"); + var destPlugin = Path.Join(destRoot, PluginName); + var stampPath = Path.Join(destPlugin, StampFileName); + var payloadBefore = SnapshotPayloadFiles(destPlugin); + var stampBefore = File.ReadAllText(stampPath); + File.SetLastWriteTimeUtc( + sourceDll, + File.GetLastWriteTimeUtc(sourceDll).AddMinutes(5) + ); + + Assert.Equal(0, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + AssertSnapshotsEqual(payloadBefore, SnapshotPayloadFiles(destPlugin)); + var refreshedStamp = File.ReadAllText(stampPath); + Assert.NotEqual(stampBefore, refreshedStamp); + + Assert.Equal(0, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + AssertSnapshotsEqual(payloadBefore, SnapshotPayloadFiles(destPlugin)); + Assert.Equal(refreshedStamp, File.ReadAllText(stampPath)); + AssertNoScratch(destRoot); + } + finally + { + TestPaths.DeleteDirectory(root); + } + } + + [Fact] + public void DeployIfMissing_ValidStampButDamagedDestination_RepairsFromBundle() + { + var root = TestPaths.CreateTempDirectory("bundled-plugin-damaged-dest"); + try + { + var sourceRoot = Path.Join(root, "bundle"); + var destRoot = Path.Join(root, "installed"); + var sourcePlugin = CreateBundle(sourceRoot); + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + + var destPlugin = Path.Join(destRoot, PluginName); + var stampPath = Path.Join(destPlugin, StampFileName); + var validStamp = File.ReadAllText(stampPath); + var destDll = Path.Join(destPlugin, "current.dll"); + + // Same-length, older-mtime damage with the stamp still valid: only a content + // re-hash of the destination can catch it. + File.WriteAllText(destDll, "corrupted!"); + Assert.Equal( + File.ReadAllBytes(Path.Join(sourcePlugin, "current.dll")).Length, + File.ReadAllBytes(destDll).Length + ); + File.SetLastWriteTimeUtc(destDll, File.GetLastWriteTimeUtc(destDll).AddMinutes(-5)); + + var deployed = BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot); + + Assert.Equal(1, deployed); + Assert.Equal("current-v1", File.ReadAllText(destDll)); + Assert.Equal(validStamp, File.ReadAllText(stampPath)); + AssertSourceFilesMatch(sourcePlugin, destPlugin); + AssertNoScratch(destRoot); + } + finally + { + TestPaths.DeleteDirectory(root); + } + } + + [Fact] + public void DeployIfMissing_SameLengthContentWithOlderMtime_Redeploys() + { + var root = TestPaths.CreateTempDirectory("bundled-plugin-content-change"); + try + { + var sourceRoot = Path.Join(root, "bundle"); + var destRoot = Path.Join(root, "installed"); + var sourcePlugin = CreateBundle(sourceRoot); + var sourceDll = Path.Join(sourcePlugin, "current.dll"); + File.WriteAllText(sourceDll, "AAAA"); + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + + var destDll = Path.Join(destRoot, PluginName, "current.dll"); + File.WriteAllText(sourceDll, "BBBB"); + File.SetLastWriteTimeUtc( + sourceDll, + File.GetLastWriteTimeUtc(destDll).AddMinutes(-5) + ); + Assert.True(File.GetLastWriteTimeUtc(sourceDll) <= File.GetLastWriteTimeUtc(destDll)); + + var deployed = BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot); + + Assert.Equal(1, deployed); + Assert.Equal("BBBB", File.ReadAllText(destDll)); + AssertNoScratch(destRoot); + } + finally + { + TestPaths.DeleteDirectory(root); + } + } + + [Fact] + public void DeployIfMissing_RemovedSourceFile_PrunesDestinationOnlyFile() + { + var root = TestPaths.CreateTempDirectory("bundled-plugin-prune"); + try + { + var sourceRoot = Path.Join(root, "bundle"); + var destRoot = Path.Join(root, "installed"); + var sourcePlugin = CreateBundle(sourceRoot); + var sourceObsolete = Path.Join(sourcePlugin, "runtimes", "obsolete.dll"); + File.WriteAllText(sourceObsolete, "obsolete"); + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + + var destPlugin = Path.Join(destRoot, PluginName); + var destObsolete = Path.Join(destPlugin, "runtimes", "obsolete.dll"); + Assert.True(File.Exists(destObsolete)); + File.Delete(sourceObsolete); + + var deployed = BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot); + + Assert.Equal(1, deployed); + AssertSourceFilesMatch(sourcePlugin, destPlugin); + Assert.False(File.Exists(destObsolete)); + Assert.Equal("current-v1", File.ReadAllText(Path.Join(destPlugin, "current.dll"))); + AssertNoScratch(destRoot); + } + finally + { + TestPaths.DeleteDirectory(root); + } + } + + [Fact] + public void DeployIfMissing_CopyFailure_PreservesCompletePriorDeployment() + { + var root = TestPaths.CreateTempDirectory("bundled-plugin-copy-failure"); + try + { + var sourceRoot = Path.Join(root, "bundle"); + var destRoot = Path.Join(root, "installed"); + var sourcePlugin = CreateBundle(sourceRoot); + File.WriteAllText(Path.Join(sourcePlugin, "z-fail.dll"), "v1-failure-target"); + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + + var destPlugin = Path.Join(destRoot, PluginName); + var deployedBefore = SnapshotFiles(destPlugin); + File.WriteAllText(Path.Join(sourcePlugin, "current.dll"), "current-v2"); + File.WriteAllText(Path.Join(sourcePlugin, "a-v2-only.dll"), "new-v2"); + var copiedBeforeFailure = 0; + + var deployed = BundledPluginDeployer.DeployIfMissing( + sourceRoot, + destRoot, + (source, destination) => + { + if (string.Equals( + Path.GetFileName(source), + "z-fail.dll", + StringComparison.Ordinal + )) + { + throw new IOException("Injected staging copy failure."); + } + + File.Copy(source, destination); + copiedBeforeFailure++; + } + ); + + Assert.Equal(0, deployed); + Assert.True(copiedBeforeFailure > 0); + AssertSnapshotsEqual(deployedBefore, SnapshotFiles(destPlugin)); + Assert.Equal("current-v1", File.ReadAllText(Path.Join(destPlugin, "current.dll"))); + Assert.False(File.Exists(Path.Join(destPlugin, "a-v2-only.dll"))); + AssertNoScratch(destRoot); + } + finally + { + TestPaths.DeleteDirectory(root); + } + } + + [Fact] + public void DeployIfMissing_InterruptedCommit_RestoresBackupAndCleansScratch() + { + var root = TestPaths.CreateTempDirectory("bundled-plugin-interrupted-commit"); + try + { + var sourceRoot = Path.Join(root, "bundle"); + var destRoot = Path.Join(root, "installed"); + _ = CreateBundle(sourceRoot); + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + + var destPlugin = Path.Join(destRoot, PluginName); + var deployedBefore = SnapshotFiles(destPlugin); + + var pluginScratch = Path.Join(destRoot, ScratchDirectoryName, PluginName); + Directory.CreateDirectory(pluginScratch); + Directory.Move(destPlugin, Path.Join(pluginScratch, "backup")); + var abandonedStage = Path.Join(pluginScratch, "stage-deadbeef"); + Directory.CreateDirectory(abandonedStage); + File.WriteAllText(Path.Join(abandonedStage, "junk.tmp"), "interrupted-copy"); + Assert.False(Directory.Exists(destPlugin)); + + var deployed = BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot); + + Assert.Equal(0, deployed); + AssertSnapshotsEqual(deployedBefore, SnapshotFiles(destPlugin)); + Assert.True(File.Exists(Path.Join(destPlugin, StampFileName))); + AssertNoScratch(destRoot); + } + finally + { + TestPaths.DeleteDirectory(root); + } + } + + [Fact] + public void DeployIfMissing_DestinationMutatedDuringCommit_DoesNotBlessAndRedeploys() + { + var root = TestPaths.CreateTempDirectory("bundled-plugin-commit-race"); + try + { + var sourceRoot = Path.Join(root, "bundle"); + var destRoot = Path.Join(root, "installed"); + var sourcePlugin = CreateBundle(sourceRoot); + Assert.Equal(1, BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot)); + + var sourceDll = Path.Join(sourcePlugin, "current.dll"); + var destDll = Path.Join(destRoot, PluginName, "current.dll"); + File.WriteAllText(sourceDll, "current-v2"); + + // Corrupt dest after CommitStage but before the stamp is finalized; a distinct + // mtime keeps it stat-detectable, so finalize must not bless it. + var corrupted = 0; + var deployed = BundledPluginDeployer.DeployIfMissing( + sourceRoot, + destRoot, + copyFile: null, + afterCommit: _ => + { + if (corrupted++ > 0) + { + return; + } + + File.WriteAllText(destDll, "wrecked-v2"); + File.SetLastWriteTimeUtc( + destDll, + File.GetLastWriteTimeUtc(destDll).AddMinutes(-5) + ); + } + ); + + Assert.Equal(0, deployed); + Assert.Equal("wrecked-v2", File.ReadAllText(destDll)); + + var repaired = BundledPluginDeployer.DeployIfMissing(sourceRoot, destRoot); + + Assert.Equal(1, repaired); + Assert.Equal("current-v2", File.ReadAllText(destDll)); + AssertSourceFilesMatch(sourcePlugin, Path.Join(destRoot, PluginName)); + AssertNoScratch(destRoot); + } + finally + { + TestPaths.DeleteDirectory(root); + } + } + + private static string CreateBundle(string sourceRoot) + { + var plugin = Path.Join(sourceRoot, PluginName); + Directory.CreateDirectory(Path.Join(plugin, "runtimes")); + Directory.CreateDirectory(Path.Join(plugin, "empty")); + File.WriteAllText(Path.Join(plugin, "manifest.json"), "{\"id\":\"sample-plugin\"}"); + File.WriteAllText(Path.Join(plugin, "current.dll"), "current-v1"); + File.WriteAllText(Path.Join(plugin, "runtimes", "native.so"), "native-v1"); + return plugin; + } + + private static void AssertSourceFilesMatch(string sourcePlugin, string destPlugin) + { + var sourceFiles = SnapshotFiles(sourcePlugin); + var destFiles = SnapshotPayloadFiles(destPlugin); + AssertSnapshotsEqual(sourceFiles, destFiles); + } + + private static Dictionary SnapshotPayloadFiles(string plugin) + { + return SnapshotFiles(plugin) + .Where(entry => !string.Equals(entry.Key, StampFileName, StringComparison.Ordinal)) + .ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.Ordinal); + } + + private static Dictionary SnapshotFiles(string root) + { + return Directory + .GetFiles(root, "*", SearchOption.AllDirectories) + .ToDictionary( + file => Path.GetRelativePath(root, file).Replace(Path.DirectorySeparatorChar, '/'), + File.ReadAllBytes, + StringComparer.Ordinal + ); + } + + private static void AssertSnapshotsEqual( + IReadOnlyDictionary expected, + IReadOnlyDictionary actual + ) + { + Assert.Equal( + expected.Keys.OrderBy(path => path, StringComparer.Ordinal), + actual.Keys.OrderBy(path => path, StringComparer.Ordinal) + ); + foreach (var path in expected.Keys) + { + Assert.Equal(expected[path], actual[path]); + } + } + + private static void AssertStructuredStamp(string stamp) + { + var lines = stamp.Split(Environment.NewLine); + Assert.Collection( + lines, + line => AssertStampDigest(line, "content"), + line => AssertStampDigest(line, "sourceStat"), + line => AssertStampDigest(line, "destStat") + ); + } + + private static void AssertStampDigest(string line, string key) + { + var prefix = $"{key}="; + Assert.StartsWith(prefix, line, StringComparison.Ordinal); + var value = line[prefix.Length..]; + Assert.Equal(64, value.Length); + Assert.All(value, character => Assert.True(Uri.IsHexDigit(character))); + } + + private static void AssertNoScratch(string destRoot) + { + Assert.False(Directory.Exists(Path.Join(destRoot, ScratchDirectoryName))); + } +} From a4300f5823a96c38645e51798ba61ff49514018e Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 13:05:42 +0000 Subject: [PATCH 143/226] Bound plugin shutdown and never skip disposal PluginManager.Dispose deactivated plugins sequentially with an unbounded synchronous wait, and deactivation plus disposal shared one try block - a plugin whose DeactivateAsync never completed hung host shutdown forever, and one that threw skipped its own Dispose. Each plugin now shuts down on a dedicated worker: deactivation is awaited with an observation deadline (injectable, default 5s), and the caller bounds the whole worker with the same deadline, so host shutdown always progresses to the next plugin and to load-context unload. Disposal is isolated from deactivation failure - a throw is logged and Dispose still runs. On a deactivation timeout, Dispose is chained as a continuation of the abandoned task so deactivate and dispose never run concurrently for the same plugin; a deactivation that never completes forfeits its Dispose, accepted because the host process is exiting. Late completions and faults are observed and trace-logged so abandoned tasks can never surface as unobserved exceptions. --- .../Services/Plugins/PluginManager.cs | 172 +++++++++++++- .../PluginManagerTests.cs | 211 ++++++++++++++++++ 2 files changed, 375 insertions(+), 8 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs index 32581c1ae..bd764f900 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs @@ -14,6 +14,8 @@ namespace TypeWhisper.Linux.Services.Plugins; /// public sealed class PluginManager : IDisposable { + private static readonly TimeSpan s_defaultPluginShutdownTimeout = TimeSpan.FromSeconds(5); + // Fresh-install defaults: offline transcription engines only, so dictation works // out of the box without a key. Cloud providers default off until opted in. private static readonly HashSet s_defaultEnabledPluginIds = new(StringComparer.Ordinal) @@ -33,6 +35,7 @@ public sealed class PluginManager : IDisposable private readonly IProfileService _profiles; private readonly string[] _searchDirectories; private readonly ISettingsService _settings; + private readonly TimeSpan _pluginShutdownTimeout; private readonly IErrorLogService? _errorLog; private List _actionPlugins = []; @@ -72,7 +75,8 @@ internal PluginManager( IProfileService profiles, ISettingsService settings, IEnumerable searchDirectories, - IErrorLogService? errorLog = null + IErrorLogService? errorLog = null, + TimeSpan? pluginShutdownTimeout = null ) { _loader = loader; @@ -82,6 +86,15 @@ internal PluginManager( _settings = settings; _searchDirectories = searchDirectories.ToArray(); _errorLog = errorLog; + _pluginShutdownTimeout = + pluginShutdownTimeout ?? s_defaultPluginShutdownTimeout; + if (_pluginShutdownTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(pluginShutdownTimeout), + "The plugin shutdown timeout must be greater than zero." + ); + } } public IReadOnlyList AllPlugins @@ -166,20 +179,42 @@ public void Dispose() foreach (var plugin in plugins) { - try + // Dispose is synchronous and can't be canceled. A hostile plugin can strand this + // worker past the deadline; bounded shutdown accepts that leaked thread. + var shutdownTask = Task.Factory.StartNew( + () => ShutdownPlugin(plugin, activated.Contains(plugin.Manifest.Id)), + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default + ); + + var completedTask = Task.WhenAny( + shutdownTask, + Task.Delay(_pluginShutdownTimeout) + ) + .GetAwaiter() + .GetResult(); + + if (completedTask == shutdownTask) { - if (activated.Contains(plugin.Manifest.Id)) + try { - plugin.Instance.DeactivateAsync().GetAwaiter().GetResult(); + shutdownTask.GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[PluginManager] Error shutting down plugin {plugin.Manifest.Id}: {ex.Message}" + ); } - - plugin.Instance.Dispose(); } - catch (Exception ex) + else { Trace.WriteLine( - $"[PluginManager] Error disposing plugin {plugin.Manifest.Id}: {ex.Message}" + $"[PluginManager] Timed out shutting down plugin {plugin.Manifest.Id} " + + $"after {_pluginShutdownTimeout.TotalSeconds:0.###} seconds" ); + ObserveLateShutdown(shutdownTask, plugin.Manifest.Id); } try @@ -207,6 +242,127 @@ public void Dispose() } } + private void ShutdownPlugin(LoadedPlugin plugin, bool deactivate) + { + if (deactivate) + { + try + { + var deactivationTask = plugin.Instance.DeactivateAsync(); + var completedTask = Task.WhenAny( + deactivationTask, + Task.Delay(_pluginShutdownTimeout) + ) + .GetAwaiter() + .GetResult(); + + if (completedTask == deactivationTask) + { + deactivationTask.GetAwaiter().GetResult(); + } + else + { + Trace.WriteLine( + $"[PluginManager] Timed out deactivating plugin {plugin.Manifest.Id} " + + $"after {_pluginShutdownTimeout.TotalSeconds:0.###} seconds" + ); + + // Ordering guarantee: deactivate and dispose never run concurrently for the + // same plugin. Disposing now would race the still-running deactivation, so + // Dispose is deferred to a continuation that fires once it completes — + // forfeited entirely if it never does, acceptable since the host is exiting. + ObserveLateDeactivationThenDispose(deactivationTask, plugin); + return; + } + } + catch (Exception ex) + { + Trace.WriteLine( + $"[PluginManager] Error deactivating plugin {plugin.Manifest.Id}: {ex.Message}" + ); + } + } + + try + { + plugin.Instance.Dispose(); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[PluginManager] Error disposing plugin {plugin.Manifest.Id}: {ex.Message}" + ); + } + } + + private static void ObserveLateDeactivationThenDispose(Task deactivationTask, LoadedPlugin plugin) + { + var pluginId = plugin.Manifest.Id; + _ = deactivationTask.ContinueWith( + completedTask => + { + if (completedTask.IsFaulted) + { + Trace.WriteLine( + $"[PluginManager] Deactivation for plugin {pluginId} faulted after timeout: " + + completedTask.Exception!.GetBaseException().Message + ); + } + else if (completedTask.IsCanceled) + { + Trace.WriteLine( + $"[PluginManager] Deactivation for plugin {pluginId} was canceled after timeout" + ); + } + else + { + Trace.WriteLine( + $"[PluginManager] Deactivation for plugin {pluginId} completed after timeout" + ); + } + + try + { + plugin.Instance.Dispose(); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[PluginManager] Error disposing plugin {pluginId}: {ex.Message}" + ); + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + + private static void ObserveLateShutdown(Task shutdownTask, string pluginId) + { + _ = shutdownTask.ContinueWith( + completedTask => + { + if (completedTask.IsFaulted) + { + Trace.WriteLine( + $"[PluginManager] Shutdown for plugin {pluginId} faulted after timeout: " + + completedTask.Exception!.GetBaseException().Message + ); + } + else + { + Trace.WriteLine( + $"[PluginManager] Shutdown for plugin {pluginId} completed after timeout" + ); + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + public IReadOnlyList GetPlugins() where T : class { diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs index 8285a653c..affca76f6 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs @@ -1,4 +1,5 @@ using Moq; +using System.Reflection; using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; using TypeWhisper.Linux.Services.Plugins; @@ -176,6 +177,9 @@ private PluginManager CreateManager() // Verifies enable/disable/capability-index logic without loading real plugin assemblies. public sealed class PluginManagerWithFakePluginTests : IDisposable { + private static readonly TimeSpan s_shutdownTimeout = TimeSpan.FromMilliseconds(50); + private static readonly TimeSpan s_outerTimeout = TimeSpan.FromSeconds(2); + private readonly Mock _activeWindow = new(); private readonly PluginEventBus _eventBus = new(); private readonly Mock _profiles = new(); @@ -244,4 +248,211 @@ public async Task DisablePluginAsync_NotActivated_PersistsDisabledState() Assert.Null(savedSettings); } + + [Fact] + public async Task Dispose_HangingDeactivation_ReturnsAndShutsDownLaterPlugin() + { + var hangingPlugin = new HangingDeactivationPlugin("com.test.hanging"); + var laterPlugin = new LifecyclePlugin( + "com.test.later", + () => Task.CompletedTask + ); + var manager = await CreateManagerAsync(hangingPlugin, laterPlugin); + var disposeTask = Task.Run(manager.Dispose); + + try + { + await disposeTask.WaitAsync(s_outerTimeout); + + Assert.Equal(1, laterPlugin.DeactivationCount); + Assert.Equal(1, laterPlugin.DisposeCount); + } + finally + { + hangingPlugin.CompleteDeactivation(); + await disposeTask.WaitAsync(s_outerTimeout); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Dispose_ThrowingDeactivation_DisposesPluginAndShutsDownLaterPlugin( + bool throwsSynchronously + ) + { + Func throwingDeactivation = throwsSynchronously + ? () => throw new InvalidOperationException("Synchronous deactivation failure") + : () => Task.FromException( + new InvalidOperationException("Asynchronous deactivation failure") + ); + var throwingPlugin = new LifecyclePlugin( + "com.test.throwing", + throwingDeactivation + ); + var laterPlugin = new LifecyclePlugin( + "com.test.later", + () => Task.CompletedTask + ); + var manager = await CreateManagerAsync(throwingPlugin, laterPlugin); + + manager.Dispose(); + + Assert.Equal(1, throwingPlugin.DeactivationCount); + Assert.Equal(1, throwingPlugin.DisposeCount); + Assert.Equal(1, laterPlugin.DeactivationCount); + Assert.Equal(1, laterPlugin.DisposeCount); + } + + [Fact] + public async Task Dispose_TimedOutDeactivation_ObservesLateCompletion() + { + var hangingPlugin = new HangingDeactivationPlugin( + "com.test.late-completion" + ); + var manager = await CreateManagerAsync(hangingPlugin); + var disposeTask = Task.Run(manager.Dispose); + + try + { + await disposeTask.WaitAsync(s_outerTimeout); + + // Under the ordering guarantee, Dispose must not have run yet; wait briefly + // so this negative assertion isn't just early timing luck. + await Task.Delay(100); + Assert.False(hangingPlugin.DisposeCalled.IsCompleted); + + hangingPlugin.CompleteDeactivation(); + + await hangingPlugin.DeactivationCompleted.WaitAsync(s_outerTimeout); + await hangingPlugin.DisposeCalled.WaitAsync(s_outerTimeout); + Assert.True(hangingPlugin.DidCompleteDeactivation); + } + finally + { + hangingPlugin.CompleteDeactivation(); + await disposeTask.WaitAsync(s_outerTimeout); + } + } + + private async Task CreateManagerAsync( + params ITypeWhisperPlugin[] plugins + ) + { + _manager = new PluginManager( + new PluginLoader(TestPaths.NewTempPath("TypeWhisper.PluginManagerData")), + _eventBus, + _activeWindow.Object, + _profiles.Object, + _settings.Object, + [], + pluginShutdownTimeout: s_shutdownTimeout + ); + + var loadedPlugins = GetLoadedPlugins(_manager); + foreach (var plugin in plugins) + { + loadedPlugins.Add(CreateLoadedPlugin(plugin)); + await _manager.EnablePluginAsync(plugin.PluginId); + } + + return _manager; + } + + private static LoadedPlugin CreateLoadedPlugin(ITypeWhisperPlugin plugin) + { + var testAssemblyPath = typeof(PluginManagerTests).Assembly.Location; + return new LoadedPlugin( + new PluginManifest + { + Id = plugin.PluginId, + Name = plugin.PluginName, + Version = plugin.PluginVersion, + AssemblyName = "fake.dll", + PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name, + }, + plugin, + new PluginAssemblyLoadContext(testAssemblyPath), + Path.GetDirectoryName(testAssemblyPath)! + ); + } + + private static List GetLoadedPlugins(PluginManager manager) + { + var field = + typeof(PluginManager).GetField( + "_allPlugins", + BindingFlags.Instance | BindingFlags.NonPublic + ) ?? throw new MissingFieldException(typeof(PluginManager).FullName, "_allPlugins"); + return (List)field.GetValue(manager)!; + } + + private sealed class LifecyclePlugin( + string pluginId, + Func deactivateAsync + ) : ITypeWhisperPlugin + { + private int _deactivationCount; + private int _disposeCount; + + public string PluginId { get; } = pluginId; + public string PluginName => PluginId; + public string PluginVersion => "1.0.0"; + public int DeactivationCount => Volatile.Read(ref _deactivationCount); + public int DisposeCount => Volatile.Read(ref _disposeCount); + + public Task ActivateAsync(IPluginHostServices host) => Task.CompletedTask; + + public Task DeactivateAsync() + { + Interlocked.Increment(ref _deactivationCount); + return deactivateAsync(); + } + + public void Dispose() + { + Interlocked.Increment(ref _disposeCount); + } + } + + private sealed class HangingDeactivationPlugin(string pluginId) : ITypeWhisperPlugin + { + private readonly TaskCompletionSource _deactivationCompleted = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + private readonly TaskCompletionSource _deactivationRelease = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + private readonly TaskCompletionSource _disposeCalled = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + private int _didCompleteDeactivation; + + public string PluginId { get; } = pluginId; + public string PluginName => PluginId; + public string PluginVersion => "1.0.0"; + public Task DeactivationCompleted => _deactivationCompleted.Task; + public Task DisposeCalled => _disposeCalled.Task; + public bool DidCompleteDeactivation => + Volatile.Read(ref _didCompleteDeactivation) == 1; + + public Task ActivateAsync(IPluginHostServices host) => Task.CompletedTask; + + public async Task DeactivateAsync() + { + await _deactivationRelease.Task; + Volatile.Write(ref _didCompleteDeactivation, 1); + _deactivationCompleted.TrySetResult(); + } + + public void CompleteDeactivation() + { + _deactivationRelease.TrySetResult(); + } + + public void Dispose() + { + _disposeCalled.TrySetResult(); + } + } } From fb49e3326a966e3f73bac5a19338728dbcac4be0 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 13:59:54 +0000 Subject: [PATCH 144/226] Persist watch-folder fingerprints transactionally with backup recovery The processed-fingerprint store was mutated in memory before persistence, persisted by truncating the final JSON file in place, and every failure was swallowed; a corrupt file on load silently became an empty set. After a crash, disk-full event, or partial write, restart forgot every prior success and re-transcribed all retained sources - overwriting artifacts and re-incurring paid provider work. Saves now validate and back up the current primary generation to watch-folder-processed.json.bak (skipping the backup when the primary is already unreadable so a good backup survives), then publish the new primary atomically via AtomicFileWrite; persistence failures are wrapped and rethrown instead of swallowed. AddProcessedFingerprint rolls back the in-memory add on a failed commit so memory always matches the generation on disk, and the rethrown failure reaches the per-file failure/history path; the run-local failed set prevents a hot retry. Load tries the primary, then the backup, and only starts empty when both generations are unreadable. PA36 tracks the deeper fsync durability gap in AtomicFileWrite itself. --- .../Services/WatchFolderService.cs | 171 +++++++- .../WatchFolderServiceTests.cs | 394 ++++++++++++++++++ 2 files changed, 542 insertions(+), 23 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/WatchFolderService.cs b/src/TypeWhisper.Linux/Services/WatchFolderService.cs index 0d3b51f25..e0ad63ae9 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderService.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderService.cs @@ -23,8 +23,10 @@ public sealed class WatchFolderService : IDisposable, IAsyncDisposable private readonly List _history = []; private readonly string _historyPath; private readonly SemaphoreSlim _lifecycleGate = new(1, 1); + private readonly Action _atomicWriteAllText; private readonly Lock _persistenceGate = new(); private readonly HashSet _processedFingerprints = new(StringComparer.Ordinal); + private readonly string _processedFingerprintsBackupPath; private readonly string _processedFingerprintsPath; private readonly Lock _stateGate = new(); private readonly Func _waitForWorkers; @@ -40,18 +42,33 @@ public WatchFolderService() } internal WatchFolderService(string dataPath) - : this(dataPath, static (workers, timeout) => workers.WaitAsync(timeout)) + : this( + dataPath, + static (workers, timeout) => workers.WaitAsync(timeout), + AtomicFileWrite.WriteAllText + ) { } internal WatchFolderService( string dataPath, Func waitForWorkers + ) + : this(dataPath, waitForWorkers, AtomicFileWrite.WriteAllText) + { + } + + internal WatchFolderService( + string dataPath, + Func waitForWorkers, + Action atomicWriteAllText ) { _waitForWorkers = waitForWorkers; + _atomicWriteAllText = atomicWriteAllText; Directory.CreateDirectory(dataPath); _processedFingerprintsPath = Path.Join(dataPath, "watch-folder-processed.json"); + _processedFingerprintsBackupPath = _processedFingerprintsPath + ".bak"; _historyPath = Path.Join(dataPath, "watch-folder-history.json"); LoadProcessedFingerprints(); LoadHistory(); @@ -844,11 +861,6 @@ private void AddProcessedFingerprint(WatchFolderRun run, string fingerprint) return; } - lock (run.FailedFingerprintsGate) - { - run.FailedFingerprints.Remove(fingerprint); - } - lock (_persistenceGate) { if (!IsRunCurrentAndLive(run)) @@ -856,8 +868,27 @@ private void AddProcessedFingerprint(WatchFolderRun run, string fingerprint) return; } - _processedFingerprints.Add(fingerprint); - SaveProcessedFingerprintsCore(); + if (!_processedFingerprints.Add(fingerprint)) + { + return; + } + + try + { + SaveProcessedFingerprintsCore(); + } + catch + { + // Roll back so the live set matches disk; the caller surfaces this via the + // normal per-file failure path, and the run's failed set blocks a hot retry. + _processedFingerprints.Remove(fingerprint); + throw; + } + } + + lock (run.FailedFingerprintsGate) + { + run.FailedFingerprints.Remove(fingerprint); } } @@ -977,42 +1008,136 @@ private static async Task WaitForFileReadyAsync(string path, CancellationToken c private void LoadProcessedFingerprints() { - try + var primaryExists = File.Exists(_processedFingerprintsPath); + var backupExists = File.Exists(_processedFingerprintsBackupPath); + if (!primaryExists && !backupExists) { - if (!File.Exists(_processedFingerprintsPath)) - { - return; - } + return; + } - var json = File.ReadAllText(_processedFingerprintsPath); - var loaded = JsonSerializer.Deserialize>(json, s_jsonOptions); - if (loaded is null) - { - return; - } + if ( + TryLoadProcessedFingerprints( + _processedFingerprintsPath, + out var loaded, + out var primaryFailure + ) + ) + { + AddProcessedFingerprints(loaded); + return; + } - foreach (var fingerprint in loaded) + Debug.WriteLine( + $"Failed to load primary watch folder fingerprints " + + $"'{_processedFingerprintsPath}': {primaryFailure}" + ); + if ( + TryLoadProcessedFingerprints( + _processedFingerprintsBackupPath, + out loaded, + out var backupFailure + ) + ) + { + AddProcessedFingerprints(loaded); + Debug.WriteLine( + $"Recovered watch folder fingerprints from " + + $"'{_processedFingerprintsBackupPath}'." + ); + return; + } + + Debug.WriteLine( + $"Failed to load both watch folder fingerprint generations; " + + $"starting with an empty set. Primary: {primaryFailure} Backup: {backupFailure}" + ); + } + + private static bool TryLoadProcessedFingerprints( + string path, + out HashSet loaded, + out Exception? failure + ) + { + try + { + if (!File.Exists(path)) { - _processedFingerprints.Add(fingerprint); + throw new FileNotFoundException( + "Watch folder fingerprint generation does not exist.", + path + ); } + + loaded = DeserializeProcessedFingerprints(File.ReadAllText(path)); + failure = null; + return true; } catch (Exception ex) when (IsExpectedPersistenceException(ex)) { - Debug.WriteLine($"Failed to load watch folder fingerprints: {ex}"); + loaded = new HashSet(StringComparer.Ordinal); + failure = ex; + return false; } } + private void AddProcessedFingerprints(IEnumerable fingerprints) + { + foreach (var fingerprint in fingerprints) + { + // ReSharper disable once InconsistentlySynchronizedField -- only called during construction, before the instance is published; concurrent access is guarded elsewhere by _persistenceGate. + _processedFingerprints.Add(fingerprint); + } + } + + private static HashSet DeserializeProcessedFingerprints(string json) + { + return JsonSerializer.Deserialize>(json, s_jsonOptions) + ?? throw new JsonException( + "Watch folder fingerprint generation contained JSON null." + ); + } + private void SaveProcessedFingerprintsCore() { try { Directory.CreateDirectory(Path.GetDirectoryName(_processedFingerprintsPath)!); var json = JsonSerializer.Serialize(_processedFingerprints, s_jsonOptions); - File.WriteAllText(_processedFingerprintsPath, json); + + if (File.Exists(_processedFingerprintsPath)) + { + string? currentJson = null; + try + { + var candidate = File.ReadAllText(_processedFingerprintsPath); + DeserializeProcessedFingerprints(candidate); + currentJson = candidate; + } + catch (Exception ex) when (IsExpectedPersistenceException(ex)) + { + // Skip the backup write rather than overwrite a good backup with this + // corrupt read; the new primary is still published atomically below. + Debug.WriteLine( + $"Skipped backup of unreadable watch folder fingerprints: {ex}" + ); + } + + if (currentJson is not null) + { + _atomicWriteAllText(_processedFingerprintsBackupPath, currentJson); + } + } + + _atomicWriteAllText(_processedFingerprintsPath, json); } catch (Exception ex) when (IsExpectedPersistenceException(ex)) { Debug.WriteLine($"Failed to save watch folder fingerprints: {ex}"); + throw new IOException( + $"Failed to persist watch folder processed fingerprints: {ex.Message}", + ex + ); } } diff --git a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs index aee025497..2674ef7aa 100644 --- a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs @@ -1,6 +1,7 @@ // ReSharper disable MethodSupportsCancellation -- every WaitAsync here uses only a test-guard timeout; there is no ambient cancellation token to pass. // ReSharper disable MethodHasAsyncOverload -- synchronous File.Read/WriteAllText is deliberate in these test assertions; the async overload would only add await noise with no benefit off the hot path. using System.Collections.Concurrent; +using System.Text.Json; using TypeWhisper.Linux.Services; using TypeWhisper.Tests; using Xunit; @@ -25,6 +26,387 @@ public void Dispose() } } + [Fact] + public async Task Restart_WhenPrimaryFingerprintStoreIsCorrupt_RecoversBackupAndSkipsRetainedSource() + { + var watchPath = Path.Join(_tempDir, "recovery-watch"); + var outputPath = Path.Join(_tempDir, "recovery-output"); + var dataPath = Path.Join(_tempDir, "recovery-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + var retainedPath = Path.Join(watchPath, "a-retained.wav"); + var priorPath = Path.Join(watchPath, "b-prior.wav"); + File.WriteAllBytes(retainedPath, [1, 2, 3]); + File.WriteAllBytes(priorPath, [4, 5, 6]); + + var initialService = new WatchFolderService(dataPath); + WatchFolderService.WatchFolderRun? initialRun = null; + try + { + var initialItems = await StartAndWaitForProcessedItemsAsync( + initialService, + expectedCount: 2, + CreateOptions(watchPath, outputPath) + ); + initialRun = initialService.CurrentRun; + Assert.NotNull(initialRun); + Assert.All(initialItems, item => Assert.True(item.Success, item.ErrorMessage)); + } + finally + { + if (initialService.CurrentRun is not null) + { + await initialService.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (initialRun is not null) + { + await initialRun.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await initialService.DisposeAsync(); + } + + var primaryPath = Path.Join(dataPath, "watch-folder-processed.json"); + var backupPath = primaryPath + ".bak"; + var retainedFingerprint = CreateTestFingerprint(retainedPath); + var backupFingerprints = ReadFingerprints(backupPath); + Assert.Equal(retainedFingerprint, Assert.Single(backupFingerprints)); + + File.WriteAllText(primaryPath, "{ definitely-not-json"); + File.Delete(priorPath); + var freshPath = Path.Join(watchPath, "c-fresh.wav"); + File.WriteAllBytes(freshPath, [7, 8, 9]); + + var freshProcessed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var calls = new ConcurrentQueue(); + var recoveredService = new WatchFolderService(dataPath); + WatchFolderService.WatchFolderRun? recoveredRun = null; + recoveredService.FileProcessed += (_, item) => + { + if (string.Equals(item.FileName, "c-fresh.wav", StringComparison.Ordinal)) + { + freshProcessed.TrySetResult(item); + } + }; + + try + { + recoveredService.Start( + CreateOptions(watchPath, outputPath), + (request, ct) => + { + ct.ThrowIfCancellationRequested(); + calls.Enqueue(Path.GetFileName(request.FilePath)); + return Task.FromResult(CreateResult(request)); + } + ); + recoveredRun = recoveredService.CurrentRun; + Assert.NotNull(recoveredRun); + + var item = await freshProcessed.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.True(item.Success, item.ErrorMessage); + Assert.Equal(["c-fresh.wav"], calls); + var persisted = ReadFingerprints(primaryPath); + Assert.Equal(2, persisted.Count); + Assert.Contains(retainedFingerprint, persisted); + Assert.Contains(CreateTestFingerprint(freshPath), persisted); + Assert.Equal(retainedFingerprint, Assert.Single(ReadFingerprints(backupPath))); + } + finally + { + if (recoveredService.CurrentRun is not null) + { + await recoveredService.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (recoveredRun is not null) + { + await recoveredRun.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await recoveredService.DisposeAsync(); + } + } + + [Fact] + public async Task Restart_WhenBothFingerprintGenerationsAreCorrupt_StartsWithEmptySetAndRebuildsStore() + { + var watchPath = Path.Join(_tempDir, "both-corrupt-watch"); + var outputPath = Path.Join(_tempDir, "both-corrupt-output"); + var dataPath = Path.Join(_tempDir, "both-corrupt-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + Directory.CreateDirectory(dataPath); + File.WriteAllBytes(Path.Join(watchPath, "a-first.wav"), [1, 2, 3]); + File.WriteAllBytes(Path.Join(watchPath, "b-second.wav"), [4, 5, 6]); + var primaryPath = Path.Join(dataPath, "watch-folder-processed.json"); + var backupPath = primaryPath + ".bak"; + File.WriteAllText(primaryPath, "{ corrupt-primary"); + File.WriteAllText(backupPath, "[ corrupt-backup"); + + var twoItemsProcessed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var calls = new ConcurrentQueue(); + var processed = new ConcurrentQueue(); + var service = new WatchFolderService(dataPath); + WatchFolderService.WatchFolderRun? run = null; + service.FileProcessed += (_, item) => + { + processed.Enqueue(item); + if (processed.Count >= 2) + { + twoItemsProcessed.TrySetResult(true); + } + }; + + try + { + service.Start( + CreateOptions(watchPath, outputPath), + (request, ct) => + { + ct.ThrowIfCancellationRequested(); + calls.Enqueue(Path.GetFileName(request.FilePath)); + return Task.FromResult(CreateResult(request)); + } + ); + run = service.CurrentRun; + Assert.NotNull(run); + + await twoItemsProcessed.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.True(service.IsRunning); + Assert.Equal(["a-first.wav", "b-second.wav"], calls); + Assert.All(processed, item => Assert.True(item.Success, item.ErrorMessage)); + var primaryFingerprints = ReadFingerprints(primaryPath); + var backupFingerprints = ReadFingerprints(backupPath); + Assert.Equal(2, primaryFingerprints.Count); + Assert.Single(backupFingerprints); + Assert.All( + backupFingerprints, + fingerprint => Assert.Contains(fingerprint, primaryFingerprints) + ); + } + finally + { + if (service.CurrentRun is not null) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (run is not null) + { + await run.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + + [Fact] + public async Task ProcessFile_WhenFingerprintCommitFails_RecordsFailureAndRollsBackInMemoryFingerprint() + { + var watchPath = Path.Join(_tempDir, "commit-failure-watch"); + var outputPath = Path.Join(_tempDir, "commit-failure-output"); + var dataPath = Path.Join(_tempDir, "commit-failure-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + var sourcePath = Path.Join(watchPath, "retained.wav"); + File.WriteAllBytes(sourcePath, [1, 2, 3]); + + var firstFailure = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var secondFailure = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var calls = new ConcurrentQueue(); + var failures = new ConcurrentQueue(); + var service = new WatchFolderService( + dataPath, + static (workers, timeout) => workers.WaitAsync(timeout), + (_, _) => throw new IOException("Simulated fingerprint atomic-write failure.") + ); + WatchFolderService.WatchFolderRun? firstRun = null; + WatchFolderService.WatchFolderRun? secondRun = null; + service.FileProcessed += (_, item) => + { + if (item.Success) + { + return; + } + + failures.Enqueue(item); + if (failures.Count == 1) + { + firstFailure.TrySetResult(item); + } + else if (failures.Count == 2) + { + secondFailure.TrySetResult(item); + } + }; + + Task TranscribeAndCountAsync( + WatchFolderTranscriptionRequest request, + CancellationToken ct + ) + { + ct.ThrowIfCancellationRequested(); + calls.Enqueue(Path.GetFileName(request.FilePath)); + return Task.FromResult(CreateResult(request)); + } + + try + { + service.Start( + CreateOptions(watchPath, outputPath), + TranscribeAndCountAsync + ); + firstRun = service.CurrentRun; + Assert.NotNull(firstRun); + + var firstItem = await firstFailure.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.False(firstItem.Success); + Assert.Contains( + "persist watch folder processed fingerprints", + firstItem.ErrorMessage, + StringComparison.OrdinalIgnoreCase + ); + Assert.Single(firstRun.FailedFingerprints); + Assert.False( + File.Exists(Path.Join(dataPath, "watch-folder-processed.json")) + ); + + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + await firstRun.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + + service.Start( + CreateOptions(watchPath, outputPath), + TranscribeAndCountAsync + ); + secondRun = service.CurrentRun; + Assert.NotNull(secondRun); + + var secondItem = await secondFailure.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.False(secondItem.Success); + Assert.Equal(["retained.wav", "retained.wav"], calls); + Assert.Equal(2, failures.Count); + Assert.Single(secondRun.FailedFingerprints); + Assert.All(service.History, item => Assert.False(item.Success)); + Assert.False( + File.Exists(Path.Join(dataPath, "watch-folder-processed.json")) + ); + } + finally + { + if (service.CurrentRun is not null) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (firstRun is not null) + { + await firstRun.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (secondRun is not null) + { + await secondRun.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + + [Fact] + public async Task Restart_AfterTruncatedPrimaryWrite_RecoversBackupAndPublishesCompleteStore() + { + var watchPath = Path.Join(_tempDir, "torn-write-watch"); + var outputPath = Path.Join(_tempDir, "torn-write-output"); + var dataPath = Path.Join(_tempDir, "torn-write-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + Directory.CreateDirectory(dataPath); + var retainedPath = Path.Join(watchPath, "a-retained.wav"); + var freshPath = Path.Join(watchPath, "z-fresh.wav"); + File.WriteAllBytes(retainedPath, [1, 2, 3]); + File.WriteAllBytes(freshPath, [4, 5, 6]); + + var retainedFingerprint = CreateTestFingerprint(retainedPath); + var primaryPath = Path.Join(dataPath, "watch-folder-processed.json"); + var backupPath = primaryPath + ".bak"; + File.WriteAllText( + backupPath, + JsonSerializer.Serialize(new[] { retainedFingerprint }) + ); + var completePrimary = JsonSerializer.Serialize( + new[] { retainedFingerprint, "newer-generation-fingerprint" } + ); + File.WriteAllText(primaryPath, completePrimary[..(completePrimary.Length / 2)]); + + var freshProcessed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var calls = new ConcurrentQueue(); + var service = new WatchFolderService(dataPath); + WatchFolderService.WatchFolderRun? run = null; + service.FileProcessed += (_, item) => + { + if (string.Equals(item.FileName, "z-fresh.wav", StringComparison.Ordinal)) + { + freshProcessed.TrySetResult(item); + } + }; + + try + { + service.Start( + CreateOptions(watchPath, outputPath), + (request, ct) => + { + ct.ThrowIfCancellationRequested(); + calls.Enqueue(Path.GetFileName(request.FilePath)); + return Task.FromResult(CreateResult(request)); + } + ); + run = service.CurrentRun; + Assert.NotNull(run); + + var item = await freshProcessed.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.True(item.Success, item.ErrorMessage); + Assert.Equal(["z-fresh.wav"], calls); + var primaryFingerprints = ReadFingerprints(primaryPath); + Assert.Equal(2, primaryFingerprints.Count); + Assert.Contains(retainedFingerprint, primaryFingerprints); + Assert.Contains(CreateTestFingerprint(freshPath), primaryFingerprints); + Assert.Equal(retainedFingerprint, Assert.Single(ReadFingerprints(backupPath))); + Assert.Empty(Directory.EnumerateFiles(dataPath, "*.tmp")); + } + finally + { + if (service.CurrentRun is not null) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (run is not null) + { + await run.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + [Fact] public async Task Start_WhenSourceBasenamesCollide_CommitsDistinctExportsBeforeDeletingSources() { @@ -1104,4 +1486,16 @@ WatchFolderTranscriptionRequest request "test" ); } + + private static string CreateTestFingerprint(string path) + { + var info = new FileInfo(path); + return $"{Path.GetFullPath(path)}|{info.Length}|{info.LastWriteTimeUtc.Ticks}"; + } + + private static HashSet ReadFingerprints(string path) + { + return JsonSerializer.Deserialize>(File.ReadAllText(path)) + ?? throw new JsonException("Fingerprint test fixture contained JSON null."); + } } From 16f4214d106345df299e9dfa6539add5c38bd1d1 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 14:02:30 +0000 Subject: [PATCH 145/226] Marshal plugin capability refreshes to the UI thread and coalesce them Plugin capability notifications reach the host synchronously on whatever thread the plugin used - Gemma Local, for one, notifies from a Task.Run worker after ConfigureAwait(false). AdvancedSectionViewModel handled those events by raising properties and clearing/repopulating its bound ObservableCollections directly, so routine plugin work could deliver Avalonia collection-change notifications from a pool thread: cross-thread exceptions or inconsistent selector state. Both event handlers now funnel into one refresh path that increments an Interlocked generation and posts a single complete refresh (property raises, provider-collection rebuild, memory-toggle correction) through an injectable posting seam defaulting to Dispatcher.UIThread.Post. Stale generations no-op, and the applied refresh reads plugin state at apply time, so bursts of notifications collapse into one refresh of the latest state. PluginManager's capability callback no longer raises PluginStateChanged twice per change (RebuildCapabilityIndices already raises it), and the PluginStateChanged / NotifyCapabilitiesChanged contracts are documented as thread-agnostic - subscribers marshal. --- .../Services/Plugins/PluginManager.cs | 10 +- .../Sections/AdvancedSectionViewModel.cs | 38 +- .../IPluginHostServices.cs | 4 +- .../AdvancedSectionViewModelTests.cs | 342 ++++++++++++++++++ 4 files changed, 384 insertions(+), 10 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs index bd764f900..e2e0780cf 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs @@ -673,6 +673,10 @@ public async Task RefreshProviderModelsAsync() } } + /// + /// Raised when the active plugins or their capabilities change. This event may be raised + /// on any thread; UI subscribers are responsible for marshalling to the UI thread. + /// public event EventHandler? PluginStateChanged; private async Task ActivatePluginAsync(LoadedPlugin plugin) @@ -686,11 +690,7 @@ private async Task ActivatePluginAsync(LoadedPlugin plugin) EventBus, _profiles, _settings, - () => - { - RebuildCapabilityIndices(); - PluginStateChanged?.Invoke(this, EventArgs.Empty); - }, + RebuildCapabilityIndices, _errorLog, ResolveErrorCategory(plugin), plugin.Manifest.Name, diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs index 67f40a117..294d27365 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs @@ -1,3 +1,4 @@ +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using System.Collections.ObjectModel; using TypeWhisper.Core.Interfaces; @@ -12,8 +13,10 @@ namespace TypeWhisper.Linux.ViewModels.Sections; public partial class AdvancedSectionViewModel : ObservableObject { private readonly PluginManager _pluginManager; + private readonly Action _post; private readonly ISettingsService _settings; private readonly SpeechFeedbackService _speechFeedback; + private long _refreshGeneration; [ObservableProperty] private bool _captureLlmProvenance; @@ -43,17 +46,44 @@ public AdvancedSectionViewModel( ISettingsService settings, SpeechFeedbackService speechFeedback, PluginManager pluginManager + ) + : this( + settings, + speechFeedback, + pluginManager, + action => Dispatcher.UIThread.Post(action) + ) + { + } + + internal AdvancedSectionViewModel( + ISettingsService settings, + SpeechFeedbackService speechFeedback, + PluginManager pluginManager, + Action post ) { _settings = settings; _speechFeedback = speechFeedback; _pluginManager = pluginManager; - _speechFeedback.ProvidersChanged += (_, _) => RefreshSpokenFeedbackProviders(); + _post = post; + _speechFeedback.ProvidersChanged += (_, _) => PostPluginStateRefresh(); Refresh(settings.Current); RefreshSpokenFeedbackProviders(); _settings.SettingsChanged += Refresh; - _pluginManager.PluginStateChanged += (_, _) => + _pluginManager.PluginStateChanged += (_, _) => PostPluginStateRefresh(); + } + + private void PostPluginStateRefresh() + { + var generation = Interlocked.Increment(ref _refreshGeneration); + _post(() => { + if (generation != Interlocked.Read(ref _refreshGeneration)) + { + return; + } + OnPropertyChanged(nameof(CanUseMemory)); OnPropertyChanged(nameof(ShowMemoryUnavailableReason)); OnPropertyChanged(nameof(MemoryHint)); @@ -65,7 +95,7 @@ PluginManager pluginManager { MemoryEnabled = false; } - }; + }); } public ObservableCollection SpokenFeedbackProviders { get; } = []; @@ -384,4 +414,4 @@ public sealed record HistoryRetentionOption( HistoryRetentionMode Mode, int? Minutes, string DisplayName -); \ No newline at end of file +); diff --git a/src/TypeWhisper.PluginSDK/IPluginHostServices.cs b/src/TypeWhisper.PluginSDK/IPluginHostServices.cs index b02429bc5..6a8d9d0da 100644 --- a/src/TypeWhisper.PluginSDK/IPluginHostServices.cs +++ b/src/TypeWhisper.PluginSDK/IPluginHostServices.cs @@ -78,7 +78,9 @@ public interface IPluginHostServices /// /// Notifies the host that the plugin's capabilities have changed (e.g. new models available). - /// The host will rebuild its capability indices and update the UI accordingly. + /// The host will rebuild its capability indices and update the UI accordingly. This method + /// may notify host subscribers synchronously on the calling thread; subscribers are + /// responsible for marshalling UI work to the UI thread. /// // ReSharper disable once UnusedMemberInSuper.Global void NotifyCapabilitiesChanged(); diff --git a/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs new file mode 100644 index 000000000..d9015decb --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs @@ -0,0 +1,342 @@ +using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Plugins; +using TypeWhisper.Linux.ViewModels.Sections; +using TypeWhisper.PluginSDK; +using TypeWhisper.PluginSDK.Models; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class AdvancedSectionViewModelTests +{ + [Fact] + public async Task CapabilityEvent_OnWorkerThread_DefersCompleteBoundStateRefresh() + { + using var harness = await TestHarness.CreateAsync(); + var providerMutations = 0; + var voiceMutations = 0; + var propertyNames = new List(); + // ReSharper disable AccessToModifiedClosure -- deliberate shared counters incremented from the handler and read via Volatile.Read after the worker completes. + harness.ViewModel.SpokenFeedbackProviders.CollectionChanged += (_, _) => + Interlocked.Increment(ref providerMutations); + harness.ViewModel.SpokenFeedbackVoices.CollectionChanged += (_, _) => + Interlocked.Increment(ref voiceMutations); + // ReSharper restore AccessToModifiedClosure + harness.ViewModel.PropertyChanged += (_, args) => propertyNames.Add(args.PropertyName); + + await Task.Run(() => + { + harness.Plugin.SetState("Worker provider", "worker-voice", "Worker voice"); + harness.Plugin.NotifyCapabilitiesChanged(); + }); + + Assert.Equal(2, harness.PostedActions.Count); + Assert.Equal(0, Volatile.Read(ref providerMutations)); + Assert.Equal(0, Volatile.Read(ref voiceMutations)); + Assert.Empty(propertyNames); + Assert.Equal( + "Before provider", + GetPluginProvider(harness.ViewModel).DisplayName + ); + Assert.Equal( + ["before-voice"], + GetPluginVoices(harness.ViewModel).Select(voice => voice.Id) + ); + + foreach (var action in harness.PostedActions) + { + action(); + } + + Assert.True(Volatile.Read(ref providerMutations) > 0); + Assert.True(Volatile.Read(ref voiceMutations) > 0); + Assert.Equal( + "Worker provider", + GetPluginProvider(harness.ViewModel).DisplayName + ); + Assert.Equal( + ["worker-voice"], + GetPluginVoices(harness.ViewModel).Select(voice => voice.Id) + ); + Assert.Contains(nameof(AdvancedSectionViewModel.CanUseMemory), propertyNames); + Assert.Contains( + nameof(AdvancedSectionViewModel.ShowMemoryUnavailableReason), + propertyNames + ); + Assert.Contains(nameof(AdvancedSectionViewModel.MemoryHint), propertyNames); + Assert.Contains(nameof(AdvancedSectionViewModel.CanUseSpokenFeedback), propertyNames); + Assert.Contains( + nameof(AdvancedSectionViewModel.ShowSpokenFeedbackUnavailableReason), + propertyNames + ); + Assert.Contains(nameof(AdvancedSectionViewModel.SpokenFeedbackHint), propertyNames); + } + + [Fact] + public async Task RapidCapabilityEvents_OnlyLatestPostedGenerationApplies() + { + using var harness = await TestHarness.CreateAsync(); + var capabilityPropertyRaises = 0; + var providerMutations = 0; + var voiceMutations = 0; + harness.ViewModel.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(AdvancedSectionViewModel.CanUseSpokenFeedback)) + { + capabilityPropertyRaises++; + } + }; + harness.ViewModel.SpokenFeedbackProviders.CollectionChanged += (_, _) => + providerMutations++; + harness.ViewModel.SpokenFeedbackVoices.CollectionChanged += (_, _) => + voiceMutations++; + + for (var generation = 1; generation <= 3; generation++) + { + harness.Plugin.SetState( + $"Provider {generation}", + $"voice-{generation}", + $"Voice {generation}" + ); + harness.Plugin.NotifyCapabilitiesChanged(); + } + + Assert.Equal(6, harness.PostedActions.Count); + foreach (var staleAction in harness.PostedActions.Take( + harness.PostedActions.Count - 1 + )) + { + staleAction(); + } + + Assert.Equal(0, capabilityPropertyRaises); + Assert.Equal(0, providerMutations); + Assert.Equal(0, voiceMutations); + Assert.Equal( + "Before provider", + GetPluginProvider(harness.ViewModel).DisplayName + ); + Assert.Equal( + ["before-voice"], + GetPluginVoices(harness.ViewModel).Select(voice => voice.Id) + ); + + harness.PostedActions[^1](); + + Assert.Equal(1, capabilityPropertyRaises); + Assert.True(providerMutations > 0); + Assert.True(voiceMutations > 0); + Assert.Equal("Provider 3", GetPluginProvider(harness.ViewModel).DisplayName); + Assert.Equal( + ["voice-3"], + GetPluginVoices(harness.ViewModel).Select(voice => voice.Id) + ); + } + + [Fact] + public async Task PostedCapabilityRefresh_ReadsPluginStateAtApplyTime() + { + using var harness = await TestHarness.CreateAsync(); + harness.Plugin.SetState("Event-time provider", "event-voice", "Event voice"); + harness.Plugin.NotifyCapabilitiesChanged(); + + Assert.Equal(2, harness.PostedActions.Count); + harness.Plugin.SetState("Apply-time provider", "apply-voice", "Apply voice"); + + foreach (var action in harness.PostedActions) + { + action(); + } + + Assert.Equal( + "Apply-time provider", + GetPluginProvider(harness.ViewModel).DisplayName + ); + var voice = Assert.Single(GetPluginVoices(harness.ViewModel)); + Assert.Equal("apply-voice", voice.Id); + Assert.Equal("Apply voice", voice.DisplayName); + } + + private static TtsProviderOption GetPluginProvider(AdvancedSectionViewModel viewModel) + { + return Assert.Single( + viewModel.SpokenFeedbackProviders, + provider => provider.Id == MutableTtsPlugin.ProviderId + ); + } + + private static IEnumerable GetPluginVoices( + AdvancedSectionViewModel viewModel + ) + { + return viewModel.SpokenFeedbackVoices.Where(voice => + voice.Id != SpeechFeedbackService.DefaultVoiceOptionId + ); + } + + private sealed class TestHarness : IDisposable + { + private TestHarness( + PluginManager pluginManager, + SpeechFeedbackService speechFeedback, + MutableTtsPlugin plugin, + AdvancedSectionViewModel viewModel, + List postedActions + ) + { + PluginManager = pluginManager; + SpeechFeedback = speechFeedback; + Plugin = plugin; + ViewModel = viewModel; + PostedActions = postedActions; + } + + private PluginManager PluginManager { get; } + private SpeechFeedbackService SpeechFeedback { get; } + public MutableTtsPlugin Plugin { get; } + public AdvancedSectionViewModel ViewModel { get; } + public List PostedActions { get; } + + public static async Task CreateAsync() + { + var settings = TestPluginManagerFactory.CreateSettings( + new AppSettings + { + SpokenFeedbackProviderId = MutableTtsPlugin.ProviderId, + SpokenFeedbackVoiceId = "before-voice", + } + ); + var plugin = new MutableTtsPlugin( + MutableTtsPlugin.ProviderId, + "Before provider", + "before-voice", + "Before voice" + ); + var pluginDirectory = Path.GetDirectoryName( + typeof(AdvancedSectionViewModelTests).Assembly.Location + )!; + var loadedPlugin = TestPluginManagerFactory.CreateLoadedPlugin( + pluginDirectory, + plugin.PluginId, + plugin + ); + var pluginManager = TestPluginManagerFactory.Create(loadedPlugins: [loadedPlugin]); + await pluginManager.EnablePluginAsync(plugin.PluginId); + + var systemProvider = new MutableTtsPlugin( + "linux-system", + "System provider", + "system-voice", + "System voice" + ); + var speechFeedback = new SpeechFeedbackService( + settings.Object, + pluginManager, + systemProvider + ); + var postedActions = new List(); + var viewModel = new AdvancedSectionViewModel( + settings.Object, + speechFeedback, + pluginManager, + postedActions.Add + ); + return new TestHarness( + pluginManager, + speechFeedback, + plugin, + viewModel, + postedActions + ); + } + + public void Dispose() + { + SpeechFeedback.Dispose(); + PluginManager.Dispose(); + } + } + + private sealed class MutableTtsPlugin : ITtsProviderPlugin + { + public const string ProviderId = "mutable-provider"; + + private IPluginHostServices? _host; + + public MutableTtsPlugin( + string providerId, + string displayName, + string voiceId, + string voiceName + ) + { + ProviderIdValue = providerId; + SetState(displayName, voiceId, voiceName); + } + + public string PluginId => $"plugin.{ProviderIdValue}"; + public string PluginName => ProviderDisplayName; + public string PluginVersion => "1.0.0"; + private string ProviderIdValue { get; } + string ITtsProviderPlugin.ProviderId => ProviderIdValue; + public string ProviderDisplayName { get; private set; } = ""; + public bool IsConfigured => true; + public IReadOnlyList AvailableVoices { get; private set; } = []; + public string? SelectedVoiceId { get; private set; } + + public Task ActivateAsync(IPluginHostServices host) + { + _host = host; + return Task.CompletedTask; + } + + public Task DeactivateAsync() + { + return Task.CompletedTask; + } + + public void SetState(string displayName, string voiceId, string voiceName) + { + ProviderDisplayName = displayName; + AvailableVoices = [new PluginVoiceInfo(voiceId, voiceName)]; + SelectedVoiceId = voiceId; + } + + public void NotifyCapabilitiesChanged() + { + Assert.NotNull(_host); + _host.NotifyCapabilitiesChanged(); + } + + public void SelectVoice(string? voiceId) + { + SelectedVoiceId = voiceId; + } + + public Task SpeakAsync( + TtsSpeakRequest request, + CancellationToken ct + ) + { + return Task.FromResult(InactivePlaybackSession.Instance); + } + + public void Dispose() { } + } + + private sealed class InactivePlaybackSession : ITtsPlaybackSession + { + public static InactivePlaybackSession Instance { get; } = new(); + + public bool IsActive => false; + + public event EventHandler? Completed + { + add { value?.Invoke(this, EventArgs.Empty); } + remove { } + } + + public void Stop() { } + } +} From ba3744e611a9c3a8fc81363f4921e02189cf8ef7 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 14:05:17 +0000 Subject: [PATCH 146/226] Stage the deferred bootstrap with per-stage boundaries and dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BootstrapAsync ran history loading, session cleanup, audio setup, bundled-plugin deployment, plugin initialization, retention setup, model migration, and model auto-load as one linear sequence guarded only by an outer catch-all - an exception in any early stage silently prevented every later stage, with nothing but debug output. Bootstrap is now a list of explicit stages executed by an internal BootstrapRunner: each stage runs inside its own exception boundary (Trace + English error-log entry), a stage whose declared dependency did not succeed is skipped and recorded as such (plugin initialization depends on bundled-plugin deployment; model auto-load on model migration; the rest are independent), and the resulting BootstrapReport (per-stage outcomes, IsDegraded, RequiredFailures) is retained on App.LastBootstrapReport for inspection. Stage order is unchanged and the all-success path is behaviorally identical. BootstrapDeferredAsync now upholds a documented invariant: the task it returns never faults, because it is awaited inside an async-void main.Opened handler and unobserved when onboarding is complete. Required failures and unexpected exceptions both produce a report instead of a fault, and failure-path diagnostics go through a SafeTrace guard so a throwing trace listener cannot break the invariant. User-visible surfacing of degraded state is deliberately left to §7 H5. --- src/TypeWhisper.Linux/App.axaml.cs | 392 ++++++++++++++++-- .../AppBootstrapTests.cs | 288 +++++++++++++ 2 files changed, 636 insertions(+), 44 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs diff --git a/src/TypeWhisper.Linux/App.axaml.cs b/src/TypeWhisper.Linux/App.axaml.cs index 3335c9ea4..ef7c4870d 100644 --- a/src/TypeWhisper.Linux/App.axaml.cs +++ b/src/TypeWhisper.Linux/App.axaml.cs @@ -40,6 +40,9 @@ public class App : Application /// private static bool ClosePermitted { get; set; } + // ReSharper disable once UnusedAutoPropertyAccessor.Global -- diagnostic seam, kept for degraded-startup inspection. + internal BootstrapReport? LastBootstrapReport { get; private set; } + public override void Initialize() { BootTrace.Stage("App.Initialize begin"); @@ -673,50 +676,132 @@ internal static void DisposeDictationBeforeAudio( } } - private static async Task BootstrapAsync(IServiceProvider services) + private static Task BootstrapAsync(IServiceProvider services) { BootTrace.Stage("BootstrapAsync begin"); - var settings = services.GetRequiredService(); - - var history = services.GetRequiredService(); - await history.EnsureLoadedAsync(); - BootTrace.Stage("history.EnsureLoadedAsync"); - - services.GetRequiredService().DeleteSessionCaptures(); - - var audio = services.GetRequiredService(); - ApplyConfiguredMicrophone(audio, settings); - BootTrace.Stage("audio configured"); - - _ = services.GetRequiredService(); - BundledPluginDeployer.DeployIfMissing(); - BootTrace.Stage("BundledPluginDeployer.DeployIfMissing"); - - var pluginManager = services.GetRequiredService(); - await pluginManager.InitializeAsync(); - BootTrace.Stage("PluginManager.InitializeAsync"); - - // PluginRegistryService targets the upstream Windows registry (Windows-built artifacts); - // the Linux fork ships its own plugins via BundledPluginDeployer, so the registry is not used. - - var historyRetention = services.GetRequiredService(); - historyRetention.Initialize(); - - var modelManager = services.GetRequiredService(); - modelManager.MigrateSettings(); + var stages = CreateBootstrapStages(services); + var errorLog = services.GetService(); + return new BootstrapRunner(stages, errorLog).RunAsync(); + } - var selectedModel = settings.Current.SelectedModelId; - if (!string.IsNullOrEmpty(selectedModel) && modelManager.IsDownloaded(selectedModel)) - { - try - { - await modelManager.LoadModelAsync(selectedModel); - } - catch (Exception ex) - { - Debug.WriteLine($"[App] Auto-load model failed: {ex.Message}"); - } - } + internal static IReadOnlyList CreateBootstrapStages( + IServiceProvider services + ) + { + return + [ + new( + BootstrapStageNames.HistoryLoad, + [], + async () => + { + var history = services.GetRequiredService(); + await history.EnsureLoadedAsync(); + BootTrace.Stage("history.EnsureLoadedAsync"); + }, + Required: false + ), + new( + BootstrapStageNames.SessionCleanup, + [], + () => + { + services + .GetRequiredService() + .DeleteSessionCaptures(); + return Task.CompletedTask; + }, + Required: false + ), + new( + BootstrapStageNames.AudioConfiguration, + [], + () => + { + var settings = services.GetRequiredService(); + var audio = services.GetRequiredService(); + ApplyConfiguredMicrophone(audio, settings); + BootTrace.Stage("audio configured"); + return Task.CompletedTask; + }, + Required: false + ), + new( + BootstrapStageNames.BundledPluginDeployment, + [], + () => + { + _ = services.GetRequiredService(); + BundledPluginDeployer.DeployIfMissing(); + BootTrace.Stage("BundledPluginDeployer.DeployIfMissing"); + return Task.CompletedTask; + }, + Required: false + ), + new( + BootstrapStageNames.PluginInitialization, + [BootstrapStageNames.BundledPluginDeployment], + async () => + { + var pluginManager = services.GetRequiredService(); + await pluginManager.InitializeAsync(); + BootTrace.Stage("PluginManager.InitializeAsync"); + }, + Required: false + ), + // PluginRegistryService targets the upstream Windows registry (Windows-built + // artifacts); the Linux fork ships its own plugins via BundledPluginDeployer, + // so the registry is not used. + new( + BootstrapStageNames.RetentionInitialization, + [], + () => + { + var historyRetention = + services.GetRequiredService(); + historyRetention.Initialize(); + return Task.CompletedTask; + }, + Required: false + ), + new( + BootstrapStageNames.ModelMigration, + [], + () => + { + var modelManager = services.GetRequiredService(); + modelManager.MigrateSettings(); + return Task.CompletedTask; + }, + Required: false + ), + new( + BootstrapStageNames.ModelAutoLoad, + [BootstrapStageNames.ModelMigration], + async () => + { + var settings = services.GetRequiredService(); + var modelManager = services.GetRequiredService(); + var selectedModel = settings.Current.SelectedModelId; + if ( + !string.IsNullOrEmpty(selectedModel) + && modelManager.IsDownloaded(selectedModel) + ) + { + try + { + await modelManager.LoadModelAsync(selectedModel); + } + catch (Exception ex) + { + Debug.WriteLine($"[App] Auto-load model failed: {ex.Message}"); + throw; + } + } + }, + Required: false + ), + ]; } /// @@ -739,15 +824,234 @@ private static async Task RunStartupUpdateCheckAsync(IServiceProvider services) } } - private static async Task BootstrapDeferredAsync(IServiceProvider services) + // Trace.WriteLine can throw (e.g. a broken-stdout ConsoleTraceListener); this runs + // inside the never-faulting deferred bootstrap path (see BootstrapDeferredAsync), so + // failures here are swallowed rather than propagated. + private static void SafeTrace(string message) + { + try + { + Trace.WriteLine(message); + } + catch + { + // Nowhere left to report to; dropping the diagnostic beats faulting the task. + } + } + + // Invariant: the returned task never faults. It is awaited inside an async-void UI + // handler (main.Opened), where a faulted task would escape into Avalonia's dispatcher + // and crash; when onboarding is complete nothing awaits it, so a fault would surface as + // an unobserved TaskScheduler exception. Every failure is captured into the report. + private async Task BootstrapDeferredAsync(IServiceProvider services) { try { - await BootstrapAsync(services); + var report = await BootstrapAsync(services); + LastBootstrapReport = report; + return report; + } + catch (RequiredBootstrapStageException ex) + { + LastBootstrapReport = ex.Report; + SafeTrace(ex.Message); + return ex.Report; } catch (Exception ex) { - Debug.WriteLine($"[App] Deferred bootstrap failed: {ex}"); + SafeTrace($"[App] Deferred bootstrap failed: {ex}"); + var report = new BootstrapReport( + [ + new BootstrapStageOutcome( + "Bootstrap", + Required: false, + BootstrapStageStatus.Failed, + ex + ), + ] + ); + LastBootstrapReport = report; + return report; + } + } + + internal static class BootstrapStageNames + { + public const string HistoryLoad = "History load"; + public const string SessionCleanup = "Session cleanup"; + public const string AudioConfiguration = "Audio configuration"; + public const string BundledPluginDeployment = "Bundled-plugin deployment"; + public const string PluginInitialization = "Plugin initialization"; + public const string RetentionInitialization = "Retention initialization"; + public const string ModelMigration = "Model migration"; + public const string ModelAutoLoad = "Model auto-load"; + } + + internal sealed record BootstrapStage( + string Name, + IReadOnlyList Dependencies, + Func Action, + bool Required + ); + + internal enum BootstrapStageStatus + { + Succeeded, + Failed, + Skipped, + } + + internal sealed record BootstrapStageOutcome( + string Name, + bool Required, + BootstrapStageStatus Status, + Exception? Exception = null, + string? SkippedDueTo = null + ); + + internal sealed class BootstrapReport + { + public BootstrapReport(IReadOnlyList outcomes) + { + Outcomes = outcomes; + } + + public IReadOnlyList Outcomes { get; } + + public bool IsDegraded => + Outcomes.Any(outcome => outcome.Status != BootstrapStageStatus.Succeeded); + + public IReadOnlyList RequiredFailures => + Outcomes + .Where(outcome => + outcome.Required && outcome.Status != BootstrapStageStatus.Succeeded + ) + .ToArray(); + } + + internal sealed class RequiredBootstrapStageException : Exception + { + public RequiredBootstrapStageException(BootstrapReport report) + : base( + $"Required bootstrap stage(s) failed: {string.Join( + ", ", + report.RequiredFailures.Select(outcome => outcome.Name) + )}" + ) + { + Report = report; + } + + public BootstrapReport Report { get; } + } + + internal sealed class BootstrapRunner + { + private readonly IErrorLogService? _errorLog; + private readonly IReadOnlyList _stages; + + public BootstrapRunner( + IReadOnlyList stages, + IErrorLogService? errorLog = null + ) + { + _stages = stages; + _errorLog = errorLog; + } + + public async Task RunAsync() + { + var outcomes = new List(_stages.Count); + var outcomesByName = new Dictionary( + StringComparer.Ordinal + ); + + foreach (var stage in _stages) + { + string? skippedDueTo = null; + foreach (var dependency in stage.Dependencies) + { + if ( + !outcomesByName.TryGetValue(dependency, out var dependencyOutcome) + || dependencyOutcome.Status != BootstrapStageStatus.Succeeded + ) + { + skippedDueTo = dependency; + break; + } + } + + BootstrapStageOutcome outcome; + if (skippedDueTo is not null) + { + outcome = new( + stage.Name, + stage.Required, + BootstrapStageStatus.Skipped, + SkippedDueTo: skippedDueTo + ); + SafeTrace( + $"[App] Bootstrap stage '{stage.Name}' skipped because dependency " + + $"'{skippedDueTo}' did not succeed." + ); + } + else + { + try + { + await stage.Action(); + outcome = new( + stage.Name, + stage.Required, + BootstrapStageStatus.Succeeded + ); + } + catch (Exception ex) + { + outcome = new( + stage.Name, + stage.Required, + BootstrapStageStatus.Failed, + Exception: ex + ); + SafeTrace($"[App] Bootstrap stage '{stage.Name}' failed: {ex}"); + TryWriteErrorLog(stage.Name, ex); + } + } + + outcomes.Add(outcome); + outcomesByName.Add(stage.Name, outcome); + } + + var report = new BootstrapReport(outcomes); + if (report.RequiredFailures.Count > 0) + { + throw new RequiredBootstrapStageException(report); + } + + return report; + } + + private void TryWriteErrorLog(string stageName, Exception exception) + { + if (_errorLog is null) + { + return; + } + + try + { + _errorLog.AddEntry( + $"Bootstrap stage '{stageName}' failed: {exception.Message}" + ); + } + catch (Exception errorLogException) + { + SafeTrace( + "[App] Could not write bootstrap failure to the error log: " + + errorLogException + ); + } } } diff --git a/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs b/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs new file mode 100644 index 000000000..45b228166 --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs @@ -0,0 +1,288 @@ +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class AppBootstrapTests +{ + public static TheoryData ProductionStageFailures => + new() + { + { App.BootstrapStageNames.HistoryLoad, null }, + { App.BootstrapStageNames.SessionCleanup, null }, + { App.BootstrapStageNames.AudioConfiguration, null }, + { + App.BootstrapStageNames.BundledPluginDeployment, + App.BootstrapStageNames.PluginInitialization + }, + { App.BootstrapStageNames.PluginInitialization, null }, + { App.BootstrapStageNames.RetentionInitialization, null }, + { + App.BootstrapStageNames.ModelMigration, + App.BootstrapStageNames.ModelAutoLoad + }, + { App.BootstrapStageNames.ModelAutoLoad, null }, + }; + + [Theory] + [MemberData(nameof(ProductionStageFailures))] + public async Task RunAsync_WhenEachProductionStageFails_RunsIndependentStagesAndSkipsDependents( + string failingStage, + string? expectedSkippedStage + ) + { + var attempted = new List(); + var failure = new InvalidOperationException($"Failure in {failingStage}"); + var stages = CreateProductionShapedStages(attempted, failingStage, failure); + + var report = await new App.BootstrapRunner(stages).RunAsync(); + + var expectedAttempted = stages + .Select(stage => stage.Name) + .Where(name => name != expectedSkippedStage); + Assert.Equal(expectedAttempted, attempted); + Assert.Equal( + expectedSkippedStage is null ? [] : [expectedSkippedStage], + report + .Outcomes.Where(outcome => + outcome.Status == App.BootstrapStageStatus.Skipped + ) + .Select(outcome => outcome.Name) + ); + + foreach (var outcome in report.Outcomes) + { + var expectedStatus = + outcome.Name == failingStage + ? App.BootstrapStageStatus.Failed + : outcome.Name == expectedSkippedStage + ? App.BootstrapStageStatus.Skipped + : App.BootstrapStageStatus.Succeeded; + Assert.Equal(expectedStatus, outcome.Status); + } + } + + [Fact] + public async Task RunAsync_NonRequiredFailure_CapturesExceptionAndDoesNotThrow() + { + var failure = new InvalidOperationException("injected failure"); + var laterStageRan = false; + var errorLog = new RecordingErrorLogService(); + App.BootstrapStage[] stages = + [ + new("Failing stage", [], () => Task.FromException(failure), Required: false), + new( + "Later stage", + [], + () => + { + laterStageRan = true; + return Task.CompletedTask; + }, + Required: false + ), + ]; + + var report = await new App.BootstrapRunner(stages, errorLog).RunAsync(); + + var failed = Assert.Single( + report.Outcomes, + outcome => outcome.Status == App.BootstrapStageStatus.Failed + ); + Assert.Equal("Failing stage", failed.Name); + Assert.Same(failure, failed.Exception); + Assert.True(laterStageRan); + Assert.True(report.IsDegraded); + Assert.Empty(report.RequiredFailures); + Assert.Equal( + [ + ( + "Bootstrap stage 'Failing stage' failed: injected failure", + ErrorCategory.General + ), + ], + errorLog.AddedEntries + ); + } + + [Fact] + public async Task RunAsync_AllProductionStagesSucceed_RunsOnceInOrderAndReportsSuccess() + { + var runOrder = new List(); + var stages = CreateProductionShapedStages(runOrder); + + var report = await new App.BootstrapRunner(stages).RunAsync(); + + Assert.Equal(stages.Select(stage => stage.Name), runOrder); + Assert.All( + stages, + stage => Assert.Equal(1, runOrder.Count(name => name == stage.Name)) + ); + Assert.Equal( + stages.Select(stage => stage.Name), + report.Outcomes.Select(outcome => outcome.Name) + ); + Assert.All( + report.Outcomes, + outcome => Assert.Equal(App.BootstrapStageStatus.Succeeded, outcome.Status) + ); + Assert.False(report.IsDegraded); + Assert.Empty(report.RequiredFailures); + } + + [Fact] + public async Task RunAsync_DependencyChainFailure_SkipsTransitiveDependentsWithReasons() + { + var runOrder = new List(); + App.BootstrapStage[] stages = + [ + new( + "A", + [], + () => + { + runOrder.Add("A"); + throw new InvalidOperationException("A failed"); + }, + Required: false + ), + new( + "B", + ["A"], + () => + { + runOrder.Add("B"); + return Task.CompletedTask; + }, + Required: false + ), + new( + "C", + ["B"], + () => + { + runOrder.Add("C"); + return Task.CompletedTask; + }, + Required: false + ), + ]; + + var report = await new App.BootstrapRunner(stages).RunAsync(); + + Assert.Equal(["A"], runOrder); + Assert.Equal(App.BootstrapStageStatus.Failed, Outcome("A").Status); + Assert.Equal(App.BootstrapStageStatus.Skipped, Outcome("B").Status); + Assert.Equal("A", Outcome("B").SkippedDueTo); + Assert.Equal(App.BootstrapStageStatus.Skipped, Outcome("C").Status); + Assert.Equal("B", Outcome("C").SkippedDueTo); + + App.BootstrapStageOutcome Outcome(string name) + { + return Assert.Single(report.Outcomes, outcome => outcome.Name == name); + } + } + + [Fact] + public async Task RunAsync_RequiredFailure_RunsIndependentStagesThenThrowsWithReport() + { + var independentStageRan = false; + App.BootstrapStage[] stages = + [ + new( + "Required stage", + [], + () => Task.FromException(new InvalidOperationException("required failure")), + Required: true + ), + new( + "Independent stage", + [], + () => + { + independentStageRan = true; + return Task.CompletedTask; + }, + Required: false + ), + ]; + var runner = new App.BootstrapRunner(stages); + + var exception = await Assert.ThrowsAsync( + runner.RunAsync + ); + + Assert.True(independentStageRan); + Assert.Equal( + ["Required stage"], + exception.Report.RequiredFailures.Select(outcome => outcome.Name) + ); + Assert.Equal(2, exception.Report.Outcomes.Count); + } + + private static App.BootstrapStage[] CreateProductionShapedStages( + List attempted, + string? failingStage = null, + Exception? failure = null + ) + { + return App.CreateBootstrapStages(new UnusedServiceProvider()) + .Select(stage => + stage + with + { + Action = () => + { + attempted.Add(stage.Name); + return stage.Name == failingStage + ? Task.FromException( + failure + ?? new InvalidOperationException( + $"Failure in {failingStage}" + ) + ) + : Task.CompletedTask; + }, + } + ) + .ToArray(); + } + + private sealed class UnusedServiceProvider : IServiceProvider + { + // ReSharper disable once ReturnTypeCanBeNotNullable -- implements IServiceProvider.GetService, whose contract is nullable. + public object? GetService(Type serviceType) + { + throw new InvalidOperationException( + $"Production action unexpectedly resolved {serviceType.Name}." + ); + } + } + + private sealed class RecordingErrorLogService : IErrorLogService + { + public List<(string Message, string Category)> AddedEntries { get; } = []; + + public IReadOnlyList Entries => []; + + public event Action? EntriesChanged; + + public void AddEntry(string message, string category = ErrorCategory.General) + { + AddedEntries.Add((message, category)); + EntriesChanged?.Invoke(); + } + + public void ClearAll() + { + AddedEntries.Clear(); + EntriesChanged?.Invoke(); + } + + public string ExportDiagnostics() + { + return string.Empty; + } + } +} From ab35ef7ad5a038f2eb906223e1521fac3638dc93 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 14:54:56 +0000 Subject: [PATCH 147/226] Keep configured TTS and memory preferences separate from availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdvancedSectionViewModel is constructed before plugin initialization, so its provider refresh saw only the system TTS provider: a configured plugin provider was replaced with linux-system and that replacement was persisted through the generated change hooks, clearing the voice. The availability clamp had the same effect on toggles - MemoryEnabled and SpokenFeedbackEnabled displayed false while capabilities were absent, never rehydrated, and a later capability loss could persist false. The posted capability refresh added by the §7 H3 fix could likewise persist a stale voice fallback from a torn read. Configured preferences now live in dedicated fields and are never overwritten by programmatic refreshes: a nested-safe RunProgrammaticRefresh guard makes refresh-driven property assignments skip the SelectVoice and settings-save hooks entirely, so only genuine user edits persist. The observable properties represent effective state - they fall back visually while a configured provider/voice/capability is unavailable and rehydrate from the configured values inside the posted refresh when capability returns. PA37 tracks the pre-existing provider-side save inside LinuxSystemTtsProvider.SelectVoice. --- .../Sections/AdvancedSectionViewModel.cs | 214 ++++++++--- .../AdvancedSectionViewModelTests.cs | 341 +++++++++++++++++- 2 files changed, 489 insertions(+), 66 deletions(-) diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs index 294d27365..feac148cc 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs @@ -16,6 +16,13 @@ public partial class AdvancedSectionViewModel : ObservableObject private readonly Action _post; private readonly ISettingsService _settings; private readonly SpeechFeedbackService _speechFeedback; + private bool _configuredMemoryEnabled; + private bool _configuredSpokenFeedbackEnabled; + private string _configuredSpokenFeedbackProviderId = + AppSettings.DefaultSpokenFeedbackProviderId; + private string? _configuredSpokenFeedbackVoiceId = + SpeechFeedbackService.DefaultVoiceOptionId; + private bool _isProgrammaticRefresh; private long _refreshGeneration; [ObservableProperty] @@ -68,8 +75,8 @@ Action post _pluginManager = pluginManager; _post = post; _speechFeedback.ProvidersChanged += (_, _) => PostPluginStateRefresh(); - Refresh(settings.Current); RefreshSpokenFeedbackProviders(); + Refresh(settings.Current); _settings.SettingsChanged += Refresh; _pluginManager.PluginStateChanged += (_, _) => PostPluginStateRefresh(); } @@ -84,17 +91,18 @@ private void PostPluginStateRefresh() return; } - OnPropertyChanged(nameof(CanUseMemory)); - OnPropertyChanged(nameof(ShowMemoryUnavailableReason)); - OnPropertyChanged(nameof(MemoryHint)); - OnPropertyChanged(nameof(CanUseSpokenFeedback)); - OnPropertyChanged(nameof(ShowSpokenFeedbackUnavailableReason)); - OnPropertyChanged(nameof(SpokenFeedbackHint)); - RefreshSpokenFeedbackProviders(); - if (!CanUseMemory && MemoryEnabled) + RunProgrammaticRefresh(() => { - MemoryEnabled = false; - } + OnPropertyChanged(nameof(CanUseMemory)); + OnPropertyChanged(nameof(ShowMemoryUnavailableReason)); + OnPropertyChanged(nameof(MemoryHint)); + OnPropertyChanged(nameof(CanUseSpokenFeedback)); + OnPropertyChanged(nameof(ShowSpokenFeedbackUnavailableReason)); + OnPropertyChanged(nameof(SpokenFeedbackHint)); + RefreshSpokenFeedbackProviders(); + MemoryEnabled = _configuredMemoryEnabled && CanUseMemory; + SpokenFeedbackEnabled = _configuredSpokenFeedbackEnabled && CanUseSpokenFeedback; + }); }); } @@ -200,40 +208,52 @@ value is null private void Refresh(AppSettings settings) { - MemoryEnabled = settings.MemoryEnabled && CanUseMemory; - SpokenFeedbackEnabled = settings.SpokenFeedbackEnabled && CanUseSpokenFeedback; - SaveToHistoryEnabled = settings.SaveToHistoryEnabled; - CaptureLlmProvenance = settings.CaptureLlmProvenance; - SelectedSpokenFeedbackProviderId = string.IsNullOrWhiteSpace( + _configuredMemoryEnabled = settings.MemoryEnabled; + _configuredSpokenFeedbackEnabled = settings.SpokenFeedbackEnabled; + _configuredSpokenFeedbackProviderId = NormalizeProviderId( settings.SpokenFeedbackProviderId - ) - ? AppSettings.DefaultSpokenFeedbackProviderId - : settings.SpokenFeedbackProviderId; - SelectedSpokenFeedbackVoiceId = - settings.SpokenFeedbackVoiceId ?? SpeechFeedbackService.DefaultVoiceOptionId; - SelectedAutoUnloadOption = - AutoUnloadOptions.FirstOrDefault(option => - option.Seconds == settings.ModelAutoUnloadSeconds - ) ?? AutoUnloadOptions[0]; - SelectedHistoryRetention = MatchRetention( - settings.HistoryRetentionMode, - settings.HistoryRetentionMinutes ); + _configuredSpokenFeedbackVoiceId = + settings.SpokenFeedbackVoiceId ?? SpeechFeedbackService.DefaultVoiceOptionId; + + RunProgrammaticRefresh(() => + { + MemoryEnabled = _configuredMemoryEnabled && CanUseMemory; + SpokenFeedbackEnabled = _configuredSpokenFeedbackEnabled && CanUseSpokenFeedback; + SaveToHistoryEnabled = settings.SaveToHistoryEnabled; + CaptureLlmProvenance = settings.CaptureLlmProvenance; + ApplyEffectiveSpokenFeedbackPreference(); + SelectedAutoUnloadOption = + AutoUnloadOptions.FirstOrDefault(option => + option.Seconds == settings.ModelAutoUnloadSeconds + ) ?? AutoUnloadOptions[0]; + SelectedHistoryRetention = MatchRetention( + settings.HistoryRetentionMode, + settings.HistoryRetentionMinutes + ); + }); } partial void OnMemoryEnabledChanged(bool value) { + if (_isProgrammaticRefresh) + { + return; + } + if (_settings.Current.MemoryEnabled == value) { + _configuredMemoryEnabled = value; return; } if (value && !CanUseMemory) { - MemoryEnabled = false; + RunProgrammaticRefresh(() => MemoryEnabled = false); return; } + _configuredMemoryEnabled = value; _settings.Save(_settings.Current with { MemoryEnabled = value }); } @@ -249,56 +269,77 @@ partial void OnSelectedAutoUnloadOptionChanged(AutoUnloadOption? value) partial void OnSpokenFeedbackEnabledChanged(bool value) { + if (_isProgrammaticRefresh) + { + return; + } + if (_settings.Current.SpokenFeedbackEnabled == value) { + _configuredSpokenFeedbackEnabled = value; return; } if (value && !CanUseSpokenFeedback) { - SpokenFeedbackEnabled = false; + RunProgrammaticRefresh(() => SpokenFeedbackEnabled = false); return; } + _configuredSpokenFeedbackEnabled = value; _settings.Save(_settings.Current with { SpokenFeedbackEnabled = value }); } partial void OnSelectedSpokenFeedbackProviderIdChanged(string value) { - if (string.IsNullOrWhiteSpace(value)) + RefreshSpokenFeedbackVoices(); + OnPropertyChanged(nameof(SelectedSpokenFeedbackProviderOption)); + + if (_isProgrammaticRefresh) { - value = AppSettings.DefaultSpokenFeedbackProviderId; + return; } - RefreshSpokenFeedbackVoices(); + value = NormalizeProviderId(value); + _configuredSpokenFeedbackProviderId = value; + _configuredSpokenFeedbackVoiceId = + SelectedSpokenFeedbackVoiceId ?? SpeechFeedbackService.DefaultVoiceOptionId; + _speechFeedback.SelectVoice(value, _configuredSpokenFeedbackVoiceId); - if (_settings.Current.SpokenFeedbackProviderId == value) + var selectedVoiceId = NormalizeVoiceIdForSettings( + _configuredSpokenFeedbackVoiceId + ); + if ( + _settings.Current.SpokenFeedbackProviderId == value + && _settings.Current.SpokenFeedbackVoiceId == selectedVoiceId + ) { return; } - var selectedVoiceId = SpeechFeedbackService.IsDefaultVoiceOptionId( - SelectedSpokenFeedbackVoiceId - ) - ? null - : SelectedSpokenFeedbackVoiceId; _settings.Save( _settings.Current with { SpokenFeedbackProviderId = value, SpokenFeedbackVoiceId = selectedVoiceId } ); - OnPropertyChanged(nameof(SelectedSpokenFeedbackProviderOption)); } partial void OnSelectedSpokenFeedbackVoiceIdChanged(string? value) { + OnPropertyChanged(nameof(SelectedSpokenFeedbackVoiceOption)); + if (_isProgrammaticRefresh) + { + return; + } + + _configuredSpokenFeedbackVoiceId = + value ?? SpeechFeedbackService.DefaultVoiceOptionId; _speechFeedback.SelectVoice(SelectedSpokenFeedbackProviderId, value); - var normalized = SpeechFeedbackService.IsDefaultVoiceOptionId(value) ? null : value; + var normalized = NormalizeVoiceIdForSettings(value); if (_settings.Current.SpokenFeedbackVoiceId == normalized) { return; } _settings.Save(_settings.Current with { SpokenFeedbackVoiceId = normalized }); - OnPropertyChanged(nameof(SelectedSpokenFeedbackVoiceOption)); } partial void OnSaveToHistoryEnabledChanged(bool value) @@ -367,29 +408,86 @@ private HistoryRetentionOption MatchRetention(HistoryRetentionMode mode, int min private void RefreshSpokenFeedbackProviders() { - ReplaceCollection(SpokenFeedbackProviders, _speechFeedback.AvailableProviders); - if ( - SpokenFeedbackProviders.All(provider => provider.Id != SelectedSpokenFeedbackProviderId) - ) + RunProgrammaticRefresh(() => { - SelectedSpokenFeedbackProviderId = AppSettings.DefaultSpokenFeedbackProviderId; - } + ReplaceCollection(SpokenFeedbackProviders, _speechFeedback.AvailableProviders); + ApplyEffectiveSpokenFeedbackPreference(); + }); + } + private void RefreshSpokenFeedbackVoices() + { + RunProgrammaticRefresh(() => + { + ReplaceCollection( + SpokenFeedbackVoices, + _speechFeedback.GetVoiceOptions(SelectedSpokenFeedbackProviderId) + ); + var preferredVoiceId = + string.Equals( + SelectedSpokenFeedbackProviderId, + _configuredSpokenFeedbackProviderId, + StringComparison.Ordinal + ) + ? _configuredSpokenFeedbackVoiceId + : _speechFeedback.GetSelectedVoiceId(SelectedSpokenFeedbackProviderId); + var selectedVoiceId = SpokenFeedbackVoices.Any(voice => + voice.Id == preferredVoiceId + ) + ? preferredVoiceId + : SpeechFeedbackService.DefaultVoiceOptionId; + SelectedSpokenFeedbackVoiceId = selectedVoiceId; + OnPropertyChanged(nameof(SelectedSpokenFeedbackVoiceOption)); + }); + } + + private void ApplyEffectiveSpokenFeedbackPreference() + { + SelectedSpokenFeedbackProviderId = + SpokenFeedbackProviders.FirstOrDefault(provider => + string.Equals( + provider.Id, + _configuredSpokenFeedbackProviderId, + StringComparison.Ordinal + ) + )?.Id + ?? SpokenFeedbackProviders.FirstOrDefault(provider => + string.Equals( + provider.Id, + AppSettings.DefaultSpokenFeedbackProviderId, + StringComparison.Ordinal + ) + )?.Id + ?? SpokenFeedbackProviders.FirstOrDefault()?.Id + ?? AppSettings.DefaultSpokenFeedbackProviderId; RefreshSpokenFeedbackVoices(); OnPropertyChanged(nameof(SelectedSpokenFeedbackProviderOption)); } - private void RefreshSpokenFeedbackVoices() + private static string NormalizeProviderId(string? providerId) { - ReplaceCollection( - SpokenFeedbackVoices, - _speechFeedback.GetVoiceOptions(SelectedSpokenFeedbackProviderId) - ); - var selected = _speechFeedback.GetSelectedVoiceId(SelectedSpokenFeedbackProviderId); - SelectedSpokenFeedbackVoiceId = SpokenFeedbackVoices.Any(voice => voice.Id == selected) - ? selected - : SpeechFeedbackService.DefaultVoiceOptionId; - OnPropertyChanged(nameof(SelectedSpokenFeedbackVoiceOption)); + return string.IsNullOrWhiteSpace(providerId) + ? AppSettings.DefaultSpokenFeedbackProviderId + : providerId; + } + + private static string? NormalizeVoiceIdForSettings(string? voiceId) + { + return SpeechFeedbackService.IsDefaultVoiceOptionId(voiceId) ? null : voiceId; + } + + private void RunProgrammaticRefresh(Action refresh) + { + var wasProgrammaticRefresh = _isProgrammaticRefresh; + _isProgrammaticRefresh = true; + try + { + refresh(); + } + finally + { + _isProgrammaticRefresh = wasProgrammaticRefresh; + } } private static void ReplaceCollection(ObservableCollection target, IEnumerable items) diff --git a/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs index d9015decb..e02ad1299 100644 --- a/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs @@ -1,3 +1,5 @@ +using Moq; +using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; using TypeWhisper.Linux.Services; using TypeWhisper.Linux.Services.Plugins; @@ -158,6 +160,226 @@ public async Task PostedCapabilityRefresh_ReadsPluginStateAtApplyTime() Assert.Equal("Apply voice", voice.DisplayName); } + [Fact] + public async Task StartupBeforePlugins_RetainsConfiguredTtsPreferenceAndRehydratesWithoutSaving() + { + using var harness = await TestHarness.CreateAsync(enablePluginBeforeViewModel: false); + + Assert.Equal( + MutableTtsPlugin.ProviderId, + harness.Settings.Object.Current.SpokenFeedbackProviderId + ); + Assert.Equal("before-voice", harness.Settings.Object.Current.SpokenFeedbackVoiceId); + Assert.Equal( + AppSettings.DefaultSpokenFeedbackProviderId, + harness.ViewModel.SelectedSpokenFeedbackProviderId + ); + Assert.Equal( + AppSettings.DefaultSpokenFeedbackProviderId, + harness.ViewModel.SelectedSpokenFeedbackProviderOption?.Id + ); + harness.Settings.Verify( + service => service.Save(It.IsAny()), + Times.Never + ); + + await harness.PluginManager.EnablePluginAsync(harness.Plugin.PluginId); + harness.ApplyPostedActions(); + + Assert.Equal( + MutableTtsPlugin.ProviderId, + harness.ViewModel.SelectedSpokenFeedbackProviderId + ); + Assert.Equal( + MutableTtsPlugin.ProviderId, + harness.ViewModel.SelectedSpokenFeedbackProviderOption?.Id + ); + Assert.Equal("before-voice", harness.ViewModel.SelectedSpokenFeedbackVoiceId); + Assert.Equal("before-voice", harness.ViewModel.SelectedSpokenFeedbackVoiceOption?.Id); + Assert.Equal( + MutableTtsPlugin.ProviderId, + harness.Settings.Object.Current.SpokenFeedbackProviderId + ); + Assert.Equal("before-voice", harness.Settings.Object.Current.SpokenFeedbackVoiceId); + harness.Settings.Verify( + service => service.Save(It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task MemoryCapabilityChanges_PreserveConfiguredEnabledPreferenceWithoutSaving() + { + var settings = TestPluginManagerFactory.CreateSettings( + new AppSettings { MemoryEnabled = true } + ); + var plugin = new MutableMemoryLlmPlugin(); + var pluginDirectory = Path.GetDirectoryName( + typeof(AdvancedSectionViewModelTests).Assembly.Location + )!; + var loadedPlugin = TestPluginManagerFactory.CreateLoadedPlugin( + pluginDirectory, + plugin.PluginId, + plugin + ); + using var pluginManager = TestPluginManagerFactory.Create( + loadedPlugins: [loadedPlugin] + ); + var systemProvider = new MutableTtsPlugin( + "linux-system", + "System provider", + "system-voice", + "System voice" + ); + using var speechFeedback = new SpeechFeedbackService( + settings.Object, + pluginManager, + systemProvider + ); + var postedActions = new List(); + var viewModel = new AdvancedSectionViewModel( + settings.Object, + speechFeedback, + pluginManager, + postedActions.Add + ); + + Assert.False(viewModel.CanUseMemory); + Assert.False(viewModel.MemoryEnabled); + Assert.True(settings.Object.Current.MemoryEnabled); + settings.Verify(service => service.Save(It.IsAny()), Times.Never); + + await pluginManager.EnablePluginAsync(plugin.PluginId); + ApplyPostedActions(postedActions); + + Assert.True(viewModel.CanUseMemory); + Assert.True(viewModel.MemoryEnabled); + Assert.True(settings.Object.Current.MemoryEnabled); + settings.Verify(service => service.Save(It.IsAny()), Times.Never); + + await pluginManager.DisablePluginAsync(plugin.PluginId); + ApplyPostedActions(postedActions); + + Assert.False(viewModel.CanUseMemory); + Assert.False(viewModel.MemoryEnabled); + Assert.True(settings.Object.Current.MemoryEnabled); + settings.Verify(service => service.Save(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SpokenFeedbackCapabilityChanges_PreserveConfiguredEnabledPreferenceWithoutSaving() + { + var settings = TestPluginManagerFactory.CreateSettings( + new AppSettings + { + SpokenFeedbackEnabled = true, + SpokenFeedbackProviderId = "linux-system", + } + ); + var auxPlugin = new MutableTtsPlugin( + "aux-provider", + "Aux provider", + "aux-voice", + "Aux voice" + ); + var pluginDirectory = Path.GetDirectoryName( + typeof(AdvancedSectionViewModelTests).Assembly.Location + )!; + var loadedPlugin = TestPluginManagerFactory.CreateLoadedPlugin( + pluginDirectory, + auxPlugin.PluginId, + auxPlugin + ); + using var pluginManager = TestPluginManagerFactory.Create( + loadedPlugins: [loadedPlugin] + ); + var systemProvider = new MutableTtsPlugin( + "linux-system", + "System provider", + "system-voice", + "System voice" + ); + using var speechFeedback = new SpeechFeedbackService( + settings.Object, + pluginManager, + systemProvider + ); + var postedActions = new List(); + var viewModel = new AdvancedSectionViewModel( + settings.Object, + speechFeedback, + pluginManager, + postedActions.Add + ); + + Assert.True(viewModel.CanUseSpokenFeedback); + Assert.True(viewModel.SpokenFeedbackEnabled); + Assert.True(settings.Object.Current.SpokenFeedbackEnabled); + settings.Verify(service => service.Save(It.IsAny()), Times.Never); + + systemProvider.IsConfigured = false; + await pluginManager.EnablePluginAsync(auxPlugin.PluginId); + ApplyPostedActions(postedActions); + + Assert.False(viewModel.CanUseSpokenFeedback); + Assert.False(viewModel.SpokenFeedbackEnabled); + Assert.True(settings.Object.Current.SpokenFeedbackEnabled); + settings.Verify(service => service.Save(It.IsAny()), Times.Never); + + systemProvider.IsConfigured = true; + await pluginManager.DisablePluginAsync(auxPlugin.PluginId); + ApplyPostedActions(postedActions); + + Assert.True(viewModel.CanUseSpokenFeedback); + Assert.True(viewModel.SpokenFeedbackEnabled); + Assert.True(settings.Object.Current.SpokenFeedbackEnabled); + settings.Verify(service => service.Save(It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProgrammaticVoiceFallback_DoesNotSelectOrSave_UserVoiceEditDoes() + { + using var harness = await TestHarness.CreateAsync(); + harness.Settings.Invocations.Clear(); + harness.Plugin.ResetSelectVoiceCalls(); + harness.Plugin.SetState( + "Fallback provider", + "available-voice", + "Available voice", + selectedVoiceId: "missing-voice" + ); + + harness.Plugin.NotifyCapabilitiesChanged(); + harness.ApplyPostedActions(); + + Assert.Equal( + SpeechFeedbackService.DefaultVoiceOptionId, + harness.ViewModel.SelectedSpokenFeedbackVoiceId + ); + Assert.Equal(0, harness.Plugin.SelectVoiceCallCount); + Assert.Equal("before-voice", harness.Settings.Object.Current.SpokenFeedbackVoiceId); + harness.Settings.Verify( + service => service.Save(It.IsAny()), + Times.Never + ); + + harness.ViewModel.SelectedSpokenFeedbackVoiceOption = Assert.Single( + harness.ViewModel.SpokenFeedbackVoices, + voice => voice.Id == "available-voice" + ); + + Assert.Equal(1, harness.Plugin.SelectVoiceCallCount); + Assert.Equal("available-voice", harness.Plugin.SelectedVoiceId); + Assert.Equal( + "available-voice", + harness.Settings.Object.Current.SpokenFeedbackVoiceId + ); + harness.Settings.Verify( + service => service.Save(It.IsAny()), + Times.Once + ); + } + private static TtsProviderOption GetPluginProvider(AdvancedSectionViewModel viewModel) { return Assert.Single( @@ -175,9 +397,20 @@ AdvancedSectionViewModel viewModel ); } + private static void ApplyPostedActions(List postedActions) + { + var actions = postedActions.ToList(); + postedActions.Clear(); + foreach (var action in actions) + { + action(); + } + } + private sealed class TestHarness : IDisposable { private TestHarness( + Mock settings, PluginManager pluginManager, SpeechFeedbackService speechFeedback, MutableTtsPlugin plugin, @@ -185,6 +418,7 @@ private TestHarness( List postedActions ) { + Settings = settings; PluginManager = pluginManager; SpeechFeedback = speechFeedback; Plugin = plugin; @@ -192,13 +426,16 @@ List postedActions PostedActions = postedActions; } - private PluginManager PluginManager { get; } + public Mock Settings { get; } + public PluginManager PluginManager { get; } private SpeechFeedbackService SpeechFeedback { get; } public MutableTtsPlugin Plugin { get; } public AdvancedSectionViewModel ViewModel { get; } public List PostedActions { get; } - public static async Task CreateAsync() + public static async Task CreateAsync( + bool enablePluginBeforeViewModel = true + ) { var settings = TestPluginManagerFactory.CreateSettings( new AppSettings @@ -222,7 +459,10 @@ public static async Task CreateAsync() plugin ); var pluginManager = TestPluginManagerFactory.Create(loadedPlugins: [loadedPlugin]); - await pluginManager.EnablePluginAsync(plugin.PluginId); + if (enablePluginBeforeViewModel) + { + await pluginManager.EnablePluginAsync(plugin.PluginId); + } var systemProvider = new MutableTtsPlugin( "linux-system", @@ -243,6 +483,7 @@ public static async Task CreateAsync() postedActions.Add ); return new TestHarness( + settings, pluginManager, speechFeedback, plugin, @@ -251,6 +492,11 @@ public static async Task CreateAsync() ); } + public void ApplyPostedActions() + { + AdvancedSectionViewModelTests.ApplyPostedActions(PostedActions); + } + public void Dispose() { SpeechFeedback.Dispose(); @@ -268,11 +514,12 @@ public MutableTtsPlugin( string providerId, string displayName, string voiceId, - string voiceName + string voiceName, + string? selectedVoiceId = null ) { ProviderIdValue = providerId; - SetState(displayName, voiceId, voiceName); + SetState(displayName, voiceId, voiceName, selectedVoiceId); } public string PluginId => $"plugin.{ProviderIdValue}"; @@ -281,9 +528,10 @@ string voiceName private string ProviderIdValue { get; } string ITtsProviderPlugin.ProviderId => ProviderIdValue; public string ProviderDisplayName { get; private set; } = ""; - public bool IsConfigured => true; + public bool IsConfigured { get; set; } = true; public IReadOnlyList AvailableVoices { get; private set; } = []; public string? SelectedVoiceId { get; private set; } + public int SelectVoiceCallCount { get; private set; } public Task ActivateAsync(IPluginHostServices host) { @@ -296,11 +544,16 @@ public Task DeactivateAsync() return Task.CompletedTask; } - public void SetState(string displayName, string voiceId, string voiceName) + public void SetState( + string displayName, + string voiceId, + string voiceName, + string? selectedVoiceId = null + ) { ProviderDisplayName = displayName; AvailableVoices = [new PluginVoiceInfo(voiceId, voiceName)]; - SelectedVoiceId = voiceId; + SelectedVoiceId = selectedVoiceId ?? voiceId; } public void NotifyCapabilitiesChanged() @@ -311,9 +564,15 @@ public void NotifyCapabilitiesChanged() public void SelectVoice(string? voiceId) { + SelectVoiceCallCount++; SelectedVoiceId = voiceId; } + public void ResetSelectVoiceCalls() + { + SelectVoiceCallCount = 0; + } + public Task SpeakAsync( TtsSpeakRequest request, CancellationToken ct @@ -325,6 +584,72 @@ CancellationToken ct public void Dispose() { } } + private sealed class MutableMemoryLlmPlugin : IMemoryStoragePlugin, ILlmProviderPlugin + { + public string PluginId => "plugin.mutable-memory-llm"; + public string PluginName => "Mutable memory and LLM"; + public string PluginVersion => "1.0.0"; + public string ProviderName => "Mutable LLM"; + public bool IsAvailable => true; + public IReadOnlyList SupportedModels { get; } = []; + + public Task ActivateAsync(IPluginHostServices host) + { + return Task.CompletedTask; + } + + public Task DeactivateAsync() + { + return Task.CompletedTask; + } + + public Task ProcessAsync( + string systemPrompt, + string userText, + string model, + CancellationToken ct + ) + { + return Task.FromResult(""); + } + + public Task StoreAsync(string content, CancellationToken ct = default) + { + return Task.CompletedTask; + } + + public Task> SearchAsync( + string query, + int maxResults = 5, + CancellationToken ct = default + ) + { + return Task.FromResult>([]); + } + + public Task> GetAllAsync(CancellationToken ct = default) + { + return Task.FromResult>([]); + } + + public Task DeleteAsync(string content, CancellationToken ct = default) + { + return Task.CompletedTask; + } + + public Task ClearAllAsync(CancellationToken ct = default) + { + return Task.CompletedTask; + } + + public Task CountAsync(CancellationToken ct = default) + { + return Task.FromResult(0); + } + + public void Dispose() { } + } + private sealed class InactivePlaybackSession : ITtsPlaybackSession { public static InactivePlaybackSession Instance { get; } = new(); From ad73752ace49431b5a154d99ff17b0de1f7a195c Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 15:01:16 +0000 Subject: [PATCH 148/226] Isolate and time-bound every third-party plugin settings boundary PluginsSectionViewModel called every plugin's setting/collection definition methods synchronously during initial and dispatcher-posted refreshes with no per-plugin isolation, launched expanded-card loading fire-and-forget without observing faults, and awaited third-party setters, collection operations, and validation without exception boundaries. One throwing or hung plugin - loaded or disabled - could abort the whole plugin list, fault a dispatcher callback, hang construction, or take the app down. Every third-party boundary now runs through a bounded worker (Task.Run + WhenAny deadline, injectable, default 5s): a timeout or throw is reported to the error log and Trace, marks only that plugin's card failed via the existing localized status keys, and the refresh continues for everyone else. Late completions of timed-out work are observed and trace-logged. Expanded-card restoration is routed through an observed wrapper. Validation gets its own longer deadline (default 10 minutes) because bundled providers legitimately download models on demand during ValidateAsync; only the recovery reload keeps the short boundary. --- .../Sections/PluginsSectionViewModel.cs | 481 +++++++++++++++--- .../PluginCollectionSettingsViewModelTests.cs | 314 +++++++++++- 2 files changed, 733 insertions(+), 62 deletions(-) diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs index 60d69024c..e50dd8880 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs @@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using System.Collections.ObjectModel; +using System.Diagnostics; using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; using TypeWhisper.Linux.Services.Localization; @@ -16,6 +17,9 @@ namespace TypeWhisper.Linux.ViewModels.Sections; public partial class PluginsSectionViewModel : ObservableObject { + private static readonly TimeSpan s_defaultPluginBoundaryTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan s_defaultPluginValidationTimeout = TimeSpan.FromMinutes(10); + private static readonly HashSet s_transcriptionPluginIds = [ "com.typewhisper.assemblyai", @@ -66,6 +70,8 @@ public partial class PluginsSectionViewModel : ObservableObject private readonly IErrorLogService? _errorLog; private readonly Dictionary _pluginById = []; + private readonly TimeSpan _pluginBoundaryTimeout; + private readonly TimeSpan _pluginValidationTimeout; private readonly PluginManager _pluginManager; [ObservableProperty] @@ -75,9 +81,37 @@ public partial class PluginsSectionViewModel : ObservableObject private string _summary = ""; public PluginsSectionViewModel(PluginManager pluginManager, IErrorLogService? errorLog = null) + : this(pluginManager, errorLog, s_defaultPluginBoundaryTimeout) + { + } + + internal PluginsSectionViewModel( + PluginManager pluginManager, + IErrorLogService? errorLog, + TimeSpan pluginBoundaryTimeout, + TimeSpan? pluginValidationTimeout = null + ) { _pluginManager = pluginManager; _errorLog = errorLog; + _pluginBoundaryTimeout = pluginBoundaryTimeout; + if (_pluginBoundaryTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(pluginBoundaryTimeout), + "The plugin boundary timeout must be greater than zero." + ); + } + + _pluginValidationTimeout = pluginValidationTimeout ?? s_defaultPluginValidationTimeout; + if (_pluginValidationTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(pluginValidationTimeout), + "The plugin validation timeout must be greater than zero." + ); + } + _pluginManager.PluginStateChanged += (_, _) => Dispatcher.UIThread.Post(Refresh); Refresh(); } @@ -106,30 +140,98 @@ private void Refresh() PluginGroups.Clear(); _pluginById.Clear(); - var plugins = _pluginManager - .AllPlugins.Select(p => + var plugins = new List(); + foreach (var plugin in _pluginManager.AllPlugins) + { + _pluginById[plugin.Manifest.Id] = plugin; + + try { - _pluginById[p.Manifest.Id] = p; - var loc = new PluginLocalization(p.PluginDirectory); - return new PluginRow( + var loc = new PluginLocalization(plugin.PluginDirectory); + var hasExpandableSettings = false; + var settingsDefinitionFailed = false; + + if (plugin.Instance is IPluginSettingsProvider settingsProvider) + { + var definitions = TryInvokePluginBoundary( + plugin, + "read setting definitions", + () => settingsProvider.GetSettingDefinitions().ToList() + ); + if (definitions.IsSuccess) + { + hasExpandableSettings = definitions.Value!.Count > 0; + } + else + { + settingsDefinitionFailed = true; + } + } + + if ( + plugin.Instance + is IPluginCollectionSettingsProvider collectionSettingsProvider + ) + { + var definitions = TryInvokePluginBoundary( + plugin, + "read collection definitions", + () => collectionSettingsProvider.GetCollectionDefinitions().ToList() + ); + if (definitions.IsSuccess) + { + hasExpandableSettings |= definitions.Value!.Count > 0; + } + else + { + settingsDefinitionFailed = true; + } + } + + var row = new PluginRow( this, - p.Manifest.Id, - LocalizeManifest(loc, "Manifest.Name", p.Manifest.Name), - p.Manifest.Version, - LocalizeManifest(loc, "Manifest.Description", p.Manifest.Description ?? ""), - InferCategory(p.Manifest), - InferIsLocal(p.Manifest), - ( - p.Instance is IPluginSettingsProvider sp - && sp.GetSettingDefinitions().Count > 0 - ) - || ( - p.Instance is IPluginCollectionSettingsProvider cp - && cp.GetCollectionDefinitions().Count > 0 + plugin.Manifest.Id, + LocalizeManifest(loc, "Manifest.Name", plugin.Manifest.Name), + plugin.Manifest.Version, + LocalizeManifest( + loc, + "Manifest.Description", + plugin.Manifest.Description ?? "" ), - _pluginManager.IsEnabled(p.Manifest.Id) + InferCategory(plugin.Manifest), + InferIsLocal(plugin.Manifest), + hasExpandableSettings || settingsDefinitionFailed, + _pluginManager.IsEnabled(plugin.Manifest.Id) ); - }) + + if (settingsDefinitionFailed) + { + MarkSettingsLoadFailed(row); + } + + plugins.Add(row); + } + catch (Exception ex) + { + ReportPluginBoundaryFailure(plugin, "build settings card", ex); + var row = new PluginRow( + this, + plugin.Manifest.Id, + plugin.Manifest.Name, + plugin.Manifest.Version, + plugin.Manifest.Description ?? "", + InferCategory(plugin.Manifest), + InferIsLocal(plugin.Manifest), + plugin.Instance is IPluginSettingsProvider + or IPluginCollectionSettingsProvider, + _pluginManager.IsEnabled(plugin.Manifest.Id) + ); + MarkSettingsLoadFailed(row); + plugins.Add(row); + } + } + + plugins = plugins .OrderBy(p => p.CategorySortOrder) .ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -174,7 +276,7 @@ p.Instance is IPluginCollectionSettingsProvider cp } expandedPlugin.IsExpanded = true; - _ = LoadPluginSettingsAsync(expandedPlugin); + BeginObservedSettingsLoad(expandedPlugin); } [RelayCommand] @@ -238,28 +340,35 @@ private async Task SaveSettingsAsync(PluginRow row) )) .ToList(); - PluginSettingsValidationResult result; - try - { - result = await collectionProvider.SetItemsAsync(collection.Key, items); - } - catch (Exception ex) + var setResult = await TryInvokePluginBoundaryAsync( + loaded, + $"save collection '{collection.Key}'", + ct => collectionProvider.SetItemsAsync(collection.Key, items, ct) + ); + if (!setResult.IsSuccess || setResult.Value is null) { - _errorLog?.AddEntry( - $"Plugin '{loaded.Manifest.Name}' failed to save collection '{collection.Key}': {ex.Message}", - ErrorCategory.Plugin - ); + if (setResult.IsSuccess) + { + ReportPluginBoundaryFailure( + loaded, + $"save collection '{collection.Key}'", + new InvalidOperationException( + "The plugin returned no collection validation result." + ) + ); + } + row.Status = Loc.Instance["Plugins.SettingsSaveFailed"]; await LoadPluginSettingsAsync(row, true); return; } - if (result.IsSuccess) + if (setResult.Value.IsSuccess) { continue; } - row.Status = result.Message; + row.Status = setResult.Value.Message; return; } } @@ -285,8 +394,20 @@ private async Task ValidateSettingsAsync(PluginRow row) return; } - var result = await provider.ValidateAsync(); - row.Status = result?.Message ?? Loc.Instance["Plugins.NoValidationAvailable"]; + var validation = await TryInvokePluginBoundaryAsync( + loaded, + "validate settings", + provider.ValidateAsync, + _pluginValidationTimeout + ); + if (!validation.IsSuccess) + { + row.Status = Loc.Instance["Plugins.UnableToLoadSettings"]; + return; + } + + row.Status = + validation.Value?.Message ?? Loc.Instance["Plugins.NoValidationAvailable"]; await LoadPluginSettingsAsync(row, true); } @@ -298,16 +419,18 @@ IPluginSettingsProvider provider { foreach (var field in row.SettingFields) { - try - { - await provider.SetSettingValueAsync(field.Key, field.Value); - } - catch (Exception ex) + var setResult = await TryInvokePluginBoundaryAsync( + loaded, + $"save setting '{field.Key}'", + async ct => + { + await provider.SetSettingValueAsync(field.Key, field.Value, ct) + .ConfigureAwait(false); + return true; + } + ); + if (!setResult.IsSuccess) { - _errorLog?.AddEntry( - $"Plugin '{loaded.Manifest.Name}' failed to save setting '{field.Key}': {ex.Message}", - ErrorCategory.Plugin - ); row.Status = Loc.Instance["Plugins.SettingsSaveFailed"]; await LoadPluginSettingsAsync(row, true); return false; @@ -341,30 +464,97 @@ private async Task LoadPluginSettingsAsync(PluginRow row, bool preserveStatus = if (flatProvider is not null) { - foreach (var definition in flatProvider.GetSettingDefinitions()) + var definitions = await TryInvokePluginBoundaryAsync( + loaded, + "read setting definitions", + _ => Task.FromResult(flatProvider.GetSettingDefinitions().ToList()) + ); + if (!definitions.IsSuccess) { - var value = await flatProvider.GetSettingValueAsync(definition.Key) ?? string.Empty; - row.SettingFields.Add( - new PluginSettingFieldRow( - definition.Key, - definition.Label, - definition.Description ?? string.Empty, - definition.Placeholder ?? string.Empty, - definition.Options ?? [], - definition.IsSecret, - definition.Kind, - value - ) - ); + MarkSettingsLoadFailed(row, preserveStatus); + return; + } + + try + { + foreach (var definition in definitions.Value!) + { + var settingValue = await TryInvokePluginBoundaryAsync( + loaded, + $"read setting '{definition.Key}'", + ct => flatProvider.GetSettingValueAsync(definition.Key, ct) + ); + if (!settingValue.IsSuccess) + { + MarkSettingsLoadFailed(row, preserveStatus); + return; + } + + row.SettingFields.Add( + new PluginSettingFieldRow( + definition.Key, + definition.Label, + definition.Description ?? string.Empty, + definition.Placeholder ?? string.Empty, + definition.Options ?? [], + definition.IsSecret, + definition.Kind, + settingValue.Value ?? string.Empty + ) + ); + } + } + catch (Exception ex) + { + ReportPluginBoundaryFailure(loaded, "read setting definitions", ex); + MarkSettingsLoadFailed(row, preserveStatus); + return; } } if (collectionProvider is not null) { - foreach (var definition in collectionProvider.GetCollectionDefinitions()) + var definitions = await TryInvokePluginBoundaryAsync( + loaded, + "read collection definitions", + _ => Task.FromResult(collectionProvider.GetCollectionDefinitions().ToList()) + ); + if (!definitions.IsSuccess) { - var items = await collectionProvider.GetItemsAsync(definition.Key); - row.Collections.Add(new PluginCollectionRow(definition, row, items)); + MarkSettingsLoadFailed(row, preserveStatus); + return; + } + + try + { + foreach (var definition in definitions.Value!) + { + var collectionItems = await TryInvokePluginBoundaryAsync( + loaded, + $"read collection '{definition.Key}'", + async ct => + ( + await collectionProvider + .GetItemsAsync(definition.Key, ct) + .ConfigureAwait(false) + ).ToList() + ); + if (!collectionItems.IsSuccess) + { + MarkSettingsLoadFailed(row, preserveStatus); + return; + } + + row.Collections.Add( + new PluginCollectionRow(definition, row, collectionItems.Value!) + ); + } + } + catch (Exception ex) + { + ReportPluginBoundaryFailure(loaded, "read collection settings", ex); + MarkSettingsLoadFailed(row, preserveStatus); + return; } } @@ -382,6 +572,175 @@ private async Task LoadPluginSettingsAsync(PluginRow row, bool preserveStatus = } } + private void BeginObservedSettingsLoad(PluginRow row) + { + var loadTask = ObserveSettingsLoadAsync(row); + _ = loadTask.ContinueWith( + completedTask => + Trace.WriteLine( + $"[PluginsSectionViewModel] Failed to handle settings load for plugin " + + $"'{row.Id}': {completedTask.Exception!.GetBaseException().Message}" + ), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted + | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + + private async Task ObserveSettingsLoadAsync(PluginRow row) + { + try + { + await LoadPluginSettingsAsync(row); + } + catch (Exception ex) + { + if (_pluginById.TryGetValue(row.Id, out var loaded)) + { + ReportPluginBoundaryFailure(loaded, "load settings", ex); + } + else + { + Trace.WriteLine( + $"[PluginsSectionViewModel] Failed to load settings for plugin " + + $"'{row.Id}': {ex}" + ); + } + + MarkSettingsLoadFailed(row); + } + } + + private void MarkSettingsLoadFailed(PluginRow row, bool preserveStatus = false) + { + row.SettingFields.Clear(); + row.Collections.Clear(); + row.CanEditSettings = false; + row.CanValidateSettings = false; + + if (!preserveStatus) + { + row.Status = Loc.Instance["Plugins.UnableToLoadSettings"]; + } + } + + private PluginBoundaryResult TryInvokePluginBoundary( + LoadedPlugin plugin, + string operation, + Func boundary + ) + { + return TryInvokePluginBoundaryAsync( + plugin, + operation, + _ => Task.FromResult(boundary()) + ) + .GetAwaiter() + .GetResult(); + } + + private async Task> TryInvokePluginBoundaryAsync( + LoadedPlugin plugin, + string operation, + Func> boundary, + TimeSpan? overrideTimeout = null + ) + { + var timeoutDuration = overrideTimeout ?? _pluginBoundaryTimeout; + var boundaryTask = Task.Run( + () => boundary(CancellationToken.None), + CancellationToken.None + ); + var completedTask = await Task.WhenAny( + boundaryTask, + Task.Delay(timeoutDuration) + ) + .ConfigureAwait(false); + + if (completedTask != boundaryTask) + { + var timeout = new TimeoutException( + $"The operation timed out after " + + $"{timeoutDuration.TotalSeconds:0.###} seconds." + ); + ReportPluginBoundaryFailure(plugin, operation, timeout); + ObserveLatePluginBoundary( + boundaryTask, + plugin.Manifest.Id, + operation + ); + return PluginBoundaryResult.Failure; + } + + try + { + var value = await boundaryTask.ConfigureAwait(false); + return new PluginBoundaryResult(true, value); + } + catch (Exception ex) + { + ReportPluginBoundaryFailure(plugin, operation, ex); + return PluginBoundaryResult.Failure; + } + } + + private void ReportPluginBoundaryFailure( + LoadedPlugin plugin, + string operation, + Exception exception + ) + { + var failure = exception.GetBaseException(); + var message = + $"Plugin '{plugin.Manifest.Name}' failed to {operation}: {failure.Message}"; + _errorLog?.AddEntry(message, ErrorCategory.Plugin); + Trace.WriteLine($"[PluginsSectionViewModel] {message}"); + } + + private static void ObserveLatePluginBoundary( + Task boundaryTask, + string pluginId, + string operation + ) + { + _ = boundaryTask.ContinueWith( + completedTask => + { + if (completedTask.IsFaulted) + { + Trace.WriteLine( + $"[PluginsSectionViewModel] {operation} for plugin '{pluginId}' " + + $"faulted after timeout: " + + completedTask.Exception!.GetBaseException().Message + ); + } + else if (completedTask.IsCanceled) + { + Trace.WriteLine( + $"[PluginsSectionViewModel] {operation} for plugin '{pluginId}' " + + "canceled after timeout" + ); + } + else + { + Trace.WriteLine( + $"[PluginsSectionViewModel] {operation} for plugin '{pluginId}' " + + "completed after timeout" + ); + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + + private readonly record struct PluginBoundaryResult(bool IsSuccess, T? Value) + { + public static PluginBoundaryResult Failure => new(false, default); + } + // Plugin card name/description come from manifest.json (single-language). // Resolve them through the plugin's own catalog so they follow the UI // language, falling back to the manifest literal when the catalog has no diff --git a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs index 7b220d502..66517e93f 100644 --- a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs @@ -1,3 +1,6 @@ +using System.Diagnostics; +using System.Reflection; +using TypeWhisper.Core.Services; using TypeWhisper.Linux.ViewModels.Sections; using TypeWhisper.PluginSDK; using Xunit; @@ -99,6 +102,188 @@ public void HasExpandableSettings_TrueForCollectionOnlyPlugin() Assert.True(row.HasExpandableSettings); } + [Fact] + public async Task Refresh_DefinitionThrow_MarksOnlyThrowingPluginFailed_AndKeepsOtherPluginFunctional() + { + var throwing = new FakeSettingsPlugin("com.test.throwing-definitions") + { + DefinitionFactory = () => throw new InvalidOperationException("definitions exploded"), + }; + var healthy = new FakeCollectionPlugin(); + var errorLog = new ErrorLogService(_tempDir); + + var vm = CreateSection( + [throwing, healthy], + TimeSpan.FromMilliseconds(40), + errorLog + ); + + var rows = vm.PluginGroups.SelectMany(group => group.Plugins).ToList(); + var throwingRow = Assert.Single(rows, row => row.Id == throwing.PluginId); + var healthyRow = Assert.Single(rows, row => row.Id == healthy.PluginId); + Assert.Equal("Unable to load plugin settings.", throwingRow.Status); + Assert.True(throwingRow.HasExpandableSettings); + + await vm.ToggleExpandedCommand.ExecuteAsync(healthyRow); + + Assert.Single(healthyRow.Collections); + Assert.True(healthyRow.CanEditSettings); + Assert.Contains( + errorLog.Entries, + entry => + entry.Message.Contains(throwing.PluginName, StringComparison.Ordinal) + && entry.Message.Contains("read setting definitions", StringComparison.Ordinal) + ); + } + + [Fact] + public async Task Refresh_HungDefinitions_TimesOutAndObservesLateCompletion_WithoutBlockingOtherPlugins() + { + using var releaseDefinitions = new ManualResetEventSlim(); + var lateCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var hung = new FakeSettingsPlugin("com.test.hung-definitions") + { + DefinitionFactory = () => + { + // ReSharper disable once AccessToDisposedClosure -- test awaits lateCompletion before the using disposes this event. + releaseDefinitions.Wait(); + lateCompletion.TrySetResult(); + return [FakeSettingsPlugin.SettingDefinition]; + }, + }; + var healthy = new FakeCollectionPlugin(); + var errorLog = new ErrorLogService(_tempDir); + var releaseTask = Task.Run(async () => + { + await Task.Delay(750); + // ReSharper disable once AccessToDisposedClosure -- test awaits releaseTask before the using disposes this event. + releaseDefinitions.Set(); + }); + var stopwatch = Stopwatch.StartNew(); + + var vm = CreateSection( + [hung, healthy], + TimeSpan.FromMilliseconds(40), + errorLog + ); + + stopwatch.Stop(); + Assert.True( + stopwatch.Elapsed < TimeSpan.FromMilliseconds(500), + $"Refresh took {stopwatch.Elapsed.TotalMilliseconds:0} ms." + ); + var rows = vm.PluginGroups.SelectMany(group => group.Plugins).ToList(); + var hungRow = Assert.Single(rows, row => row.Id == hung.PluginId); + var healthyRow = Assert.Single(rows, row => row.Id == healthy.PluginId); + Assert.Equal("Unable to load plugin settings.", hungRow.Status); + + await vm.ToggleExpandedCommand.ExecuteAsync(healthyRow); + Assert.Single(healthyRow.Collections); + + await releaseTask; + await lateCompletion.Task.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.Contains( + errorLog.Entries, + entry => + entry.Message.Contains(hung.PluginName, StringComparison.Ordinal) + && entry.Message.Contains("timed out", StringComparison.Ordinal) + ); + } + + [Fact] + public async Task SaveSettings_SetterAndRecoveryReloadThrow_AreContainedToCard() + { + var plugin = new FakeSettingsPlugin("com.test.throwing-save"); + var errorLog = new ErrorLogService(_tempDir); + var vm = CreateSection( + [plugin], + TimeSpan.FromMilliseconds(40), + errorLog + ); + var row = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + await vm.ToggleExpandedCommand.ExecuteAsync(row); + plugin.ThrowOnSetValue = true; + plugin.ThrowOnGetValue = true; + + var exception = await Record.ExceptionAsync( + () => vm.SaveSettingsCommand.ExecuteAsync(row) + ); + + Assert.Null(exception); + Assert.Equal( + "Settings could not be saved. See the error log for details.", + row.Status + ); + Assert.Empty(row.SettingFields); + Assert.False(row.CanEditSettings); + Assert.Contains( + errorLog.Entries, + entry => entry.Message.Contains("save setting", StringComparison.Ordinal) + ); + Assert.Contains( + errorLog.Entries, + entry => entry.Message.Contains("read setting", StringComparison.Ordinal) + ); + } + + [Fact] + public async Task Refresh_ExpandedRestorationThrow_IsObservedAndMarksRestoredCardFailed() + { + var plugin = new FakeSettingsPlugin("com.test.throwing-restoration"); + var errorLog = new ErrorLogService(_tempDir); + var vm = CreateSection( + [plugin], + TimeSpan.FromMilliseconds(40), + errorLog + ); + var originalRow = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + await vm.ToggleExpandedCommand.ExecuteAsync(originalRow); + plugin.ThrowOnGetValue = true; + + InvokeRefresh(vm); + + var restoredRow = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + Assert.True(restoredRow.IsExpanded); + await WaitForAsync( + () => restoredRow.Status == "Unable to load plugin settings.", + TimeSpan.FromSeconds(2) + ); + Assert.Equal("Unable to load plugin settings.", restoredRow.Status); + Assert.Contains( + errorLog.Entries, + entry => + entry.Message.Contains(plugin.PluginName, StringComparison.Ordinal) + && entry.Message.Contains("read setting", StringComparison.Ordinal) + ); + } + + [Fact] + public async Task ValidateSettings_SlowValidateAsync_UsesLongerValidationTimeout() + { + var plugin = new FakeSettingsPlugin("com.test.slow-validate") + { + // Validation legitimately runs longer than the short boundary timeout + // (e.g. SupertonicTts downloading a model on demand). + ValidateDelay = TimeSpan.FromMilliseconds(150), + ValidationResult = new PluginSettingsValidationResult(true, "Validated OK."), + }; + var errorLog = new ErrorLogService(_tempDir); + var vm = CreateSection( + [plugin], + TimeSpan.FromMilliseconds(40), + errorLog, + TimeSpan.FromSeconds(5) + ); + var row = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + await vm.ToggleExpandedCommand.ExecuteAsync(row); + + await vm.ValidateSettingsCommand.ExecuteAsync(row); + + Assert.Equal("Validated OK.", row.Status); + } + // ---- PluginSettingFieldRow direct unit tests -------------------------- [Fact] @@ -357,6 +542,54 @@ FakeCollectionPlugin Plugin return (vm, row, plugin); } + private PluginsSectionViewModel CreateSection( + IReadOnlyList plugins, + TimeSpan pluginBoundaryTimeout, + ErrorLogService errorLog, + TimeSpan? pluginValidationTimeout = null + ) + { + var loadedPlugins = plugins + .Select(plugin => + TestPluginManagerFactory.CreateLoadedPlugin( + _tempDir, + plugin.PluginId, + plugin + ) + ) + .ToList(); + var manager = TestPluginManagerFactory.Create(loadedPlugins: loadedPlugins); + return new PluginsSectionViewModel( + manager, + errorLog, + pluginBoundaryTimeout, + pluginValidationTimeout + ); + } + + private static void InvokeRefresh(PluginsSectionViewModel vm) + { + var refresh = + typeof(PluginsSectionViewModel).GetMethod( + "Refresh", + BindingFlags.Instance | BindingFlags.NonPublic + ) + ?? throw new MissingMethodException( + typeof(PluginsSectionViewModel).FullName, + "Refresh" + ); + refresh.Invoke(vm, null); + } + + private static async Task WaitForAsync(Func condition, TimeSpan timeout) + { + var deadline = Stopwatch.StartNew(); + while (!condition() && deadline.Elapsed < timeout) + { + await Task.Delay(10); + } + } + // ---- PluginCollectionRow / PluginCollectionItemRow direct tests ------- private static PluginCollectionDefinition ThingsDefinition() @@ -391,6 +624,85 @@ private static PluginCollectionRow CreateCollectionRow(params PluginCollectionIt return new PluginCollectionRow(ThingsDefinition(), ownerRow, items); } + private sealed class FakeSettingsPlugin : ITypeWhisperPlugin, IPluginSettingsProvider + { + public static readonly PluginSettingDefinition SettingDefinition = new( + "value", + "Value", + Kind: PluginSettingKind.Text + ); + + public FakeSettingsPlugin(string pluginId) + { + PluginId = pluginId; + } + + public Func>? DefinitionFactory { get; init; } + public bool ThrowOnGetValue { get; set; } + public bool ThrowOnSetValue { get; set; } + public TimeSpan ValidateDelay { get; init; } + public PluginSettingsValidationResult? ValidationResult { get; init; } + public string PluginId { get; } + public string PluginName => $"Settings {PluginId}"; + public string PluginVersion => "1.0.0"; + + public IReadOnlyList GetSettingDefinitions() + { + return DefinitionFactory?.Invoke() ?? [SettingDefinition]; + } + + public Task GetSettingValueAsync( + string key, + CancellationToken ct = default + ) + { + if (ThrowOnGetValue) + { + throw new InvalidOperationException("setting getter exploded"); + } + + return Task.FromResult("initial"); + } + + public Task SetSettingValueAsync( + string key, + string? value, + CancellationToken ct = default + ) + { + if (ThrowOnSetValue) + { + throw new InvalidOperationException("setting setter exploded"); + } + + return Task.CompletedTask; + } + + public async Task ValidateAsync( + CancellationToken ct = default + ) + { + if (ValidateDelay > TimeSpan.Zero) + { + await Task.Delay(ValidateDelay, ct).ConfigureAwait(false); + } + + return ValidationResult; + } + + public Task ActivateAsync(IPluginHostServices host) + { + return Task.CompletedTask; + } + + public Task DeactivateAsync() + { + return Task.CompletedTask; + } + + public void Dispose() { } + } + /// /// Minimal plugin exposing only /// (no ) for view-model tests. @@ -446,4 +758,4 @@ public Task DeactivateAsync() public void Dispose() { } } -} \ No newline at end of file +} From b2342db592016d9b976b7bcadc9f860a79f0205a Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 15:18:11 +0000 Subject: [PATCH 149/226] Contain expected UI I/O and platform failures behind a shared guard Ordinary UI paths - profile Add/Save/Duplicate/Delete/Toggle persistence, file pickers, clipboard copies, exports and their direct writes, watch-folder pickers - ran without local or application-level error boundaries. A disk-full, access-denied, portal/DBus, or clipboard failure escaped the command or async void continuation into Avalonia's dispatcher and could terminate the application with no recoverable status. UiOperationGuard now wraps those operations: a typed UiFailureKind allowlist scopes what each site may treat as expected (file-system, storage-provider, clipboard, window - including Avalonia's bare IOException for an unavailable storage provider), non-listed and fatal exceptions still propagate, and on an expected failure the guard logs to Trace and the error log, runs the operation's rollback, and presents a localized message (all four locales) - with every step of that failure path itself guarded so presenting can never become a second failure. Profiles resync from the service's committed state on persistence failure. A Dispatcher.UIThread.UnhandledException handler remains as a last resort that reports instead of crashing; fatal exceptions still take the process down because nothing catches them. --- src/TypeWhisper.Linux/App.axaml.cs | 7 + .../Resources/Localization/de.json | 2 + .../Resources/Localization/en.json | 2 + .../Resources/Localization/es.json | 2 + .../Resources/Localization/ru.json | 2 + src/TypeWhisper.Linux/ServiceRegistrations.cs | 16 ++ .../Services/UiOperationGuard.cs | 257 ++++++++++++++++++ .../Sections/ProfilesSectionViewModel.cs | 124 +++++++-- .../Views/Sections/DictationSection.axaml.cs | 81 ++++-- .../FileTranscriptionSection.axaml.cs | 202 ++++++++++---- .../Views/Sections/ProfilesSection.axaml.cs | 36 ++- .../Views/Sections/RecorderSection.axaml.cs | 36 ++- .../LocalizationResourcesTests.cs | 24 ++ .../ProfilesSectionViewModelTests.cs | 139 +++++++++- .../UiOperationGuardTests.cs | 238 ++++++++++++++++ 15 files changed, 1034 insertions(+), 134 deletions(-) create mode 100644 src/TypeWhisper.Linux/Services/UiOperationGuard.cs create mode 100644 tests/TypeWhisper.Linux.Tests/UiOperationGuardTests.cs diff --git a/src/TypeWhisper.Linux/App.axaml.cs b/src/TypeWhisper.Linux/App.axaml.cs index ef7c4870d..5ed842237 100644 --- a/src/TypeWhisper.Linux/App.axaml.cs +++ b/src/TypeWhisper.Linux/App.axaml.cs @@ -69,6 +69,13 @@ public override void OnFrameworkInitializationCompleted() Loc.Instance.CurrentLanguage = Loc.Instance.ResolveLanguage(settings.Current.UiLanguage); BootTrace.Stage("Loc.Initialize"); + var uiOperations = services.GetRequiredService(); + Dispatcher.UIThread.UnhandledException += (sender, args) => + { + args.Handled = true; + _ = uiOperations.ReportDispatcherFailureAsync(args.Exception, "TypeWhisper"); + }; + // Reconcile configured state and verify native ownership before DictationOrchestrator // starts HotkeyService. This keeps the first backend snapshot free of a duplicate // app-owned dictation route when the current desktop spec is installed. diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index 6080de11a..5bc65cf40 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -123,6 +123,8 @@ "Common.On": "An", "Common.Yes": "Ja", "Common.OpenWizard": "Assistent starten", + "Common.OperationFailed": "{0} fehlgeschlagen: {1}", + "Common.OperationFailedTitle": "Vorgang fehlgeschlagen", "Common.Refresh": "Aktualisieren", "Common.Remove": "Entfernen", "Common.RemoveIntegration": "Integration entfernen", diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index 3308fed71..bab0b55c8 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -124,6 +124,8 @@ "Common.On": "On", "Common.Yes": "Yes", "Common.OpenWizard": "Open Wizard", + "Common.OperationFailed": "{0} failed: {1}", + "Common.OperationFailedTitle": "Operation failed", "Common.Refresh": "Refresh", "Common.Remove": "Remove", "Common.RemoveIntegration": "Remove integration", diff --git a/src/TypeWhisper.Linux/Resources/Localization/es.json b/src/TypeWhisper.Linux/Resources/Localization/es.json index f7b625342..ed8c89f93 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -123,6 +123,8 @@ "Common.On": "Activado", "Common.Yes": "Sí", "Common.OpenWizard": "Abrir asistente", + "Common.OperationFailed": "Error al realizar {0}: {1}", + "Common.OperationFailedTitle": "Error en la operación", "Common.Refresh": "Actualizar", "Common.Remove": "Quitar", "Common.RemoveIntegration": "Quitar integración", diff --git a/src/TypeWhisper.Linux/Resources/Localization/ru.json b/src/TypeWhisper.Linux/Resources/Localization/ru.json index a24bf8b49..f42e2c7f6 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -123,6 +123,8 @@ "Common.On": "Вкл", "Common.Yes": "Да", "Common.OpenWizard": "Открыть мастер", + "Common.OperationFailed": "Не удалось выполнить действие «{0}»: {1}", + "Common.OperationFailedTitle": "Не удалось выполнить операцию", "Common.Refresh": "Обновить", "Common.Remove": "Удалить", "Common.RemoveIntegration": "Удалить интеграцию", diff --git a/src/TypeWhisper.Linux/ServiceRegistrations.cs b/src/TypeWhisper.Linux/ServiceRegistrations.cs index 318eab3b4..88e81c7ed 100644 --- a/src/TypeWhisper.Linux/ServiceRegistrations.cs +++ b/src/TypeWhisper.Linux/ServiceRegistrations.cs @@ -10,6 +10,7 @@ using TypeWhisper.Linux.Services.Hotkey.Evdev; using TypeWhisper.Linux.Services.Insertion; using TypeWhisper.Linux.Services.Ipc; +using TypeWhisper.Linux.Services.Localization; using TypeWhisper.Linux.Services.Plugins; using TypeWhisper.Linux.Services.Setup; using TypeWhisper.Linux.ViewModels; @@ -51,6 +52,21 @@ public static void Register(IServiceCollection services) } services.AddSingleton(errorLog); + services.AddSingleton(sp => + new UiOperationGuard( + sp.GetRequiredService(), + async message => + { + var dialog = new MessageDialogWindow(); + await dialog.ShowMessageAsync( + Loc.Instance["Common.OperationFailedTitle"], + message + ); + }, + (operation, reason) => + Loc.Instance.GetString("Common.OperationFailed", operation, reason) + ) + ); services.AddSingleton( new HistoryService( Path.Join(dataPath, "history.json"), diff --git a/src/TypeWhisper.Linux/Services/UiOperationGuard.cs b/src/TypeWhisper.Linux/Services/UiOperationGuard.cs new file mode 100644 index 000000000..611eaa952 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/UiOperationGuard.cs @@ -0,0 +1,257 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Tmds.DBus.Protocol; +using TypeWhisper.Core.Interfaces; + +namespace TypeWhisper.Linux.Services; + +[Flags] +public enum UiFailureKind +{ + FileSystem = 1, + StorageProvider = 2, + Clipboard = 4, + Window = 8, +} + +/// +/// Contains expected UI-facing I/O and platform failures, records an English +/// diagnostic, rolls back caller-owned state, and presents a recoverable status. +/// Unexpected programming and fatal runtime failures are deliberately not caught. +/// +public sealed class UiOperationGuard +{ + private readonly Func _defaultPresenter; + private readonly IErrorLogService _errorLog; + private readonly Func _failureMessageFormatter; + + public UiOperationGuard( + IErrorLogService errorLog, + Func defaultPresenter, + Func failureMessageFormatter + ) + { + ArgumentNullException.ThrowIfNull(errorLog); + ArgumentNullException.ThrowIfNull(defaultPresenter); + ArgumentNullException.ThrowIfNull(failureMessageFormatter); + + _errorLog = errorLog; + _defaultPresenter = defaultPresenter; + _failureMessageFormatter = failureMessageFormatter; + } + + public bool Run( + string operationName, + string operationDisplayName, + UiFailureKind expectedFailures, + Action operation, + Action? rollback = null, + Func? presenter = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationName); + ArgumentException.ThrowIfNullOrWhiteSpace(operationDisplayName); + ArgumentNullException.ThrowIfNull(operation); + + try + { + operation(); + return true; + } + catch (Exception ex) when (IsExpectedFailure(ex, expectedFailures)) + { + LogFailure(operationName, ex); + SafeRollback(operationName, rollback); + _ = SafePresentAsync( + operationName, + FormatFailure(operationDisplayName, ex), + presenter ?? _defaultPresenter + ); + return false; + } + } + + public async Task RunAsync( + string operationName, + string operationDisplayName, + UiFailureKind expectedFailures, + Func operation, + Func? rollback = null, + Func? presenter = null + ) + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationName); + ArgumentException.ThrowIfNullOrWhiteSpace(operationDisplayName); + ArgumentNullException.ThrowIfNull(operation); + + try + { + await operation(); + return true; + } + catch (Exception ex) when (IsExpectedFailure(ex, expectedFailures)) + { + LogFailure(operationName, ex); + await SafeRollbackAsync(operationName, rollback); + await SafePresentAsync( + operationName, + FormatFailure(operationDisplayName, ex), + presenter ?? _defaultPresenter + ); + return false; + } + } + + /// + /// Last-resort reporting for an exception already delivered by Avalonia's + /// dispatcher boundary. This method never rethrows non-fatal logger or + /// presenter failures. + /// + public Task ReportDispatcherFailureAsync( + Exception exception, + string operationDisplayName + ) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentException.ThrowIfNullOrWhiteSpace(operationDisplayName); + + const string operationName = "Avalonia UI dispatcher"; + LogFailure(operationName, exception); + return SafePresentAsync( + operationName, + FormatFailure(operationDisplayName, exception), + _defaultPresenter + ); + } + + private static bool IsExpectedFailure( + Exception exception, + UiFailureKind expectedFailures + ) + { + return ( + expectedFailures.HasFlag(UiFailureKind.FileSystem) + && exception is IOException or UnauthorizedAccessException + ) + || ( + expectedFailures.HasFlag(UiFailureKind.StorageProvider) + && exception is DBusExceptionBase or TimeoutException + ) + || ( + expectedFailures.HasFlag(UiFailureKind.Clipboard) + && exception + is TimeoutException + or ExternalException + or ObjectDisposedException + ) + || ( + expectedFailures.HasFlag(UiFailureKind.Window) + && exception + is DBusExceptionBase + or TimeoutException + or Win32Exception + or ExternalException + or ObjectDisposedException + ); + } + + private string FormatFailure(string operationDisplayName, Exception exception) + { + try + { + return _failureMessageFormatter(operationDisplayName, exception.Message); + } + catch (Exception ex) when (!IsFatal(ex)) + { + SafeTrace( + $"[UI] Failure message formatting for '{operationDisplayName}' failed: {ex}" + ); + return $"{operationDisplayName} failed: {exception.Message}"; + } + } + + private void LogFailure(string operationName, Exception exception) + { + var message = + $"UI operation '{operationName}' failed with " + + $"{exception.GetType().Name}: {exception.Message}"; + SafeTrace($"[UI] {message}{Environment.NewLine}{exception}"); + + try + { + _errorLog.AddEntry(message); + } + catch (Exception ex) when (!IsFatal(ex)) + { + SafeTrace($"[UI] Error-log reporting failed: {ex}"); + } + } + + private void SafeRollback(string operationName, Action? rollback) + { + if (rollback is null) + { + return; + } + + try + { + rollback(); + } + catch (Exception ex) when (!IsFatal(ex)) + { + LogFailure($"{operationName} rollback", ex); + } + } + + private async Task SafeRollbackAsync(string operationName, Func? rollback) + { + if (rollback is null) + { + return; + } + + try + { + await rollback(); + } + catch (Exception ex) when (!IsFatal(ex)) + { + LogFailure($"{operationName} rollback", ex); + } + } + + private async Task SafePresentAsync( + string operationName, + string message, + Func presenter + ) + { + try + { + await presenter(message); + } + catch (Exception ex) when (!IsFatal(ex)) + { + LogFailure($"{operationName} failure presenter", ex); + } + } + + private static bool IsFatal(Exception exception) + { + return exception is OutOfMemoryException or AccessViolationException; + } + + private static void SafeTrace(string message) + { + try + { + Trace.WriteLine(message); + } + catch + { + // Diagnostics must never become a second UI failure. + } + } +} diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs index c2c1187e5..b5fb6f2fe 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs @@ -30,6 +30,7 @@ public partial class ProfilesSectionViewModel : ObservableObject private readonly IProfileService _profiles; private readonly IPromptActionService _promptActions; private readonly HotkeyService _hotkeys; + private readonly UiOperationGuard _uiOperations; private readonly DispatcherTimer _windowTimer; private bool _isWindowUpdateInProgress; private int _liveContextActivationCount; @@ -137,7 +138,8 @@ public ProfilesSectionViewModel( HotkeyService hotkeys, IDetectionFailureTracker failureTracker, GnomeWindowCallsSetupHelper gnomeSetup, - BrowserAccessibilitySetupHelper browserSetup + BrowserAccessibilitySetupHelper browserSetup, + UiOperationGuard uiOperations ) { _profiles = profiles; @@ -148,6 +150,7 @@ BrowserAccessibilitySetupHelper browserSetup _failureTracker = failureTracker; _gnomeSetup = gnomeSetup; _browserSetup = browserSetup; + _uiOperations = uiOperations; RefreshBrowserAccessibilityStatus(); _profiles.ProfilesChanged += () => Dispatcher.UIThread.Post(RefreshProfiles); @@ -593,19 +596,28 @@ partial void OnEditIsEnabledChanged(bool value) [RelayCommand] private void AddProfile() { - var profile = new Profile - { - Id = Guid.NewGuid().ToString(), - Name = "New profile", - IsEnabled = true, - Priority = 0, - ProcessNames = [], - UrlPatterns = [], - }; - - _profiles.AddProfile(profile); - RefreshProfiles(); - SelectById(profile.Id); + _uiOperations.Run( + "add profile", + Loc.Instance["Common.Add"], + UiFailureKind.FileSystem, + () => + { + var profile = new Profile + { + Id = Guid.NewGuid().ToString(), + Name = "New profile", + IsEnabled = true, + Priority = 0, + ProcessNames = [], + UrlPatterns = [], + }; + + _profiles.AddProfile(profile); + RefreshProfiles(); + SelectById(profile.Id); + }, + ResyncProfilesAfterFailure + ); } [RelayCommand] @@ -666,9 +678,18 @@ private void SaveProfile() }; var selectedId = SelectedProfile.Id; - _profiles.UpdateProfile(updated); - RefreshProfiles(); - SelectById(selectedId); + _uiOperations.Run( + "save profile", + Loc.Instance["Common.Save"], + UiFailureKind.FileSystem, + () => + { + _profiles.UpdateProfile(updated); + RefreshProfiles(); + SelectById(selectedId); + }, + ResyncProfilesAfterFailure + ); } [RelayCommand] @@ -690,9 +711,18 @@ private void DuplicateProfile() UpdatedAt = DateTime.UtcNow, }; - _profiles.AddProfile(duplicate); - RefreshProfiles(); - SelectById(duplicate.Id); + _uiOperations.Run( + "duplicate profile", + Loc.Instance["Common.Copy"], + UiFailureKind.FileSystem, + () => + { + _profiles.AddProfile(duplicate); + RefreshProfiles(); + SelectById(duplicate.Id); + }, + ResyncProfilesAfterFailure + ); } [RelayCommand] @@ -703,9 +733,19 @@ private void DeleteSelectedProfile() return; } - _profiles.DeleteProfile(SelectedProfile.Id); - RefreshProfiles(); - SelectedProfile = null; + var selectedId = SelectedProfile.Id; + _uiOperations.Run( + "delete profile", + Loc.Instance["Common.Delete"], + UiFailureKind.FileSystem, + () => + { + _profiles.DeleteProfile(selectedId); + RefreshProfiles(); + SelectedProfile = null; + }, + ResyncProfilesAfterFailure + ); } [RelayCommand] @@ -716,8 +756,17 @@ private void ToggleProfileEnabled(Profile? profile) return; } - _profiles.ToggleProfileEnabled(profile.Id); - RefreshProfiles(); + _uiOperations.Run( + "toggle profile", + Loc.Instance["Common.Enabled"], + UiFailureKind.FileSystem, + () => + { + _profiles.ToggleProfileEnabled(profile.Id); + RefreshProfiles(); + }, + ResyncProfilesAfterFailure + ); } [RelayCommand] @@ -919,6 +968,31 @@ private void RefreshProfiles() } } + private void ResyncProfilesAfterFailure() + { + var selectedId = SelectedProfile?.Id; + + // Force the editor hooks to reload the service's committed snapshot even + // when record value equality would otherwise suppress the assignment. + SelectedProfile = null; + Profiles.Clear(); + foreach (var profile in _profiles.Profiles) + { + Profiles.Add(profile); + } + + SelectedProfile = + selectedId is null + ? Profiles.FirstOrDefault() + : Profiles.FirstOrDefault(profile => profile.Id == selectedId) + ?? Profiles.FirstOrDefault(); + + if (SelectedProfile is null) + { + NotifyStateChanged(); + } + } + private void RefreshModelOptions() { var selected = EditModelId; diff --git a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs index 7db8612c4..569202860 100644 --- a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs @@ -1,6 +1,9 @@ using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Platform.Storage; +using Microsoft.Extensions.DependencyInjection; +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Localization; using TypeWhisper.Linux.ViewModels.Sections; namespace TypeWhisper.Linux.Views.Sections; @@ -42,17 +45,27 @@ RoutedEventArgs e return; } - var dialog = new MessageDialogWindow(); - var confirmed = await dialog.ShowConfirmationAsync( - "Delete model files?", - $"Delete {selected.DisplayLabel} from your hard drive? It can be downloaded again later.", - "Delete" - ); + // Only the confirmation dialog is uncontained here; DeleteSelectedModelAsync + // catches its own file-system failures internally, so Window alone is correct. + await UiOperations.RunAsync( + "confirm and delete model", + Loc.Instance["Common.Delete"], + UiFailureKind.Window, + async () => + { + var dialog = new MessageDialogWindow(); + var confirmed = await dialog.ShowConfirmationAsync( + "Delete model files?", + $"Delete {selected.DisplayLabel} from your hard drive? It can be downloaded again later.", + "Delete" + ); - if (confirmed) - { - await viewModel.DeleteSelectedModelAsync(); - } + if (confirmed) + { + await viewModel.DeleteSelectedModelAsync(); + } + } + ); } // ReSharper disable once AsyncVoidEventHandlerMethod -- Avalonia UI event handler; void return is mandated by the RoutedEventHandler/EventHandler delegate signature. @@ -63,24 +76,40 @@ private async void OnChangeModelStorage(object? sender, RoutedEventArgs e) return; } - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel?.StorageProvider is null) - { - return; - } + await UiOperations.RunAsync( + "select model storage folder", + Loc.Instance["Dictation.ModelStorage"], + UiFailureKind.StorageProvider | UiFailureKind.FileSystem, + async () => + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.StorageProvider is null) + { + return; + } - var folders = await topLevel.StorageProvider.OpenFolderPickerAsync( - new FolderPickerOpenOptions + var folders = await topLevel.StorageProvider.OpenFolderPickerAsync( + new FolderPickerOpenOptions + { + Title = "Choose model storage folder", + AllowMultiple = false, + } + ); + + var path = (folders.Count > 0 ? folders[0] : null)?.TryGetLocalPath(); + if (!string.IsNullOrWhiteSpace(path)) + { + await viewModel.ChangeModelStorageAsync(path); + } + }, + presenter: message => { - Title = "Choose model storage folder", - AllowMultiple = false, + viewModel.ModelStorageStatusText = message; + return Task.CompletedTask; } ); - - var path = (folders.Count > 0 ? folders[0] : null)?.TryGetLocalPath(); - if (!string.IsNullOrWhiteSpace(path)) - { - await viewModel.ChangeModelStorageAsync(path); - } } -} \ No newline at end of file + + private static UiOperationGuard UiOperations => + Program.Services.GetRequiredService(); +} diff --git a/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs index c5b81a662..83356fa2b 100644 --- a/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs @@ -3,6 +3,8 @@ using Avalonia.Input.Platform; using Avalonia.Interactivity; using Avalonia.Platform.Storage; +using Microsoft.Extensions.DependencyInjection; +using TypeWhisper.Linux.Services; using TypeWhisper.Linux.Services.Localization; using TypeWhisper.Linux.ViewModels.Sections; @@ -30,25 +32,38 @@ private async void OnSelectFile(object? sender, RoutedEventArgs e) return; } - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel?.StorageProvider is null) - { - return; - } - - var files = await topLevel.StorageProvider.OpenFilePickerAsync( - new FilePickerOpenOptions { Title = Loc.Instance["Dialog.SelectFiles"], AllowMultiple = true } + await UiOperations.RunAsync( + "select transcription files", + Loc.Instance["Dialog.SelectFiles"], + UiFailureKind.StorageProvider | UiFailureKind.FileSystem, + async () => + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.StorageProvider is null) + { + return; + } + + var files = await topLevel.StorageProvider.OpenFilePickerAsync( + new FilePickerOpenOptions + { + Title = Loc.Instance["Dialog.SelectFiles"], + AllowMultiple = true, + } + ); + + var paths = files + .Select(file => file.TryGetLocalPath()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Cast() + .ToArray(); + if (paths.Length > 0) + { + viewModel.AddFilesCommand.Execute(paths); + } + }, + presenter: message => PresentStatusAsync(viewModel, message) ); - - var paths = files - .Select(file => file.TryGetLocalPath()) - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Cast() - .ToArray(); - if (paths.Length > 0) - { - viewModel.AddFilesCommand.Execute(paths); - } } // ReSharper disable once AsyncVoidEventHandlerMethod -- Avalonia UI event handler; the void return is required by the RoutedEventHandler delegate signature @@ -59,26 +74,53 @@ private async void OnCopy(object? sender, RoutedEventArgs e) return; } - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel?.Clipboard is not null && !string.IsNullOrWhiteSpace(viewModel.ResultText)) - { - await topLevel.Clipboard.SetTextAsync(viewModel.ResultText); - } + await UiOperations.RunAsync( + "copy transcription", + Loc.Instance["Common.Copy"], + UiFailureKind.Clipboard, + async () => + { + var topLevel = TopLevel.GetTopLevel(this); + if ( + topLevel?.Clipboard is not null + && !string.IsNullOrWhiteSpace(viewModel.ResultText) + ) + { + await topLevel.Clipboard.SetTextAsync(viewModel.ResultText); + } + }, + presenter: message => PresentStatusAsync(viewModel, message) + ); } // ReSharper disable once AsyncVoidEventHandlerMethod -- Avalonia UI event handler; the void return is required by the RoutedEventHandler delegate signature private async void OnCopyItem(object? sender, RoutedEventArgs e) { - if ((sender as Control)?.DataContext is not FileTranscriptionQueueItemViewModel item) + if ( + DataContext is not FileTranscriptionSectionViewModel viewModel + || (sender as Control)?.DataContext is not FileTranscriptionQueueItemViewModel item + ) { return; } - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel?.Clipboard is not null && !string.IsNullOrWhiteSpace(item.ResultText)) - { - await topLevel.Clipboard.SetTextAsync(item.ResultText); - } + await UiOperations.RunAsync( + "copy transcription item", + Loc.Instance["Common.Copy"], + UiFailureKind.Clipboard, + async () => + { + var topLevel = TopLevel.GetTopLevel(this); + if ( + topLevel?.Clipboard is not null + && !string.IsNullOrWhiteSpace(item.ResultText) + ) + { + await topLevel.Clipboard.SetTextAsync(item.ResultText); + } + }, + presenter: message => PresentStatusAsync(viewModel, message) + ); } // ReSharper disable once AsyncVoidEventHandlerMethod -- Avalonia UI event handler; the void return is required by the RoutedEventHandler delegate signature @@ -89,13 +131,13 @@ private async void OnExportText(object? sender, RoutedEventArgs e) return; } - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel?.StorageProvider is null || string.IsNullOrWhiteSpace(viewModel.ResultText)) - { - return; - } - - await ExportTextAsync(viewModel, viewModel.SelectedItem); + await UiOperations.RunAsync( + "export transcription text", + Loc.Instance["Common.Export"], + UiFailureKind.StorageProvider | UiFailureKind.FileSystem, + () => ExportTextAsync(viewModel, viewModel.SelectedItem), + presenter: message => PresentStatusAsync(viewModel, message) + ); } // ReSharper disable once AsyncVoidEventHandlerMethod -- Avalonia UI event handler; the void return is required by the RoutedEventHandler delegate signature @@ -109,7 +151,13 @@ DataContext is not FileTranscriptionSectionViewModel viewModel return; } - await ExportTextAsync(viewModel, item); + await UiOperations.RunAsync( + "export transcription item text", + Loc.Instance["Common.Export"], + UiFailureKind.StorageProvider | UiFailureKind.FileSystem, + () => ExportTextAsync(viewModel, item), + presenter: message => PresentStatusAsync(viewModel, message) + ); } private async Task ExportTextAsync( @@ -151,13 +199,35 @@ private async Task ExportTextAsync( // ReSharper disable once AsyncVoidEventHandlerMethod -- Avalonia UI event handler; the void return is required by the RoutedEventHandler delegate signature private async void OnExportItemSrt(object? sender, RoutedEventArgs e) { - await ExportSubtitleAsync(sender, "srt", "SRT"); + if (DataContext is not FileTranscriptionSectionViewModel viewModel) + { + return; + } + + await UiOperations.RunAsync( + "export transcription SRT subtitles", + Loc.Instance["Common.Export"], + UiFailureKind.StorageProvider | UiFailureKind.FileSystem, + () => ExportSubtitleAsync(sender, "srt", "SRT"), + presenter: message => PresentStatusAsync(viewModel, message) + ); } // ReSharper disable once AsyncVoidEventHandlerMethod -- Avalonia UI event handler; the void return is required by the RoutedEventHandler delegate signature private async void OnExportItemVtt(object? sender, RoutedEventArgs e) { - await ExportSubtitleAsync(sender, "vtt", "WebVTT"); + if (DataContext is not FileTranscriptionSectionViewModel viewModel) + { + return; + } + + await UiOperations.RunAsync( + "export transcription WebVTT subtitles", + Loc.Instance["Common.Export"], + UiFailureKind.StorageProvider | UiFailureKind.FileSystem, + () => ExportSubtitleAsync(sender, "vtt", "WebVTT"), + presenter: message => PresentStatusAsync(viewModel, message) + ); } private async Task ExportSubtitleAsync(object? sender, string extension, string label) @@ -208,11 +278,21 @@ private async void OnSelectWatchFolder(object? sender, RoutedEventArgs e) return; } - var path = await PickFolderAsync("Select watch folder"); - if (!string.IsNullOrWhiteSpace(path)) - { - viewModel.SetWatchFolderPath(path); - } + await UiOperations.RunAsync( + "select watch folder", + Loc.Instance["FileTranscription.WatchFolder"], + // SetWatchFolderPath synchronously persists via SettingsService.Save + // (File.WriteAllText/Move), so a disk-full/read-only write throws here too. + UiFailureKind.StorageProvider | UiFailureKind.FileSystem, + async () => + { + var path = await PickFolderAsync("Select watch folder"); + if (!string.IsNullOrWhiteSpace(path)) + { + viewModel.SetWatchFolderPath(path); + } + } + ); } // ReSharper disable once AsyncVoidEventHandlerMethod -- Avalonia UI event handler; the void return is required by the RoutedEventHandler delegate signature @@ -226,11 +306,21 @@ RoutedEventArgs e return; } - var path = await PickFolderAsync("Select output folder"); - if (!string.IsNullOrWhiteSpace(path)) - { - viewModel.SetWatchFolderOutputPath(path); - } + await UiOperations.RunAsync( + "select watch-folder output folder", + Loc.Instance["FileTranscription.OutputFolderOptional"], + // SetWatchFolderOutputPath synchronously persists via SettingsService.Save + // (File.WriteAllText/Move), so a disk-full/read-only write throws here too. + UiFailureKind.StorageProvider | UiFailureKind.FileSystem, + async () => + { + var path = await PickFolderAsync("Select output folder"); + if (!string.IsNullOrWhiteSpace(path)) + { + viewModel.SetWatchFolderOutputPath(path); + } + } + ); } private async Task PickFolderAsync(string title) @@ -303,4 +393,16 @@ private void SetDragOver(bool isDragOver) viewModel.IsDragOver = isDragOver; } } -} \ No newline at end of file + + private static UiOperationGuard UiOperations => + Program.Services.GetRequiredService(); + + private static Task PresentStatusAsync( + FileTranscriptionSectionViewModel viewModel, + string message + ) + { + viewModel.StatusText = message; + return Task.CompletedTask; + } +} diff --git a/src/TypeWhisper.Linux/Views/Sections/ProfilesSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/ProfilesSection.axaml.cs index fe3d26854..bc0695c26 100644 --- a/src/TypeWhisper.Linux/Views/Sections/ProfilesSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/ProfilesSection.axaml.cs @@ -1,6 +1,9 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; +using Microsoft.Extensions.DependencyInjection; +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Localization; using TypeWhisper.Linux.ViewModels.Sections; namespace TypeWhisper.Linux.Views.Sections; @@ -69,18 +72,27 @@ DataContext is not ProfilesSectionViewModel viewModel return; } - var dialog = new MessageDialogWindow(); - var confirmed = await dialog.ShowConfirmationAsync( - "Delete profile", - "Delete the selected profile?", - "Delete" - ); + await UiOperations.RunAsync( + "confirm and delete profile", + Loc.Instance["Common.Delete"], + UiFailureKind.Window, + async () => + { + var dialog = new MessageDialogWindow(); + var confirmed = await dialog.ShowConfirmationAsync( + "Delete profile", + "Delete the selected profile?", + "Delete" + ); - if (!confirmed) - { - return; - } - - viewModel.DeleteSelectedProfileCommand.Execute(null); + if (confirmed) + { + viewModel.DeleteSelectedProfileCommand.Execute(null); + } + } + ); } + + private static UiOperationGuard UiOperations => + Program.Services.GetRequiredService(); } diff --git a/src/TypeWhisper.Linux/Views/Sections/RecorderSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/RecorderSection.axaml.cs index 299e51fa1..fe4477555 100644 --- a/src/TypeWhisper.Linux/Views/Sections/RecorderSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/RecorderSection.axaml.cs @@ -1,6 +1,10 @@ using Avalonia.Controls; using Avalonia.Input.Platform; using Avalonia.Interactivity; +using Microsoft.Extensions.DependencyInjection; +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Localization; +using TypeWhisper.Linux.ViewModels.Sections; namespace TypeWhisper.Linux.Views.Sections; @@ -22,10 +26,30 @@ private async void OnCopyTranscript(object? sender, RoutedEventArgs e) return; } - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel?.Clipboard is not null) - { - await topLevel.Clipboard.SetTextAsync(transcript); - } + await UiOperations.RunAsync( + "copy recorder transcript", + Loc.Instance["Common.Copy"], + UiFailureKind.Clipboard, + async () => + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.Clipboard is not null) + { + await topLevel.Clipboard.SetTextAsync(transcript); + } + }, + presenter: message => + { + if (DataContext is RecorderSectionViewModel viewModel) + { + viewModel.StatusText = message; + } + + return Task.CompletedTask; + } + ); } -} \ No newline at end of file + + private static UiOperationGuard UiOperations => + Program.Services.GetRequiredService(); +} diff --git a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs index 440a68766..b420f070b 100644 --- a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs @@ -39,6 +39,30 @@ public void CanonicalCatalog_HasAutostartPreservationMessageWithPathPlaceholder( Assert.Contains("{0}", value, StringComparison.Ordinal); } + [Theory] + [InlineData("en")] + [InlineData("de")] + [InlineData("es")] + [InlineData("ru")] + public void Catalogs_HaveUiOperationFailureStringsWithRequiredPlaceholders( + string language + ) + { + var catalog = Load(language); + + Assert.True( + catalog.TryGetValue("Common.OperationFailed", out var pattern), + $"Missing {language} key: Common.OperationFailed" + ); + Assert.Contains("{0}", pattern, StringComparison.Ordinal); + Assert.Contains("{1}", pattern, StringComparison.Ordinal); + Assert.True( + catalog.TryGetValue("Common.OperationFailedTitle", out var title), + $"Missing {language} key: Common.OperationFailedTitle" + ); + Assert.False(string.IsNullOrWhiteSpace(title)); + } + [Fact] public void CanonicalCatalog_HasNativeDictationDisclosuresWithoutObsoleteEvdevClaims() { diff --git a/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs index be50ee766..7142552ba 100644 --- a/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs @@ -16,6 +16,11 @@ public sealed class ProfilesSectionViewModelTests : IDisposable private readonly string _tempDir = TestPaths.CreateTempDirectory( "TypeWhisper.ProfilesSectionViewModelTests" ); + private readonly UiOperationGuard _uiOperations = new( + Mock.Of(), + _ => Task.CompletedTask, + (operation, reason) => $"{operation} failed: {reason}" + ); public void Dispose() { @@ -46,7 +51,8 @@ public void Constructor_SeedsGlobalDefaultModelOption() _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ); var option = Assert.Single(sut.ModelOptions); @@ -70,7 +76,8 @@ public void Constructor_DoesNotInspectActiveWindow() _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ); activeWindow.VerifyNoOtherCalls(); @@ -100,7 +107,8 @@ public void ToggleProfileEnabled_UsesAtomicServiceOperationAndRefreshesProfiles( _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ); sut.ToggleProfileEnabledCommand.Execute(profile); @@ -110,6 +118,49 @@ public void ToggleProfileEnabled_UsesAtomicServiceOperationAndRefreshesProfiles( Assert.True(Assert.Single(sut.Profiles).IsEnabled); } + [Fact] + public void AddProfile_PersistenceFailure_DoesNotEscapeAndResyncsAndPresents() + { + var committed = CreateEditableProfile(); + var profiles = new Mock(); + profiles.SetupGet(service => service.Profiles).Returns([committed]); + profiles + .Setup(service => service.AddProfile(It.IsAny())) + .Throws(new IOException("disk full")); + var presented = new List(); + var uiOperations = new UiOperationGuard( + Mock.Of(), + message => + { + presented.Add(message); + return Task.CompletedTask; + }, + (operation, reason) => $"{operation} failed: {reason}" + ); + var activeWindow = CreateActiveWindowService(); + using var pluginManager = CreatePluginManager(); + var promptActions = new PromptActionService(Path.Join(_tempDir, "prompt-actions.json")); + var sut = new ProfilesSectionViewModel( + profiles.Object, + activeWindow.Object, + pluginManager, + promptActions, + _hotkeys, + Mock.Of(), + new GnomeWindowCallsSetupHelper(), + new BrowserAccessibilitySetupHelper(), + uiOperations + ); + + var exception = Record.Exception(() => sut.AddProfileCommand.Execute(null)); + + Assert.Null(exception); + Assert.Equal(committed, Assert.Single(sut.Profiles)); + Assert.Equal(committed, sut.SelectedProfile); + Assert.Equal(committed.Name, sut.EditName); + Assert.Equal(["Add failed: disk full"], presented); + } + [Fact] public void SaveProfile_PersistsConfiguredOverrides() { @@ -126,7 +177,8 @@ public void SaveProfile_PersistsConfiguredOverrides() _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ); sut.AddProfileCommand.Execute(null); @@ -166,6 +218,52 @@ public void SaveProfile_PersistsConfiguredOverrides() Assert.False(profile.DeveloperFormattingOverride); } + [Fact] + public void SaveProfile_PersistenceFailure_DoesNotEscapeAndResyncsAndPresents() + { + var committed = CreateEditableProfile() with { Name = "Committed" }; + var profiles = new Mock(); + profiles.SetupGet(service => service.Profiles).Returns([committed]); + profiles + .Setup(service => service.UpdateProfile(It.IsAny())) + .Throws(new UnauthorizedAccessException("read-only profile store")); + var presented = new List(); + var uiOperations = new UiOperationGuard( + Mock.Of(), + message => + { + presented.Add(message); + return Task.CompletedTask; + }, + (operation, reason) => $"{operation} failed: {reason}" + ); + var activeWindow = CreateActiveWindowService(); + using var pluginManager = CreatePluginManager(); + var promptActions = new PromptActionService(Path.Join(_tempDir, "prompt-actions.json")); + var sut = new ProfilesSectionViewModel( + profiles.Object, + activeWindow.Object, + pluginManager, + promptActions, + _hotkeys, + Mock.Of(), + new GnomeWindowCallsSetupHelper(), + new BrowserAccessibilitySetupHelper(), + uiOperations + ) + { + EditName = "Unsaved draft", + }; + + var exception = Record.Exception(() => sut.SaveProfileCommand.Execute(null)); + + Assert.Null(exception); + Assert.Equal(committed, Assert.Single(sut.Profiles)); + Assert.Equal(committed, sut.SelectedProfile); + Assert.Equal("Committed", sut.EditName); + Assert.Equal(["Save failed: read-only profile store"], presented); + } + [Fact] public void SaveProfile_MalformedBindingDoesNotUpdateAndShowsFeedback() { @@ -182,7 +280,8 @@ public void SaveProfile_MalformedBindingDoesNotUpdateAndShowsFeedback() _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ) { EditHotkeyData = "Ctrl+NoSuchKey", @@ -221,7 +320,8 @@ public void SaveProfile_CrossDynamicPrefixCollisionDoesNotUpdate() _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ) { EditHotkeyData = "Ctrl+Alt+E", @@ -269,7 +369,8 @@ bool addDisabledAction _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ) { EditHotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText, @@ -311,7 +412,8 @@ public void SaveProfile_SelectedTextBindingWithEnabledActionPersistsCanonicalCho _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ) { EditHotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText, @@ -353,7 +455,8 @@ public void SaveProfile_StartDictationAcceptsValidOrBlankBinding( _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ) { EditHotkeyBehavior = ProfileHotkeyBehavior.StartDictation, @@ -403,7 +506,8 @@ public async Task ActivateLiveContext_AppliesOneSnapshotAndTracksMatchedProfile( _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ); try @@ -459,7 +563,8 @@ public async Task DeactivateLiveContext_DiscardsCompletingInFlightUpdate() _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ); sut.ActivateLiveContext(); @@ -510,7 +615,8 @@ public async Task UpdateCurrentWindowAsync_IsSingleFlight() _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ); try @@ -553,7 +659,8 @@ public async Task LiveContextActivation_IsReferenceCounted() _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ); sut.ActivateLiveContext(); @@ -601,7 +708,8 @@ public void RefreshPromptActionOptions_ExcludesManualOnlyActions() _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ); // First entry is the "No prompt action" placeholder; the manual-only @@ -629,7 +737,8 @@ public async Task AddCurrentProcessRule_AddsFocusedProcessToSelectedProfileDraft _hotkeys, Mock.Of(), new GnomeWindowCallsSetupHelper(), - new BrowserAccessibilitySetupHelper() + new BrowserAccessibilitySetupHelper(), + _uiOperations ); try diff --git a/tests/TypeWhisper.Linux.Tests/UiOperationGuardTests.cs b/tests/TypeWhisper.Linux.Tests/UiOperationGuardTests.cs new file mode 100644 index 000000000..b9db94fca --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/UiOperationGuardTests.cs @@ -0,0 +1,238 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using Moq; +using Tmds.DBus.Protocol; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Linux.Services; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class UiOperationGuardTests +{ + public static TheoryData ExpectedFailures() + { + return new TheoryData + { + { UiFailureKind.FileSystem, new IOException("disk full") }, + { + UiFailureKind.FileSystem, + new UnauthorizedAccessException("access denied") + }, + { + UiFailureKind.StorageProvider, + new DBusErrorReplyException( + "org.freedesktop.portal.Error.Failed", + "portal failed" + ) + }, + { + UiFailureKind.StorageProvider, + new TimeoutException("portal timed out") + }, + { + UiFailureKind.Window, + new Win32Exception(5, "native platform failed") + }, + { + UiFailureKind.Clipboard, + new ExternalException("clipboard platform failed") + }, + { + UiFailureKind.Clipboard, + new ObjectDisposedException("clipboard") + }, + { + UiFailureKind.Clipboard, + new TimeoutException("clipboard timed out") + }, + { + UiFailureKind.Window, + new DBusErrorReplyException( + "org.freedesktop.portal.Error.Failed", + "window portal failed" + ) + }, + { + UiFailureKind.Window, + new ObjectDisposedException("window") + }, + { + UiFailureKind.Window, + new ExternalException("native window failed") + }, + }; + } + + [Theory] + [MemberData(nameof(ExpectedFailures))] + public async Task RunAsync_ExpectedFailure_RollsBackThenPresentsAndLogs( + UiFailureKind failureKind, + Exception failure + ) + { + var events = new List(); + var errorLog = new Mock(); + var guard = CreateGuard( + errorLog, + message => + { + events.Add($"present:{message}"); + return Task.CompletedTask; + } + ); + + var result = await guard.RunAsync( + "export transcription", + "Export", + failureKind, + () => Task.FromException(failure), + () => + { + events.Add("rollback"); + return Task.CompletedTask; + } + ); + + Assert.False(result); + Assert.Equal("rollback", events[0]); + Assert.Equal($"present:Export failed: {failure.Message}", events[1]); + errorLog.Verify( + log => log.AddEntry( + It.Is(message => + message.Contains( + "UI operation 'export transcription' failed", + StringComparison.Ordinal + ) + ), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public void Run_SynchronousAction_ContainsExpectedFailure() + { + var events = new List(); + var errorLog = new Mock(); + var guard = CreateGuard( + errorLog, + message => + { + events.Add($"present:{message}"); + return Task.CompletedTask; + } + ); + + var result = guard.Run( + "save profile", + "Save", + UiFailureKind.FileSystem, + () => throw new IOException("read-only filesystem"), + () => events.Add("rollback") + ); + + Assert.False(result); + Assert.Equal( + ["rollback", "present:Save failed: read-only filesystem"], + events + ); + } + + [Fact] + public async Task RunAsync_PresenterThrows_DoesNotEscape() + { + var errorLog = new Mock(); + var guard = CreateGuard( + errorLog, + _ => throw new InvalidOperationException("dialog failed") + ); + + var result = await guard.RunAsync( + "select files", + "Select files", + UiFailureKind.FileSystem, + () => Task.FromException(new IOException("portal failed")) + ); + + Assert.False(result); + errorLog.Verify( + log => log.AddEntry( + It.Is(message => + message.Contains("failure presenter", StringComparison.Ordinal) + ), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task RunAsync_NonExpectedFailure_PropagatesWithoutRecovery() + { + var rollbackCalled = false; + var presenterCalled = false; + var errorLog = new Mock(); + var guard = CreateGuard( + errorLog, + _ => + { + presenterCalled = true; + return Task.CompletedTask; + } + ); + + await Assert.ThrowsAsync(() => + guard.RunAsync( + "format profile", + "Format", + UiFailureKind.FileSystem, + () => + Task.FromException( + new InvalidOperationException("programming failure") + ), + () => + { + rollbackCalled = true; + return Task.CompletedTask; + } + ) + ); + + Assert.False(rollbackCalled); + Assert.False(presenterCalled); + errorLog.Verify( + log => log.AddEntry(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task RunAsync_OutOfMemoryFailure_PropagatesWithoutRecovery() + { + var errorLog = new Mock(); + var guard = CreateGuard(errorLog, _ => Task.CompletedTask); + + await Assert.ThrowsAsync(() => + guard.RunAsync( + "allocate", + "Allocate", + UiFailureKind.FileSystem, + () => Task.FromException(new OutOfMemoryException("fatal")) + ) + ); + } + + private static UiOperationGuard CreateGuard( + Mock errorLog, + Func presenter + ) + { + return new UiOperationGuard( + errorLog.Object, + presenter, + (operation, reason) => $"{operation} failed: {reason}" + ); + } +} From 2b7503454c69567a19d58cec4aa4f4dbe99b266e Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 16:02:27 +0000 Subject: [PATCH 150/226] Park the hidden dictation overlay offscreen so it cannot swallow clicks The overlay is mapped once and driven by Opacity/IsHitTestVisible (the GNOME Mutter workaround), but neither Avalonia property clears a mapped toplevel's native X11 input region: once the overlay had acquired its visible size, hiding it left a transparent topmost rectangle at its onscreen coordinates that swallowed pointer events indefinitely on X11/XWayland. Reposition events could even move the hidden window back onscreen because PositionOverlay gated on Avalonia IsVisible, which stays true for a mapped-once window. Hiding now parks the still-mapped window beyond every monitor's bounds (one pixel past the union's right edge, like the correction toast), and an explicit placement state - not IsVisible - gates every reposition path, so settings, screen, and size events preserve the parked position while hidden. Showing restores the configured placement after Loaded layout and only then reveals the window, so there is no visible offscreen-to-onscreen jump. A drag that ends after content clears re-parks the window, and position changes while hidden are never persisted as the user's custom placement. On Wayland client positioning may be ignored, where parking remains a harmless best effort. --- .../Views/DictationOverlayWindow.axaml.cs | 220 ++++++++++++++++-- .../DictationOverlayWindowTests.cs | 104 +++++++++ 2 files changed, 307 insertions(+), 17 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/DictationOverlayWindowTests.cs diff --git a/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs b/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs index 76fa2204d..fbcad5330 100644 --- a/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs +++ b/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs @@ -15,6 +15,7 @@ public partial class DictationOverlayWindow : Window { private readonly ISettingsService? _settings; private readonly DictationOverlayViewModel? _viewModel; + private readonly DictationOverlayPlacementState _placementState = new(); private bool _userDragging; private bool _programmaticPositionChange; private DispatcherTimer? _dragSaveTimer; @@ -160,23 +161,58 @@ private void UpdateWindowVisibility() // WORKAROUND (backlog item 16): Show() once and drive visibility via Opacity instead of // Hide() — Avalonia's Show() after Hide() is unreliable on GNOME Mutter for utility windows // (ShowActivated=False / Topmost / ShowInTaskbar=False): some shows leave the window - // invisible until restart. Fully transparent surface is free; inner Border bindings - // still control which content is drawn. + // invisible until restart. Inner Border bindings still control which content is drawn. var hasContent = _viewModel.HasVisibleContent; if (!IsVisible) { + // Keep the first mapping transparent too: OnOverlayOpened parks it before + // _placementState.Show() runs, so a content-bearing first show is only revealed + // by the Loaded-priority reposition below. + Opacity = 0.0; + IsHitTestVisible = false; Show(); MakeStickyAcrossWorkspaces(); } - Opacity = hasContent ? 1.0 : 0.0; - IsHitTestVisible = hasContent; - if (hasContent) { - Dispatcher.UIThread.Post(PositionOverlay, DispatcherPriority.Loaded); + _placementState.Show(); + + // Post at Loaded so a size-changing transition uses final dimensions; staying + // transparent at the parked position until this runs avoids a visible jump. + Dispatcher.UIThread.Post( + () => + { + if (!_placementState.IsShown) + { + return; + } + + PositionOverlay(); + if (!_placementState.IsShown) + { + return; + } + + Opacity = 1.0; + IsHitTestVisible = true; + }, + DispatcherPriority.Loaded + ); + return; } + + // Opacity and Avalonia's IsHitTestVisible do not clear a mapped toplevel's native X11 + // input region. Leaving this Topmost window at its visible coordinates would therefore + // create a transparent dead-click rectangle on X11/XWayland. Keep it mapped for the + // Mutter workaround, but park it beyond every monitor while hidden, like the correction + // toast. Wayland may ignore client positioning, where this remains a harmless best effort. + Opacity = 0.0; + IsHitTestVisible = false; + SetPositionProgrammatically( + _placementState.Hide(CollectScreenBounds(), Position) + ); } // Cached — the desktop environment can't change within a session. @@ -204,7 +240,25 @@ private void MakeStickyAcrossWorkspaces() private void PositionOverlay() { - if (!IsVisible || _settings is null) + if (!IsVisible) + { + return; + } + + var screenBounds = CollectScreenBounds(); + + // IsVisible stays true for the mapped-once Mutter workaround. Consult our own content + // state instead, so settings, screen, and size events recompute (or preserve) an offscreen + // parked position rather than moving the transparent X11 input rectangle back on-screen. + if (!_placementState.IsShown) + { + SetPositionProgrammatically( + _placementState.Reposition(Position, screenBounds, Position) + ); + return; + } + + if (_settings is null) { return; } @@ -248,20 +302,62 @@ private void PositionOverlay() width, height); SetPositionProgrammatically( - new PixelPoint( - (int)Math.Round(clampedLeft), - (int)Math.Round(clampedTop))); + _placementState.Reposition( + new PixelPoint( + (int)Math.Round(clampedLeft), + (int)Math.Round(clampedTop) + ), + screenBounds, + Position + ) + ); return; } var workArea = primaryScreen.WorkingArea; - var x = workArea.X + (workArea.Width - (int)Math.Ceiling(width)) / 2; - var y = - _settings.Current.OverlayPosition == OverlayPosition.Top - ? workArea.Y + 12 - : workArea.Bottom - (int)Math.Ceiling(height) - 12; + var configuredPosition = DictationOverlayPlacementState.ComputeConfiguredPosition( + _settings.Current.OverlayPosition, + workArea, + new PixelSize( + (int)Math.Ceiling(width), + (int)Math.Ceiling(height) + ) + ); + + SetPositionProgrammatically( + _placementState.Reposition( + configuredPosition, + screenBounds, + Position + ) + ); + } + + private List CollectScreenBounds() + { + var result = new List(); + + var screens = Screens; + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract -- Avalonia annotates Screens non-null, but it can be null before the platform window is realized; the placement state tolerates an empty list (mirrors LearnedCorrectionToastWindow). + if (screens is null) + { + return result; + } + + // Put the primary first so parking uses a stable Y coordinate, matching the correction + // toast's documented native-X11 workaround. + if (screens.Primary is { } primary) + { + result.Add(primary.Bounds); + } + + result.AddRange( + screens.All + .Where(screen => !ReferenceEquals(screen, screens.Primary)) + .Select(screen => screen.Bounds) + ); - SetPositionProgrammatically(new PixelPoint(x, y)); + return result; } private void SetPositionProgrammatically(PixelPoint point) @@ -295,12 +391,26 @@ private void OnUserPointerPressed(object? sender, PointerPressedEventArgs e) private void OnUserPointerReleased(object? sender, PointerReleasedEventArgs e) { - _userDragging = false; + EndUserDrag(); } private void OnUserPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) + { + EndUserDrag(); + } + + private void EndUserDrag() { _userDragging = false; + + // A move-drag that outlived a hide (content cleared mid-drag) leaves the still-mapped + // window on-screen wherever the WM's interactive move dropped it — that grab overrides + // our one-off park while active. Its native X11 input region stays live regardless of + // IsHitTestVisible, so re-park now rather than waiting for a later screen/settings/size event. + if (!_placementState.IsShown) + { + PositionOverlay(); + } } private void OnUserPositionChanged(object? sender, PixelPointEventArgs e) @@ -310,6 +420,16 @@ private void OnUserPositionChanged(object? sender, PixelPointEventArgs e) return; } + // A hidden overlay is only ever moved programmatically (parked off-screen). On X11 the + // move's PositionChanged arrives asynchronously — after SetPositionProgrammatically has + // cleared _programmaticPositionChange — so if content clears mid-drag the parked sentinel + // could be mistaken for a user drag and persisted as the saved position. Never persist a + // position while parked. + if (!_placementState.IsShown) + { + return; + } + if (!_userDragging) { return; @@ -344,3 +464,69 @@ private void OnDragSaveTimerTick(object? sender, EventArgs e) }); } } + +/// +/// Deterministic visibility and placement decisions for the mapped-once dictation overlay, +/// kept independent of Window/Screens so it can be unit tested without a live compositor. +/// +internal sealed class DictationOverlayPlacementState +{ + private const int ScreenEdgeInset = 12; + + public bool IsShown { get; private set; } + + public void Show() + { + IsShown = true; + } + + public PixelPoint Hide( + IReadOnlyList screenBounds, + PixelPoint currentPosition + ) + { + IsShown = false; + return ComputeParkedPosition(screenBounds, currentPosition); + } + + public PixelPoint Reposition( + PixelPoint configuredPosition, + IReadOnlyList screenBounds, + PixelPoint currentPosition + ) + { + return IsShown + ? configuredPosition + : ComputeParkedPosition(screenBounds, currentPosition); + } + + public static PixelPoint ComputeConfiguredPosition( + OverlayPosition overlayPosition, + PixelRect workArea, + PixelSize overlaySize + ) + { + var x = workArea.X + (workArea.Width - overlaySize.Width) / 2; + var y = overlayPosition == OverlayPosition.Top + ? workArea.Y + ScreenEdgeInset + : workArea.Bottom - overlaySize.Height - ScreenEdgeInset; + + return new PixelPoint(x, y); + } + + private static PixelPoint ComputeParkedPosition( + IReadOnlyList screenBounds, + PixelPoint currentPosition + ) + { + if (screenBounds.Count == 0) + { + return currentPosition; + } + + // Match LearnedCorrectionToastWindow: the left edge just beyond the union's right boundary + // puts the entire mapped window outside every monitor, including negative-origin layouts. + var right = screenBounds.Max(bounds => bounds.Right); + return new PixelPoint(right + 1, screenBounds[0].Y); + } +} diff --git a/tests/TypeWhisper.Linux.Tests/DictationOverlayWindowTests.cs b/tests/TypeWhisper.Linux.Tests/DictationOverlayWindowTests.cs new file mode 100644 index 000000000..bfda8488b --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/DictationOverlayWindowTests.cs @@ -0,0 +1,104 @@ +using Avalonia; +using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Views; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +/// +/// Pure placement/state coverage for the mapped-once dictation overlay. These tests require no +/// Avalonia window, X server, Wayland compositor, input device, or per-user filesystem state. +/// +public sealed class DictationOverlayWindowTests +{ + private static readonly PixelRect s_primary = new(0, 0, 1920, 1080); + private static readonly PixelSize s_overlaySize = new(320, 56); + + [Fact] + public void Hiding_ParksBeyondSuppliedMonitorBounds() + { + var state = new DictationOverlayPlacementState(); + state.Show(); + + var parked = state.Hide( + [s_primary], + new PixelPoint(800, 1012) + ); + + Assert.False(state.IsShown); + Assert.Equal(s_primary.Right + 1, parked.X); + Assert.True(parked.X > s_primary.Right); + } + + [Fact] + public void RepositionWhileHidden_KeepsWindowParked() + { + var state = new DictationOverlayPlacementState(); + var parked = state.Hide( + [s_primary], + new PixelPoint(800, 1012) + ); + + var afterReposition = state.Reposition( + new PixelPoint(800, 12), + [s_primary], + parked + ); + + Assert.Equal(parked, afterReposition); + Assert.True(afterReposition.X > s_primary.Right); + } + + [Theory] + [InlineData(OverlayPosition.Top, 740, -188)] + [InlineData(OverlayPosition.Bottom, 740, 632)] + public void Showing_RestoresConfiguredPosition( + OverlayPosition overlayPosition, + int expectedX, + int expectedY + ) + { + var workArea = new PixelRect(100, -200, 1600, 900); + var state = new DictationOverlayPlacementState(); + var parked = state.Hide( + [workArea], + new PixelPoint(expectedX, expectedY) + ); + Assert.True(parked.X > workArea.Right); + + state.Show(); + var configured = DictationOverlayPlacementState.ComputeConfiguredPosition( + overlayPosition, + workArea, + s_overlaySize + ); + var restored = state.Reposition( + configured, + [workArea], + parked + ); + + Assert.True(state.IsShown); + Assert.Equal(new PixelPoint(expectedX, expectedY), restored); + } + + [Fact] + public void Parking_WithNegativeOriginMonitorLayout_StaysBeyondEveryScreen() + { + PixelRect[] screens = + [ + new(-1920, 0, 1920, 1080), + new(0, -1200, 1920, 1200), + new(0, 0, 2560, 1440), + ]; + var state = new DictationOverlayPlacementState(); + + var parked = state.Hide( + screens, + new PixelPoint(-960, 1012) + ); + + Assert.Equal(2561, parked.X); + Assert.All(screens, screen => Assert.True(parked.X > screen.Right)); + } +} From 076d1be92013e56544e8ca9b509f4ae6325847aa Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 16:08:35 +0000 Subject: [PATCH 151/226] Gate watch-folder auto-start on plugin bootstrap and retry readiness Watch-folder auto-start began during eager ViewModel construction while plugin initialization was still deferred: an existing stable file could reach model resolution with an empty engine list, fail as an unknown engine, and land in the run-local failed-fingerprint set - suppressed for the rest of the session even though the failure was purely a startup-ordering artifact. Auto-start now runs as a bootstrap stage that depends only on plugin initialization (deliberately not on the global model auto-load: watch overrides load their model on demand, so a failed global load must not suppress watch auto-start). The constructor no longer posts a start. The transcribe handler classifies readiness before invoking the processor - empty engine list or configured engine absent, by typed WatchFolderNotReadyException, no string matching - and the service gives readiness failures three bounded attempts with injectable 2s/4s backoff before recording the single failure exactly as before; other failures remain single-attempt. PA38 tracks the default selected-model path whose readiness cannot be classified without ModelManagerService's resolution logic. --- src/TypeWhisper.Linux/App.axaml.cs | 13 + .../Services/WatchFolderService.cs | 83 ++++++- .../FileTranscriptionSectionViewModel.cs | 61 ++++- .../AppBootstrapTests.cs | 67 +++++- .../FileTranscriptionSectionViewModelTests.cs | 127 +++++++++- .../WatchFolderServiceTests.cs | 224 ++++++++++++++++++ 6 files changed, 541 insertions(+), 34 deletions(-) diff --git a/src/TypeWhisper.Linux/App.axaml.cs b/src/TypeWhisper.Linux/App.axaml.cs index 5ed842237..0ace3800d 100644 --- a/src/TypeWhisper.Linux/App.axaml.cs +++ b/src/TypeWhisper.Linux/App.axaml.cs @@ -808,6 +808,18 @@ IServiceProvider services }, Required: false ), + new( + BootstrapStageNames.WatchFolderAutoStart, + [BootstrapStageNames.PluginInitialization], + () => + { + services + .GetRequiredService() + .TryAutoStartWatchFolder(); + return Task.CompletedTask; + }, + Required: false + ), ]; } @@ -892,6 +904,7 @@ internal static class BootstrapStageNames public const string RetentionInitialization = "Retention initialization"; public const string ModelMigration = "Model migration"; public const string ModelAutoLoad = "Model auto-load"; + public const string WatchFolderAutoStart = "Watch-folder auto-start"; } internal sealed record BootstrapStage( diff --git a/src/TypeWhisper.Linux/Services/WatchFolderService.cs b/src/TypeWhisper.Linux/Services/WatchFolderService.cs index e0ad63ae9..cf324a2d9 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderService.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderService.cs @@ -6,9 +6,19 @@ namespace TypeWhisper.Linux.Services; +internal sealed class WatchFolderNotReadyException : Exception +{ + public WatchFolderNotReadyException(string message) + : base(message) + { + } +} + public sealed class WatchFolderService : IDisposable, IAsyncDisposable { private const int MaxExportPathAttempts = 1000; + internal const int ReadinessRetryAttemptLimit = 3; + private static readonly TimeSpan s_readinessRetryBaseDelay = TimeSpan.FromSeconds(2); private static readonly TimeSpan s_workerDrainDeadline = TimeSpan.FromSeconds(2); private static readonly JsonSerializerOptions s_jsonOptions = new() @@ -25,6 +35,7 @@ public sealed class WatchFolderService : IDisposable, IAsyncDisposable private readonly SemaphoreSlim _lifecycleGate = new(1, 1); private readonly Action _atomicWriteAllText; private readonly Lock _persistenceGate = new(); + private readonly Func _readinessRetryDelay; private readonly HashSet _processedFingerprints = new(StringComparer.Ordinal); private readonly string _processedFingerprintsBackupPath; private readonly string _processedFingerprintsPath; @@ -45,7 +56,21 @@ internal WatchFolderService(string dataPath) : this( dataPath, static (workers, timeout) => workers.WaitAsync(timeout), - AtomicFileWrite.WriteAllText + AtomicFileWrite.WriteAllText, + static (delay, ct) => Task.Delay(delay, ct) + ) + { + } + + internal WatchFolderService( + string dataPath, + Func readinessRetryDelay + ) + : this( + dataPath, + static (workers, timeout) => workers.WaitAsync(timeout), + AtomicFileWrite.WriteAllText, + readinessRetryDelay ) { } @@ -54,7 +79,12 @@ internal WatchFolderService( string dataPath, Func waitForWorkers ) - : this(dataPath, waitForWorkers, AtomicFileWrite.WriteAllText) + : this( + dataPath, + waitForWorkers, + AtomicFileWrite.WriteAllText, + static (delay, ct) => Task.Delay(delay, ct) + ) { } @@ -62,10 +92,26 @@ internal WatchFolderService( string dataPath, Func waitForWorkers, Action atomicWriteAllText + ) + : this( + dataPath, + waitForWorkers, + atomicWriteAllText, + static (delay, ct) => Task.Delay(delay, ct) + ) + { + } + + internal WatchFolderService( + string dataPath, + Func waitForWorkers, + Action atomicWriteAllText, + Func readinessRetryDelay ) { _waitForWorkers = waitForWorkers; _atomicWriteAllText = atomicWriteAllText; + _readinessRetryDelay = readinessRetryDelay; Directory.CreateDirectory(dataPath); _processedFingerprintsPath = Path.Join(dataPath, "watch-folder-processed.json"); _processedFingerprintsBackupPath = _processedFingerprintsPath + ".bak"; @@ -609,10 +655,7 @@ CancellationToken ct return; } - var result = await run.TranscribeHandler( - new WatchFolderTranscriptionRequest(filePath), - ct - ); + var result = await TranscribeWithReadinessRetryAsync(run, filePath, ct); ct.ThrowIfCancellationRequested(); if (!IsRunCurrentAndLive(run)) { @@ -728,6 +771,34 @@ CancellationToken ct } } + private async Task TranscribeWithReadinessRetryAsync( + WatchFolderRun run, + string filePath, + CancellationToken ct + ) + { + for (var attempt = 1; ; attempt++) + { + try + { + return await run.TranscribeHandler( + new WatchFolderTranscriptionRequest(filePath), + ct + ); + } + catch (WatchFolderNotReadyException) + when (attempt < ReadinessRetryAttemptLimit) + { + var multiplier = 1 << (attempt - 1); + var delay = TimeSpan.FromTicks( + s_readinessRetryBaseDelay.Ticks * multiplier + ); + await _readinessRetryDelay(delay, ct); + ct.ThrowIfCancellationRequested(); + } + } + } + private static string CommitExport( string outputFolder, string baseName, diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionSectionViewModel.cs index 24fd9ab72..f4f04ec89 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionSectionViewModel.cs @@ -7,6 +7,7 @@ using TypeWhisper.Core.Services; using TypeWhisper.Linux.Services; using TypeWhisper.Linux.Services.Localization; +using TypeWhisper.Linux.Services.Plugins; // ReSharper disable UnusedParameterInPartialMethod @@ -18,6 +19,7 @@ public partial class FileTranscriptionSectionViewModel : ObservableObject private readonly AudioFileService _audioFiles; private readonly IFileTranscriptionProcessor _processor; + private readonly PluginManager _pluginManager; private readonly ISettingsService _settings; // One concurrent transcription at a time — shared between manual queue @@ -93,13 +95,15 @@ public FileTranscriptionSectionViewModel( IFileTranscriptionProcessor processor, ISettingsService settings, AudioFileService audioFiles, - WatchFolderService watchFolder + WatchFolderService watchFolder, + PluginManager pluginManager ) { _processor = processor; _settings = settings; _audioFiles = audioFiles; _watchFolder = watchFolder; + _pluginManager = pluginManager; Items.CollectionChanged += (_, _) => { @@ -115,16 +119,6 @@ WatchFolderService watchFolder // Item status texts and the queue summary are resolved into stored strings, // so re-resolve them when the user switches UI language at runtime. Loc.Instance.LanguageChanged += (_, _) => OnLanguageChanged(); - - if (WatchFolderAutoStart && HasWatchFolderPath) - { - // Defer past DI graph construction so a stale/hung watch path - // cannot prevent the main window from being created. - Dispatcher.UIThread.Post( - TryStartWatchFolder, - DispatcherPriority.Background - ); - } } public ObservableCollection Items { get; } = []; @@ -149,6 +143,14 @@ WatchFolderService watchFolder public bool HasWatchFolderHistory => WatchFolderHistory.Count > 0; public bool IsWatchFolderStopped => !IsWatchFolderRunning; + internal void TryAutoStartWatchFolder() + { + if (WatchFolderAutoStart && HasWatchFolderPath) + { + TryStartWatchFolder(); + } + } + public string WatchFolderOutputPathDisplay => HasWatchFolderOutputPath ? WatchFolderOutputPath! @@ -557,13 +559,16 @@ private async Task TranscribeWatchFolderFileAsyn CancellationToken ct ) { + var options = BuildWatchFolderProcessOptions(); + ThrowIfWatchFolderNotReady(options); + await _transcriptionGate.WaitAsync(ct); try { var result = await _processor.ProcessAsync( request.FilePath, _ => { }, - BuildWatchFolderProcessOptions(), + options, ct ); @@ -583,6 +588,38 @@ CancellationToken ct } } + private void ThrowIfWatchFolderNotReady(FileTranscriptionProcessOptions options) + { + var engines = _pluginManager.TranscriptionEngines; + if (engines.Count == 0) + { + throw new WatchFolderNotReadyException( + "Transcription engines are not ready." + ); + } + + if ( + !string.IsNullOrWhiteSpace(options.EngineId) + && engines.All(engine => + !string.Equals( + engine.ProviderId, + options.EngineId, + StringComparison.OrdinalIgnoreCase + ) + && !string.Equals( + engine.PluginId, + options.EngineId, + StringComparison.OrdinalIgnoreCase + ) + ) + ) + { + throw new WatchFolderNotReadyException( + $"Transcription engine '{options.EngineId}' is not ready." + ); + } + } + private FileTranscriptionProcessOptions BuildWatchFolderProcessOptions() { var s = _settings.Current; diff --git a/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs b/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs index 45b228166..04742cc38 100644 --- a/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs @@ -6,44 +6,57 @@ namespace TypeWhisper.Linux.Tests; public sealed class AppBootstrapTests { - public static TheoryData ProductionStageFailures => + public static TheoryData ProductionStageFailures => new() { - { App.BootstrapStageNames.HistoryLoad, null }, - { App.BootstrapStageNames.SessionCleanup, null }, - { App.BootstrapStageNames.AudioConfiguration, null }, + { App.BootstrapStageNames.HistoryLoad, null, null }, + { App.BootstrapStageNames.SessionCleanup, null, null }, + { App.BootstrapStageNames.AudioConfiguration, null, null }, { App.BootstrapStageNames.BundledPluginDeployment, - App.BootstrapStageNames.PluginInitialization + App.BootstrapStageNames.PluginInitialization, + App.BootstrapStageNames.WatchFolderAutoStart }, - { App.BootstrapStageNames.PluginInitialization, null }, - { App.BootstrapStageNames.RetentionInitialization, null }, + { + App.BootstrapStageNames.PluginInitialization, + App.BootstrapStageNames.WatchFolderAutoStart, + null + }, + { App.BootstrapStageNames.RetentionInitialization, null, null }, { App.BootstrapStageNames.ModelMigration, - App.BootstrapStageNames.ModelAutoLoad + App.BootstrapStageNames.ModelAutoLoad, + null }, - { App.BootstrapStageNames.ModelAutoLoad, null }, + { App.BootstrapStageNames.ModelAutoLoad, null, null }, + { App.BootstrapStageNames.WatchFolderAutoStart, null, null }, }; [Theory] [MemberData(nameof(ProductionStageFailures))] public async Task RunAsync_WhenEachProductionStageFails_RunsIndependentStagesAndSkipsDependents( string failingStage, - string? expectedSkippedStage + string? firstExpectedSkippedStage, + string? secondExpectedSkippedStage ) { var attempted = new List(); var failure = new InvalidOperationException($"Failure in {failingStage}"); var stages = CreateProductionShapedStages(attempted, failingStage, failure); + var expectedSkippedStages = new[] + { + firstExpectedSkippedStage, + secondExpectedSkippedStage, + }.OfType().ToArray(); var report = await new App.BootstrapRunner(stages).RunAsync(); var expectedAttempted = stages .Select(stage => stage.Name) - .Where(name => name != expectedSkippedStage); + .Where(name => !expectedSkippedStages.Contains(name, StringComparer.Ordinal)); Assert.Equal(expectedAttempted, attempted); Assert.Equal( - expectedSkippedStage is null ? [] : [expectedSkippedStage], + expectedSkippedStages, report .Outcomes.Where(outcome => outcome.Status == App.BootstrapStageStatus.Skipped @@ -56,13 +69,41 @@ public async Task RunAsync_WhenEachProductionStageFails_RunsIndependentStagesAnd var expectedStatus = outcome.Name == failingStage ? App.BootstrapStageStatus.Failed - : outcome.Name == expectedSkippedStage + : expectedSkippedStages.Contains( + outcome.Name, + StringComparer.Ordinal + ) ? App.BootstrapStageStatus.Skipped : App.BootstrapStageStatus.Succeeded; Assert.Equal(expectedStatus, outcome.Status); } } + [Fact] + public void CreateBootstrapStages_WatchFolderAutoStart_IsAfterModelAutoLoadAndDependsOnlyOnPluginInitialization() + { + var stages = App.CreateBootstrapStages(new UnusedServiceProvider()); + var watchFolderAutoStart = Assert.Single( + stages, + stage => stage.Name == App.BootstrapStageNames.WatchFolderAutoStart + ); + + Assert.Equal( + [App.BootstrapStageNames.PluginInitialization], + watchFolderAutoStart.Dependencies + ); + Assert.True( + stages.Index() + .Single(item => item.Item.Name == App.BootstrapStageNames.ModelAutoLoad) + .Index + < stages.Index() + .Single(item => + item.Item.Name == App.BootstrapStageNames.WatchFolderAutoStart + ) + .Index + ); + } + [Fact] public async Task RunAsync_NonRequiredFailure_CapturesExceptionAndDoesNotThrow() { diff --git a/tests/TypeWhisper.Linux.Tests/FileTranscriptionSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/FileTranscriptionSectionViewModelTests.cs index 969d9512e..b8d7013a2 100644 --- a/tests/TypeWhisper.Linux.Tests/FileTranscriptionSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/FileTranscriptionSectionViewModelTests.cs @@ -1,6 +1,7 @@ using TypeWhisper.Core.Services; using TypeWhisper.Linux.Services; using TypeWhisper.Linux.Services.Localization; +using TypeWhisper.Linux.Services.Plugins; using TypeWhisper.Linux.ViewModels.Sections; using TypeWhisper.Tests; using Xunit; @@ -12,9 +13,15 @@ public sealed class FileTranscriptionSectionViewModelTests : IDisposable private readonly string _tempDir = TestPaths.CreateTempDirectory( "TypeWhisper.FileTranscriptionSectionViewModelTests" ); + private readonly List _pluginManagers = []; public void Dispose() { + foreach (var pluginManager in _pluginManagers) + { + pluginManager.Dispose(); + } + try { TestPaths.DeleteDirectory(_tempDir); @@ -60,6 +67,29 @@ public void HasClearableItems_FalseWhenNoTerminalItems() Assert.False(vm.HasClearableItems); } + [Fact] + public void Constructor_WithConfiguredAutoStart_WaitsForExplicitEntryPoint() + { + var watchPath = Path.Join(_tempDir, "configured-auto-start"); + Directory.CreateDirectory(watchPath); + var settings = CreateSettingsWithWatchFolder(watchPath, autoStart: true); + var vm = CreateViewModel(settings); + + try + { + Assert.False(vm.IsWatchFolderRunning); + + vm.TryAutoStartWatchFolder(); + + Assert.True(vm.IsWatchFolderRunning); + Assert.Equal(watchPath, vm.WatchFolderPath); + } + finally + { + vm.StopWatchFolderCommand.Execute(null); + } + } + [Fact] public void Constructor_WithPoisonedAutoStartPath_DoesNotThrow() { @@ -73,6 +103,54 @@ public void Constructor_WithPoisonedAutoStartPath_DoesNotThrow() Assert.False(vm.IsWatchFolderRunning); } + [Fact] + public async Task AutoStart_WhenPluginCapabilitiesAreEmpty_ClassifiesReadinessBeforeProcessor() + { + var watchPath = Path.Join(_tempDir, "not-ready-watch"); + var outputPath = Path.Join(_tempDir, "not-ready-output"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + File.WriteAllBytes(Path.Join(watchPath, "waiting.wav"), [1, 2, 3]); + var settings = CreateSettingsWithWatchFolder(watchPath, autoStart: true); + settings.Save( + settings.Current with + { + WatchFolderOutputPath = outputPath, + } + ); + var processor = new CountingProcessor(); + var watchFolder = new WatchFolderService( + Path.Join(_tempDir, "not-ready-data"), + readinessRetryDelay: static (_, ct) => + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + ); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + watchFolder.FileProcessed += (_, item) => completion.TrySetResult(item); + var vm = CreateViewModel(settings, processor, watchFolder); + + try + { + vm.TryAutoStartWatchFolder(); + + var item = await completion.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.False(item.Success); + Assert.Equal("Transcription engines are not ready.", item.ErrorMessage); + Assert.Equal(0, processor.CallCount); + Assert.Single(watchFolder.CurrentRun!.FailedFingerprints); + } + finally + { + await watchFolder.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + await watchFolder.DisposeAsync(); + } + } + [Fact] public void StartWatchFolder_WithPoisonedPath_ShowsErrorAndCanRecover() { @@ -145,15 +223,42 @@ settings.Current with return settings; } - private FileTranscriptionSectionViewModel CreateViewModel(SettingsService? settings = null) + private SettingsService CreateSettingsWithWatchFolder(string watchPath, bool autoStart) + { + var settings = new SettingsService( + Path.Join(_tempDir, $"settings-{Guid.NewGuid():N}.json") + ); + settings.Save( + settings.Current with + { + WatchFolderPath = watchPath, + WatchFolderAutoStart = autoStart, + } + ); + return settings; + } + + private FileTranscriptionSectionViewModel CreateViewModel( + SettingsService? settings = null, + IFileTranscriptionProcessor? processor = null, + WatchFolderService? watchFolder = null + ) { settings ??= new SettingsService(Path.Join(_tempDir, "settings.json")); var commands = new SystemCommandAvailabilityService(); var audioFiles = new AudioFileService(commands); - var watchFolder = new WatchFolderService( + watchFolder ??= new WatchFolderService( Path.Join(_tempDir, $"watch-folder-data-{Guid.NewGuid():N}") ); - return new FileTranscriptionSectionViewModel(new StubProcessor(), settings, audioFiles, watchFolder); + var pluginManager = TestPluginManagerFactory.Create(); + _pluginManagers.Add(pluginManager); + return new FileTranscriptionSectionViewModel( + processor ?? new StubProcessor(), + settings, + audioFiles, + watchFolder, + pluginManager + ); } private sealed class StubProcessor : IFileTranscriptionProcessor @@ -165,4 +270,20 @@ public Task ProcessAsync( CancellationToken cancellationToken ) => throw new NotSupportedException(); } + + private sealed class CountingProcessor : IFileTranscriptionProcessor + { + public int CallCount { get; private set; } + + public Task ProcessAsync( + string filePath, + Action onProgress, + FileTranscriptionProcessOptions? options, + CancellationToken cancellationToken + ) + { + CallCount++; + throw new InvalidOperationException("Processor should not be invoked before readiness."); + } + } } diff --git a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs index 2674ef7aa..c2318fa97 100644 --- a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs @@ -326,6 +326,230 @@ CancellationToken ct } } + [Fact] + public async Task ProcessFile_WhenReadinessRecoversWithinRetryBound_ProcessesWithoutFailedFingerprint() + { + var watchPath = Path.Join(_tempDir, "readiness-recovers-watch"); + var outputPath = Path.Join(_tempDir, "readiness-recovers-output"); + var dataPath = Path.Join(_tempDir, "readiness-recovers-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + File.WriteAllBytes(Path.Join(watchPath, "waiting.wav"), [1, 2, 3]); + var processed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var delays = new ConcurrentQueue(); + var attempts = 0; + var service = new WatchFolderService( + dataPath, + readinessRetryDelay: (delay, ct) => + { + ct.ThrowIfCancellationRequested(); + delays.Enqueue(delay); + return Task.CompletedTask; + } + ); + WatchFolderService.WatchFolderRun? run = null; + service.FileProcessed += (_, item) => processed.TrySetResult(item); + + try + { + service.Start( + CreateOptions(watchPath, outputPath), + (request, ct) => + { + ct.ThrowIfCancellationRequested(); + var attempt = Interlocked.Increment(ref attempts); + return attempt < WatchFolderService.ReadinessRetryAttemptLimit + ? Task.FromException( + new WatchFolderNotReadyException( + $"Capabilities unavailable on attempt {attempt}." + ) + ) + : Task.FromResult(CreateResult(request)); + } + ); + run = service.CurrentRun; + Assert.NotNull(run); + + var item = await processed.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.True(item.Success, item.ErrorMessage); + Assert.Equal(WatchFolderService.ReadinessRetryAttemptLimit, attempts); + Assert.Equal( + [TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4)], + delays + ); + Assert.Empty(run.FailedFingerprints); + Assert.Same(item, Assert.Single(service.History)); + Assert.True(File.Exists(Path.Join(outputPath, "waiting.txt"))); + } + finally + { + if (service.CurrentRun is not null) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (run is not null) + { + await run.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + + [Fact] + public async Task ProcessFile_WhenReadinessRetriesAreExhausted_RecordsSingleFailure() + { + var watchPath = Path.Join(_tempDir, "readiness-exhausted-watch"); + var outputPath = Path.Join(_tempDir, "readiness-exhausted-output"); + var dataPath = Path.Join(_tempDir, "readiness-exhausted-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + File.WriteAllBytes(Path.Join(watchPath, "waiting.wav"), [1, 2, 3]); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var processed = new ConcurrentQueue(); + var delays = new ConcurrentQueue(); + var attempts = 0; + var service = new WatchFolderService( + dataPath, + readinessRetryDelay: (delay, ct) => + { + ct.ThrowIfCancellationRequested(); + delays.Enqueue(delay); + return Task.CompletedTask; + } + ); + WatchFolderService.WatchFolderRun? run = null; + service.FileProcessed += (_, item) => + { + processed.Enqueue(item); + completion.TrySetResult(item); + }; + + try + { + service.Start( + CreateOptions(watchPath, outputPath), + (_, ct) => + { + ct.ThrowIfCancellationRequested(); + Interlocked.Increment(ref attempts); + return Task.FromException( + new WatchFolderNotReadyException("Capabilities are still unavailable.") + ); + } + ); + run = service.CurrentRun; + Assert.NotNull(run); + + var item = await completion.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.False(item.Success); + Assert.Equal("Capabilities are still unavailable.", item.ErrorMessage); + Assert.Equal(WatchFolderService.ReadinessRetryAttemptLimit, attempts); + Assert.Equal( + [TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4)], + delays + ); + Assert.Single(run.FailedFingerprints); + Assert.Same(item, Assert.Single(service.History)); + Assert.Same(item, Assert.Single(processed)); + Assert.False(File.Exists(Path.Join(outputPath, "waiting.txt"))); + } + finally + { + if (service.CurrentRun is not null) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (run is not null) + { + await run.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + + [Fact] + public async Task ProcessFile_WhenFailureIsNotReadiness_DoesNotRetry() + { + var watchPath = Path.Join(_tempDir, "ordinary-failure-watch"); + var outputPath = Path.Join(_tempDir, "ordinary-failure-output"); + var dataPath = Path.Join(_tempDir, "ordinary-failure-data"); + Directory.CreateDirectory(watchPath); + Directory.CreateDirectory(outputPath); + File.WriteAllBytes(Path.Join(watchPath, "broken.wav"), [1, 2, 3]); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var processed = new ConcurrentQueue(); + var delays = new ConcurrentQueue(); + var attempts = 0; + var service = new WatchFolderService( + dataPath, + readinessRetryDelay: (delay, ct) => + { + ct.ThrowIfCancellationRequested(); + delays.Enqueue(delay); + return Task.CompletedTask; + } + ); + WatchFolderService.WatchFolderRun? run = null; + service.FileProcessed += (_, item) => + { + processed.Enqueue(item); + completion.TrySetResult(item); + }; + + try + { + service.Start( + CreateOptions(watchPath, outputPath), + (_, ct) => + { + ct.ThrowIfCancellationRequested(); + Interlocked.Increment(ref attempts); + return Task.FromException( + new InvalidOperationException("Ordinary transcription failure.") + ); + } + ); + run = service.CurrentRun; + Assert.NotNull(run); + + var item = await completion.Task.WaitAsync(TimeSpan.FromSeconds(15)); + + Assert.False(item.Success); + Assert.Equal("Ordinary transcription failure.", item.ErrorMessage); + Assert.Equal(1, attempts); + Assert.Empty(delays); + Assert.Single(run.FailedFingerprints); + Assert.Same(item, Assert.Single(service.History)); + Assert.Same(item, Assert.Single(processed)); + } + finally + { + if (service.CurrentRun is not null) + { + await service.StopAsync().WaitAsync(TimeSpan.FromSeconds(15)); + } + + if (run is not null) + { + await run.WorkerCompletion.WaitAsync(TimeSpan.FromSeconds(15)); + } + + await service.DisposeAsync(); + } + } + [Fact] public async Task Restart_AfterTruncatedPrimaryWrite_RecoversBackupAndPublishesCompleteStore() { From 47cc361227c389a9df448167a0d952edb7718e04 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 16:18:15 +0000 Subject: [PATCH 152/226] Verify focus restoration before recent-transcription auto-paste Selecting a palette entry closed the window and inserted immediately. The service captured only an xdotool window ID before the palette stole focus - null on native Wayland, and stale even on Wayland-with-xdotool, where it reflects XWayland state - and insertion treated a null target as focused after a fixed 100 ms delay. Text could be typed or pasted into TypeWhisper itself or whatever surface the compositor picked. Capture now records the X11 handle plus a provider-chain identity snapshot before the palette shows; selection awaits the palette's actual Closed event; and with auto-paste on, insertion requires the focused surface to match the captured identity within a bounded 1s poll. Insertion authority, strongest first: verified focus, then the X11 window-ID activation path (the insertion layer re-activates that window deterministically - never trusted on Wayland, where an xdotool identity is discarded at capture), then clipboard-only fallback with the existing feedback. When auto-paste is off the plain clipboard path is unchanged. --- .../Services/RecentTranscriptionsService.cs | 309 ++++++++++++++++-- ...RecentTranscriptionsPaletteWindow.axaml.cs | 18 +- .../RecentTranscriptionsServiceTests.cs | 229 +++++++++++++ 3 files changed, 519 insertions(+), 37 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/RecentTranscriptionsServiceTests.cs diff --git a/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs b/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs index c8b64e796..83afc31fc 100644 --- a/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs +++ b/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs @@ -10,14 +10,22 @@ namespace TypeWhisper.Linux.Services; public sealed class RecentTranscriptionsService { - private readonly ActiveWindowService _activeWindow; - private readonly SystemCommandAvailabilityService _commands; + private const int FocusRestorePollAttempts = 11; + private static readonly TimeSpan s_focusRestorePollInterval = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan s_focusRestoreTimeout = TimeSpan.FromSeconds(1); + private readonly Func _activeWindowIdProvider; + private readonly Func> + _activeWindowSnapshotProvider; + private readonly Func _autoPasteProvider; + private readonly Func _delay; private readonly IHistoryService _history; - private readonly ISettingsService _settings; + private readonly Func> _insertTextAsync; + private readonly bool _isWaylandSession; + private readonly Func _pasteToolInstallHintProvider; private readonly RecentTranscriptionStore _store; - private readonly TextInsertionService _textInsertion; + private bool _paletteOpening; private RecentTranscriptionsPaletteWindow? _paletteWindow; public RecentTranscriptionsService( @@ -27,14 +35,42 @@ public RecentTranscriptionsService( ISettingsService settings, ActiveWindowService activeWindow, SystemCommandAvailabilityService commands + ) + : this( + history, + store, + () => settings.Current.AutoPaste, + activeWindow.GetActiveWindowId, + activeWindow.GetActiveWindowSnapshotAsync, + textInsertion.InsertTextAsync, + Task.Delay, + () => commands.GetSnapshot().PasteToolInstallHint, + Environment.GetEnvironmentVariable("WAYLAND_DISPLAY") is { Length: > 0 } + ) + { + } + + internal RecentTranscriptionsService( + IHistoryService history, + RecentTranscriptionStore store, + Func autoPasteProvider, + Func activeWindowIdProvider, + Func> activeWindowSnapshotProvider, + Func> insertTextAsync, + Func delay, + Func? pasteToolInstallHintProvider = null, + bool isWaylandSession = false ) { _history = history; _store = store; - _textInsertion = textInsertion; - _settings = settings; - _activeWindow = activeWindow; - _commands = commands; + _autoPasteProvider = autoPasteProvider; + _activeWindowIdProvider = activeWindowIdProvider; + _activeWindowSnapshotProvider = activeWindowSnapshotProvider; + _insertTextAsync = insertTextAsync; + _delay = delay; + _pasteToolInstallHintProvider = pasteToolInstallHintProvider ?? (() => ""); + _isWaylandSession = isWaylandSession; } public void RecordTranscription( @@ -62,13 +98,30 @@ public async Task CopyLastTranscriptionToClipboardAsync() return; } - var result = await _textInsertion.InsertTextAsync(entry.FinalText, false); + var result = await _insertTextAsync( + new TextInsertionRequest(entry.FinalText, AutoPaste: false) + ); FeedbackRequested?.Invoke(StatusTextFor(result), IsError(result)); } public event Action? FeedbackRequested; private void TogglePaletteCore() + { + TogglePaletteCoreAsync() + .ContinueWith( + t => + Trace.WriteLine( + $"[RecentTranscriptionsService] TogglePaletteCoreAsync faulted: {t.Exception?.GetBaseException().Message}" + ), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted + | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + + private async Task TogglePaletteCoreAsync() { if (_paletteWindow is { } existingWindow) { @@ -76,6 +129,11 @@ private void TogglePaletteCore() return; } + if (_paletteOpening) + { + return; + } + var entries = _store.MergedEntries(_history.Records); if (entries.Count == 0) { @@ -83,30 +141,78 @@ private void TogglePaletteCore() return; } - // Capture the focused window ID before the palette steals focus, - // so InsertEntryAsync can refocus the original app when inserting. - var targetWindowId = _activeWindow.GetActiveWindowId(); - var viewModel = new RecentTranscriptionsPaletteViewModel( - entries, - item => InsertEntryFireAndForget(item.Entry, targetWindowId) - ); - var window = new RecentTranscriptionsPaletteWindow(viewModel); - _paletteWindow = window; - window.Closed += (_, _) => + _paletteOpening = true; + try + { + // Capture the X11 handle and identity snapshot before the palette can steal focus. + var target = await CaptureInsertionTargetAsync(); + var viewModel = new RecentTranscriptionsPaletteViewModel( + entries, + item => InsertEntryFireAndForget(item.Entry, target) + ); + var window = new RecentTranscriptionsPaletteWindow(viewModel); + _paletteWindow = window; + window.Closed += (_, _) => + { + if (ReferenceEquals(_paletteWindow, window)) + { + _paletteWindow = null; + } + }; + + window.Show(); + window.Activate(); + } + finally { - if (ReferenceEquals(_paletteWindow, window)) + _paletteOpening = false; + } + } + + internal async Task CaptureInsertionTargetAsync() + { + var windowId = _activeWindowIdProvider(); + ActiveWindowSnapshot? snapshot = null; + try + { + snapshot = await _activeWindowSnapshotProvider(CancellationToken.None); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[RecentTranscriptionsService] Active-window capture failed: {ex.Message}" + ); + } + + if (_isWaylandSession) + { + // xdotool only sees stale/XWayland state on Wayland, so its window id can't + // prove focus and must never authorize insertion; drop it and any xdotool + // snapshot so capture falls back to a compositor-native id or clipboard-only. + windowId = null; + if (snapshot is { Source: "xdotool" }) { - _paletteWindow = null; + snapshot = null; } - }; + } + else if (snapshot is { Source: "xdotool", WindowId.Length: > 0 }) + { + // On X11, keep the activation handle in sync with an xdotool-sourced snapshot. + windowId = snapshot.WindowId; + } - window.Show(); - window.Activate(); + return new RecentTranscriptionInsertionTarget( + windowId, + HasUsableIdentity(snapshot) ? snapshot : null + ); } - private void InsertEntryFireAndForget(RecentTranscriptionEntry entry, string? targetWindowId) + private void InsertEntryFireAndForget( + RecentTranscriptionEntry entry, + RecentTranscriptionInsertionTarget target + ) { - InsertEntryAsync(entry, targetWindowId) + InsertEntryAsync(entry, target) .ContinueWith( t => Trace.WriteLine( @@ -119,14 +225,144 @@ private void InsertEntryFireAndForget(RecentTranscriptionEntry entry, string? ta ); } - private async Task InsertEntryAsync(RecentTranscriptionEntry entry, string? targetWindowId) + internal async Task InsertEntryAsync( + RecentTranscriptionEntry entry, + RecentTranscriptionInsertionTarget target + ) { - var result = await _textInsertion.InsertTextAsync( - entry.FinalText, - _settings.Current.AutoPaste, - targetWindowId - ); + // Insertion authority, strongest first: verified focus > X11 window-id activation > + // clipboard-only. An X11 id is trustworthy because insertion re-activates it + // deterministically before typing; a Wayland id is just a stale xdotool guess, so a + // failed verification falls back to clipboard-only. + var autoPaste = _autoPasteProvider(); + var focusVerified = + !autoPaste + || target.Snapshot is not null && await WaitForFocusRestorationAsync(target.Snapshot) + || !string.IsNullOrWhiteSpace(target.WindowId) + && (target.Snapshot is null || !_isWaylandSession); + + var request = focusVerified + ? new TextInsertionRequest( + entry.FinalText, + autoPaste, + target.WindowId + ) + : new TextInsertionRequest(entry.FinalText, AutoPaste: false); + var result = await _insertTextAsync(request); FeedbackRequested?.Invoke(StatusTextFor(result), IsError(result)); + return result; + } + + private async Task WaitForFocusRestorationAsync(ActiveWindowSnapshot target) + { + using var timeout = new CancellationTokenSource(s_focusRestoreTimeout); + for (var attempt = 0; attempt < FocusRestorePollAttempts; attempt++) + { + ActiveWindowSnapshot? current; + try + { + current = await _activeWindowSnapshotProvider(timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + return false; + } + catch (Exception ex) + { + Trace.WriteLine( + $"[RecentTranscriptionsService] Focus verification failed: {ex.Message}" + ); + current = null; + } + + if (MatchesTargetIdentity(target, current)) + { + return true; + } + + if (attempt == FocusRestorePollAttempts - 1 || timeout.IsCancellationRequested) + { + break; + } + + try + { + await _delay(s_focusRestorePollInterval, timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + break; + } + } + + return false; + } + + private static bool HasUsableIdentity(ActiveWindowSnapshot? snapshot) + { + return snapshot is not null + && ( + !string.IsNullOrWhiteSpace(snapshot.WindowId) + || ( + !string.IsNullOrWhiteSpace(snapshot.Title) + && ( + !string.IsNullOrWhiteSpace(snapshot.AppId) + || !string.IsNullOrWhiteSpace(snapshot.ProcessName) + ) + ) + ); + } + + private static bool MatchesTargetIdentity( + ActiveWindowSnapshot target, + ActiveWindowSnapshot? current + ) + { + if (current is null) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(target.WindowId)) + { + return string.Equals(target.WindowId, current.WindowId, StringComparison.Ordinal) + && string.Equals(target.Source, current.Source, StringComparison.OrdinalIgnoreCase); + } + + if ( + string.IsNullOrWhiteSpace(target.Title) + || !string.Equals(target.Title, current.Title, StringComparison.Ordinal) + ) + { + return false; + } + + var hasAppIdentity = false; + if (!string.IsNullOrWhiteSpace(target.AppId)) + { + hasAppIdentity = true; + if (!string.Equals(target.AppId, current.AppId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + if (!string.IsNullOrWhiteSpace(target.ProcessName)) + { + hasAppIdentity = true; + if ( + !string.Equals( + target.ProcessName, + current.ProcessName, + StringComparison.OrdinalIgnoreCase + ) + ) + { + return false; + } + } + + return hasAppIdentity; } private static bool IsError(InsertionResult result) @@ -146,7 +382,7 @@ private string StatusTextFor(InsertionResult result) InsertionResult.CopiedToClipboard => "Copied recent transcription to clipboard.", InsertionResult.NoText => Localization.Loc.Instance["Overlay.NoRecentTranscriptions"], InsertionResult.MissingClipboardTool => ClipboardToolMissingMessage(), - InsertionResult.MissingPasteTool => _commands.GetSnapshot().PasteToolInstallHint, + InsertionResult.MissingPasteTool => _pasteToolInstallHintProvider(), InsertionResult.Failed => "Text insertion failed.", _ => "Done.", }; @@ -158,4 +394,9 @@ private static string ClipboardToolMissingMessage() ? "Install wl-clipboard to copy recent transcriptions." : "Install xclip to copy recent transcriptions."; } -} \ No newline at end of file +} + +internal sealed record RecentTranscriptionInsertionTarget( + string? WindowId, + ActiveWindowSnapshot? Snapshot +); diff --git a/src/TypeWhisper.Linux/Views/RecentTranscriptionsPaletteWindow.axaml.cs b/src/TypeWhisper.Linux/Views/RecentTranscriptionsPaletteWindow.axaml.cs index ef657e659..c81dbae51 100644 --- a/src/TypeWhisper.Linux/Views/RecentTranscriptionsPaletteWindow.axaml.cs +++ b/src/TypeWhisper.Linux/Views/RecentTranscriptionsPaletteWindow.axaml.cs @@ -6,6 +6,9 @@ namespace TypeWhisper.Linux.Views; public partial class RecentTranscriptionsPaletteWindow : Window { + private readonly TaskCompletionSource _closed = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); private readonly RecentTranscriptionsPaletteViewModel _viewModel; // Guards Close() against re-entry: Deactivated can fire again while the @@ -29,6 +32,7 @@ public RecentTranscriptionsPaletteWindow(RecentTranscriptionsPaletteViewModel vi DataContext = viewModel; Opened += OnOpened; Deactivated += OnDeactivated; + Closed += OnClosed; KeyDown += OnKeyDown; } @@ -56,6 +60,11 @@ private void OnDeactivated(object? sender, EventArgs e) } } + private void OnClosed(object? sender, EventArgs e) + { + _closed.TrySetResult(); + } + private void OnKeyDown(object? sender, KeyEventArgs e) { // Other keys fall through to the SearchBox for normal text input. @@ -102,15 +111,18 @@ private void Entry_PointerReleased(object? sender, PointerReleasedEventArgs e) } } - private void SelectAndClose(RecentTranscriptionPaletteItem? item) + // ReSharper disable once AsyncVoidMethod -- called from synchronous KeyDown/PointerReleased + // handlers; awaits _closed.Task so selection runs only after the window has closed. + private async void SelectAndClose(RecentTranscriptionPaletteItem? item) { - if (item is null) + if (item is null || _isSelecting) { return; } _isSelecting = true; RequestClose(); + await _closed.Task; _viewModel.Select(item); } -} \ No newline at end of file +} diff --git a/tests/TypeWhisper.Linux.Tests/RecentTranscriptionsServiceTests.cs b/tests/TypeWhisper.Linux.Tests/RecentTranscriptionsServiceTests.cs new file mode 100644 index 000000000..c0b7f1a96 --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/RecentTranscriptionsServiceTests.cs @@ -0,0 +1,229 @@ +using Moq; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Core.Services; +using TypeWhisper.Linux.Services; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class RecentTranscriptionsServiceTests +{ + [Fact] + public async Task FocusReturnsToCapturedTarget_InvokesNormalInsertionWithCapturedTarget() + { + var captured = Snapshot("editor", "Document", "x11-target", "editor"); + var fixture = new Fixture("x11-target", captured, captured); + + var result = await fixture.CaptureAndInsertAsync(); + + Assert.Equal(InsertionResult.Pasted, result); + var request = Assert.Single(fixture.InsertionRequests); + Assert.True(request.AutoPaste); + Assert.Equal("x11-target", request.TargetWindowId); + Assert.Empty(fixture.Delays); + } + + [Fact] + public async Task FocusStaysOnDifferentWindow_UsesClipboardOnlyWithoutDirectInsertion() + { + var captured = Snapshot("editor", "Document", "wayland-target", "editor"); + var different = Snapshot("typewhisper", "TypeWhisper", "palette", "typewhisper"); + var fixture = new Fixture(null, captured, different); + + var result = await fixture.CaptureAndInsertAsync(); + + Assert.Equal(InsertionResult.CopiedToClipboard, result); + var request = Assert.Single(fixture.InsertionRequests); + Assert.False(request.AutoPaste); + Assert.Null(request.TargetWindowId); + Assert.Equal(10, fixture.Delays.Count); + Assert.DoesNotContain(fixture.InsertionRequests, candidate => candidate.AutoPaste); + Assert.Equal( + ("Copied recent transcription to clipboard.", false), + Assert.Single(fixture.Feedback) + ); + } + + [Fact] + public async Task FocusMatchArrivesLateWithinBound_InvokesNormalInsertion() + { + var captured = Snapshot("editor", "Document", "wayland-target", "editor"); + var different = Snapshot("browser", "Browser", "other-window", "browser"); + var fixture = new Fixture(null, captured, different, different, different, captured); + + var result = await fixture.CaptureAndInsertAsync(); + + Assert.Equal(InsertionResult.Pasted, result); + var request = Assert.Single(fixture.InsertionRequests); + Assert.True(request.AutoPaste); + Assert.Null(request.TargetWindowId); + Assert.Equal(3, fixture.Delays.Count); + } + + [Theory] + [MemberData(nameof(NullIdentityCases))] + public async Task NullIdentitySnapshot_UsesX11PathOrClipboardFallback( + string? targetWindowId, + bool expectsDirectInsertion + ) + { + var fixture = new Fixture(targetWindowId, (ActiveWindowSnapshot?)null); + + var result = await fixture.CaptureAndInsertAsync(); + + Assert.Equal( + expectsDirectInsertion + ? InsertionResult.Pasted + : InsertionResult.CopiedToClipboard, + result + ); + var request = Assert.Single(fixture.InsertionRequests); + Assert.Equal(expectsDirectInsertion, request.AutoPaste); + Assert.Equal(expectsDirectInsertion ? targetWindowId : null, request.TargetWindowId); + Assert.Empty(fixture.Delays); + } + + [Fact] + public async Task X11FocusUnverifiedWithWindowId_InsertsViaWindowIdInsteadOfClipboard() + { + var captured = Snapshot("editor", "Document", "x11-target", "editor"); + var different = Snapshot("browser", "Browser", "other-window", "browser"); + var fixture = new Fixture("x11-target", captured, different); + + var result = await fixture.CaptureAndInsertAsync(); + + Assert.Equal(InsertionResult.Pasted, result); + var request = Assert.Single(fixture.InsertionRequests); + Assert.True(request.AutoPaste); + Assert.Equal("x11-target", request.TargetWindowId); + } + + [Fact] + public async Task WaylandXdotoolIdentity_FallsBackToClipboardOnly() + { + var xdotool = Snapshot("editor", "Document", "0x123", null, "xdotool"); + var fixture = new Fixture(isWayland: true, "0x123", xdotool, xdotool); + + var result = await fixture.CaptureAndInsertAsync(); + + Assert.Equal(InsertionResult.CopiedToClipboard, result); + var request = Assert.Single(fixture.InsertionRequests); + Assert.False(request.AutoPaste); + Assert.Null(request.TargetWindowId); + Assert.Empty(fixture.Delays); + } + + [Fact] + public async Task WaylandCompositorFocusVerified_InsertsWithoutStaleXdotoolId() + { + var captured = Snapshot("editor", "Document", "0xhypr", "editor", "hyprland"); + var fixture = new Fixture(isWayland: true, "0xstale", captured, captured); + + var result = await fixture.CaptureAndInsertAsync(); + + Assert.Equal(InsertionResult.Pasted, result); + var request = Assert.Single(fixture.InsertionRequests); + Assert.True(request.AutoPaste); + Assert.Null(request.TargetWindowId); + Assert.Empty(fixture.Delays); + } + + public static TheoryData NullIdentityCases => + new() + { + { "x11-target", true }, + { null, false }, + }; + + private static ActiveWindowSnapshot Snapshot( + string processName, + string title, + string windowId, + string? appId, + string source = "test" + ) + { + return new ActiveWindowSnapshot(processName, title, windowId, appId, source); + } + + private sealed class Fixture + { + private readonly RecentTranscriptionsService _service; + private readonly Queue _snapshots; + private ActiveWindowSnapshot? _lastSnapshot; + + public Fixture(string? targetWindowId, params ActiveWindowSnapshot?[] snapshots) + : this(false, targetWindowId, snapshots) + { + } + + public Fixture( + bool isWayland, + string? targetWindowId, + params ActiveWindowSnapshot?[] snapshots + ) + { + _snapshots = new Queue(snapshots); + _service = new RecentTranscriptionsService( + Mock.Of(), + new RecentTranscriptionStore(), + () => true, + () => targetWindowId, + _ => Task.FromResult(NextSnapshot()), + InsertAsync, + DelayAsync, + isWaylandSession: isWayland + ); + _service.FeedbackRequested += (message, isError) => + Feedback.Add((message, isError)); + } + + public List InsertionRequests { get; } = []; + public List Delays { get; } = []; + public List<(string Message, bool IsError)> Feedback { get; } = []; + + public async Task CaptureAndInsertAsync() + { + var target = await _service.CaptureInsertionTargetAsync(); + return await _service.InsertEntryAsync( + new RecentTranscriptionEntry( + "recent", + "transcribed text", + DateTime.UtcNow, + null, + null, + RecentTranscriptionSource.Session + ), + target + ); + } + + private ActiveWindowSnapshot? NextSnapshot() + { + if (_snapshots.Count > 0) + { + _lastSnapshot = _snapshots.Dequeue(); + } + + return _lastSnapshot; + } + + private Task InsertAsync(TextInsertionRequest request) + { + InsertionRequests.Add(request); + return Task.FromResult( + request.AutoPaste + ? InsertionResult.Pasted + : InsertionResult.CopiedToClipboard + ); + } + + private Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Delays.Add(delay); + return Task.CompletedTask; + } + } +} From 46499b2c36c6c37551a82b951388da2bc3219d44 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 16:43:51 +0000 Subject: [PATCH 153/226] Render History, Dashboard, and About timestamps in local time Records and error-log entries are stamped with DateTime.UtcNow, but History compared their raw dates against local DateTime.Today for Today/Yesterday/week grouping and formatted the raw value as the row time; Dashboard's recent activity and About's error log formatted the raw UTC timestamp directly. Anyone outside UTC saw shifted times and, near midnight or week boundaries, records grouped under the wrong day. Conversion now happens once at the presentation layer through a shared PresentationDateTime.ToLocal helper - UTC converts, legacy Unspecified values are treated as UTC so nothing double-converts, Local passes through - with an injectable TimeZoneInfo (and, for History grouping, an injectable UTC clock) so tests pin boundary behavior with custom zones instead of mutating process state. Rows expose LocalTimestamp, the three XAML surfaces bind it, and UTC storage, ordering, and duration filtering are unchanged. --- .../Sections/AboutSectionViewModel.cs | 35 ++++++++- .../Sections/DashboardSectionViewModel.cs | 35 ++++++++- .../Sections/HistorySectionViewModel.cs | 62 +++++++++++++-- .../Views/Sections/AboutSection.axaml | 7 +- .../Views/Sections/DashboardSection.axaml | 7 +- .../Views/Sections/HistorySection.axaml | 4 +- .../AboutSectionViewModelTests.cs | 64 ++++++++++++++++ .../DashboardSectionViewModelTests.cs | 29 ++++++- .../HistorySectionViewModelTests.cs | 75 ++++++++++++++++++- 9 files changed, 291 insertions(+), 27 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/AboutSectionViewModelTests.cs diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs index 79380c216..581b23829 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs @@ -18,6 +18,7 @@ public partial class AboutSectionViewModel : ObservableObject { private readonly IErrorLogService _errorLog; private readonly SettingsBackupService _settingsBackup; + private readonly TimeZoneInfo _timeZone; private readonly UpdateCheckService _updateCheck; [ObservableProperty] @@ -50,11 +51,20 @@ public AboutSectionViewModel( IErrorLogService errorLog, SettingsBackupService settingsBackup, UpdateCheckService updateCheck + ) + : this(errorLog, settingsBackup, updateCheck, TimeZoneInfo.Local) { } + + internal AboutSectionViewModel( + IErrorLogService errorLog, + SettingsBackupService settingsBackup, + UpdateCheckService updateCheck, + TimeZoneInfo timeZone ) { _errorLog = errorLog; _settingsBackup = settingsBackup; _updateCheck = updateCheck; + _timeZone = timeZone; RefreshErrors(); // EntriesChanged fires synchronously on whichever thread called AddEntry — // and producers now log from background threads (transcription, detection, @@ -98,10 +108,10 @@ UpdateCheckService updateCheck public bool CanCheckForUpdates => !IsCheckingForUpdates; // Full, unfiltered backing list; drives HasErrors and the category options. - private ObservableCollection ErrorEntries { get; } = []; + private ObservableCollection ErrorEntries { get; } = []; // The entries actually shown — ErrorEntries narrowed by SelectedCategoryFilter. - public ObservableCollection FilteredErrorEntries { get; } = []; + public ObservableCollection FilteredErrorEntries { get; } = []; // "All categories" + one option per category currently present in the log. public ObservableCollection CategoryFilters { get; } = []; @@ -262,7 +272,12 @@ private void RefreshErrors() ErrorEntries.Clear(); foreach (var entry in _errorLog.Entries) { - ErrorEntries.Add(entry); + ErrorEntries.Add( + new ErrorLogEntryRow( + entry, + PresentationDateTime.ToLocal(entry.Timestamp, _timeZone) + ) + ); } RebuildCategoryFilters(); @@ -349,3 +364,17 @@ public override string ToString() } } } + +public sealed class ErrorLogEntryRow +{ + public ErrorLogEntryRow(ErrorLogEntry record, DateTime localTimestamp) + { + Record = record; + LocalTimestamp = localTimestamp; + } + + public ErrorLogEntry Record { get; } + public DateTime LocalTimestamp { get; } + public string Category => Record.Category; + public string Message => Record.Message; +} diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs index 7c282ac1a..8ae98b949 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs @@ -22,6 +22,7 @@ public enum TimeRange private readonly IHistoryService _history; private readonly IHistoryInsightsService _insights; private readonly ISettingsService _settings; + private readonly TimeZoneInfo _timeZone; [ObservableProperty] private int _appCount; @@ -83,11 +84,20 @@ public DashboardSectionViewModel( IHistoryService history, ISettingsService settings, IHistoryInsightsService insights + ) + : this(history, settings, insights, TimeZoneInfo.Local) { } + + internal DashboardSectionViewModel( + IHistoryService history, + ISettingsService settings, + IHistoryInsightsService insights, + TimeZoneInfo timeZone ) { _history = history; _settings = settings; _insights = insights; + _timeZone = timeZone; // ReadSelectedRange guards against out-of-range ints stored by older // versions of the app (DashboardSelectedPeriod is an unvalidated int). _selectedRange = ReadSelectedRange(settings.Current.DashboardSelectedPeriod); @@ -98,7 +108,7 @@ IHistoryInsightsService insights _ = InitializeAsync(); } - public ObservableCollection RecentActivity { get; } = []; + public ObservableCollection RecentActivity { get; } = []; public ObservableCollection TopApps { get; } = []; public bool HasTopApps => TopApps.Count > 0; public bool HasRecentActivity => RecentActivity.Count > 0; @@ -211,7 +221,12 @@ private void Refresh() RecentActivity.Clear(); foreach (var r in records.OrderByDescending(r => r.Timestamp).Take(10)) { - RecentActivity.Add(r); + RecentActivity.Add( + new DashboardRecentActivityRow( + r, + PresentationDateTime.ToLocal(r.Timestamp, _timeZone) + ) + ); } TopApps.Clear(); @@ -247,6 +262,20 @@ private static string FormatDuration(double seconds) } } +public sealed class DashboardRecentActivityRow +{ + public DashboardRecentActivityRow(TranscriptionRecord record, DateTime localTimestamp) + { + Record = record; + LocalTimestamp = localTimestamp; + } + + public TranscriptionRecord Record { get; } + public DateTime LocalTimestamp { get; } + public string Preview => Record.Preview; + public string? AppName => Record.AppName; +} + public sealed class AppUsageInsightRow { public AppUsageInsightRow(AppUsageInsight insight) @@ -260,4 +289,4 @@ public AppUsageInsightRow(AppUsageInsight insight) private int RecordCount { get; } private int WordCount { get; } public string Summary => Loc.Instance.GetString("Dashboard.SummaryStat", RecordCount, WordCount); -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs index 831a286c2..8cfde21db 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs @@ -13,6 +13,22 @@ namespace TypeWhisper.Linux.ViewModels.Sections; +internal static class PresentationDateTime +{ + internal static DateTime ToLocal(DateTime timestamp, TimeZoneInfo timeZone) + { + if (timestamp.Kind == DateTimeKind.Local) + { + return timestamp; + } + + var utcTimestamp = timestamp.Kind == DateTimeKind.Utc + ? timestamp + : DateTime.SpecifyKind(timestamp, DateTimeKind.Utc); + return TimeZoneInfo.ConvertTimeFromUtc(utcTimestamp, timeZone); + } +} + public partial class HistorySectionViewModel : ObservableObject { // Thousands of entries: rows are materialized in pages and appended on scroll. @@ -23,6 +39,8 @@ public partial class HistorySectionViewModel : ObservableObject private readonly IHistoryService _history; private readonly SessionAudioFileService _sessionAudioFiles; private readonly ISettingsService _settings; + private readonly TimeZoneInfo _timeZone; + private readonly Func _utcNow; [ObservableProperty] private bool _isLoading; @@ -48,6 +66,25 @@ public HistorySectionViewModel( ISettingsService settings, SessionAudioFileService sessionAudioFiles, AudioPlaybackService audioPlayback + ) + : this( + history, + dictionary, + settings, + sessionAudioFiles, + audioPlayback, + TimeZoneInfo.Local, + () => DateTime.UtcNow + ) { } + + internal HistorySectionViewModel( + IHistoryService history, + IDictionaryService dictionary, + ISettingsService settings, + SessionAudioFileService sessionAudioFiles, + AudioPlaybackService audioPlayback, + TimeZoneInfo timeZone, + Func utcNow ) { _history = history; @@ -55,6 +92,8 @@ AudioPlaybackService audioPlayback _settings = settings; _sessionAudioFiles = sessionAudioFiles; _audioPlayback = audioPlayback; + _timeZone = timeZone; + _utcNow = utcNow; _history.RecordsChanged += () => { @@ -254,6 +293,11 @@ internal bool IsPlaying(TranscriptionRecord record) ); } + internal DateTime ToLocalPresentationTime(DateTime timestamp) + { + return PresentationDateTime.ToLocal(timestamp, _timeZone); + } + private async Task LoadAsync() { IsLoading = true; @@ -368,10 +412,12 @@ private void Refresh() private void AppendNextPage() { var end = Math.Min(_shownCount + PageSize, _filtered.Count); + var today = ToLocalPresentationTime(_utcNow()).Date; for (var i = _shownCount; i < end; i++) { var record = _filtered[i]; - var groupName = ComputeDateGroup(record.Timestamp); + var row = new HistoryRecordRow(record, this); + var groupName = ComputeDateGroup(row.LocalTimestamp, today); // Records are newest-first; each record either extends the last group or starts a new one. var group = @@ -382,7 +428,7 @@ private void AppendNextPage() Groups.Add(group); } - group.Entries.Add(new HistoryRecordRow(record, this)); + group.Entries.Add(row); } _shownCount = end; @@ -411,9 +457,8 @@ private void RebuildAppFilter() SelectedAppFilter = AvailableApps.Contains(current) ? current : allApps; } - private static string ComputeDateGroup(DateTime timestamp) + private static string ComputeDateGroup(DateTime timestamp, DateTime today) { - var today = DateTime.Today; var date = timestamp.Date; if (date == today) @@ -473,12 +518,14 @@ public HistoryRecordRow(TranscriptionRecord record, HistorySectionViewModel owne { _record = record; _owner = owner; + LocalTimestamp = owner.ToLocalPresentationTime(record.Timestamp); SetCorrectionSuggestions(record.PendingCorrectionSuggestions); } public ObservableCollection CorrectionSuggestions { get; } = []; - public string TimeLabel => Record.Timestamp.ToString("HH:mm"); + public DateTime LocalTimestamp { get; private set; } + public string TimeLabel => LocalTimestamp.ToString("HH:mm"); public string DurationLabel => $"{Record.DurationSeconds:F1}s"; public bool HasProfileName => !string.IsNullOrWhiteSpace(Record.ProfileName); public bool HasAppProcessName => !string.IsNullOrWhiteSpace(Record.AppProcessName); @@ -528,8 +575,11 @@ public HistoryRecordRow(TranscriptionRecord record, HistorySectionViewModel owne partial void OnRecordChanged(TranscriptionRecord value) { + LocalTimestamp = _owner.ToLocalPresentationTime(value.Timestamp); _rawVsFinalDiffCache = null; _inspectorCallsCache = null; + OnPropertyChanged(nameof(LocalTimestamp)); + OnPropertyChanged(nameof(TimeLabel)); OnPropertyChanged(nameof(RawVsFinalDiff)); OnPropertyChanged(nameof(InspectorCalls)); } @@ -739,4 +789,4 @@ public LlmCallDisplay(LlmCallProvenance call) public bool HasUserPrompt => !string.IsNullOrWhiteSpace(_call.UserPromptSent); public bool HasInjectedContext => !string.IsNullOrWhiteSpace(_call.InjectedMemoryContext); public bool HasResponse => !string.IsNullOrWhiteSpace(_call.ResponseReceived); -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml b/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml index a698ed4c2..702677bcf 100644 --- a/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml @@ -1,7 +1,6 @@ - + @@ -165,7 +164,7 @@ - @@ -183,4 +182,4 @@ - \ No newline at end of file + diff --git a/src/TypeWhisper.Linux/Views/Sections/DashboardSection.axaml b/src/TypeWhisper.Linux/Views/Sections/DashboardSection.axaml index af77d9616..7f98db1ba 100644 --- a/src/TypeWhisper.Linux/Views/Sections/DashboardSection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/DashboardSection.axaml @@ -2,7 +2,6 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:TypeWhisper.Linux.ViewModels.Sections" xmlns:shellvm="using:TypeWhisper.Linux.ViewModels" - xmlns:models="using:TypeWhisper.Core.Models" xmlns:local="using:TypeWhisper.Linux" xmlns:loc="using:TypeWhisper.Linux.Services.Localization" x:Class="TypeWhisper.Linux.Views.Sections.DashboardSection" @@ -430,7 +429,7 @@ - + @@ -440,7 +439,7 @@ TextWrapping="Wrap" /> - - \ No newline at end of file + diff --git a/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml b/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml index 68f46bf3b..cbfc136fc 100644 --- a/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml @@ -269,7 +269,7 @@ + Text="{Binding LocalTimestamp, StringFormat='{}{0:MM/dd/yyyy HH:mm}'}" /> @@ -510,4 +510,4 @@ - \ No newline at end of file + diff --git a/tests/TypeWhisper.Linux.Tests/AboutSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/AboutSectionViewModelTests.cs new file mode 100644 index 000000000..aaa1bb952 --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/AboutSectionViewModelTests.cs @@ -0,0 +1,64 @@ +using Moq; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.ViewModels.Sections; +using TypeWhisper.Tests; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class AboutSectionViewModelTests : IDisposable +{ + private readonly string _tempDir = TestPaths.CreateTempDirectory( + "TypeWhisper.AboutSectionViewModelTests" + ); + + public void Dispose() + { + try + { + TestPaths.DeleteDirectory(_tempDir); + } + catch + { + // Best-effort cleanup for temp test directories. + } + } + + [Fact] + public void FilteredErrorEntries_ExposesLocalPresentationTimestamp() + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone( + "About tests UTC+13", + TimeSpan.FromHours(13), + "About tests UTC+13", + "About tests UTC+13" + ); + var entry = new ErrorLogEntry + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = new DateTime(2030, 1, 2, 23, 30, 0, DateTimeKind.Utc), + Message = "test error", + Category = ErrorCategory.General, + }; + var errorLog = new Mock(); + errorLog.SetupGet(service => service.Entries).Returns([entry]); + var preferences = new LinuxPreferencesService( + Path.Join(_tempDir, "linux-preferences.json") + ); + var sut = new AboutSectionViewModel( + errorLog.Object, + new SettingsBackupService(_tempDir), + new UpdateCheckService(preferences), + timeZone + ); + + var presentationEntry = Assert.Single(sut.FilteredErrorEntries); + Assert.Same(entry, presentationEntry.Record); + Assert.Equal( + new DateTime(2030, 1, 3, 12, 30, 0), + presentationEntry.LocalTimestamp + ); + } +} diff --git a/tests/TypeWhisper.Linux.Tests/DashboardSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/DashboardSectionViewModelTests.cs index 992aa52ad..b5d9cfda2 100644 --- a/tests/TypeWhisper.Linux.Tests/DashboardSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DashboardSectionViewModelTests.cs @@ -122,6 +122,33 @@ public void Refresh_CountsWordsSeparatedByNewlines() Assert.Equal(5, sut.WordCount); } + [Fact] + public void RecentActivity_ExposesLocalPresentationTimestamp() + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone( + "Dashboard tests UTC+13", + TimeSpan.FromHours(13), + "Dashboard tests UTC+13", + "Dashboard tests UTC+13" + ); + var history = new HistoryService(Path.Join(_tempDir, "history.json")); + var timestamp = new DateTime(2030, 1, 2, 23, 30, 0, DateTimeKind.Utc); + history.AddRecord(CreateRecord("recent words", "code", 2, timestamp)); + var settings = new SettingsService(Path.Join(_tempDir, "settings.json")); + var sut = new DashboardSectionViewModel( + history, + settings, + new HistoryInsightsService(), + timeZone + ); + + sut.SelectedRange = DashboardSectionViewModel.TimeRange.AllTime; + + var activity = Assert.Single(sut.RecentActivity); + Assert.Same(history.Records[0], activity.Record); + Assert.Equal(new DateTime(2030, 1, 3, 12, 30, 0), activity.LocalTimestamp); + } + private static TranscriptionRecord CreateRecord( string finalText, string appProcessName, @@ -151,4 +178,4 @@ private static TranscriptionRecord CreateRecord( TranslationApplied = translationApplied, }; } -} \ No newline at end of file +} diff --git a/tests/TypeWhisper.Linux.Tests/HistorySectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/HistorySectionViewModelTests.cs index a345d162f..58e321a3a 100644 --- a/tests/TypeWhisper.Linux.Tests/HistorySectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HistorySectionViewModelTests.cs @@ -104,6 +104,68 @@ public void SaveEdit_AutoLearnsCorrectionsWhenEnabled() Assert.Equal("Kubernetes", entry.Replacement); } + [Fact] + public void Refresh_UtcPlus13_GroupsLateUtcRecordUnderLocalTodayAndFormatsLocalTime() + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone( + "History tests UTC+13", + TimeSpan.FromHours(13), + "History tests UTC+13", + "History tests UTC+13" + ); + var utcNow = new DateTime(2030, 1, 2, 23, 45, 0, DateTimeKind.Utc); + var history = CreateHistoryService(); + var record = CreateRecord( + "crosses midnight", + timestamp: new DateTime(2030, 1, 2, 23, 30, 0, DateTimeKind.Utc) + ); + history.AddRecord(record); + + var sut = CreateViewModel( + history, + CreateDictionaryService(), + timeZone: timeZone, + utcNow: () => utcNow + ); + + var group = Assert.Single(sut.Groups); + Assert.Equal(Loc.Instance["History.GroupToday"], group.Name); + var row = Assert.Single(group.Entries); + Assert.Equal(new DateTime(2030, 1, 3, 12, 30, 0), row.LocalTimestamp); + Assert.Equal("12:30", row.TimeLabel); + } + + [Fact] + public void Refresh_UtcMinus11_TreatsUnspecifiedTimestampAsUtcAndGroupsLocalYesterday() + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone( + "History tests UTC-11", + TimeSpan.FromHours(-11), + "History tests UTC-11", + "History tests UTC-11" + ); + var utcNow = new DateTime(2030, 1, 3, 12, 0, 0, DateTimeKind.Utc); + var history = CreateHistoryService(); + var record = CreateRecord( + "legacy timestamp", + timestamp: new DateTime(2030, 1, 3, 10, 30, 0, DateTimeKind.Unspecified) + ); + history.AddRecord(record); + + var sut = CreateViewModel( + history, + CreateDictionaryService(), + timeZone: timeZone, + utcNow: () => utcNow + ); + + var group = Assert.Single(sut.Groups); + Assert.Equal(Loc.Instance["History.GroupYesterday"], group.Name); + var row = Assert.Single(group.Entries); + Assert.Equal(new DateTime(2030, 1, 2, 23, 30, 0), row.LocalTimestamp); + Assert.Equal("23:30", row.TimeLabel); + } + private HistoryService CreateHistoryService() { return new HistoryService(Path.Join(_tempDir, "history.json"), Path.Join(_tempDir, "audio")); @@ -135,7 +197,9 @@ AppSettings.Default with private HistorySectionViewModel CreateViewModel( HistoryService history, DictionaryService dictionary, - SettingsService? settings = null + SettingsService? settings = null, + TimeZoneInfo? timeZone = null, + Func? utcNow = null ) => new( history, @@ -147,20 +211,23 @@ private HistorySectionViewModel CreateViewModel( // tests that never trigger audio playback don't fail on device init. #pragma warning disable SYSLIB0050 (AudioPlaybackService) - FormatterServices.GetUninitializedObject(typeof(AudioPlaybackService)) + FormatterServices.GetUninitializedObject(typeof(AudioPlaybackService)), + timeZone ?? TimeZoneInfo.Local, + utcNow ?? (() => DateTime.UtcNow) ); #pragma warning restore SYSLIB0050 private static TranscriptionRecord CreateRecord( string finalText, string? raw = null, - IReadOnlyList? llmCalls = null + IReadOnlyList? llmCalls = null, + DateTime? timestamp = null ) { return new TranscriptionRecord { Id = Guid.NewGuid().ToString("N"), - Timestamp = DateTime.UtcNow, + Timestamp = timestamp ?? DateTime.UtcNow, RawText = raw ?? finalText, FinalText = finalText, DurationSeconds = 2.4, From 205e4a059685be4222721a34d784922a5cc3184b Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 16:54:04 +0000 Subject: [PATCH 154/226] Preserve unsaved plugin settings drafts across ambient refreshes Every PluginStateChanged posted a full plugin-list refresh that kept only the expanded plugin ID and recreated every row from provider values. Unsaved flat edits (text, secrets, option selections) and collection additions, removals, reorders, and nested edits lived only in the discarded row objects - a background capability notification from any plugin silently threw away the user's in-progress edits. Rows now capture a structural baseline after loads and after post-Save/post-Validate reloads, and compare it against the current draft (flat values, ordered collection items, nested fields, hidden IDs) to know when they hold unsaved edits. An ambient refresh carries the existing row object across when it is dirty and its LoadedPlugin instance is unchanged; a different instance under the same ID recreates the row - cross-instance draft reconciliation is deliberately out of scope. Reload intent is explicit (preserve-draft vs reset-baseline), so intentional reloads establish a clean baseline, and the post-Save reload re-resolves the currently visible row by instance identity so an ambient refresh racing a save cannot leave detached-row UI state. --- .../Sections/PluginsSectionViewModel.cs | 207 ++++++++++- .../PluginCollectionSettingsViewModelTests.cs | 329 ++++++++++++++++++ 2 files changed, 520 insertions(+), 16 deletions(-) diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs index e50dd8880..fd7ad8582 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs @@ -113,7 +113,7 @@ internal PluginsSectionViewModel( } _pluginManager.PluginStateChanged += (_, _) => Dispatcher.UIThread.Post(Refresh); - Refresh(); + RebuildPluginRows(PluginListRefreshKind.Initial); } public ObservableCollection PluginGroups { get; } = []; @@ -130,12 +130,17 @@ public Task RefreshProviderModelsAsync() } private void Refresh() + { + RebuildPluginRows(PluginListRefreshKind.Ambient); + } + + private void RebuildPluginRows(PluginListRefreshKind refreshKind) { // Preserve expanded state across rebuilds so the user doesn't lose their open settings panel. - var expandedPluginId = PluginGroups + var existingRows = PluginGroups .SelectMany(group => group.Plugins) - .FirstOrDefault(plugin => plugin.IsExpanded) - ?.Id; + .ToDictionary(plugin => plugin.Id, StringComparer.Ordinal); + var expandedPluginId = existingRows.Values.FirstOrDefault(plugin => plugin.IsExpanded)?.Id; PluginGroups.Clear(); _pluginById.Clear(); @@ -145,6 +150,18 @@ private void Refresh() { _pluginById[plugin.Manifest.Id] = plugin; + if ( + refreshKind == PluginListRefreshKind.Ambient + && existingRows.TryGetValue(plugin.Manifest.Id, out var existingRow) + && existingRow.HasUnsavedSettings + && ReferenceEquals(existingRow.LoadedPlugin, plugin) + ) + { + existingRow.IsEnabled = _pluginManager.IsEnabled(plugin.Manifest.Id); + plugins.Add(existingRow); + continue; + } + try { var loc = new PluginLocalization(plugin.PluginDirectory); @@ -202,7 +219,7 @@ is IPluginCollectionSettingsProvider collectionSettingsProvider InferIsLocal(plugin.Manifest), hasExpandableSettings || settingsDefinitionFailed, _pluginManager.IsEnabled(plugin.Manifest.Id) - ); + ) { LoadedPlugin = plugin }; if (settingsDefinitionFailed) { @@ -225,7 +242,7 @@ is IPluginCollectionSettingsProvider collectionSettingsProvider plugin.Instance is IPluginSettingsProvider or IPluginCollectionSettingsProvider, _pluginManager.IsEnabled(plugin.Manifest.Id) - ); + ) { LoadedPlugin = plugin }; MarkSettingsLoadFailed(row); plugins.Add(row); } @@ -276,7 +293,12 @@ plugin.Instance is IPluginSettingsProvider } expandedPlugin.IsExpanded = true; - BeginObservedSettingsLoad(expandedPlugin); + BeginObservedSettingsLoad( + expandedPlugin, + refreshKind == PluginListRefreshKind.Ambient + ? SettingsReloadKind.PreserveDraft + : SettingsReloadKind.ResetBaseline + ); } [RelayCommand] @@ -307,7 +329,7 @@ private async Task ToggleExpandedAsync(PluginRow row) } row.IsExpanded = true; - await LoadPluginSettingsAsync(row); + await LoadPluginSettingsAsync(row, SettingsReloadKind.ResetBaseline); } [RelayCommand] @@ -359,7 +381,7 @@ private async Task SaveSettingsAsync(PluginRow row) } row.Status = Loc.Instance["Plugins.SettingsSaveFailed"]; - await LoadPluginSettingsAsync(row, true); + await ReloadCurrentVisibleRowAsync(row, loaded, true); return; } @@ -374,6 +396,7 @@ private async Task SaveSettingsAsync(PluginRow row) } row.Status = Loc.Instance["Plugins.SettingsSaved"]; + await ReloadCurrentVisibleRowAsync(row, loaded, true); } [RelayCommand] @@ -408,7 +431,7 @@ private async Task ValidateSettingsAsync(PluginRow row) row.Status = validation.Value?.Message ?? Loc.Instance["Plugins.NoValidationAvailable"]; - await LoadPluginSettingsAsync(row, true); + await ReloadCurrentVisibleRowAsync(row, loaded, true); } private async Task TrySaveFlatSettingsAsync( @@ -432,7 +455,7 @@ await provider.SetSettingValueAsync(field.Key, field.Value, ct) if (!setResult.IsSuccess) { row.Status = Loc.Instance["Plugins.SettingsSaveFailed"]; - await LoadPluginSettingsAsync(row, true); + await ReloadCurrentVisibleRowAsync(row, loaded, true); return false; } } @@ -440,8 +463,54 @@ await provider.SetSettingValueAsync(field.Key, field.Value, ct) return true; } - private async Task LoadPluginSettingsAsync(PluginRow row, bool preserveStatus = false) + private async Task ReloadCurrentVisibleRowAsync( + PluginRow commandRow, + LoadedPlugin loaded, + bool preserveStatus + ) + { + if ( + !_pluginById.TryGetValue(commandRow.Id, out var currentLoaded) + || !ReferenceEquals(currentLoaded, loaded) + ) + { + return; + } + + var currentRow = PluginGroups + .SelectMany(group => group.Plugins) + .FirstOrDefault(row => ReferenceEquals(row.LoadedPlugin, loaded)); + if (currentRow is null) + { + return; + } + + if (!ReferenceEquals(currentRow, commandRow)) + { + currentRow.Status = commandRow.Status; + } + + await LoadPluginSettingsAsync( + currentRow, + SettingsReloadKind.ResetBaseline, + preserveStatus + ); + } + + private async Task LoadPluginSettingsAsync( + PluginRow row, + SettingsReloadKind reloadKind, + bool preserveStatus = false + ) { + if ( + reloadKind == SettingsReloadKind.PreserveDraft + && row.HasUnsavedSettings + ) + { + return; + } + row.SettingFields.Clear(); row.Collections.Clear(); row.CanEditSettings = false; @@ -450,6 +519,7 @@ private async Task LoadPluginSettingsAsync(PluginRow row, bool preserveStatus = if (!_pluginById.TryGetValue(row.Id, out var loaded)) { row.Status = Loc.Instance["Plugins.UnableToLoadSettings"]; + row.CaptureSettingsBaseline(); return; } @@ -459,6 +529,7 @@ private async Task LoadPluginSettingsAsync(PluginRow row, bool preserveStatus = if (flatProvider is null && collectionProvider is null) { row.Status = Loc.Instance["Plugins.NoHostNeutralSettings"]; + row.CaptureSettingsBaseline(); return; } @@ -570,11 +641,13 @@ await collectionProvider ? Loc.Instance["Plugins.EditValuesHint"] : Loc.Instance["Plugins.NoEditableFields"]; } + + row.CaptureSettingsBaseline(); } - private void BeginObservedSettingsLoad(PluginRow row) + private void BeginObservedSettingsLoad(PluginRow row, SettingsReloadKind reloadKind) { - var loadTask = ObserveSettingsLoadAsync(row); + var loadTask = ObserveSettingsLoadAsync(row, reloadKind); _ = loadTask.ContinueWith( completedTask => Trace.WriteLine( @@ -588,11 +661,14 @@ private void BeginObservedSettingsLoad(PluginRow row) ); } - private async Task ObserveSettingsLoadAsync(PluginRow row) + private async Task ObserveSettingsLoadAsync( + PluginRow row, + SettingsReloadKind reloadKind + ) { try { - await LoadPluginSettingsAsync(row); + await LoadPluginSettingsAsync(row, reloadKind); } catch (Exception ex) { @@ -618,6 +694,7 @@ private void MarkSettingsLoadFailed(PluginRow row, bool preserveStatus = false) row.Collections.Clear(); row.CanEditSettings = false; row.CanValidateSettings = false; + row.CaptureSettingsBaseline(); if (!preserveStatus) { @@ -741,6 +818,18 @@ private readonly record struct PluginBoundaryResult(bool IsSuccess, T? Value) public static PluginBoundaryResult Failure => new(false, default); } + private enum PluginListRefreshKind + { + Initial, + Ambient, + } + + private enum SettingsReloadKind + { + PreserveDraft, + ResetBaseline, + } + // Plugin card name/description come from manifest.json (single-language). // Resolve them through the plugin's own catalog so they follow the UI // language, falling back to the manifest literal when the catalog has no @@ -844,6 +933,8 @@ public PluginCategoryGroup(string title, IEnumerable plugins) public partial class PluginRow : ObservableObject { + private PluginSettingsDraftEntry[]? _settingsBaseline; + [ObservableProperty] private bool _canEditSettings; @@ -920,6 +1011,73 @@ bool isEnabled public ObservableCollection Collections { get; } = []; public PluginsSectionViewModel? Owner { get; } + internal LoadedPlugin? LoadedPlugin { get; init; } + internal bool HasUnsavedSettings => + _settingsBaseline is not null + && !_settingsBaseline.SequenceEqual(CaptureSettingsDraft()); + + internal void CaptureSettingsBaseline() + { + _settingsBaseline = CaptureSettingsDraft(); + } + + private PluginSettingsDraftEntry[] CaptureSettingsDraft() + { + var entries = new List(); + for (var fieldIndex = 0; fieldIndex < SettingFields.Count; fieldIndex++) + { + var field = SettingFields[fieldIndex]; + entries.Add( + new PluginSettingsDraftEntry( + PluginSettingsDraftEntryKind.FlatField, + -1, + -1, + fieldIndex, + field.Key, + field.Value, + field.SelectedOption + ) + ); + } + + for (var collectionIndex = 0; collectionIndex < Collections.Count; collectionIndex++) + { + var collection = Collections[collectionIndex]; + entries.Add( + new PluginSettingsDraftEntry( + PluginSettingsDraftEntryKind.Collection, + collectionIndex, + -1, + -1, + collection.Key, + null, + null + ) + ); + + for (var itemIndex = 0; itemIndex < collection.Items.Count; itemIndex++) + { + var item = collection.Items[itemIndex]; + for (var fieldIndex = 0; fieldIndex < item.Fields.Count; fieldIndex++) + { + var field = item.Fields[fieldIndex]; + entries.Add( + new PluginSettingsDraftEntry( + PluginSettingsDraftEntryKind.CollectionField, + collectionIndex, + itemIndex, + fieldIndex, + field.Key, + field.Value, + field.SelectedOption + ) + ); + } + } + } + + return entries.ToArray(); + } partial void OnIsExpandedChanged(bool value) { @@ -933,6 +1091,23 @@ partial void OnIsEnabledChanged(bool value) OnPropertyChanged(nameof(StatusBadgeBorder)); OnPropertyChanged(nameof(StatusBadgeForeground)); } + + private enum PluginSettingsDraftEntryKind + { + FlatField, + Collection, + CollectionField, + } + + private sealed record PluginSettingsDraftEntry( + PluginSettingsDraftEntryKind Kind, + int CollectionIndex, + int ItemIndex, + int FieldIndex, + string Key, + string? Value, + PluginSettingOption? SelectedOption + ); } public sealed record PluginFailureRow(string FolderName, string Message); diff --git a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs index 66517e93f..7f23174ed 100644 --- a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Reflection; using TypeWhisper.Core.Services; +using TypeWhisper.Linux.Services.Plugins; using TypeWhisper.Linux.ViewModels.Sections; using TypeWhisper.PluginSDK; using Xunit; @@ -284,6 +285,198 @@ public async Task ValidateSettings_SlowValidateAsync_UsesLongerValidationTimeout Assert.Equal("Validated OK.", row.Status); } + [Fact] + public async Task PluginStateChanged_SameInstance_PreservesDirtyFlatAndCollectionDrafts_ButRecreatesCleanSibling() + { + var plugin = new FakeEditablePlugin("com.test.draft-preservation"); + plugin.Items.Add(CreateItem("A")); + plugin.Items.Add(CreateItem("B")); + var sibling = new FakeSettingsPlugin("com.test.clean-sibling"); + var loadedPlugin = TestPluginManagerFactory.CreateLoadedPlugin( + _tempDir, + plugin.PluginId, + plugin + ); + var loadedSibling = TestPluginManagerFactory.CreateLoadedPlugin( + _tempDir, + sibling.PluginId, + sibling + ); + var manager = TestPluginManagerFactory.Create( + loadedPlugins: [loadedPlugin, loadedSibling] + ); + var vm = new PluginsSectionViewModel(manager); + var row = vm.PluginGroups + .SelectMany(group => group.Plugins) + .Single(candidate => candidate.Id == plugin.PluginId); + var siblingRow = vm.PluginGroups + .SelectMany(group => group.Plugins) + .Single(candidate => candidate.Id == sibling.PluginId); + await vm.ToggleExpandedCommand.ExecuteAsync(row); + + row.SettingFields.Single().Value = "flat draft"; + var collection = Assert.Single(row.Collections); + collection.AddItemCommand.Execute(null); + var added = collection.Items[^1]; + added.Fields.Single(field => field.Key == "name").Value = "C"; + collection.MoveUpCommand.Execute(added); + + InvokeRefresh(vm); + + var visibleRows = vm.PluginGroups.SelectMany(group => group.Plugins).ToList(); + var visibleRow = visibleRows.Single(candidate => candidate.Id == plugin.PluginId); + var visibleSibling = visibleRows.Single(candidate => candidate.Id == sibling.PluginId); + Assert.Same(row, visibleRow); + Assert.Equal("flat draft", visibleRow.SettingFields.Single().Value); + Assert.Equal( + ["A", "C", "B"], + visibleRow + .Collections.Single() + .Items.Select(item => item.Fields.Single(field => field.Key == "name").Value) + .ToArray() + ); + Assert.NotSame(siblingRow, visibleSibling); + } + + [Fact] + public async Task PluginStateChanged_NewInstanceWithSameId_DropsDraftAndRecreatesRow() + { + var originalPlugin = new FakeEditablePlugin("com.test.instance-replacement") + { + SettingValue = "original", + }; + var originalLoaded = TestPluginManagerFactory.CreateLoadedPlugin( + _tempDir, + originalPlugin.PluginId, + originalPlugin + ); + var manager = TestPluginManagerFactory.Create(loadedPlugins: [originalLoaded]); + var vm = new PluginsSectionViewModel(manager); + var originalRow = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + await vm.ToggleExpandedCommand.ExecuteAsync(originalRow); + originalRow.SettingFields.Single().Value = "draft"; + + var replacementPlugin = new FakeEditablePlugin(originalPlugin.PluginId) + { + SettingValue = "replacement", + }; + var replacementLoaded = TestPluginManagerFactory.CreateLoadedPlugin( + _tempDir, + replacementPlugin.PluginId, + replacementPlugin + ); + ReplaceLoadedPlugins(manager, replacementLoaded); + + InvokeRefresh(vm); + + var replacementRow = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + Assert.NotSame(originalRow, replacementRow); + await WaitForAsync( + () => replacementRow.SettingFields.Count == 1, + TimeSpan.FromSeconds(2) + ); + Assert.Equal("replacement", replacementRow.SettingFields.Single().Value); + } + + [Fact] + public async Task SaveSuccess_ResetsBaseline_SoAmbientRefreshRecreatesRowWithSavedValues() + { + var plugin = new FakeEditablePlugin("com.test.clean-after-save"); + plugin.Items.Add(CreateItem("Before")); + var loaded = TestPluginManagerFactory.CreateLoadedPlugin( + _tempDir, + plugin.PluginId, + plugin + ); + var manager = TestPluginManagerFactory.Create(loadedPlugins: [loaded]); + var vm = new PluginsSectionViewModel(manager); + var row = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + await vm.ToggleExpandedCommand.ExecuteAsync(row); + row.SettingFields.Single().Value = "saved flat"; + row.Collections + .Single() + .Items.Single() + .Fields.Single(field => field.Key == "name") + .Value = "Saved item"; + + await vm.SaveSettingsCommand.ExecuteAsync(row); + + Assert.Equal(2, plugin.GetSettingValueCallCount); + Assert.Equal("saved flat", row.SettingFields.Single().Value); + Assert.Equal( + "Saved item", + row.Collections + .Single() + .Items.Single() + .Fields.Single(field => field.Key == "name") + .Value + ); + + InvokeRefresh(vm); + + var refreshedRow = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + Assert.NotSame(row, refreshedRow); + await WaitForAsync( + () => refreshedRow.SettingFields.Count == 1 && refreshedRow.Collections.Count == 1, + TimeSpan.FromSeconds(2) + ); + Assert.Equal("saved flat", refreshedRow.SettingFields.Single().Value); + Assert.Equal( + "Saved item", + refreshedRow + .Collections.Single() + .Items.Single() + .Fields.Single(field => field.Key == "name") + .Value + ); + } + + [Fact] + public async Task AmbientRefresh_DuringInFlightSave_PostSaveReloadTargetsCurrentVisibleRow() + { + var plugin = new FakeEditablePlugin("com.test.save-refresh-race") + { + NormalizeSettingOnSave = true, + SetSettingStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ), + ContinueSetSetting = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ), + }; + var loaded = TestPluginManagerFactory.CreateLoadedPlugin( + _tempDir, + plugin.PluginId, + plugin + ); + var manager = TestPluginManagerFactory.Create(loadedPlugins: [loaded]); + var vm = new PluginsSectionViewModel(manager); + var commandRow = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + await vm.ToggleExpandedCommand.ExecuteAsync(commandRow); + + var saveTask = vm.SaveSettingsCommand.ExecuteAsync(commandRow); + await plugin.SetSettingStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + InvokeRefresh(vm); + + var visibleRow = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + Assert.NotSame(commandRow, visibleRow); + await WaitForAsync( + () => visibleRow.SettingFields.Count == 1, + TimeSpan.FromSeconds(2) + ); + Assert.Equal("initial", visibleRow.SettingFields.Single().Value); + + plugin.ContinueSetSetting.TrySetResult(); + await saveTask; + + Assert.Same( + visibleRow, + vm.PluginGroups.SelectMany(group => group.Plugins).Single() + ); + Assert.Equal("initial-saved", visibleRow.SettingFields.Single().Value); + Assert.True(plugin.GetSettingValueCallCount >= 3); + } + // ---- PluginSettingFieldRow direct unit tests -------------------------- [Fact] @@ -581,6 +774,22 @@ private static void InvokeRefresh(PluginsSectionViewModel vm) refresh.Invoke(vm, null); } + private static void ReplaceLoadedPlugins( + PluginManager manager, + params LoadedPlugin[] loadedPlugins + ) + { + var field = + typeof(PluginManager).GetField( + "_allPlugins", + BindingFlags.Instance | BindingFlags.NonPublic + ) + ?? throw new MissingFieldException(typeof(PluginManager).FullName, "_allPlugins"); + var current = Assert.IsType>(field.GetValue(manager)); + current.Clear(); + current.AddRange(loadedPlugins); + } + private static async Task WaitForAsync(Func condition, TimeSpan timeout) { var deadline = Stopwatch.StartNew(); @@ -608,6 +817,18 @@ private static PluginCollectionDefinition ThingsDefinition() ); } + private static PluginCollectionItem CreateItem(string name) + { + return new PluginCollectionItem( + new Dictionary + { + ["name"] = name, + ["enabled"] = "true", + ["__id"] = Guid.NewGuid().ToString("D"), + } + ); + } + private static PluginCollectionRow CreateCollectionRow(params PluginCollectionItem[] items) { var ownerRow = new PluginRow( @@ -703,6 +924,114 @@ public Task DeactivateAsync() public void Dispose() { } } + private sealed class FakeEditablePlugin + : ITypeWhisperPlugin, + IPluginSettingsProvider, + IPluginCollectionSettingsProvider + { + private static readonly PluginSettingDefinition s_settingDefinition = new( + "value", + "Value", + Kind: PluginSettingKind.Text + ); + + public FakeEditablePlugin(string pluginId) + { + PluginId = pluginId; + } + + public List Items { get; } = []; + public string SettingValue { get; set; } = "initial"; + public int GetSettingValueCallCount { get; private set; } + public bool NormalizeSettingOnSave { get; init; } + public TaskCompletionSource? SetSettingStarted { get; init; } + public TaskCompletionSource? ContinueSetSetting { get; init; } + public string PluginId { get; } + public string PluginName => $"Editable {PluginId}"; + public string PluginVersion => "1.0.0"; + + public IReadOnlyList GetSettingDefinitions() + { + return [s_settingDefinition]; + } + + public Task GetSettingValueAsync( + string key, + CancellationToken ct = default + ) + { + GetSettingValueCallCount++; + return Task.FromResult(SettingValue); + } + + public async Task SetSettingValueAsync( + string key, + string? value, + CancellationToken ct = default + ) + { + SetSettingStarted?.TrySetResult(); + if (ContinueSetSetting is not null) + { + await ContinueSetSetting.Task.ConfigureAwait(false); + } + + SettingValue = NormalizeSettingOnSave ? $"{value}-saved" : value ?? string.Empty; + } + + public Task ValidateAsync( + CancellationToken ct = default + ) + { + return Task.FromResult(null); + } + + public IReadOnlyList GetCollectionDefinitions() + { + return [ThingsDefinition()]; + } + + public Task> GetItemsAsync( + string collectionKey, + CancellationToken ct = default + ) + { + return Task.FromResult>( + Items.Select(CloneItem).ToList() + ); + } + + public Task SetItemsAsync( + string collectionKey, + IReadOnlyList items, + CancellationToken ct = default + ) + { + Items.Clear(); + Items.AddRange(items.Select(CloneItem)); + return Task.FromResult(new PluginSettingsValidationResult(true, "ok")); + } + + public Task ActivateAsync(IPluginHostServices host) + { + return Task.CompletedTask; + } + + public Task DeactivateAsync() + { + return Task.CompletedTask; + } + + public void Dispose() { } + + private static PluginCollectionItem CloneItem(PluginCollectionItem item) + { + return new PluginCollectionItem( + new Dictionary(item.Values, StringComparer.Ordinal) + ); + } + } + /// /// Minimal plugin exposing only /// (no ) for view-model tests. From ba5f1b1a33aa0b276cb5dc561cb5860599712491 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 17:51:34 +0000 Subject: [PATCH 155/226] Cancel onboarding work when the wizard closes or is skipped Wizard cleanup only unsubscribed events and stopped audio; it owned no lifetime cancellation. Model downloads, privileged setup actions, CUDA benchmarking, and the first transcription all ran with no token (or an explicit CancellationToken.None) and mutated settings, rows, and UI after their awaits - multi-gigabyte downloads and privileged work kept running after the user closed or skipped onboarding, and completed operations mutated a cleaned-up ViewModel. The ViewModel now owns a lifetime CancellationTokenSource: idempotent Cleanup cancels and disposes it (Cancel before Dispose so late registrations short-circuit safely), and its token is threaded through model download/load, setup evaluation and actions, transcription acquisition, plugin transcription, and the benchmark. Lifetime cancellation is silent abandonment - no failure status or error-log entry - and an IsAbandoned guard (cleaned-up flag or canceled token) suppresses every post-await settings/row/UI mutation, including the paste and plugin-toggle paths whose underlying APIs cannot cancel: the in-flight work may finish, but no state lands afterward. Recording stop on the close path stays deliberately non-cancellable so captured audio is returned. PA39 tracks ModelManagerService's own status after an externally canceled download. --- .../ViewModels/WelcomeWizardViewModel.cs | 286 ++++++++++- .../WelcomeWizardViewModelTests.cs | 451 ++++++++++++++++++ 2 files changed, 721 insertions(+), 16 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/WelcomeWizardViewModelTests.cs diff --git a/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs b/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs index 52079ac06..66ff94da0 100644 --- a/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs @@ -35,9 +35,12 @@ public partial class WelcomeWizardViewModel : ObservableObject { private const string PasteSmokeExpectedText = "typewhisper paste test"; private readonly AudioRecordingService _audio; + private readonly IReadOnlyList? _availableMics; private readonly SystemCommandAvailabilityService _commands; private readonly IDictionaryService _dictionary; private readonly HotkeyService _hotkey; + private readonly CancellationTokenSource _lifetimeCts; + private readonly CancellationToken _lifetimeToken; private readonly ModelManagerService _models; private readonly PropertyChangedEventHandler _modelStateChangedHandler; private readonly PluginManager _pluginManager; @@ -45,7 +48,7 @@ public partial class WelcomeWizardViewModel : ObservableObject private readonly ISettingsService _settings; private readonly IReadOnlyList _setupTasks; private readonly TextInsertionService _textInsertion; - private bool _cleanedUp; + private int _cleanedUp; private AudioRecordingService.AudioCaptureSession? _firstDictationCaptureSession; [ObservableProperty] @@ -125,19 +128,63 @@ public WelcomeWizardViewModel( IDictionaryService dictionary, ISettingsService settings ) + : this( + models, + pluginManager, + hotkey, + audio, + commands, + textInsertion, + setupTasks, + dictionary, + settings, + availableMics: null + ) { + } + + internal WelcomeWizardViewModel( + ModelManagerService models, + PluginManager pluginManager, + HotkeyService hotkey, + AudioRecordingService audio, + SystemCommandAvailabilityService commands, + TextInsertionService textInsertion, + IEnumerable setupTasks, + IDictionaryService dictionary, + ISettingsService settings, + IReadOnlyList? availableMics + ) + { + _lifetimeCts = new CancellationTokenSource(); + _lifetimeToken = _lifetimeCts.Token; _models = models; _pluginManager = pluginManager; _hotkey = hotkey; _audio = audio; + _availableMics = availableMics; _commands = commands; _textInsertion = textInsertion; _setupTasks = setupTasks.Where(t => t.AppliesToThisMachine()).ToArray(); _dictionary = dictionary; _settings = settings; - _pluginStateChangedHandler = (_, _) => Dispatcher.UIThread.Post(RefreshPluginState); - _modelStateChangedHandler = (_, _) => Dispatcher.UIThread.Post(OnModelStatusChanged); + _pluginStateChangedHandler = (_, _) => + Dispatcher.UIThread.Post(() => + { + if (!IsAbandoned) + { + RefreshPluginState(); + } + }); + _modelStateChangedHandler = (_, _) => + Dispatcher.UIThread.Post(() => + { + if (!IsAbandoned) + { + OnModelStatusChanged(); + } + }); _pluginManager.PluginStateChanged += _pluginStateChangedHandler; _models.PropertyChanged += _modelStateChangedHandler; _audio.LevelChanged += OnAudioLevelChanged; @@ -188,6 +235,11 @@ ISettingsService settings public async Task RunPasteSmokeTestAsync() { + if (IsAbandoned) + { + return false; + } + PasteTestPassed = false; PasteSmokeText = ""; PasteTestStatus = Loc.Instance["Wizard.PasteTestRunning"]; @@ -202,12 +254,22 @@ public async Task RunPasteSmokeTestAsync() } catch (Exception ex) { + if (IsAbandoned) + { + return false; + } + PasteSmokeText = ex.Message; PasteTestPassed = false; PasteTestStatus = Loc.Instance.GetString("Wizard.PasteTestFailed", ex.Message); return false; } + if (IsAbandoned) + { + return false; + } + // Remaining values (Pasted, Typed, NoText, …) are handled by the check below. // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault -- only the actionable cases are handled; remaining enum values are deliberate no-ops. switch (result) @@ -238,6 +300,11 @@ public async Task RunPasteSmokeTestAsync() public void CompletePasteSmokeTest(string? actualText) { + if (IsAbandoned) + { + return; + } + PasteSmokeText = actualText ?? ""; PasteTestPassed = PasteSmokeText.Contains( PasteSmokeExpectedText, @@ -251,15 +318,26 @@ public void CompletePasteSmokeTest(string? actualText) // Guards against Avalonia firing Closed more than once on certain backends. public void Cleanup() { - if (_cleanedUp) + if (Interlocked.Exchange(ref _cleanedUp, 1) != 0) { return; } - _cleanedUp = true; _pluginManager.PluginStateChanged -= _pluginStateChangedHandler; _models.PropertyChanged -= _modelStateChangedHandler; _audio.LevelChanged -= OnAudioLevelChanged; + try + { + _lifetimeCts.Cancel(); + } + catch (AggregateException) + { + // A cancellation callback must not block the rest of close cleanup. + } + finally + { + _lifetimeCts.Dispose(); + } if (IsMicTestRunning) { @@ -271,6 +349,7 @@ public void Cleanup() if (firstDictationCaptureSession is not null) { FireAndLog( + // ReSharper disable once MethodSupportsCancellation -- teardown path; the CTS is already cancelled and disposed, so forwarding it would just fault the stop. () => _audio.StopRecordingAsync(firstDictationCaptureSession), "welcome wizard stop recording" ); @@ -354,7 +433,7 @@ private void LoadExtensions() private void LoadMics() { Mics.Clear(); - foreach (var d in AudioRecordingService.GetInputDevices()) + foreach (var d in _availableMics ?? AudioRecordingService.GetInputDevices()) { Mics.Add(d); } @@ -367,6 +446,11 @@ private void LoadMics() private void RefreshPluginState() { + if (IsAbandoned) + { + return; + } + foreach (var existing in ExtensionPlugins) { var isEnabled = _pluginManager.IsEnabled(existing.Id); @@ -386,6 +470,11 @@ private void RefreshPluginState() // running the heavy probe on each one would saturate the UI thread. private void OnModelStatusChanged() { + if (IsAbandoned) + { + return; + } + UpdateDownloadProgress(); if (!IsModelDownloading) @@ -452,6 +541,11 @@ partial void OnSelectedModelChanged(WizardModelRow? value) partial void OnStepIndexChanged(int value) { + if (IsAbandoned) + { + return; + } + OnPropertyChanged(nameof(IsFirstStep)); OnPropertyChanged(nameof(IsLastStep)); OnPropertyChanged(nameof(NextLabel)); @@ -490,6 +584,11 @@ partial void OnHotkeyTextChanged(string value) [RelayCommand] private void Back() { + if (IsAbandoned) + { + return; + } + if (StepIndex > 0) { StepIndex--; @@ -499,6 +598,11 @@ private void Back() [RelayCommand] private async Task NextAsync() { + if (IsAbandoned) + { + return; + } + // Step 0: pick model — download/load before advancing if (StepIndex == 0) { @@ -523,14 +627,28 @@ private async Task NextAsync() try { - await _models.DownloadAndLoadModelAsync(row.ModelId); + await _models.DownloadAndLoadModelAsync(row.ModelId, _lifetimeToken); + if (IsAbandoned) + { + return; + } + _settings.Save(_settings.Current with { SelectedModelId = row.ModelId }); ModelStatus = Loc.Instance.GetString("Wizard.ModelReady", row.DisplayName); IsModelDownloading = false; RefreshModelState(); } + catch (OperationCanceledException) when (_lifetimeToken.IsCancellationRequested) + { + return; + } catch (Exception ex) { + if (IsAbandoned) + { + return; + } + IsModelDownloading = false; ModelStatus = Loc.Instance.GetString("Wizard.ModelFailed", ex.Message); return; @@ -583,6 +701,11 @@ _settings.Current with [RelayCommand] private void Skip() { + if (IsAbandoned) + { + return; + } + FinishOnboardingWithIndustryPreset(); RequestClose?.Invoke(this, EventArgs.Empty); } @@ -606,6 +729,11 @@ _settings.Current with [RelayCommand] private async Task TogglePluginEnabledAsync(PluginRow row) { + if (IsAbandoned) + { + return; + } + if (row.IsEnabled) { await _pluginManager.DisablePluginAsync(row.Id); @@ -622,6 +750,11 @@ private async Task TogglePluginEnabledAsync(PluginRow row) /// private async Task RefreshSetupAsync() { + if (IsAbandoned) + { + return; + } + if (SetupItems.Count == 0) { foreach (var task in _setupTasks) @@ -640,20 +773,42 @@ private async Task RefreshSetupAsync() SetupTaskState state; try { - state = await Task.Run(() => row.Source.EvaluateAsync(CancellationToken.None)) + state = await Task.Run( + () => row.Source.EvaluateAsync(_lifetimeToken), + _lifetimeToken + ) .ConfigureAwait(true); } + catch (OperationCanceledException) when (_lifetimeToken.IsCancellationRequested) + { + return; + } catch (Exception ex) { + if (IsAbandoned) + { + return; + } + state = new SetupTaskState( SetupTaskStatusKind.Failed, Loc.Instance.GetString("Wizard.SetupCheckFailed", ex.Message) ); } + if (IsAbandoned) + { + return; + } + row.Apply(state); } + if (IsAbandoned) + { + return; + } + RefreshSetupGating(); } @@ -686,7 +841,7 @@ hotkeyRow is not null [RelayCommand] private async Task RunSetupActionAsync(SetupTaskRow? row) { - if (row is null || row.IsBusy) + if (IsAbandoned || row is null || row.IsBusy) { return; } @@ -697,17 +852,34 @@ private async Task RunSetupActionAsync(SetupTaskRow? row) SetupActionOutcome outcome; try { - outcome = await Task.Run(() => row.Source.RunActionAsync(CancellationToken.None)) + outcome = await Task.Run( + () => row.Source.RunActionAsync(_lifetimeToken), + _lifetimeToken + ) .ConfigureAwait(true); } + catch (OperationCanceledException) when (_lifetimeToken.IsCancellationRequested) + { + return; + } catch (Exception ex) { + if (IsAbandoned) + { + return; + } + outcome = new SetupActionOutcome( false, Loc.Instance.GetString("Wizard.SetupActionFailed", ex.Message) ); } + if (IsAbandoned) + { + return; + } + row.EndAction(outcome); // Re-evaluate all tasks: one install can satisfy several (e.g. a shared package). @@ -717,12 +889,22 @@ private async Task RunSetupActionAsync(SetupTaskRow? row) [RelayCommand] private async Task RecheckSetupAsync() { + if (IsAbandoned) + { + return; + } + await RefreshSetupAsync().ConfigureAwait(true); } [RelayCommand] private void ToggleMicTest() { + if (IsAbandoned) + { + return; + } + if (IsMicTestRunning) { _audio.StopPreview(); @@ -753,6 +935,11 @@ private void ToggleMicTest() [RelayCommand] private async Task ToggleFirstDictationAsync() { + if (IsAbandoned) + { + return; + } + if (!IsFirstDictationRecording) { if (IsMicTestRunning) @@ -804,14 +991,25 @@ private async Task ToggleFirstDictationAsync() { wav = captureSession is null ? [] + // ReSharper disable once MethodSupportsCancellation -- must run to completion to return the captured audio; intentionally non-cancellable. : await _audio.StopRecordingAsync(captureSession); } catch (Exception ex) { + if (IsAbandoned) + { + return; + } + FirstDictationStatus = Loc.Instance.GetString("Wizard.RecordingFailed", ex.Message); return; } + if (IsAbandoned) + { + return; + } + if (wav.Length == 0) { FirstDictationStatus = Loc.Instance["Wizard.NoAudioCaptured"]; @@ -823,10 +1021,22 @@ private async Task ToggleFirstDictationAsync() ModelManagerService.TranscriptionLease lease; try { - lease = await _models.AcquireTranscriptionAsync(SelectedModel?.ModelId); + lease = await _models.AcquireTranscriptionAsync( + SelectedModel?.ModelId, + cancellationToken: _lifetimeToken + ); + } + catch (OperationCanceledException) when (_lifetimeToken.IsCancellationRequested) + { + return; } catch (InvalidOperationException) { + if (IsAbandoned) + { + return; + } + FirstDictationStatus = Loc.Instance["Wizard.ModelLoadFailed"]; return; } @@ -834,6 +1044,11 @@ private async Task ToggleFirstDictationAsync() string transcript; await using (lease) { + if (IsAbandoned) + { + return; + } + var plugin = lease.Plugin; FirstDictationStatus = Loc.Instance.GetString( "Wizard.Transcribing", @@ -844,19 +1059,38 @@ private async Task ToggleFirstDictationAsync() null, false, null, - CancellationToken.None + _lifetimeToken ); + if (IsAbandoned) + { + return; + } + // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract -- Text comes from an external ITranscriptionEnginePlugin; its non-null annotation may not hold, keep the defensive ?. transcript = result.Text?.Trim() ?? ""; } + if (IsAbandoned) + { + return; + } + FirstDictationText = transcript; FirstDictationStatus = string.IsNullOrWhiteSpace(FirstDictationText) ? Loc.Instance["Wizard.NoTextReturned"] : Loc.Instance["Wizard.FirstDictationPassed"]; } + catch (OperationCanceledException) when (_lifetimeToken.IsCancellationRequested) + { + // Cancellation during teardown is expected; swallow it. + } catch (Exception ex) { + if (IsAbandoned) + { + return; + } + FirstDictationStatus = Loc.Instance.GetString("Wizard.TranscriptionFailed", ex.Message); } } @@ -864,7 +1098,7 @@ private async Task ToggleFirstDictationAsync() [RelayCommand] private async Task RunCudaBenchmarkAsync() { - if (IsCudaBenchmarkRunning) + if (IsAbandoned || IsCudaBenchmarkRunning) { return; } @@ -879,24 +1113,41 @@ private async Task RunCudaBenchmarkAsync() CudaBenchmarkStatus = Loc.Instance["Wizard.CudaChecking"]; try { - var result = await _commands.RunCudaBenchmarkAsync(); + var result = await _commands.RunCudaBenchmarkAsync(_lifetimeToken); + if (IsAbandoned) + { + return; + } + CudaBenchmarkStatus = result.Message; } + catch (OperationCanceledException) when (_lifetimeToken.IsCancellationRequested) + { + // Cancellation during teardown is expected; swallow it. + } finally { - IsCudaBenchmarkRunning = false; + if (!IsAbandoned) + { + IsCudaBenchmarkRunning = false; + } } } private void OnAudioLevelChanged(object? sender, float level) { - if (!IsMicTestRunning && !IsFirstDictationRecording) + if (IsAbandoned || (!IsMicTestRunning && !IsFirstDictationRecording)) { return; } Dispatcher.UIThread.Post(() => { + if (IsAbandoned) + { + return; + } + // Raw RMS is typically well below 0.1 for normal speech; ×8 maps it to 0–1 for the meter. MicLevel = Math.Clamp(level * 8, 0, 1); if (IsMicTestRunning && MicLevel > 0.05) @@ -930,6 +1181,9 @@ private static void FireAndLog(Func start, string label) ); } + private bool IsAbandoned => + Volatile.Read(ref _cleanedUp) != 0 || _lifetimeToken.IsCancellationRequested; + private void RefreshStepDots() { while (StepDots.Count < StepCount) diff --git a/tests/TypeWhisper.Linux.Tests/WelcomeWizardViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/WelcomeWizardViewModelTests.cs new file mode 100644 index 000000000..3d745bf4a --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/WelcomeWizardViewModelTests.cs @@ -0,0 +1,451 @@ +using Moq; +using System.Reflection; +using System.Runtime.CompilerServices; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Hotkey; +using TypeWhisper.Linux.Services.Localization; +using TypeWhisper.Linux.Services.Plugins; +using TypeWhisper.Linux.Services.Setup; +using TypeWhisper.Linux.ViewModels; +using TypeWhisper.PluginSDK; +using TypeWhisper.PluginSDK.Models; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +// `token` is the view model's lifetime token; Cleanup cancels it mid-test, so the +// .WaitAsync(timeout) guards must stay time-bounded and never forward it. +// ReSharper disable MethodSupportsCancellation +public sealed class WelcomeWizardViewModelTests +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromSeconds(2); + + [Fact] + public async Task CleanupDuringModelDownload_CancelsTokenAndSuppressesLateCompletionMutations() + { + var releaseDownload = NewCompletionSource(); + var plugin = new FakeTranscriptionPlugin + { + DownloadImplementation = _ => releaseDownload.Task, + }; + using var harness = CreateHarness(plugin); + + var nextTask = harness.ViewModel.NextCommand.ExecuteAsync(null); + var token = await plugin.DownloadStarted.Task.WaitAsync(s_testTimeout); + + harness.ViewModel.Cleanup(); + var statusAfterCleanup = harness.ViewModel.ModelStatus; + var isDownloadingAfterCleanup = harness.ViewModel.IsModelDownloading; + + Assert.True(token.IsCancellationRequested); + + releaseDownload.SetResult(); + await nextTask.WaitAsync(s_testTimeout); + + harness.Settings.Verify( + settings => settings.Save(It.IsAny()), + Times.Never + ); + Assert.Equal(0, harness.ViewModel.StepIndex); + Assert.Equal(statusAfterCleanup, harness.ViewModel.ModelStatus); + Assert.Equal(isDownloadingAfterCleanup, harness.ViewModel.IsModelDownloading); + Assert.False(Assert.Single(harness.ViewModel.AvailableModels).IsDownloaded); + } + + [Fact] + public async Task CleanupDuringSetupAction_CancelsTokenAndSuppressesPostAwaitRowMutation() + { + var actionStarted = NewCompletionSource(); + var releaseAction = NewCompletionSource(); + var setupTask = CreateSetupTask(); + setupTask + .Setup(task => task.RunActionAsync(It.IsAny())) + .Returns( + (CancellationToken token) => + { + actionStarted.TrySetResult(token); + return releaseAction.Task; + } + ); + using var harness = CreateHarness(setupTasks: [setupTask.Object]); + var row = new SetupTaskRow(setupTask.Object); + harness.ViewModel.SetupItems.Add(row); + + var actionTask = harness.ViewModel.RunSetupActionCommand.ExecuteAsync(row); + var token = await actionStarted.Task.WaitAsync(s_testTimeout); + var actionMessageWhileRunning = row.ActionMessage; + + harness.ViewModel.Cleanup(); + + Assert.True(token.IsCancellationRequested); + + releaseAction.SetResult(new SetupActionOutcome(true, "late success")); + await actionTask.WaitAsync(s_testTimeout); + + Assert.True(row.IsBusy); + Assert.Equal(SetupTaskStatusKind.Working, row.Kind); + Assert.Equal(actionMessageWhileRunning, row.ActionMessage); + setupTask.Verify( + task => task.EvaluateAsync(It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task LifetimeCancellation_DoesNotSurfaceModelFailureStatus() + { + var plugin = new FakeTranscriptionPlugin + { + DownloadImplementation = token => + token.CanBeCanceled + ? Task.Delay(Timeout.InfiniteTimeSpan, token) + : Task.FromCanceled(new CancellationToken(canceled: true)), + }; + using var harness = CreateHarness(plugin); + + var nextTask = harness.ViewModel.NextCommand.ExecuteAsync(null); + var token = await plugin.DownloadStarted.Task.WaitAsync(s_testTimeout); + var runningStatus = harness.ViewModel.ModelStatus; + + harness.ViewModel.Cleanup(); + await nextTask.WaitAsync(s_testTimeout); + + Assert.True(token.IsCancellationRequested); + Assert.Equal(runningStatus, harness.ViewModel.ModelStatus); + Assert.NotEqual( + Loc.Instance.GetString("Wizard.ModelFailed", "A task was canceled."), + harness.ViewModel.ModelStatus + ); + harness.Settings.Verify( + settings => settings.Save(It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task ModelDownloadWithoutCleanup_CompletesAndAppliesMutations() + { + var releaseDownload = NewCompletionSource(); + var plugin = new FakeTranscriptionPlugin + { + DownloadImplementation = _ => releaseDownload.Task, + }; + using var harness = CreateHarness(plugin); + + var nextTask = harness.ViewModel.NextCommand.ExecuteAsync(null); + var token = await plugin.DownloadStarted.Task.WaitAsync(s_testTimeout); + + Assert.True(token.CanBeCanceled); + Assert.False(token.IsCancellationRequested); + + releaseDownload.SetResult(); + await nextTask.WaitAsync(s_testTimeout); + + Assert.False(token.IsCancellationRequested); + Assert.Equal(1, harness.ViewModel.StepIndex); + Assert.False(harness.ViewModel.IsModelDownloading); + Assert.True(Assert.Single(harness.ViewModel.AvailableModels).IsDownloaded); + Assert.Equal(plugin.FullModelId, harness.Settings.Object.Current.SelectedModelId); + Assert.Equal( + Loc.Instance.GetString( + "Wizard.ModelReady", + Assert.IsType(harness.ViewModel.SelectedModel).DisplayName + ), + harness.ViewModel.ModelStatus + ); + harness.Settings.Verify( + settings => + settings.Save( + It.Is(saved => saved.SelectedModelId == plugin.FullModelId) + ), + Times.Once + ); + } + + private static Mock CreateSetupTask() + { + var setupTask = new Mock(); + setupTask.SetupGet(task => task.Id).Returns("test-setup"); + setupTask.SetupGet(task => task.Title).Returns("Test setup"); + setupTask.SetupGet(task => task.Severity).Returns(SetupTaskSeverity.Required); + setupTask.Setup(task => task.AppliesToThisMachine()).Returns(true); + setupTask + .Setup(task => task.EvaluateAsync(It.IsAny())) + .ReturnsAsync(new SetupTaskState(SetupTaskStatusKind.Satisfied, "ready")); + return setupTask; + } + + private static TestHarness CreateHarness( + FakeTranscriptionPlugin? plugin = null, + IReadOnlyList? setupTasks = null + ) + { + var settings = TestPluginManagerFactory.CreateSettings(AppSettings.Default); + var pluginManager = TestPluginManagerFactory.Create(); + if (plugin is not null) + { + SetTranscriptionEngines(pluginManager, [plugin]); + } + + var models = new ModelManagerService(pluginManager, settings.Object); + var hotkey = new HotkeyService( + new BackendSelector(static () => new TestShortcutBackend()) + ); + var audio = new AudioRecordingService(_ => { }, () => 0, () => { }); + var commands = CreateCommandsWithoutHostProbes(); + var textInsertion = new TextInsertionService(new NoOpTextInsertionPlatform()); + var dictionary = new Mock(); + var viewModel = new WelcomeWizardViewModel( + models, + pluginManager, + hotkey, + audio, + commands, + textInsertion, + setupTasks ?? [], + dictionary.Object, + settings.Object, + availableMics: [] + ); + return new TestHarness( + viewModel, + settings, + models, + pluginManager, + hotkey, + audio + ); + } + + private static SystemCommandAvailabilityService CreateCommandsWithoutHostProbes() + { + var commands = (SystemCommandAvailabilityService) + RuntimeHelpers.GetUninitializedObject(typeof(SystemCommandAvailabilityService)); + commands.RaiseSnapshotChangedForTests( + new LinuxCapabilitySnapshot( + "Unknown", + false, + "none", + false, + false, + false, + false, + null, + false, + false, + false, + false, + false + ) + ); + return commands; + } + + private static void SetTranscriptionEngines( + PluginManager pluginManager, + IReadOnlyList plugins + ) + { + var field = + typeof(PluginManager).GetField( + "_transcriptionEngines", + BindingFlags.Instance | BindingFlags.NonPublic + ) + ?? throw new MissingFieldException( + typeof(PluginManager).FullName, + "_transcriptionEngines" + ); + field.SetValue(pluginManager, plugins.ToList()); + } + + private static TaskCompletionSource NewCompletionSource() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private static TaskCompletionSource NewCompletionSource() + { + return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private sealed class TestHarness : IDisposable + { + public TestHarness( + WelcomeWizardViewModel viewModel, + Mock settings, + ModelManagerService models, + PluginManager pluginManager, + HotkeyService hotkey, + AudioRecordingService audio + ) + { + ViewModel = viewModel; + Settings = settings; + Models = models; + PluginManager = pluginManager; + Hotkey = hotkey; + Audio = audio; + } + + public WelcomeWizardViewModel ViewModel { get; } + public Mock Settings { get; } + private ModelManagerService Models { get; } + private PluginManager PluginManager { get; } + private HotkeyService Hotkey { get; } + private AudioRecordingService Audio { get; } + + public void Dispose() + { + ViewModel.Cleanup(); + Models.Dispose(); + PluginManager.Dispose(); + Hotkey.Dispose(); + Audio.Dispose(); + } + } + + private sealed class FakeTranscriptionPlugin : ITranscriptionEnginePlugin + { + private const string ModelId = "test-model"; + private const string ModelDisplayName = "Test model"; + + private bool _isDownloaded; + + public Func DownloadImplementation { get; init; } = + _ => Task.CompletedTask; + + public TaskCompletionSource DownloadStarted { get; } = + NewCompletionSource(); + + public string FullModelId => ModelManagerService.GetPluginModelId(PluginId, ModelId); + public string PluginId => "com.test.welcome-wizard"; + public string PluginName => "Welcome wizard fake"; + public string PluginVersion => "1.0.0"; + public string ProviderId => "welcome-wizard"; + public string ProviderDisplayName => "Test provider"; + public bool IsConfigured => true; + public IReadOnlyList TranscriptionModels { get; } = + [ + new(ModelId, ModelDisplayName) + { + IsRecommended = true, + }, + ]; + public string? SelectedModelId { get; private set; } + public bool SupportsTranslation => false; + public bool SupportsModelDownload => true; + + public Task ActivateAsync(IPluginHostServices host) + { + return Task.CompletedTask; + } + + public Task DeactivateAsync() + { + return Task.CompletedTask; + } + + public void SelectModel(string modelId) + { + SelectedModelId = modelId; + } + + public bool IsModelDownloaded(string modelId) + { + return _isDownloaded; + } + + public async Task DownloadModelAsync( + string modelId, + IProgress? progress, + CancellationToken ct + ) + { + DownloadStarted.TrySetResult(ct); + await DownloadImplementation(ct); + _isDownloaded = true; + } + + public Task LoadModelAsync(string modelId, CancellationToken ct) + { + return Task.CompletedTask; + } + + public Task TranscribeAsync( + byte[] wavAudio, + string? language, + bool translate, + string? prompt, + CancellationToken ct + ) + { + return Task.FromResult( + new PluginTranscriptionResult("", DetectedLanguage: null, 0) + ); + } + + public void Dispose() { } + } + + private sealed class NoOpTextInsertionPlatform : ITextInsertionPlatform + { + public bool IsClipboardSetAvailable => false; + public bool IsPasteAvailable => false; + public bool IsKdePlasma => false; + public bool PrefersDirectTypingForUnknownTarget => false; + public InsertionFailureReason LastFailureReason => InsertionFailureReason.None; + public bool LastTypingDeliveredPartialText => false; + + public Task TryGetClipboardTextAsync() + { + return Task.FromResult(null); + } + + public Task SetClipboardTextAsync(string text) + { + return Task.FromResult(false); + } + + public Task ClipboardHasNonTextFormatsAsync() + { + return Task.FromResult(false); + } + + public Task DelayAsync(TimeSpan delay) + { + return Task.CompletedTask; + } + + public string? GetActiveWindowId() + { + return null; + } + + public Task ActivateWindowAsync(string windowId) + { + return Task.FromResult(false); + } + + public Task SendPasteAsync(bool useTerminalShortcut = false) + { + return Task.FromResult(false); + } + + public Task TypeTextAsync(string text) + { + return Task.FromResult(false); + } + + public Task SendCopyAsync(bool useTerminalShortcut) + { + return Task.FromResult(false); + } + + public Task SendEnterAsync() + { + return Task.FromResult(false); + } + } +} From f2e8fa9200e738f18f69d52a5f8a20e732241ccf Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 18:27:56 +0000 Subject: [PATCH 156/226] Make plugin dropdowns show exactly what Save will send When a persisted dropdown value no longer matched any advertised option, PluginSettingFieldRow displayed the first current option while retaining the old value in the field Save submits - the user could see one model selected, press Save or Validate, and silently send a removed or renamed identifier. An unknown nonempty persisted value now injects a raw-value sentinel option, selected with Value kept equal to it, so the visible selection and the submitted value are the same thing. Selecting a real option updates Value atomically under a reentrancy flag and drops the sentinel. Known-value and empty-value construction behavior is unchanged, and the M6 draft-baseline semantics hold: a sentinel-loaded row is clean, switching away from the sentinel is a dirty edit. The sentinel label is the raw machine identifier, deliberately unlocalized. --- .../Sections/PluginsSectionViewModel.cs | 121 ++++++++- .../PluginCollectionSettingsViewModelTests.cs | 254 ++++++++++++++++++ 2 files changed, 367 insertions(+), 8 deletions(-) diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs index fd7ad8582..d0bd496b6 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs @@ -1114,15 +1114,24 @@ public sealed record PluginFailureRow(string FolderName, string Message); public sealed partial class PluginSettingFieldRow : ObservableObject { + private readonly PluginSettingOption[] _advertisedOptions; + [ObservableProperty] private bool _boolValue; + private readonly ObservableCollection _options; + [ObservableProperty] private PluginSettingOption? _selectedOption; // Prevents infinite cycling: Value↔BoolValue two-way sync would otherwise loop. private bool _syncingBoolValue; + // Keeps dropdown Value↔SelectedOption changes atomic and prevents recursive synchronization. + private bool _syncingOptionValue; + + private PluginSettingOption? _unavailableOption; + [ObservableProperty] private string _value; @@ -1141,10 +1150,24 @@ string value Label = label; Description = description; Placeholder = placeholder; - Options = options; Kind = ResolveKind(kind, options, isSecret); + _advertisedOptions = options.ToArray(); + _options = new ObservableCollection(_advertisedOptions); + Options = new ReadOnlyObservableCollection(_options); _value = value; - _selectedOption = Options.FirstOrDefault(o => o.Value == value) ?? (Options.Count > 0 ? Options[0] : null); + _selectedOption = _advertisedOptions.FirstOrDefault(option => option.Value == value); + if ( + _selectedOption is null + && Kind == PluginSettingKind.Dropdown + && !string.IsNullOrEmpty(_value) + ) + { + _unavailableOption = new PluginSettingOption(_value, _value); + _options.Insert(0, _unavailableOption); + _selectedOption = _unavailableOption; + } + + _selectedOption ??= Options.Count > 0 ? Options[0] : null; if (_selectedOption is not null && string.IsNullOrEmpty(_value)) { _value = _selectedOption.Value; @@ -1158,8 +1181,7 @@ string value public string Description { get; } public bool HasDescription => !string.IsNullOrWhiteSpace(Description); public string Placeholder { get; } - public IReadOnlyList Options { get; } - private bool HasOptions => Options.Count > 0; + public ReadOnlyObservableCollection Options { get; } public PluginSettingKind Kind { get; } public bool IsTextKind => Kind == PluginSettingKind.Text; @@ -1191,17 +1213,50 @@ bool isSecret partial void OnSelectedOptionChanged(PluginSettingOption? value) { - if (value is not null && _value != value.Value) + if (_syncingOptionValue) { - Value = value.Value; + return; + } + + if (Kind != PluginSettingKind.Dropdown) + { + if (value is not null && _value != value.Value) + { + Value = value.Value; + } + + return; + } + + _syncingOptionValue = true; + try + { + Value = value?.Value ?? string.Empty; + RemoveUnavailableOptionIfDeselected(value); + } + finally + { + _syncingOptionValue = false; } } partial void OnValueChanged(string value) { - if (HasOptions) + if (Kind == PluginSettingKind.Dropdown && !_syncingOptionValue) { - var option = Options.FirstOrDefault(o => o.Value == value); + _syncingOptionValue = true; + try + { + SynchronizeDropdownSelection(value); + } + finally + { + _syncingOptionValue = false; + } + } + else if (Kind != PluginSettingKind.Dropdown && Options.Count > 0) + { + var option = Options.FirstOrDefault(candidate => candidate.Value == value); if (!Equals(_selectedOption, option)) { SelectedOption = option; @@ -1229,6 +1284,56 @@ partial void OnBoolValueChanged(bool value) Value = value ? "true" : "false"; _syncingBoolValue = false; } + + private void SynchronizeDropdownSelection(string value) + { + var advertisedOption = _advertisedOptions.FirstOrDefault( + option => option.Value == value + ); + if (advertisedOption is not null) + { + SelectedOption = advertisedOption; + RemoveUnavailableOptionIfDeselected(advertisedOption); + return; + } + + if (string.IsNullOrEmpty(value)) + { + SelectedOption = null; + RemoveUnavailableOptionIfDeselected(null); + return; + } + + if (_unavailableOption?.Value == value) + { + SelectedOption = _unavailableOption; + return; + } + + var previousUnavailableOption = _unavailableOption; + _unavailableOption = new PluginSettingOption(value, value); + _options.Insert(0, _unavailableOption); + SelectedOption = _unavailableOption; + if (previousUnavailableOption is not null) + { + _options.Remove(previousUnavailableOption); + } + } + + private void RemoveUnavailableOptionIfDeselected(PluginSettingOption? selectedOption) + { + if ( + _unavailableOption is null + || ReferenceEquals(selectedOption, _unavailableOption) + ) + { + return; + } + + var unavailableOption = _unavailableOption; + _unavailableOption = null; + _options.Remove(unavailableOption); + } } internal sealed record PluginCategoryInfo(string Key, string DisplayName, int SortOrder); diff --git a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs index 7f23174ed..4afd45894 100644 --- a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs @@ -81,6 +81,45 @@ public async Task SaveSettings_ForwardsEditedItemsToProvider() Assert.True(Guid.TryParse(forwarded.Values["__id"], out _)); } + [Fact] + public async Task SaveSettings_DropdownSentinel_SubmitsDisplayedValueThenDropsAfterRealSelection() + { + var plugin = new FakeDropdownSettingsPlugin("com.test.dropdown-save"); + var loaded = TestPluginManagerFactory.CreateLoadedPlugin( + _tempDir, + plugin.PluginId, + plugin + ); + var manager = TestPluginManagerFactory.Create(loadedPlugins: [loaded]); + var vm = new PluginsSectionViewModel(manager); + var row = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + await vm.ToggleExpandedCommand.ExecuteAsync(row); + + var field = row.SettingFields.Single(candidate => candidate.Key == "model"); + var sentinel = Assert.IsType(field.SelectedOption); + Assert.Equal(plugin.ModelValue, sentinel.Label); + Assert.Equal(sentinel.Value, field.Value); + + await vm.SaveSettingsCommand.ExecuteAsync(row); + + Assert.Equal(["retired-model"], plugin.SavedModelValues); + field = row.SettingFields.Single(candidate => candidate.Key == "model"); + var advertisedOption = field.Options.Single(option => option.Value == "current-b"); + field.SelectedOption = advertisedOption; + + Assert.Equal(advertisedOption.Value, field.Value); + Assert.DoesNotContain(field.Options, option => option.Value == "retired-model"); + Assert.True(row.HasUnsavedSettings); + + await vm.SaveSettingsCommand.ExecuteAsync(row); + + Assert.Equal(["retired-model", "current-b"], plugin.SavedModelValues); + field = row.SettingFields.Single(candidate => candidate.Key == "model"); + Assert.Equal("current-b", field.Value); + Assert.Equal("current-b", field.SelectedOption?.Value); + Assert.DoesNotContain(field.Options, option => option.Value == "retired-model"); + } + [Fact] public async Task SaveSettings_FailureResultSurfacesInRowStatus() { @@ -338,6 +377,37 @@ public async Task PluginStateChanged_SameInstance_PreservesDirtyFlatAndCollectio Assert.NotSame(siblingRow, visibleSibling); } + [Fact] + public async Task PluginStateChanged_DirtyRowWithDropdownSentinel_PreservesCoherentDraft() + { + var plugin = new FakeDropdownSettingsPlugin("com.test.dropdown-draft"); + var loaded = TestPluginManagerFactory.CreateLoadedPlugin( + _tempDir, + plugin.PluginId, + plugin + ); + var manager = TestPluginManagerFactory.Create(loadedPlugins: [loaded]); + var vm = new PluginsSectionViewModel(manager); + var row = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + await vm.ToggleExpandedCommand.ExecuteAsync(row); + row.SettingFields.Single(field => field.Key == "notes").Value = "draft notes"; + + InvokeRefresh(vm); + + var visibleRow = vm.PluginGroups.SelectMany(group => group.Plugins).Single(); + Assert.Same(row, visibleRow); + var dropdown = visibleRow.SettingFields.Single(field => field.Key == "model"); + var sentinel = Assert.IsType(dropdown.SelectedOption); + Assert.Equal("retired-model", sentinel.Label); + Assert.Equal(sentinel.Value, dropdown.Value); + Assert.Contains(dropdown.Options, option => ReferenceEquals(option, sentinel)); + Assert.Equal( + "draft notes", + visibleRow.SettingFields.Single(field => field.Key == "notes").Value + ); + Assert.True(visibleRow.HasUnsavedSettings); + } + [Fact] public async Task PluginStateChanged_NewInstanceWithSameId_DropsDraftAndRecreatesRow() { @@ -479,6 +549,98 @@ await WaitForAsync( // ---- PluginSettingFieldRow direct unit tests -------------------------- + [Fact] + public void FieldRow_UnknownPersistedDropdownValue_SelectsSentinelWithoutDirtyingBaseline() + { + var field = new PluginSettingFieldRow( + "model", + "Model", + "", + "", + [ + new PluginSettingOption("current-a", "Current A"), + new PluginSettingOption("current-b", "Current B"), + ], + false, + PluginSettingKind.Dropdown, + "retired-model" + ); + var row = new PluginRow( + null, + "p", + "P", + "1", + "", + "utility", + true, + true, + true + ); + row.SettingFields.Add(field); + row.CaptureSettingsBaseline(); + + var sentinel = Assert.IsType(field.SelectedOption); + Assert.Equal("retired-model", sentinel.Value); + Assert.Equal("retired-model", sentinel.Label); + Assert.Equal(sentinel.Value, field.Value); + Assert.Contains(field.Options, option => ReferenceEquals(option, sentinel)); + Assert.False(row.HasUnsavedSettings); + + field.SelectedOption = field.Options.Single(option => option.Value == "current-b"); + + Assert.Equal("current-b", field.Value); + Assert.DoesNotContain(field.Options, option => option.Value == "retired-model"); + Assert.True(row.HasUnsavedSettings); + } + + [Fact] + public void FieldRow_DropdownConstruction_KeepsUnknownKnownAndEmptyValuesCoherent() + { + PluginSettingOption[] options = + [ + new("current-a", "Current A"), + new("current-b", "Current B"), + ]; + + var unknown = new PluginSettingFieldRow( + "unknown", + "Unknown", + "", + "", + options, + false, + PluginSettingKind.Dropdown, + "retired-model" + ); + var known = new PluginSettingFieldRow( + "known", + "Known", + "", + "", + options, + false, + PluginSettingKind.Dropdown, + "current-b" + ); + var empty = new PluginSettingFieldRow( + "empty", + "Empty", + "", + "", + options, + false, + PluginSettingKind.Dropdown, + "" + ); + + Assert.Equal("retired-model", unknown.SelectedOption?.Value); + Assert.Equal(unknown.SelectedOption?.Value, unknown.Value); + Assert.Same(options[1], known.SelectedOption); + Assert.Equal("current-b", known.Value); + Assert.Same(options[0], empty.SelectedOption); + Assert.Equal("current-a", empty.Value); + } + [Fact] public void FieldRow_AutoKind_WithOptions_ResolvesToDropdown() { @@ -924,6 +1086,98 @@ public Task DeactivateAsync() public void Dispose() { } } + private sealed class FakeDropdownSettingsPlugin + : ITypeWhisperPlugin, + IPluginSettingsProvider + { + private static readonly PluginSettingDefinition s_modelDefinition = new( + "model", + "Model", + Options: + [ + new PluginSettingOption("current-a", "Current A"), + new PluginSettingOption("current-b", "Current B"), + ], + Kind: PluginSettingKind.Dropdown + ); + private static readonly PluginSettingDefinition s_notesDefinition = new( + "notes", + "Notes", + Kind: PluginSettingKind.Text + ); + + public FakeDropdownSettingsPlugin(string pluginId) + { + PluginId = pluginId; + } + + public List SavedModelValues { get; } = []; + public string ModelValue { get; private set; } = "retired-model"; + private string NotesValue { get; set; } = "initial notes"; + public string PluginId { get; } + public string PluginName => $"Dropdown {PluginId}"; + public string PluginVersion => "1.0.0"; + + public IReadOnlyList GetSettingDefinitions() + { + return [s_modelDefinition, s_notesDefinition]; + } + + public Task GetSettingValueAsync( + string key, + CancellationToken ct = default + ) + { + return Task.FromResult( + key switch + { + "model" => ModelValue, + "notes" => NotesValue, + _ => null, + } + ); + } + + public Task SetSettingValueAsync( + string key, + string? value, + CancellationToken ct = default + ) + { + switch (key) + { + case "model": + SavedModelValues.Add(value); + ModelValue = value ?? string.Empty; + break; + case "notes": + NotesValue = value ?? string.Empty; + break; + } + + return Task.CompletedTask; + } + + public Task ValidateAsync( + CancellationToken ct = default + ) + { + return Task.FromResult(null); + } + + public Task ActivateAsync(IPluginHostServices host) + { + return Task.CompletedTask; + } + + public Task DeactivateAsync() + { + return Task.CompletedTask; + } + + public void Dispose() { } + } + private sealed class FakeEditablePlugin : ITypeWhisperPlugin, IPluginSettingsProvider, From 604d0a6619c4cad419f5b5598c2aaba348ee4e4e Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 18:40:41 +0000 Subject: [PATCH 157/226] Surface recent-transcription feedback through the real presentation path RecentTranscriptionsService emits empty-history, success, failure, and missing-tool guidance through FeedbackRequested, but the only subscriber wrote Debug.WriteLine - in release use a hotkey could appear to do nothing, install guidance was invisible, and most of the messages were hard-coded English. Feedback strings now resolve through the localization catalog (new RecentTranscriptions.* keys in all four locales, reusing existing keys where they fit), and the App subscriber routes messages through DictationOrchestrator's standard feedback pipeline - the overlay on normal desktops, the notification service on tiling WMs - via a minimal TryPublishTransientFeedback entry point. Transient toasts are dropped, never deferred, while a dictation owns capture or the overlay: the guard checks the toggle gate, the audio-capture flag, visible overlay ownership, and the in-flight tracker, and suppresses the terminal sound cue so a toast can never bleed into an opening mic. Error-level feedback also lands in the error log in English so guidance is durable. PA40 tracks the pre-existing dictation/transform overlay coordination gap. --- src/TypeWhisper.Linux/App.axaml.cs | 31 +++- .../Resources/Localization/de.json | 7 + .../Resources/Localization/en.json | 7 + .../Resources/Localization/es.json | 7 + .../Resources/Localization/ru.json | 7 + .../Services/DictationOrchestrator.cs | 76 +++++++++- .../Services/RecentTranscriptionsService.cs | 80 +++++++++-- .../AppBootstrapTests.cs | 63 +++++++++ .../LocalizationResourcesTests.cs | 40 ++++++ .../RecentTranscriptionsServiceTests.cs | 133 +++++++++++++++++- 10 files changed, 423 insertions(+), 28 deletions(-) diff --git a/src/TypeWhisper.Linux/App.axaml.cs b/src/TypeWhisper.Linux/App.axaml.cs index 0ace3800d..f2c80ef61 100644 --- a/src/TypeWhisper.Linux/App.axaml.cs +++ b/src/TypeWhisper.Linux/App.axaml.cs @@ -401,11 +401,12 @@ void ReconcileDynamicHotkeys() var recentTranscriptions = services.GetRequiredService(); recentTranscriptions.FeedbackRequested += (message, isError) => - { - Debug.WriteLine( - $"[RecentTranscriptions] {(isError ? "Error" : "Info")}: {message}" + RouteRecentTranscriptionFeedback( + dictation.TryPublishTransientFeedback, + errorLog, + message, + isError ); - }; hotkey.RecentTranscriptionsRequested += (_, _) => recentTranscriptions.TogglePalette(); hotkey.CopyLastTranscriptionRequested += (_, _) => _ = recentTranscriptions.CopyLastTranscriptionToClipboardAsync(); @@ -683,6 +684,28 @@ internal static void DisposeDictationBeforeAudio( } } + internal static bool RouteRecentTranscriptionFeedback( + Func publishFeedback, + IErrorLogService errorLog, + string message, + bool isError + ) + { + Debug.WriteLine( + $"[RecentTranscriptions] {(isError ? "Error" : "Info")}: {message}" + ); + var published = publishFeedback(message, isError); + if (isError) + { + errorLog.AddEntry( + "Recent transcription insertion failed. Install wl-clipboard on Wayland or xclip on X11 for clipboard access. For automatic paste, set up ydotool on GNOME/KDE Wayland, install wtype or ydotool on other Wayland compositors, or install xdotool on X11.", + ErrorCategory.Insertion + ); + } + + return published; + } + private static Task BootstrapAsync(IServiceProvider services) { BootTrace.Stage("BootstrapAsync begin"); diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index 5bc65cf40..df917e5e0 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -608,9 +608,16 @@ "Prompts.Title": "Prompts", "Prompts.UseDefaultProvider": "Standardanbieter verwenden", "Prompts.UseDefaultProviderFallback": "{0} ({1})", + "RecentTranscriptions.CopiedToClipboard": "Letzte Transkription in die Zwischenablage kopiert.", "RecentTranscriptions.Empty": "Keine letzten Transkriptionen", + "RecentTranscriptions.InsertionFailed": "Texteinfügung fehlgeschlagen.", + "RecentTranscriptions.Pasted": "Letzte Transkription eingefügt.", + "RecentTranscriptions.PasteToolInstallHintWayland": "Installieren Sie wtype oder ydotool, um automatisches Einfügen zu aktivieren.", + "RecentTranscriptions.PasteToolInstallHintWaylandYdotool": "Richten Sie ydotool ein, um automatisches Einfügen unter GNOME / KDE Wayland zu aktivieren.", + "RecentTranscriptions.PasteToolInstallHintX11": "Installieren Sie xdotool, um automatisches Einfügen zu aktivieren.", "RecentTranscriptions.SearchPlaceholder": "Suche", "RecentTranscriptions.Title": "Letzte Transkriptionen", + "RecentTranscriptions.Typed": "Letzte Transkription eingegeben.", "Recorder.Capture": "Aufnahme", "Recorder.InputLevel": "Eingangspegel", "Recorder.Record": "Aufnehmen", diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index bab0b55c8..3a9406f3b 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -614,9 +614,16 @@ "Prompts.Title": "Prompts", "Prompts.UseDefaultProvider": "Use default provider", "Prompts.UseDefaultProviderFallback": "{0} ({1})", + "RecentTranscriptions.CopiedToClipboard": "Copied recent transcription to clipboard.", "RecentTranscriptions.Empty": "No recent transcriptions", + "RecentTranscriptions.InsertionFailed": "Text insertion failed.", + "RecentTranscriptions.Pasted": "Pasted recent transcription.", + "RecentTranscriptions.PasteToolInstallHintWayland": "Install wtype or ydotool to enable automatic paste.", + "RecentTranscriptions.PasteToolInstallHintWaylandYdotool": "Set up ydotool to enable automatic paste on GNOME / KDE Wayland.", + "RecentTranscriptions.PasteToolInstallHintX11": "Install xdotool to enable automatic paste.", "RecentTranscriptions.SearchPlaceholder": "Search", "RecentTranscriptions.Title": "Recent transcriptions", + "RecentTranscriptions.Typed": "Typed recent transcription.", "Recorder.Capture": "Capture", "Recorder.InputLevel": "Input level", "Recorder.Record": "Record", diff --git a/src/TypeWhisper.Linux/Resources/Localization/es.json b/src/TypeWhisper.Linux/Resources/Localization/es.json index ed8c89f93..39e7d986a 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -608,9 +608,16 @@ "Prompts.Title": "Prompts", "Prompts.UseDefaultProvider": "Usar proveedor predeterminado", "Prompts.UseDefaultProviderFallback": "{0} ({1})", + "RecentTranscriptions.CopiedToClipboard": "Transcripción reciente copiada al portapapeles.", "RecentTranscriptions.Empty": "No hay transcripciones recientes", + "RecentTranscriptions.InsertionFailed": "No se pudo insertar el texto.", + "RecentTranscriptions.Pasted": "Transcripción reciente pegada.", + "RecentTranscriptions.PasteToolInstallHintWayland": "Instala wtype o ydotool para activar el pegado automático.", + "RecentTranscriptions.PasteToolInstallHintWaylandYdotool": "Configura ydotool para activar el pegado automático en GNOME / KDE con Wayland.", + "RecentTranscriptions.PasteToolInstallHintX11": "Instala xdotool para activar el pegado automático.", "RecentTranscriptions.SearchPlaceholder": "Buscar", "RecentTranscriptions.Title": "Transcripciones recientes", + "RecentTranscriptions.Typed": "Transcripción reciente escrita.", "Recorder.Capture": "Captura", "Recorder.InputLevel": "Nivel de entrada", "Recorder.Record": "Grabar", diff --git a/src/TypeWhisper.Linux/Resources/Localization/ru.json b/src/TypeWhisper.Linux/Resources/Localization/ru.json index f42e2c7f6..3ada98245 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -608,9 +608,16 @@ "Prompts.Title": "Промпты", "Prompts.UseDefaultProvider": "Использовать провайдера по умолчанию", "Prompts.UseDefaultProviderFallback": "{0} ({1})", + "RecentTranscriptions.CopiedToClipboard": "Недавняя транскрипция скопирована в буфер обмена.", "RecentTranscriptions.Empty": "Нет недавних транскрипций", + "RecentTranscriptions.InsertionFailed": "Не удалось вставить текст.", + "RecentTranscriptions.Pasted": "Недавняя транскрипция вставлена.", + "RecentTranscriptions.PasteToolInstallHintWayland": "Установите wtype или ydotool, чтобы включить автоматическую вставку.", + "RecentTranscriptions.PasteToolInstallHintWaylandYdotool": "Настройте ydotool, чтобы включить автоматическую вставку в GNOME / KDE Wayland.", + "RecentTranscriptions.PasteToolInstallHintX11": "Установите xdotool, чтобы включить автоматическую вставку.", "RecentTranscriptions.SearchPlaceholder": "Поиск", "RecentTranscriptions.Title": "Недавние транскрипции", + "RecentTranscriptions.Typed": "Недавняя транскрипция введена.", "Recorder.Capture": "Захват", "Recorder.InputLevel": "Уровень входа", "Recorder.Record": "Запись", diff --git a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs index 6cf68e245..55425ef28 100644 --- a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs +++ b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs @@ -3560,7 +3560,12 @@ private void ReportStatus(RecordingContext context, string message) ); } - private void ShowFeedback(string text, bool isError, bool isCanceled = false) + private void ShowFeedback( + string text, + bool isError, + bool isCanceled = false, + bool playSound = true + ) { SetOverlayState(state => state with @@ -3583,8 +3588,11 @@ state with // flows through, so classify off the caller's intent. Cancellation // surfaces with isError=false but is neither success nor failure — // callers flag it via isCanceled rather than us sniffing the text - // (which varies: "Canceled", "Dictation canceled.", …). - if (!_settings.Current.SoundFeedbackEnabled) + // (which varies: "Canceled", "Dictation canceled.", …). Transient + // (non-dictation) feedback passes playSound: false — its cue is + // fire-and-forget and would otherwise outlive the ownership check and + // bleed into a microphone a dictation opens moments later. + if (!playSound || !_settings.Current.SoundFeedbackEnabled) { return; } @@ -3600,7 +3608,67 @@ state with } /// - /// variant that no-ops once a newer dictation has + /// Publishes non-dictation feedback through the standard overlay state pipeline, + /// skipped while a dictation owns the overlay so a secondary hotkey can't clobber + /// that state. Ownership is read from four signals: + /// + /// the toggle gate, held through the whole start/stop transition — + /// including before the mic opens, when the pre-capture start-up cue plays — + /// so a toast's cue can't bleed into a recording that's just starting; + /// the audio-capture flag, raised by any consumer of the shared recorder + /// (e.g. transform selection) that bypasses this gate, so a toast (and its cue) + /// never lands on a mic owned by another capture; + /// a visible overlay showing status rather than a terminal toast + /// (IsOverlayVisible && !ShowFeedback) — covers the post-stop + /// pipeline, including "Inserting…", whose awaited insertion runs after the + /// in-flight tracker has already cleared; + /// the in-flight tracker, covering hand-off between back-to-back + /// dictations. + /// + /// + internal bool TryPublishTransientFeedback(string text, bool isError) + { + lock (_overlayStateLock) + { + // CurrentCount == 0 means a start or stop owns the gate; reading it takes no + // lock, so it cannot invert against _overlayStateLock. + var toggleInProgress = _toggleGate.CurrentCount == 0; + var overlayOwnedByActiveSession = + _overlayState is { IsOverlayVisible: true, ShowFeedback: false }; + var hasActiveCapture = + toggleInProgress + || _audio.IsRecording + || overlayOwnedByActiveSession + || HasActiveOverlayOwningDictation(); + if (!CanPublishTransientFeedback(hasActiveCapture)) + { + return false; + } + + ShowFeedback(text, isError, playSound: false); + return true; + } + } + + internal static bool CanPublishTransientFeedback(bool hasActiveDictation) + { + return !hasActiveDictation; + } + + private bool HasActiveOverlayOwningDictation() + { + lock (_recordingSessionLock) + { + return _recordingSession > 0 + && ( + _inFlightTracker.Contains(_recordingSession) + || _inFlightTracker.Contains(_recordingSession - 1) + ); + } + } + + /// + /// variant that no-ops once a newer dictation has /// taken over the overlay. Prevents the previous recording's terminal /// feedback ("Typed N char(s)", "Transcription failed", "Canceled") from /// hiding the new recording's overlay. diff --git a/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs b/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs index 83afc31fc..ed00ccd02 100644 --- a/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs +++ b/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs @@ -22,7 +22,7 @@ public sealed class RecentTranscriptionsService private readonly IHistoryService _history; private readonly Func> _insertTextAsync; private readonly bool _isWaylandSession; - private readonly Func _pasteToolInstallHintProvider; + private readonly Func _pasteToolHintProvider; private readonly RecentTranscriptionStore _store; private bool _paletteOpening; @@ -44,7 +44,7 @@ SystemCommandAvailabilityService commands activeWindow.GetActiveWindowSnapshotAsync, textInsertion.InsertTextAsync, Task.Delay, - () => commands.GetSnapshot().PasteToolInstallHint, + () => PasteToolHintFor(commands.GetSnapshot()), Environment.GetEnvironmentVariable("WAYLAND_DISPLAY") is { Length: > 0 } ) { @@ -58,7 +58,7 @@ internal RecentTranscriptionsService( Func> activeWindowSnapshotProvider, Func> insertTextAsync, Func delay, - Func? pasteToolInstallHintProvider = null, + Func? pasteToolHintProvider = null, bool isWaylandSession = false ) { @@ -69,7 +69,8 @@ internal RecentTranscriptionsService( _activeWindowSnapshotProvider = activeWindowSnapshotProvider; _insertTextAsync = insertTextAsync; _delay = delay; - _pasteToolInstallHintProvider = pasteToolInstallHintProvider ?? (() => ""); + _pasteToolHintProvider = + pasteToolHintProvider ?? (() => RecentTranscriptionPasteToolHint.X11); _isWaylandSession = isWaylandSession; } @@ -94,7 +95,10 @@ public async Task CopyLastTranscriptionToClipboardAsync() var entry = _store.LatestEntry(_history.Records); if (entry is null) { - FeedbackRequested?.Invoke("No recent transcriptions.", false); + FeedbackRequested?.Invoke( + Localization.Loc.Instance["Overlay.NoRecentTranscriptions"], + false + ); return; } @@ -137,7 +141,10 @@ private async Task TogglePaletteCoreAsync() var entries = _store.MergedEntries(_history.Records); if (entries.Count == 0) { - FeedbackRequested?.Invoke("No recent transcriptions.", false); + FeedbackRequested?.Invoke( + Localization.Loc.Instance["Overlay.NoRecentTranscriptions"], + false + ); return; } @@ -377,23 +384,66 @@ private string StatusTextFor(InsertionResult result) { return result switch { - InsertionResult.Typed => "Typed recent transcription.", - InsertionResult.Pasted => "Pasted recent transcription.", - InsertionResult.CopiedToClipboard => "Copied recent transcription to clipboard.", + InsertionResult.Typed => + Localization.Loc.Instance["RecentTranscriptions.Typed"], + InsertionResult.Pasted => + Localization.Loc.Instance["RecentTranscriptions.Pasted"], + InsertionResult.CopiedToClipboard => + Localization.Loc.Instance["RecentTranscriptions.CopiedToClipboard"], InsertionResult.NoText => Localization.Loc.Instance["Overlay.NoRecentTranscriptions"], InsertionResult.MissingClipboardTool => ClipboardToolMissingMessage(), - InsertionResult.MissingPasteTool => _pasteToolInstallHintProvider(), - InsertionResult.Failed => "Text insertion failed.", - _ => "Done.", + InsertionResult.MissingPasteTool => + Localization.Loc.Instance[PasteToolInstallHintKey(_pasteToolHintProvider())], + InsertionResult.Failed => + Localization.Loc.Instance["RecentTranscriptions.InsertionFailed"], + _ => Localization.Loc.Instance["Recorder.StatusDone"], }; } private static string ClipboardToolMissingMessage() { - return Environment.GetEnvironmentVariable("WAYLAND_DISPLAY") is { Length: > 0 } - ? "Install wl-clipboard to copy recent transcriptions." - : "Install xclip to copy recent transcriptions."; + var clipboardTool = + Environment.GetEnvironmentVariable("WAYLAND_DISPLAY") is { Length: > 0 } + ? "wl-clipboard" + : "xclip"; + return Localization.Loc.Instance.GetString( + "TextInsertion.ClipboardInstallHint", + clipboardTool + ); } + + private static RecentTranscriptionPasteToolHint PasteToolHintFor( + LinuxCapabilitySnapshot snapshot + ) + { + if (snapshot.SessionType != "Wayland") + { + return RecentTranscriptionPasteToolHint.X11; + } + + return snapshot.CompositorRejectsWtype + ? RecentTranscriptionPasteToolHint.WaylandYdotool + : RecentTranscriptionPasteToolHint.Wayland; + } + + private static string PasteToolInstallHintKey(RecentTranscriptionPasteToolHint hint) + { + return hint switch + { + RecentTranscriptionPasteToolHint.Wayland => + "RecentTranscriptions.PasteToolInstallHintWayland", + RecentTranscriptionPasteToolHint.WaylandYdotool => + "RecentTranscriptions.PasteToolInstallHintWaylandYdotool", + _ => "RecentTranscriptions.PasteToolInstallHintX11", + }; + } +} + +internal enum RecentTranscriptionPasteToolHint +{ + X11, + Wayland, + WaylandYdotool, } internal sealed record RecentTranscriptionInsertionTarget( diff --git a/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs b/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs index 04742cc38..38ea18b7e 100644 --- a/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs @@ -1,5 +1,6 @@ using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services; using Xunit; namespace TypeWhisper.Linux.Tests; @@ -262,6 +263,68 @@ public async Task RunAsync_RequiredFailure_RunsIndependentStagesThenThrowsWithRe Assert.Equal(2, exception.Report.Outcomes.Count); } + [Fact] + public void RouteRecentTranscriptionFeedback_WhenIdle_PublishesThroughFeedbackStatePath() + { + var publications = new List<(string Message, bool IsError)>(); + var errorLog = new RecordingErrorLogService(); + + var published = App.RouteRecentTranscriptionFeedback( + (message, isError) => + { + if (!DictationOrchestrator.CanPublishTransientFeedback(false)) + { + return false; + } + + publications.Add((message, isError)); + return true; + }, + errorLog, + "localized success", + false + ); + + Assert.True(published); + Assert.Equal([("localized success", false)], publications); + Assert.Empty(errorLog.AddedEntries); + } + + [Fact] + public void RouteRecentTranscriptionFeedback_WhenDictationActive_SkipsPublicationButLogsEnglishError() + { + var publications = new List<(string Message, bool IsError)>(); + var errorLog = new RecordingErrorLogService(); + + var published = App.RouteRecentTranscriptionFeedback( + (message, isError) => + { + if (!DictationOrchestrator.CanPublishTransientFeedback(true)) + { + return false; + } + + publications.Add((message, isError)); + return true; + }, + errorLog, + "lokalisierter Fehler", + true + ); + + Assert.False(published); + Assert.Empty(publications); + Assert.Equal( + [ + ( + "Recent transcription insertion failed. Install wl-clipboard on Wayland or xclip on X11 for clipboard access. For automatic paste, set up ydotool on GNOME/KDE Wayland, install wtype or ydotool on other Wayland compositors, or install xdotool on X11.", + ErrorCategory.Insertion + ), + ], + errorLog.AddedEntries + ); + } + private static App.BootstrapStage[] CreateProductionShapedStages( List attempted, string? failingStage = null, diff --git a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs index b420f070b..cfbbf60d0 100644 --- a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs @@ -63,6 +63,46 @@ string language Assert.False(string.IsNullOrWhiteSpace(title)); } + [Theory] + [InlineData("en")] + [InlineData("de")] + [InlineData("es")] + [InlineData("ru")] + public void Catalogs_HaveRecentTranscriptionFeedbackStringsWithRequiredPlaceholders( + string language + ) + { + var catalog = Load(language); + var keysWithoutPlaceholders = new[] + { + "RecentTranscriptions.CopiedToClipboard", + "RecentTranscriptions.InsertionFailed", + "RecentTranscriptions.Pasted", + "RecentTranscriptions.PasteToolInstallHintWayland", + "RecentTranscriptions.PasteToolInstallHintWaylandYdotool", + "RecentTranscriptions.PasteToolInstallHintX11", + "RecentTranscriptions.Typed", + }; + + foreach (var key in keysWithoutPlaceholders) + { + Assert.True( + catalog.TryGetValue(key, out var value), + $"Missing {language} key: {key}" + ); + Assert.False( + string.IsNullOrWhiteSpace(value), + $"{language} key is empty: {key}" + ); + } + + Assert.True( + catalog.TryGetValue("TextInsertion.ClipboardInstallHint", out var clipboardHint), + $"Missing {language} key: TextInsertion.ClipboardInstallHint" + ); + Assert.Contains("{0}", clipboardHint, StringComparison.Ordinal); + } + [Fact] public void CanonicalCatalog_HasNativeDictationDisclosuresWithoutObsoleteEvdevClaims() { diff --git a/tests/TypeWhisper.Linux.Tests/RecentTranscriptionsServiceTests.cs b/tests/TypeWhisper.Linux.Tests/RecentTranscriptionsServiceTests.cs index c0b7f1a96..40315dea6 100644 --- a/tests/TypeWhisper.Linux.Tests/RecentTranscriptionsServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/RecentTranscriptionsServiceTests.cs @@ -3,6 +3,7 @@ using TypeWhisper.Core.Models; using TypeWhisper.Core.Services; using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Localization; using Xunit; namespace TypeWhisper.Linux.Tests; @@ -40,7 +41,10 @@ public async Task FocusStaysOnDifferentWindow_UsesClipboardOnlyWithoutDirectInse Assert.Equal(10, fixture.Delays.Count); Assert.DoesNotContain(fixture.InsertionRequests, candidate => candidate.AutoPaste); Assert.Equal( - ("Copied recent transcription to clipboard.", false), + ( + Loc.Instance["RecentTranscriptions.CopiedToClipboard"], + false + ), Assert.Single(fixture.Feedback) ); } @@ -129,6 +133,111 @@ public async Task WaylandCompositorFocusVerified_InsertsWithoutStaleXdotoolId() Assert.Empty(fixture.Delays); } + [Fact] + public async Task CopyLastWithEmptyHistory_RaisesLocalizedEmptyHistoryFeedback() + { + var fixture = new Fixture(null); + + await fixture.CopyLastTranscriptionToClipboardAsync(); + + Assert.Equal( + (Loc.Instance["Overlay.NoRecentTranscriptions"], false), + Assert.Single(fixture.Feedback) + ); + Assert.Empty(fixture.InsertionRequests); + } + + [Theory] + [InlineData(InsertionResult.Typed, "RecentTranscriptions.Typed", false)] + [InlineData(InsertionResult.Pasted, "RecentTranscriptions.Pasted", false)] + [InlineData( + InsertionResult.CopiedToClipboard, + "RecentTranscriptions.CopiedToClipboard", + false + )] + [InlineData(InsertionResult.NoText, "Overlay.NoRecentTranscriptions", false)] + [InlineData(InsertionResult.Failed, "RecentTranscriptions.InsertionFailed", true)] + [InlineData(InsertionResult.ActionHandled, "Recorder.StatusDone", false)] + public async Task InsertionFeedback_UsesLocalizedCatalogMessage( + InsertionResult insertionResult, + string localizationKey, + bool isError + ) + { + var fixture = new Fixture(null) + { + InsertionResultOverride = insertionResult, + }; + + await fixture.CaptureAndInsertAsync(); + + Assert.Equal( + (Loc.Instance[localizationKey], isError), + Assert.Single(fixture.Feedback) + ); + } + + [Fact] + public async Task MissingClipboardToolFeedback_UsesLocalizedInstallHint() + { + var fixture = new Fixture(null) + { + InsertionResultOverride = InsertionResult.MissingClipboardTool, + }; + var clipboardTool = + Environment.GetEnvironmentVariable("WAYLAND_DISPLAY") is { Length: > 0 } + ? "wl-clipboard" + : "xclip"; + + await fixture.CaptureAndInsertAsync(); + + Assert.Equal( + ( + Loc.Instance.GetString( + "TextInsertion.ClipboardInstallHint", + clipboardTool + ), + true + ), + Assert.Single(fixture.Feedback) + ); + } + + [Theory] + [InlineData( + (int)RecentTranscriptionPasteToolHint.X11, + "RecentTranscriptions.PasteToolInstallHintX11" + )] + [InlineData( + (int)RecentTranscriptionPasteToolHint.Wayland, + "RecentTranscriptions.PasteToolInstallHintWayland" + )] + [InlineData( + (int)RecentTranscriptionPasteToolHint.WaylandYdotool, + "RecentTranscriptions.PasteToolInstallHintWaylandYdotool" + )] + public async Task MissingPasteToolFeedback_UsesLocalizedPlatformGuidance( + int pasteToolHint, + string localizationKey + ) + { + var fixture = new Fixture(null) + { + InsertionResultOverride = InsertionResult.MissingPasteTool, + PasteToolHint = (RecentTranscriptionPasteToolHint)pasteToolHint, + }; + + await fixture.CaptureAndInsertAsync(); + + Assert.Equal( + ( + Loc.Instance[localizationKey], + true + ), + Assert.Single(fixture.Feedback) + ); + } + public static TheoryData NullIdentityCases => new() { @@ -165,24 +274,35 @@ params ActiveWindowSnapshot?[] snapshots ) { _snapshots = new Queue(snapshots); + var history = new Mock(); + history.SetupGet(service => service.Records).Returns([]); _service = new RecentTranscriptionsService( - Mock.Of(), + history.Object, new RecentTranscriptionStore(), () => true, () => targetWindowId, _ => Task.FromResult(NextSnapshot()), InsertAsync, DelayAsync, + () => PasteToolHint, isWaylandSession: isWayland ); _service.FeedbackRequested += (message, isError) => Feedback.Add((message, isError)); } + public InsertionResult? InsertionResultOverride { get; init; } + public RecentTranscriptionPasteToolHint PasteToolHint { get; init; } = + RecentTranscriptionPasteToolHint.X11; public List InsertionRequests { get; } = []; public List Delays { get; } = []; public List<(string Message, bool IsError)> Feedback { get; } = []; + public Task CopyLastTranscriptionToClipboardAsync() + { + return _service.CopyLastTranscriptionToClipboardAsync(); + } + public async Task CaptureAndInsertAsync() { var target = await _service.CaptureInsertionTargetAsync(); @@ -213,9 +333,12 @@ private Task InsertAsync(TextInsertionRequest request) { InsertionRequests.Add(request); return Task.FromResult( - request.AutoPaste - ? InsertionResult.Pasted - : InsertionResult.CopiedToClipboard + InsertionResultOverride + ?? ( + request.AutoPaste + ? InsertionResult.Pasted + : InsertionResult.CopiedToClipboard + ) ); } From f6c6c4edd16cb4fa6f4b3dbe9652f1cfa4e1cded Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 18:47:22 +0000 Subject: [PATCH 158/226] Relocalize option collections and the tray menu on language change General applies a new interface language immediately, but the option collections in Advanced (auto-unload, retention, spoken-feedback voices), Dictation (acceleration, spoken language, cleanup, insertion strategy, per-app rows), and Profiles (style preset, hotkey behavior, cleanup overrides, model, prompt actions) resolved their localized labels once at construction, and the native tray menu captured its three labels at initialization - so the app ran mixed-language until restart. Each ViewModel now rebuilds its stored option collections on Loc.LanguageChanged, re-selecting the previous entries by identity (seconds, enum values, ids) so a language switch never changes the user's effective selection, and the rebuilds run under the existing programmatic-refresh/save-suppression guards so nothing persists. TrayIconService relabels its existing native menu items in place, ignores language changes before initialization, and unsubscribes on dispose. PA41 tracks the distinct pre-existing gap of computed localized properties (hints/summaries/statuses) not re-raising on language change. --- .../Services/TrayIconService.cs | 58 ++++- .../Sections/AdvancedSectionViewModel.cs | 84 ++++++-- .../Sections/DictationSectionViewModel.cs | 201 +++++++++++++++--- .../Sections/ProfilesSectionViewModel.cs | 126 ++++++++--- .../AdvancedSectionViewModelTests.cs | 82 +++++++ ...tationSectionViewModelLocalizationTests.cs | 130 +++++++++++ .../ProfilesSectionViewModelTests.cs | 83 ++++++++ .../TrayIconServiceTests.cs | 76 ++++++- 8 files changed, 743 insertions(+), 97 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/DictationSectionViewModelLocalizationTests.cs diff --git a/src/TypeWhisper.Linux/Services/TrayIconService.cs b/src/TypeWhisper.Linux/Services/TrayIconService.cs index a04e53725..7cf441aa2 100644 --- a/src/TypeWhisper.Linux/Services/TrayIconService.cs +++ b/src/TypeWhisper.Linux/Services/TrayIconService.cs @@ -15,13 +15,17 @@ namespace TypeWhisper.Linux.Services; public sealed class TrayIconService : IDisposable { private readonly IProcessRunner _runner; + private NativeMenuItem? _dictationMenuItem; private bool _disposed; + private NativeMenuItem? _exitMenuItem; + private NativeMenuItem? _settingsMenuItem; private TrayIcon? _trayIcon; private TrayIcons? _trayIcons; public TrayIconService(IProcessRunner runner) { _runner = runner; + Loc.Instance.LanguageChanged += OnLanguageChanged; } /// @@ -38,6 +42,7 @@ public void Dispose() } _disposed = true; + Loc.Instance.LanguageChanged -= OnLanguageChanged; if (Application.Current is { } app) { TrayIcon.SetIcons(app, null); @@ -45,6 +50,10 @@ public void Dispose() _trayIcons?.Clear(); _trayIcon?.Dispose(); + _dictationMenuItem = null; + _settingsMenuItem = null; + _exitMenuItem = null; + _trayIcon = null; } public void Initialize() @@ -124,6 +133,21 @@ internal bool ProbeTrayAvailable() public event EventHandler? ExitRequested; public event EventHandler? DictationToggleRequested; + internal bool IsMenuBuilt => + _dictationMenuItem is not null + && _settingsMenuItem is not null + && _exitMenuItem is not null; + + internal IReadOnlyList MenuLabels => + IsMenuBuilt + ? + [ + _dictationMenuItem!.Header ?? string.Empty, + _settingsMenuItem!.Header ?? string.Empty, + _exitMenuItem!.Header ?? string.Empty, + ] + : []; + private static WindowIcon? LoadIcon() { // 32x32 PNG is preferred; most SNI hosts downscale cleanly from there. @@ -160,21 +184,35 @@ private NativeMenu BuildMenu() { var menu = new NativeMenu(); - var dictate = new NativeMenuItem(Loc.Instance["Tray.ToggleDictation"]); - dictate.Click += (_, _) => DictationToggleRequested?.Invoke(this, EventArgs.Empty); + _dictationMenuItem = new NativeMenuItem(Loc.Instance["Tray.ToggleDictation"]); + _dictationMenuItem.Click += (_, _) => + DictationToggleRequested?.Invoke(this, EventArgs.Empty); - var settings = new NativeMenuItem(Loc.Instance["Tray.Settings"]); - settings.Click += (_, _) => ShowSettingsRequested?.Invoke(this, EventArgs.Empty); + _settingsMenuItem = new NativeMenuItem(Loc.Instance["Tray.Settings"]); + _settingsMenuItem.Click += (_, _) => + ShowSettingsRequested?.Invoke(this, EventArgs.Empty); - var exit = new NativeMenuItem(Loc.Instance["Tray.Exit"]); - exit.Click += (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty); + _exitMenuItem = new NativeMenuItem(Loc.Instance["Tray.Exit"]); + _exitMenuItem.Click += (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty); - menu.Add(dictate); + menu.Add(_dictationMenuItem); menu.Add(new NativeMenuItemSeparator()); - menu.Add(settings); + menu.Add(_settingsMenuItem); menu.Add(new NativeMenuItemSeparator()); - menu.Add(exit); + menu.Add(_exitMenuItem); return menu; } -} \ No newline at end of file + + private void OnLanguageChanged(object? sender, EventArgs e) + { + if (!IsMenuBuilt) + { + return; + } + + _dictationMenuItem!.Header = Loc.Instance["Tray.ToggleDictation"]; + _settingsMenuItem!.Header = Loc.Instance["Tray.Settings"]; + _exitMenuItem!.Header = Loc.Instance["Tray.Exit"]; + } +} diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs index feac148cc..f1387718d 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs @@ -79,6 +79,7 @@ Action post Refresh(settings.Current); _settings.SettingsChanged += Refresh; _pluginManager.PluginStateChanged += (_, _) => PostPluginStateRefresh(); + Loc.Instance.LanguageChanged += OnInterfaceLanguageChanged; } private void PostPluginStateRefresh() @@ -159,24 +160,11 @@ value is null } } - public IReadOnlyList AutoUnloadOptions { get; } = - [ - new(0, Loc.Instance["Advanced.AutoUnloadNever"]), - new(30, Loc.Instance["Advanced.AutoUnload30Seconds"]), - new(60, Loc.Instance["Advanced.AutoUnload1Minute"]), - new(300, Loc.Instance["Advanced.AutoUnload5Minutes"]), - new(900, Loc.Instance["Advanced.AutoUnload15Minutes"]), - ]; - - public IReadOnlyList HistoryRetentionOptions { get; } = - [ - new(HistoryRetentionMode.Duration, 24 * 60, Loc.Instance["Advanced.Retention1Day"]), - new(HistoryRetentionMode.Duration, 7 * 24 * 60, Loc.Instance["Advanced.Retention7Days"]), - new(HistoryRetentionMode.Duration, 30 * 24 * 60, Loc.Instance["Advanced.Retention30Days"]), - new(HistoryRetentionMode.Duration, 90 * 24 * 60, Loc.Instance["Advanced.Retention90Days"]), - new(HistoryRetentionMode.Forever, null, Loc.Instance["Advanced.RetentionForever"]), - new(HistoryRetentionMode.UntilAppCloses, null, Loc.Instance["Advanced.RetentionUntilAppCloses"]), - ]; + public IReadOnlyList AutoUnloadOptions { get; private set; } = + CreateAutoUnloadOptions(); + + public IReadOnlyList HistoryRetentionOptions { get; private set; } = + CreateHistoryRetentionOptions(); public bool CanUseSpokenFeedback => _speechFeedback.IsAvailable; public bool ShowSpokenFeedbackUnavailableReason => !CanUseSpokenFeedback; @@ -259,7 +247,11 @@ partial void OnMemoryEnabledChanged(bool value) partial void OnSelectedAutoUnloadOptionChanged(AutoUnloadOption? value) { - if (value is null || _settings.Current.ModelAutoUnloadSeconds == value.Seconds) + if ( + _isProgrammaticRefresh + || value is null + || _settings.Current.ModelAutoUnloadSeconds == value.Seconds + ) { return; } @@ -364,7 +356,7 @@ partial void OnCaptureLlmProvenanceChanged(bool value) partial void OnSelectedHistoryRetentionChanged(HistoryRetentionOption? value) { - if (value is null) + if (_isProgrammaticRefresh || value is null) { return; } @@ -390,6 +382,58 @@ _settings.Current with ); } + private void OnInterfaceLanguageChanged(object? sender, EventArgs e) + { + var autoUnloadSeconds = + SelectedAutoUnloadOption?.Seconds ?? _settings.Current.ModelAutoUnloadSeconds; + var retentionMode = + SelectedHistoryRetention?.Mode ?? _settings.Current.HistoryRetentionMode; + var retentionMinutes = + SelectedHistoryRetention?.Minutes ?? _settings.Current.HistoryRetentionMinutes; + + RunProgrammaticRefresh(() => + { + AutoUnloadOptions = CreateAutoUnloadOptions(); + HistoryRetentionOptions = CreateHistoryRetentionOptions(); + OnPropertyChanged(nameof(AutoUnloadOptions)); + OnPropertyChanged(nameof(HistoryRetentionOptions)); + + SelectedAutoUnloadOption = + AutoUnloadOptions.FirstOrDefault(option => option.Seconds == autoUnloadSeconds) + ?? AutoUnloadOptions[0]; + SelectedHistoryRetention = MatchRetention(retentionMode, retentionMinutes); + + // The voices list carries a localized "System default voice" entry, so it + // must be rebuilt too or the dropdown stays in the previous language. + RefreshSpokenFeedbackVoices(); + }); + } + + private static IReadOnlyList CreateAutoUnloadOptions() + { + return + [ + new(0, Loc.Instance["Advanced.AutoUnloadNever"]), + new(30, Loc.Instance["Advanced.AutoUnload30Seconds"]), + new(60, Loc.Instance["Advanced.AutoUnload1Minute"]), + new(300, Loc.Instance["Advanced.AutoUnload5Minutes"]), + new(900, Loc.Instance["Advanced.AutoUnload15Minutes"]), + ]; + } + + private static IReadOnlyList CreateHistoryRetentionOptions() + { + return + [ + new(HistoryRetentionMode.Duration, 24 * 60, Loc.Instance["Advanced.Retention1Day"]), + new(HistoryRetentionMode.Duration, 7 * 24 * 60, Loc.Instance["Advanced.Retention7Days"]), + new(HistoryRetentionMode.Duration, 30 * 24 * 60, Loc.Instance["Advanced.Retention30Days"]), + new(HistoryRetentionMode.Duration, 90 * 24 * 60, Loc.Instance["Advanced.Retention90Days"]), + new(HistoryRetentionMode.Forever, null, Loc.Instance["Advanced.RetentionForever"]), + new(HistoryRetentionMode.UntilAppCloses, null, Loc.Instance["Advanced.RetentionUntilAppCloses"]), + ]; + } + // First try exact match; if the stored minutes value no longer matches any // option (e.g. a custom value from a future version), fall back to the // app default, then to the first option as a last resort. diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs index 99c1c1868..5af5c8bc1 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs @@ -21,6 +21,7 @@ public partial class DictationSectionViewModel : ObservableObject // ReSharper disable once InconsistentNaming -- "a11y" is the standard accessibility numeronym (a + 11 letters + y) mirroring the org.a11y.Bus service name; ReSharper's camelCase splitter mis-reads "11y" and wants the non-standard "a11YBus". private readonly IAccessibilityBusActivation _a11yBus; private readonly AudioRecordingService _audio; + private readonly Func> _getInputDevices; private readonly SystemCommandAvailabilityService _commands; private readonly DictationOrchestrator _dictation; private readonly ModelManagerService _models; @@ -151,6 +152,7 @@ public partial class DictationSectionViewModel : ObservableObject // Set while hydrating from saved settings so OnLocalModelAccelerationChanged doesn't // run its CUDA-availability revert guard against a not-yet-loaded engine. private bool _suppressAccelerationGuard; + private bool _isLocalizedOptionRefresh; [ObservableProperty] private string _modelStatusText = Loc.Instance["Dictation.StatusNotReady"]; @@ -210,11 +212,36 @@ public DictationSectionViewModel( SystemCommandAvailabilityService commands, // ReSharper disable once InconsistentNaming -- "a11y" is the standard accessibility numeronym mirroring org.a11y.Bus; ReSharper's camelCase splitter mis-reads "11y". IAccessibilityBusActivation a11yBus + ) + : this( + dictation, + models, + audio, + settings, + pluginManager, + commands, + a11yBus, + AudioRecordingService.GetInputDevices + ) + { + } + + internal DictationSectionViewModel( + DictationOrchestrator dictation, + ModelManagerService models, + AudioRecordingService audio, + ISettingsService settings, + PluginManager pluginManager, + SystemCommandAvailabilityService commands, + // ReSharper disable once InconsistentNaming -- "a11y" is the standard accessibility numeronym mirroring org.a11y.Bus; ReSharper's camelCase splitter mis-reads "11y". + IAccessibilityBusActivation a11yBus, + Func> getInputDevices ) { _dictation = dictation; _models = models; _audio = audio; + _getInputDevices = getInputDevices; _settings = settings; _pluginManager = pluginManager; _commands = commands; @@ -268,52 +295,25 @@ IAccessibilityBusActivation a11yBus // Read the current accessibility-bridge flag so the enable/remove button reflects // reality on first paint. _ = RefreshAccessibilityBridgeStateAsync(); + Loc.Instance.LanguageChanged += OnInterfaceLanguageChanged; } public ObservableCollection ModelOptions { get; } = []; public ObservableCollection Devices { get; } = []; public ObservableCollection AccelerationOptions { get; } = - [ - new(AppSettings.LocalModelAccelerationAuto, Loc.Instance["Dictation.AccelerationAuto"]), - new(AppSettings.LocalModelAccelerationCpu, Loc.Instance["Dictation.AccelerationCpu"]), - new(AppSettings.LocalModelAccelerationNvidiaCuda, Loc.Instance["Dictation.AccelerationNvidiaCuda"]), - ]; + new(CreateAccelerationOptions()); public ObservableCollection LanguageChoices { get; } = - [ - new("auto", Loc.Instance["Dictation.LanguageAutoDetect"]), - new("de", "Deutsch"), - new("en", "English"), - new("fr", "Français"), - new("es", "Español"), - new("it", "Italiano"), - new("pt", "Português"), - new("nl", "Nederlands"), - new("pl", "Polski"), - new("cs", "Čeština"), - new("sv", "Svenska"), - new("da", "Dansk"), - new("fi", "Suomi"), - ]; + new(CreateLanguageChoices()); public ObservableCollection TranslationTargetOptions { get; } = []; public ObservableCollection CleanupLevelOptions { get; } = - [ - new(CleanupLevel.None, Loc.Instance["Dictation.CleanupNone"]), - new(CleanupLevel.Light, Loc.Instance["Dictation.CleanupLight"]), - new(CleanupLevel.Medium, Loc.Instance["Dictation.CleanupMedium"]), - new(CleanupLevel.High, Loc.Instance["Dictation.CleanupHigh"]), - ]; + new(CreateCleanupLevelOptions()); public ObservableCollection InsertionStrategyOptions { get; } = - [ - new(TextInsertionStrategy.Auto, Loc.Instance["Dictation.AccelerationAuto"]), - new(TextInsertionStrategy.ClipboardPaste, Loc.Instance["Dictation.StrategyClipboardPaste"]), - new(TextInsertionStrategy.DirectTyping, Loc.Instance["Dictation.StrategyDirectTyping"]), - new(TextInsertionStrategy.CopyOnly, Loc.Instance["Dictation.StrategyCopyOnly"]), - ]; + new(CreateInsertionStrategyOptions()); public ObservableCollection AppInsertionStrategies { get; } = []; @@ -635,7 +635,7 @@ private async Task Toggle() private void RefreshDevices() { Devices.Clear(); - foreach (var d in AudioRecordingService.GetInputDevices()) + foreach (var d in _getInputDevices()) { Devices.Add(d); } @@ -732,6 +732,108 @@ private void RefreshFromSettings(AppSettings settings) RefreshModelState(); } + private void OnInterfaceLanguageChanged(object? sender, EventArgs e) + { + var acceleration = LocalModelAcceleration; + var language = Language; + var cleanupLevel = CleanupLevel; + var newInsertionStrategy = NewInsertionStrategy; + var appInsertionStrategies = AppInsertionStrategies + .Select(row => (Row: row, row.Strategy)) + .ToList(); + + _isLocalizedOptionRefresh = true; + try + { + ReplaceCollection(AccelerationOptions, CreateAccelerationOptions()); + ReplaceCollection(LanguageChoices, CreateLanguageChoices()); + ReplaceCollection(CleanupLevelOptions, CreateCleanupLevelOptions()); + ReplaceCollection(InsertionStrategyOptions, CreateInsertionStrategyOptions()); + + LocalModelAcceleration = acceleration; + Language = language; + CleanupLevel = cleanupLevel; + NewInsertionStrategy = newInsertionStrategy; + foreach (var (row, strategy) in appInsertionStrategies) + { + row.RestoreStrategySelection(strategy); + } + + OnPropertyChanged(nameof(SelectedAccelerationOption)); + OnPropertyChanged(nameof(SelectedLanguageOption)); + OnPropertyChanged(nameof(SelectedCleanupLevelOption)); + OnPropertyChanged(nameof(SelectedNewInsertionStrategyOption)); + } + finally + { + _isLocalizedOptionRefresh = false; + } + } + + private static IReadOnlyList CreateAccelerationOptions() + { + return + [ + new(AppSettings.LocalModelAccelerationAuto, Loc.Instance["Dictation.AccelerationAuto"]), + new(AppSettings.LocalModelAccelerationCpu, Loc.Instance["Dictation.AccelerationCpu"]), + new(AppSettings.LocalModelAccelerationNvidiaCuda, Loc.Instance["Dictation.AccelerationNvidiaCuda"]), + ]; + } + + private static IReadOnlyList CreateLanguageChoices() + { + return + [ + new("auto", Loc.Instance["Dictation.LanguageAutoDetect"]), + new("de", "Deutsch"), + new("en", "English"), + new("fr", "Français"), + new("es", "Español"), + new("it", "Italiano"), + new("pt", "Português"), + new("nl", "Nederlands"), + new("pl", "Polski"), + new("cs", "Čeština"), + new("sv", "Svenska"), + new("da", "Dansk"), + new("fi", "Suomi"), + ]; + } + + private static IReadOnlyList CreateCleanupLevelOptions() + { + return + [ + new(CleanupLevel.None, Loc.Instance["Dictation.CleanupNone"]), + new(CleanupLevel.Light, Loc.Instance["Dictation.CleanupLight"]), + new(CleanupLevel.Medium, Loc.Instance["Dictation.CleanupMedium"]), + new(CleanupLevel.High, Loc.Instance["Dictation.CleanupHigh"]), + ]; + } + + private static IReadOnlyList CreateInsertionStrategyOptions() + { + return + [ + new(TextInsertionStrategy.Auto, Loc.Instance["Dictation.AccelerationAuto"]), + new(TextInsertionStrategy.ClipboardPaste, Loc.Instance["Dictation.StrategyClipboardPaste"]), + new(TextInsertionStrategy.DirectTyping, Loc.Instance["Dictation.StrategyDirectTyping"]), + new(TextInsertionStrategy.CopyOnly, Loc.Instance["Dictation.StrategyCopyOnly"]), + ]; + } + + private static void ReplaceCollection( + ObservableCollection target, + IEnumerable items + ) + { + target.Clear(); + foreach (var item in items) + { + target.Add(item); + } + } + private void RefreshAppInsertionStrategies( IReadOnlyDictionary? strategies ) @@ -843,6 +945,11 @@ partial void OnSelectedModelChanged(DictationModelOption? value) partial void OnLocalModelAccelerationChanged(string value) { + if (_isLocalizedOptionRefresh) + { + return; + } + // During settings hydration just reflect the saved value — no revert, no persist, // no reload (see RefreshFromSettings). The guard below is only for live user edits. if (_suppressAccelerationGuard) @@ -1332,6 +1439,11 @@ _settings.Current with partial void OnLanguageChanged(string value) { + if (_isLocalizedOptionRefresh) + { + return; + } + _settings.Save(_settings.Current with { Language = value }); OnPropertyChanged(nameof(SelectedLanguageOption)); } @@ -1344,6 +1456,11 @@ partial void OnTranslationTargetLanguageChanged(string? value) partial void OnCleanupLevelChanged(CleanupLevel value) { + if (_isLocalizedOptionRefresh) + { + return; + } + _settings.Save(_settings.Current with { CleanupLevel = value }); OnPropertyChanged(nameof(SelectedCleanupLevelOption)); } @@ -1548,6 +1665,11 @@ private void RemoveAppInsertionStrategy(AppInsertionStrategyRow? row) private void SaveAppInsertionStrategies() { + if (_isLocalizedOptionRefresh) + { + return; + } + var strategies = AppInsertionStrategies .Select(row => (ProcessName: NormalizeProcessName(row.ProcessName), row.Strategy)) .Where(row => !string.IsNullOrWhiteSpace(row.ProcessName)) @@ -1737,4 +1859,15 @@ public InsertionStrategyOption? SelectedStrategyOption Strategy = selected; } } -} \ No newline at end of file + + internal void RestoreStrategySelection(TextInsertionStrategy strategy) + { + if (_strategy != strategy) + { + _strategy = strategy; + OnPropertyChanged(nameof(Strategy)); + } + + OnPropertyChanged(nameof(SelectedStrategyOption)); + } +} diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs index b5fb6f2fe..9d940047e 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs @@ -190,6 +190,7 @@ UiOperationGuard uiOperations _windowTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _windowTimer.Tick += (_, _) => StartCurrentWindowUpdate(); + Loc.Instance.LanguageChanged += OnInterfaceLanguageChanged; } public ObservableCollection Profiles { get; } = []; @@ -201,31 +202,13 @@ UiOperationGuard uiOperations internal bool IsLiveContextActive => _liveContextActivationCount > 0; public ObservableCollection StylePresetOptions { get; } = - [ - new(ProfileStylePreset.Raw, Loc.Instance["Profiles.StylePresetRaw"]), - new(ProfileStylePreset.Clean, Loc.Instance["Profiles.StylePresetClean"]), - new(ProfileStylePreset.Concise, Loc.Instance["Profiles.StylePresetConcise"]), - new(ProfileStylePreset.FormalEmail, Loc.Instance["Profiles.StylePresetFormalEmail"]), - new(ProfileStylePreset.CasualMessage, Loc.Instance["Profiles.StylePresetCasualMessage"]), - new(ProfileStylePreset.Developer, Loc.Instance["Profiles.StylePresetDeveloper"]), - new(ProfileStylePreset.TerminalSafe, Loc.Instance["Profiles.StylePresetTerminalSafe"]), - new(ProfileStylePreset.MeetingNotes, Loc.Instance["Profiles.StylePresetMeetingNotes"]), - ]; + new(CreateStylePresetOptions()); public ObservableCollection HotkeyBehaviorOptions { get; } = - [ - new(ProfileHotkeyBehavior.StartDictation, Loc.Instance["Profiles.HotkeyBehaviorStartDictation"]), - new(ProfileHotkeyBehavior.ProcessSelectedText, Loc.Instance["Profiles.HotkeyBehaviorProcessSelectedText"]), - ]; + new(CreateHotkeyBehaviorOptions()); public ObservableCollection CleanupOverrideOptions { get; } = - [ - new(null, Loc.Instance["Profiles.CleanupUseStylePreset"]), - new(CleanupLevel.None, Loc.Instance["Profiles.CleanupNone"]), - new(CleanupLevel.Light, Loc.Instance["Profiles.CleanupLight"]), - new(CleanupLevel.Medium, Loc.Instance["Profiles.CleanupMedium"]), - new(CleanupLevel.High, Loc.Instance["Profiles.CleanupHigh"]), - ]; + new(CreateCleanupOverrideOptions()); public ObservableCollection ProcessNameChips { get; } = []; public ObservableCollection UrlPatternChips { get; } = []; @@ -326,12 +309,8 @@ SelectedProfile is null public string EditIsEnabledStatusText => EditIsEnabled ? Loc.Instance["Common.On"] : Loc.Instance["Common.Off"]; - public IReadOnlyList WhisperModeOptions { get; } = - [ - new(null, Loc.Instance["Profiles.UseGlobalDefault"]), - new(true, Loc.Instance["Common.Enabled"]), - new(false, Loc.Instance["Common.Disabled"]), - ]; + public IReadOnlyList WhisperModeOptions { get; private set; } = + CreateNullableBooleanOptions(); public TranslationTargetOption? SelectedTranslationTargetOption { @@ -993,6 +972,99 @@ selectedId is null } } + private void OnInterfaceLanguageChanged(object? sender, EventArgs e) + { + var modelId = EditModelId; + var promptActionId = EditPromptActionId; + var stylePreset = EditStylePreset; + var hotkeyBehavior = EditHotkeyBehavior; + var cleanupLevelOverride = EditCleanupLevelOverride; + var whisperModeOverride = EditWhisperModeOverride; + var developerFormattingOverride = EditDeveloperFormattingOverride; + + RefreshModelOptions(); + RefreshPromptActionOptions(); + ReplaceCollection(StylePresetOptions, CreateStylePresetOptions()); + ReplaceCollection(HotkeyBehaviorOptions, CreateHotkeyBehaviorOptions()); + ReplaceCollection(CleanupOverrideOptions, CreateCleanupOverrideOptions()); + WhisperModeOptions = CreateNullableBooleanOptions(); + OnPropertyChanged(nameof(WhisperModeOptions)); + + EditModelId = modelId; + EditPromptActionId = promptActionId; + EditStylePreset = stylePreset; + EditHotkeyBehavior = hotkeyBehavior; + EditCleanupLevelOverride = cleanupLevelOverride; + EditWhisperModeOverride = whisperModeOverride; + EditDeveloperFormattingOverride = developerFormattingOverride; + + OnPropertyChanged(nameof(SelectedModelOption)); + OnPropertyChanged(nameof(SelectedPromptActionOption)); + OnPropertyChanged(nameof(SelectedStylePresetOption)); + OnPropertyChanged(nameof(SelectedHotkeyBehaviorOption)); + OnPropertyChanged(nameof(SelectedCleanupOverrideOption)); + OnPropertyChanged(nameof(SelectedWhisperModeOption)); + OnPropertyChanged(nameof(SelectedDeveloperFormattingOverrideOption)); + } + + private static IReadOnlyList CreateStylePresetOptions() + { + return + [ + new(ProfileStylePreset.Raw, Loc.Instance["Profiles.StylePresetRaw"]), + new(ProfileStylePreset.Clean, Loc.Instance["Profiles.StylePresetClean"]), + new(ProfileStylePreset.Concise, Loc.Instance["Profiles.StylePresetConcise"]), + new(ProfileStylePreset.FormalEmail, Loc.Instance["Profiles.StylePresetFormalEmail"]), + new(ProfileStylePreset.CasualMessage, Loc.Instance["Profiles.StylePresetCasualMessage"]), + new(ProfileStylePreset.Developer, Loc.Instance["Profiles.StylePresetDeveloper"]), + new(ProfileStylePreset.TerminalSafe, Loc.Instance["Profiles.StylePresetTerminalSafe"]), + new(ProfileStylePreset.MeetingNotes, Loc.Instance["Profiles.StylePresetMeetingNotes"]), + ]; + } + + private static IReadOnlyList CreateHotkeyBehaviorOptions() + { + return + [ + new(ProfileHotkeyBehavior.StartDictation, Loc.Instance["Profiles.HotkeyBehaviorStartDictation"]), + new(ProfileHotkeyBehavior.ProcessSelectedText, Loc.Instance["Profiles.HotkeyBehaviorProcessSelectedText"]), + ]; + } + + private static IReadOnlyList CreateCleanupOverrideOptions() + { + return + [ + new(null, Loc.Instance["Profiles.CleanupUseStylePreset"]), + new(CleanupLevel.None, Loc.Instance["Profiles.CleanupNone"]), + new(CleanupLevel.Light, Loc.Instance["Profiles.CleanupLight"]), + new(CleanupLevel.Medium, Loc.Instance["Profiles.CleanupMedium"]), + new(CleanupLevel.High, Loc.Instance["Profiles.CleanupHigh"]), + ]; + } + + private static IReadOnlyList CreateNullableBooleanOptions() + { + return + [ + new(null, Loc.Instance["Profiles.UseGlobalDefault"]), + new(true, Loc.Instance["Common.Enabled"]), + new(false, Loc.Instance["Common.Disabled"]), + ]; + } + + private static void ReplaceCollection( + ObservableCollection target, + IEnumerable items + ) + { + target.Clear(); + foreach (var item in items) + { + target.Add(item); + } + } + private void RefreshModelOptions() { var selected = EditModelId; diff --git a/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs index e02ad1299..d7b7ffbb5 100644 --- a/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs @@ -2,6 +2,7 @@ using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Localization; using TypeWhisper.Linux.Services.Plugins; using TypeWhisper.Linux.ViewModels.Sections; using TypeWhisper.PluginSDK; @@ -380,6 +381,87 @@ public async Task ProgrammaticVoiceFallback_DoesNotSelectOrSave_UserVoiceEditDoe ); } + [Fact] + public async Task LanguageChange_RebuildsLocalizedOptions_PreservesSelectionWithoutSaving() + { + var originalLanguage = Loc.Instance.CurrentLanguage; + try + { + Loc.Instance.CurrentLanguage = "en"; + using var harness = await TestHarness.CreateAsync(); + harness.ViewModel.SelectedAutoUnloadOption = Assert.Single( + harness.ViewModel.AutoUnloadOptions, + option => option.Seconds == 300 + ); + harness.ViewModel.SelectedHistoryRetention = Assert.Single( + harness.ViewModel.HistoryRetentionOptions, + option => option.Mode == HistoryRetentionMode.UntilAppCloses + ); + harness.Settings.Invocations.Clear(); + + var autoUnloadBefore = harness.ViewModel.SelectedAutoUnloadOption!; + var retentionBefore = harness.ViewModel.SelectedHistoryRetention!; + var defaultVoiceBefore = Assert.Single( + harness.ViewModel.SpokenFeedbackVoices, + voice => voice.Id == SpeechFeedbackService.DefaultVoiceOptionId + ); + var selectedVoiceIdBefore = harness.ViewModel.SelectedSpokenFeedbackVoiceId; + harness.ViewModel.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(AdvancedSectionViewModel.AutoUnloadOptions)) + { + // ReSharper disable once AccessToDisposedClosure -- handler runs synchronously while setting Loc.Instance.CurrentLanguage below, before the using disposes harness at scope end. + harness.ViewModel.SelectedAutoUnloadOption = null; + } + + if (args.PropertyName == nameof(AdvancedSectionViewModel.HistoryRetentionOptions)) + { + // ReSharper disable once AccessToDisposedClosure -- handler runs synchronously while setting Loc.Instance.CurrentLanguage below, before the using disposes harness at scope end. + harness.ViewModel.SelectedHistoryRetention = null; + } + }; + + Loc.Instance.CurrentLanguage = "de"; + + Assert.NotEqual( + autoUnloadBefore.DisplayName, + harness.ViewModel.SelectedAutoUnloadOption?.DisplayName + ); + Assert.NotSame(autoUnloadBefore, harness.ViewModel.SelectedAutoUnloadOption); + Assert.Equal(300, harness.ViewModel.SelectedAutoUnloadOption?.Seconds); + Assert.NotEqual( + retentionBefore.DisplayName, + harness.ViewModel.SelectedHistoryRetention?.DisplayName + ); + Assert.NotSame(retentionBefore, harness.ViewModel.SelectedHistoryRetention); + Assert.Equal( + HistoryRetentionMode.UntilAppCloses, + harness.ViewModel.SelectedHistoryRetention?.Mode + ); + Assert.Null(harness.ViewModel.SelectedHistoryRetention?.Minutes); + + var defaultVoiceAfter = Assert.Single( + harness.ViewModel.SpokenFeedbackVoices, + voice => voice.Id == SpeechFeedbackService.DefaultVoiceOptionId + ); + Assert.NotEqual(defaultVoiceBefore.DisplayName, defaultVoiceAfter.DisplayName); + Assert.NotSame(defaultVoiceBefore, defaultVoiceAfter); + Assert.Equal( + selectedVoiceIdBefore, + harness.ViewModel.SelectedSpokenFeedbackVoiceId + ); + + harness.Settings.Verify( + service => service.Save(It.IsAny()), + Times.Never + ); + } + finally + { + Loc.Instance.CurrentLanguage = originalLanguage; + } + } + private static TtsProviderOption GetPluginProvider(AdvancedSectionViewModel viewModel) { return Assert.Single( diff --git a/tests/TypeWhisper.Linux.Tests/DictationSectionViewModelLocalizationTests.cs b/tests/TypeWhisper.Linux.Tests/DictationSectionViewModelLocalizationTests.cs new file mode 100644 index 000000000..6d2dd2037 --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/DictationSectionViewModelLocalizationTests.cs @@ -0,0 +1,130 @@ +using Moq; +using System.Runtime.CompilerServices; +using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.ActiveWindow; +using TypeWhisper.Linux.Services.Localization; +using TypeWhisper.Linux.ViewModels.Sections; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class DictationSectionViewModelLocalizationTests +{ + [Fact] + public void LanguageChange_RebuildsLocalizedOptions_PreservesSelectionsWithoutSaving() + { + var originalLanguage = Loc.Instance.CurrentLanguage; + try + { + Loc.Instance.CurrentLanguage = "en"; + var settings = TestPluginManagerFactory.CreateSettings( + new AppSettings + { + Language = "fr", + CleanupLevel = CleanupLevel.High, + LocalModelAcceleration = AppSettings.LocalModelAccelerationCpu, + AppInsertionStrategies = new Dictionary + { + ["firefox"] = TextInsertionStrategy.DirectTyping, + }, + } + ); + using var pluginManager = TestPluginManagerFactory.Create(); + var commands = new SystemCommandAvailabilityService(); + using var models = new ModelManagerService( + pluginManager, + settings.Object, + commands + ); + using var audio = new AudioRecordingService( + _ => { }, + () => 0, + () => { } + ); + // ReSharper disable once InconsistentNaming -- "a11y" is the standard numeronym for accessibility. + var a11yBus = new Mock(); + a11yBus + .Setup(bus => bus.IsActivatedAsync(It.IsAny())) + .ReturnsAsync(false); + var dictation = (DictationOrchestrator)RuntimeHelpers.GetUninitializedObject( + typeof(DictationOrchestrator) + ); + var sut = new DictationSectionViewModel( + dictation, + models, + audio, + settings.Object, + pluginManager, + commands, + a11yBus.Object, + () => [] + ) + { + NewInsertionStrategy = TextInsertionStrategy.CopyOnly, + }; + settings.Invocations.Clear(); + + var accelerationBefore = sut.SelectedAccelerationOption!; + var languageBefore = Assert.Single( + sut.LanguageChoices, + option => option.Code == "auto" + ); + var cleanupBefore = sut.SelectedCleanupLevelOption!; + var insertionBefore = sut.SelectedNewInsertionStrategyOption!; + var appStrategyRow = Assert.Single(sut.AppInsertionStrategies); + var appInsertionBefore = appStrategyRow.SelectedStrategyOption!; + + sut.AccelerationOptions.CollectionChanged += (_, _) => + sut.SelectedAccelerationOption = null; + sut.LanguageChoices.CollectionChanged += (_, _) => + sut.SelectedLanguageOption = null; + sut.CleanupLevelOptions.CollectionChanged += (_, _) => + sut.SelectedCleanupLevelOption = null; + sut.InsertionStrategyOptions.CollectionChanged += (_, _) => + { + sut.SelectedNewInsertionStrategyOption = null; + appStrategyRow.SelectedStrategyOption = null; + }; + + Loc.Instance.CurrentLanguage = "de"; + + Assert.NotSame(accelerationBefore, sut.SelectedAccelerationOption); + Assert.Equal( + AppSettings.LocalModelAccelerationCpu, + sut.SelectedAccelerationOption?.Value + ); + var autoLanguageAfter = Assert.Single( + sut.LanguageChoices, + option => option.Code == "auto" + ); + Assert.NotEqual(languageBefore.DisplayName, autoLanguageAfter.DisplayName); + Assert.Equal("fr", sut.SelectedLanguageOption?.Code); + Assert.NotEqual(cleanupBefore.DisplayName, sut.SelectedCleanupLevelOption?.DisplayName); + Assert.NotSame(cleanupBefore, sut.SelectedCleanupLevelOption); + Assert.Equal(CleanupLevel.High, sut.SelectedCleanupLevelOption?.Value); + Assert.NotEqual( + insertionBefore.DisplayName, + sut.SelectedNewInsertionStrategyOption?.DisplayName + ); + Assert.NotSame(insertionBefore, sut.SelectedNewInsertionStrategyOption); + Assert.Equal( + TextInsertionStrategy.CopyOnly, + sut.SelectedNewInsertionStrategyOption?.Value + ); + Assert.NotEqual( + appInsertionBefore.DisplayName, + appStrategyRow.SelectedStrategyOption?.DisplayName + ); + Assert.Equal(TextInsertionStrategy.DirectTyping, appStrategyRow.Strategy); + settings.Verify( + service => service.Save(It.IsAny()), + Times.Never + ); + } + finally + { + Loc.Instance.CurrentLanguage = originalLanguage; + } + } +} diff --git a/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs index 7142552ba..4b9353f3e 100644 --- a/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs @@ -3,6 +3,7 @@ using TypeWhisper.Core.Models; using TypeWhisper.Core.Services; using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Localization; using TypeWhisper.Linux.Services.Plugins; using TypeWhisper.Linux.ViewModels.Sections; using TypeWhisper.Tests; @@ -83,6 +84,88 @@ public void Constructor_DoesNotInspectActiveWindow() activeWindow.VerifyNoOtherCalls(); } + [Fact] + public void LanguageChange_RebuildsLocalizedOptions_PreservesSelectionsWithoutPersisting() + { + var originalLanguage = Loc.Instance.CurrentLanguage; + try + { + Loc.Instance.CurrentLanguage = "en"; + var profiles = new Mock(); + profiles.SetupGet(service => service.Profiles).Returns([]); + var activeWindow = CreateActiveWindowService(); + using var pluginManager = CreatePluginManager(); + var promptActions = new PromptActionService( + Path.Join(_tempDir, "localized-prompt-actions.json") + ); + var sut = new ProfilesSectionViewModel( + profiles.Object, + activeWindow.Object, + pluginManager, + promptActions, + _hotkeys, + Mock.Of(), + new GnomeWindowCallsSetupHelper(), + new BrowserAccessibilitySetupHelper(), + _uiOperations + ) + { + EditStylePreset = ProfileStylePreset.Developer, + EditHotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText, + EditCleanupLevelOverride = CleanupLevel.High, + EditWhisperModeOverride = true, + EditDeveloperFormattingOverride = false, + }; + + var styleBefore = sut.SelectedStylePresetOption!; + var hotkeyBefore = sut.SelectedHotkeyBehaviorOption!; + var cleanupBefore = sut.SelectedCleanupOverrideOption!; + var whisperBefore = sut.SelectedWhisperModeOption!; + var modelDefaultBefore = sut.ModelOptions[0]; + var promptDefaultBefore = sut.PromptActionOptions[0]; + + sut.StylePresetOptions.CollectionChanged += (_, _) => + sut.SelectedStylePresetOption = null; + sut.HotkeyBehaviorOptions.CollectionChanged += (_, _) => + sut.SelectedHotkeyBehaviorOption = null; + sut.CleanupOverrideOptions.CollectionChanged += (_, _) => + sut.SelectedCleanupOverrideOption = null; + sut.PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(ProfilesSectionViewModel.WhisperModeOptions)) + { + sut.SelectedWhisperModeOption = null; + sut.SelectedDeveloperFormattingOverrideOption = null; + } + }; + + Loc.Instance.CurrentLanguage = "de"; + + Assert.NotEqual(styleBefore.Label, sut.SelectedStylePresetOption?.Label); + Assert.NotSame(styleBefore, sut.SelectedStylePresetOption); + Assert.Equal(ProfileStylePreset.Developer, sut.EditStylePreset); + Assert.NotEqual(hotkeyBefore.Label, sut.SelectedHotkeyBehaviorOption?.Label); + Assert.NotSame(hotkeyBefore, sut.SelectedHotkeyBehaviorOption); + Assert.Equal(ProfileHotkeyBehavior.ProcessSelectedText, sut.EditHotkeyBehavior); + Assert.NotEqual(cleanupBefore.Label, sut.SelectedCleanupOverrideOption?.Label); + Assert.NotSame(cleanupBefore, sut.SelectedCleanupOverrideOption); + Assert.Equal(CleanupLevel.High, sut.EditCleanupLevelOverride); + Assert.NotEqual(whisperBefore.Label, sut.SelectedWhisperModeOption?.Label); + Assert.True(sut.EditWhisperModeOverride); + Assert.False(sut.EditDeveloperFormattingOverride); + Assert.NotEqual(modelDefaultBefore.Label, sut.ModelOptions[0].Label); + Assert.NotEqual(promptDefaultBefore.Label, sut.PromptActionOptions[0].Label); + profiles.Verify( + service => service.UpdateProfile(It.IsAny()), + Times.Never + ); + } + finally + { + Loc.Instance.CurrentLanguage = originalLanguage; + } + } + [Fact] public void ToggleProfileEnabled_UsesAtomicServiceOperationAndRefreshesProfiles() { diff --git a/tests/TypeWhisper.Linux.Tests/TrayIconServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TrayIconServiceTests.cs index 6c21876f4..5088afda6 100644 --- a/tests/TypeWhisper.Linux.Tests/TrayIconServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TrayIconServiceTests.cs @@ -1,4 +1,5 @@ using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Localization; using Xunit; namespace TypeWhisper.Linux.Tests; @@ -8,18 +9,79 @@ namespace TypeWhisper.Linux.Tests; /// that decides whether close-to-tray is safe (backlog #18). The probe reads /// the StatusNotifierWatcher's IsStatusNotifierHostRegistered property /// (true only when a watcher exists *and* a host registered with it). Probe -/// logic is testable through the seam; the -/// Avalonia TrayIcon wiring in Initialize() is verified manually. +/// logic is testable through the seam. /// public sealed class TrayIconServiceTests { + [Fact] + public void Language_change_updates_existing_native_menu_labels() + { + var originalLanguage = Loc.Instance.CurrentLanguage; + try + { + Loc.Instance.CurrentLanguage = "en"; + var runner = new FakeProcessRunner(); + runner.RespondWith((file, _) => file == "gdbus", "(,)\n"); + using var sut = new TrayIconService(runner); + sut.Initialize(); + var englishLabels = sut.MenuLabels.ToArray(); + + Loc.Instance.CurrentLanguage = "de"; + + Assert.True(sut.IsMenuBuilt); + Assert.Equal( + [ + Loc.Instance["Tray.ToggleDictation"], + Loc.Instance["Tray.Settings"], + Loc.Instance["Tray.Exit"], + ], + sut.MenuLabels + ); + Assert.NotEqual(englishLabels, sut.MenuLabels); + } + finally + { + Loc.Instance.CurrentLanguage = originalLanguage; + } + } + + [Fact] + public void Language_change_before_initialization_is_safe_and_does_not_build_menu() + { + var originalLanguage = Loc.Instance.CurrentLanguage; + try + { + Loc.Instance.CurrentLanguage = "en"; + var runner = new FakeProcessRunner(); + runner.RespondWith((file, _) => file == "gdbus", "(,)\n"); + using var sut = new TrayIconService(runner); + + var exception = Record.Exception(() => Loc.Instance.CurrentLanguage = "de"); + + Assert.Null(exception); + Assert.False(sut.IsMenuBuilt); + Assert.Empty(sut.MenuLabels); + + sut.Initialize(); + + Assert.True(sut.IsMenuBuilt); + Assert.Equal(3, sut.MenuLabels.Count); + Assert.Equal(Loc.Instance["Tray.ToggleDictation"], sut.MenuLabels[0]); + } + finally + { + Loc.Instance.CurrentLanguage = originalLanguage; + } + } + [Fact] public void Tray_is_available_when_a_host_is_registered() { var runner = new FakeProcessRunner(); runner.RespondWith((file, _) => file == "gdbus", "(,)\n"); + using var sut = new TrayIconService(runner); - Assert.True(new TrayIconService(runner).ProbeTrayAvailable()); + Assert.True(sut.ProbeTrayAvailable()); } [Fact] @@ -29,8 +91,9 @@ public void Tray_is_unavailable_when_a_watcher_exists_but_no_host_registered() // icons. Name-ownership alone would mis-report this as available. var runner = new FakeProcessRunner(); runner.RespondWith((file, _) => file == "gdbus", "(,)\n"); + using var sut = new TrayIconService(runner); - Assert.False(new TrayIconService(runner).ProbeTrayAvailable()); + Assert.False(sut.ProbeTrayAvailable()); } [Fact] @@ -40,7 +103,8 @@ public void Tray_is_unavailable_when_the_probe_cannot_run() // missing, or the session bus unreachable — fail safe to "no tray" // so close-to-tray falls back to quitting rather than stranding. var runner = new FakeProcessRunner { Default = FakeProcessRunner.NotStarted() }; + using var sut = new TrayIconService(runner); - Assert.False(new TrayIconService(runner).ProbeTrayAvailable()); + Assert.False(sut.ProbeTrayAvailable()); } -} \ No newline at end of file +} From 0c06e75e67cc2a304fa8a543a0bfd268cb292cd2 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 19:11:05 +0000 Subject: [PATCH 159/226] Drive overlay Timer and Clock widgets from their own ticks The overlay's side-slot Timer text updated only because audio-level callbacks happened to notify LeftText/RightText - the recording timer itself notified only RecordingTimerText - and the Clock widget read DateTime.Now with no tick at all. When level callbacks were missing or suspended, both widgets froze; their apparent operation depended on the excessive zero-level callbacks the audit's Section 5 identified. The recording tick now notifies exactly the side slots configured as Timer, audio-level changes no longer notify unrelated side text, and a dedicated one-second clock timer runs only while the overlay is visible and a slot displays Clock, starting and stopping on settings and visibility changes. An internal synchronous-dispatch seam and manual tick methods make the notification wiring testable headlessly. --- .../ViewModels/DictationOverlayViewModel.cs | 116 +++++++++--- .../DictationOverlayViewModelTests.cs | 166 ++++++++++++++++++ 2 files changed, 253 insertions(+), 29 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/DictationOverlayViewModelTests.cs diff --git a/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs b/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs index 5ba2a38e6..5b11fba8d 100644 --- a/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs @@ -17,8 +17,9 @@ public partial class DictationOverlayViewModel : ObservableObject // ~10 Hz cadence of LevelChanged. private const int WaveformSampleCount = 5; - private readonly AudioRecordingService _audio; + private readonly DispatcherTimer _clockTimer; private readonly DispatcherTimer _feedbackTimer; + private readonly Action _postToUiThread; private readonly DispatcherTimer _recordingTimer; private readonly ISettingsService _settings; private readonly float[] _waveformLevels = new float[WaveformSampleCount]; @@ -68,37 +69,18 @@ public DictationOverlayViewModel( ISettingsService settings, IDetectionFailureTracker failureTracker ) + : this(settings, static action => Dispatcher.UIThread.Post(action)) { - _audio = audio; - _settings = settings; - - _recordingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) }; - _recordingTimer.Tick += (_, _) => RefreshRecordingSeconds(); - - // Interval is set per arm so live setting changes take effect on the - // next event. RestartFeedbackTimer re-arms even when ShowFeedback is - // already true — plain re-assignment is skipped by value equality. - _feedbackTimer = new DispatcherTimer(); - _feedbackTimer.Tick += (_, _) => - { - _feedbackTimer.Stop(); - ShowFeedback = false; - FeedbackText = null; - OnPropertyChanged(nameof(HasVisibleContent)); - }; - dictation.OverlayStateChanged += (_, state) => - Dispatcher.UIThread.Post(() => ApplyState(state)); + _postToUiThread(() => ApplyState(state)); transformSelection.OverlayStateChanged += (_, state) => - Dispatcher.UIThread.Post(() => ApplyState(state)); + _postToUiThread(() => ApplyState(state)); // Raw RMS is typically well below 0.1 for speech, so amplify ×8 to drive a // visible meter — same scaling the recorder and wizard VMs apply. - _audio.LevelChanged += (_, level) => - Dispatcher.UIThread.Post(() => AudioLevel = Math.Clamp(level * 8, 0f, 1f)); - - _settings.SettingsChanged += _ => Dispatcher.UIThread.Post(RefreshOverlaySlots); + audio.LevelChanged += (_, level) => + _postToUiThread(() => AudioLevel = Math.Clamp(level * 8, 0f, 1f)); failureTracker.OnFailure += (_, e) => { @@ -107,7 +89,7 @@ IDetectionFailureTracker failureTracker return; } - Dispatcher.UIThread.Post(() => + _postToUiThread(() => { FeedbackText = e.Reason; FeedbackIsError = true; @@ -117,6 +99,40 @@ IDetectionFailureTracker failureTracker }; } + // Test seam: production posts service events to Avalonia's UI thread; tests run the same + // settings-change path synchronously and drive the timer tick methods below directly. + internal DictationOverlayViewModel( + ISettingsService settings, + Action postToUiThread + ) + { + _settings = settings; + _postToUiThread = postToUiThread; + + _recordingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) }; + _recordingTimer.Tick += (_, _) => RecordingTimerTick(); + + // LeftText/RightText render DateTime.Now with minute resolution. Polling once per second + // keeps a minute rollover's visible delay below one second without re-arming for wall-clock + // alignment; this timer is stopped whenever no clock slot is actually visible. + _clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + _clockTimer.Tick += (_, _) => ClockTimerTick(); + + // Interval is set per arm so live setting changes take effect on the + // next event. RestartFeedbackTimer re-arms even when ShowFeedback is + // already true — plain re-assignment is skipped by value equality. + _feedbackTimer = new DispatcherTimer(); + _feedbackTimer.Tick += (_, _) => + { + _feedbackTimer.Stop(); + ShowFeedback = false; + FeedbackText = null; + OnPropertyChanged(nameof(HasVisibleContent)); + }; + + _settings.SettingsChanged += _ => _postToUiThread(RefreshOverlaySlots); + } + public bool HasVisibleContent => IsOverlayVisible || ShowFeedback; public string RecordingTimerText @@ -177,6 +193,7 @@ private static double PerceptualLevel(float level) partial void OnIsOverlayVisibleChanged(bool value) { OnPropertyChanged(nameof(HasVisibleContent)); + UpdateClockTimer(); } partial void OnShowFeedbackChanged(bool value) @@ -234,8 +251,6 @@ partial void OnAudioLevelChanged(float value) OnPropertyChanged(nameof(WaveformBar2Height)); OnPropertyChanged(nameof(WaveformBar3Height)); OnPropertyChanged(nameof(WaveformBar4Height)); - OnPropertyChanged(nameof(LeftText)); - OnPropertyChanged(nameof(RightText)); } partial void OnFeedbackIsErrorChanged(bool value) @@ -289,6 +304,19 @@ private void RefreshRecordingSeconds() RecordingSeconds = Math.Max(0, (DateTime.UtcNow - startedAt).TotalSeconds); } + internal void RecordingTimerTick() + { + RefreshRecordingSeconds(); + NotifyTextSlots(OverlayWidget.Timer); + } + + internal void ClockTimerTick() + { + NotifyTextSlots(OverlayWidget.Clock); + } + + internal bool IsClockTimerRunning => _clockTimer.IsEnabled; + private void RefreshOverlaySlots() { OnPropertyChanged(nameof(ShowLeftIndicator)); @@ -299,6 +327,36 @@ private void RefreshOverlaySlots() OnPropertyChanged(nameof(ShowRightWaveform)); OnPropertyChanged(nameof(ShowRightText)); OnPropertyChanged(nameof(RightText)); + UpdateClockTimer(); + } + + private void NotifyTextSlots(OverlayWidget widget) + { + if (_settings.Current.OverlayLeftWidget == widget) + { + OnPropertyChanged(nameof(LeftText)); + } + + if (_settings.Current.OverlayRightWidget == widget) + { + OnPropertyChanged(nameof(RightText)); + } + } + + private void UpdateClockTimer() + { + var shouldRun = IsOverlayVisible + && (_settings.Current.OverlayLeftWidget == OverlayWidget.Clock + || _settings.Current.OverlayRightWidget == OverlayWidget.Clock); + + if (shouldRun) + { + _clockTimer.Start(); + } + else + { + _clockTimer.Stop(); + } } private static bool IsTextWidget(OverlayWidget widget) @@ -330,4 +388,4 @@ private string ResolveText(OverlayWidget widget) _ => "", }; } -} \ No newline at end of file +} diff --git a/tests/TypeWhisper.Linux.Tests/DictationOverlayViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/DictationOverlayViewModelTests.cs new file mode 100644 index 000000000..0019451df --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/DictationOverlayViewModelTests.cs @@ -0,0 +1,166 @@ +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Linux.ViewModels; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class DictationOverlayViewModelTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void RecordingTimerTick_TimerInSideSlot_RaisesOnlyCorrespondingTextNotification( + bool timerOnLeft + ) + { + var settings = new FakeSettingsService( + AppSettings.Default with + { + OverlayLeftWidget = timerOnLeft + ? OverlayWidget.Timer + : OverlayWidget.Profile, + OverlayRightWidget = timerOnLeft + ? OverlayWidget.Profile + : OverlayWidget.Timer, + }); + var sut = CreateViewModel(settings); + var propertyNames = TrackPropertyChanges(sut); + + sut.RecordingTimerTick(); + + Assert.Equal( + [timerOnLeft + ? nameof(DictationOverlayViewModel.LeftText) + : nameof(DictationOverlayViewModel.RightText)], + SideTextNotifications(propertyNames) + ); + } + + [Fact] + public void RecordingTimerTick_NoTimerSlots_DoesNotRaiseSideTextNotifications() + { + var settings = new FakeSettingsService( + AppSettings.Default with + { + OverlayLeftWidget = OverlayWidget.Profile, + OverlayRightWidget = OverlayWidget.HotkeyMode, + }); + var sut = CreateViewModel(settings); + var propertyNames = TrackPropertyChanges(sut); + + sut.RecordingTimerTick(); + + Assert.Empty(SideTextNotifications(propertyNames)); + } + + [Fact] + public void ClockTimerTick_VisibleClockSlot_RaisesOnlyClockSlotTextNotification() + { + var settings = new FakeSettingsService( + AppSettings.Default with + { + OverlayLeftWidget = OverlayWidget.Profile, + OverlayRightWidget = OverlayWidget.Clock, + }); + var sut = CreateViewModel(settings); + sut.IsOverlayVisible = true; + var propertyNames = TrackPropertyChanges(sut); + + sut.ClockTimerTick(); + + Assert.Equal( + [nameof(DictationOverlayViewModel.RightText)], + SideTextNotifications(propertyNames) + ); + + sut.IsOverlayVisible = false; + } + + [Fact] + public void ClockTimer_TracksLiveSlotSettingAndOverlayVisibility() + { + var settings = new FakeSettingsService( + AppSettings.Default with + { + OverlayLeftWidget = OverlayWidget.Clock, + OverlayRightWidget = OverlayWidget.Profile, + }); + var sut = CreateViewModel(settings); + + Assert.False(sut.IsClockTimerRunning); + + sut.IsOverlayVisible = true; + + Assert.True(sut.IsClockTimerRunning); + + settings.Change( + settings.Current with + { + OverlayLeftWidget = OverlayWidget.Profile, + }); + + Assert.False(sut.IsClockTimerRunning); + + settings.Change( + settings.Current with + { + OverlayRightWidget = OverlayWidget.Clock, + }); + + Assert.True(sut.IsClockTimerRunning); + + sut.IsOverlayVisible = false; + + Assert.False(sut.IsClockTimerRunning); + } + + private static DictationOverlayViewModel CreateViewModel(FakeSettingsService settings) + { + return new DictationOverlayViewModel(settings, static action => action()); + } + + private static List TrackPropertyChanges(DictationOverlayViewModel sut) + { + var propertyNames = new List(); + sut.PropertyChanged += (_, args) => propertyNames.Add(args.PropertyName); + return propertyNames; + } + + private static IEnumerable SideTextNotifications(IEnumerable propertyNames) + { + return propertyNames.Where(name => + name is nameof(DictationOverlayViewModel.LeftText) + or nameof(DictationOverlayViewModel.RightText)); + } + + private sealed class FakeSettingsService(AppSettings current) : ISettingsService + { + public AppSettings Current { get; private set; } = current; + + public AppSettings Load() + { + return Current; + } + + public void Save(AppSettings settings) + { + Change(settings); + } + + public AppSettings Update(Func mutate) + { + var updated = mutate(Current); + Change(updated); + return updated; + } + + public void Change(AppSettings settings) + { + Current = settings; + SettingsChanged?.Invoke(settings); + } + + public event Action? SettingsChanged; + } +} From 9391baab76dac0c933d56f540eaa82dae65495ef Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 19:16:52 +0000 Subject: [PATCH 160/226] Reject malformed successful responses in the SDK batch parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK's batch chat parser returned an empty string whenever choices, message, or string content was missing from a 2xx body, and the transcription parser produced a successful empty result when text was absent. Schema drift, invalid payloads, and provider error objects encoded inside successful responses all became "successful empty output" - the post-processing pipeline would replace working text with nothing, and dictation misdiagnosed protocol failures as ordinary no-speech results. Both parsers now require the documented response shape: missing or wrong-typed required fields throw an InvalidOperationException naming the violated field, surfacing a top-level error.message when the body is a provider error object, and including a 200-character body snippet (response payloads only; credentials live in requests). An explicitly present empty content or text string remains a successful empty result, and the transcription parser's behavior is now uniform instead of throwing only for wrong-typed text. All nine consumer plugins build unchanged. PA42 tracks the still-permissive streaming delta parser, which likely belongs to §9 H6 / §12 M5. --- .../Helpers/OpenAiChatHelper.cs | 66 +++++++++-- .../Helpers/OpenAiTranscriptionHelper.cs | 44 ++++++- .../OpenAiChatHelperTests.cs | 110 ++++++++++++++++++ .../OpenAiTranscriptionHelperTests.cs | 76 +++++++++++- 4 files changed, 286 insertions(+), 10 deletions(-) diff --git a/src/TypeWhisper.PluginSDK/Helpers/OpenAiChatHelper.cs b/src/TypeWhisper.PluginSDK/Helpers/OpenAiChatHelper.cs index 130bda555..ed6bf054f 100644 --- a/src/TypeWhisper.PluginSDK/Helpers/OpenAiChatHelper.cs +++ b/src/TypeWhisper.PluginSDK/Helpers/OpenAiChatHelper.cs @@ -348,24 +348,74 @@ private static string ParseChatCompletionResponse(string json) || choices.ValueKind != JsonValueKind.Array || choices.GetArrayLength() == 0) { - return ""; + throw CreateInvalidResponseException( + json, + root, + "'choices' must be a non-empty array" + ); } var firstChoice = choices[0]; - if ( - firstChoice.ValueKind != JsonValueKind.Object + if (firstChoice.ValueKind != JsonValueKind.Object || !firstChoice.TryGetProperty("message", out var message) - || message.ValueKind != JsonValueKind.Object - || !message.TryGetProperty("content", out var content) - || content.ValueKind != JsonValueKind.String - ) + || message.ValueKind != JsonValueKind.Object) + { + throw CreateInvalidResponseException( + json, + root, + "'choices[0].message' must be an object" + ); + } + + if (!message.TryGetProperty("content", out var content) + || content.ValueKind != JsonValueKind.String) { - return ""; + throw CreateInvalidResponseException( + json, + root, + "'choices[0].message.content' must be a string" + ); } return content.GetString()?.Trim() ?? ""; } + private static InvalidOperationException CreateInvalidResponseException( + string json, + JsonElement root, + string requiredField + ) + { + var providerError = TryGetProviderErrorMessage(root); + var providerErrorDetail = providerError is null + ? "" + : $" Provider error: {providerError}"; + return new InvalidOperationException( + $"Invalid chat completion response: required field {requiredField}." + + $"{providerErrorDetail} Body: {GetBodySnippet(json)}" + ); + } + + private static string? TryGetProviderErrorMessage(JsonElement root) + { + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.Object + && error.TryGetProperty("message", out var message) + && message.ValueKind == JsonValueKind.String) + { + return message.GetString(); + } + + return null; + } + + private static string GetBodySnippet(string json) + { + const int maxLength = 200; + return json.Length > maxLength ? $"{json[..maxLength]}..." : json; + } + private static Dictionary BuildRequestBody( string model, string systemPrompt, diff --git a/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs b/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs index 9a084d7fe..7d0b633de 100644 --- a/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs +++ b/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs @@ -84,7 +84,14 @@ internal static PluginTranscriptionResult ParseTranscriptionResponse(string json using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - var text = root.TryGetProperty("text", out var textEl) ? textEl.GetString() ?? "" : ""; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("text", out var textEl) + || textEl.ValueKind != JsonValueKind.String) + { + throw CreateInvalidResponseException(json, root); + } + + var text = textEl.GetString() ?? ""; var language = root.TryGetProperty("language", out var langEl) ? langEl.GetString() : null; var duration = root.TryGetProperty("duration", out var durEl) ? durEl.GetDouble() : 0; var segments = new List(); @@ -122,4 +129,39 @@ internal static PluginTranscriptionResult ParseTranscriptionResponse(string json return new PluginTranscriptionResult(text.Trim(), language, duration, minNoSpeechProb) { Segments = segments }; } + + private static InvalidOperationException CreateInvalidResponseException( + string json, + JsonElement root + ) + { + var providerError = TryGetProviderErrorMessage(root); + var providerErrorDetail = providerError is null + ? "" + : $" Provider error: {providerError}"; + return new InvalidOperationException( + "Invalid transcription response: required field 'text' must be a string." + + $"{providerErrorDetail} Body: {GetBodySnippet(json)}" + ); + } + + private static string? TryGetProviderErrorMessage(JsonElement root) + { + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.Object + && error.TryGetProperty("message", out var message) + && message.ValueKind == JsonValueKind.String) + { + return message.GetString(); + } + + return null; + } + + private static string GetBodySnippet(string json) + { + const int maxLength = 200; + return json.Length > maxLength ? $"{json[..maxLength]}..." : json; + } } diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiChatHelperTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiChatHelperTests.cs index 1feae88c4..3e956db98 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenAiChatHelperTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiChatHelperTests.cs @@ -1,4 +1,6 @@ +using System.Net; using System.Reflection; +using System.Text; using TypeWhisper.PluginSDK.Helpers; namespace TypeWhisper.PluginSystem.Tests; @@ -31,6 +33,77 @@ public void SendChatCompletionAsync_PreservesLegacySevenParameterOverload() Assert.Equal(typeof(Task), method.ReturnType); } + [Fact] + public async Task SendChatCompletionAsync_MissingChoices_ThrowsButExplicitEmptyContentSucceeds() + { + var emptyResult = await SendChatResponseAsync( + """{"choices":[{"message":{"content":""}}]}"""); + const string json = """{"id":"chatcmpl-123"}"""; + + var exception = await AssertProtocolFailureAsync(json); + + Assert.Equal("", emptyResult); + Assert.Contains("'choices'", exception.Message); + } + + [Fact] + public async Task SendChatCompletionAsync_EmptyChoices_ThrowsProtocolFailure() + { + const string json = """{"choices":[]}"""; + + var exception = await AssertProtocolFailureAsync(json); + + Assert.Contains("'choices'", exception.Message); + } + + [Fact] + public async Task SendChatCompletionAsync_ChoiceWithoutMessage_ThrowsProtocolFailure() + { + const string json = """{"choices":[{"finish_reason":"stop"}]}"""; + + var exception = await AssertProtocolFailureAsync(json); + + Assert.Contains("'choices[0].message'", exception.Message); + } + + [Fact] + public async Task SendChatCompletionAsync_MessageWithoutContent_ThrowsProtocolFailure() + { + const string json = """{"choices":[{"message":{"role":"assistant"}}]}"""; + + var exception = await AssertProtocolFailureAsync(json); + + Assert.Contains("'choices[0].message.content'", exception.Message); + } + + [Fact] + public async Task SendChatCompletionAsync_NonStringContent_ThrowsProtocolFailure() + { + const string json = """{"choices":[{"message":{"content":42}}]}"""; + + var exception = await AssertProtocolFailureAsync(json); + + Assert.Contains("'choices[0].message.content'", exception.Message); + } + + [Fact] + public async Task SendChatCompletionAsync_SuccessfulErrorObject_SurfacesProviderMessage() + { + const string json = """ + { + "error": { + "message": "The provider rejected this request.", + "type": "invalid_request_error" + } + } + """; + + var exception = await AssertProtocolFailureAsync(json); + + Assert.Contains("'choices'", exception.Message); + Assert.Contains("The provider rejected this request.", exception.Message); + } + [Fact] public void ParseChatCompletionStreamDelta_ExtractsContentDelta() { @@ -77,4 +150,41 @@ public void ParseChatCompletionStreamError_NonErrorFrame_ReturnsNull(string payl { Assert.Null(OpenAiChatHelper.ParseChatCompletionStreamError(payload)); } + + private static async Task AssertProtocolFailureAsync(string json) + { + var exception = await Assert.ThrowsAsync( + () => SendChatResponseAsync(json)); + Assert.Contains("Body:", exception.Message); + Assert.Contains(json.Length > 200 ? json[..200] : json, exception.Message); + return exception; + } + + private static async Task SendChatResponseAsync(string json) + { + using var httpClient = new HttpClient(new JsonResponseHandler(json)); + return await OpenAiChatHelper.SendChatCompletionAsync( + httpClient, + "https://example.test", + "test-key", + "test-model", + "system", + "user", + CancellationToken.None + ); + } + + private sealed class JsonResponseHandler(string json) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }); + } + } } diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiTranscriptionHelperTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiTranscriptionHelperTests.cs index 077de78c3..9ddea78b8 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenAiTranscriptionHelperTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiTranscriptionHelperTests.cs @@ -1,9 +1,69 @@ +using System.Net; +using System.Text; using TypeWhisper.PluginSDK.Helpers; namespace TypeWhisper.PluginSystem.Tests; public class OpenAiTranscriptionHelperTests { + [Fact] + public void ParseTranscriptionResponse_MissingText_ThrowsProtocolFailure() + { + const string json = """{"language":"en","duration":1.0}"""; + + var exception = Assert.Throws( + () => OpenAiTranscriptionHelper.ParseTranscriptionResponse(json)); + + Assert.Contains("'text'", exception.Message); + Assert.Contains("Body:", exception.Message); + Assert.Contains(json, exception.Message); + } + + [Fact] + public void ParseTranscriptionResponse_NonStringText_ThrowsProtocolFailure() + { + const string json = """{"text":42,"language":"en","duration":1.0}"""; + + var exception = Assert.Throws( + () => OpenAiTranscriptionHelper.ParseTranscriptionResponse(json)); + + Assert.Contains("'text'", exception.Message); + Assert.Contains("Body:", exception.Message); + Assert.Contains(json, exception.Message); + } + + [Fact] + public async Task TranscribeAsync_SuccessfulErrorObject_SurfacesProviderMessage() + { + const string json = """ + { + "error": { + "message": "The audio format is not supported.", + "type": "invalid_request_error" + } + } + """; + using var httpClient = new HttpClient(new JsonResponseHandler(json)); + + var exception = await Assert.ThrowsAsync( + () => OpenAiTranscriptionHelper.TranscribeAsync( + httpClient, + "https://example.test", + "test-key", + "test-model", + [], + null, + false, + "json", + CancellationToken.None + )); + + Assert.Contains("'text'", exception.Message); + Assert.Contains("The audio format is not supported.", exception.Message); + Assert.Contains("Body:", exception.Message); + Assert.Contains(json.Length > 200 ? json[..200] : json, exception.Message); + } + [Fact] public void ParseTranscriptionResponse_VerboseJson_ExtractsNoSpeechProb() { @@ -125,4 +185,18 @@ public void ParseTranscriptionResponse_LowNoSpeechProb_IndicatesSpeech() Assert.NotNull(result.NoSpeechProbability); Assert.True(result.NoSpeechProbability < 0.1f); } -} \ No newline at end of file + + private sealed class JsonResponseHandler(string json) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }); + } + } +} From 89067c087a642f120d5759cb4836cc50cab36d8d Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 19:56:16 +0000 Subject: [PATCH 161/226] Honor the transcription helper's plain-text response format OpenAiTranscriptionHelper.TranscribeAsync documented response formats including "text" and sent the caller's selection to the server, but unconditionally fed the returned body to its JSON parser - a conforming endpoint answering the documented plain-text option made every otherwise successful call throw. Parsing now branches on the requested format before the request is sent: "text" treats the raw body as the transcription (removing exactly one trailing newline; an empty body is a successful empty result, consistent with the M1 explicit-empty rule; metadata uses defaults), "json" and "verbose_json" keep the M1-strict JSON path unchanged, and any other value - including the never-advertised srt/vtt - fails fast with an ArgumentException naming the supported set. No in-tree consumer passes anything but verbose_json/json, so the fail-fast is unreachable in current production paths; the documentation now states the exact supported formats. --- .../Helpers/OpenAiTranscriptionHelper.cs | 37 +++++- .../OpenAiTranscriptionHelperTests.cs | 107 ++++++++++++++++++ 2 files changed, 140 insertions(+), 4 deletions(-) diff --git a/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs b/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs index 7d0b633de..4721cc8c3 100644 --- a/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs +++ b/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs @@ -24,10 +24,16 @@ public static class OpenAiTranscriptionHelper /// WAV-encoded audio bytes. /// Language hint (ISO code) or null for auto-detection. /// If true, uses the translations endpoint (audio to English). - /// Response format (e.g. "verbose_json", "json", "text"). + /// + /// Response format. Supported values are "verbose_json", "json", + /// and "text". + /// /// Cancellation token. /// Optional text to bias the model toward specific spelling, vocabulary, or style; null to omit. - /// Transcription result with text, detected language, and duration. + /// + /// Transcription result with text, detected language, and duration. The "text" + /// format supplies only text, so language, duration, and segments use their default values. + /// // ReSharper disable once UnusedMember.Global // ReSharper disable once UnusedParameter.Global public static async Task TranscribeAsync( @@ -43,6 +49,17 @@ public static async Task TranscribeAsync( string? prompt = null ) { + var parseAsPlainText = responseFormat switch + { + "text" => true, + "json" or "verbose_json" => false, + _ => throw new ArgumentException( + $"Unsupported transcription response format: '{responseFormat}'. " + + "Supported formats are 'verbose_json', 'json', and 'text'.", + nameof(responseFormat) + ), + }; + var endpoint = translate ? $"{baseUrl}/v1/audio/translations" : $"{baseUrl}/v1/audio/transcriptions"; @@ -70,8 +87,20 @@ public static async Task TranscribeAsync( request.Content = content; var response = await OpenAiApiHelper.SendWithErrorHandlingAsync(httpClient, request, ct); - var json = await response.Content.ReadAsStringAsync(ct); - return ParseTranscriptionResponse(json); + var responseBody = await response.Content.ReadAsStringAsync(ct); + return parseAsPlainText + ? ParsePlainTextTranscriptionResponse(responseBody) + : ParseTranscriptionResponse(responseBody); + } + + private static PluginTranscriptionResult ParsePlainTextTranscriptionResponse(string responseBody) + { + var text = responseBody.EndsWith("\r\n", StringComparison.Ordinal) + ? responseBody[..^2] + : responseBody.EndsWith('\n') || responseBody.EndsWith('\r') + ? responseBody[..^1] + : responseBody; + return new PluginTranscriptionResult(text, null, 0, null); } /// diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiTranscriptionHelperTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiTranscriptionHelperTests.cs index 9ddea78b8..8a6860e0b 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenAiTranscriptionHelperTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiTranscriptionHelperTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text; using TypeWhisper.PluginSDK.Helpers; +using TypeWhisper.PluginSDK.Models; namespace TypeWhisper.PluginSystem.Tests; @@ -64,6 +65,69 @@ public async Task TranscribeAsync_SuccessfulErrorObject_SurfacesProviderMessage( Assert.Contains(json.Length > 200 ? json[..200] : json, exception.Message); } + [Fact] + public async Task TranscribeAsync_TextFormat_RemovesOnlyOneTrailingNewline() + { + using var httpClient = new HttpClient(new PlainTextResponseHandler("Plain transcription\n\n")); + + var result = await TranscribeAsync(httpClient, "text"); + + Assert.Equal("Plain transcription\n", result.Text); + Assert.Null(result.DetectedLanguage); + Assert.Equal(0, result.DurationSeconds); + Assert.Null(result.NoSpeechProbability); + Assert.Empty(result.Segments); + } + + [Fact] + public async Task TranscribeAsync_TextFormat_EmptyBody_ReturnsSuccessfulEmptyResult() + { + using var httpClient = new HttpClient(new PlainTextResponseHandler("")); + + var result = await TranscribeAsync(httpClient, "text"); + + Assert.Equal("", result.Text); + } + + [Fact] + public async Task TranscribeAsync_TextFormat_JsonLookingBody_RemainsPlainText() + { + const string body = """{"text":"JSON value"}"""; + using var httpClient = new HttpClient(new PlainTextResponseHandler(body)); + + var result = await TranscribeAsync(httpClient, "text"); + + Assert.Equal(body, result.Text); + } + + [Theory] + [InlineData("srt")] + [InlineData("vtt")] + public async Task TranscribeAsync_SubtitleFormat_ThrowsUnsupportedFormat(string responseFormat) + { + using var httpClient = new HttpClient(new UnexpectedRequestHandler()); + + var exception = await Assert.ThrowsAsync( + () => TranscribeAsync(httpClient, responseFormat)); + + Assert.Equal("responseFormat", exception.ParamName); + Assert.Contains(responseFormat, exception.Message); + Assert.Contains("Supported formats", exception.Message); + } + + [Fact] + public async Task TranscribeAsync_UnknownFormat_ThrowsUnsupportedFormat() + { + using var httpClient = new HttpClient(new UnexpectedRequestHandler()); + + var exception = await Assert.ThrowsAsync( + () => TranscribeAsync(httpClient, "yaml")); + + Assert.Equal("responseFormat", exception.ParamName); + Assert.Contains("yaml", exception.Message); + Assert.Contains("Supported formats", exception.Message); + } + [Fact] public void ParseTranscriptionResponse_VerboseJson_ExtractsNoSpeechProb() { @@ -186,6 +250,24 @@ public void ParseTranscriptionResponse_LowNoSpeechProb_IndicatesSpeech() Assert.True(result.NoSpeechProbability < 0.1f); } + private static Task TranscribeAsync( + HttpClient httpClient, + string responseFormat + ) + { + return OpenAiTranscriptionHelper.TranscribeAsync( + httpClient, + "https://example.test", + "test-key", + "test-model", + [], + null, + false, + responseFormat, + CancellationToken.None + ); + } + private sealed class JsonResponseHandler(string json) : HttpMessageHandler { protected override Task SendAsync( @@ -199,4 +281,29 @@ CancellationToken cancellationToken }); } } + + private sealed class PlainTextResponseHandler(string text) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(text, Encoding.UTF8, "text/plain"), + }); + } + } + + private sealed class UnexpectedRequestHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + throw new InvalidOperationException("The request should fail validation before it is sent."); + } + } } From db31e19814dfcdf41c105c30433dd2f1a74701eb Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 20:00:58 +0000 Subject: [PATCH 162/226] Coordinate CUDA cache maintenance with a root lock outside the tree Wheel-download sentinels lived inside the shared CUDA cache even though InterProcessFileLock's contract forbids ever unlinking a sentinel - a recreated pathname is a new inode that a second process can "acquire" while the first still holds the old one. ClearCacheAsync recursively deleted the whole cache, sentinels included, under only its per-instance semaphore, and stale-bundle pruning had no cross-process coordination at all: concurrent plugin copies could overlap provisioning with deletion, resurrect a cleared cache, or fail unpredictably. Sentinels now live outside the deleted tree (a sibling maintenance lock plus per-package locks in a sibling .locks directory) and are never deleted by production code. The locking hierarchy is strictly root then wheels: provisioning takes the root lock, acquires the wheel locks it needs in stable path order, releases root, and holds the wheels for the batch, so disjoint batches still run in parallel; clear and prune hold root plus all wheel locks for their whole delete window. Acquisition order makes deadlock impossible - nothing holding a wheel ever waits on root. Clear fails with a bounded TimeoutException leaving the cache untouched; pruning stays best-effort and skips with a logged reason. PA43 records the accepted preload-window and mixed-version migration edges. --- plugins/Shared/Cuda/CudaRuntimeProvisioner.cs | 319 +++++++++++++++--- .../CudaRuntimeCacheClearTests.cs | 87 +++++ .../CudaRuntimeProvisionerTests.cs | 193 ++++++++++- 3 files changed, 534 insertions(+), 65 deletions(-) diff --git a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs index 8672501e6..6fe326c67 100644 --- a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs +++ b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs @@ -53,6 +53,9 @@ public class CudaRuntimeProvisioner private const int RtldNow = 0x002; private const int RtldGlobal = 0x100; + private static readonly TimeSpan s_defaultMaintenanceLockTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan s_provisioningLockAttempt = TimeSpan.FromMilliseconds(25); + private static readonly TimeSpan s_provisioningLockRetry = TimeSpan.FromMilliseconds(100); // Each wheel maps a PyPI package@version to the sonames it must contribute. // RequiredSonames are the libraries we both (a) check to decide whether the host @@ -144,17 +147,40 @@ private static string[] BuildSystemLibraryDirectories() private readonly SemaphoreSlim _gate = new(1, 1); private readonly Lock _preloadSync = new(); private readonly HashSet _preloaded = new(StringComparer.Ordinal); + private readonly string _cacheRoot; + private readonly string _maintenanceLockPath; + private readonly string _wheelLockDirectory; public CudaRuntimeProvisioner(string cacheRoot, HttpClient httpClient, Action? log = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _log = log; CacheDirectory = Path.Join(cacheRoot, BundleVersion); + + var cacheRootDirectory = Directory.GetParent(CacheDirectory) + ?? throw new ArgumentException("The CUDA cache root must have a parent directory.", nameof(cacheRoot)); + var cacheParent = cacheRootDirectory.Parent + ?? throw new ArgumentException("The CUDA cache root must not be a filesystem root.", nameof(cacheRoot)); + _cacheRoot = cacheRootDirectory.FullName; + _maintenanceLockPath = Path.Join( + cacheParent.FullName, + cacheRootDirectory.Name + ".maintenance.lock" + ); + _wheelLockDirectory = Path.Join( + cacheParent.FullName, + cacheRootDirectory.Name + ".locks" + ); } /// Directory holding the downloaded CUDA .so files for this bundle version. public string CacheDirectory { get; } + // Test seams: pin the lock paths and timeout so tests avoid a real per-user cache. + internal string MaintenanceLockPathForTests => _maintenanceLockPath; + internal string WheelLockDirectoryForTests => _wheelLockDirectory; + internal TimeSpan MaintenanceLockTimeoutForTests { get; init; } = + s_defaultMaintenanceLockTimeout; + /// /// The shared cache root both local engines use, so the CUDA math libraries /// are downloaded once. Resolves to @@ -240,8 +266,7 @@ CancellationToken ct await _gate.WaitAsync(ct).ConfigureAwait(false); try { - Directory.CreateDirectory(CacheDirectory); - PruneStaleBundles(); + EnsureExternalLockDirectory(); // A wheel is fetched unless EVERY library it provides is already // resolvable (on-system or in our cache). Checking only the primary @@ -250,7 +275,16 @@ CancellationToken ct // as complete and then fail at native session creation. cuBLAS in // particular is a ~580 MB wheel we still skip when the host toolkit // already ships its full set. - var missing = wheels.Where(w => !IsWheelSatisfied(w)).ToList(); + List missing; + await using ( + await InterProcessFileLock + .AcquireAsync(_maintenanceLockPath, ct) + .ConfigureAwait(false) + ) + { + Directory.CreateDirectory(CacheDirectory); + missing = wheels.Where(w => !IsWheelSatisfied(w)).ToList(); + } if (missing.Count > 0) { @@ -265,6 +299,10 @@ CancellationToken ct _log?.Invoke("CUDA runtime: all required libraries already present."); progress?.Report(1.0); } + + // Pruning is best-effort; run it after provisioning so two provisioners + // with disjoint wheel sets can still download/extract in parallel. + await PruneStaleBundlesAsync(ct).ConfigureAwait(false); } finally { @@ -289,30 +327,169 @@ CancellationToken ct jobs.Add((wheel, url, size, sha256)); } - var totalBytes = jobs.Sum(j => j.Size); - long completedBytes = 0; + // Lock coupling: root -> every wheel needed by this batch, in stable path + // order. The root lock is then released while the wheel locks remain held + // through the full batch. Clear/prune take root -> every wheel, so they wait + // for an active batch and prevent a new one from starting. Provisioners with + // disjoint wheel sets retain their existing safe parallelism. + await using (await AcquireProvisioningWheelLocksAsync(missing, ct).ConfigureAwait(false)) + { + // Clear may have run while PyPI metadata was resolving. Recreate the cache + // only after this batch owns its external wheel locks, so maintenance can + // no longer delete it until all of the batch's writes finish. + Directory.CreateDirectory(CacheDirectory); + + var totalBytes = jobs.Sum(j => j.Size); + long completedBytes = 0; - foreach (var (wheel, url, size, sha256) in jobs) + foreach (var (wheel, url, size, sha256) in jobs) + { + var baseline = completedBytes; + var downloaded = await DownloadAndExtractWheelAsync( + wheel, + url, + sha256, + read => + { + if (totalBytes > 0) + progress?.Report(Math.Min(1.0, (double)(baseline + read) / totalBytes)); + }, + ct + ).ConfigureAwait(false); + // Advance by the metadata size when known, else by what we actually read, + // so a wheel whose PyPI size was missing still moves the cumulative counter + // instead of stalling it at the previous baseline. + completedBytes += size > 0 ? size : downloaded; + } + } + + progress?.Report(1.0); + } + + private async Task AcquireProvisioningWheelLocksAsync( + IReadOnlyList wheels, + CancellationToken ct + ) + { + EnsureExternalLockDirectory(); + var lockPaths = wheels + .Select(WheelLockPath) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + + while (true) { - var baseline = completedBytes; - var downloaded = await DownloadAndExtractWheelAsync( - wheel, - url, - sha256, - read => + ct.ThrowIfCancellationRequested(); + FileStream? rootLock = null; + var wheelLocks = new List(lockPaths.Length); + try + { + rootLock = await InterProcessFileLock + .AcquireAsync(_maintenanceLockPath, ct) + .ConfigureAwait(false); + + foreach (var lockPath in lockPaths) { - if (totalBytes > 0) - progress?.Report(Math.Min(1.0, (double)(baseline + read) / totalBytes)); - }, - ct - ).ConfigureAwait(false); - // Advance by the metadata size when known, else by what we actually read, - // so a wheel whose PyPI size was missing still moves the cumulative counter - // instead of stalling it at the previous baseline. - completedBytes += size > 0 ? size : downloaded; + // Do not sit on the root lock behind a busy wheel: a short + // acquire attempt followed by a full retry lets a provisioner + // for a disjoint wheel (or maintenance) take the root meanwhile. + using var attemptCts = + CancellationTokenSource.CreateLinkedTokenSource(ct); + attemptCts.CancelAfter(s_provisioningLockAttempt); + wheelLocks.Add( + await InterProcessFileLock + .AcquireAsync(lockPath, attemptCts.Token) + .ConfigureAwait(false) + ); + } + + return new ExternalLockLease(wheelLocks); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + await DisposeLocksAsync(wheelLocks).ConfigureAwait(false); + } + catch + { + await DisposeLocksAsync(wheelLocks).ConfigureAwait(false); + throw; + } + finally + { + if (rootLock is not null) + await rootLock.DisposeAsync().ConfigureAwait(false); + } + + await Task.Delay(s_provisioningLockRetry, ct).ConfigureAwait(false); } + } - progress?.Report(1.0); + private async Task AcquireMaintenanceLocksAsync( + string operation, + CancellationToken ct + ) + { + EnsureExternalLockDirectory(); + using var timeoutCts = new CancellationTokenSource(MaintenanceLockTimeoutForTests); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + ct, + timeoutCts.Token + ); + var acquired = new List(); + + try + { + acquired.Add( + await InterProcessFileLock + .AcquireAsync(_maintenanceLockPath, linkedCts.Token) + .ConfigureAwait(false) + ); + + // Root is held before this enumeration, so no provisioner can add a new + // wheel sentinel after the snapshot. Include the known wheel set plus + // existing sentinels, for forward compatibility with bundle/package changes. + var wheelLockPaths = s_onnxRuntimeWheels + .Select(WheelLockPath) + .Concat(Directory.EnumerateFiles(_wheelLockDirectory, "*.lock")) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal); + foreach (var lockPath in wheelLockPaths) + { + acquired.Add( + await InterProcessFileLock + .AcquireAsync(lockPath, linkedCts.Token) + .ConfigureAwait(false) + ); + } + + return new ExternalLockLease(acquired); + } + catch (OperationCanceledException ex) when (!ct.IsCancellationRequested) + { + await DisposeLocksAsync(acquired).ConfigureAwait(false); + throw new TimeoutException( + $"Timed out waiting for another CUDA cache operation before {operation}.", + ex + ); + } + catch + { + await DisposeLocksAsync(acquired).ConfigureAwait(false); + throw; + } + } + + private string WheelLockPath(CudaWheel wheel) => + Path.Join(_wheelLockDirectory, wheel.Package + ".lock"); + + private void EnsureExternalLockDirectory() => + Directory.CreateDirectory(_wheelLockDirectory); + + private static async ValueTask DisposeLocksAsync(List locks) + { + for (var i = locks.Count - 1; i >= 0; i--) + await locks[i].DisposeAsync().ConfigureAwait(false); } private async Task<(string Url, long Size, string Sha256)> ResolveWheelAsync( @@ -387,14 +564,10 @@ CancellationToken ct ) { // Per-package staging name so two wheels never share a .partial and a dropped - // download resumes via Range. The shared cache means two provisioners (or two app - // processes) can pick the same path, which the per-instance _gate doesn't cover — - // so guard it with a cross-process lock (see InterProcessFileLock). + // download resumes via Range. AcquireProvisioningWheelLocksAsync already holds + // this package's stable EXTERNAL sentinel for the full provisioning batch. var wheelPath = Path.Join(CacheDirectory, $"{wheel.Package}.whl"); - await using var stagingLock = - await InterProcessFileLock.AcquireAsync(wheelPath + ".lock", ct).ConfigureAwait(false); - // A sibling that held the lock first may have just completed this wheel — re-check // so we don't redundantly re-fetch a hundreds-of-MB wheel. if (IsWheelSatisfied(wheel)) @@ -687,33 +860,44 @@ private static bool LdConfigContains(string soname) /// Deletes the entire shared CUDA cache root (the parent of /// — every bundle version, not just the current /// one) so the next re-downloads from scratch. - /// Guarded by the same gate as provisioning so it can't race an in-flight - /// download — and awaits the gate with so a cancel isn't - /// stuck behind that download. A missing cache is a no-op (already clear); a - /// delete failure is logged and rethrown so the caller can surface it rather than - /// report a corrupt runtime as repaired. Note: libraries already dlopen'd this - /// process are held until exit, so a restart is required for a fresh re-provision - /// to take effect. + /// The per-instance gate is layered with a bounded, cross-process maintenance + /// lock outside the deleted tree. Maintenance owns root -> every external wheel + /// sentinel through the full delete window, so it cannot unlink a live sentinel + /// or race another provisioner. A missing cache is a no-op (already clear); a + /// timeout or delete failure is logged and rethrown so the caller can surface it + /// rather than report a corrupt runtime as repaired. Note: libraries already + /// dlopen'd this process are held until exit, so a restart is required for a fresh + /// re-provision to take effect. /// public async Task ClearCacheAsync(CancellationToken ct) { await _gate.WaitAsync(ct).ConfigureAwait(false); try { - var root = Directory.GetParent(CacheDirectory)?.FullName; - if (root is null || !Directory.Exists(root)) - return; - try { - Directory.Delete(root, recursive: true); - _log?.Invoke($"CUDA runtime: cleared cache at {root}."); + await using ( + await AcquireMaintenanceLocksAsync( + "clearing the CUDA runtime cache", + ct + ) + .ConfigureAwait(false) + ) + { + if (!Directory.Exists(_cacheRoot)) + return; + + Directory.Delete(_cacheRoot, recursive: true); + _log?.Invoke($"CUDA runtime: cleared cache at {_cacheRoot}."); + } } catch (Exception ex) { // Don't swallow: the caller reports "cleared" to the user only when the // cache is actually gone, so a corrupt runtime can't masquerade as repaired. - _log?.Invoke($"CUDA runtime: failed to clear cache at {root}: {ex.Message}"); + _log?.Invoke( + $"CUDA runtime: failed to clear cache at {_cacheRoot}: {ex.Message}" + ); throw; } } @@ -727,24 +911,48 @@ public async Task ClearCacheAsync(CancellationToken ct) // don't accumulate (cuDNN alone is ~1.7 GB unpacked). // internal so a unit test can assert a different-version sibling dir is deleted while // the current version's dir is kept. - internal void PruneStaleBundles() + internal void PruneStaleBundles() => + PruneStaleBundlesAsync(CancellationToken.None).GetAwaiter().GetResult(); + + private async Task PruneStaleBundlesAsync(CancellationToken ct) { try { - var parent = Directory.GetParent(CacheDirectory); - if (parent is null || !parent.Exists) - return; - - foreach (var dir in parent.EnumerateDirectories() - .Where(dir => !string.Equals(dir.Name, BundleVersion, StringComparison.Ordinal))) + await using ( + await AcquireMaintenanceLocksAsync( + "pruning stale CUDA runtime bundles", + ct + ) + .ConfigureAwait(false) + ) { - try { dir.Delete(recursive: true); } - catch { /* best effort cleanup */ } + var parent = new DirectoryInfo(_cacheRoot); + if (!parent.Exists) + return; + + foreach (var dir in parent.EnumerateDirectories() + .Where(dir => + !string.Equals(dir.Name, BundleVersion, StringComparison.Ordinal))) + { + try { dir.Delete(recursive: true); } + catch { /* best effort cleanup */ } + } } } - catch + catch (TimeoutException ex) + { + // Cleanup is best-effort; provisioning continues with an explicit reason. + _log?.Invoke($"CUDA runtime: skipped stale-bundle pruning: {ex.Message}"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { - // Cleanup is best-effort; never block provisioning on it. + throw; + } + catch (Exception ex) + { + _log?.Invoke( + $"CUDA runtime: skipped stale-bundle pruning because locking failed: {ex.Message}" + ); } } @@ -811,6 +1019,11 @@ internal static bool TryInitializeCudaDriver(out string? error) private static extern IntPtr dlerror(); #pragma warning restore SYSLIB1054, CA2101 + private sealed class ExternalLockLease(List locks) : IAsyncDisposable + { + public ValueTask DisposeAsync() => DisposeLocksAsync(locks); + } + private sealed record CudaWheel( string Package, string Version, diff --git a/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeCacheClearTests.cs b/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeCacheClearTests.cs index 7d21bd6f9..e64861ae4 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeCacheClearTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeCacheClearTests.cs @@ -38,6 +38,93 @@ public async Task CudaRuntimeProvisioner_ClearCache_RemovesEntireCacheRoot() } } + [Fact] + public async Task CudaRuntimeProvisioner_ClearCache_LeavesExternalSentinelsInPlace() + { + var temp = CreateTempDir(); + try + { + var cacheRoot = Path.Join(temp, "cuda"); + using var http = new HttpClient(); + var provisioner = new Provisioner(cacheRoot, http); + Directory.CreateDirectory(provisioner.CacheDirectory); + await File.WriteAllTextAsync( + Path.Join(provisioner.CacheDirectory, "libcudart.so.12"), + "dummy" + ); + + await provisioner.ClearCacheAsync(CancellationToken.None); + + Assert.False(Directory.Exists(cacheRoot)); + Assert.Equal( + Path.Join(temp, "cuda.maintenance.lock"), + provisioner.MaintenanceLockPathForTests + ); + Assert.True(File.Exists(provisioner.MaintenanceLockPathForTests)); + Assert.Equal( + Path.Join(temp, "cuda.locks"), + provisioner.WheelLockDirectoryForTests + ); + var wheelSentinels = Directory.GetFiles( + provisioner.WheelLockDirectoryForTests, + "*.lock" + ); + Assert.NotEmpty(wheelSentinels); + Assert.All(wheelSentinels, path => Assert.True(File.Exists(path))); + } + finally + { + TryDeleteDir(temp); + } + } + + [Fact] + public async Task CudaRuntimeProvisioner_ClearCache_TimesOutWithoutDeleting() + { + var temp = CreateTempDir(); + try + { + var cacheRoot = Path.Join(temp, "cuda"); + using var http = new HttpClient(); + var provisioner = new Provisioner(cacheRoot, http) + { + MaintenanceLockTimeoutForTests = TimeSpan.FromMilliseconds(100), + }; + Directory.CreateDirectory(provisioner.CacheDirectory); + await File.WriteAllTextAsync( + Path.Join(provisioner.CacheDirectory, "libcudart.so.12"), + "dummy" + ); + Directory.CreateDirectory( + Directory.GetParent(provisioner.MaintenanceLockPathForTests)!.FullName + ); + await using var heldMaintenanceLock = new FileStream( + provisioner.MaintenanceLockPathForTests, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None + ); + + var ex = await Assert.ThrowsAsync( + () => provisioner.ClearCacheAsync(CancellationToken.None) + ); + + Assert.Contains( + "Timed out waiting for another CUDA cache operation before clearing", + ex.Message, + StringComparison.Ordinal + ); + Assert.True(Directory.Exists(cacheRoot)); + Assert.True( + File.Exists(Path.Join(provisioner.CacheDirectory, "libcudart.so.12")) + ); + } + finally + { + TryDeleteDir(temp); + } + } + [Fact] public async Task SherpaCudaRuntimeInstaller_ClearCache_RemovesRuntimeTree() { diff --git a/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs b/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs index d2d87a262..bfd02dd08 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs @@ -305,9 +305,142 @@ public async Task DownloadAndExtract_TwoConcurrentCalls_GateSerializes_SingleDow Assert.Equal(2, handler.WheelRequests); } + [Fact] + public async Task DownloadAndExtract_TwoProvisioners_ClearWaitsForActiveProvisioning() + { + using var temp = new TempDir(); + var (handler, http) = WhisperCublasFixture(pauseFirstWheelResponse: true); + using var _ = http; + var provisioner = new CudaRuntimeProvisioner(temp.Path, http) + { + SystemLibraryProbeForTests = _ => false, + }; + var clearingProvisioner = new CudaRuntimeProvisioner(temp.Path, http) + { + SystemLibraryProbeForTests = _ => false, + }; + + var provisioning = provisioner.DownloadAndExtractAsync( + CudaRuntimeProfile.WhisperCublas, + null, + CancellationToken.None + ); + await handler.FirstWheelRequestStarted.WaitAsync(TimeSpan.FromSeconds(5)); + + var cudartSentinel = Path.Join( + provisioner.WheelLockDirectoryForTests, + CudartPackage + ".lock" + ); + Assert.True(File.Exists(cudartSentinel)); + + var clearing = clearingProvisioner.ClearCacheAsync(CancellationToken.None); + bool completedWhileProvisioning; + try + { + completedWhileProvisioning = + await Task.WhenAny(clearing, Task.Delay(500)) == clearing; + } + finally + { + handler.ReleaseFirstWheelResponse(); + } + + await provisioning; + await clearing; + + Assert.False(completedWhileProvisioning); + Assert.False(Directory.Exists(temp.Path)); + Assert.True(File.Exists(provisioner.MaintenanceLockPathForTests)); + Assert.True(File.Exists(cudartSentinel)); + } + + [Fact] + public async Task PruneStaleBundles_TwoProvisioners_WaitsForActiveProvisioning() + { + using var temp = new TempDir(); + var (handler, http) = WhisperCublasFixture(pauseFirstWheelResponse: true); + using var _ = http; + var provisioner = new CudaRuntimeProvisioner(temp.Path, http) + { + SystemLibraryProbeForTests = _ => false, + }; + var pruningProvisioner = new CudaRuntimeProvisioner(temp.Path, http) + { + SystemLibraryProbeForTests = _ => false, + }; + var staleDir = Path.Join(temp.Path, "cuda12-v0-stale"); + Directory.CreateDirectory(staleDir); + await File.WriteAllTextAsync(Path.Join(staleDir, "old.so"), "x"); + + var provisioning = provisioner.DownloadAndExtractAsync( + CudaRuntimeProfile.WhisperCublas, + null, + CancellationToken.None + ); + await handler.FirstWheelRequestStarted.WaitAsync(TimeSpan.FromSeconds(5)); + + var pruning = Task.Run(pruningProvisioner.PruneStaleBundles); + bool completedWhileProvisioning; + bool staleSurvivedWhileProvisioning; + try + { + completedWhileProvisioning = + await Task.WhenAny(pruning, Task.Delay(500)) == pruning; + staleSurvivedWhileProvisioning = Directory.Exists(staleDir); + } + finally + { + handler.ReleaseFirstWheelResponse(); + } + + await provisioning; + await pruning; + + Assert.False(completedWhileProvisioning); + Assert.True(staleSurvivedWhileProvisioning); + Assert.False(Directory.Exists(staleDir)); + } + + [Fact] + public async Task PruneStaleBundles_WhenMaintenanceLockTimesOut_SkipsWithReason() + { + using var temp = new TempDir(); + var (_, http) = WhisperCublasFixture(); + using var _ = http; + var logs = new List(); + var provisioner = new CudaRuntimeProvisioner(temp.Path, http, logs.Add) + { + MaintenanceLockTimeoutForTests = TimeSpan.FromMilliseconds(100), + }; + Directory.CreateDirectory(provisioner.CacheDirectory); + var staleDir = Path.Join(temp.Path, "cuda12-v0-stale"); + Directory.CreateDirectory(staleDir); + await File.WriteAllTextAsync(Path.Join(staleDir, "old.so"), "x"); + await using var heldMaintenanceLock = new FileStream( + provisioner.MaintenanceLockPathForTests, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None + ); + + provisioner.PruneStaleBundles(); + + Assert.True(Directory.Exists(staleDir)); + Assert.Contains( + logs, + message => + message.Contains( + "skipped stale-bundle pruning: Timed out waiting", + StringComparison.Ordinal + ) + ); + } + // ---- fixtures / helpers ------------------------------------------------------------ - private static (FakePyPiHandler Handler, HttpClient Http) WhisperCublasFixture() + private static (FakePyPiHandler Handler, HttpClient Http) WhisperCublasFixture( + bool pauseFirstWheelResponse = false + ) { var fixtures = new[] { @@ -315,10 +448,10 @@ private static (FakePyPiHandler Handler, HttpClient Http) WhisperCublasFixture() Wheel(CublasPackage, CublasVersion, [ ("nvidia/cublas/lib/libcublas.so.12", 16), - ("nvidia/cublas/lib/libcublasLt.so.12", 16), + ("nvidia/cublas/lib/libcublasLt.so.12", 16), ]), }; - var handler = new FakePyPiHandler(fixtures); + var handler = new FakePyPiHandler(fixtures, pauseFirstWheelResponse); return (handler, new HttpClient(handler)); } @@ -394,22 +527,36 @@ private sealed class FakePyPiHandler : HttpMessageHandler { private readonly Dictionary _byPackage; private readonly Dictionary _byUrl; + private readonly bool _pauseFirstWheelResponse; + private readonly TaskCompletionSource _firstWheelRequestStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseFirstWheelResponse = + new(TaskCreationOptions.RunContinuationsAsynchronously); private int _json; private int _wheel; - public FakePyPiHandler(IEnumerable wheels) + public FakePyPiHandler( + IEnumerable wheels, + bool pauseFirstWheelResponse = false + ) { var list = wheels.ToList(); _byPackage = list.ToDictionary(w => w.Package, StringComparer.Ordinal); _byUrl = list.ToDictionary(w => w.WheelUrl, StringComparer.Ordinal); + _pauseFirstWheelResponse = pauseFirstWheelResponse; } public int JsonRequests => Volatile.Read(ref _json); public int WheelRequests => Volatile.Read(ref _wheel); + public Task FirstWheelRequestStarted => _firstWheelRequestStarted.Task; + + public void ReleaseFirstWheelResponse() => + _releaseFirstWheelResponse.TrySetResult(true); - protected override Task SendAsync( + protected override async Task SendAsync( HttpRequestMessage request, - CancellationToken cancellationToken) + CancellationToken cancellationToken + ) { var uri = request.RequestUri!; if (uri.Host == "pypi.org") @@ -418,21 +565,24 @@ protected override Task SendAsync( // /pypi/{package}/{version}/json var parts = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); var package = parts[1]; - return Task.FromResult(Json(BuildPyPiJson(_byPackage[package]))); + return Json(BuildPyPiJson(_byPackage[package])); } if (!_byUrl.TryGetValue(uri.ToString(), out var fixture)) + return new HttpResponseMessage(HttpStatusCode.NotFound); + + var wheelRequest = Interlocked.Increment(ref _wheel); + if (_pauseFirstWheelResponse && wheelRequest == 1) { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + _firstWheelRequestStarted.TrySetResult(true); + await _releaseFirstWheelResponse.Task.WaitAsync(cancellationToken); } - Interlocked.Increment(ref _wheel); - return Task.FromResult( + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(fixture.Zip), - }); - + }; } private static HttpResponseMessage Json(string json) => @@ -493,6 +643,25 @@ public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); + + var parent = Directory.GetParent(Path); + if (parent is null) + return; + + var cacheName = System.IO.Path.GetFileName(Path); + var lockDirectory = System.IO.Path.Join( + parent.FullName, + cacheName + ".locks" + ); + if (Directory.Exists(lockDirectory)) + Directory.Delete(lockDirectory, recursive: true); + + var maintenanceLock = System.IO.Path.Join( + parent.FullName, + cacheName + ".maintenance.lock" + ); + if (File.Exists(maintenanceLock)) + File.Delete(maintenanceLock); } catch { From 45e1ae962581d75d8a85f4df7b4d6b50cae4f673 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 20:11:04 +0000 Subject: [PATCH 163/226] Fail closed on unverifiable downloads and recover invalid Sherpa models With no server-declared total and no caller verifier, the shared downloader promoted a clean EOF straight to the final path. Sherpa model downloads used exactly that mode - no published checksums, resume disabled, no verifier - and downloaded state was mere file existence, so an HTTP/2, chunked, or proxy response that ended early published a truncated model that stayed "downloaded" until someone deleted it by hand. The downloader now refuses to publish when neither an exact declared total nor a completion verifier exists; declared-length and verifier paths are unchanged, and every other production caller already supplies a SHA-256 gate. Sherpa supplies per-artifact structural verification - ONNX protobuf/graph framing with length-checked payloads, strict tokens.txt format (UTF-8, token/ID rows, unique non-negative IDs, no NULs) - with the blank-symbol requirement applied only to transducer models: Canary is an attention encoder-decoder whose vocabulary has no blank, and an unconditional check would have rejected every fresh Canary download and deleted cached copies. Model-load failures delete artifacts only when classified artifact-invalid (structural preflight or known native parse-format diagnostics); CUDA, provider, and loader environment failures preserve the files, and unknown diagnostics default to preserving. --- plugins/Shared/Net/ResilientDownloader.cs | 19 +- .../SherpaOnnxPlugin.cs | 464 +++++++++++++++--- .../ResilientDownloaderTests.cs | 79 ++- .../WhisperCppPluginTests.cs | 99 +++- 4 files changed, 579 insertions(+), 82 deletions(-) diff --git a/plugins/Shared/Net/ResilientDownloader.cs b/plugins/Shared/Net/ResilientDownloader.cs index 5c906fdf8..4b12e6e92 100644 --- a/plugins/Shared/Net/ResilientDownloader.cs +++ b/plugins/Shared/Net/ResilientDownloader.cs @@ -67,7 +67,8 @@ internal static class ResilientDownloader /// /// /// Caller integrity check run on the completed partial before the - /// atomic move; it must throw on mismatch. Required when resuming. + /// atomic move; it must throw on mismatch. Required when resuming, or when + /// the server omits an exact total (Content-Length/Content-Range). /// /// Cancellation for the whole operation (user cancel). public static async Task DownloadToFileAsync( @@ -202,8 +203,14 @@ public static async Task DownloadToFileAsync( } } - // Only a server-declared total gates here (approxTotalBytes never does); a - // missing total falls through to verifyComplete rather than a false incomplete. + // approxTotalBytes never gates completeness. Without a declared total, a + // verifier is mandatory: clean EOF alone can't prove the object ended. + if (declaredTotal is null && verifyComplete is null) + throw new DownloadIncompleteException( + "Download cannot be verified: the server did not declare an exact " + + "total length and the caller supplied no completion verifier." + ); + if (onDisk < declaredTotal) throw new DownloadIncompleteException( $"Download incomplete: wrote {onDisk} of {declaredTotal.Value} " @@ -264,8 +271,8 @@ private static void TryDelete(string path) internal sealed class DownloadStalledException(string message) : Exception(message); /// -/// Thrown when the server declared a total length (Content-Length on a 200, -/// Content-Range total on a 206) but the body ended before that many bytes -/// arrived. +/// Thrown when the body ended before a server-declared total length +/// (Content-Length on a 200, Content-Range total on a 206), or when neither +/// a total nor a caller verifier can establish completeness. /// internal sealed class DownloadIncompleteException(string message) : Exception(message); diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs index 0a1cb9523..b9d8a62fa 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs @@ -1,5 +1,7 @@ using System.Diagnostics; +using System.Globalization; using System.Runtime.InteropServices; +using System.Text; using System.Text.Json; using SherpaOnnx; using TypeWhisper.Plugins.Shared.Cuda; @@ -24,6 +26,26 @@ public sealed class SherpaOnnxPlugin : ITranscriptionEnginePlugin "es", ]; + // Native-library parse diagnostics from org.k2fsa.sherpa.onnx 1.12.23. Match only + // these artifact-specific signatures; generic InvalidOperationException failures + // (CUDA/provider/runtime setup) must leave the downloaded model intact. + private static readonly string[] s_invalidModelLoadMessageFragments = + [ + "INVALID_PROTOBUF", + "INVALID_GRAPH", + "Failed to load model because protobuf parsing failed", + "Protobuf parsing failed", + "ModelProto does not have a graph", + "model format error", + "Missing opset in the model", + "number of lines in tokens.txt", + "tokens.txt does not include the blank token", + "We expect that tokens.txt contains the symbol", + "Error when reading tokens", + "tokens.size()", + " != output_size:", + ]; + private static readonly IReadOnlyList s_models = [ new( @@ -34,6 +56,7 @@ public sealed class SherpaOnnxPlugin : ITranscriptionEnginePlugin 25, true, false, + true, [ new ModelFileDefinition("encoder.int8.onnx", $"{ParakeetRepo}/encoder.int8.onnx", 652), new ModelFileDefinition("decoder.int8.onnx", $"{ParakeetRepo}/decoder.int8.onnx", 12), @@ -49,6 +72,7 @@ public sealed class SherpaOnnxPlugin : ITranscriptionEnginePlugin 4, false, true, + false, [ new ModelFileDefinition("encoder.int8.onnx", $"{CanaryRepo}/encoder.int8.onnx", 127), new ModelFileDefinition("decoder.int8.onnx", $"{CanaryRepo}/decoder.int8.onnx", 71), @@ -72,6 +96,8 @@ public sealed class SherpaOnnxPlugin : ITranscriptionEnginePlugin }; private IPluginHostServices? _host; private OfflineRecognizer? _recognizer; + private Func _parakeetRecognizerFactory = + CreateParakeetRecognizer; private SherpaCudaRuntimeInstaller? _cudaRuntimeInstaller; private CudaRuntimeProvisioner? _cudaProvisioner; private string? _loadedModelId; @@ -309,7 +335,8 @@ await ResilientDownloader.DownloadToFileAsync( lastReport = now; } }, - verifyComplete: null, + verifyComplete: path => + VerifyModelArtifact(path, file.FileName, model.RequiresBlankToken), ct ); @@ -365,80 +392,94 @@ public async Task LoadModelAsync(string modelId, IProgress? progress, Ca } } - await Task.Run( - () => - { - lock (_sync) + try + { + await Task.Run( + () => { - // Provisioning can take minutes; the backend may have been - // switched out from under us in that window. Abort the stale - // load rather than pinning the process to a runtime the user - // no longer wants (and possibly after downloading it for nothing). - if (!string.Equals(_computeBackend, desiredProvider, StringComparison.Ordinal)) - throw new InvalidOperationException( - "Compute backend changed during model load; reload to apply the new backend." - ); - - UnloadRecognizerUnsafe(); - - var activeProvider = desiredProvider; - try + lock (_sync) { - _recognizer = model.SupportsTranslation - ? CreateCanaryRecognizer(dir, "en", "en", activeProvider) - : CreateParakeetRecognizer(dir, activeProvider); - } - catch (Exception ex) - when (string.Equals(activeProvider, "cuda", StringComparison.Ordinal)) - { - // Recreate with the CPU execution provider. The GPU ONNX - // Runtime is already wired in by ConfigureCudaRuntime and runs - // the CPU provider correctly, so this yields working CPU - // transcription rather than failing the load outright. - _host?.Log( - PluginLogLevel.Warning, - $"sherpa-onnx CUDA recognizer creation failed ({ex.Message}); falling back to CPU." - ); - cudaUnavailableDetail = ex.Message; - activeProvider = "cpu"; - _computeBackend = "cpu"; - _recognizer = model.SupportsTranslation - ? CreateCanaryRecognizer(dir, "en", "en", activeProvider) - : CreateParakeetRecognizer(dir, activeProvider); - } + // Provisioning can take minutes; the backend may have been + // switched out from under us in that window. Abort the stale + // load rather than pinning the process to a runtime the user + // no longer wants (and possibly after downloading it for nothing). + if (!string.Equals(_computeBackend, desiredProvider, StringComparison.Ordinal)) + throw new InvalidOperationException( + "Compute backend changed during model load; reload to apply the new backend." + ); + + // Revalidate cached/pre-fix artifacts before the native loader + // (guarantees below, at VerifyModelArtifact). + UnloadRecognizerUnsafe(); + VerifyModelArtifacts(model, dir); + + var activeProvider = desiredProvider; + try + { + _recognizer = model.SupportsTranslation + ? CreateCanaryRecognizer(dir, "en", "en", activeProvider) + : _parakeetRecognizerFactory(dir, activeProvider); + } + catch (Exception ex) + when (string.Equals(activeProvider, "cuda", StringComparison.Ordinal)) + { + // Recreate with the CPU execution provider. The GPU ONNX + // Runtime is already wired in by ConfigureCudaRuntime and runs + // the CPU provider correctly, so this yields working CPU + // transcription rather than failing the load outright. + _host?.Log( + PluginLogLevel.Warning, + $"sherpa-onnx CUDA recognizer creation failed ({ex.Message}); falling back to CPU." + ); + cudaUnavailableDetail = ex.Message; + activeProvider = "cpu"; + _computeBackend = "cpu"; + _recognizer = model.SupportsTranslation + ? CreateCanaryRecognizer(dir, "en", "en", activeProvider) + : _parakeetRecognizerFactory(dir, activeProvider); + } + + // First successful load pins the native runtime for the process. + // Record the WIRED runtime (CUDA-capable vs CPU-only), not the + // recognizer's active provider: a CUDA-wired runtime whose recognizer + // fell back to CPU is still CUDA-capable, so it pins "cuda" and a later + // CPU↔CUDA swap needs no restart. + _loadedNativeProvider ??= _cudaOrtRuntimeWired ? "cuda" : activeProvider; + + _loadedModelId = modelId; + _loadedModelDir = dir; + SelectedModelId = modelId; + _canarySrcLang = "en"; + _canaryTgtLang = "en"; + // Restart is required only if the wired runtime is CPU-only (a + // provisioning failure). A CUDA-wired runtime whose recognizer fell back + // to CPU pins "cuda" above, so CUDA is reachable again by a reload — no + // restart (matches CreateLoadedAccelerationStatus / the swap logic). + AccelerationStatus = cudaUnavailableDetail is null + ? CreateLoadedAccelerationStatus(activeProvider, AccelerationPreference) + : CreateCudaUnavailableStatus( + cudaUnavailableDetail, + requiresRestart: string.Equals( + _loadedNativeProvider, + "cpu", + StringComparison.Ordinal + ) + ); - // First successful load pins the native runtime for the process. - // Record the WIRED runtime (CUDA-capable vs CPU-only), not the - // recognizer's active provider: a CUDA-wired runtime whose recognizer - // fell back to CPU is still CUDA-capable, so it pins "cuda" and a later - // CPU↔CUDA swap needs no restart. - _loadedNativeProvider ??= _cudaOrtRuntimeWired ? "cuda" : activeProvider; - - _loadedModelId = modelId; - _loadedModelDir = dir; - SelectedModelId = modelId; - _canarySrcLang = "en"; - _canaryTgtLang = "en"; - // Restart is required only if the wired runtime is CPU-only (a - // provisioning failure). A CUDA-wired runtime whose recognizer fell back - // to CPU pins "cuda" above, so CUDA is reachable again by a reload — no - // restart (matches CreateLoadedAccelerationStatus / the swap logic). - AccelerationStatus = cudaUnavailableDetail is null - ? CreateLoadedAccelerationStatus(activeProvider, AccelerationPreference) - : CreateCudaUnavailableStatus( - cudaUnavailableDetail, - requiresRestart: string.Equals( - _loadedNativeProvider, "cpu", StringComparison.Ordinal) + Debug.WriteLine( + $"[SherpaOnnx] Model {modelId} loaded from {dir} ({activeProvider})" ); - - Debug.WriteLine( - $"[SherpaOnnx] Model {modelId} loaded from {dir} ({activeProvider})" - ); - } - }, - ct - ) - .ConfigureAwait(false); + } + }, + ct + ) + .ConfigureAwait(false); + } + catch (Exception ex) when (IsArtifactInvalidLoadFailure(ex)) + { + DeleteInvalidModelArtifacts(model, dir, ex); + throw; + } } public async Task EnsureCudaRuntimeReadyAsync(IProgress? progress, CancellationToken ct) @@ -695,6 +736,28 @@ SherpaCudaRuntimeInstaller installer _cudaRuntimeInstaller = installer; } + // Test seam: inject a throwing recognizer factory so native-load-failure + // classification can be exercised without a real model, native runtime, or GPU. + internal void SetParakeetRecognizerFactoryForTests( + Func factory + ) + { + ArgumentNullException.ThrowIfNull(factory); + _parakeetRecognizerFactory = factory; + } + + // Avoid ActivateAsync's one-shot migration probe in filesystem-isolated load tests. + internal void SetHostForTests(IPluginHostServices host) + { + ArgumentNullException.ThrowIfNull(host); + _host = host; + } + + // Test seam: run the structural preflight without the native loader, so per-model + // token/ONNX acceptance (e.g. Canary carries no blank token) is testable in isolation. + internal void RunArtifactPreflightForTests(string modelId, string modelDir) => + VerifyModelArtifacts(GetModelDefinition(modelId), modelDir); + private static ModelDefinition GetModelDefinition(string modelId) => s_models.FirstOrDefault(m => m.Id == modelId) ?? throw new ArgumentException($"Unknown model: {modelId}"); @@ -715,6 +778,259 @@ private void UnloadRecognizerUnsafe() _canaryTgtLang = "en"; } + private static void VerifyModelArtifacts(ModelDefinition model, string modelDir) + { + foreach (var file in model.Files) + VerifyModelArtifact( + Path.Join(modelDir, file.FileName), + file.FileName, + model.RequiresBlankToken + ); + } + + // Artifact guarantees: + // *.onnx — non-empty, well-framed top-level protobuf with a positive ONNX + // IR version and a non-empty GraphProto field. The graph's declared + // byte range must fit inside the file, which detects clean-EOF + // truncation without hashing or parsing hundreds of MB of tensors. + // tokens.txt — non-empty, strict UTF-8 token/id rows with non-negative unique IDs; + // transducer models (requireBlankToken) must also carry sherpa's blank + // symbol, which attention encoder-decoder models (Canary) do not use. + // These are structural gates, not authenticity checks; upstream publishes no hashes. + private static void VerifyModelArtifact(string path, string fileName, bool requireBlankToken) + { + if (fileName.EndsWith(".onnx", StringComparison.OrdinalIgnoreCase)) + { + VerifyOnnxProtobuf(path, fileName); + return; + } + + if (string.Equals(fileName, "tokens.txt", StringComparison.OrdinalIgnoreCase)) + { + VerifyTokensFile(path, fileName, requireBlankToken); + return; + } + + throw new InvalidDataException( + $"No structural verification is defined for model artifact '{fileName}'." + ); + } + + private static void VerifyOnnxProtobuf(string path, string fileName) + { + using var stream = File.OpenRead(path); + if (stream.Length == 0) + throw new InvalidDataException($"Model artifact '{fileName}' is empty."); + + var hasPositiveIrVersion = false; + var hasNonEmptyGraph = false; + + while (stream.Position < stream.Length) + { + var key = ReadProtobufVarint(stream, fileName); + var fieldNumber = key >> 3; + var wireType = key & 7; + if (fieldNumber == 0) + throw new InvalidDataException( + $"Model artifact '{fileName}' has an invalid protobuf field number." + ); + + switch (wireType) + { + case 0: + { + var value = ReadProtobufVarint(stream, fileName); + if (fieldNumber == 1 && value > 0) + hasPositiveIrVersion = true; + break; + } + case 1: + SkipProtobufBytes(stream, 8, fileName); + break; + case 2: + { + var length = ReadProtobufVarint(stream, fileName); + if (fieldNumber == 7 && length > 0) + hasNonEmptyGraph = true; + SkipProtobufBytes(stream, length, fileName); + break; + } + case 5: + SkipProtobufBytes(stream, 4, fileName); + break; + default: + throw new InvalidDataException( + $"Model artifact '{fileName}' uses an invalid top-level protobuf wire type." + ); + } + } + + if (!hasPositiveIrVersion || !hasNonEmptyGraph) + throw new InvalidDataException( + $"Model artifact '{fileName}' is not a structurally valid ONNX ModelProto." + ); + } + + private static ulong ReadProtobufVarint(Stream stream, string fileName) + { + ulong value = 0; + for (var i = 0; i < 10; i++) + { + var next = stream.ReadByte(); + if (next < 0) + throw new InvalidDataException( + $"Model artifact '{fileName}' ends inside a protobuf varint." + ); + + if (i == 9 && (next & 0xfe) != 0) + throw new InvalidDataException( + $"Model artifact '{fileName}' contains an oversized protobuf varint." + ); + + value |= (ulong)(next & 0x7f) << (i * 7); + if ((next & 0x80) == 0) + return value; + } + + throw new InvalidDataException( + $"Model artifact '{fileName}' contains an unterminated protobuf varint." + ); + } + + private static void SkipProtobufBytes(FileStream stream, ulong count, string fileName) + { + var remaining = stream.Length - stream.Position; + if (count > (ulong)remaining) + throw new InvalidDataException( + $"Model artifact '{fileName}' ends before its declared protobuf field length." + ); + + stream.Position += (long)count; + } + + private static void VerifyTokensFile(string path, string fileName, bool requireBlankToken) + { + if (new FileInfo(path).Length == 0) + throw new InvalidDataException($"Model artifact '{fileName}' is empty."); + + var ids = new HashSet(); + var rowCount = 0; + var hasBlankSymbol = false; + try + { + using var reader = new StreamReader( + path, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), + detectEncodingFromByteOrderMarks: true + ); + + while (reader.ReadLine() is { } line) + { + if (line.IndexOf('\0') >= 0) + throw new InvalidDataException( + $"Model artifact '{fileName}' contains a null character." + ); + + if (string.IsNullOrWhiteSpace(line)) + continue; + + var columns = line.Split( + (char[]?)null, + StringSplitOptions.RemoveEmptyEntries + ); + if ( + columns.Length != 2 + || !int.TryParse( + columns[^1], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var id + ) + || id < 0 + || !ids.Add(id) + ) + throw new InvalidDataException( + $"Model artifact '{fileName}' has an invalid token/id row." + ); + + hasBlankSymbol |= columns[0] is "" or "" or ""; + rowCount++; + } + } + catch (DecoderFallbackException ex) + { + throw new InvalidDataException( + $"Model artifact '{fileName}' is not valid UTF-8.", + ex + ); + } + + if (rowCount == 0) + throw new InvalidDataException( + $"Model artifact '{fileName}' contains no token/id rows." + ); + + if (requireBlankToken && !hasBlankSymbol) + throw new InvalidDataException( + $"Model artifact '{fileName}' does not contain a required blank token." + ); + } + + private static bool IsArtifactInvalidLoadFailure(Exception exception) + { + for (Exception? current = exception; current is not null; current = current.InnerException) + { + if (current is InvalidDataException) + return true; + + if ( + current is InvalidOperationException + && s_invalidModelLoadMessageFragments.Any( + fragment => current.Message.Contains( + fragment, + StringComparison.OrdinalIgnoreCase + ) + ) + ) + return true; + } + + return false; + } + + private void DeleteInvalidModelArtifacts( + ModelDefinition model, + string modelDir, + Exception failure + ) + { + var deleteFailures = new List(); + foreach (var file in model.Files) + { + var path = Path.Join(modelDir, file.FileName); + try + { + File.Delete(path); + } + catch (Exception ex) + { + deleteFailures.Add($"{file.FileName}: {ex.Message}"); + } + } + + _host?.Log( + PluginLogLevel.Warning, + $"sherpa-onnx rejected model '{model.Id}' as invalid ({failure.Message}); " + + "deleted its artifacts so it can be downloaded again." + ); + if (deleteFailures.Count > 0) + _host?.Log( + PluginLogLevel.Warning, + "Some invalid model artifacts could not be deleted: " + + string.Join("; ", deleteFailures) + ); + } + private static OfflineRecognizer CreateParakeetRecognizer(string modelDir, string provider) { var config = new OfflineRecognizerConfig(); @@ -891,7 +1207,7 @@ private static float[] DecodeWav(byte[] wavData) var pos = 12; // skip the leading RIFF/WAVE header while (pos + 8 < wavData.Length) { - var chunkId = System.Text.Encoding.ASCII.GetString(wavData, pos, 4); + var chunkId = Encoding.ASCII.GetString(wavData, pos, 4); var chunkSize = BitConverter.ToInt32(wavData, pos + 4); // chunkSize comes from untrusted WAV bytes — reject anything @@ -1003,6 +1319,10 @@ private sealed record ModelDefinition( int LanguageCount, bool IsRecommended, bool SupportsTranslation, + // Transducer/CTC models (Parakeet) carry a blank token in tokens.txt and sherpa's + // native reader requires it; attention encoder-decoder models (Canary) do not, so + // the token verifier must only demand a blank symbol when this is set. + bool RequiresBlankToken, IReadOnlyList Files ); diff --git a/tests/TypeWhisper.Core.Tests/ResilientDownloaderTests.cs b/tests/TypeWhisper.Core.Tests/ResilientDownloaderTests.cs index 33eb4ea3f..902cb0792 100644 --- a/tests/TypeWhisper.Core.Tests/ResilientDownloaderTests.cs +++ b/tests/TypeWhisper.Core.Tests/ResilientDownloaderTests.cs @@ -275,6 +275,77 @@ await Assert.ThrowsAsync(() => finally { Directory.Delete(dir, recursive: true); } } + [Fact] + public async Task AbsentLengthCleanEofWithoutVerifier_ThrowsIncompleteAndPublishesNothing() + { + var dir = NewTempDir(); + try + { + var body = MakeBody(); + var dest = Path.Join(dir, "asset.bin"); + var handler = new ScriptedHandler(body) { DeclareLength = false }; + using var client = new HttpClient(handler); + + var ex = await Assert.ThrowsAsync(() => + ResilientDownloader.DownloadToFileAsync( + client, Url, dest, + approxTotalBytes: null, idleTimeout: s_longIdle, allowResume: false, + onBytesOnDisk: null, verifyComplete: null, ct: CancellationToken.None)); + + Assert.Contains("cannot be verified", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(File.Exists(dest)); + Assert.Empty(Directory.GetFiles(dir, "*.partial")); + } + finally { Directory.Delete(dir, recursive: true); } + } + + [Fact] + public async Task AbsentLengthWithAcceptingVerifier_PublishesVerifiedFile() + { + var dir = NewTempDir(); + try + { + var body = MakeBody(); + var dest = Path.Join(dir, "asset.bin"); + var handler = new ScriptedHandler(body) { DeclareLength = false }; + using var client = new HttpClient(handler); + + await ResilientDownloader.DownloadToFileAsync( + client, Url, dest, + approxTotalBytes: null, idleTimeout: s_longIdle, allowResume: false, + onBytesOnDisk: null, verifyComplete: VerifyEquals(body), + ct: CancellationToken.None); + + Assert.Equal(body, await File.ReadAllBytesAsync(dest)); + Assert.Empty(Directory.GetFiles(dir, "*.partial")); + } + finally { Directory.Delete(dir, recursive: true); } + } + + [Fact] + public async Task AbsentLengthWithRejectingVerifier_DeletesPartialAndPublishesNothing() + { + var dir = NewTempDir(); + try + { + var body = MakeBody(); + var dest = Path.Join(dir, "asset.bin"); + var handler = new ScriptedHandler(body) { DeclareLength = false }; + using var client = new HttpClient(handler); + Action reject = _ => throw new InvalidDataException("Invalid artifact."); + + await Assert.ThrowsAsync(() => + ResilientDownloader.DownloadToFileAsync( + client, Url, dest, + approxTotalBytes: null, idleTimeout: s_longIdle, allowResume: false, + onBytesOnDisk: null, verifyComplete: reject, ct: CancellationToken.None)); + + Assert.False(File.Exists(dest)); + Assert.Empty(Directory.GetFiles(dir, "*.partial")); + } + finally { Directory.Delete(dir, recursive: true); } + } + [Fact] public async Task ChecksumMismatch_Propagates_AndDeletesPartial() { @@ -380,6 +451,7 @@ Task Download() => ResilientDownloader.DownloadToFileAsync( private sealed class ScriptedHandler(byte[] body) : HttpMessageHandler { public bool HonorRange { get; set; } = true; + public bool DeclareLength { get; set; } = true; public Func? WrapStream { get; set; } public int RequestCount { get; private set; } public List ReceivedRanges { get; } = []; @@ -429,7 +501,12 @@ protected override Task SendAsync( var content = new StreamContent(stream); // Declare the FULL slice length even when a WrapStream serves fewer bytes, so // a truncated body trips the completeness check. - content.Headers.ContentLength = slice.Length; + if (DeclareLength) + content.Headers.ContentLength = slice.Length; + else + // StreamContent can infer length from a seekable MemoryStream; suppress it + // to model HTTP/2/chunked/proxy responses with no declared total. + content.Headers.ContentLength = null; if (contentRange is not null) content.Headers.ContentRange = contentRange; diff --git a/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs index ee69592eb..be0bacb42 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs @@ -620,6 +620,77 @@ public async Task LoadModelAsync_BackendSwitchedDuringProvision_AbortsLoad() Assert.Contains("Compute backend changed", ex.Message); } + [Fact] + public async Task LoadModelAsync_NativeParseFailure_DeletesArtifactsAndMarksModelFetchable() + { + using var temp = new TempAssetDir(); + var host = CreateHostMock(temp.Path); + using var plugin = new SherpaOnnxPlugin(); + plugin.SetHostForTests(host.Object); + WriteParakeetModelFiles(temp.Path); + plugin.SetParakeetRecognizerFactoryForTests( + (_, _) => throw new InvalidOperationException( + "Failed to load model because protobuf parsing failed." + ) + ); + + Assert.True(plugin.IsModelDownloaded("parakeet-tdt-0.6b")); + + var ex = await Assert.ThrowsAsync( + () => plugin.LoadModelAsync("parakeet-tdt-0.6b", CancellationToken.None) + ); + + Assert.Contains("protobuf parsing failed", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(plugin.IsModelDownloaded("parakeet-tdt-0.6b")); + Assert.Empty( + Directory.GetFiles(Path.Join(temp.Path, "Models", "parakeet-tdt-0.6b")) + ); + } + + [Fact] + public async Task LoadModelAsync_EnvironmentFailure_PreservesDownloadedArtifacts() + { + using var temp = new TempAssetDir(); + var host = CreateHostMock(temp.Path); + using var plugin = new SherpaOnnxPlugin(); + plugin.SetHostForTests(host.Object); + WriteParakeetModelFiles(temp.Path); + plugin.SetParakeetRecognizerFactoryForTests( + (_, _) => throw new InvalidOperationException( + "CUDA execution provider failed to initialize because libcudnn was unavailable." + ) + ); + + var ex = await Assert.ThrowsAsync( + () => plugin.LoadModelAsync("parakeet-tdt-0.6b", CancellationToken.None) + ); + + Assert.Contains("CUDA execution provider", ex.Message); + Assert.True(plugin.IsModelDownloaded("parakeet-tdt-0.6b")); + Assert.Equal( + 4, + Directory.GetFiles(Path.Join(temp.Path, "Models", "parakeet-tdt-0.6b")).Length + ); + } + + [Fact] + public void ArtifactPreflight_CanaryTokensWithoutBlank_AcceptedButStillStructurallyChecked() + { + using var temp = new TempAssetDir(); + using var plugin = new SherpaOnnxPlugin(); + var dir = WriteCanaryModelFiles(temp.Path); + + // Canary (attention encoder-decoder) has no blank token; preflight must accept + // it, or a blank requirement would fail every Canary download and delete caches. + plugin.RunArtifactPreflightForTests("canary-180m-flash", dir); + + // The blank exemption must not switch off the remaining token checks. + File.WriteAllText(Path.Join(dir, "tokens.txt"), " 0\n not-an-id\n"); + Assert.Throws( + () => plugin.RunArtifactPreflightForTests("canary-180m-flash", dir) + ); + } + private static Mock CreateHostMock(string assetDir) { var host = new Mock(); @@ -628,15 +699,37 @@ private static Mock CreateHostMock(string assetDir) return host; } + private static string WriteCanaryModelFiles(string assetDir) + { + var dir = Path.Join(assetDir, "Models", "canary-180m-flash"); + Directory.CreateDirectory(dir); + foreach (var fileName in new[] { "encoder.int8.onnx", "decoder.int8.onnx" }) + File.WriteAllBytes(Path.Join(dir, fileName), [0x08, 0x09, 0x3a, 0x02, 0x12, 0x00]); + + // Real Canary vocab uses //<|...|> tokens and no transducer blank symbol. + File.WriteAllText(Path.Join(dir, "tokens.txt"), " 0\nfoo 1\n"); + return dir; + } + private static void WriteParakeetModelFiles(string assetDir) { var dir = Path.Join(assetDir, "Models", "parakeet-tdt-0.6b"); Directory.CreateDirectory(dir); - foreach (var f in new[] + foreach (var fileName in new[] { - "encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt", + "encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", }) - File.WriteAllText(Path.Join(dir, f), "dummy"); + { + // Minimal ONNX protobuf framing (ir_version=9, non-empty graph) for the + // structural preflight; the injected recognizer factory means these bytes + // never reach the native loader. + File.WriteAllBytes( + Path.Join(dir, fileName), + [0x08, 0x09, 0x3a, 0x02, 0x12, 0x00] + ); + } + + File.WriteAllText(Path.Join(dir, "tokens.txt"), " 0\n"); } private sealed class SherpaBlockingProvisioner : SherpaCuda.CudaRuntimeProvisioner From fa622db567f294d81c04794e5fcf2491691cb075 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 20:49:23 +0000 Subject: [PATCH 164/226] Define and enforce a grammar for plugin selection identifiers The SDK documented transcription and LLM selection IDs only as stable strings, but the host embeds the effective ID unescaped in its persisted plugin:: identifier and parses the next colon as the ID's end. An external provider ID like server:work produced visible, persistable model identifiers the host could never resolve back to the provider. Both identity interfaces now document the grammar [A-Za-z0-9._-]+ and PluginSelectionExtensions enforces it: empty or whitespace custom identities fall back to PluginId and the effective ID is then validated, with a public IsValidSelectionId helper for plugin authors. PluginManager's capability-index rebuild validates every primary and additional transcription/LLM role individually - an invalid identifier skips that provider with a trace and a role-specific error-log entry while siblings continue indexing, matching the file's per-plugin isolation idiom. All 32 bundled plugin IDs and the only in-tree custom identity conform to the grammar; the persisted format is unchanged. --- .../Services/Plugins/PluginManager.cs | 106 ++++++++-- .../ILlmProviderSelectionIdentity.cs | 10 +- .../ITranscriptionEngineSelectionIdentity.cs | 10 +- .../PluginSelectionExtensions.cs | 83 ++++++-- .../PluginManagerTests.cs | 199 ++++++++++++++++++ 5 files changed, 375 insertions(+), 33 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs index e2e0780cf..b429b967a 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs @@ -777,28 +777,33 @@ private void RebuildCapabilityIndices() // (e.g. OpenAI-compatible profiles), then de-dup by selection ID so a // role and the plugin's own default never collide. GroupBy().First() // keeps the first occurrence — the plugin's primary role is enumerated - // before its additional roles. - _llmProviders = activePlugins - .OfType() - .Concat( + // before its additional roles. Resolve and validate every effective ID + // before grouping so one malformed external role cannot poison the rebuild. + _llmProviders = ValidLlmProviders( activePlugins - // ReSharper disable once SuspiciousTypeConversion.Global -- plugin instances are loaded from external assemblies (AssemblyLoadContext) that implement this capability interface; the cross-assembly implementer is not visible in-solution. - .OfType() - .SelectMany(SafeAdditionalLlmProviders) + .OfType() + .Concat( + activePlugins + // ReSharper disable once SuspiciousTypeConversion.Global -- plugin instances are loaded from external assemblies (AssemblyLoadContext) that implement this capability interface; the cross-assembly implementer is not visible in-solution. + .OfType() + .SelectMany(SafeAdditionalLlmProviders) + ) ) - .GroupBy(p => p.GetLlmSelectionId(), StringComparer.Ordinal) - .Select(group => group.First()) + .GroupBy(entry => entry.SelectionId, StringComparer.Ordinal) + .Select(group => group.First().Provider) .ToList(); - _transcriptionEngines = activePlugins - .OfType() - .Concat( + _transcriptionEngines = ValidTranscriptionEngines( activePlugins - // ReSharper disable once SuspiciousTypeConversion.Global -- plugin instances are loaded from external assemblies (AssemblyLoadContext) that implement this capability interface; the cross-assembly implementer is not visible in-solution. - .OfType() - .SelectMany(SafeAdditionalTranscriptionEngines) + .OfType() + .Concat( + activePlugins + // ReSharper disable once SuspiciousTypeConversion.Global -- plugin instances are loaded from external assemblies (AssemblyLoadContext) that implement this capability interface; the cross-assembly implementer is not visible in-solution. + .OfType() + .SelectMany(SafeAdditionalTranscriptionEngines) + ) ) - .GroupBy(p => p.GetTranscriptionSelectionId(), StringComparer.Ordinal) - .Select(group => group.First()) + .GroupBy(entry => entry.SelectionId, StringComparer.Ordinal) + .Select(group => group.First().Provider) .ToList(); _postProcessors = activePlugins .OfType() @@ -812,6 +817,73 @@ private void RebuildCapabilityIndices() PluginStateChanged?.Invoke(this, EventArgs.Empty); } + private IEnumerable<(ILlmProviderPlugin Provider, string SelectionId)> ValidLlmProviders( + IEnumerable providers + ) + { + foreach (var provider in providers) + { + string selectionId; + try + { + selectionId = provider.GetLlmSelectionId(); + } + catch (Exception ex) + { + LogInvalidSelectionId( + "LLM provider", + provider, + ex, + ErrorCategory.Prompt + ); + continue; + } + + yield return (provider, selectionId); + } + } + + private IEnumerable<( + ITranscriptionEnginePlugin Provider, + string SelectionId + )> ValidTranscriptionEngines(IEnumerable providers) + { + foreach (var provider in providers) + { + string selectionId; + try + { + selectionId = provider.GetTranscriptionSelectionId(); + } + catch (Exception ex) + { + LogInvalidSelectionId( + "transcription engine", + provider, + ex, + ErrorCategory.Transcription + ); + continue; + } + + yield return (provider, selectionId); + } + } + + private void LogInvalidSelectionId( + string providerRole, + object provider, + Exception exception, + string errorCategory + ) + { + var message = + $"Skipping {providerRole} '{provider.GetType().Name}' because its effective " + + $"selection ID is invalid: {exception.Message}"; + Trace.WriteLine($"[PluginManager] {message}"); + _errorLog?.AddEntry(message, errorCategory); + } + // A misbehaving third-party plugin must not be able to abort the whole // capability rebuild: materialize each provider's additional roles inside a // try/catch so a throwing getter (or one that throws mid-enumeration) just diff --git a/src/TypeWhisper.PluginSDK/ILlmProviderSelectionIdentity.cs b/src/TypeWhisper.PluginSDK/ILlmProviderSelectionIdentity.cs index 14df16b6c..1f78ce3ad 100644 --- a/src/TypeWhisper.PluginSDK/ILlmProviderSelectionIdentity.cs +++ b/src/TypeWhisper.PluginSDK/ILlmProviderSelectionIdentity.cs @@ -4,11 +4,17 @@ namespace TypeWhisper.PluginSDK; /// -/// Optional stable selection identity for LLM provider roles. +/// Optional stable selection identity for LLM provider roles. Selection IDs must contain +/// only ASCII letters, ASCII digits, dots, dashes, and underscores +/// ([A-Za-z0-9._-]+). /// // ReSharper disable once UnusedType.Global public interface ILlmProviderSelectionIdentity { - /// Stable identifier used in plugin LLM selection IDs. + /// + /// Stable identifier used in plugin LLM selection IDs. A null, empty, or + /// whitespace-only value is treated as absent and falls back to the plugin ID; + /// the resulting effective ID must match [A-Za-z0-9._-]+. + /// string LlmSelectionId { get; } } diff --git a/src/TypeWhisper.PluginSDK/ITranscriptionEngineSelectionIdentity.cs b/src/TypeWhisper.PluginSDK/ITranscriptionEngineSelectionIdentity.cs index a926af0f9..3ceb8114d 100644 --- a/src/TypeWhisper.PluginSDK/ITranscriptionEngineSelectionIdentity.cs +++ b/src/TypeWhisper.PluginSDK/ITranscriptionEngineSelectionIdentity.cs @@ -4,11 +4,17 @@ namespace TypeWhisper.PluginSDK; /// -/// Optional stable selection identity for transcription engine roles. +/// Optional stable selection identity for transcription engine roles. Selection IDs must +/// contain only ASCII letters, ASCII digits, dots, dashes, and underscores +/// ([A-Za-z0-9._-]+). /// // ReSharper disable once UnusedType.Global public interface ITranscriptionEngineSelectionIdentity { - /// Stable identifier used in plugin model selection IDs. + /// + /// Stable identifier used in plugin model selection IDs. A null, empty, or + /// whitespace-only value is treated as absent and falls back to the plugin ID; + /// the resulting effective ID must match [A-Za-z0-9._-]+. + /// string TranscriptionSelectionId { get; } } diff --git a/src/TypeWhisper.PluginSDK/PluginSelectionExtensions.cs b/src/TypeWhisper.PluginSDK/PluginSelectionExtensions.cs index a1639201d..cc32fcb6f 100644 --- a/src/TypeWhisper.PluginSDK/PluginSelectionExtensions.cs +++ b/src/TypeWhisper.PluginSDK/PluginSelectionExtensions.cs @@ -13,27 +13,86 @@ namespace TypeWhisper.PluginSDK; // ReSharper disable once UnusedType.Global public static class PluginSelectionExtensions { + private const string InvalidSelectionIdMessage = + "Effective selection IDs must be non-empty and contain only ASCII letters, " + + "ASCII digits, dots, dashes, and underscores ([A-Za-z0-9._-]+)."; + /// /// Returns the selection ID for a transcription engine role. - /// Existing providers default to their plugin ID. + /// Existing providers and empty or whitespace-only custom identities default to their + /// plugin ID. /// + /// + /// The effective selection ID does not match [A-Za-z0-9._-]+. + /// // ReSharper disable once UnusedMember.Global // ReSharper disable once UnusedParameter.Global - public static string GetTranscriptionSelectionId(this ITranscriptionEnginePlugin plugin) => - plugin is ITranscriptionEngineSelectionIdentity identity - && !string.IsNullOrWhiteSpace(identity.TranscriptionSelectionId) - ? identity.TranscriptionSelectionId - : plugin.PluginId; + public static string GetTranscriptionSelectionId(this ITranscriptionEnginePlugin plugin) + { + var customSelectionId = plugin is ITranscriptionEngineSelectionIdentity identity + ? identity.TranscriptionSelectionId + : null; + var selectionId = string.IsNullOrWhiteSpace(customSelectionId) + ? plugin.PluginId + : customSelectionId; + return ValidateSelectionId(selectionId); + } /// /// Returns the selection ID for an LLM provider role. - /// Existing providers default to their plugin ID. + /// Existing providers and empty or whitespace-only custom identities default to their + /// plugin ID. /// + /// + /// The effective selection ID does not match [A-Za-z0-9._-]+. + /// // ReSharper disable once UnusedMember.Global // ReSharper disable once UnusedParameter.Global - public static string GetLlmSelectionId(this ILlmProviderPlugin plugin) => - plugin is ILlmProviderSelectionIdentity identity - && !string.IsNullOrWhiteSpace(identity.LlmSelectionId) - ? identity.LlmSelectionId - : plugin.PluginId; + public static string GetLlmSelectionId(this ILlmProviderPlugin plugin) + { + var customSelectionId = plugin is ILlmProviderSelectionIdentity identity + ? identity.LlmSelectionId + : null; + var selectionId = string.IsNullOrWhiteSpace(customSelectionId) + ? plugin.PluginId + : customSelectionId; + return ValidateSelectionId(selectionId); + } + + /// + /// Returns whether an effective selection ID matches [A-Za-z0-9._-]+. + /// + // ReSharper disable once UnusedMember.Global + // ReSharper disable once MemberCanBePrivate.Global -- public plugin-SDK surface; external plugin authors call it to pre-validate custom selection IDs against the Get* methods' documented contract. + public static bool IsValidSelectionId(string? selectionId) + { + if (string.IsNullOrEmpty(selectionId)) + { + return false; + } + + foreach (var character in selectionId) + { + if ( + character is not (>= 'A' and <= 'Z') + and not (>= 'a' and <= 'z') + and not (>= '0' and <= '9') + and not '.' + and not '-' + and not '_' + ) + { + return false; + } + } + + return true; + } + + private static string ValidateSelectionId(string selectionId) + { + return IsValidSelectionId(selectionId) + ? selectionId + : throw new InvalidOperationException(InvalidSelectionIdMessage); + } } diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs index affca76f6..9c00bc3f2 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs @@ -2,6 +2,7 @@ using System.Reflection; using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services; using TypeWhisper.Linux.Services.Plugins; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Models; @@ -181,6 +182,7 @@ public sealed class PluginManagerWithFakePluginTests : IDisposable private static readonly TimeSpan s_outerTimeout = TimeSpan.FromSeconds(2); private readonly Mock _activeWindow = new(); + private readonly Mock _errorLog = new(); private readonly PluginEventBus _eventBus = new(); private readonly Mock _profiles = new(); private readonly Mock _settings = new(); @@ -249,6 +251,122 @@ public async Task DisablePluginAsync_NotActivated_PersistsDisabledState() Assert.Null(savedSettings); } + [Fact] + public async Task CapabilityIndices_ValidCustomTranscriptionId_RoundTripsWhileColonSiblingIsRejected() + { + const string validSelectionId = "server.work_1"; + var invalidProvider = new IdentifiedTranscriptionPlugin( + "com.test.invalid-transcription", + "server:work" + ); + var validSibling = new IdentifiedTranscriptionPlugin( + "com.test.valid-transcription", + validSelectionId + ); + + var manager = await CreateManagerAsync(invalidProvider, validSibling); + + Assert.DoesNotContain(invalidProvider, manager.TranscriptionEngines); + Assert.Contains(validSibling, manager.TranscriptionEngines); + _errorLog.Verify( + log => log.AddEntry( + It.Is(message => + message.Contains("Skipping transcription engine", StringComparison.Ordinal) + && message.Contains("[A-Za-z0-9._-]+", StringComparison.Ordinal) + ), + ErrorCategory.Transcription + ), + Times.AtLeastOnce + ); + + var persistedId = ModelManagerService.GetPluginModelId( + validSibling.GetTranscriptionSelectionId(), + validSibling.TranscriptionModels[0].Id + ); + var parsedId = ModelManagerService.ParsePluginModelId(persistedId); + var resolvedProvider = Assert.Single(manager.TranscriptionEngines, provider => + provider.GetTranscriptionSelectionId() == parsedId.PluginId + ); + + Assert.Equal(validSelectionId, parsedId.PluginId); + Assert.Equal(validSibling.TranscriptionModels[0].Id, parsedId.ModelId); + Assert.Same(validSibling, resolvedProvider); + } + + [Fact] + public async Task CapabilityIndices_PluginIdFallbackLlmRoleRoundTripsWhileColonSiblingIsRejected() + { + var invalidProvider = new IdentifiedLlmPlugin( + "com.test.invalid-llm", + "server:work" + ); + var validSibling = new FakeLlmPlugin("com.test.valid-llm"); + + var manager = await CreateManagerAsync(invalidProvider, validSibling); + + Assert.DoesNotContain(invalidProvider, manager.LlmProviders); + Assert.Contains(validSibling, manager.LlmProviders); + Assert.Equal(validSibling.PluginId, validSibling.GetLlmSelectionId()); + _errorLog.Verify( + log => log.AddEntry( + It.Is(message => + message.Contains("Skipping LLM provider", StringComparison.Ordinal) + && message.Contains("[A-Za-z0-9._-]+", StringComparison.Ordinal) + ), + ErrorCategory.Prompt + ), + Times.AtLeastOnce + ); + + var persistedId = ModelManagerService.GetPluginModelId( + validSibling.GetLlmSelectionId(), + validSibling.SupportedModels[0].Id + ); + var parsedId = ModelManagerService.ParsePluginModelId(persistedId); + var resolvedProvider = Assert.Single(manager.LlmProviders, provider => + provider.GetLlmSelectionId() == parsedId.PluginId + ); + + Assert.Equal(validSibling.PluginId, parsedId.PluginId); + Assert.Equal(validSibling.SupportedModels[0].Id, parsedId.ModelId); + Assert.Same(validSibling, resolvedProvider); + } + + [Theory] + [InlineData("")] + [InlineData(" \t")] + public async Task CapabilityIndices_EmptyOrWhitespaceCustomId_FallsBackThenValidatesPluginId( + string customSelectionId + ) + { + var invalidFallback = new IdentifiedTranscriptionPlugin( + "invalid:fallback", + customSelectionId + ); + var validFallback = new IdentifiedTranscriptionPlugin( + "com.test.valid_fallback", + customSelectionId + ); + + var manager = await CreateManagerAsync(invalidFallback, validFallback); + + Assert.DoesNotContain(invalidFallback, manager.TranscriptionEngines); + Assert.Contains(validFallback, manager.TranscriptionEngines); + Assert.Equal( + validFallback.PluginId, + validFallback.GetTranscriptionSelectionId() + ); + _errorLog.Verify( + log => log.AddEntry( + It.Is(message => + message.Contains("Skipping transcription engine", StringComparison.Ordinal) + ), + ErrorCategory.Transcription + ), + Times.AtLeastOnce + ); + } + [Fact] public async Task Dispose_HangingDeactivation_ReturnsAndShutsDownLaterPlugin() { @@ -346,6 +464,7 @@ params ITypeWhisperPlugin[] plugins _profiles.Object, _settings.Object, [], + errorLog: _errorLog.Object, pluginShutdownTimeout: s_shutdownTimeout ); @@ -387,6 +506,86 @@ private static List GetLoadedPlugins(PluginManager manager) return (List)field.GetValue(manager)!; } + private abstract class FakeCapabilityPlugin(string pluginId) : ITypeWhisperPlugin + { + public string PluginId { get; } = pluginId; + public string PluginName => PluginId; + public string PluginVersion => "1.0.0"; + + public Task ActivateAsync(IPluginHostServices host) => Task.CompletedTask; + + public Task DeactivateAsync() => Task.CompletedTask; + + public void Dispose() { } + } + + private class FakeTranscriptionPlugin(string pluginId) + : FakeCapabilityPlugin(pluginId), + ITranscriptionEnginePlugin + { + public string ProviderId => PluginId; + public string ProviderDisplayName => PluginName; + public bool IsConfigured => true; + public IReadOnlyList TranscriptionModels { get; } = + [new("model:version-1", "Test model")]; + // ReSharper disable once ReturnTypeCanBeNotNullable -- implements ITranscriptionEnginePlugin.SelectedModelId, whose contract is nullable. + public string? SelectedModelId => TranscriptionModels[0].Id; + public bool SupportsTranslation => false; + + public void SelectModel(string modelId) { } + + public Task TranscribeAsync( + byte[] wavAudio, + string? language, + bool translate, + string? prompt, + CancellationToken ct + ) + { + return Task.FromResult(new PluginTranscriptionResult("", null, 0, null)); + } + } + + private sealed class IdentifiedTranscriptionPlugin( + string pluginId, + string customSelectionId + ) + : FakeTranscriptionPlugin(pluginId), + ITranscriptionEngineSelectionIdentity + { + public string TranscriptionSelectionId { get; } = customSelectionId; + } + + private class FakeLlmPlugin(string pluginId) + : FakeCapabilityPlugin(pluginId), + ILlmProviderPlugin + { + public string ProviderName => PluginName; + public bool IsAvailable => true; + public IReadOnlyList SupportedModels { get; } = + [new("model:version-1", "Test model")]; + + public Task ProcessAsync( + string systemPrompt, + string userText, + string model, + CancellationToken ct + ) + { + return Task.FromResult(""); + } + } + + private sealed class IdentifiedLlmPlugin( + string pluginId, + string customSelectionId + ) + : FakeLlmPlugin(pluginId), + ILlmProviderSelectionIdentity + { + public string LlmSelectionId { get; } = customSelectionId; + } + private sealed class LifecyclePlugin( string pluginId, Func deactivateAsync From da5ace72eceefc53f6fc12993ee6aade1277db3b Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 21:00:19 +0000 Subject: [PATCH 165/226] Fail closed on plugin minimum-host-version compatibility Plugin manifests document MinHostVersion as SemVer, but registry filtering compared it with System.Version against AssemblyVersion - discarding prerelease information - and treated every parse failure as compatible; the common loader never checked MinHostVersion at all. A registry, manually installed, or retained-after-downgrade plugin could be constructed against host behavior it explicitly declared unsupported, and a prerelease minimum like 0.13.0-rc.2 was never honored. AppVersion gains a strict SemVer surface alongside its untouched tolerant APIs: TryParseStrict rejects malformed input instead of zero-filling, comparison follows SemVer prerelease precedence and ignores build metadata, and IsHostCompatible encodes the fail-closed rule - null/blank minimum is compatible, a malformed minimum or host version is incompatible with a logged reason, and otherwise the strict comparison decides. Registry filtering and the common loader both enforce it; the loader checks after manifest deserialization and before assembly load, so an incompatible plugin becomes a recorded load failure and its constructor never runs. The update checker's tolerant comparisons are unchanged, and no bundled manifest declares a minimum today. --- src/TypeWhisper.Linux/Services/AppVersion.cs | 230 +++++++++++++++++- .../Services/Plugins/PluginLoader.cs | 17 ++ .../Services/Plugins/PluginRegistryService.cs | 30 ++- .../AppVersionCompareTests.cs | 63 +++++ .../PluginLoaderTests.cs | 148 +++++++++++ .../PluginRegistryServiceTests.cs | 100 ++++++++ 6 files changed, 574 insertions(+), 14 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/AppVersion.cs b/src/TypeWhisper.Linux/Services/AppVersion.cs index c80a0ef84..bcf5ea5eb 100644 --- a/src/TypeWhisper.Linux/Services/AppVersion.cs +++ b/src/TypeWhisper.Linux/Services/AppVersion.cs @@ -8,6 +8,13 @@ namespace TypeWhisper.Linux.Services; /// public static class AppVersion { + internal readonly record struct StrictSemanticVersion( + string Major, + string Minor, + string Patch, + IReadOnlyList PreRelease + ); + /// /// Display version, e.g. "0.5.0" or "0.5.0-rc.1". Uses AssemblyInformationalVersion /// so pre-release suffixes survive (AssemblyVersion silently drops them); the +hash @@ -58,6 +65,119 @@ public static int Compare(string? a, string? b) return ComparePreRelease(preA, preB); } + /// + /// Parses a strict SemVer 2.0 version: exactly major.minor.patch, with optional + /// pre-release and build metadata. Leading zeroes in numeric core or pre-release + /// identifiers and malformed/empty identifiers are rejected. + /// + internal static bool TryParseStrict(string? raw, out StrictSemanticVersion version) + { + version = default; + if (string.IsNullOrEmpty(raw)) + { + return false; + } + + var versionPart = raw; + var plus = versionPart.IndexOf('+'); + if (plus >= 0) + { + if ( + versionPart.IndexOf('+', plus + 1) >= 0 + || !AreValidIdentifiers(versionPart[(plus + 1)..], false) + ) + { + return false; + } + + versionPart = versionPart[..plus]; + } + + var preRelease = Array.Empty(); + var dash = versionPart.IndexOf('-'); + if (dash >= 0) + { + var rawPreRelease = versionPart[(dash + 1)..]; + if (!AreValidIdentifiers(rawPreRelease, true)) + { + return false; + } + + preRelease = rawPreRelease.Split('.'); + versionPart = versionPart[..dash]; + } + + var core = versionPart.Split('.'); + if ( + core.Length != 3 + || !IsValidCoreIdentifier(core[0]) + || !IsValidCoreIdentifier(core[1]) + || !IsValidCoreIdentifier(core[2]) + ) + { + return false; + } + + version = new StrictSemanticVersion(core[0], core[1], core[2], preRelease); + return true; + } + + /// + /// Strictly parses and compares two SemVer 2.0 versions. Returns false when either + /// input is malformed; otherwise comparison is <0/0/>0 for older/equal/newer. + /// + internal static bool TryCompareStrict(string? a, string? b, out int comparison) + { + comparison = 0; + if (!TryParseStrict(a, out var parsedA) || !TryParseStrict(b, out var parsedB)) + { + return false; + } + + comparison = CompareStrict(parsedA, parsedB); + return true; + } + + /// + /// Applies the plugin minimum-host rule. A blank minimum accepts any host; + /// malformed non-blank minima and hosts fail closed. + /// + internal static bool IsHostCompatible( + string? minimumHostVersion, + string hostVersion, + out string reason + ) + { + if (string.IsNullOrWhiteSpace(minimumHostVersion)) + { + reason = string.Empty; + return true; + } + + if (!TryParseStrict(minimumHostVersion, out var minimum)) + { + reason = $"Minimum host version '{minimumHostVersion}' is not valid SemVer."; + return false; + } + + if (!TryParseStrict(hostVersion, out var host)) + { + reason = + $"Host version '{hostVersion}' is not valid SemVer, so compatibility cannot be verified."; + return false; + } + + if (CompareStrict(host, minimum) >= 0) + { + reason = string.Empty; + return true; + } + + reason = + $"Requires host version '{minimumHostVersion}' or later; current host version is '{hostVersion}'."; + return false; + } + private static string Resolve() { var asm = Assembly.GetExecutingAssembly(); @@ -73,6 +193,114 @@ private static string Resolve() return plus >= 0 ? info[..plus] : info; } + private static int CompareStrict(StrictSemanticVersion a, StrictSemanticVersion b) + { + var core = CompareNumericIdentifier(a.Major, b.Major); + if (core == 0) + { + core = CompareNumericIdentifier(a.Minor, b.Minor); + } + + if (core == 0) + { + core = CompareNumericIdentifier(a.Patch, b.Patch); + } + + if (core != 0) + { + return core; + } + + if (a.PreRelease.Count == 0 && b.PreRelease.Count == 0) + { + return 0; + } + + if (a.PreRelease.Count == 0) + { + return 1; + } + + if (b.PreRelease.Count == 0) + { + return -1; + } + + var shared = Math.Min(a.PreRelease.Count, b.PreRelease.Count); + for (var i = 0; i < shared; i++) + { + var aIdentifier = a.PreRelease[i]; + var bIdentifier = b.PreRelease[i]; + var aNumeric = IsAsciiDigits(aIdentifier); + var bNumeric = IsAsciiDigits(bIdentifier); + + var identifier = (aNumeric, bNumeric) switch + { + (true, true) => CompareNumericIdentifier(aIdentifier, bIdentifier), + (true, _) => -1, + (_, true) => 1, + _ => string.CompareOrdinal(aIdentifier, bIdentifier), + }; + if (identifier != 0) + { + return identifier; + } + } + + return a.PreRelease.Count.CompareTo(b.PreRelease.Count); + } + + private static int CompareNumericIdentifier(string a, string b) + { + var length = a.Length.CompareTo(b.Length); + return length != 0 ? length : string.CompareOrdinal(a, b); + } + + private static bool IsValidCoreIdentifier(string value) + { + return IsAsciiDigits(value) && (value.Length == 1 || value[0] != '0'); + } + + private static bool AreValidIdentifiers(string value, bool rejectNumericLeadingZeroes) + { + if (value.Length == 0) + { + return false; + } + + foreach (var identifier in value.Split('.')) + { + if ( + identifier.Length == 0 + || !identifier.All(IsSemVerIdentifierCharacter) + || ( + rejectNumericLeadingZeroes + && identifier.Length > 1 + && identifier[0] == '0' + && IsAsciiDigits(identifier) + ) + ) + { + return false; + } + } + + return true; + } + + private static bool IsAsciiDigits(string value) + { + return value.Length > 0 && value.All(c => c is >= '0' and <= '9'); + } + + private static bool IsSemVerIdentifierCharacter(char value) + { + return value is >= '0' and <= '9' + or >= 'A' and <= 'Z' + or >= 'a' and <= 'z' + or '-'; + } + /// /// SemVer 2.0 §11 pre-release comparison: dot-separated identifiers left-to-right; /// numeric identifiers compared numerically and rank below alphanumeric; @@ -149,4 +377,4 @@ private static (Version Core, string PreRelease) Split(string? raw) return (new Version(nums[0], nums[1], nums[2]), pre); } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs index e8a935fb0..4a65885cf 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs @@ -78,6 +78,8 @@ public PluginLoader(string pluginDataRoot) } public IReadOnlyList LastLoadFailures => _lastLoadFailures; + // Internal deterministic seam for compatibility tests; production uses informational SemVer. + internal string HostVersion { get; init; } = AppVersion.Display; internal string PluginDataRoot { get; } public List DiscoverAndLoad(IEnumerable searchDirectories) @@ -144,6 +146,21 @@ public List DiscoverAndLoad(IEnumerable searchDirectories) return null; } + if ( + !AppVersion.IsHostCompatible( + manifest.MinHostVersion, + HostVersion, + out var incompatibilityReason + ) + ) + { + var message = + $"Plugin '{manifest.Id}' is incompatible with this host: {incompatibilityReason}"; + _lastLoadFailures.Add(new PluginLoadFailure(pluginDir, message)); + Trace.WriteLine($"[PluginLoader] {message}"); + return null; + } + var assemblyPath = Path.Join(pluginDir, manifest.AssemblyName); if (!File.Exists(assemblyPath)) { diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs index c04749141..2d954f04f 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.IO.Compression; -using System.Reflection; using System.Text.Json; using TypeWhisper.Core; using TypeWhisper.Core.Interfaces; @@ -77,6 +76,9 @@ public PluginRegistryService( _httpClient = httpClient ?? new HttpClient(); } + // Internal deterministic seam for compatibility tests; production uses informational SemVer. + internal string HostVersion { get; init; } = AppVersion.Display; + public async Task> FetchRegistryAsync( CancellationToken ct = default ) @@ -92,10 +94,9 @@ public async Task> FetchRegistryAsync( var allPlugins = JsonSerializer.Deserialize>(json, s_jsonOptions) ?? []; - var hostVersion = GetHostVersion(); _cachedRegistry = allPlugins .Where(p => s_supportedPluginIds.Contains(p.Id)) - .Where(p => IsCompatible(p.MinHostVersion, hostVersion)) + .Where(IsCompatible) .ToList(); _cacheTimestamp = DateTime.UtcNow; @@ -329,19 +330,22 @@ public async Task FirstRunAutoInstallAsync(CancellationToken ct = default) } } - private static Version GetHostVersion() - { - var asm = Assembly.GetEntryAssembly(); - return asm?.GetName().Version ?? new Version(1, 0); - } - - private static bool IsCompatible(string? minHostVersion, Version hostVersion) + private bool IsCompatible(RegistryPlugin plugin) { - if (string.IsNullOrEmpty(minHostVersion)) + if ( + AppVersion.IsHostCompatible( + plugin.MinHostVersion, + HostVersion, + out var incompatibilityReason + ) + ) { return true; } - return !Version.TryParse(minHostVersion, out var minVer) || hostVersion >= minVer; + Trace.WriteLine( + $"[PluginRegistry] Excluding incompatible plugin '{plugin.Id}': {incompatibilityReason}" + ); + return false; } -} \ No newline at end of file +} diff --git a/tests/TypeWhisper.Linux.Tests/AppVersionCompareTests.cs b/tests/TypeWhisper.Linux.Tests/AppVersionCompareTests.cs index 68b946a17..d2397b6e3 100644 --- a/tests/TypeWhisper.Linux.Tests/AppVersionCompareTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AppVersionCompareTests.cs @@ -44,4 +44,67 @@ public void Compare_TreatsNullOrEmpty_AsZeroVersion() Assert.True(AppVersion.Compare("0.1.0", "") > 0); Assert.Equal(0, AppVersion.Compare(null, "")); } + + [Theory] + [InlineData("0.13.0")] + [InlineData("0.13.0-rc.2")] + [InlineData("0.13.0-rc.2+sha.abc-123")] + [InlineData("1.0.0-alpha.beta-1")] + [InlineData("999999999999999999999999.0.1")] + public void TryParseStrict_AcceptsValidSemVer(string version) + { + Assert.True(AppVersion.TryParseStrict(version, out _)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("0.13")] + [InlineData("0.13.0.1")] + [InlineData("v0.13.0")] + [InlineData("01.13.0")] + [InlineData("0.13.0-rc.02")] + [InlineData("0.13.0-rc..2")] + [InlineData("0.13.0-rc_2")] + [InlineData("0.13.0-")] + [InlineData("0.13.0+")] + [InlineData("0.13.0+build..sha")] + [InlineData(" 0.13.0")] + public void TryParseStrict_RejectsMalformedSemVer(string? version) + { + Assert.False(AppVersion.TryParseStrict(version, out _)); + } + + [Theory] + [InlineData("0.13.0-rc.2", "0.13.0")] + [InlineData("0.13.0-rc.2", "0.13.0-rc.10")] + public void TryCompareStrict_UsesSemVerPrereleasePrecedence(string older, string newer) + { + Assert.True(AppVersion.TryCompareStrict(older, newer, out var comparison)); + Assert.True(comparison < 0); + + Assert.True(AppVersion.TryCompareStrict(newer, older, out comparison)); + Assert.True(comparison > 0); + } + + [Fact] + public void TryCompareStrict_IgnoresBuildMetadata() + { + Assert.True( + AppVersion.TryCompareStrict( + "0.13.0-rc.2+sha.abc", + "0.13.0-rc.2+sha.def", + out var comparison + ) + ); + Assert.Equal(0, comparison); + } + + [Fact] + public void TryCompareStrict_RejectsMalformedInput() + { + Assert.False(AppVersion.TryCompareStrict("0.13", "0.13.0", out _)); + Assert.False(AppVersion.TryCompareStrict("0.13.0", "not-semver", out _)); + } } diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginLoaderTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginLoaderTests.cs index 8fea6fde2..6f7ce3023 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginLoaderTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginLoaderTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using TypeWhisper.Linux.Services.Plugins; +using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Models; using TypeWhisper.Tests; @@ -130,4 +131,151 @@ public void DiscoverAndLoad_ManifestDeserializesToNull_ReturnsEmpty() var result = _loader.DiscoverAndLoad([_tempDir]); Assert.Empty(result); } + + [Fact] + public void DiscoverAndLoad_MinimumAboveHost_RecordsFailureWithoutConstruction() + { + var loader = CreateLoader("0.13.0-rc.2"); + var pluginDir = StageConstructorTrackingPlugin("0.13.0"); + + var result = loader.DiscoverAndLoad([_tempDir]); + + Assert.Empty(result); + var failure = Assert.Single(loader.LastLoadFailures); + Assert.Equal(pluginDir, failure.PluginDirectory); + Assert.Contains("Requires host version", failure.Message); + Assert.False(File.Exists(Path.Join(pluginDir, ConstructorMarkerFileName))); + } + + [Theory] + [InlineData("0.13.0-rc.2")] + [InlineData("0.13.0-rc.1")] + public void DiscoverAndLoad_MinimumAtOrBelowHost_Loads(string minimumHostVersion) + { + var loader = CreateLoader("0.13.0-rc.2"); + var pluginDir = StageConstructorTrackingPlugin(minimumHostVersion); + + var result = loader.DiscoverAndLoad([_tempDir]); + + var loaded = Assert.Single(result); + try + { + Assert.Equal("com.test.compatibility", loaded.Manifest.Id); + Assert.True(File.Exists(Path.Join(pluginDir, ConstructorMarkerFileName))); + Assert.Empty(loader.LastLoadFailures); + } + finally + { + loaded.Instance.Dispose(); + loaded.LoadContext.Unload(); + } + } + + [Fact] + public void DiscoverAndLoad_MalformedMinimum_RecordsFailureWithoutConstruction() + { + var loader = CreateLoader("0.13.0"); + var pluginDir = StageConstructorTrackingPlugin("0.13"); + + var result = loader.DiscoverAndLoad([_tempDir]); + + Assert.Empty(result); + var failure = Assert.Single(loader.LastLoadFailures); + Assert.Equal(pluginDir, failure.PluginDirectory); + Assert.Contains("not valid SemVer", failure.Message); + Assert.False(File.Exists(Path.Join(pluginDir, ConstructorMarkerFileName))); + } + + [Fact] + public void DiscoverAndLoad_AbsentMinimum_Loads() + { + var loader = CreateLoader("0.13.0-rc.2"); + var pluginDir = StageConstructorTrackingPlugin(null, includeMinimum: false); + + var result = loader.DiscoverAndLoad([_tempDir]); + + var loaded = Assert.Single(result); + try + { + Assert.True(File.Exists(Path.Join(pluginDir, ConstructorMarkerFileName))); + Assert.Empty(loader.LastLoadFailures); + } + finally + { + loaded.Instance.Dispose(); + loaded.LoadContext.Unload(); + } + } + + private PluginLoader CreateLoader(string hostVersion) + { + return new PluginLoader(Path.Join(_tempDir, "PluginData")) + { + HostVersion = hostVersion, + }; + } + + private string StageConstructorTrackingPlugin( + string? minimumHostVersion, + bool includeMinimum = true + ) + { + var pluginDir = Path.Join(_tempDir, "com.test.compatibility"); + Directory.CreateDirectory(pluginDir); + + var sourceAssembly = typeof(PluginLoaderTests).Assembly.Location; + var assemblyName = Path.GetFileName(sourceAssembly); + File.Copy(sourceAssembly, Path.Join(pluginDir, assemblyName), true); + + var manifest = new Dictionary + { + ["id"] = "com.test.compatibility", + ["name"] = "Compatibility Test Plugin", + ["version"] = "1.0.0", + ["assemblyName"] = assemblyName, + ["pluginClass"] = typeof(ConstructorTrackingPlugin).FullName, + }; + if (includeMinimum) + { + manifest["minHostVersion"] = minimumHostVersion; + } + + File.WriteAllText( + Path.Join(pluginDir, "manifest.json"), + JsonSerializer.Serialize(manifest) + ); + return pluginDir; + } + + private sealed class ConstructorTrackingPlugin : ITypeWhisperPlugin + { + public ConstructorTrackingPlugin() + { + var assemblyDirectory = Path.GetDirectoryName( + typeof(ConstructorTrackingPlugin).Assembly.Location + )!; + File.WriteAllText( + Path.Join(assemblyDirectory, ConstructorMarkerFileName), + "constructed" + ); + } + + public string PluginId => "com.test.compatibility"; + public string PluginName => "Compatibility Test Plugin"; + public string PluginVersion => "1.0.0"; + + public Task ActivateAsync(IPluginHostServices host) + { + return Task.CompletedTask; + } + + public Task DeactivateAsync() + { + return Task.CompletedTask; + } + + public void Dispose() { } + } + + private const string ConstructorMarkerFileName = "constructor.marker"; } diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginRegistryServiceTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginRegistryServiceTests.cs index 232dd50fa..c7fe9d61b 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginRegistryServiceTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginRegistryServiceTests.cs @@ -155,6 +155,106 @@ public async Task FetchRegistryAsync_FiltersIncompatibleVersions() Assert.Equal("com.typewhisper.groq", result[0].Id); } + [Fact] + public async Task FetchRegistryAsync_HonorsPrereleaseMinimumHostVersions() + { + var plugins = new[] + { + new + { + Id = "com.typewhisper.groq", + Name = "Compatible", + Version = "1.0.0", + MinHostVersion = "0.13.0-rc.2", + Author = "A", + Description = "D", + Size = 100L, + DownloadUrl = "u", + RequiresApiKey = false, + }, + new + { + Id = "com.typewhisper.openai", + Name = "Newer prerelease", + Version = "1.0.0", + MinHostVersion = "0.13.0-rc.3", + Author = "A", + Description = "D", + Size = 100L, + DownloadUrl = "u", + RequiresApiKey = false, + }, + new + { + Id = "com.typewhisper.openrouter", + Name = "Final release", + Version = "1.0.0", + MinHostVersion = "0.13.0", + Author = "A", + Description = "D", + Size = 100L, + DownloadUrl = "u", + RequiresApiKey = false, + }, + }; + + var httpClient = CreateMockHttpClient(JsonSerializer.Serialize(plugins)); + var manager = CreateManager(); + var service = new PluginRegistryService(manager, _loader, _settings.Object, httpClient) + { + HostVersion = "0.13.0-rc.2", + }; + + var result = await service.FetchRegistryAsync(); + + var compatible = Assert.Single(result); + Assert.Equal("com.typewhisper.groq", compatible.Id); + } + + [Fact] + public async Task FetchRegistryAsync_RejectsMalformedMinimumHostVersion() + { + var plugins = new[] + { + new + { + Id = "com.typewhisper.groq", + Name = "Malformed", + Version = "1.0.0", + MinHostVersion = (string?)"not-semver", + Author = "A", + Description = "D", + Size = 100L, + DownloadUrl = "u", + RequiresApiKey = false, + }, + new + { + Id = "com.typewhisper.openai", + Name = "No minimum", + Version = "1.0.0", + MinHostVersion = (string?)null, + Author = "A", + Description = "D", + Size = 100L, + DownloadUrl = "u", + RequiresApiKey = false, + }, + }; + + var httpClient = CreateMockHttpClient(JsonSerializer.Serialize(plugins)); + var manager = CreateManager(); + var service = new PluginRegistryService(manager, _loader, _settings.Object, httpClient) + { + HostVersion = "0.13.0", + }; + + var result = await service.FetchRegistryAsync(); + + var compatible = Assert.Single(result); + Assert.Equal("com.typewhisper.openai", compatible.Id); + } + [Fact] public async Task FetchRegistryAsync_HttpError_ReturnsEmptyList() { From 0f135adc9d4f9ec14b4d23853bce9aa94cf6d919 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 21:11:29 +0000 Subject: [PATCH 166/226] Derive the shared CUDA cache from the configured asset root and migrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK promises PluginAssetDirectory for large runtime assets and lets the host redirect it to user-selected storage, but the shared CUDA provisioner hardcoded ~/.local/share/TypeWhisper/Runtimes/cuda and both WhisperCpp and Sherpa instantiated that default. After the user moved model storage, the CUDA wheel set - cuDNN alone is ~1.7 GB unpacked - kept filling the system drive, outside Core's asset migration. Both engines now derive an identical shared root from the host layout (/PluginData//Runtimes/cuda), falling back to the legacy default when no asset directory exists. On first use of a non-default root with a populated legacy cache and an absent destination, migration acquires complete maintenance leases on BOTH roots, re-checks state, and moves the tree atomically; a move or lock failure logs and leaves the legacy cache in place while provisioning fresh at the configured root - the old cache is never deleted automatically. External sentinels stay per-root siblings and are never moved or unlinked, preserving the never-unlink invariant. PA44 records the clear-after-failed-migration resurrection corner. --- plugins/Shared/Cuda/CudaRuntimeProvisioner.cs | 211 +++++++++-- .../SherpaOnnxPlugin.cs | 33 +- .../WhisperCppPlugin.cs | 29 +- .../CudaRuntimeProvisionerTests.cs | 340 +++++++++++++++--- .../WhisperCppPluginTests.cs | 42 +++ 5 files changed, 565 insertions(+), 90 deletions(-) diff --git a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs index 6fe326c67..f56a92853 100644 --- a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs +++ b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs @@ -37,8 +37,8 @@ public enum CudaRuntimeProfile /// can resolve their symbols. /// /// GPU binaries are never bundled into the app packages — they are -/// fetched here at first CUDA use and cached under -/// ~/.local/share/TypeWhisper/Runtimes/cuda/<BundleVersion>. +/// fetched here at first CUDA use and cached under the host-selected +/// shared runtime root. /// /// // Not sealed: tests subclass it with a fake that overrides EnsureReadyAsync (the dlopen @@ -150,26 +150,41 @@ private static string[] BuildSystemLibraryDirectories() private readonly string _cacheRoot; private readonly string _maintenanceLockPath; private readonly string _wheelLockDirectory; + private readonly string _legacyCacheRoot; + private readonly Action _moveDirectory; + private bool _legacyMigrationAttempted; public CudaRuntimeProvisioner(string cacheRoot, HttpClient httpClient, Action? log = null) + : this( + cacheRoot, + httpClient, + log, + DefaultCacheRoot(), + Directory.Move + ) { } + + internal CudaRuntimeProvisioner( + string cacheRoot, + HttpClient httpClient, + Action? log, + string legacyCacheRoot, + Action moveDirectory + ) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _log = log; - CacheDirectory = Path.Join(cacheRoot, BundleVersion); - - var cacheRootDirectory = Directory.GetParent(CacheDirectory) - ?? throw new ArgumentException("The CUDA cache root must have a parent directory.", nameof(cacheRoot)); - var cacheParent = cacheRootDirectory.Parent - ?? throw new ArgumentException("The CUDA cache root must not be a filesystem root.", nameof(cacheRoot)); - _cacheRoot = cacheRootDirectory.FullName; - _maintenanceLockPath = Path.Join( - cacheParent.FullName, - cacheRootDirectory.Name + ".maintenance.lock" - ); - _wheelLockDirectory = Path.Join( - cacheParent.FullName, - cacheRootDirectory.Name + ".locks" - ); + _moveDirectory = moveDirectory + ?? throw new ArgumentNullException(nameof(moveDirectory)); + + var cachePaths = ResolveCachePaths(cacheRoot, nameof(cacheRoot)); + _cacheRoot = cachePaths.CacheRoot; + _maintenanceLockPath = cachePaths.MaintenanceLockPath; + _wheelLockDirectory = cachePaths.WheelLockDirectory; + CacheDirectory = Path.Join(_cacheRoot, BundleVersion); + _legacyCacheRoot = ResolveCachePaths( + legacyCacheRoot, + nameof(legacyCacheRoot) + ).CacheRoot; } /// Directory holding the downloaded CUDA .so files for this bundle version. @@ -194,6 +209,26 @@ public static string DefaultCacheRoot() => "cuda" ); + /// + /// Resolves the shared CUDA root from a host-provided per-plugin asset + /// directory. Host paths have the shape + /// <asset-root>/PluginData/<plugin-id>, so walking up + /// through the plugin and PluginData directories lets every engine select + /// the same <asset-root>/Runtimes/cuda sibling. Older hosts + /// and tests that provide no asset directory retain the legacy default. + /// + internal static string CacheRootForPluginAssetDirectory(string? pluginAssetDirectory) + { + if (string.IsNullOrWhiteSpace(pluginAssetDirectory)) + return DefaultCacheRoot(); + + var pluginDirectory = new DirectoryInfo(pluginAssetDirectory); + var commonAssetRoot = pluginDirectory.Parent?.Parent; + return commonAssetRoot is null + ? DefaultCacheRoot() + : Path.Join(commonAssetRoot.FullName, "Runtimes", "cuda"); + } + private static CudaWheel[] WheelsFor(CudaRuntimeProfile profile) => profile == CudaRuntimeProfile.WhisperCublas ? s_whisperWheels : s_onnxRuntimeWheels; @@ -266,6 +301,7 @@ CancellationToken ct await _gate.WaitAsync(ct).ConfigureAwait(false); try { + await TryMigrateLegacyCacheAsync(ct).ConfigureAwait(false); EnsureExternalLockDirectory(); // A wheel is fetched unless EVERY library it provides is already @@ -310,6 +346,72 @@ await InterProcessFileLock } } + private async Task TryMigrateLegacyCacheAsync(CancellationToken ct) + { + if (_legacyMigrationAttempted) + return; + + if (PathsEqual(_legacyCacheRoot, _cacheRoot) + || !Directory.Exists(_legacyCacheRoot) + || Directory.Exists(_cacheRoot)) + { + _legacyMigrationAttempted = true; + return; + } + + var legacyPaths = ResolveCachePaths(_legacyCacheRoot, nameof(_legacyCacheRoot)); + try + { + // Protect the destination against another provisioner creating it while + // migration waits for the legacy cache. Each maintenance lease owns root + // -> every external wheel sentinel, so no active or starting provisioning + // batch can overlap the atomic move. + await using var destinationLocks = await AcquireMaintenanceLocksAsync( + _maintenanceLockPath, + _wheelLockDirectory, + "migrating the CUDA runtime cache", + ct + ) + .ConfigureAwait(false); + await using var legacyLocks = await AcquireMaintenanceLocksAsync( + legacyPaths.MaintenanceLockPath, + legacyPaths.WheelLockDirectory, + "migrating the legacy CUDA runtime cache", + ct + ) + .ConfigureAwait(false); + + // Re-check under both roots' complete maintenance leases: another + // process may have migrated or provisioned while these locks were pending. + if (!Directory.Exists(_legacyCacheRoot) || Directory.Exists(_cacheRoot)) + { + _legacyMigrationAttempted = true; + return; + } + + _moveDirectory(_legacyCacheRoot, _cacheRoot); + _log?.Invoke( + $"CUDA runtime: migrated cache from {_legacyCacheRoot} to {_cacheRoot}." + ); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // Best effort only. The old cache is never deleted here; a failed move + // falls through to normal provisioning at the configured destination. + _log?.Invoke( + $"CUDA runtime: could not migrate cache from {_legacyCacheRoot} " + + $"to {_cacheRoot}: {ex.Message} Leaving the old cache in place " + + "and provisioning at the configured location." + ); + } + + _legacyMigrationAttempted = true; + } + private async Task DownloadMissingAsync( IReadOnlyList missing, IProgress? progress, @@ -425,12 +527,25 @@ await InterProcessFileLock } } + private Task AcquireMaintenanceLocksAsync( + string operation, + CancellationToken ct + ) => + AcquireMaintenanceLocksAsync( + _maintenanceLockPath, + _wheelLockDirectory, + operation, + ct + ); + private async Task AcquireMaintenanceLocksAsync( + string maintenanceLockPath, + string wheelLockDirectory, string operation, CancellationToken ct ) { - EnsureExternalLockDirectory(); + EnsureExternalLockDirectory(wheelLockDirectory); using var timeoutCts = new CancellationTokenSource(MaintenanceLockTimeoutForTests); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( ct, @@ -442,7 +557,7 @@ CancellationToken ct { acquired.Add( await InterProcessFileLock - .AcquireAsync(_maintenanceLockPath, linkedCts.Token) + .AcquireAsync(maintenanceLockPath, linkedCts.Token) .ConfigureAwait(false) ); @@ -450,8 +565,8 @@ await InterProcessFileLock // wheel sentinel after the snapshot. Include the known wheel set plus // existing sentinels, for forward compatibility with bundle/package changes. var wheelLockPaths = s_onnxRuntimeWheels - .Select(WheelLockPath) - .Concat(Directory.EnumerateFiles(_wheelLockDirectory, "*.lock")) + .Select(wheel => WheelLockPath(wheel, wheelLockDirectory)) + .Concat(Directory.EnumerateFiles(wheelLockDirectory, "*.lock")) .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal); foreach (var lockPath in wheelLockPaths) @@ -481,10 +596,54 @@ await InterProcessFileLock } private string WheelLockPath(CudaWheel wheel) => - Path.Join(_wheelLockDirectory, wheel.Package + ".lock"); + WheelLockPath(wheel, _wheelLockDirectory); + + private static string WheelLockPath(CudaWheel wheel, string wheelLockDirectory) => + Path.Join(wheelLockDirectory, wheel.Package + ".lock"); private void EnsureExternalLockDirectory() => - Directory.CreateDirectory(_wheelLockDirectory); + EnsureExternalLockDirectory(_wheelLockDirectory); + + private static void EnsureExternalLockDirectory(string wheelLockDirectory) => + Directory.CreateDirectory(wheelLockDirectory); + + private static CachePaths ResolveCachePaths(string cacheRoot, string parameterName) + { + var cacheRootDirectory = Directory.GetParent(Path.Join(cacheRoot, BundleVersion)) + ?? throw new ArgumentException( + "The CUDA cache root must have a parent directory.", + parameterName + ); + var cacheParent = cacheRootDirectory.Parent + ?? throw new ArgumentException( + "The CUDA cache root must not be a filesystem root.", + parameterName + ); + return new CachePaths( + cacheRootDirectory.FullName, + Path.Join( + cacheParent.FullName, + cacheRootDirectory.Name + ".maintenance.lock" + ), + Path.Join( + cacheParent.FullName, + cacheRootDirectory.Name + ".locks" + ) + ); + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.GetFullPath(left).TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + ), + Path.GetFullPath(right).TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + ), + StringComparison.Ordinal + ); private static async ValueTask DisposeLocksAsync(List locks) { @@ -1029,4 +1188,10 @@ private sealed record CudaWheel( string Version, string[] RequiredSonames ); + + private sealed record CachePaths( + string CacheRoot, + string MaintenanceLockPath, + string WheelLockDirectory + ); } diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs index b9d8a62fa..b804f8951 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs @@ -170,23 +170,30 @@ public Task ActivateAsync(IPluginHostServices host) _host = host; // Lazily provisioned on demand; the ?? lets tests inject fakes before activate. + InitializeCudaDependencies(host); + + // Register the import resolver now; until CUDA is configured it defers to + // the default loader, which picks up the CPU runtime from the managed nuget. + SherpaOnnxNativeRuntime.RegisterResolver(); + + MigrateModelFiles(); + return Task.CompletedTask; + } + + private void InitializeCudaDependencies(IPluginHostServices host) + { _cudaRuntimeInstaller ??= new SherpaCudaRuntimeInstaller( host.PluginAssetDirectory, _httpClient, msg => host.Log(PluginLogLevel.Info, msg) ); _cudaProvisioner ??= new CudaRuntimeProvisioner( - CudaRuntimeProvisioner.DefaultCacheRoot(), + CudaRuntimeProvisioner.CacheRootForPluginAssetDirectory( + host.PluginAssetDirectory + ), _httpClient, msg => host.Log(PluginLogLevel.Info, msg) ); - - // Register the import resolver now; until CUDA is configured it defers to - // the default loader, which picks up the CPU runtime from the managed nuget. - SherpaOnnxNativeRuntime.RegisterResolver(); - - MigrateModelFiles(); - return Task.CompletedTask; } public Task DeactivateAsync() @@ -736,6 +743,16 @@ SherpaCudaRuntimeInstaller installer _cudaRuntimeInstaller = installer; } + // Test seam: exercise the same eager construction path as ActivateAsync without + // running the legacy model-file migration against a real per-user directory. + internal void InitializeCudaDependenciesForTests(IPluginHostServices host) => + InitializeCudaDependencies(host); + + internal string? CudaRuntimeCacheRootForTests => + _cudaProvisioner is null + ? null + : Directory.GetParent(_cudaProvisioner.CacheDirectory)?.FullName; + // Test seam: inject a throwing recognizer factory so native-load-failure // classification can be exercised without a real model, native runtime, or GPU. internal void SetParakeetRecognizerFactoryForTests( diff --git a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs index 54bf41e4c..464f0e496 100644 --- a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs +++ b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs @@ -311,8 +311,18 @@ public Task ActivateAsync(IPluginHostServices host) // report a warm cache immediately after a restart (the host gates CUDA selection // on it), not only after a download has been attempted. Both are cheap to build; // the ?? lets tests inject fakes before activate. + InitializeCudaDependencies(host); + + host.Log(PluginLogLevel.Info, "Activated"); + return Task.CompletedTask; + } + + private void InitializeCudaDependencies(IPluginHostServices host) + { _cudaProvisioner ??= new CudaRuntimeProvisioner( - CudaRuntimeProvisioner.DefaultCacheRoot(), + CudaRuntimeProvisioner.CacheRootForPluginAssetDirectory( + host.PluginAssetDirectory + ), _httpClient, msg => host.Log(PluginLogLevel.Info, msg) ); @@ -321,9 +331,6 @@ public Task ActivateAsync(IPluginHostServices host) _httpClient, msg => host.Log(PluginLogLevel.Info, msg) ); - - host.Log(PluginLogLevel.Info, "Activated"); - return Task.CompletedTask; } private static float ReadNoSpeechThreshold(IPluginHostServices host) @@ -925,7 +932,9 @@ public async Task EnsureCudaRuntimeReadyAsync(IProgress? progress, Cance ); _cudaProvisioner ??= new CudaRuntimeProvisioner( - CudaRuntimeProvisioner.DefaultCacheRoot(), + CudaRuntimeProvisioner.CacheRootForPluginAssetDirectory( + _host?.PluginAssetDirectory + ), _httpClient, msg => _host?.Log(PluginLogLevel.Info, msg) ); @@ -1268,6 +1277,16 @@ WhisperCudaRuntimeInstaller installer _whisperCudaInstaller = installer; } + // Test seam: exercise the same eager construction path as ActivateAsync without + // invoking unrelated activation work. + internal void InitializeCudaDependenciesForTests(IPluginHostServices host) => + InitializeCudaDependencies(host); + + internal string? CudaRuntimeCacheRootForTests => + _cudaProvisioner is null + ? null + : Directory.GetParent(_cudaProvisioner.CacheDirectory)?.FullName; + private static TranscriptionAccelerationStatus CreatePendingAccelerationStatus( TranscriptionAccelerationPreference preference ) diff --git a/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs b/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs index bfd02dd08..b299c2f7b 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.IO.Compression; using System.Net; using System.Security.Cryptography; @@ -35,10 +36,11 @@ public async Task DownloadAndExtract_ColdCache_DownloadsExtractsAndWritesMarkers using var temp = new TempDir(); var (handler, http) = WhisperCublasFixture(); using var _ = http; - var provisioner = new CudaRuntimeProvisioner(temp.Path, http) - { - SystemLibraryProbeForTests = _ => false, - }; + var provisioner = CreateProvisioner( + temp.Path, + http, + systemLibraryProbe: _ => false + ); await provisioner.DownloadAndExtractAsync( CudaRuntimeProfile.WhisperCublas, null, CancellationToken.None); @@ -71,10 +73,11 @@ public async Task DownloadAndExtract_WarmCache_IsSatisfied_MakesNoSecondRequest( using var temp = new TempDir(); var (handler, http) = WhisperCublasFixture(); using var _ = http; - var provisioner = new CudaRuntimeProvisioner(temp.Path, http) - { - SystemLibraryProbeForTests = _ => false, - }; + var provisioner = CreateProvisioner( + temp.Path, + http, + systemLibraryProbe: _ => false + ); await provisioner.DownloadAndExtractAsync( CudaRuntimeProfile.WhisperCublas, null, CancellationToken.None); @@ -94,10 +97,11 @@ public async Task DownloadAndExtract_MarkerDeleted_ReDownloadsThatWheelOnly() using var temp = new TempDir(); var (handler, http) = WhisperCublasFixture(); using var _ = http; - var provisioner = new CudaRuntimeProvisioner(temp.Path, http) - { - SystemLibraryProbeForTests = _ => false, - }; + var provisioner = CreateProvisioner( + temp.Path, + http, + systemLibraryProbe: _ => false + ); await provisioner.DownloadAndExtractAsync( CudaRuntimeProfile.WhisperCublas, null, CancellationToken.None); @@ -120,11 +124,12 @@ public async Task DownloadAndExtract_WhenSystemProvidesLibraries_DownloadsNothin using var temp = new TempDir(); var (handler, http) = WhisperCublasFixture(); using var _ = http; - var provisioner = new CudaRuntimeProvisioner(temp.Path, http) - { + var provisioner = CreateProvisioner( + temp.Path, + http, // Every soname resolvable on the "system" → no wheel is missing. - SystemLibraryProbeForTests = _ => true, - }; + systemLibraryProbe: _ => true + ); var progress = new RecordingProgress(); await provisioner.DownloadAndExtractAsync( @@ -141,7 +146,7 @@ public void ExtractSharedObjects_KeepsOnlyLibSharedObjects_FlattenedNoTmp() using var temp = new TempDir(); var (_, http) = WhisperCublasFixture(); using var _ = http; - var provisioner = new CudaRuntimeProvisioner(temp.Path, http); + var provisioner = CreateProvisioner(temp.Path, http); Directory.CreateDirectory(provisioner.CacheDirectory); var zip = BuildWheelZip( @@ -168,7 +173,7 @@ public void PruneStaleBundles_DeletesOtherVersions_KeepsCurrent() using var temp = new TempDir(); var (_, http) = WhisperCublasFixture(); using var _ = http; - var provisioner = new CudaRuntimeProvisioner(temp.Path, http); + var provisioner = CreateProvisioner(temp.Path, http); // CacheDirectory = /; create it plus a stale sibling. Directory.CreateDirectory(provisioner.CacheDirectory); @@ -198,10 +203,11 @@ public async Task DownloadAndExtract_FailsClosed_WhenPyPiOmitsSha256() }; var handler = new FakePyPiHandler(fixtures); using var http = new HttpClient(handler); - var provisioner = new CudaRuntimeProvisioner(temp.Path, http) - { - SystemLibraryProbeForTests = _ => false, - }; + var provisioner = CreateProvisioner( + temp.Path, + http, + systemLibraryProbe: _ => false + ); await Assert.ThrowsAsync(() => provisioner.DownloadAndExtractAsync( @@ -228,10 +234,11 @@ public async Task DownloadAndExtract_FailsClosed_WhenNoManylinuxWheel() }; var handler = new FakePyPiHandler(fixtures); using var http = new HttpClient(handler); - var provisioner = new CudaRuntimeProvisioner(temp.Path, http) - { - SystemLibraryProbeForTests = _ => false, - }; + var provisioner = CreateProvisioner( + temp.Path, + http, + systemLibraryProbe: _ => false + ); await Assert.ThrowsAsync(() => provisioner.DownloadAndExtractAsync( @@ -261,10 +268,11 @@ public async Task DownloadAndExtract_ProgressAdvancesByActualBytes_WhenSizeOmitt ]); var handler = new FakePyPiHandler([cudart, cublas]); using var http = new HttpClient(handler); - var provisioner = new CudaRuntimeProvisioner(temp.Path, http) - { - SystemLibraryProbeForTests = _ => false, - }; + var provisioner = CreateProvisioner( + temp.Path, + http, + systemLibraryProbe: _ => false + ); var progress = new RecordingProgress(); await provisioner.DownloadAndExtractAsync( @@ -288,10 +296,11 @@ public async Task DownloadAndExtract_TwoConcurrentCalls_GateSerializes_SingleDow using var temp = new TempDir(); var (handler, http) = WhisperCublasFixture(); using var _ = http; - var provisioner = new CudaRuntimeProvisioner(temp.Path, http) - { - SystemLibraryProbeForTests = _ => false, - }; + var provisioner = CreateProvisioner( + temp.Path, + http, + systemLibraryProbe: _ => false + ); var a = provisioner.DownloadAndExtractAsync( CudaRuntimeProfile.WhisperCublas, null, CancellationToken.None); @@ -311,14 +320,16 @@ public async Task DownloadAndExtract_TwoProvisioners_ClearWaitsForActiveProvisio using var temp = new TempDir(); var (handler, http) = WhisperCublasFixture(pauseFirstWheelResponse: true); using var _ = http; - var provisioner = new CudaRuntimeProvisioner(temp.Path, http) - { - SystemLibraryProbeForTests = _ => false, - }; - var clearingProvisioner = new CudaRuntimeProvisioner(temp.Path, http) - { - SystemLibraryProbeForTests = _ => false, - }; + var provisioner = CreateProvisioner( + temp.Path, + http, + systemLibraryProbe: _ => false + ); + var clearingProvisioner = CreateProvisioner( + temp.Path, + http, + systemLibraryProbe: _ => false + ); var provisioning = provisioner.DownloadAndExtractAsync( CudaRuntimeProfile.WhisperCublas, @@ -360,14 +371,16 @@ public async Task PruneStaleBundles_TwoProvisioners_WaitsForActiveProvisioning() using var temp = new TempDir(); var (handler, http) = WhisperCublasFixture(pauseFirstWheelResponse: true); using var _ = http; - var provisioner = new CudaRuntimeProvisioner(temp.Path, http) - { - SystemLibraryProbeForTests = _ => false, - }; - var pruningProvisioner = new CudaRuntimeProvisioner(temp.Path, http) - { - SystemLibraryProbeForTests = _ => false, - }; + var provisioner = CreateProvisioner( + temp.Path, + http, + systemLibraryProbe: _ => false + ); + var pruningProvisioner = CreateProvisioner( + temp.Path, + http, + systemLibraryProbe: _ => false + ); var staleDir = Path.Join(temp.Path, "cuda12-v0-stale"); Directory.CreateDirectory(staleDir); await File.WriteAllTextAsync(Path.Join(staleDir, "old.so"), "x"); @@ -408,10 +421,12 @@ public async Task PruneStaleBundles_WhenMaintenanceLockTimesOut_SkipsWithReason( var (_, http) = WhisperCublasFixture(); using var _ = http; var logs = new List(); - var provisioner = new CudaRuntimeProvisioner(temp.Path, http, logs.Add) - { - MaintenanceLockTimeoutForTests = TimeSpan.FromMilliseconds(100), - }; + var provisioner = CreateProvisioner( + temp.Path, + http, + logs.Add, + maintenanceLockTimeout: TimeSpan.FromMilliseconds(100) + ); Directory.CreateDirectory(provisioner.CacheDirectory); var staleDir = Path.Join(temp.Path, "cuda12-v0-stale"); Directory.CreateDirectory(staleDir); @@ -436,8 +451,225 @@ public async Task PruneStaleBundles_WhenMaintenanceLockTimesOut_SkipsWithReason( ); } + [Fact] + public void CacheRootForPluginAssetDirectory_NoDirectory_UsesLegacyDefault() + { + Assert.Equal( + CudaRuntimeProvisioner.DefaultCacheRoot(), + CudaRuntimeProvisioner.CacheRootForPluginAssetDirectory(null) + ); + } + + [Fact] + public async Task DownloadAndExtract_LegacyCacheAndMissingConfiguredRoot_MovesCacheAtomically() + { + using var temp = new TempDir(); + var legacyRoot = Path.Join(temp.Path, "legacy", "cuda"); + var configuredRoot = Path.Join(temp.Path, "selected", "Runtimes", "cuda"); + var legacyBundle = Path.Join( + legacyRoot, + CudaRuntimeProvisioner.BundleVersion + ); + Directory.CreateDirectory(legacyBundle); + await File.WriteAllTextAsync( + Path.Join(legacyBundle, "migrated-artifact.so"), + "legacy" + ); + + var (_, http) = WhisperCublasFixture(); + using var _ = http; + var logs = new List(); + var provisioner = CreateProvisioner( + configuredRoot, + http, + logs.Add, + systemLibraryProbe: _ => true, + legacyCacheRoot: legacyRoot + ); + + await provisioner.DownloadAndExtractAsync( + CudaRuntimeProfile.WhisperCublas, + null, + CancellationToken.None + ); + + Assert.False(Directory.Exists(legacyRoot)); + Assert.Equal( + "legacy", + await File.ReadAllTextAsync( + Path.Join(provisioner.CacheDirectory, "migrated-artifact.so") + ) + ); + Assert.Contains( + logs, + message => message.Contains("migrated cache", StringComparison.Ordinal) + ); + + // Sentinels remain external to the moved cache. The old set stays in place, + // while future provisioning uses the independently-created new set. + var legacyCacheParent = Directory.GetParent(legacyRoot)!; + Assert.True( + File.Exists( + Path.Join( + legacyCacheParent.FullName, + Path.GetFileName(legacyRoot) + ".maintenance.lock" + ) + ) + ); + Assert.True( + Directory.Exists( + Path.Join( + legacyCacheParent.FullName, + Path.GetFileName(legacyRoot) + ".locks" + ) + ) + ); + Assert.True(File.Exists(provisioner.MaintenanceLockPathForTests)); + Assert.True(Directory.Exists(provisioner.WheelLockDirectoryForTests)); + } + + [Fact] + public async Task DownloadAndExtract_LegacyMoveFails_LeavesOldCacheAndProvisionsConfiguredRoot() + { + using var temp = new TempDir(); + var legacyRoot = Path.Join(temp.Path, "legacy", "cuda"); + var configuredRoot = Path.Join(temp.Path, "selected", "Runtimes", "cuda"); + var legacyArtifact = Path.Join( + legacyRoot, + CudaRuntimeProvisioner.BundleVersion, + "legacy-artifact.so" + ); + Directory.CreateDirectory(Path.GetDirectoryName(legacyArtifact)!); + await File.WriteAllTextAsync(legacyArtifact, "legacy"); + + var (_, http) = WhisperCublasFixture(); + using var _ = http; + var logs = new List(); + var moveAttempts = 0; + var provisioner = CreateProvisioner( + configuredRoot, + http, + logs.Add, + systemLibraryProbe: _ => true, + legacyCacheRoot: legacyRoot, + moveDirectory: (_, _) => + { + moveAttempts++; + throw new IOException("simulated cross-device move failure"); + } + ); + + await provisioner.DownloadAndExtractAsync( + CudaRuntimeProfile.WhisperCublas, + null, + CancellationToken.None + ); + + Assert.Equal(1, moveAttempts); + Assert.True(File.Exists(legacyArtifact)); + Assert.True(Directory.Exists(provisioner.CacheDirectory)); + Assert.False( + File.Exists(Path.Join(provisioner.CacheDirectory, "legacy-artifact.so")) + ); + Assert.Contains( + logs, + message => + message.Contains("could not migrate cache", StringComparison.Ordinal) + && message.Contains( + "Leaving the old cache in place", + StringComparison.Ordinal + ) + ); + } + + [Fact] + public async Task DownloadAndExtract_LegacyMaintenanceLockHeld_BoundsMigrationAndProvisionsConfiguredRoot() + { + using var temp = new TempDir(); + var legacyRoot = Path.Join(temp.Path, "legacy", "cuda"); + var configuredRoot = Path.Join(temp.Path, "selected", "Runtimes", "cuda"); + var legacyArtifact = Path.Join( + legacyRoot, + CudaRuntimeProvisioner.BundleVersion, + "legacy-artifact.so" + ); + Directory.CreateDirectory(Path.GetDirectoryName(legacyArtifact)!); + await File.WriteAllTextAsync(legacyArtifact, "legacy"); + + var legacyCacheParent = Directory.GetParent(legacyRoot)!; + var legacyMaintenanceLockPath = Path.Join( + legacyCacheParent.FullName, + Path.GetFileName(legacyRoot) + ".maintenance.lock" + ); + await using var heldMaintenanceLock = new FileStream( + legacyMaintenanceLockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None + ); + + var (_, http) = WhisperCublasFixture(); + using var _ = http; + var logs = new List(); + var provisioner = CreateProvisioner( + configuredRoot, + http, + logs.Add, + systemLibraryProbe: _ => true, + legacyCacheRoot: legacyRoot, + maintenanceLockTimeout: TimeSpan.FromMilliseconds(100) + ); + + var stopwatch = Stopwatch.StartNew(); + await provisioner.DownloadAndExtractAsync( + CudaRuntimeProfile.WhisperCublas, + null, + CancellationToken.None + ); + stopwatch.Stop(); + + Assert.InRange( + stopwatch.Elapsed, + TimeSpan.FromMilliseconds(75), + TimeSpan.FromSeconds(5) + ); + Assert.True(File.Exists(legacyArtifact)); + Assert.True(Directory.Exists(provisioner.CacheDirectory)); + Assert.Contains( + logs, + message => + message.Contains( + "Timed out waiting for another CUDA cache operation", + StringComparison.Ordinal + ) + ); + } + // ---- fixtures / helpers ------------------------------------------------------------ + private static CudaRuntimeProvisioner CreateProvisioner( + string cacheRoot, + HttpClient http, + Action? log = null, + Func? systemLibraryProbe = null, + string? legacyCacheRoot = null, + Action? moveDirectory = null, + TimeSpan? maintenanceLockTimeout = null + ) => + new( + cacheRoot, + http, + log, + // Keep every existing unit test isolated from the real per-user default. + legacyCacheRoot ?? Path.Join(cacheRoot, ".test-legacy-cuda"), + moveDirectory ?? Directory.Move + ) + { + SystemLibraryProbeForTests = systemLibraryProbe, + MaintenanceLockTimeoutForTests = + maintenanceLockTimeout ?? TimeSpan.FromSeconds(30), + }; + private static (FakePyPiHandler Handler, HttpClient Http) WhisperCublasFixture( bool pauseFirstWheelResponse = false ) diff --git a/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs index be0bacb42..0be4874e7 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs @@ -20,6 +20,27 @@ public partial class WhisperCppPluginTests { private const float TranscriptionNoSpeechThreshold = 0.8f; + [Fact] + public void InitializeCudaDependencies_CustomPluginAssetDirectory_UsesSharedConfiguredRoot() + { + using var temp = new TempAssetDir(); + var storageRoot = Path.Join(temp.Path, "selected-storage"); + var pluginAssetDirectory = Path.Join( + storageRoot, + "PluginData", + "com.typewhisper.whisper-cpp" + ); + var host = CreateHostMock(pluginAssetDirectory); + using var plugin = new WhisperCppPlugin(); + + plugin.InitializeCudaDependenciesForTests(host.Object); + + Assert.Equal( + Path.Join(storageRoot, "Runtimes", "cuda"), + plugin.CudaRuntimeCacheRootForTests + ); + } + [Fact] public async Task AccumulateSegmentsAsync_SpeechThenTrailingSilence_UsesMinimumProbability() { @@ -399,6 +420,27 @@ private static string WhisperCppCsprojPath([CallerFilePath] string thisFile = "" public partial class SherpaOnnxPluginTests { + [Fact] + public void InitializeCudaDependencies_CustomPluginAssetDirectory_UsesSharedConfiguredRoot() + { + using var temp = new TempAssetDir(); + var storageRoot = Path.Join(temp.Path, "selected-storage"); + var pluginAssetDirectory = Path.Join( + storageRoot, + "PluginData", + "com.typewhisper.sherpa-onnx" + ); + var host = CreateHostMock(pluginAssetDirectory); + using var plugin = new SherpaOnnxPlugin(); + + plugin.InitializeCudaDependenciesForTests(host.Object); + + Assert.Equal( + Path.Join(storageRoot, "Runtimes", "cuda"), + plugin.CudaRuntimeCacheRootForTests + ); + } + [Fact] public void SupportedAccelerationBackends_IsCpuAndNvidiaCuda() { From fbd3b7e39a619cc0cbcfa02ef2b352ae51edea76 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 22:20:49 +0000 Subject: [PATCH 167/226] Deliver plugin events ordered, non-reentrant, coalesced, and bounded The event-bus contract implied serialized background delivery, but the implementation started an untracked Task.Run for every subscriber and event: a slow subscriber was re-entered concurrently, later events overtook earlier ones, and bursts of partial-transcription and LLM-token updates accumulated as unbounded thread-pool backlog. Each subscription now owns a FIFO queue with a single on-demand worker: events are delivered strictly in publish order, never concurrently with the same subscriber, while separate subscribers progress independently and handler exceptions stay isolated per event. High-frequency event types are marked ICoalescibleEvent with latest-wins-by-type semantics - except terminal frames (a final LLM flush, a recording-ended partial), which are appended, never replaced, and never replace a pending event, so stream endpoints keep their fidelity while bursts stay bounded because bursts are non-terminal by construction. Unsubscribe discards queued events and lets the in-flight handler finish; disposal stops workers with a bounded five-second deadline and abandons a hung handler with a trace instead of stalling process exit. The public contract now documents all of it. --- .../Services/Plugins/PluginEventBus.cs | 343 ++++++++++-- src/TypeWhisper.PluginSDK/IPluginEventBus.cs | 33 +- .../Models/LlmResponseTokenEvent.cs | 5 +- .../Models/PartialTranscriptionUpdateEvent.cs | 5 +- .../Models/PluginEvent.cs | 17 + .../PluginEventBusTests.cs | 529 +++++++++++++++++- 6 files changed, 883 insertions(+), 49 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginEventBus.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginEventBus.cs index 428dbc63e..8e9e7f1d3 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginEventBus.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginEventBus.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Diagnostics; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Models; @@ -7,83 +6,345 @@ namespace TypeWhisper.Linux.Services.Plugins; /// /// Thread-safe publish/subscribe event bus for plugin communication. -/// Handlers are invoked fire-and-forget on the thread pool so a slow or -/// throwing plugin handler cannot block the publisher or starve other handlers. +/// Each subscription owns a FIFO queue and an on-demand thread-pool worker, so +/// its handler is ordered and non-reentrant while separate subscriptions progress +/// independently. Handler exceptions are isolated to the event being delivered. /// -public sealed class PluginEventBus : IPluginEventBus +/// +/// Pending non-terminal instances use latest-wins +/// delivery: an older pending non-terminal event of the same runtime type is removed +/// and the latest event is appended at its publish position. A terminal frame +/// () is always appended and is never +/// the target of a later replacement, preserving stream-endpoint fidelity. +/// Non-coalescible events are never dropped, so bursts limited to a finite set of +/// coalescible types have bounded pending queues. +/// +/// Unsubscribing abandons queued events and lets an in-flight handler complete. +/// Disposing the bus applies the same abandon policy to every subscription and +/// waits for their in-flight workers to exit, up to a bounded deadline; any handler +/// still running past the deadline is abandoned (traced) so disposal always +/// completes. Publishes after disposal are ignored. +/// +public sealed class PluginEventBus : IPluginEventBus, IDisposable, IAsyncDisposable { - // ConcurrentDictionary guards per-type slot creation; the inner List - // requires _lock for add/remove/snapshot because List is not thread-safe. - private readonly ConcurrentDictionary>> _handlers = new(); + private static readonly TimeSpan s_defaultDisposeTimeout = TimeSpan.FromSeconds(5); + + private readonly Dictionary> _subscriptions = []; + private readonly HashSet _trackedSubscriptions = []; private readonly Lock _lock = new(); + private readonly TimeSpan _disposeTimeout; + private Task? _disposeTask; + private bool _disposed; + + public PluginEventBus() + : this(s_defaultDisposeTimeout) { } + + // Test seam: lets tests inject a short deadline to exercise abandon-on-timeout. + internal PluginEventBus(TimeSpan disposeTimeout) + { + _disposeTimeout = disposeTimeout; + } public void Publish(T pluginEvent) where T : PluginEvent { var eventType = typeof(T); - if (!_handlers.TryGetValue(eventType, out var handlers)) + lock (_lock) { - return; + if ( + _disposed + || !_subscriptions.TryGetValue(eventType, out var subscriptions) + ) + { + return; + } + + foreach (var subscription in subscriptions) + { + subscription.Enqueue(pluginEvent); + } } + } + + public IDisposable Subscribe(Func handler) + where T : PluginEvent + { + var eventType = typeof(T); + Task WrappedHandler(object obj) => handler((T)obj); + var subscription = new Subscription(this, eventType, WrappedHandler); - List> snapshot; lock (_lock) { - snapshot = [.. handlers]; + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_subscriptions.TryGetValue(eventType, out var subscriptions)) + { + subscriptions = []; + _subscriptions.Add(eventType, subscriptions); + } + + subscriptions.Add(subscription); + _trackedSubscriptions.Add(subscription); } - foreach (var handler in snapshot) + return subscription; + } + + public void Dispose() + { + GetOrStartDisposeTask().GetAwaiter().GetResult(); + // ReSharper disable once GCSuppressFinalizeForTypeWithoutDestructor -- satisfies CA1816; keeps the standard Dispose pattern if a finalizer is ever added. + GC.SuppressFinalize(this); + } + + public async ValueTask DisposeAsync() + { + await GetOrStartDisposeTask().ConfigureAwait(false); + // ReSharper disable once GCSuppressFinalizeForTypeWithoutDestructor -- satisfies CA1816; keeps the standard Dispose pattern if a finalizer is ever added. + GC.SuppressFinalize(this); + } + + private Task GetOrStartDisposeTask() + { + Subscription[] subscriptions; + Task disposeTask; + lock (_lock) { - _ = Task.Run(async () => + if (_disposeTask is not null) { - try - { - await handler(pluginEvent); - } - catch (Exception ex) + return _disposeTask; + } + + _disposed = true; + subscriptions = _trackedSubscriptions.ToArray(); + _subscriptions.Clear(); + + var completion = Task.WhenAll( + subscriptions.Select(subscription => subscription.Completion) + ); + disposeTask = WaitForWorkersAsync(completion); + _disposeTask = disposeTask; + } + + foreach (var subscription in subscriptions) + { + subscription.Stop(); + } + + return disposeTask; + } + + // Bounded wait so a hung handler can't stall process exit; on timeout the + // in-flight workers (at most one per subscription) are simply abandoned. + private async Task WaitForWorkersAsync(Task completion) + { + var finished = await Task.WhenAny(completion, Task.Delay(_disposeTimeout)) + .ConfigureAwait(false); + if (!ReferenceEquals(finished, completion)) + { + Trace.WriteLine( + $"[PluginEventBus] Dispose deadline of {_disposeTimeout.TotalMilliseconds:F0}ms elapsed; abandoning in-flight handlers." + ); + } + } + + private void Unsubscribe(Subscription subscription) + { + lock (_lock) + { + if ( + _subscriptions.TryGetValue( + subscription.EventType, + out var subscriptions + ) + ) + { + subscriptions.Remove(subscription); + if (subscriptions.Count == 0) { - Trace.WriteLine( - $"[PluginEventBus] Handler for {eventType.Name} threw: {ex.Message}" - ); + _subscriptions.Remove(subscription.EventType); } - }); + } } + + subscription.Stop(); } - public IDisposable Subscribe(Func handler) - where T : PluginEvent + private void OnSubscriptionStopped(Subscription subscription) { - var eventType = typeof(T); - Func wrappedHandler = obj => handler((T)obj); - lock (_lock) { - var handlers = _handlers.GetOrAdd(eventType, _ => []); - handlers.Add(wrappedHandler); + _trackedSubscriptions.Remove(subscription); } + } + + private sealed class Subscription( + PluginEventBus owner, + Type eventType, + Func handler + ) : IDisposable + { + private readonly TaskCompletionSource _completion = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + // ReSharper disable once ReplaceWithPrimaryConstructorParameter -- keep an explicit named field, matching how this class projects its other ctor params into named members. + private readonly Func _handler = handler; + private readonly Lock _lock = new(); + // ReSharper disable once ReplaceWithPrimaryConstructorParameter -- keep an explicit named field, matching how this class projects its other ctor params into named members. + private readonly PluginEventBus _owner = owner; + private readonly LinkedList _queue = []; + private Task? _workerTask; + private bool _stopped; + + public Task Completion => _completion.Task; - return new Subscription(() => + public Type EventType { get; } = eventType; + + public void Enqueue(object pluginEvent) { lock (_lock) { - if (_handlers.TryGetValue(eventType, out var handlers)) + if (_stopped) { - handlers.Remove(wrappedHandler); + return; } - } - }); - } - private sealed class Subscription(Action onDispose) : IDisposable - { - private int _disposed; + if (pluginEvent is ICoalescibleEvent { IsTerminalFrame: false }) + { + RemovePendingNonTerminalEventOfType(pluginEvent.GetType()); + } + + _queue.AddLast(pluginEvent); + if (_workerTask is null) + { + StartWorker(); + } + } + } public void Dispose() { - if (Interlocked.Exchange(ref _disposed, 1) == 0) + _owner.Unsubscribe(this); + } + + public void Stop() + { + var stoppedWithoutWorker = false; + lock (_lock) + { + if (_stopped) + { + return; + } + + _stopped = true; + _queue.Clear(); + if (_workerTask is null) + { + _completion.TrySetResult(); + stoppedWithoutWorker = true; + } + } + + if (stoppedWithoutWorker) + { + _owner.OnSubscriptionStopped(this); + } + } + + private void RemovePendingNonTerminalEventOfType(Type eventType) + { + for (var node = _queue.First; node is not null; node = node.Next) + { + if ( + node.Value.GetType() != eventType + || node.Value is not ICoalescibleEvent { IsTerminalFrame: false } + ) + { + continue; + } + + _queue.Remove(node); + return; + } + } + + private void StartWorker() + { + _workerTask = Task.Run(ProcessQueueAsync); + _ = _workerTask.ContinueWith( + static (workerTask, state) => + ((Subscription)state!).OnWorkerCompleted(workerTask), + this, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + + private async Task ProcessQueueAsync() + { + while (true) + { + object pluginEvent; + lock (_lock) + { + if (_stopped || _queue.First is null) + { + return; + } + + pluginEvent = _queue.First.Value; + _queue.RemoveFirst(); + } + + try + { + await _handler(pluginEvent).ConfigureAwait(false); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[PluginEventBus] Handler for {pluginEvent.GetType().Name} threw: {ex.Message}" + ); + } + } + } + + private void OnWorkerCompleted(Task workerTask) + { + if (workerTask.IsFaulted) + { + Trace.WriteLine( + $"[PluginEventBus] Subscription worker threw: {workerTask.Exception}" + ); + } + + var stopped = false; + lock (_lock) + { + if (!ReferenceEquals(_workerTask, workerTask)) + { + return; + } + + _workerTask = null; + if (_stopped) + { + _queue.Clear(); + _completion.TrySetResult(); + stopped = true; + } + else if (_queue.Count > 0) + { + StartWorker(); + } + } + + if (stopped) { - onDispose(); + _owner.OnSubscriptionStopped(this); } } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.PluginSDK/IPluginEventBus.cs b/src/TypeWhisper.PluginSDK/IPluginEventBus.cs index 65359a83b..4f7f96213 100644 --- a/src/TypeWhisper.PluginSDK/IPluginEventBus.cs +++ b/src/TypeWhisper.PluginSDK/IPluginEventBus.cs @@ -6,21 +6,44 @@ namespace TypeWhisper.PluginSDK; /// -/// Publish/subscribe event bus for plugin communication. Handlers are invoked on -/// background threads, so subscribers must not assume UI-thread affinity and must -/// keep work short — a slow handler blocks delivery to the rest of the chain. +/// Publish/subscribe event bus for plugin communication. Each subscription has +/// an independent background delivery queue: its handler is invoked in accepted +/// publish order and never re-entered, while other subscriptions continue +/// independently. A handler exception is isolated to that event and does not stop +/// later delivery. /// +/// +/// Pending non-terminal instances use latest-wins +/// delivery. A newer non-terminal event replaces an older pending non-terminal +/// event of the same runtime type and takes the newer event's position in the +/// queue. A terminal frame () is +/// always appended: it never replaces a pending event, and a later same-type event +/// never replaces it, so stream endpoints are delivered with full fidelity. +/// Non-coalescible events are never dropped. Bursts stay bounded because only +/// non-terminal frames coalesce and terminal frames are finite per stream; +/// non-coalescible producers remain responsible for limiting their publish rate. +/// +/// Disposing a subscription discards its queued, undelivered events; a handler +/// already in flight is allowed to complete. On host shutdown, the owning bus +/// abandons queued events and stops its workers, waiting for in-flight handlers up +/// to a bounded deadline; handlers still running past the deadline are abandoned so +/// disposal always completes. +/// // ReSharper disable once UnusedType.Global public interface IPluginEventBus { - /// Publishes an event to all subscribers of type . + /// + /// Enqueues an event for all current subscribers of type + /// and returns without waiting for handlers. + /// // ReSharper disable once UnusedMemberInSuper.Global void Publish(T pluginEvent) where T : PluginEvent; /// /// Subscribes to events of type . - /// Dispose the returned handle to unsubscribe. + /// Dispose the returned handle to unsubscribe, discard queued events, and + /// allow an in-flight handler invocation to complete. /// // ReSharper disable once UnusedMemberInSuper.Global IDisposable Subscribe(Func handler) diff --git a/src/TypeWhisper.PluginSDK/Models/LlmResponseTokenEvent.cs b/src/TypeWhisper.PluginSDK/Models/LlmResponseTokenEvent.cs index d636a4357..25ef346e9 100644 --- a/src/TypeWhisper.PluginSDK/Models/LlmResponseTokenEvent.cs +++ b/src/TypeWhisper.PluginSDK/Models/LlmResponseTokenEvent.cs @@ -5,7 +5,7 @@ namespace TypeWhisper.PluginSDK.Models; /// Raised as an LLM response streams in, carrying the accumulated text. // ReSharper disable once UnusedType.Global -public sealed record LlmResponseTokenEvent : PluginEvent +public sealed record LlmResponseTokenEvent : PluginEvent, ICoalescibleEvent { /// Full accumulated response text so far. // ReSharper disable once UnusedMember.Global @@ -25,6 +25,9 @@ public sealed record LlmResponseTokenEvent : PluginEvent // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global public bool IsFinal { get; init; } + /// + public bool IsTerminalFrame => IsFinal; + /// True when the terminal flush is due to a mid-stream fault. // ReSharper disable once UnusedMember.Global // ReSharper disable once UnusedAutoPropertyAccessor.Global diff --git a/src/TypeWhisper.PluginSDK/Models/PartialTranscriptionUpdateEvent.cs b/src/TypeWhisper.PluginSDK/Models/PartialTranscriptionUpdateEvent.cs index e20b190cb..375c933d8 100644 --- a/src/TypeWhisper.PluginSDK/Models/PartialTranscriptionUpdateEvent.cs +++ b/src/TypeWhisper.PluginSDK/Models/PartialTranscriptionUpdateEvent.cs @@ -5,7 +5,7 @@ namespace TypeWhisper.PluginSDK.Models; /// Raised when partial transcription text is updated during recording. // ReSharper disable once UnusedType.Global -public sealed record PartialTranscriptionUpdateEvent : PluginEvent +public sealed record PartialTranscriptionUpdateEvent : PluginEvent, ICoalescibleEvent { /// The current partial transcription text. // ReSharper disable once UnusedMember.Global @@ -19,6 +19,9 @@ public sealed record PartialTranscriptionUpdateEvent : PluginEvent // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global public bool IsRecording { get; init; } = true; + /// + public bool IsTerminalFrame => !IsRecording; + /// Elapsed seconds since recording started. // ReSharper disable once UnusedMember.Global // ReSharper disable once UnusedAutoPropertyAccessor.Global diff --git a/src/TypeWhisper.PluginSDK/Models/PluginEvent.cs b/src/TypeWhisper.PluginSDK/Models/PluginEvent.cs index fa8401264..6bae2fe02 100644 --- a/src/TypeWhisper.PluginSDK/Models/PluginEvent.cs +++ b/src/TypeWhisper.PluginSDK/Models/PluginEvent.cs @@ -3,6 +3,23 @@ // the host). Per-item, not file-level, so a genuinely-unused member added later still surfaces. namespace TypeWhisper.PluginSDK.Models; +/// +/// Marks an event type as a latest-wins update. While a handler is busy, an +/// event bus may replace an older pending event of the same runtime type with +/// the latest event. An event whose handler has already started is not replaced. +/// +// ReSharper disable once UnusedType.Global +public interface ICoalescibleEvent +{ + /// + /// True on a terminal frame (for example a stream's final flush). Terminal + /// frames are always appended: never replaced by a later same-type event, + /// and never used to replace a pending one. + /// + // ReSharper disable once UnusedMemberInSuper.Global + bool IsTerminalFrame { get; } +} + /// /// Base class for all plugin events published via the event bus. /// diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginEventBusTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginEventBusTests.cs index 0933b6abe..743dd9d29 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginEventBusTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginEventBusTests.cs @@ -206,6 +206,379 @@ public async Task ConcurrentPublishAndSubscribe_DoesNotThrow() } } + [Fact] + public async Task Publish_ToSlowSubscriber_DeliversFifoWithoutReentrancy() + { + const int eventCount = 12; + var handlerGate = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var firstEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var allDelivered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var received = new List(); + var activeHandlers = 0; + var delivered = 0; + var overlapped = 0; + + _bus.Subscribe(async pluginEvent => + { + if (Interlocked.Increment(ref activeHandlers) > 1) + { + // ReSharper disable once AccessToModifiedClosure -- overlapped is written from the handler and read in the test body; access is Interlocked by design. + Interlocked.Exchange(ref overlapped, 1); + } + + lock (received) + { + received.Add(pluginEvent.Sequence); + } + + firstEntered.TrySetResult(true); + try + { + await handlerGate.Task; + await Task.Delay(10); + } + finally + { + Interlocked.Decrement(ref activeHandlers); + if (Interlocked.Increment(ref delivered) == eventCount) + { + allDelivered.TrySetResult(true); + } + } + }); + + for (var sequence = 0; sequence < eventCount; sequence++) + { + _bus.Publish(new SequencedEvent(sequence)); + } + + try + { + await firstEntered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await Task.Delay(100); + } + finally + { + handlerGate.TrySetResult(true); + } + + await allDelivered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.Equal(0, Volatile.Read(ref overlapped)); + lock (received) + { + Assert.Equal(Enumerable.Range(0, eventCount), received); + } + } + + [Fact] + public async Task Publish_SlowSubscriber_DoesNotDelayIndependentSubscriber() + { + var slowHandlerGate = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var slowHandlerEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var slowHandlerCompleted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var fastHandlerCompleted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + _bus.Subscribe(async _ => + { + slowHandlerEntered.TrySetResult(true); + await slowHandlerGate.Task; + slowHandlerCompleted.TrySetResult(true); + }); + _bus.Subscribe(_ => + { + fastHandlerCompleted.TrySetResult(true); + return Task.CompletedTask; + }); + + _bus.Publish(new SequencedEvent(0)); + + try + { + await slowHandlerEntered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await fastHandlerCompleted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.False(slowHandlerCompleted.Task.IsCompleted); + } + finally + { + slowHandlerGate.TrySetResult(true); + } + + await slowHandlerCompleted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + } + + [Fact] + public async Task Publish_CoalescesLatestByType_WithoutDroppingDurableEvent() + { + var handlerGate = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var firstEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var latestDelivered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var received = new List(); + + _bus.Subscribe(async pluginEvent => + { + var description = pluginEvent switch + { + PartialTranscriptionUpdateEvent partial => + $"partial:{partial.PartialText}", + LlmResponseTokenEvent token => $"llm:{token.AccumulatedText}", + TranscriptionCompletedEvent completed => $"completed:{completed.Text}", + _ => throw new InvalidOperationException( + $"Unexpected event type {pluginEvent.GetType().Name}." + ), + }; + + lock (received) + { + received.Add(description); + } + + if ( + pluginEvent + is PartialTranscriptionUpdateEvent + { + PartialText: "partial-0", + } + ) + { + firstEntered.TrySetResult(true); + await handlerGate.Task; + } + + if ( + pluginEvent + is LlmResponseTokenEvent + { + AccumulatedText: "llm-2", + } + ) + { + latestDelivered.TrySetResult(true); + } + }); + + _bus.Publish( + new PartialTranscriptionUpdateEvent { PartialText = "partial-0" } + ); + + try + { + await firstEntered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + _bus.Publish( + new PartialTranscriptionUpdateEvent { PartialText = "partial-1" } + ); + _bus.Publish( + new PartialTranscriptionUpdateEvent { PartialText = "partial-2" } + ); + _bus.Publish( + new LlmResponseTokenEvent { AccumulatedText = "llm-1" } + ); + _bus.Publish( + new TranscriptionCompletedEvent { Text = "durable" } + ); + _bus.Publish( + new PartialTranscriptionUpdateEvent { PartialText = "partial-3" } + ); + _bus.Publish( + new LlmResponseTokenEvent { AccumulatedText = "llm-2" } + ); + + await Task.Delay(100); + lock (received) + { + Assert.Equal(["partial:partial-0"], received); + } + } + finally + { + handlerGate.TrySetResult(true); + } + + await latestDelivered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + lock (received) + { + Assert.Equal( + [ + "partial:partial-0", + "completed:durable", + "partial:partial-3", + "llm:llm-2", + ], + received + ); + } + } + + [Fact] + public async Task Dispose_Subscription_DiscardsQueueAndCompletesInFlightHandler() + { + var handlerGate = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var firstEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var inFlightCompleted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var received = new List(); + + var subscription = _bus.Subscribe(async pluginEvent => + { + lock (received) + { + received.Add(pluginEvent.Sequence); + } + + if (pluginEvent.Sequence == 0) + { + firstEntered.TrySetResult(true); + } + + await handlerGate.Task; + if (pluginEvent.Sequence == 0) + { + inFlightCompleted.TrySetResult(true); + } + }); + + _bus.Publish(new SequencedEvent(0)); + + try + { + await firstEntered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + for (var sequence = 1; sequence < 6; sequence++) + { + _bus.Publish(new SequencedEvent(sequence)); + } + + await Task.Delay(100); + subscription.Dispose(); + } + finally + { + handlerGate.TrySetResult(true); + } + + await inFlightCompleted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await Task.Delay(100); + lock (received) + { + Assert.Equal([0], received); + } + } + + [Fact] + public async Task ExceptionInHandler_DoesNotStopLaterQueuedEvent() + { + var handlerGate = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var firstEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var secondDelivered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + _bus.Subscribe(async pluginEvent => + { + if (pluginEvent.Sequence == 0) + { + firstEntered.TrySetResult(true); + await handlerGate.Task; + throw new InvalidOperationException("Boom!"); + } + + secondDelivered.TrySetResult(true); + }); + + _bus.Publish(new SequencedEvent(0)); + + try + { + await firstEntered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + _bus.Publish(new SequencedEvent(1)); + } + finally + { + handlerGate.TrySetResult(true); + } + + await secondDelivered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + } + + [Fact] + public async Task DisposeAsync_AbandonsQueueAndWaitsForInFlightWorker() + { + var handlerGate = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var firstEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var inFlightCompleted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var calls = 0; + + _bus.Subscribe(async pluginEvent => + { + // ReSharper disable once AccessToModifiedClosure -- calls is incremented from the handler and read in the test body; access is Interlocked by design. + Interlocked.Increment(ref calls); + if (pluginEvent.Sequence == 0) + { + firstEntered.TrySetResult(true); + await handlerGate.Task; + inFlightCompleted.TrySetResult(true); + } + }); + + _bus.Publish(new SequencedEvent(0)); + await firstEntered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + _bus.Publish(new SequencedEvent(1)); + + var disposeTask = _bus.DisposeAsync().AsTask(); + try + { + await Task.Delay(100); + Assert.False(disposeTask.IsCompleted); + } + finally + { + handlerGate.TrySetResult(true); + } + + await disposeTask.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.True(inFlightCompleted.Task.IsCompletedSuccessfully); + Assert.Equal(1, Volatile.Read(ref calls)); + + _bus.Publish(new SequencedEvent(2)); + await Task.Delay(100); + Assert.Equal(1, Volatile.Read(ref calls)); + } + [Fact] public async Task TranscriptionCompletedEvent_FullPayload() { @@ -269,4 +642,158 @@ public async Task TranscriptionFailedEvent_IsDelivered() Assert.Equal("timeout", received.ErrorMessage); Assert.Equal("m1", received.ModelId); } -} \ No newline at end of file + + [Fact] + public async Task Publish_QueuedTerminalFrame_SurvivesBurstOfLaterNonTerminalSameType() + { + var handlerGate = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var firstEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var latestDelivered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var received = new List(); + + _bus.Subscribe(async pluginEvent => + { + lock (received) + { + received.Add(pluginEvent.AccumulatedText); + } + + if (pluginEvent.AccumulatedText == "gate") + { + firstEntered.TrySetResult(true); + await handlerGate.Task; + } + + if (pluginEvent.AccumulatedText == "non-final-latest") + { + latestDelivered.TrySetResult(true); + } + }); + + _bus.Publish(new LlmResponseTokenEvent { AccumulatedText = "gate" }); + + try + { + await firstEntered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + _bus.Publish( + new LlmResponseTokenEvent { AccumulatedText = "final", IsFinal = true } + ); + _bus.Publish(new LlmResponseTokenEvent { AccumulatedText = "non-final-1" }); + _bus.Publish(new LlmResponseTokenEvent { AccumulatedText = "non-final-2" }); + _bus.Publish( + new LlmResponseTokenEvent { AccumulatedText = "non-final-latest" } + ); + } + finally + { + handlerGate.TrySetResult(true); + } + + await latestDelivered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + lock (received) + { + // "final" survives the burst undisplaced; only the latest non-final coalesces in. + Assert.Equal(["gate", "final", "non-final-latest"], received); + } + } + + [Fact] + public async Task Publish_TerminalFrame_NeverReplacesPendingNonTerminalSameType() + { + var handlerGate = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var firstEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var finalDelivered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var received = new List(); + + _bus.Subscribe(async pluginEvent => + { + lock (received) + { + received.Add(pluginEvent.AccumulatedText); + } + + if (pluginEvent.AccumulatedText == "gate") + { + firstEntered.TrySetResult(true); + await handlerGate.Task; + } + + if (pluginEvent is { IsFinal: true }) + { + finalDelivered.TrySetResult(true); + } + }); + + _bus.Publish(new LlmResponseTokenEvent { AccumulatedText = "gate" }); + + try + { + await firstEntered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + _bus.Publish(new LlmResponseTokenEvent { AccumulatedText = "non-final" }); + _bus.Publish( + new LlmResponseTokenEvent { AccumulatedText = "final", IsFinal = true } + ); + } + finally + { + handlerGate.TrySetResult(true); + } + + await finalDelivered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + lock (received) + { + // The terminal frame appends after the pending non-final one rather than + // coalescing it away; both are delivered, order preserved. + Assert.Equal(["gate", "non-final", "final"], received); + } + } + + [Fact] + public async Task Dispose_WithHungHandler_ReturnsWithinBoundedDeadline() + { + var handlerGate = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var firstEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + await using var bus = new PluginEventBus(TimeSpan.FromMilliseconds(200)); + + bus.Subscribe(async _ => + { + firstEntered.TrySetResult(true); + await handlerGate.Task; + }); + + bus.Publish(new SequencedEvent(0)); + await firstEntered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + try + { + // Handler never returns; the outer WaitAsync fails the test if disposal doesn't + // complete within its own bounded deadline. + // ReSharper disable once DisposeOnUsingVariable -- explicit dispose is the assertion under test; the await using re-dispose at scope end is idempotent. + await bus.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)); + } + finally + { + handlerGate.TrySetResult(true); + } + } + + private sealed record SequencedEvent(int Sequence) : PluginEvent; +} From 4b7ff3170d2de553e3804c57da047578235c4c90 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 22:39:44 +0000 Subject: [PATCH 168/226] Make plugin manifests authoritative for locality and category Category was optional free text and non-nullable IsLocal made an omitted field indistinguishable from an explicit false; only 4 of 32 manifests declared isLocal and 9 declared category. Four divergent authorities compensated - raw wizard values, a hardcoded locality classifier, an activation allowlist, and keyword-inference grouping - and disagreed: the classifier marked Webhook local even though it posts transcripts to arbitrary HTTP endpoints, so history claimed the text "stayed on this machine"; Supertonic's declared tts category rendered under Utility. The manifest schema now carries a PluginNetworkAccess enum (Local, Network, Mixed, UserControlled) and a categories set (legacy singular category and nullable isLocal remain readable for external manifests, documented obsolete). PluginLoader builds one normalized descriptor - valid manifest values win, the legacy classifier and inference survive only as an external-manifest fallback with Webhook and Script removed from its local list, and an unlabeled external plugin fails closed to Network. Every consumer reads the descriptor: wizard rows, settings badges and grouping, default activation, error categories, and the provenance producers, whose RanLocally is now strictly networkAccess == Local. All 32 bundled manifests declare the new fields (Qwen3Stt is user-controlled - a BYO localhost endpoint, not a fixed vendor), new labels exist in all four locales, bundled completeness is CI-enforced, and a parity test pins that wizard, settings, and history agree. PA45/PA46 track the publish-workflow schema and a cosmetic badge. --- .../manifest.json | 2 + .../TypeWhisper.Plugin.Cerebras/manifest.json | 2 + .../TypeWhisper.Plugin.Claude/manifest.json | 2 + .../manifest.json | 2 + .../TypeWhisper.Plugin.Cohere/manifest.json | 2 + .../TypeWhisper.Plugin.Deepgram/manifest.json | 2 + .../manifest.json | 4 +- .../manifest.json | 2 + .../manifest.json | 2 + .../TypeWhisper.Plugin.Gemini/manifest.json | 2 + .../manifest.json | 2 + .../TypeWhisper.Plugin.Gladia/manifest.json | 2 + .../manifest.json | 2 + plugins/TypeWhisper.Plugin.Groq/manifest.json | 2 + .../TypeWhisper.Plugin.Linear/manifest.json | 2 + .../TypeWhisper.Plugin.Obsidian/manifest.json | 2 + .../TypeWhisper.Plugin.OpenAi/manifest.json | 3 +- .../manifest.json | 2 + .../manifest.json | 2 + .../manifest.json | 3 +- .../TypeWhisper.Plugin.Qwen3Stt/manifest.json | 2 + .../TypeWhisper.Plugin.Reson8/manifest.json | 3 +- .../TypeWhisper.Plugin.Script/manifest.json | 2 + .../manifest.json | 2 + .../manifest.json | 3 +- .../TypeWhisper.Plugin.Soniox/manifest.json | 7 +- .../manifest.json | 2 + .../manifest.json | 4 +- .../TypeWhisper.Plugin.Voxtral/manifest.json | 2 + .../TypeWhisper.Plugin.Webhook/manifest.json | 2 + .../manifest.json | 3 +- plugins/TypeWhisper.Plugin.Xai/manifest.json | 3 +- .../Resources/Localization/de.json | 5 + .../Resources/Localization/en.json | 5 + .../Resources/Localization/es.json | 5 + .../Resources/Localization/ru.json | 5 + .../Services/MemoryService.cs | 4 +- .../Services/Plugins/PluginLoader.cs | 212 +++++++++++++- .../Plugins/PluginLocalityClassifier.cs | 36 +-- .../Services/Plugins/PluginManager.cs | 51 ++-- .../Services/PromptProcessingService.cs | 4 +- .../Services/TranslationService.cs | 4 +- .../Sections/PluginsSectionViewModel.cs | 263 +++++++----------- .../ViewModels/WelcomeWizardViewModel.cs | 3 +- .../Models/PluginCategory.cs | 25 ++ .../Models/PluginManifest.cs | 55 +++- .../Models/PluginNetworkAccess.cs | 22 ++ .../LocalizationResourcesTests.cs | 63 +++++ .../PluginCollectionSettingsViewModelTests.cs | 68 ++++- .../PluginMetadataConsumerParityTests.cs | 158 +++++++++++ .../PromptProcessingServiceTests.cs | 80 +++++- .../TestPluginManagerFactory.cs | 26 +- .../WelcomeWizardViewModelTests.cs | 35 ++- .../BundledPluginManifestTests.cs | 117 ++++++++ .../OpenAiPluginTests.cs | 2 +- .../OpenRouterPluginTests.cs | 2 +- .../PluginLoaderTests.cs | 129 +++++++++ .../PluginManagerTests.cs | 55 +++- .../PluginManifestTests.cs | 65 ++++- .../Reson8PluginTests.cs | 3 +- .../SmallestAiPluginTests.cs | 2 +- .../SonioxPluginTests.cs | 3 +- .../SupertonicTtsPluginTests.cs | 4 +- .../XaiPluginTests.cs | 2 +- 64 files changed, 1307 insertions(+), 285 deletions(-) create mode 100644 src/TypeWhisper.PluginSDK/Models/PluginCategory.cs create mode 100644 src/TypeWhisper.PluginSDK/Models/PluginNetworkAccess.cs create mode 100644 tests/TypeWhisper.Linux.Tests/PluginMetadataConsumerParityTests.cs create mode 100644 tests/TypeWhisper.PluginSystem.Tests/BundledPluginManifestTests.cs diff --git a/plugins/TypeWhisper.Plugin.AssemblyAi/manifest.json b/plugins/TypeWhisper.Plugin.AssemblyAi/manifest.json index 5747b0857..ae22f8ef0 100644 --- a/plugins/TypeWhisper.Plugin.AssemblyAi/manifest.json +++ b/plugins/TypeWhisper.Plugin.AssemblyAi/manifest.json @@ -4,6 +4,8 @@ "version": "1.1.2", "author": "TypeWhisper", "description": "AssemblyAI Universal-2 transcription engine", + "networkAccess": "network", + "categories": ["transcription"], "assemblyName": "TypeWhisper.Plugin.AssemblyAi.dll", "pluginClass": "TypeWhisper.Plugin.AssemblyAi.AssemblyAiPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Cerebras/manifest.json b/plugins/TypeWhisper.Plugin.Cerebras/manifest.json index a1de47c8c..008385061 100644 --- a/plugins/TypeWhisper.Plugin.Cerebras/manifest.json +++ b/plugins/TypeWhisper.Plugin.Cerebras/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Cerebras fast LLM inference", + "networkAccess": "network", + "categories": ["llm"], "assemblyName": "TypeWhisper.Plugin.Cerebras.dll", "pluginClass": "TypeWhisper.Plugin.Cerebras.CerebrasPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Claude/manifest.json b/plugins/TypeWhisper.Plugin.Claude/manifest.json index de467c711..39ae45e4e 100644 --- a/plugins/TypeWhisper.Plugin.Claude/manifest.json +++ b/plugins/TypeWhisper.Plugin.Claude/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Anthropic Claude LLM for prompt processing", + "networkAccess": "network", + "categories": ["llm"], "assemblyName": "TypeWhisper.Plugin.Claude.dll", "pluginClass": "TypeWhisper.Plugin.Claude.ClaudePlugin" } diff --git a/plugins/TypeWhisper.Plugin.CloudflareAsr/manifest.json b/plugins/TypeWhisper.Plugin.CloudflareAsr/manifest.json index 634c4d772..c4d6e2386 100644 --- a/plugins/TypeWhisper.Plugin.CloudflareAsr/manifest.json +++ b/plugins/TypeWhisper.Plugin.CloudflareAsr/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Cloudflare Workers AI Whisper transcription engine", + "networkAccess": "network", + "categories": ["transcription"], "assemblyName": "TypeWhisper.Plugin.CloudflareAsr.dll", "pluginClass": "TypeWhisper.Plugin.CloudflareAsr.CloudflareAsrPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Cohere/manifest.json b/plugins/TypeWhisper.Plugin.Cohere/manifest.json index c0adc6eb2..058a7248a 100644 --- a/plugins/TypeWhisper.Plugin.Cohere/manifest.json +++ b/plugins/TypeWhisper.Plugin.Cohere/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Cohere Command LLM for prompt processing", + "networkAccess": "network", + "categories": ["llm"], "assemblyName": "TypeWhisper.Plugin.Cohere.dll", "pluginClass": "TypeWhisper.Plugin.Cohere.CoherePlugin" } diff --git a/plugins/TypeWhisper.Plugin.Deepgram/manifest.json b/plugins/TypeWhisper.Plugin.Deepgram/manifest.json index 0bf466fc0..13a700887 100644 --- a/plugins/TypeWhisper.Plugin.Deepgram/manifest.json +++ b/plugins/TypeWhisper.Plugin.Deepgram/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.2", "author": "TypeWhisper", "description": "Deepgram Nova transcription engine", + "networkAccess": "network", + "categories": ["transcription"], "assemblyName": "TypeWhisper.Plugin.Deepgram.dll", "pluginClass": "TypeWhisper.Plugin.Deepgram.DeepgramPlugin" } diff --git a/plugins/TypeWhisper.Plugin.ElevenLabs/manifest.json b/plugins/TypeWhisper.Plugin.ElevenLabs/manifest.json index 073c801bd..4d2a62ebe 100644 --- a/plugins/TypeWhisper.Plugin.ElevenLabs/manifest.json +++ b/plugins/TypeWhisper.Plugin.ElevenLabs/manifest.json @@ -4,8 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Cloud transcription via ElevenLabs Scribe with real-time WebSocket streaming", - "category": "transcription", - "isLocal": false, + "networkAccess": "network", + "categories": ["transcription"], "requiresApiKey": true, "iconSystemName": "waveform.badge.mic", "descriptions": { diff --git a/plugins/TypeWhisper.Plugin.FileMemory/manifest.json b/plugins/TypeWhisper.Plugin.FileMemory/manifest.json index a8b9cf05a..317158f34 100644 --- a/plugins/TypeWhisper.Plugin.FileMemory/manifest.json +++ b/plugins/TypeWhisper.Plugin.FileMemory/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "File-based memory storage for extracted facts", + "networkAccess": "local", + "categories": ["memory"], "assemblyName": "TypeWhisper.Plugin.FileMemory.dll", "pluginClass": "TypeWhisper.Plugin.FileMemory.FileMemoryPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Fireworks/manifest.json b/plugins/TypeWhisper.Plugin.Fireworks/manifest.json index 9b264082f..616232461 100644 --- a/plugins/TypeWhisper.Plugin.Fireworks/manifest.json +++ b/plugins/TypeWhisper.Plugin.Fireworks/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Fireworks AI fast LLM inference", + "networkAccess": "network", + "categories": ["llm"], "assemblyName": "TypeWhisper.Plugin.Fireworks.dll", "pluginClass": "TypeWhisper.Plugin.Fireworks.FireworksPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Gemini/manifest.json b/plugins/TypeWhisper.Plugin.Gemini/manifest.json index db768fd1c..d7a251088 100644 --- a/plugins/TypeWhisper.Plugin.Gemini/manifest.json +++ b/plugins/TypeWhisper.Plugin.Gemini/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.1", "author": "TypeWhisper", "description": "Google Gemini LLM provider for prompt actions and translation", + "networkAccess": "network", + "categories": ["llm"], "assemblyName": "TypeWhisper.Plugin.Gemini.dll", "pluginClass": "TypeWhisper.Plugin.Gemini.GeminiPlugin" } diff --git a/plugins/TypeWhisper.Plugin.GemmaLocal/manifest.json b/plugins/TypeWhisper.Plugin.GemmaLocal/manifest.json index e7f0cd6fe..489b00090 100644 --- a/plugins/TypeWhisper.Plugin.GemmaLocal/manifest.json +++ b/plugins/TypeWhisper.Plugin.GemmaLocal/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Run Google Gemma 4 locally via Ollama or any OpenAI-compatible server", + "networkAccess": "local", + "categories": ["llm"], "assemblyName": "TypeWhisper.Plugin.GemmaLocal.dll", "pluginClass": "TypeWhisper.Plugin.GemmaLocal.GemmaLocalPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Gladia/manifest.json b/plugins/TypeWhisper.Plugin.Gladia/manifest.json index 70936171b..d5de6412e 100644 --- a/plugins/TypeWhisper.Plugin.Gladia/manifest.json +++ b/plugins/TypeWhisper.Plugin.Gladia/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Gladia speech-to-text transcription engine", + "networkAccess": "network", + "categories": ["transcription"], "assemblyName": "TypeWhisper.Plugin.Gladia.dll", "pluginClass": "TypeWhisper.Plugin.Gladia.GladiaPlugin" } diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json b/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json index 191ed356a..48a3a5f7a 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Google Cloud Speech-to-Text v2 transcription", + "networkAccess": "network", + "categories": ["transcription"], "assemblyName": "TypeWhisper.Plugin.GoogleCloudStt.dll", "pluginClass": "TypeWhisper.Plugin.GoogleCloudStt.GoogleCloudSttPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Groq/manifest.json b/plugins/TypeWhisper.Plugin.Groq/manifest.json index fdbc4e6ca..77cfea6c6 100644 --- a/plugins/TypeWhisper.Plugin.Groq/manifest.json +++ b/plugins/TypeWhisper.Plugin.Groq/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.3", "author": "TypeWhisper", "description": "Groq Whisper transcription and Llama translation", + "networkAccess": "network", + "categories": ["transcription", "llm"], "assemblyName": "TypeWhisper.Plugin.Groq.dll", "pluginClass": "TypeWhisper.Plugin.Groq.GroqPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Linear/manifest.json b/plugins/TypeWhisper.Plugin.Linear/manifest.json index 5f3207bd2..058ab9393 100644 --- a/plugins/TypeWhisper.Plugin.Linear/manifest.json +++ b/plugins/TypeWhisper.Plugin.Linear/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Create Linear issues from transcriptions", + "networkAccess": "network", + "categories": ["action"], "assemblyName": "TypeWhisper.Plugin.Linear.dll", "pluginClass": "TypeWhisper.Plugin.Linear.LinearPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Obsidian/manifest.json b/plugins/TypeWhisper.Plugin.Obsidian/manifest.json index b3557751f..38e1772be 100644 --- a/plugins/TypeWhisper.Plugin.Obsidian/manifest.json +++ b/plugins/TypeWhisper.Plugin.Obsidian/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Save transcriptions as notes in Obsidian", + "networkAccess": "local", + "categories": ["action"], "assemblyName": "TypeWhisper.Plugin.Obsidian.dll", "pluginClass": "TypeWhisper.Plugin.Obsidian.ObsidianPlugin" } diff --git a/plugins/TypeWhisper.Plugin.OpenAi/manifest.json b/plugins/TypeWhisper.Plugin.OpenAi/manifest.json index 3aeff84b6..a8eed17e2 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/manifest.json +++ b/plugins/TypeWhisper.Plugin.OpenAi/manifest.json @@ -4,7 +4,8 @@ "version": "1.2.0", "author": "TypeWhisper", "description": "OpenAI transcription, ChatGPT/OpenAI prompt processing, and text-to-speech. Use an API key for STT/TTS or ChatGPT login for prompts.", - "category": "transcription", + "networkAccess": "network", + "categories": ["transcription", "llm", "tts"], "assemblyName": "TypeWhisper.Plugin.OpenAi.dll", "pluginClass": "TypeWhisper.Plugin.OpenAi.OpenAiPlugin" } diff --git a/plugins/TypeWhisper.Plugin.OpenAiCompatible/manifest.json b/plugins/TypeWhisper.Plugin.OpenAiCompatible/manifest.json index e4b6efe87..53edcb5f0 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiCompatible/manifest.json +++ b/plugins/TypeWhisper.Plugin.OpenAiCompatible/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.1", "author": "TypeWhisper", "description": "Connect to any OpenAI-compatible server (Ollama, LM Studio, vLLM, etc.)", + "networkAccess": "userControlled", + "categories": ["transcription", "llm"], "assemblyName": "TypeWhisper.Plugin.OpenAiCompatible.dll", "pluginClass": "TypeWhisper.Plugin.OpenAiCompatible.OpenAiCompatiblePlugin" } diff --git a/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/manifest.json b/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/manifest.json index 3bd35aea2..c137fbf01 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/manifest.json +++ b/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Vector-based memory storage using OpenAI embeddings", + "networkAccess": "network", + "categories": ["memory"], "assemblyName": "TypeWhisper.Plugin.OpenAiVectorMemory.dll", "pluginClass": "TypeWhisper.Plugin.OpenAiVectorMemory.OpenAiVectorMemoryPlugin" } diff --git a/plugins/TypeWhisper.Plugin.OpenRouter/manifest.json b/plugins/TypeWhisper.Plugin.OpenRouter/manifest.json index b85493f45..db6a05e70 100644 --- a/plugins/TypeWhisper.Plugin.OpenRouter/manifest.json +++ b/plugins/TypeWhisper.Plugin.OpenRouter/manifest.json @@ -4,7 +4,8 @@ "version": "1.1.0", "author": "TypeWhisper", "description": "Access LLMs and speech-to-text models from OpenAI, Anthropic, Meta, Google and more via OpenRouter. Shows model pricing and account balance. Requires API key.", - "category": "llm", + "networkAccess": "network", + "categories": ["transcription", "llm"], "assemblyName": "TypeWhisper.Plugin.OpenRouter.dll", "pluginClass": "TypeWhisper.Plugin.OpenRouter.OpenRouterPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Qwen3Stt/manifest.json b/plugins/TypeWhisper.Plugin.Qwen3Stt/manifest.json index a5b673473..2c53f4928 100644 --- a/plugins/TypeWhisper.Plugin.Qwen3Stt/manifest.json +++ b/plugins/TypeWhisper.Plugin.Qwen3Stt/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Qwen3 ASR transcription via OpenAI-compatible endpoint", + "networkAccess": "userControlled", + "categories": ["transcription"], "assemblyName": "TypeWhisper.Plugin.Qwen3Stt.dll", "pluginClass": "TypeWhisper.Plugin.Qwen3Stt.Qwen3SttPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Reson8/manifest.json b/plugins/TypeWhisper.Plugin.Reson8/manifest.json index 6298203c3..341e594dd 100644 --- a/plugins/TypeWhisper.Plugin.Reson8/manifest.json +++ b/plugins/TypeWhisper.Plugin.Reson8/manifest.json @@ -4,9 +4,8 @@ "version": "1.0.0", "author": "Y. Vos", "description": "Cloud transcription via Reson8 speech-to-text API with real-time WebSocket streaming. Requires API key.", - "category": "transcription", + "networkAccess": "network", "categories": ["transcription"], - "isLocal": false, "requiresApiKey": true, "iconSystemName": "waveform.badge.mic", "descriptions": { diff --git a/plugins/TypeWhisper.Plugin.Script/manifest.json b/plugins/TypeWhisper.Plugin.Script/manifest.json index a180c1c67..f031a127f 100644 --- a/plugins/TypeWhisper.Plugin.Script/manifest.json +++ b/plugins/TypeWhisper.Plugin.Script/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Process transcriptions through custom shell scripts", + "networkAccess": "userControlled", + "categories": ["postProcessing"], "assemblyName": "TypeWhisper.Plugin.Script.dll", "pluginClass": "TypeWhisper.Plugin.Script.ScriptPlugin" } diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/manifest.json b/plugins/TypeWhisper.Plugin.SherpaOnnx/manifest.json index 26622940d..c3e719e6c 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/manifest.json +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.1", "author": "TypeWhisper", "description": "Offline transcription via sherpa-onnx (NVIDIA NeMo Parakeet + Canary)", + "networkAccess": "local", + "categories": ["transcription"], "assemblyName": "TypeWhisper.Plugin.SherpaOnnx.dll", "pluginClass": "TypeWhisper.Plugin.SherpaOnnx.SherpaOnnxPlugin" } diff --git a/plugins/TypeWhisper.Plugin.SmallestAi/manifest.json b/plugins/TypeWhisper.Plugin.SmallestAi/manifest.json index 6cca44e8e..7c6210daf 100644 --- a/plugins/TypeWhisper.Plugin.SmallestAi/manifest.json +++ b/plugins/TypeWhisper.Plugin.SmallestAi/manifest.json @@ -4,7 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Smallest AI Pulse speech-to-text transcription engine", - "category": "transcription", + "networkAccess": "network", + "categories": ["transcription"], "assemblyName": "TypeWhisper.Plugin.SmallestAi.dll", "pluginClass": "TypeWhisper.Plugin.SmallestAi.SmallestAiPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Soniox/manifest.json b/plugins/TypeWhisper.Plugin.Soniox/manifest.json index 9ebf83bfe..06d4e4581 100644 --- a/plugins/TypeWhisper.Plugin.Soniox/manifest.json +++ b/plugins/TypeWhisper.Plugin.Soniox/manifest.json @@ -4,11 +4,8 @@ "version": "1.0.3", "author": "TypeWhisper", "description": "Soniox speech-to-text transcription engine", - "category": "transcription", - "categories": [ - "transcription" - ], - "isLocal": false, + "networkAccess": "network", + "categories": ["transcription"], "requiresApiKey": true, "assemblyName": "TypeWhisper.Plugin.Soniox.dll", "pluginClass": "TypeWhisper.Plugin.Soniox.SonioxPlugin" diff --git a/plugins/TypeWhisper.Plugin.Speechmatics/manifest.json b/plugins/TypeWhisper.Plugin.Speechmatics/manifest.json index 9fdac4152..e02c28c7c 100644 --- a/plugins/TypeWhisper.Plugin.Speechmatics/manifest.json +++ b/plugins/TypeWhisper.Plugin.Speechmatics/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Speechmatics speech-to-text transcription engine", + "networkAccess": "network", + "categories": ["transcription"], "assemblyName": "TypeWhisper.Plugin.Speechmatics.dll", "pluginClass": "TypeWhisper.Plugin.Speechmatics.SpeechmaticsPlugin" } diff --git a/plugins/TypeWhisper.Plugin.SupertonicTts/manifest.json b/plugins/TypeWhisper.Plugin.SupertonicTts/manifest.json index 18fa82c35..401dcb936 100644 --- a/plugins/TypeWhisper.Plugin.SupertonicTts/manifest.json +++ b/plugins/TypeWhisper.Plugin.SupertonicTts/manifest.json @@ -4,8 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Local Supertonic 3 text-to-speech provider. Downloads OpenRAIL-M model assets on demand and runs synthesis on-device with ONNX Runtime.", - "category": "tts", - "isLocal": true, + "networkAccess": "local", + "categories": ["tts"], "assemblyName": "TypeWhisper.Plugin.SupertonicTts.dll", "pluginClass": "TypeWhisper.Plugin.SupertonicTts.SupertonicTtsPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Voxtral/manifest.json b/plugins/TypeWhisper.Plugin.Voxtral/manifest.json index 8a9a2e8fc..549c4dbec 100644 --- a/plugins/TypeWhisper.Plugin.Voxtral/manifest.json +++ b/plugins/TypeWhisper.Plugin.Voxtral/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Voxtral (Mistral) audio transcription and translation", + "networkAccess": "network", + "categories": ["transcription"], "assemblyName": "TypeWhisper.Plugin.Voxtral.dll", "pluginClass": "TypeWhisper.Plugin.Voxtral.VoxtralPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Webhook/manifest.json b/plugins/TypeWhisper.Plugin.Webhook/manifest.json index 9a9d177aa..0d3f3e205 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/manifest.json +++ b/plugins/TypeWhisper.Plugin.Webhook/manifest.json @@ -4,6 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Sends event notifications to a webhook URL", + "networkAccess": "userControlled", + "categories": ["integration"], "assemblyName": "TypeWhisper.Plugin.Webhook.dll", "pluginClass": "TypeWhisper.Plugin.Webhook.WebhookPlugin" } diff --git a/plugins/TypeWhisper.Plugin.WhisperCpp/manifest.json b/plugins/TypeWhisper.Plugin.WhisperCpp/manifest.json index 3d4f43bfb..2a329bce6 100644 --- a/plugins/TypeWhisper.Plugin.WhisperCpp/manifest.json +++ b/plugins/TypeWhisper.Plugin.WhisperCpp/manifest.json @@ -4,7 +4,8 @@ "version": "1.0.0", "author": "TypeWhisper", "description": "Offline transcription via whisper.cpp using Whisper.net", - "category": "transcription", + "networkAccess": "local", + "categories": ["transcription"], "assemblyName": "TypeWhisper.Plugin.WhisperCpp.dll", "pluginClass": "TypeWhisper.Plugin.WhisperCpp.WhisperCppPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Xai/manifest.json b/plugins/TypeWhisper.Plugin.Xai/manifest.json index 30f308e0f..ebd998eec 100644 --- a/plugins/TypeWhisper.Plugin.Xai/manifest.json +++ b/plugins/TypeWhisper.Plugin.Xai/manifest.json @@ -4,7 +4,8 @@ "version": "1.1.0", "author": "TypeWhisper", "description": "Cloud LLM, speech-to-text, and text-to-speech via xAI Grok APIs. Requires an xAI API key.", - "category": "transcription", + "networkAccess": "network", + "categories": ["transcription", "llm", "tts"], "assemblyName": "TypeWhisper.Plugin.Xai.dll", "pluginClass": "TypeWhisper.Plugin.Xai.XaiPlugin" } diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index df917e5e0..8f41b0289 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -472,11 +472,16 @@ "Plugins.BadgeDisabled": "Deaktiviert", "Plugins.BadgeEnabled": "Aktiv", "Plugins.BadgeLocal": "Lokal", + "Plugins.BadgeMixed": "Gemischt", + "Plugins.BadgeUserControlled": "Benutzergesteuert", "Plugins.CategoryAction": "Aktionen", + "Plugins.CategoryIntegration": "Integrationen", "Plugins.CategoryLlm": "LLM-Anbieter", "Plugins.CategoryMemory": "Speicher", "Plugins.CategoryPostProcessing": "Nachbearbeitung", "Plugins.CategoryTranscription": "Transkriptions-Engines", + "Plugins.CategoryTts": "Sprachausgabe", + "Plugins.CategoryUnknown": "Unbekannt", "Plugins.CategoryUtility": "Hilfsfunktionen", "Plugins.EditValuesHint": "Bearbeiten Sie die Werte unten und klicken Sie auf Speichern.", "Plugins.ExpandToEdit": "Ausklappen, um die Plugin-Einstellungen zu bearbeiten.", diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index 3a9406f3b..6e52754f8 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -473,11 +473,16 @@ "Plugins.BadgeDisabled": "Disabled", "Plugins.BadgeEnabled": "Enabled", "Plugins.BadgeLocal": "Local", + "Plugins.BadgeMixed": "Mixed", + "Plugins.BadgeUserControlled": "User controlled", "Plugins.CategoryAction": "Actions", + "Plugins.CategoryIntegration": "Integrations", "Plugins.CategoryLlm": "LLM Providers", "Plugins.CategoryMemory": "Memory", "Plugins.CategoryPostProcessing": "Post-Processors", "Plugins.CategoryTranscription": "Transcription Engines", + "Plugins.CategoryTts": "Text-to-Speech", + "Plugins.CategoryUnknown": "Unknown", "Plugins.CategoryUtility": "Utilities", "Plugins.EditValuesHint": "Edit the values below and click Save.", "Plugins.ExpandToEdit": "Expand to edit plugin settings.", diff --git a/src/TypeWhisper.Linux/Resources/Localization/es.json b/src/TypeWhisper.Linux/Resources/Localization/es.json index 39e7d986a..f78a6f1c2 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -472,11 +472,16 @@ "Plugins.BadgeDisabled": "Desactivado", "Plugins.BadgeEnabled": "Activado", "Plugins.BadgeLocal": "Local", + "Plugins.BadgeMixed": "Mixto", + "Plugins.BadgeUserControlled": "Controlado por el usuario", "Plugins.CategoryAction": "Acciones", + "Plugins.CategoryIntegration": "Integraciones", "Plugins.CategoryLlm": "Proveedores de LLM", "Plugins.CategoryMemory": "Memoria", "Plugins.CategoryPostProcessing": "Posprocesadores", "Plugins.CategoryTranscription": "Motores de transcripción", + "Plugins.CategoryTts": "Texto a voz", + "Plugins.CategoryUnknown": "Desconocido", "Plugins.CategoryUtility": "Utilidades", "Plugins.EditValuesHint": "Edita los valores de abajo y haz clic en Guardar.", "Plugins.ExpandToEdit": "Despliega para editar los ajustes del plugin.", diff --git a/src/TypeWhisper.Linux/Resources/Localization/ru.json b/src/TypeWhisper.Linux/Resources/Localization/ru.json index 3ada98245..a3f721790 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -472,11 +472,16 @@ "Plugins.BadgeDisabled": "Отключено", "Plugins.BadgeEnabled": "Включено", "Plugins.BadgeLocal": "Локально", + "Plugins.BadgeMixed": "Смешанный", + "Plugins.BadgeUserControlled": "Управляется пользователем", "Plugins.CategoryAction": "Действия", + "Plugins.CategoryIntegration": "Интеграции", "Plugins.CategoryLlm": "LLM-провайдеры", "Plugins.CategoryMemory": "Память", "Plugins.CategoryPostProcessing": "Постобработка", "Plugins.CategoryTranscription": "Движки транскрипции", + "Plugins.CategoryTts": "Синтез речи", + "Plugins.CategoryUnknown": "Неизвестно", "Plugins.CategoryUtility": "Утилиты", "Plugins.EditValuesHint": "Измените значения ниже и нажмите «Сохранить».", "Plugins.ExpandToEdit": "Разверните, чтобы изменить настройки плагина.", diff --git a/src/TypeWhisper.Linux/Services/MemoryService.cs b/src/TypeWhisper.Linux/Services/MemoryService.cs index a26d6f980..0df0ce9b2 100644 --- a/src/TypeWhisper.Linux/Services/MemoryService.cs +++ b/src/TypeWhisper.Linux/Services/MemoryService.cs @@ -127,7 +127,7 @@ string userPrompt var providerId = provider.GetLlmSelectionId(); var plugin = _pluginManager.GetPlugin(providerId); - var ranLocally = plugin is not null && PluginLocalityClassifier.IsLocal(plugin.Manifest); + var ranLocally = plugin?.Metadata.RanLocally ?? false; var provenance = new LlmCallProvenance { @@ -171,4 +171,4 @@ string userPrompt return null; } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs index 4a65885cf..bca635790 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using System.Diagnostics; using System.Reflection; using System.Runtime.Loader; @@ -11,11 +12,38 @@ public sealed record LoadedPlugin( PluginManifest Manifest, ITypeWhisperPlugin Instance, PluginAssemblyLoadContext LoadContext, - string PluginDirectory + string PluginDirectory, + PluginMetadataDescriptor Metadata ); public sealed record PluginLoadFailure(string PluginDirectory, string Message); +/// +/// Validated, normalized plugin metadata consumed throughout the host. +/// +public sealed class PluginMetadataDescriptor +{ + public PluginMetadataDescriptor( + PluginNetworkAccess networkAccess, + IEnumerable categories + ) + { + NetworkAccess = networkAccess; + Categories = categories.ToFrozenSet(); + if (Categories.Count == 0) + { + throw new ArgumentException( + "A plugin metadata descriptor requires at least one category.", + nameof(categories) + ); + } + } + + public PluginNetworkAccess NetworkAccess { get; } + public IReadOnlySet Categories { get; } + public bool RanLocally => NetworkAccess == PluginNetworkAccess.Local; +} + /// /// Isolated assembly load context for each plugin, enabling per-plugin /// dependency resolution. Collectible so plugins can be unloaded. @@ -146,6 +174,8 @@ public List DiscoverAndLoad(IEnumerable searchDirectories) return null; } + var metadata = ResolveMetadata(manifest); + if ( !AppVersion.IsHostCompatible( manifest.MinHostVersion, @@ -259,6 +289,184 @@ out var incompatibilityReason localizationAware.SetLocalization(new PluginLocalization(pluginDir)); } - return new LoadedPlugin(manifest, instance, loadContext, pluginDir); + return new LoadedPlugin(manifest, instance, loadContext, pluginDir, metadata); + } + + internal static PluginMetadataDescriptor ResolveMetadata(PluginManifest manifest) + { + ArgumentNullException.ThrowIfNull(manifest); + + var networkAccess = manifest.NetworkAccess; + if (networkAccess is { } declaredNetworkAccess) + { + if (!Enum.IsDefined(declaredNetworkAccess)) + { + throw new InvalidDataException( + $"Plugin '{manifest.Id}' declares an invalid networkAccess value." + ); + } + } + else + { + networkAccess = PluginLocalityClassifier.ResolveLegacy(manifest); + } + + var categories = manifest.Categories; + if (categories is { Length: 0 }) + { + throw new InvalidDataException( + $"Plugin '{manifest.Id}' declares an empty categories array." + ); + } + + if (categories is not null) + { + if ( + categories.Any(category => + !Enum.IsDefined(category) || category == PluginCategory.Unknown + ) + ) + { + throw new InvalidDataException( + $"Plugin '{manifest.Id}' declares an invalid category." + ); + } + + return new PluginMetadataDescriptor(networkAccess.Value, categories); + } + + return new PluginMetadataDescriptor( + networkAccess.Value, + [InferLegacyCategory(manifest)] + ); + } + + private static PluginCategory InferLegacyCategory(PluginManifest manifest) + { + var id = manifest.Id.Trim().ToLowerInvariant(); + if (s_legacyTranscriptionPluginIds.Contains(id)) + { + return PluginCategory.Transcription; + } + + if (s_legacyLlmPluginIds.Contains(id)) + { + return PluginCategory.Llm; + } + + if (s_legacyActionPluginIds.Contains(id)) + { + return PluginCategory.Action; + } + + if (s_legacyMemoryPluginIds.Contains(id)) + { + return PluginCategory.Memory; + } + + if (s_legacyUtilityPluginIds.Contains(id)) + { + return PluginCategory.Utility; + } + + var combined = $"{manifest.Name} {manifest.Description}".ToLowerInvariant(); + if ( + combined.Contains("transcription") + || combined.Contains("speech-to-text") + || combined.Contains("speech to text") + || combined.Contains("asr") + ) + { + return PluginCategory.Transcription; + } + + if ( + combined.Contains("llm") + || combined.Contains("prompt") + || combined.Contains("inference") + || combined.Contains("multi-model") + ) + { + return PluginCategory.Llm; + } + + if (combined.Contains("text-to-speech") || combined.Contains("tts")) + { + return PluginCategory.Tts; + } + + if (combined.Contains("memory")) + { + return PluginCategory.Memory; + } + + if (combined.Contains("webhook")) + { + return PluginCategory.Integration; + } + + if ( + combined.Contains("issue") + || combined.Contains("obsidian") + || combined.Contains("script") + ) + { + return PluginCategory.Action; + } + + return PluginCategory.Unknown; } + + private static readonly FrozenSet s_legacyTranscriptionPluginIds = + new[] + { + "com.typewhisper.assemblyai", + "com.typewhisper.cloudflare-asr", + "com.typewhisper.deepgram", + "com.typewhisper.gladia", + "com.typewhisper.google-cloud-stt", + "com.typewhisper.openai", + "com.typewhisper.qwen3-stt", + "com.typewhisper.sherpa-onnx", + "com.typewhisper.soniox", + "com.typewhisper.speechmatics", + "com.typewhisper.voxtral", + "com.typewhisper.whisper-cpp", + }.ToFrozenSet(StringComparer.Ordinal); + + private static readonly FrozenSet s_legacyLlmPluginIds = + new[] + { + "com.typewhisper.cerebras", + "com.typewhisper.claude", + "com.typewhisper.cohere", + "com.typewhisper.fireworks", + "com.typewhisper.gemini", + "com.typewhisper.gemma-local", + "com.typewhisper.groq", + "com.typewhisper.openai-compatible", + "com.typewhisper.openrouter", + }.ToFrozenSet(StringComparer.Ordinal); + + private static readonly FrozenSet s_legacyActionPluginIds = + new[] + { + "com.typewhisper.linear", + "com.typewhisper.obsidian", + "com.typewhisper.script", + "com.typewhisper.webhook", + }.ToFrozenSet(StringComparer.Ordinal); + + private static readonly FrozenSet s_legacyMemoryPluginIds = + new[] + { + "com.typewhisper.file-memory", + "com.typewhisper.openai-vector-memory", + }.ToFrozenSet(StringComparer.Ordinal); + + private static readonly FrozenSet s_legacyUtilityPluginIds = + new[] + { + "com.typewhisper.openai-compatible", + }.ToFrozenSet(StringComparer.Ordinal); } diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs index b4b916d6e..af7dec3c5 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs @@ -3,18 +3,12 @@ namespace TypeWhisper.Linux.Services.Plugins; /// -/// Decides whether a plugin runs on-device or calls out to the network. -/// Shared by the Plugins settings badges and the history Inspect provenance -/// ("Stayed on this machine") so both agree on whether a call left the machine. -/// Classification is deterministic: a manifest's explicit -/// flag or a known on-device id. Bundled -/// local plugins that omit the flag (e.g. "Gemma 4 (Local)") are listed -/// explicitly. Anything else defaults to non-local — for a privacy badge, -/// wrongly claiming a call stayed on-device is worse than wrongly showing that -/// it was sent to a provider, so locality is never inferred from free-text -/// name/description keywords (which a cloud plugin could trivially trip). +/// Compatibility-only locality fallback for external manifests that predate +/// . New metadata is normalized once +/// by and consumers must use the resulting descriptor. +/// Unknown plugins fail closed to . /// -public static class PluginLocalityClassifier +internal static class PluginLocalityClassifier { private static readonly HashSet s_knownLocalPluginIds = [ @@ -23,11 +17,21 @@ public static class PluginLocalityClassifier "com.typewhisper.gemma-local", "com.typewhisper.file-memory", "com.typewhisper.obsidian", - "com.typewhisper.script", - "com.typewhisper.webhook", ]; - public static bool IsLocal(PluginManifest manifest) => - manifest.IsLocal - || s_knownLocalPluginIds.Contains(manifest.Id.Trim().ToLowerInvariant()); + public static PluginNetworkAccess ResolveLegacy(PluginManifest manifest) + { + if (manifest.IsLocal is { } declaredIsLocal) + { + return declaredIsLocal + ? PluginNetworkAccess.Local + : PluginNetworkAccess.Network; + } + + return s_knownLocalPluginIds.Contains( + manifest.Id.Trim().ToLowerInvariant() + ) + ? PluginNetworkAccess.Local + : PluginNetworkAccess.Network; + } } diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs index b429b967a..bef5154e7 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs @@ -4,6 +4,7 @@ using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; using TypeWhisper.PluginSDK; +using TypeWhisper.PluginSDK.Models; namespace TypeWhisper.Linux.Services.Plugins; @@ -16,14 +17,6 @@ public sealed class PluginManager : IDisposable { private static readonly TimeSpan s_defaultPluginShutdownTimeout = TimeSpan.FromSeconds(5); - // Fresh-install defaults: offline transcription engines only, so dictation works - // out of the box without a key. Cloud providers default off until opted in. - private static readonly HashSet s_defaultEnabledPluginIds = new(StringComparer.Ordinal) - { - "com.typewhisper.whisper-cpp", // offline transcription (recommended default) - "com.typewhisper.sherpa-onnx", // offline transcription - }; - private readonly HashSet _activatedPlugins = []; private readonly ConcurrentDictionary> _activationTasks = new(); private readonly IActiveWindowService _activeWindow; @@ -401,12 +394,11 @@ public async Task InitializeAsync() foreach (var plugin in discovered) { - // Honor saved choice; otherwise enable local/offline engines by default so a - // fresh install has working transcription without an API key. IsLocal in the - // manifest is unreliable across plugins, so we anchor on an explicit allowlist. + // Honor saved choice; otherwise default-enable plugins whose metadata + // marks them local-only. var isEnabled = enabledState.TryGetValue(plugin.Manifest.Id, out var state) ? state - : s_defaultEnabledPluginIds.Contains(plugin.Manifest.Id) || plugin.Manifest.IsLocal; + : IsEnabledByDefault(plugin); if (isEnabled) { @@ -418,6 +410,11 @@ public async Task InitializeAsync() await MigrateApiKeysAsync(); } + internal static bool IsEnabledByDefault(LoadedPlugin plugin) + { + return plugin.Metadata.NetworkAccess == PluginNetworkAccess.Local; + } + public async Task EnablePluginAsync(string pluginId) { var plugin = GetPlugin(pluginId); @@ -721,22 +718,26 @@ private async Task ActivatePluginAsync(LoadedPlugin plugin) } } - // Pick the error-log category for a plugin's host.Log(Error) calls. The manifest - // Category is the plugin's self-declared primary role, but most bundled plugins omit - // it — so fall back to the runtime capability interfaces (transcription engines log - // under Transcription, LLM providers under Prompt) before the generic Plugin bucket. + // Same normalized categories as the UI (Transcription, then Llm, take priority). + // Legacy manifests that normalized to Unknown fall back to the instance's + // capability interfaces instead of the generic bucket. private static string ResolveErrorCategory(LoadedPlugin plugin) { - return plugin.Manifest.Category?.Trim().ToLowerInvariant() switch + if (plugin.Metadata.Categories.Contains(PluginCategory.Transcription)) { - "transcription" => ErrorCategory.Transcription, - "llm" or "prompt" => ErrorCategory.Prompt, - _ => plugin.Instance switch - { - ITranscriptionEnginePlugin => ErrorCategory.Transcription, - ILlmProviderPlugin => ErrorCategory.Prompt, - _ => ErrorCategory.Plugin, - }, + return ErrorCategory.Transcription; + } + + if (plugin.Metadata.Categories.Contains(PluginCategory.Llm)) + { + return ErrorCategory.Prompt; + } + + return plugin.Instance switch + { + ITranscriptionEnginePlugin => ErrorCategory.Transcription, + ILlmProviderPlugin => ErrorCategory.Prompt, + _ => ErrorCategory.Plugin, }; } diff --git a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs index 9594a36a5..a43c5b1fd 100644 --- a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs +++ b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs @@ -217,7 +217,7 @@ public async Task ProcessSystemPromptAsync( var providerId = provider.GetLlmSelectionId(); var plugin = _pluginManager.GetPlugin(providerId); - var ranLocally = plugin is not null && PluginLocalityClassifier.IsLocal(plugin.Manifest); + var ranLocally = plugin?.Metadata.RanLocally ?? false; var provenance = new LlmCallProvenance { @@ -314,4 +314,4 @@ string pluginModelId return provider is null ? (null, string.Empty) : (provider, modelId); } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/TranslationService.cs b/src/TypeWhisper.Linux/Services/TranslationService.cs index 191731652..ff131d6ca 100644 --- a/src/TypeWhisper.Linux/Services/TranslationService.cs +++ b/src/TypeWhisper.Linux/Services/TranslationService.cs @@ -102,7 +102,7 @@ string userPrompt var providerId = provider.GetLlmSelectionId(); var plugin = _pluginManager.GetPlugin(providerId); - var ranLocally = plugin is not null && PluginLocalityClassifier.IsLocal(plugin.Manifest); + var ranLocally = plugin?.Metadata.RanLocally ?? false; var provenance = new LlmCallProvenance { @@ -404,4 +404,4 @@ internal sealed record LoadedTranslationModel( InferenceSession Decoder, MarianTokenizer Tokenizer, MarianConfig Config -); \ No newline at end of file +); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs index d0bd496b6..698e0b093 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs @@ -20,54 +20,6 @@ public partial class PluginsSectionViewModel : ObservableObject private static readonly TimeSpan s_defaultPluginBoundaryTimeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan s_defaultPluginValidationTimeout = TimeSpan.FromMinutes(10); - private static readonly HashSet s_transcriptionPluginIds = - [ - "com.typewhisper.assemblyai", - "com.typewhisper.cloudflare-asr", - "com.typewhisper.deepgram", - "com.typewhisper.gladia", - "com.typewhisper.google-cloud-stt", - "com.typewhisper.openai", - "com.typewhisper.qwen3-stt", - "com.typewhisper.sherpa-onnx", - "com.typewhisper.soniox", - "com.typewhisper.speechmatics", - "com.typewhisper.voxtral", - "com.typewhisper.whisper-cpp", - ]; - - private static readonly HashSet s_llmPluginIds = - [ - "com.typewhisper.cerebras", - "com.typewhisper.claude", - "com.typewhisper.cohere", - "com.typewhisper.fireworks", - "com.typewhisper.gemini", - "com.typewhisper.gemma-local", - "com.typewhisper.groq", - "com.typewhisper.openai-compatible", - "com.typewhisper.openrouter", - ]; - - private static readonly HashSet s_actionPluginIds = - [ - "com.typewhisper.linear", - "com.typewhisper.obsidian", - "com.typewhisper.script", - "com.typewhisper.webhook", - ]; - - private static readonly HashSet s_memoryPluginIds = - [ - "com.typewhisper.file-memory", - "com.typewhisper.openai-vector-memory", - ]; - - private static readonly HashSet s_utilityPluginIds = - [ - "com.typewhisper.openai-compatible", - ]; - private readonly IErrorLogService? _errorLog; private readonly Dictionary _pluginById = []; private readonly TimeSpan _pluginBoundaryTimeout; @@ -139,6 +91,7 @@ private void RebuildPluginRows(PluginListRefreshKind refreshKind) // Preserve expanded state across rebuilds so the user doesn't lose their open settings panel. var existingRows = PluginGroups .SelectMany(group => group.Plugins) + .DistinctBy(plugin => plugin.Id) .ToDictionary(plugin => plugin.Id, StringComparer.Ordinal); var expandedPluginId = existingRows.Values.FirstOrDefault(plugin => plugin.IsExpanded)?.Id; @@ -215,8 +168,7 @@ is IPluginCollectionSettingsProvider collectionSettingsProvider "Manifest.Description", plugin.Manifest.Description ?? "" ), - InferCategory(plugin.Manifest), - InferIsLocal(plugin.Manifest), + plugin.Metadata, hasExpandableSettings || settingsDefinitionFailed, _pluginManager.IsEnabled(plugin.Manifest.Id) ) { LoadedPlugin = plugin }; @@ -237,8 +189,7 @@ is IPluginCollectionSettingsProvider collectionSettingsProvider plugin.Manifest.Name, plugin.Manifest.Version, plugin.Manifest.Description ?? "", - InferCategory(plugin.Manifest), - InferIsLocal(plugin.Manifest), + plugin.Metadata, plugin.Instance is IPluginSettingsProvider or IPluginCollectionSettingsProvider, _pluginManager.IsEnabled(plugin.Manifest.Id) @@ -253,10 +204,23 @@ plugin.Instance is IPluginSettingsProvider .ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase) .ToList(); - foreach (var group in plugins.GroupBy(p => p.CategoryKey)) + var categoryMemberships = plugins + .SelectMany(plugin => + plugin.Categories.Select(category => + new + { + Plugin = plugin, + Category = PluginCategories.Resolve(category), + } + ) + ) + .OrderBy(item => item.Category.SortOrder) + .ThenBy(item => item.Plugin.Name, StringComparer.OrdinalIgnoreCase); + + foreach (var group in categoryMemberships.GroupBy(item => item.Category.Key)) { - var categoryPlugins = group.ToList(); - var categoryLabel = categoryPlugins[0].CategoryLabel; + var categoryPlugins = group.Select(item => item.Plugin).ToList(); + var categoryLabel = group.First().Category.DisplayName; PluginGroups.Add(new PluginCategoryGroup(categoryLabel, categoryPlugins)); } @@ -840,83 +804,6 @@ private static string LocalizeManifest(PluginLocalization loc, string key, strin var localized = loc.GetString(key); return string.Equals(localized, key, StringComparison.Ordinal) ? fallback : localized; } - - // Local-vs-cloud inference is shared with the history Inspect provenance badges. - private static bool InferIsLocal(PluginManifest manifest) => - PluginLocalityClassifier.IsLocal(manifest); - - // Manifest Category takes precedence; fall back to known-ID lists then keyword heuristics. - private static string? InferCategory(PluginManifest manifest) - { - if (!string.IsNullOrWhiteSpace(manifest.Category)) - { - return manifest.Category; - } - - var id = manifest.Id.Trim().ToLowerInvariant(); - if (s_transcriptionPluginIds.Contains(id)) - { - return "transcription"; - } - - if (s_llmPluginIds.Contains(id)) - { - return "llm"; - } - - if (s_actionPluginIds.Contains(id)) - { - return "action"; - } - - if (s_memoryPluginIds.Contains(id)) - { - return "memory"; - } - - if (s_utilityPluginIds.Contains(id)) - { - return "utility"; - } - - var combined = $"{manifest.Name} {manifest.Description}".ToLowerInvariant(); - if ( - combined.Contains("transcription") - || combined.Contains("speech-to-text") - || combined.Contains("speech to text") - || combined.Contains("asr") - ) - { - return "transcription"; - } - - if ( - combined.Contains("llm") - || combined.Contains("prompt") - || combined.Contains("inference") - || combined.Contains("multi-model") - ) - { - return "llm"; - } - - if (combined.Contains("memory")) - { - return "memory"; - } - - if ( - combined.Contains("issue") - || combined.Contains("obsidian") - || combined.Contains("webhook") - || combined.Contains("script") - ) - { - return "action"; - } - - return "utility"; - } } public sealed class PluginCategoryGroup @@ -956,8 +843,7 @@ public PluginRow( string name, string version, string description, - string? category, - bool isLocal, + PluginMetadataDescriptor metadata, bool hasExpandableSettings, bool isEnabled ) @@ -967,11 +853,15 @@ bool isEnabled Name = name; Version = version; Description = description; - IsLocal = isLocal; + NetworkAccess = metadata.NetworkAccess; + Categories = metadata.Categories; HasExpandableSettings = hasExpandableSettings; IsEnabled = isEnabled; - var descriptor = PluginCategories.Resolve(category); + var descriptor = Categories + .Select(PluginCategories.Resolve) + .OrderBy(category => category.SortOrder) + .First(); CategoryKey = descriptor.Key; CategoryLabel = descriptor.DisplayName; CategorySortOrder = descriptor.SortOrder; @@ -984,14 +874,40 @@ bool isEnabled public string CategoryKey { get; } public string CategoryLabel { get; } public int CategorySortOrder { get; } - private bool IsLocal { get; } - public string LocationBadge => - IsLocal ? Loc.Instance["Plugins.BadgeLocal"] : Loc.Instance["Plugins.BadgeCloud"]; + public IReadOnlySet Categories { get; } + public PluginNetworkAccess NetworkAccess { get; } + public bool RanLocally => NetworkAccess == PluginNetworkAccess.Local; + public string LocationBadge => NetworkAccess switch + { + PluginNetworkAccess.Local => Loc.Instance["Plugins.BadgeLocal"], + PluginNetworkAccess.Network => Loc.Instance["Plugins.BadgeCloud"], + PluginNetworkAccess.Mixed => Loc.Instance["Plugins.BadgeMixed"], + PluginNetworkAccess.UserControlled => Loc.Instance["Plugins.BadgeUserControlled"], + _ => Loc.Instance["Plugins.BadgeCloud"], + }; public string StatusBadge => IsEnabled ? Loc.Instance["Plugins.BadgeEnabled"] : Loc.Instance["Plugins.BadgeDisabled"]; - public string LocationBadgeBackground => IsLocal ? "#1B2F24" : "#1A3453"; - public string LocationBadgeBorder => IsLocal ? "#2F5E45" : "#2E5B89"; - public string LocationBadgeForeground => IsLocal ? "#D8F3E5" : "#D6E7FF"; + public string LocationBadgeBackground => NetworkAccess switch + { + PluginNetworkAccess.Local => "#1B2F24", + PluginNetworkAccess.Mixed => "#30264A", + PluginNetworkAccess.UserControlled => "#3A2C16", + _ => "#1A3453", + }; + public string LocationBadgeBorder => NetworkAccess switch + { + PluginNetworkAccess.Local => "#2F5E45", + PluginNetworkAccess.Mixed => "#66518F", + PluginNetworkAccess.UserControlled => "#80622C", + _ => "#2E5B89", + }; + public string LocationBadgeForeground => NetworkAccess switch + { + PluginNetworkAccess.Local => "#D8F3E5", + PluginNetworkAccess.Mixed => "#E4D9FF", + PluginNetworkAccess.UserControlled => "#FFE7B3", + _ => "#D6E7FF", + }; public string StatusBadgeBackground => IsEnabled ? "#173222" : "#3A1F1F"; public string StatusBadgeBorder => IsEnabled ? "#2F7D4E" : "#8A3A3A"; public string StatusBadgeForeground => IsEnabled ? "#D9FBE7" : "#FFD9D9"; @@ -1340,38 +1256,55 @@ internal sealed record PluginCategoryInfo(string Key, string DisplayName, int So internal static class PluginCategories { - public static PluginCategoryInfo Resolve(string? rawCategory) + public static PluginCategoryInfo Resolve(PluginCategory category) { - return Normalize(rawCategory) switch + return category switch { - "transcription" => new PluginCategoryInfo( + PluginCategory.Transcription => new PluginCategoryInfo( "transcription", Loc.Instance["Plugins.CategoryTranscription"], 0 ), - "llm" => new PluginCategoryInfo("llm", Loc.Instance["Plugins.CategoryLlm"], 1), - "post-processing" => new PluginCategoryInfo( - "post-processing", - Loc.Instance["Plugins.CategoryPostProcessing"], + PluginCategory.Llm => new PluginCategoryInfo( + "llm", + Loc.Instance["Plugins.CategoryLlm"], + 1 + ), + PluginCategory.Tts => new PluginCategoryInfo( + "tts", + Loc.Instance["Plugins.CategoryTts"], 2 ), - "action" => new PluginCategoryInfo("action", Loc.Instance["Plugins.CategoryAction"], 3), - "memory" => new PluginCategoryInfo("memory", Loc.Instance["Plugins.CategoryMemory"], 4), - _ => new PluginCategoryInfo("utility", Loc.Instance["Plugins.CategoryUtility"], 5), - }; - } - - private static string Normalize(string? rawCategory) - { - return rawCategory?.Trim().ToLowerInvariant() switch - { - "transcription" => "transcription", - "llm" => "llm", - "postprocessing" or "post-processing" or "postprocessor" or "post-processor" => + PluginCategory.PostProcessing => new PluginCategoryInfo( "post-processing", - "action" => "action", - "memory" => "memory", - _ => "utility", + Loc.Instance["Plugins.CategoryPostProcessing"], + 3 + ), + PluginCategory.Action => new PluginCategoryInfo( + "action", + Loc.Instance["Plugins.CategoryAction"], + 4 + ), + PluginCategory.Memory => new PluginCategoryInfo( + "memory", + Loc.Instance["Plugins.CategoryMemory"], + 5 + ), + PluginCategory.Integration => new PluginCategoryInfo( + "integration", + Loc.Instance["Plugins.CategoryIntegration"], + 6 + ), + PluginCategory.Utility => new PluginCategoryInfo( + "utility", + Loc.Instance["Plugins.CategoryUtility"], + 7 + ), + _ => new PluginCategoryInfo( + "unknown", + Loc.Instance["Plugins.CategoryUnknown"], + 8 + ), }; } } diff --git a/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs b/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs index 66ff94da0..f505db70c 100644 --- a/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs @@ -421,8 +421,7 @@ private void LoadExtensions() p.Manifest.Name, p.Manifest.Version, p.Manifest.Description ?? "", - p.Manifest.Category, - p.Manifest.IsLocal, + p.Metadata, false, _pluginManager.IsEnabled(p.Manifest.Id) ) diff --git a/src/TypeWhisper.PluginSDK/Models/PluginCategory.cs b/src/TypeWhisper.PluginSDK/Models/PluginCategory.cs new file mode 100644 index 000000000..638de1cf9 --- /dev/null +++ b/src/TypeWhisper.PluginSDK/Models/PluginCategory.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace TypeWhisper.PluginSDK.Models; + +/// +/// A capability category exposed by a plugin. Plugins may declare more than one. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PluginCategory +{ + Transcription, + Llm, + Tts, + PostProcessing, + Action, + Memory, + Integration, + Utility, + + /// + /// Host fallback for an external plugin whose capability cannot be determined. + /// Bundled manifests must not declare this value. + /// + Unknown, +} diff --git a/src/TypeWhisper.PluginSDK/Models/PluginManifest.cs b/src/TypeWhisper.PluginSDK/Models/PluginManifest.cs index 52bbb9a51..4568ed1ae 100644 --- a/src/TypeWhisper.PluginSDK/Models/PluginManifest.cs +++ b/src/TypeWhisper.PluginSDK/Models/PluginManifest.cs @@ -45,17 +45,45 @@ public sealed record PluginManifest // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global public string? Description { get; init; } - /// Plugin category for UI grouping (e.g. "transcription", "llm", "memory", "action", "utility"). + /// + /// Legacy singular category, superseded by . Recognized + /// values map into when the plural field is absent. + /// // ReSharper disable once UnusedMember.Global // ReSharper disable once UnusedAutoPropertyAccessor.Global // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global public string? Category { get; init; } - /// Whether this is a local (on-device) or cloud-based plugin. + /// + /// Capability categories used for host grouping and routing. Bundled manifests + /// must declare a non-empty set. + /// // ReSharper disable once UnusedMember.Global // ReSharper disable once UnusedAutoPropertyAccessor.Global // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global - public bool IsLocal { get; init; } + public PluginCategory[]? Categories + { + get => field ?? MapLegacyCategory(Category); + init; + } + + /// + /// Declares whether plugin operations remain local or can use the network. + /// Bundled manifests must declare this field. + /// + // ReSharper disable once UnusedMember.Global + // ReSharper disable once UnusedAutoPropertyAccessor.Global + // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global + public PluginNetworkAccess? NetworkAccess { get; init; } + + /// + /// Obsolete legacy locality flag retained for external-manifest compatibility. + /// New manifests must use . Null means omitted. + /// + // ReSharper disable once UnusedMember.Global + // ReSharper disable once UnusedAutoPropertyAccessor.Global + // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global + public bool? IsLocal { get; init; } /// DLL file name containing the plugin type (e.g. "MyPlugin.dll"). // ReSharper disable once UnusedMember.Global @@ -68,4 +96,25 @@ public sealed record PluginManifest // ReSharper disable once UnusedAutoPropertyAccessor.Global // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global public required string PluginClass { get; init; } + + private static PluginCategory[]? MapLegacyCategory(string? category) + { + var mapped = category?.Trim().ToLowerInvariant() switch + { + "transcription" => PluginCategory.Transcription, + "llm" or "prompt" => PluginCategory.Llm, + "tts" or "text-to-speech" => PluginCategory.Tts, + "postprocessing" + or "post-processing" + or "postprocessor" + or "post-processor" => PluginCategory.PostProcessing, + "action" => PluginCategory.Action, + "memory" => PluginCategory.Memory, + "integration" => PluginCategory.Integration, + "utility" => PluginCategory.Utility, + _ => (PluginCategory?)null, + }; + + return mapped is { } value ? [value] : null; + } } diff --git a/src/TypeWhisper.PluginSDK/Models/PluginNetworkAccess.cs b/src/TypeWhisper.PluginSDK/Models/PluginNetworkAccess.cs new file mode 100644 index 000000000..ef1c72e9c --- /dev/null +++ b/src/TypeWhisper.PluginSDK/Models/PluginNetworkAccess.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace TypeWhisper.PluginSDK.Models; + +/// +/// Describes whether plugin operations can send data beyond the local machine. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PluginNetworkAccess +{ + /// All processing stays on the local machine. + Local, + + /// The plugin sends data to a fixed network service. + Network, + + /// The plugin combines local processing with network service calls. + Mixed, + + /// The destination or executable behavior is selected by the user. + UserControlled, +} diff --git a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs index cfbbf60d0..f3306b5af 100644 --- a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs @@ -148,6 +148,69 @@ public void CanonicalCatalog_HasDesktopIntegrationStaleAndRefreshMessages() Assert.Contains("Refresh desktop integration", en["Shortcuts.RefreshDesktopIntegrationOn"]); } + [Theory] + [InlineData( + "en", + "Local", + "Cloud", + "Mixed", + "User controlled", + "Text-to-Speech", + "Integrations", + "Unknown" + )] + [InlineData( + "de", + "Lokal", + "Cloud", + "Gemischt", + "Benutzergesteuert", + "Sprachausgabe", + "Integrationen", + "Unbekannt" + )] + [InlineData( + "es", + "Local", + "Nube", + "Mixto", + "Controlado por el usuario", + "Texto a voz", + "Integraciones", + "Desconocido" + )] + [InlineData( + "ru", + "Локально", + "Облако", + "Смешанный", + "Управляется пользователем", + "Синтез речи", + "Интеграции", + "Неизвестно" + )] + public void Catalogs_HaveNetworkAccessAndNewCategoryLabels( + string language, + string local, + string network, + string mixed, + string userControlled, + string tts, + string integration, + string unknown + ) + { + var catalog = Load(language); + + Assert.Equal(local, catalog["Plugins.BadgeLocal"]); + Assert.Equal(network, catalog["Plugins.BadgeCloud"]); + Assert.Equal(mixed, catalog["Plugins.BadgeMixed"]); + Assert.Equal(userControlled, catalog["Plugins.BadgeUserControlled"]); + Assert.Equal(tts, catalog["Plugins.CategoryTts"]); + Assert.Equal(integration, catalog["Plugins.CategoryIntegration"]); + Assert.Equal(unknown, catalog["Plugins.CategoryUnknown"]); + } + [Theory] [MemberData(nameof(NonCanonicalLanguages))] public void TranslationKeysAreSubsetOfEnglish(string lang) diff --git a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs index 4afd45894..832f4526d 100644 --- a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs @@ -4,6 +4,7 @@ using TypeWhisper.Linux.Services.Plugins; using TypeWhisper.Linux.ViewModels.Sections; using TypeWhisper.PluginSDK; +using TypeWhisper.PluginSDK.Models; using Xunit; namespace TypeWhisper.Linux.Tests; @@ -142,6 +143,61 @@ public void HasExpandableSettings_TrueForCollectionOnlyPlugin() Assert.True(row.HasExpandableSettings); } + [Fact] + public void Descriptor_DrivesLocationBadgeAndEveryCategoryGroup() + { + var plugin = new FakeCollectionPlugin(); + var loaded = TestPluginManagerFactory.CreateLoadedPlugin( + _tempDir, + plugin.PluginId, + plugin, + PluginNetworkAccess.Mixed, + [PluginCategory.Tts, PluginCategory.Integration] + ); + var manager = TestPluginManagerFactory.Create(loadedPlugins: [loaded]); + + var vm = new PluginsSectionViewModel(manager); + + Assert.Equal( + ["Text-to-Speech", "Integrations"], + vm.PluginGroups.Select(group => group.Title).ToArray() + ); + var rows = vm.PluginGroups.SelectMany(group => group.Plugins).ToArray(); + Assert.Equal(2, rows.Length); + Assert.Same(rows[0], rows[1]); + Assert.Equal(PluginNetworkAccess.Mixed, rows[0].NetworkAccess); + Assert.True( + rows[0].Categories.SetEquals( + [PluginCategory.Tts, PluginCategory.Integration] + ) + ); + Assert.Equal("Mixed", rows[0].LocationBadge); + Assert.False(rows[0].RanLocally); + } + + [Fact] + public void TtsDescriptor_RendersSupertonicUnderTtsInsteadOfUtility() + { + var plugin = new FakeSettingsPlugin("com.typewhisper.supertonic-tts"); + var loaded = TestPluginManagerFactory.CreateLoadedPlugin( + _tempDir, + plugin.PluginId, + plugin, + PluginNetworkAccess.Local, + [PluginCategory.Tts] + ); + var manager = TestPluginManagerFactory.Create(loadedPlugins: [loaded]); + + var vm = new PluginsSectionViewModel(manager); + + var group = Assert.Single(vm.PluginGroups); + Assert.Equal("Text-to-Speech", group.Title); + var row = Assert.Single(group.Plugins); + Assert.Equal("tts", row.CategoryKey); + Assert.Equal("Local", row.LocationBadge); + Assert.True(row.RanLocally); + } + [Fact] public async Task Refresh_DefinitionThrow_MarksOnlyThrowingPluginFailed_AndKeepsOtherPluginFunctional() { @@ -571,8 +627,10 @@ public void FieldRow_UnknownPersistedDropdownValue_SelectsSentinelWithoutDirtyin "P", "1", "", - "utility", - true, + new PluginMetadataDescriptor( + PluginNetworkAccess.Local, + [PluginCategory.Utility] + ), true, true ); @@ -999,8 +1057,10 @@ private static PluginCollectionRow CreateCollectionRow(params PluginCollectionIt "P", "1", "", - "utility", - true, + new PluginMetadataDescriptor( + PluginNetworkAccess.Local, + [PluginCategory.Utility] + ), true, true ); diff --git a/tests/TypeWhisper.Linux.Tests/PluginMetadataConsumerParityTests.cs b/tests/TypeWhisper.Linux.Tests/PluginMetadataConsumerParityTests.cs new file mode 100644 index 000000000..97b05b787 --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/PluginMetadataConsumerParityTests.cs @@ -0,0 +1,158 @@ +using Moq; +using System.Runtime.CompilerServices; +using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Hotkey; +using TypeWhisper.Linux.ViewModels; +using TypeWhisper.Linux.ViewModels.Sections; +using TypeWhisper.PluginSDK; +using TypeWhisper.PluginSDK.Models; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +public sealed class PluginMetadataConsumerParityTests +{ + [Theory] + [InlineData(PluginNetworkAccess.Local, "Local", true)] + [InlineData(PluginNetworkAccess.Network, "Cloud", false)] + [InlineData(PluginNetworkAccess.Mixed, "Mixed", false)] + [InlineData(PluginNetworkAccess.UserControlled, "User controlled", false)] + public void Descriptor_WizardSettingsAndRanLocallyProjectionAgree( + PluginNetworkAccess networkAccess, + string expectedBadge, + bool expectedRanLocally + ) + { + var plugin = new FakePlugin(); + var loaded = TestPluginManagerFactory.CreateLoadedPlugin( + Path.GetTempPath(), + plugin.PluginId, + plugin, + networkAccess, + [PluginCategory.Tts] + ); + var settings = TestPluginManagerFactory.CreateSettings(AppSettings.Default); + using var pluginManager = TestPluginManagerFactory.Create( + loadedPlugins: [loaded] + ); + using var models = new ModelManagerService(pluginManager, settings.Object); + using var hotkey = new HotkeyService( + new BackendSelector(static () => new TestShortcutBackend()) + ); + using var audio = new AudioRecordingService(_ => { }, () => 0, () => { }); + var textInsertion = new TextInsertionService( + new NoOpTextInsertionPlatform() + ); + var dictionary = new Mock(); + var wizard = new WelcomeWizardViewModel( + models, + pluginManager, + hotkey, + audio, + CreateCommandsWithoutHostProbes(), + textInsertion, + [], + dictionary.Object, + settings.Object, + availableMics: [] + ); + + try + { + var wizardRow = Assert.Single(wizard.ExtensionPlugins); + var settingsViewModel = new PluginsSectionViewModel(pluginManager); + var settingsGroup = Assert.Single(settingsViewModel.PluginGroups); + var settingsRow = Assert.Single(settingsGroup.Plugins); + + Assert.Equal("Text-to-Speech", settingsGroup.Title); + Assert.Equal(networkAccess, loaded.Metadata.NetworkAccess); + Assert.Equal(networkAccess, wizardRow.NetworkAccess); + Assert.Equal(networkAccess, settingsRow.NetworkAccess); + Assert.Equal(expectedBadge, wizardRow.LocationBadge); + Assert.Equal(expectedBadge, settingsRow.LocationBadge); + Assert.Equal(expectedRanLocally, loaded.Metadata.RanLocally); + Assert.Equal(expectedRanLocally, wizardRow.RanLocally); + Assert.Equal(expectedRanLocally, settingsRow.RanLocally); + } + finally + { + wizard.Cleanup(); + } + } + + private static SystemCommandAvailabilityService CreateCommandsWithoutHostProbes() + { + var commands = (SystemCommandAvailabilityService) + RuntimeHelpers.GetUninitializedObject( + typeof(SystemCommandAvailabilityService) + ); + commands.RaiseSnapshotChangedForTests( + new LinuxCapabilitySnapshot( + "Unknown", + false, + "none", + false, + false, + false, + false, + null, + false, + false, + false, + false, + false + ) + ); + return commands; + } + + private sealed class FakePlugin : ITypeWhisperPlugin + { + public string PluginId => "com.test.metadata-parity"; + public string PluginName => "Metadata parity"; + public string PluginVersion => "1.0.0"; + + public Task ActivateAsync(IPluginHostServices host) => Task.CompletedTask; + public Task DeactivateAsync() => Task.CompletedTask; + public void Dispose() { } + } + + private sealed class NoOpTextInsertionPlatform : ITextInsertionPlatform + { + public bool IsClipboardSetAvailable => false; + public bool IsPasteAvailable => false; + public bool IsKdePlasma => false; + public bool PrefersDirectTypingForUnknownTarget => false; + public InsertionFailureReason LastFailureReason => + InsertionFailureReason.None; + public bool LastTypingDeliveredPartialText => false; + + public Task TryGetClipboardTextAsync() => + Task.FromResult(null); + + public Task SetClipboardTextAsync(string text) => + Task.FromResult(false); + + public Task ClipboardHasNonTextFormatsAsync() => + Task.FromResult(false); + + public Task DelayAsync(TimeSpan delay) => Task.CompletedTask; + public string? GetActiveWindowId() => null; + + public Task ActivateWindowAsync(string windowId) => + Task.FromResult(false); + + public Task SendPasteAsync(bool useTerminalShortcut = false) => + Task.FromResult(false); + + public Task TypeTextAsync(string text) => + Task.FromResult(false); + + public Task SendCopyAsync(bool useTerminalShortcut) => + Task.FromResult(false); + + public Task SendEnterAsync() => Task.FromResult(false); + } +} diff --git a/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs index 02d8f6dcc..6c994be84 100644 --- a/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs @@ -245,12 +245,18 @@ await sut.ProcessAsync( } [Fact] - public async Task ProcessAsync_WithLocalPlugin_MarksRanLocallyFromManifest() + public async Task ProcessAsync_WithLocalDescriptor_MarksRanLocally() { var provider = new FakeLlmProviderPlugin("com.test.local", "Local Provider", "model-l"); using var pluginManager = CreatePluginManager( [provider], - [CreateLoadedPlugin(provider.PluginId, provider, isLocal: true)] + [ + CreateLoadedPlugin( + provider.PluginId, + provider, + PluginNetworkAccess.Local + ), + ] ); var settings = CreateSettings( new AppSettings { DefaultLlmProvider = "plugin:com.test.local:model-l" } @@ -274,6 +280,51 @@ await sut.ProcessAsync( Assert.True(call.RanLocally); } + [Theory] + [InlineData(PluginNetworkAccess.Network)] + [InlineData(PluginNetworkAccess.Mixed)] + [InlineData(PluginNetworkAccess.UserControlled)] + public async Task ProcessAsync_WithNonLocalDescriptor_DoesNotMarkRanLocally( + PluginNetworkAccess networkAccess + ) + { + var provider = new FakeLlmProviderPlugin( + "com.test.non-local", + "Non-local Provider", + "model-n" + ); + using var pluginManager = CreatePluginManager( + [provider], + [CreateLoadedPlugin(provider.PluginId, provider, networkAccess)] + ); + var settings = CreateSettings( + new AppSettings + { + DefaultLlmProvider = "plugin:com.test.non-local:model-n", + } + ); + var sut = new PromptProcessingService( + pluginManager, + settings.Object, + new MemoryService(pluginManager) + ); + + var capture = new LlmCallCapture(); + await sut.ProcessAsync( + new PromptAction + { + Id = "prompt", + Name = "Rewrite", + SystemPrompt = "Rewrite this", + }, + "hello", + capture, + CancellationToken.None + ); + + Assert.False(Assert.Single(capture.Calls).RanLocally); + } + [Fact] public async Task ProcessAsync_WithNullCapture_RecordsNothing() { @@ -448,25 +499,28 @@ IReadOnlyList loadedPlugins private LoadedPlugin CreateLoadedPlugin( string pluginId, ITypeWhisperPlugin plugin, - bool isLocal = false + PluginNetworkAccess networkAccess = PluginNetworkAccess.Network ) { var pluginDir = Path.Join(_tempDir, pluginId); Directory.CreateDirectory(pluginDir); + var manifest = new PluginManifest + { + Id = pluginId, + Name = plugin.PluginName, + Version = plugin.PluginVersion, + AssemblyName = "fake.dll", + PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name, + NetworkAccess = networkAccess, + Categories = [PluginCategory.Llm], + }; return new LoadedPlugin( - new PluginManifest - { - Id = pluginId, - Name = plugin.PluginName, - Version = plugin.PluginVersion, - AssemblyName = "fake.dll", - PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name, - IsLocal = isLocal, - }, + manifest, plugin, new PluginAssemblyLoadContext(pluginDir), - pluginDir + pluginDir, + PluginLoader.ResolveMetadata(manifest) ); } diff --git a/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs b/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs index 4b752bdf7..6754c6f7c 100644 --- a/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs +++ b/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs @@ -70,21 +70,27 @@ public static Mock CreateSettings(AppSettings current) public static LoadedPlugin CreateLoadedPlugin( string pluginDir, string pluginId, - ITypeWhisperPlugin plugin + ITypeWhisperPlugin plugin, + PluginNetworkAccess networkAccess = PluginNetworkAccess.Network, + IReadOnlyList? categories = null ) { + var manifest = new PluginManifest + { + Id = pluginId, + Name = plugin.PluginName, + Version = plugin.PluginVersion, + AssemblyName = "fake.dll", + PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name, + NetworkAccess = networkAccess, + Categories = (categories ?? [PluginCategory.Utility]).ToArray(), + }; return new LoadedPlugin( - new PluginManifest - { - Id = pluginId, - Name = plugin.PluginName, - Version = plugin.PluginVersion, - AssemblyName = "fake.dll", - PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name, - }, + manifest, plugin, new PluginAssemblyLoadContext(pluginDir), - pluginDir + pluginDir, + PluginLoader.ResolveMetadata(manifest) ); } diff --git a/tests/TypeWhisper.Linux.Tests/WelcomeWizardViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/WelcomeWizardViewModelTests.cs index 3d745bf4a..d5059d7d3 100644 --- a/tests/TypeWhisper.Linux.Tests/WelcomeWizardViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WelcomeWizardViewModelTests.cs @@ -164,6 +164,34 @@ public async Task ModelDownloadWithoutCleanup_CompletesAndAppliesMutations() ); } + [Fact] + public void ExtensionRow_UsesNormalizedDescriptorMetadata() + { + var plugin = new FakeTranscriptionPlugin(); + var loaded = TestPluginManagerFactory.CreateLoadedPlugin( + Path.GetTempPath(), + plugin.PluginId, + plugin, + PluginNetworkAccess.UserControlled, + [PluginCategory.Transcription, PluginCategory.Tts] + ); + using var harness = CreateHarness( + plugin, + loadedPlugins: [loaded] + ); + + var row = Assert.Single(harness.ViewModel.ExtensionPlugins); + + Assert.Equal(PluginNetworkAccess.UserControlled, row.NetworkAccess); + Assert.True( + row.Categories.SetEquals( + [PluginCategory.Transcription, PluginCategory.Tts] + ) + ); + Assert.Equal("User controlled", row.LocationBadge); + Assert.False(row.RanLocally); + } + private static Mock CreateSetupTask() { var setupTask = new Mock(); @@ -179,11 +207,14 @@ private static Mock CreateSetupTask() private static TestHarness CreateHarness( FakeTranscriptionPlugin? plugin = null, - IReadOnlyList? setupTasks = null + IReadOnlyList? setupTasks = null, + IReadOnlyList? loadedPlugins = null ) { var settings = TestPluginManagerFactory.CreateSettings(AppSettings.Default); - var pluginManager = TestPluginManagerFactory.Create(); + var pluginManager = TestPluginManagerFactory.Create( + loadedPlugins: loadedPlugins + ); if (plugin is not null) { SetTranscriptionEngines(pluginManager, [plugin]); diff --git a/tests/TypeWhisper.PluginSystem.Tests/BundledPluginManifestTests.cs b/tests/TypeWhisper.PluginSystem.Tests/BundledPluginManifestTests.cs new file mode 100644 index 000000000..1cbffc7fd --- /dev/null +++ b/tests/TypeWhisper.PluginSystem.Tests/BundledPluginManifestTests.cs @@ -0,0 +1,117 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using TypeWhisper.PluginSDK.Models; + +namespace TypeWhisper.PluginSystem.Tests; + +public sealed class BundledPluginManifestTests +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + [Fact] + public void AllBundledManifests_DeclareCompleteNormalizedMetadata() + { + var manifestPaths = ManifestPaths(); + + Assert.Equal(32, manifestPaths.Length); + foreach (var path in manifestPaths) + { + var json = File.ReadAllText(path); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + Assert.True( + root.TryGetProperty("networkAccess", out var networkAccessElement), + $"{path} does not declare networkAccess." + ); + Assert.Equal(JsonValueKind.String, networkAccessElement.ValueKind); + Assert.True( + Enum.TryParse( + networkAccessElement.GetString(), + true, + out var declaredNetworkAccess + ) + && Enum.IsDefined(declaredNetworkAccess), + $"{path} declares an invalid networkAccess." + ); + + Assert.True( + root.TryGetProperty("categories", out var categoriesElement), + $"{path} does not declare categories." + ); + Assert.Equal(JsonValueKind.Array, categoriesElement.ValueKind); + var categoryValues = categoriesElement.EnumerateArray().ToArray(); + Assert.NotEmpty(categoryValues); + foreach (var categoryElement in categoryValues) + { + Assert.Equal(JsonValueKind.String, categoryElement.ValueKind); + Assert.True( + Enum.TryParse( + categoryElement.GetString(), + true, + out var category + ) + && Enum.IsDefined(category) + && category != PluginCategory.Unknown, + $"{path} declares an invalid category." + ); + } + + Assert.False( + root.TryGetProperty("category", out _), + $"{path} still declares the legacy category field." + ); + Assert.False( + root.TryGetProperty("isLocal", out _), + $"{path} still declares the legacy isLocal field." + ); + + var manifest = JsonSerializer.Deserialize(json, s_jsonOptions); + Assert.NotNull(manifest); + Assert.Equal(declaredNetworkAccess, manifest.NetworkAccess); + Assert.NotNull(manifest.Categories); + Assert.NotEmpty(manifest.Categories); + } + } + + [Fact] + public void Webhook_IsUserControlledAndNeverLocal() + { + var path = Assert.Single( + ManifestPaths(), + candidate => + candidate.EndsWith( + Path.Join("TypeWhisper.Plugin.Webhook", "manifest.json"), + StringComparison.Ordinal + ) + ); + var manifest = JsonSerializer.Deserialize( + File.ReadAllText(path), + s_jsonOptions + ); + + Assert.NotNull(manifest); + Assert.Equal(PluginNetworkAccess.UserControlled, manifest.NetworkAccess); + Assert.NotEqual(PluginNetworkAccess.Local, manifest.NetworkAccess); + Assert.Equal([PluginCategory.Integration], manifest.Categories); + } + + private static string[] ManifestPaths( + [CallerFilePath] string thisFile = "" + ) + { + var testDirectory = Path.GetDirectoryName(thisFile)!; + var pluginsDirectory = Path.GetFullPath( + Path.Join(testDirectory, "..", "..", "plugins") + ); + return Directory + .EnumerateDirectories(pluginsDirectory, "TypeWhisper.Plugin.*") + .Select(directory => Path.Join(directory, "manifest.json")) + .Where(File.Exists) + .Order(StringComparer.Ordinal) + .ToArray(); + } +} diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs index f453f78ff..296f33419 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs @@ -33,7 +33,7 @@ public void Manifest_AdvertisesOpenAiPluginIdentity() Assert.Equal("com.typewhisper.openai", manifest.GetProperty("id").GetString()); Assert.Equal("OpenAI / ChatGPT", manifest.GetProperty("name").GetString()); - Assert.Equal("transcription", manifest.GetProperty("category").GetString()); + Assert.Equal(["transcription", "llm", "tts"], manifest.GetProperty("categories").EnumerateArray().Select(e => e.GetString()!).ToArray()); Assert.Equal( "TypeWhisper.Plugin.OpenAi.dll", manifest.GetProperty("assemblyName").GetString() diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenRouterPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenRouterPluginTests.cs index d894031da..6f01af05d 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenRouterPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenRouterPluginTests.cs @@ -33,7 +33,7 @@ public void Manifest_AdvertisesOpenRouterIdentity() Assert.Equal("com.typewhisper.openrouter", manifest.GetProperty("id").GetString()); Assert.Equal("OpenRouter", manifest.GetProperty("name").GetString()); - Assert.Equal("llm", manifest.GetProperty("category").GetString()); + Assert.Equal(["transcription", "llm"], manifest.GetProperty("categories").EnumerateArray().Select(e => e.GetString()!).ToArray()); Assert.Equal( "TypeWhisper.Plugin.OpenRouter.dll", manifest.GetProperty("assemblyName").GetString()); diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginLoaderTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginLoaderTests.cs index 6f7ce3023..14fbcb493 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginLoaderTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginLoaderTests.cs @@ -207,6 +207,135 @@ public void DiscoverAndLoad_AbsentMinimum_Loads() } } + [Fact] + public void ResolveMetadata_NewFieldsOverrideContradictoryLegacyValues() + { + var manifest = CreateManifest("com.typewhisper.whisper-cpp") with + { + Category = "transcription", + IsLocal = true, + NetworkAccess = PluginNetworkAccess.UserControlled, + Categories = [PluginCategory.Integration, PluginCategory.Action], + }; + + var descriptor = PluginLoader.ResolveMetadata(manifest); + + Assert.Equal(PluginNetworkAccess.UserControlled, descriptor.NetworkAccess); + Assert.Equal( + new HashSet + { + PluginCategory.Integration, + PluginCategory.Action, + }, + descriptor.Categories + ); + Assert.False(descriptor.RanLocally); + } + + [Fact] + public void ResolveMetadata_LegacyExternalManifestUsesCompatibilityFallback() + { + var manifest = CreateManifest("com.typewhisper.whisper-cpp"); + + var descriptor = PluginLoader.ResolveMetadata(manifest); + + Assert.Equal(PluginNetworkAccess.Local, descriptor.NetworkAccess); + Assert.Equal( + [PluginCategory.Transcription], + descriptor.Categories + ); + Assert.True(descriptor.RanLocally); + } + + [Fact] + public void ResolveMetadata_LegacyWebhookIsNotPresumedLocal() + { + var manifest = CreateManifest("com.typewhisper.webhook"); + + var descriptor = PluginLoader.ResolveMetadata(manifest); + + Assert.Equal(PluginNetworkAccess.Network, descriptor.NetworkAccess); + Assert.False(descriptor.RanLocally); + } + + [Fact] + public void ResolveMetadata_ExplicitLegacyFalseOverridesKnownLocalFallback() + { + var manifest = CreateManifest("com.typewhisper.whisper-cpp") with + { + IsLocal = false, + }; + + var descriptor = PluginLoader.ResolveMetadata(manifest); + + Assert.Equal(PluginNetworkAccess.Network, descriptor.NetworkAccess); + Assert.False(descriptor.RanLocally); + } + + [Fact] + public void ResolveMetadata_UnlabeledExternalManifestFailsClosed() + { + var manifest = CreateManifest("com.example.unlabeled") with + { + Name = "Unlabeled", + Description = null, + }; + + var descriptor = PluginLoader.ResolveMetadata(manifest); + + Assert.Equal(PluginNetworkAccess.Network, descriptor.NetworkAccess); + Assert.Equal([PluginCategory.Unknown], descriptor.Categories); + Assert.False(descriptor.RanLocally); + } + + [Fact] + public void ResolveMetadata_EmptyDeclaredCategoriesIsRejected() + { + var manifest = CreateManifest("com.example.empty") with + { + NetworkAccess = PluginNetworkAccess.Network, + Categories = [], + }; + + var error = Assert.Throws( + () => PluginLoader.ResolveMetadata(manifest) + ); + + Assert.Contains("empty categories", error.Message); + } + + [Theory] + [InlineData((PluginNetworkAccess)999, PluginCategory.Utility)] + [InlineData(PluginNetworkAccess.Network, (PluginCategory)999)] + [InlineData(PluginNetworkAccess.Network, PluginCategory.Unknown)] + public void ResolveMetadata_InvalidDeclaredEnumIsRejected( + PluginNetworkAccess networkAccess, + PluginCategory category + ) + { + var manifest = CreateManifest("com.example.invalid") with + { + NetworkAccess = networkAccess, + Categories = [category], + }; + + Assert.Throws( + () => PluginLoader.ResolveMetadata(manifest) + ); + } + + private static PluginManifest CreateManifest(string id) + { + return new PluginManifest + { + Id = id, + Name = id, + Version = "1.0.0", + AssemblyName = "fake.dll", + PluginClass = "Fake.Plugin", + }; + } + private PluginLoader CreateLoader(string hostVersion) { return new PluginLoader(Path.Join(_tempDir, "PluginData")) diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs index 9c00bc3f2..68bb3fe7f 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs @@ -251,6 +251,26 @@ public async Task DisablePluginAsync_NotActivated_PersistsDisabledState() Assert.Null(savedSettings); } + [Theory] + [InlineData(PluginNetworkAccess.Local, false, true)] + [InlineData(PluginNetworkAccess.Network, true, false)] + [InlineData(PluginNetworkAccess.Mixed, true, false)] + [InlineData(PluginNetworkAccess.UserControlled, true, false)] + public void DefaultActivation_UsesDescriptorInsteadOfLegacyManifestFlag( + PluginNetworkAccess networkAccess, + bool legacyIsLocal, + bool expectedEnabled + ) + { + var plugin = new LifecyclePlugin( + "com.test.default-activation", + () => Task.CompletedTask + ); + var loaded = CreateLoadedPlugin(plugin, networkAccess, legacyIsLocal); + + Assert.Equal(expectedEnabled, PluginManager.IsEnabledByDefault(loaded)); + } + [Fact] public async Task CapabilityIndices_ValidCustomTranscriptionId_RoundTripsWhileColonSiblingIsRejected() { @@ -478,21 +498,36 @@ params ITypeWhisperPlugin[] plugins return _manager; } - private static LoadedPlugin CreateLoadedPlugin(ITypeWhisperPlugin plugin) + private static LoadedPlugin CreateLoadedPlugin( + ITypeWhisperPlugin plugin, + PluginNetworkAccess networkAccess = PluginNetworkAccess.Network, + bool? legacyIsLocal = null + ) { var testAssemblyPath = typeof(PluginManagerTests).Assembly.Location; + var categories = plugin switch + { + ITranscriptionEnginePlugin => new[] { PluginCategory.Transcription }, + ILlmProviderPlugin => [PluginCategory.Llm], + _ => [PluginCategory.Utility], + }; + var manifest = new PluginManifest + { + Id = plugin.PluginId, + Name = plugin.PluginName, + Version = plugin.PluginVersion, + AssemblyName = "fake.dll", + PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name, + NetworkAccess = networkAccess, + Categories = categories, + IsLocal = legacyIsLocal, + }; return new LoadedPlugin( - new PluginManifest - { - Id = plugin.PluginId, - Name = plugin.PluginName, - Version = plugin.PluginVersion, - AssemblyName = "fake.dll", - PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name, - }, + manifest, plugin, new PluginAssemblyLoadContext(testAssemblyPath), - Path.GetDirectoryName(testAssemblyPath)! + Path.GetDirectoryName(testAssemblyPath)!, + PluginLoader.ResolveMetadata(manifest) ); } diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginManifestTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginManifestTests.cs index bc2e5c47d..ddcfe1840 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginManifestTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginManifestTests.cs @@ -21,6 +21,8 @@ public void Deserialize_AllFields() "minHostVersion": "1.0.0", "author": "Test Author", "description": "A test plugin for unit tests", + "networkAccess": "userControlled", + "categories": ["transcription", "llm", "tts"], "assemblyName": "TestPlugin.dll", "pluginClass": "TestPlugin.MyPlugin" } @@ -35,6 +37,11 @@ public void Deserialize_AllFields() Assert.Equal("1.0.0", manifest.MinHostVersion); Assert.Equal("Test Author", manifest.Author); Assert.Equal("A test plugin for unit tests", manifest.Description); + Assert.Equal(PluginNetworkAccess.UserControlled, manifest.NetworkAccess); + Assert.Equal( + [PluginCategory.Transcription, PluginCategory.Llm, PluginCategory.Tts], + manifest.Categories + ); Assert.Equal("TestPlugin.dll", manifest.AssemblyName); Assert.Equal("TestPlugin.MyPlugin", manifest.PluginClass); } @@ -63,6 +70,58 @@ public void Deserialize_OnlyRequiredFields() Assert.Null(manifest.MinHostVersion); Assert.Null(manifest.Author); Assert.Null(manifest.Description); + Assert.Null(manifest.NetworkAccess); + Assert.Null(manifest.Categories); + Assert.Null(manifest.IsLocal); + } + + [Fact] + public void Deserialize_LegacyFieldsRemainReadableAndCategoryMapsToSet() + { + const string json = """ + { + "id": "com.example.legacy", + "name": "Legacy", + "version": "1.0.0", + "category": "post-processing", + "isLocal": false, + "assemblyName": "Legacy.dll", + "pluginClass": "Legacy.Plugin" + } + """; + + var manifest = JsonSerializer.Deserialize(json, s_jsonOptions); + + Assert.NotNull(manifest); + Assert.Equal("post-processing", manifest.Category); + Assert.Equal([PluginCategory.PostProcessing], manifest.Categories); + Assert.False(manifest.IsLocal); + Assert.Null(manifest.NetworkAccess); + } + + [Theory] + [InlineData("networkAccess", "\"satellite\"")] + [InlineData("categories", "[\"transcription\", \"telepathy\"]")] + public void Deserialize_InvalidNewEnumValue_Throws( + string property, + string value + ) + { + var json = + $$""" + { + "id": "com.example.invalid", + "name": "Invalid", + "version": "1.0.0", + "{{property}}": {{value}}, + "assemblyName": "Invalid.dll", + "pluginClass": "Invalid.Plugin" + } + """; + + Assert.Throws( + () => JsonSerializer.Deserialize(json, s_jsonOptions) + ); } [Fact] @@ -75,6 +134,8 @@ public void Serialize_RoundTrip() Version = "3.0.0", Author = "Me", Description = "Test roundtrip", + NetworkAccess = PluginNetworkAccess.Mixed, + Categories = [PluginCategory.Llm, PluginCategory.Tts], AssemblyName = "RT.dll", PluginClass = "RT.Plugin", MinHostVersion = "2.0.0", @@ -89,6 +150,8 @@ public void Serialize_RoundTrip() Assert.Equal(original.Version, deserialized.Version); Assert.Equal(original.Author, deserialized.Author); Assert.Equal(original.Description, deserialized.Description); + Assert.Equal(original.NetworkAccess, deserialized.NetworkAccess); + Assert.Equal(original.Categories, deserialized.Categories); Assert.Equal(original.AssemblyName, deserialized.AssemblyName); Assert.Equal(original.PluginClass, deserialized.PluginClass); Assert.Equal(original.MinHostVersion, deserialized.MinHostVersion); @@ -178,4 +241,4 @@ public void Record_With_CreatesModifiedCopy() Assert.Equal("2.0.0", modified.Version); Assert.NotEqual(original, modified); } -} \ No newline at end of file +} diff --git a/tests/TypeWhisper.PluginSystem.Tests/Reson8PluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/Reson8PluginTests.cs index 31e8913b1..133b58830 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/Reson8PluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/Reson8PluginTests.cs @@ -40,9 +40,8 @@ public void Manifest_AdvertisesTranscriptionCapabilitiesAndApiKeyRequirement() Assert.Equal("com.typewhisper.reson8", manifest.GetProperty("id").GetString()); Assert.Equal("Reson8", manifest.GetProperty("name").GetString()); - Assert.Equal("transcription", manifest.GetProperty("category").GetString()); Assert.Equal(["transcription"], manifest.GetProperty("categories").EnumerateArray().Select(e => e.GetString()!).ToArray()); - Assert.False(manifest.GetProperty("isLocal").GetBoolean()); + Assert.Equal("network", manifest.GetProperty("networkAccess").GetString()); Assert.True(manifest.GetProperty("requiresApiKey").GetBoolean()); } diff --git a/tests/TypeWhisper.PluginSystem.Tests/SmallestAiPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/SmallestAiPluginTests.cs index d19dfb7a0..26d05a69b 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/SmallestAiPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/SmallestAiPluginTests.cs @@ -32,7 +32,7 @@ public void Manifest_AdvertisesSmallestAiPluginIdentity() Assert.Equal("com.typewhisper.smallest-ai", manifest.GetProperty("id").GetString()); Assert.Equal("Smallest AI Pulse", manifest.GetProperty("name").GetString()); - Assert.Equal("transcription", manifest.GetProperty("category").GetString()); + Assert.Equal(["transcription"], manifest.GetProperty("categories").EnumerateArray().Select(e => e.GetString()!).ToArray()); Assert.Equal( "TypeWhisper.Plugin.SmallestAi.dll", manifest.GetProperty("assemblyName").GetString() diff --git a/tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs index 233986b96..82b0f8650 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs @@ -33,9 +33,8 @@ public void Manifest_AdvertisesTranscriptionCapabilitiesAndApiKeyRequirement() Assert.Equal("com.typewhisper.soniox", manifest.GetProperty("id").GetString()); Assert.Equal("Soniox", manifest.GetProperty("name").GetString()); - Assert.Equal("transcription", manifest.GetProperty("category").GetString()); Assert.Equal(["transcription"], manifest.GetProperty("categories").EnumerateArray().Select(e => e.GetString()!).ToArray()); - Assert.False(manifest.GetProperty("isLocal").GetBoolean()); + Assert.Equal("network", manifest.GetProperty("networkAccess").GetString()); Assert.True(manifest.GetProperty("requiresApiKey").GetBoolean()); } diff --git a/tests/TypeWhisper.PluginSystem.Tests/SupertonicTtsPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/SupertonicTtsPluginTests.cs index 85d86cdb2..5963f912f 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/SupertonicTtsPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/SupertonicTtsPluginTests.cs @@ -18,8 +18,8 @@ public void Manifest_DeclaresLocalTtsPlugin() Assert.Equal("com.typewhisper.supertonic-tts", root.GetProperty("id").GetString()); Assert.Equal("Supertonic TTS", root.GetProperty("name").GetString()); - Assert.Equal("tts", root.GetProperty("category").GetString()); - Assert.True(root.GetProperty("isLocal").GetBoolean()); + Assert.Equal(["tts"], root.GetProperty("categories").EnumerateArray().Select(e => e.GetString()!).ToArray()); + Assert.Equal("local", root.GetProperty("networkAccess").GetString()); Assert.Equal("TypeWhisper.Plugin.SupertonicTts.dll", root.GetProperty("assemblyName").GetString()); Assert.Equal("TypeWhisper.Plugin.SupertonicTts.SupertonicTtsPlugin", root.GetProperty("pluginClass").GetString()); } diff --git a/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs index ceb63d0ea..701d45d2b 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs @@ -32,7 +32,7 @@ public void Manifest_AdvertisesXaiPluginIdentity() Assert.Equal("com.typewhisper.xai", manifest.GetProperty("id").GetString()); Assert.Equal("xAI / Grok", manifest.GetProperty("name").GetString()); - Assert.Equal("transcription", manifest.GetProperty("category").GetString()); + Assert.Equal(["transcription", "llm", "tts"], manifest.GetProperty("categories").EnumerateArray().Select(e => e.GetString()!).ToArray()); Assert.Equal( "TypeWhisper.Plugin.Xai.dll", manifest.GetProperty("assemblyName").GetString() From 72792579e77f4715cdd0b7258b10ed75ab1acdac Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 23:12:22 +0000 Subject: [PATCH 169/226] Implement Voxtral against Mistral's documented transcription API After Tier 0 fixed the model identifier and translation capability flag, the plugin still delegated to the shared OpenAI transcription helper: OpenAI-only response_format and prompt fields went to Mistral's endpoint, and a direct caller passing translate=true could still reach the unsupported /v1/audio/translations route despite the capability saying otherwise. Voxtral now builds a Mistral-native request: POST /v1/audio/ transcriptions only, with translate rejected before any HTTP; multipart file, model, timestamp_granularities=segment (without which Mistral's documented response returns an empty segments array, leaving the segment parser dead), and an explicit language only when the caller supplies one - the auto sentinel is omitted so Mistral detects the language. The response parser follows Mistral's documented schema: string text is required (explicit empty succeeds, missing or wrong- typed fails as a protocol error), with language, segment chunks, and usage-derived duration taken when present. The prompt parameter is deliberately ignored: Mistral documents context_bias as an array, and mapping a single prompt string into it would be undocumented behavior. --- .../TypeWhisper.Plugin.Voxtral.csproj | 3 + .../VoxtralPlugin.cs | 166 ++++++++- .../VoxtralPluginTests.cs | 331 +++++++++++++++++- 3 files changed, 484 insertions(+), 16 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.Voxtral/TypeWhisper.Plugin.Voxtral.csproj b/plugins/TypeWhisper.Plugin.Voxtral/TypeWhisper.Plugin.Voxtral.csproj index 03e994f43..b8ee01017 100644 --- a/plugins/TypeWhisper.Plugin.Voxtral/TypeWhisper.Plugin.Voxtral.csproj +++ b/plugins/TypeWhisper.Plugin.Voxtral/TypeWhisper.Plugin.Voxtral.csproj @@ -6,6 +6,9 @@ latest TypeWhisper.Plugin.Voxtral + + + diff --git a/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs b/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs index 6ec0fc7f1..04bd7fe5d 100644 --- a/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs @@ -4,8 +4,8 @@ // and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. using System.Net.Http.Headers; +using System.Text.Json; using TypeWhisper.PluginSDK; -using TypeWhisper.PluginSDK.Helpers; using TypeWhisper.PluginSDK.Models; namespace TypeWhisper.Plugin.Voxtral; @@ -16,9 +16,19 @@ public sealed class VoxtralPlugin : ITranscriptionEnginePlugin, IPluginSettingsP private const string ModelId = "voxtral-mini-latest"; private const string LegacyModelId = "mistral-whisper"; - private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; + private readonly HttpClient _httpClient; private IPluginHostServices? _host; + public VoxtralPlugin() + : this(new HttpClient { Timeout = TimeSpan.FromSeconds(60) }) + { + } + + internal VoxtralPlugin(HttpClient httpClient) + { + _httpClient = httpClient; + } + public string PluginId => "com.typewhisper.voxtral"; public string PluginName => "Voxtral"; public string PluginVersion => "1.0.0"; @@ -81,21 +91,149 @@ public async Task TranscribeAsync( CancellationToken ct ) { + if (translate) + { + throw new InvalidOperationException( + "Voxtral does not support translation; Mistral only documents the audio transcriptions endpoint." + ); + } + if (!IsConfigured) throw new InvalidOperationException(Loc.L("Settings.NotConfiguredMistralApiKeyRequired")); - return await OpenAiTranscriptionHelper.TranscribeAsync( - _httpClient, - BaseUrl, - ApiKey!, - ModelId, - wavAudio, - language, - translate, - "verbose_json", - ct, - prompt + using var content = new MultipartFormDataContent(); + using var fileContent = new ByteArrayContent(wavAudio); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("audio/wav"); + content.Add(fileContent, "file", "audio.wav"); + content.Add(new StringContent(ModelId), "model"); + + // Without a requested granularity Mistral returns an empty "segments" array + // (its documented response example), so ask for segment timestamps explicitly. + content.Add(new StringContent("segment"), "timestamp_granularities"); + + // "auto" is TypeWhisper's sentinel; omit it so Mistral detects the language. + if (!string.IsNullOrWhiteSpace(language) + && !language.Equals("auto", StringComparison.OrdinalIgnoreCase)) + { + content.Add(new StringContent(language), "language"); + } + + // Mistral exposes context_bias as an array; do not guess how a single prompt maps to it. + _ = prompt; + + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"{BaseUrl}/v1/audio/transcriptions" ); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey); + request.Content = content; + + using var response = await _httpClient.SendAsync(request, ct); + var responseBody = await response.Content.ReadAsStringAsync(ct); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Mistral API error {(int)response.StatusCode}: {responseBody}", + inner: null, + statusCode: response.StatusCode + ); + } + + return ParseTranscriptionResponse(responseBody); + } + + internal static PluginTranscriptionResult ParseTranscriptionResponse(string json) + { + try + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("text", out var textElement) + || textElement.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException( + "Invalid Mistral transcription response: required field 'text' must be a string." + ); + } + + var text = textElement.GetString() ?? string.Empty; + var detectedLanguage = + root.TryGetProperty("language", out var languageElement) + && languageElement.ValueKind == JsonValueKind.String + ? languageElement.GetString() + : null; + + var duration = TryGetPromptAudioSeconds(root, out var promptAudioSeconds) + ? promptAudioSeconds + : 0; + var segments = ParseSegments(root, ref duration); + + return new PluginTranscriptionResult( + text, + detectedLanguage, + duration, + NoSpeechProbability: null + ) + { + Segments = segments, + }; + } + catch (JsonException ex) + { + throw new InvalidOperationException( + "Invalid Mistral transcription response: the response body is not valid JSON.", + ex + ); + } + } + + private static List ParseSegments( + JsonElement root, + ref double duration + ) + { + var segments = new List(); + if (!root.TryGetProperty("segments", out var segmentsElement) + || segmentsElement.ValueKind != JsonValueKind.Array) + { + return segments; + } + + foreach (var segment in segmentsElement.EnumerateArray()) + { + if (segment.ValueKind != JsonValueKind.Object + || !segment.TryGetProperty("text", out var textElement) + || textElement.ValueKind != JsonValueKind.String + || !TryGetDouble(segment, "start", out var start) + || !TryGetDouble(segment, "end", out var end)) + { + continue; + } + + segments.Add( + new PluginTranscriptionSegment(textElement.GetString() ?? string.Empty, start, end) + ); + duration = Math.Max(duration, end); + } + + return segments; + } + + private static bool TryGetPromptAudioSeconds(JsonElement root, out double duration) + { + duration = 0; + return root.TryGetProperty("usage", out var usage) + && usage.ValueKind == JsonValueKind.Object + && TryGetDouble(usage, "prompt_audio_seconds", out duration); + } + + private static bool TryGetDouble(JsonElement element, string propertyName, out double value) + { + value = 0; + return element.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.Number + && property.TryGetDouble(out value); } internal string? ApiKey { get; private set; } @@ -116,7 +254,7 @@ internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken c request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); try { - var response = await _httpClient.SendAsync(request, ct); + using var response = await _httpClient.SendAsync(request, ct); return response.IsSuccessStatusCode; } catch diff --git a/tests/TypeWhisper.PluginSystem.Tests/VoxtralPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/VoxtralPluginTests.cs index 1df3c1b78..151b636a0 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/VoxtralPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/VoxtralPluginTests.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Text; using Moq; using TypeWhisper.Plugin.Voxtral; using TypeWhisper.PluginSDK; @@ -22,6 +24,16 @@ public async Task ActivateAsync_UsesVoxtralMiniAndDisablesTranslation() Assert.False(sut.SupportsTranslation); } + [Fact] + public void TranscriptionModels_AdvertisesDocumentedLatestAlias() + { + using var sut = new VoxtralPlugin(); + + var model = Assert.Single(sut.TranscriptionModels); + + Assert.Equal("voxtral-mini-latest", model.Id); + } + [Fact] public async Task ActivateAsync_MigratesLegacyModelSelection() { @@ -50,12 +62,327 @@ public async Task SelectModel_NormalizesLegacyModelId() Assert.Equal("voxtral-mini-latest", sut.SelectedModelId); } - private static Mock CreateHostMock(string? selectedModelId = null) + [Fact] + public async Task TranscribeAsync_PostsDocumentedMultipartRequestAndOmitsAutoLanguage() + { + var handler = new StubHttpMessageHandler(async (request, ct) => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal( + "https://api.mistral.ai/v1/audio/transcriptions", + request.RequestUri?.AbsoluteUri + ); + Assert.Equal("Bearer voxtral-key", request.Headers.Authorization?.ToString()); + + var content = Assert.IsType(request.Content); + var parts = content.ToArray(); + Assert.Equal( + ["file", "model", "timestamp_granularities"], + parts.Select(GetPartName).ToArray() + ); + + var file = Assert.Single(parts, part => GetPartName(part) == "file"); + Assert.Equal("audio.wav", file.Headers.ContentDisposition?.FileName?.Trim('"')); + Assert.Equal("audio/wav", file.Headers.ContentType?.MediaType); + Assert.Equal([1, 2, 3], await file.ReadAsByteArrayAsync(ct)); + + var model = Assert.Single(parts, part => GetPartName(part) == "model"); + Assert.Equal("voxtral-mini-latest", await model.ReadAsStringAsync(ct)); + + var granularities = Assert.Single( + parts, + part => GetPartName(part) == "timestamp_granularities" + ); + Assert.Equal("segment", await granularities.ReadAsStringAsync(ct)); + + return JsonResponse("""{ "text": "Hello", "language": "en", "usage": {} }"""); + }); + using var sut = await CreateConfiguredPluginAsync(handler); + + var result = await sut.TranscribeAsync( + [1, 2, 3], + "auto", + translate: false, + prompt: "Do not send this as prompt or context_bias", + CancellationToken.None + ); + + Assert.Equal("Hello", result.Text); + Assert.Equal(1, handler.CallCount); + } + + [Fact] + public async Task TranscribeAsync_SendsExplicitLanguage() + { + var handler = new StubHttpMessageHandler(async (request, ct) => + { + var content = Assert.IsType(request.Content); + var parts = content.ToArray(); + Assert.Equal( + ["file", "model", "timestamp_granularities", "language"], + parts.Select(GetPartName).ToArray() + ); + var language = Assert.Single(parts, part => GetPartName(part) == "language"); + Assert.Equal("de", await language.ReadAsStringAsync(ct)); + + return JsonResponse("""{ "text": "Hallo", "language": "de", "usage": {} }"""); + }); + using var sut = await CreateConfiguredPluginAsync(handler); + + var result = await sut.TranscribeAsync( + [1, 2, 3], + "de", + translate: false, + prompt: null, + CancellationToken.None + ); + + Assert.Equal("de", result.DetectedLanguage); + } + + [Fact] + public async Task TranscribeAsync_ParsesDocumentedTextLanguageSegmentsAndUsage() + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult( + JsonResponse( + """ + { + "model": "voxtral-mini-2507", + "text": "Hello world", + "language": "en", + "segments": [ + { + "type": "transcription_segment", + "text": "Hello", + "start": 0.1, + "end": 0.7, + "score": 0.98, + "speaker_id": "speaker_0" + }, + { + "type": "transcription_segment", + "text": " world", + "start": 0.7, + "end": 1.2, + "score": null, + "speaker_id": null + } + ], + "usage": { + "prompt_audio_seconds": 2, + "prompt_tokens": 4, + "completion_tokens": 6, + "total_tokens": 10 + } + } + """ + ) + ) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var result = await sut.TranscribeAsync( + [1, 2, 3], + null, + translate: false, + prompt: null, + CancellationToken.None + ); + + Assert.Equal("Hello world", result.Text); + Assert.Equal("en", result.DetectedLanguage); + Assert.Equal(2, result.DurationSeconds); + Assert.Collection( + result.Segments, + segment => Assert.Equal(("Hello", 0.1, 0.7), (segment.Text, segment.Start, segment.End)), + segment => Assert.Equal((" world", 0.7, 1.2), (segment.Text, segment.Start, segment.End)) + ); + Assert.Null(result.NoSpeechProbability); + } + + [Fact] + public async Task TranscribeAsync_AcceptsExplicitEmptyText() + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult(JsonResponse("""{ "text": "", "language": null, "usage": {} }""")) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var result = await sut.TranscribeAsync( + [1, 2, 3], + null, + translate: false, + prompt: null, + CancellationToken.None + ); + + Assert.Equal(string.Empty, result.Text); + } + + [Theory] + [InlineData("""{}""")] + [InlineData("""{ "text": null }""")] + [InlineData("""{ "text": 42 }""")] + public async Task TranscribeAsync_RejectsResponseWithoutStringText(string responseBody) + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult(JsonResponse(responseBody)) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + null, + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Equal( + "Invalid Mistral transcription response: required field 'text' must be a string.", + exception.Message + ); + } + + [Fact] + public async Task TranscribeAsync_SurfacesProviderHttpError() + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult( + JsonResponse( + """{ "detail": "Unsupported audio format" }""", + HttpStatusCode.UnprocessableEntity + ) + ) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + null, + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Contains("Mistral API error 422", exception.Message); + Assert.Contains("Unsupported audio format", exception.Message); + Assert.Equal(HttpStatusCode.UnprocessableEntity, exception.StatusCode); + } + + [Fact] + public async Task TranscribeAsync_PropagatesCancellation() + { + var requestStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var handler = new StubHttpMessageHandler(async (_, ct) => + { + requestStarted.SetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return JsonResponse("""{ "text": "unreachable" }"""); + }); + using var sut = await CreateConfiguredPluginAsync(handler); + using var cancellation = new CancellationTokenSource(); + + var transcription = sut.TranscribeAsync( + [1, 2, 3], + null, + translate: false, + prompt: null, + cancellation.Token + ); + // ReSharper disable once MethodSupportsCancellation -- the only token in scope is + // cancellation.Token, which the test cancels below to exercise the cancellation path; + // passing it here would abort the wait early. The timeout is the intended guard. + await requestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => transcription); + } + + [Fact] + public async Task TranscribeAsync_RejectsTranslationBeforeSendingHttpRequest() + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult(JsonResponse("""{ "text": "unexpected" }""")) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: true, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Equal( + "Voxtral does not support translation; Mistral only documents the audio transcriptions endpoint.", + exception.Message + ); + Assert.Equal(0, handler.CallCount); + } + + private static async Task CreateConfiguredPluginAsync( + StubHttpMessageHandler handler + ) + { + var sut = new VoxtralPlugin(new HttpClient(handler)); + await sut.ActivateAsync(CreateHostMock(apiKey: "voxtral-key").Object); + return sut; + } + + private static string GetPartName(HttpContent content) + { + var name = content.Headers.ContentDisposition?.Name; + Assert.NotNull(name); + return name.Trim('"'); + } + + private static HttpResponseMessage JsonResponse( + string json, + HttpStatusCode statusCode = HttpStatusCode.OK + ) => + new(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + private static Mock CreateHostMock( + string? selectedModelId = null, + string? apiKey = null + ) { var host = new Mock(); - host.Setup(service => service.LoadSecretAsync("api-key")).ReturnsAsync((string?)null); + host.Setup(service => service.LoadSecretAsync("api-key")).ReturnsAsync(apiKey); host.Setup(service => service.GetSetting("selectedModel")) .Returns(selectedModelId); return host; } + + private sealed class StubHttpMessageHandler( + Func> responder + ) : HttpMessageHandler + { + private int _callCount; + + public int CallCount => Volatile.Read(ref _callCount); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + Interlocked.Increment(ref _callCount); + return responder(request, cancellationToken); + } + } } From 39cbc7f82e9168f3de7bf5e8a822cb468362bf95 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sat, 25 Jul 2026 23:42:16 +0000 Subject: [PATCH 170/226] Implement Gladia's v2 upload, initiate, and poll batch protocol Tier 0 gated Gladia's batch path after removing an implementation that posted raw multipart audio straight to /v2/pre-recorded and parsed a final transcript out of that response - a protocol that does not exist, so every batch transcription failed and batch could not serve as the fallback when live streaming faulted. The plugin now speaks the documented three-stage protocol: multipart upload to /v2/upload yielding audio_url; JSON initiation at /v2/pre-recorded with explicit languages mapped through language_config.languages and the auto sentinel omitted entirely; then polling the RETURNED result_url - never a constructed one, and HTTPS only, since polling attaches x-gladia-key and a non-HTTPS URL would forward the key in plaintext. Queued and processing continue under a bounded window with an injectable delay; done requires a string result.transcription.full_transcript (missing means protocol failure, never an empty success); error surfaces the provider's details, with non-object JSON error bodies rendered directly since TryGetProperty throws on them; unknown statuses fail. After any terminal outcome the job is deleted best-effort - transcripts are user speech and Gladia documents the delete - bounded to seconds so a stalled cleanup cannot hold up the finished result, and its failure never affects the outcome. The prompt parameter has no documented equivalent and is not sent. --- .../TypeWhisper.Plugin.Gladia/GladiaPlugin.cs | 531 +++++++++- .../GladiaPluginTests.cs | 965 +++++++++++++++++- 2 files changed, 1481 insertions(+), 15 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs index 394baa803..68e5b5cb5 100644 --- a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs @@ -2,6 +2,10 @@ // Plugin types are instantiated by the host via reflection and invoked through plugin interfaces // and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. +using System.Diagnostics; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Models; @@ -9,7 +13,14 @@ namespace TypeWhisper.Plugin.Gladia; public sealed class GladiaPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware { - private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(120) }; + private const string BaseUrl = "https://api.gladia.io"; + + private static readonly TimeSpan s_defaultPollDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan s_defaultPollWindow = TimeSpan.FromMinutes(30); + + private readonly HttpClient _httpClient; + private readonly TimeSpan _pollDelay; + private readonly TimeSpan _pollWindow; private IPluginHostServices? _host; private string? _apiKey; @@ -18,6 +29,34 @@ public sealed class GladiaPlugin : ITranscriptionEnginePlugin, IPluginSettingsPr new("default", "Gladia (Auto)"), ]; + public GladiaPlugin() + : this(CreateHttpClient()) + { + } + + internal GladiaPlugin( + HttpClient httpClient, + TimeSpan? pollDelay = null, + TimeSpan? pollWindow = null + ) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _pollDelay = pollDelay ?? s_defaultPollDelay; + _pollWindow = pollWindow ?? s_defaultPollWindow; + + if (_pollDelay < TimeSpan.Zero) + throw new ArgumentOutOfRangeException( + nameof(pollDelay), + "The poll delay cannot be negative." + ); + + if (_pollWindow < TimeSpan.Zero) + throw new ArgumentOutOfRangeException( + nameof(pollWindow), + "The polling window cannot be negative." + ); + } + public string PluginId => "com.typewhisper.gladia"; public string PluginName => "Gladia"; public string PluginVersion => "1.0.0"; @@ -68,8 +107,7 @@ public void SelectModel(string modelId) _host?.SetSetting("selectedModel", modelId); } - // Batch intentionally throws until Gladia's upload/initiate/poll protocol is implemented. - public Task TranscribeAsync( + public async Task TranscribeAsync( byte[] wavAudio, string? language, bool translate, @@ -77,10 +115,43 @@ public Task TranscribeAsync( CancellationToken ct ) { - throw new NotSupportedException( - "Gladia batch transcription is not supported in this build; use live streaming. " - + "The batch API requires a multi-stage upload/poll protocol that is not yet implemented." - ); + if (translate) + throw new InvalidOperationException("Gladia does not support translation."); + + // Gladia's pre-recorded request has no prompt equivalent. + _ = prompt; + + // Snapshot the key so a concurrent settings change cannot alter a multi-request job. + var apiKey = _apiKey; + if (string.IsNullOrEmpty(apiKey)) + throw new InvalidOperationException(Loc.L("Settings.NotConfiguredApiKeyRequired")); + + var audioUrl = await UploadAudioAsync(wavAudio, apiKey, ct); + var job = await InitiateTranscriptionAsync(audioUrl, language, apiKey, ct); + var terminalJson = await PollUntilTerminalAsync(job, apiKey, ct); + + try + { + using var terminalDocument = ParseProtocolJson( + terminalJson, + "polling" + ); + var terminal = terminalDocument.RootElement; + var status = RequireString(terminal, "status", "polling"); + + if (string.Equals(status, "error", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Gladia transcription failed: {ExtractProviderDetails(terminal)}" + ); + } + + return ParseCompletedResult(terminal, NormalizeLanguage(language)); + } + finally + { + await DeleteJobBestEffortAsync(job.Id, apiKey); + } } public void Dispose() @@ -88,6 +159,452 @@ public void Dispose() _httpClient.Dispose(); } + private async Task UploadAudioAsync( + byte[] wavAudio, + string apiKey, + CancellationToken ct + ) + { + using var multipart = new MultipartFormDataContent(); + using var audioContent = new ByteArrayContent(wavAudio); + audioContent.Headers.ContentType = new MediaTypeHeaderValue("audio/wav"); + multipart.Add(audioContent, "audio", "audio.wav"); + + using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v2/upload"); + AddApiKey(request, apiKey); + request.Content = multipart; + + var json = await SendJsonAsync(request, "Gladia audio upload", ct); + using var document = ParseProtocolJson(json, "audio upload"); + return RequireString(document.RootElement, "audio_url", "audio upload"); + } + + private async Task InitiateTranscriptionAsync( + string audioUrl, + string? language, + string apiKey, + CancellationToken ct + ) + { + var payload = new Dictionary + { + ["audio_url"] = audioUrl, + }; + + if (NormalizeLanguage(language) is { } normalizedLanguage) + { + payload["language_config"] = new Dictionary + { + ["languages"] = new[] { normalizedLanguage }, + }; + } + + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"{BaseUrl}/v2/pre-recorded" + ); + AddApiKey(request, apiKey); + request.Content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + "application/json" + ); + + var json = await SendJsonAsync(request, "Gladia transcription initiation", ct); + using var document = ParseProtocolJson(json, "transcription initiation"); + var id = RequireString(document.RootElement, "id", "transcription initiation"); + var resultUrl = RequireString( + document.RootElement, + "result_url", + "transcription initiation" + ); + + // Require HTTPS: polling sends x-gladia-key to this URL; non-HTTPS would leak it in plaintext. + if (!Uri.TryCreate(resultUrl, UriKind.Absolute, out var resultUri) + || resultUri.Scheme != Uri.UriSchemeHttps) + { + throw new InvalidOperationException( + "Gladia transcription initiation response contained an invalid result_url." + ); + } + + return new InitiatedJob(id, resultUri); + } + + private async Task PollUntilTerminalAsync( + InitiatedJob job, + string apiKey, + CancellationToken ct + ) + { + if (_pollWindow == TimeSpan.Zero) + throw PollTimeout(job.Id); + + using var timeoutCts = new CancellationTokenSource(_pollWindow); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + ct, + timeoutCts.Token + ); + + try + { + while (true) + { + ct.ThrowIfCancellationRequested(); + + using var request = new HttpRequestMessage(HttpMethod.Get, job.ResultUrl); + AddApiKey(request, apiKey); + + var json = await SendJsonAsync( + request, + "Gladia transcription polling", + linkedCts.Token + ); + using var document = ParseProtocolJson(json, "transcription polling"); + var status = RequireString( + document.RootElement, + "status", + "transcription polling" + ); + + switch (status.ToLowerInvariant()) + { + case "done": + case "error": + return json; + case "queued": + case "processing": + break; + default: + throw new InvalidOperationException( + $"Gladia transcription polling response contained unknown status '{status}'." + ); + } + + if (_pollDelay > TimeSpan.Zero) + await Task.Delay(_pollDelay, linkedCts.Token); + } + } + catch (OperationCanceledException) + when (!ct.IsCancellationRequested && timeoutCts.IsCancellationRequested) + { + throw PollTimeout(job.Id); + } + } + + private async Task SendJsonAsync( + HttpRequestMessage request, + string operation, + CancellationToken ct + ) + { + using var response = await _httpClient.SendAsync(request, ct); + var json = await response.Content.ReadAsStringAsync(ct); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"{operation} error {(int)response.StatusCode}: {ExtractProviderDetails(json)}" + ); + } + + return json; + } + + private async Task DeleteJobBestEffortAsync(string jobId, string apiKey) + { + // Cleanup is best-effort and awaited before returning; bound it short + // so a stalled DELETE can't hold up the finished result. + using var cleanupCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var request = new HttpRequestMessage( + HttpMethod.Delete, + $"{BaseUrl}/v2/pre-recorded/{Uri.EscapeDataString(jobId)}" + ); + AddApiKey(request, apiKey); + + try + { + using var response = await _httpClient.SendAsync(request, cleanupCts.Token); + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(cleanupCts.Token); + Trace.TraceWarning( + "Gladia cleanup could not delete pre-recorded job " + + $"{jobId}: {(int)response.StatusCode} {ExtractProviderDetails(responseBody)}" + ); + } + } + catch (Exception ex) + { + Trace.TraceWarning( + $"Gladia cleanup could not delete pre-recorded job {jobId}: {ex.Message}" + ); + } + } + + private static PluginTranscriptionResult ParseCompletedResult( + JsonElement root, + string? fallbackLanguage + ) + { + if (!root.TryGetProperty("result", out var result) + || result.ValueKind != JsonValueKind.Object + || !result.TryGetProperty("transcription", out var transcription) + || transcription.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + "Gladia done response did not include result.transcription." + ); + } + + if (!transcription.TryGetProperty("full_transcript", out var transcriptElement) + || transcriptElement.ValueKind != JsonValueKind.String + || transcriptElement.GetString() is not { } transcript) + { + throw new InvalidOperationException( + "Gladia done response did not include a string result.transcription.full_transcript." + ); + } + + var detectedLanguage = FirstLanguage(transcription); + var duration = ReadDuration(root, result); + var segments = ReadSegments(transcription, ref duration, ref detectedLanguage); + detectedLanguage ??= fallbackLanguage; + + return new PluginTranscriptionResult( + transcript.Trim(), + detectedLanguage, + duration, + NoSpeechProbability: null + ) + { + Segments = segments, + }; + } + + private static string? FirstLanguage(JsonElement transcription) + { + if (!transcription.TryGetProperty("languages", out var languages) + || languages.ValueKind != JsonValueKind.Array) + { + return null; + } + + foreach (var language in languages.EnumerateArray()) + { + if (language.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(language.GetString())) + { + return language.GetString(); + } + } + + return null; + } + + private static double ReadDuration(JsonElement root, JsonElement result) + { + if (result.TryGetProperty("metadata", out var metadata) + && TryGetDouble(metadata, "audio_duration", out var resultDuration)) + { + return resultDuration; + } + + if (root.TryGetProperty("file", out var file) + && TryGetDouble(file, "audio_duration", out var fileDuration)) + { + return fileDuration; + } + + return 0; + } + + private static List ReadSegments( + JsonElement transcription, + ref double duration, + ref string? detectedLanguage + ) + { + var segments = new List(); + if (!transcription.TryGetProperty("utterances", out var utterances) + || utterances.ValueKind != JsonValueKind.Array) + { + return segments; + } + + foreach (var utterance in utterances.EnumerateArray()) + { + if (utterance.ValueKind != JsonValueKind.Object + || !TryGetString(utterance, "text", out var text) + || !TryGetDouble(utterance, "start", out var start) + || !TryGetDouble(utterance, "end", out var end) + || end < start) + { + continue; + } + + if (detectedLanguage is null + && TryGetString(utterance, "language", out var utteranceLanguage) + && !string.IsNullOrWhiteSpace(utteranceLanguage)) + { + detectedLanguage = utteranceLanguage; + } + + segments.Add(new PluginTranscriptionSegment(text, start, end)); + duration = Math.Max(duration, end); + } + + return segments; + } + + private static JsonDocument ParseProtocolJson(string json, string operation) + { + try + { + return JsonDocument.Parse(json); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + $"Gladia {operation} response contained invalid JSON.", + ex + ); + } + } + + private static string RequireString( + JsonElement root, + string propertyName, + string operation + ) + { + if (root.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(property.GetString())) + { + return property.GetString()!; + } + + throw new InvalidOperationException( + $"Gladia {operation} response did not include a string {propertyName}." + ); + } + + private static bool TryGetString( + JsonElement root, + string propertyName, + out string value + ) + { + if (root.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + && property.GetString() is { } stringValue) + { + value = stringValue; + return true; + } + + value = string.Empty; + return false; + } + + private static bool TryGetDouble( + JsonElement root, + string propertyName, + out double value + ) + { + if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.Number + && property.TryGetDouble(out value)) + { + return true; + } + + value = 0; + return false; + } + + private static string ExtractProviderDetails(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return "empty response"; + + try + { + using var document = JsonDocument.Parse(json); + return ExtractProviderDetails(document.RootElement); + } + catch (JsonException) + { + return json.Trim(); + } + } + + private static string ExtractProviderDetails(JsonElement root) + { + // TryGetProperty throws on non-objects, so render those bodies directly. + if (root.ValueKind != JsonValueKind.Object) + { + return root.ValueKind == JsonValueKind.String + ? root.GetString() ?? string.Empty + : root.GetRawText(); + } + + var details = new List(); + foreach (var propertyName in new[] + { + "status", + "error_code", + "error", + "error_type", + "error_message", + "message", + "request_id", + }) + { + if (!root.TryGetProperty(propertyName, out var value) + || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + continue; + } + + var rendered = value.ValueKind == JsonValueKind.String + ? value.GetString() + : value.GetRawText(); + details.Add($"{propertyName}={rendered}"); + } + + return details.Count > 0 ? string.Join(", ", details) : root.GetRawText(); + } + + private static string? NormalizeLanguage(string? language) + { + var normalized = language?.Trim(); + return string.IsNullOrEmpty(normalized) + || string.Equals(normalized, "auto", StringComparison.OrdinalIgnoreCase) + ? null + : normalized; + } + + private static void AddApiKey(HttpRequestMessage request, string apiKey) => + request.Headers.Add("x-gladia-key", apiKey); + + private TimeoutException PollTimeout(string jobId) => + new( + $"Gladia transcription {jobId} did not complete within " + + $"{_pollWindow.TotalSeconds:0.###} seconds." + ); + + private static HttpClient CreateHttpClient() => + new() + { + Timeout = TimeSpan.FromSeconds(120), + }; + + private sealed record InitiatedJob(string Id, Uri ResultUrl); + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => diff --git a/tests/TypeWhisper.PluginSystem.Tests/GladiaPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/GladiaPluginTests.cs index 688523c1e..d1ab9815f 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/GladiaPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/GladiaPluginTests.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Text; using System.Text.Json; using TypeWhisper.Linux.Services.Plugins; using TypeWhisper.Plugin.Gladia; @@ -14,6 +16,40 @@ public class GladiaPluginTests PropertyNameCaseInsensitive = true, }; + private const string DoneResponse = + """ + { + "id": "job-123", + "status": "done", + "file": { + "audio_duration": 2.25 + }, + "result": { + "metadata": { + "audio_duration": 2.5 + }, + "transcription": { + "full_transcript": " Hallo Welt ", + "languages": ["de"], + "utterances": [ + { + "text": "Hallo", + "start": 0.1, + "end": 1.0, + "language": "de" + }, + { + "text": "Welt", + "start": 1.1, + "end": 2.4, + "language": "de" + } + ] + } + } + } + """; + [Fact] public void PluginVersion_MatchesManifestVersion() { @@ -134,21 +170,777 @@ await Assert.ThrowsAsync( } [Fact] - public void TranscribeAsync_ThrowsNotSupportedExceptionSynchronously() + public async Task TranscribeAsync_UsesUploadInitiateReturnedPollUrlAndDeleteProtocol() { - using var sut = new GladiaPlugin(); + var seen = new List(); + var handler = new AsyncCapturingHandler(async (request, body, cancellationToken) => + { + seen.Add($"{request.Method} {request.RequestUri}"); + AssertApiKey(request); + + if (request.Method == HttpMethod.Post + && request.RequestUri?.ToString() == "https://api.gladia.io/v2/upload") + { + var multipart = Assert.IsType(request.Content); + Assert.Equal("multipart/form-data", multipart.Headers.ContentType?.MediaType); + var audio = Assert.Single(multipart); + Assert.Equal("audio", audio.Headers.ContentDisposition?.Name?.Trim('"')); + Assert.Equal("audio.wav", audio.Headers.ContentDisposition?.FileName?.Trim('"')); + Assert.Equal("audio/wav", audio.Headers.ContentType?.MediaType); + Assert.Equal([1, 2, 3, 4], await audio.ReadAsByteArrayAsync(cancellationToken)); + return JsonResponse( + """{ "audio_url": "https://api.gladia.io/file/upload-456" }""" + ); + } + + if (request.Method == HttpMethod.Post + && request.RequestUri?.ToString() == "https://api.gladia.io/v2/pre-recorded") + { + Assert.Equal("application/json", request.Content?.Headers.ContentType?.MediaType); + using var document = JsonDocument.Parse( + body ?? throw new InvalidOperationException("Missing initiation body.") + ); + var root = document.RootElement; + Assert.Equal( + ["audio_url", "language_config"], + root.EnumerateObject().Select(property => property.Name).ToArray() + ); + Assert.Equal( + "https://api.gladia.io/file/upload-456", + root.GetProperty("audio_url").GetString() + ); + Assert.Equal( + ["de"], + root + .GetProperty("language_config") + .GetProperty("languages") + .EnumerateArray() + .Select(item => item.GetString()!) + .ToArray() + ); + Assert.False(root.TryGetProperty("prompt", out _)); + return JsonResponse( + """ + { + "id": "job-123", + "result_url": "https://results.gladia.test/custom/jobs/job-123?token=returned" + } + """, + HttpStatusCode.Created + ); + } + + if (request.Method == HttpMethod.Get + && request.RequestUri?.ToString() + == "https://results.gladia.test/custom/jobs/job-123?token=returned") + { + return JsonResponse(DoneResponse); + } + + if (request.Method == HttpMethod.Delete + && request.RequestUri?.ToString() + == "https://api.gladia.io/v2/pre-recorded/job-123") + { + return JsonResponse("{}", HttpStatusCode.Accepted); + } - // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- void local binds Assert.Throws to the Action overload, asserting a synchronous throw (not a faulted Task). - void Act() => - _ = sut.TranscribeAsync([], null, false, null, CancellationToken.None); + throw new InvalidOperationException( + $"Unexpected request: {request.Method} {request.RequestUri}" + ); + }); + + using var sut = await CreateConfiguredPluginAsync(handler); - var exception = Assert.Throws(Act); + var result = await sut.TranscribeAsync( + [1, 2, 3, 4], + "de", + translate: false, + prompt: "This has no Gladia mapping.", + CancellationToken.None + ); + Assert.Equal("Hallo Welt", result.Text); + Assert.Equal("de", result.DetectedLanguage); + Assert.Equal(2.5, result.DurationSeconds); + Assert.Equal(["Hallo", "Welt"], result.Segments.Select(segment => segment.Text).ToArray()); + Assert.Equal([0.1, 1.1], result.Segments.Select(segment => segment.Start).ToArray()); + Assert.Equal([1.0, 2.4], result.Segments.Select(segment => segment.End).ToArray()); Assert.Equal( - "Gladia batch transcription is not supported in this build; use live streaming. " - + "The batch API requires a multi-stage upload/poll protocol that is not yet implemented.", + [ + "POST https://api.gladia.io/v2/upload", + "POST https://api.gladia.io/v2/pre-recorded", + "GET https://results.gladia.test/custom/jobs/job-123?token=returned", + "DELETE https://api.gladia.io/v2/pre-recorded/job-123", + ], + seen + ); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("auto")] + public async Task TranscribeAsync_OmitsLanguageConfigForAutoOrEmpty(string? language) + { + var handler = new SuccessfulFlowHandler((request, body) => + { + if (request.Method != HttpMethod.Post + || request.RequestUri?.AbsolutePath != "/v2/pre-recorded") + { + return; + } + + using var document = JsonDocument.Parse( + body ?? throw new InvalidOperationException("Missing initiation body.") + ); + Assert.False(document.RootElement.TryGetProperty("language_config", out _)); + }); + + using var sut = await CreateConfiguredPluginAsync(handler); + + var result = await sut.TranscribeAsync( + [1, 2, 3], + language, + translate: false, + prompt: null, + CancellationToken.None + ); + + Assert.Equal("Hallo Welt", result.Text); + } + + [Fact] + public async Task TranscribeAsync_MapsExplicitLanguageToLanguageConfig() + { + var handler = new SuccessfulFlowHandler((request, body) => + { + if (request.Method != HttpMethod.Post + || request.RequestUri?.AbsolutePath != "/v2/pre-recorded") + { + return; + } + + using var document = JsonDocument.Parse( + body ?? throw new InvalidOperationException("Missing initiation body.") + ); + var languageConfig = document.RootElement.GetProperty("language_config"); + Assert.Equal( + ["fr"], + languageConfig + .GetProperty("languages") + .EnumerateArray() + .Select(item => item.GetString()!) + .ToArray() + ); + }); + + using var sut = await CreateConfiguredPluginAsync(handler); + + await sut.TranscribeAsync( + [1, 2, 3], + "fr", + translate: false, + prompt: null, + CancellationToken.None + ); + } + + [Fact] + public async Task TranscribeAsync_PollsQueuedAndProcessingUntilDone() + { + var pollCount = 0; + var deleteCount = 0; + var handler = new CapturingHandler((request, _) => + { + AssertApiKey(request); + + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/upload") + { + return JsonResponse("""{ "audio_url": "https://api.gladia.io/file/upload-456" }"""); + } + + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/pre-recorded") + { + return InitiationResponse(); + } + + if (request.Method == HttpMethod.Get) + { + pollCount++; + return pollCount switch + { + 1 => JsonResponse("""{ "status": "queued" }"""), + 2 => JsonResponse("""{ "status": "processing" }"""), + _ => JsonResponse(DoneResponse), + }; + } + + if (request.Method == HttpMethod.Delete) + { + deleteCount++; + return JsonResponse("{}", HttpStatusCode.Accepted); + } + + throw new InvalidOperationException( + $"Unexpected request: {request.Method} {request.RequestUri}" + ); + }); + + using var sut = await CreateConfiguredPluginAsync(handler); + + var result = await sut.TranscribeAsync( + [1, 2, 3], + "de", + translate: false, + prompt: null, + CancellationToken.None + ); + + Assert.Equal("Hallo Welt", result.Text); + Assert.Equal(3, pollCount); + Assert.Equal(1, deleteCount); + } + + [Fact] + public async Task TranscribeAsync_TerminalErrorIncludesProviderDetailsAndDeletesJob() + { + var deleteCount = 0; + var handler = new CapturingHandler((request, _) => + { + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/upload") + { + return JsonResponse("""{ "audio_url": "https://api.gladia.io/file/upload-456" }"""); + } + + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/pre-recorded") + { + return InitiationResponse(); + } + + if (request.Method == HttpMethod.Get) + { + return JsonResponse( + """ + { + "status": "error", + "error_code": 422, + "error": { "message": "Audio could not be decoded" }, + "request_id": "G-request-7" + } + """ + ); + } + + if (request.Method == HttpMethod.Delete) + { + Assert.Equal( + "https://api.gladia.io/v2/pre-recorded/job-123", + request.RequestUri?.ToString() + ); + deleteCount++; + return JsonResponse("{}", HttpStatusCode.Accepted); + } + + throw new InvalidOperationException( + $"Unexpected request: {request.Method} {request.RequestUri}" + ); + }); + + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Contains("status=error", exception.Message); + Assert.Contains("422", exception.Message); + Assert.Contains("Audio could not be decoded", exception.Message); + Assert.Contains("G-request-7", exception.Message); + Assert.Equal(1, deleteCount); + } + + [Fact] + public async Task TranscribeAsync_DoneWithoutFullTranscriptFailsAndDeletesJob() + { + var deleteCount = 0; + var handler = new CapturingHandler((request, _) => + { + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/upload") + { + return JsonResponse("""{ "audio_url": "https://api.gladia.io/file/upload-456" }"""); + } + + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/pre-recorded") + { + return InitiationResponse(); + } + + if (request.Method == HttpMethod.Get) + { + return JsonResponse( + """ + { + "status": "done", + "result": { + "metadata": { "audio_duration": 1.0 }, + "transcription": { "languages": ["en"] } + } + } + """ + ); + } + + if (request.Method == HttpMethod.Delete) + { + deleteCount++; + return JsonResponse("{}", HttpStatusCode.Accepted); + } + + throw new InvalidOperationException( + $"Unexpected request: {request.Method} {request.RequestUri}" + ); + }); + + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Contains("result.transcription.full_transcript", exception.Message); + Assert.Equal(1, deleteCount); + } + + [Theory] + [InlineData("upload")] + [InlineData("initiate")] + [InlineData("poll")] + public async Task TranscribeAsync_HttpErrorAtEachStageFails(string failingStage) + { + var requestCount = 0; + var handler = new CapturingHandler((request, _) => + { + requestCount++; + + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/upload") + { + return failingStage == "upload" + ? JsonResponse( + """{ "message": "upload rejected" }""", + HttpStatusCode.BadGateway + ) + : JsonResponse( + """{ "audio_url": "https://api.gladia.io/file/upload-456" }""" + ); + } + + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/pre-recorded") + { + return failingStage == "initiate" + ? JsonResponse( + """{ "message": "initiation rejected" }""", + HttpStatusCode.BadGateway + ) + : InitiationResponse(); + } + + if (request.Method == HttpMethod.Get) + { + Assert.Equal("poll", failingStage); + return JsonResponse( + """{ "message": "poll rejected" }""", + HttpStatusCode.BadGateway + ); + } + + throw new InvalidOperationException( + $"Unexpected request: {request.Method} {request.RequestUri}" + ); + }); + + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Contains("502", exception.Message); + Assert.Contains( + failingStage switch + { + "upload" => "upload rejected", + "initiate" => "initiation rejected", + _ => "poll rejected", + }, exception.Message ); + Assert.Equal( + failingStage switch + { + "upload" => 1, + "initiate" => 2, + _ => 3, + }, + requestCount + ); + } + + [Theory] + [InlineData("\"unauthorized\"")] + [InlineData("[]")] + [InlineData("null")] + public async Task TranscribeAsync_NonObjectJsonErrorBodyStillReportsHttpError(string errorBody) + { + // Non-object error bodies must not derail HttpRequestException (TryGetProperty would throw). + var handler = new CapturingHandler((request, _) => + request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/upload" + ? JsonResponse(errorBody, HttpStatusCode.BadGateway) + : throw new InvalidOperationException( + $"Unexpected request: {request.Method} {request.RequestUri}" + )); + + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Contains("502", exception.Message); + } + + [Theory] + [InlineData("""{}""")] + [InlineData("""{ "status": 17 }""")] + [InlineData("""{ "status": "paused" }""")] + public async Task TranscribeAsync_MalformedOrUnknownPollStatusFails(string pollResponse) + { + var handler = new CapturingHandler((request, _) => + { + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/upload") + { + return JsonResponse("""{ "audio_url": "https://api.gladia.io/file/upload-456" }"""); + } + + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/pre-recorded") + { + return InitiationResponse(); + } + + return request.Method == HttpMethod.Get + ? JsonResponse(pollResponse) + : throw new InvalidOperationException( + $"Unexpected request: {request.Method} {request.RequestUri}" + ); + }); + + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Contains("status", exception.Message); + } + + [Theory] + [InlineData( + """{ "result_url": "https://results.gladia.test/custom/jobs/job-123" }""", + "id" + )] + [InlineData("""{ "id": "job-123" }""", "result_url")] + public async Task TranscribeAsync_InitiationRequiresIdAndResultUrl( + string initiationJson, + string missingField + ) + { + var handler = new CapturingHandler((request, _) => + { + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/upload") + { + return JsonResponse("""{ "audio_url": "https://api.gladia.io/file/upload-456" }"""); + } + + return request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/pre-recorded" + ? JsonResponse(initiationJson, HttpStatusCode.Created) + : throw new InvalidOperationException( + $"Unexpected request: {request.Method} {request.RequestUri}" + ); + }); + + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Contains(missingField, exception.Message); + } + + [Theory] + [InlineData("http://results.gladia.test/custom/jobs/job-123?token=returned")] + [InlineData("ftp://results.gladia.test/custom/jobs/job-123")] + [InlineData("not-a-url")] + public async Task TranscribeAsync_RejectsNonHttpsResultUrl(string resultUrl) + { + // Non-HTTPS result_url would leak x-gladia-key in plaintext during polling; must be rejected. + var handler = new CapturingHandler((request, _) => + { + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/upload") + { + return JsonResponse("""{ "audio_url": "https://api.gladia.io/file/upload-456" }"""); + } + + return request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/pre-recorded" + ? JsonResponse( + $$"""{ "id": "job-123", "result_url": "{{resultUrl}}" }""", + HttpStatusCode.Created + ) + : throw new InvalidOperationException( + $"Unexpected request: {request.Method} {request.RequestUri}" + ); + }); + + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Contains("result_url", exception.Message); + } + + [Fact] + public async Task TranscribeAsync_CancellationDuringPollingIsObserved() + { + var pollStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var deleteCount = 0; + var handler = new AsyncCapturingHandler(async (request, _, cancellationToken) => + { + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/upload") + { + return JsonResponse("""{ "audio_url": "https://api.gladia.io/file/upload-456" }"""); + } + + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/pre-recorded") + { + return InitiationResponse(); + } + + if (request.Method == HttpMethod.Get) + { + pollStarted.TrySetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return JsonResponse("""{ "status": "processing" }"""); + } + + if (request.Method == HttpMethod.Delete) + { + deleteCount++; + return JsonResponse("{}", HttpStatusCode.Accepted); + } + + throw new InvalidOperationException( + $"Unexpected request: {request.Method} {request.RequestUri}" + ); + }); + + using var sut = await CreateConfiguredPluginAsync(handler); + using var cts = new CancellationTokenSource(); + + var transcription = sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + cts.Token + ); + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; using cts.Token would abort this wait on the cancellation the test triggers next. + await pollStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + // ReSharper disable once MethodHasAsyncOverload -- synchronous Cancel must trip the token before the assertion; CancelAsync would defer it. + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => transcription); + Assert.Equal(0, deleteCount); + } + + [Fact] + public async Task TranscribeAsync_BoundedPollingWindowTimesOut() + { + var pollCount = 0; + var handler = new CapturingHandler((request, _) => + { + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/upload") + { + return JsonResponse("""{ "audio_url": "https://api.gladia.io/file/upload-456" }"""); + } + + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/pre-recorded") + { + return InitiationResponse(); + } + + if (request.Method == HttpMethod.Get) + { + pollCount++; + return JsonResponse("""{ "status": "processing" }"""); + } + + throw new InvalidOperationException( + $"Unexpected request: {request.Method} {request.RequestUri}" + ); + }); + + using var sut = await CreateConfiguredPluginAsync( + handler, + pollDelay: TimeSpan.FromHours(1), + pollWindow: TimeSpan.FromMilliseconds(20) + ); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Contains("did not complete", exception.Message); + Assert.Equal(1, pollCount); + } + + [Fact] + public async Task TranscribeAsync_RejectsTranslationBeforeHttp() + { + var handler = new CountingHandler(); + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: true, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Contains("does not support translation", exception.Message); + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public async Task TranscribeAsync_RequiresConfigurationBeforeHttp() + { + var handler = new CountingHandler(); + using var sut = new GladiaPlugin( + new HttpClient(handler), + pollDelay: TimeSpan.Zero + ); + await sut.ActivateAsync(new TestHost()); + + await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Equal(0, handler.RequestCount); + } + + [Fact] + public async Task TranscribeAsync_DeleteFailureDoesNotAffectCompletedResult() + { + var deleteCount = 0; + var handler = new SuccessfulFlowHandler( + inspectRequest: (request, _) => + { + if (request.Method == HttpMethod.Delete) + deleteCount++; + }, + deleteStatusCode: HttpStatusCode.InternalServerError + ); + + using var sut = await CreateConfiguredPluginAsync(handler); + + var result = await sut.TranscribeAsync( + [1, 2, 3], + "de", + translate: false, + prompt: null, + CancellationToken.None + ); + + Assert.Equal("Hallo Welt", result.Text); + Assert.Equal(1, deleteCount); } [Fact] @@ -246,6 +1038,163 @@ public void ParseMessage_ReturnsEmpty_OnMalformedJson() Assert.Null(msg.Text); } + private static async Task CreateConfiguredPluginAsync( + HttpMessageHandler handler, + TimeSpan? pollDelay = null, + TimeSpan? pollWindow = null + ) + { + var sut = new GladiaPlugin( + new HttpClient(handler), + pollDelay ?? TimeSpan.Zero, + pollWindow + ); + await sut.ActivateAsync( + new TestHost + { + Secrets = + { + ["api-key"] = "test-key", + }, + } + ); + return sut; + } + + private static void AssertApiKey(HttpRequestMessage request) + { + Assert.True(request.Headers.TryGetValues("x-gladia-key", out var values)); + Assert.Equal(["test-key"], values.ToArray()); + } + + private static HttpResponseMessage InitiationResponse() => + JsonResponse( + """ + { + "id": "job-123", + "result_url": "https://results.gladia.test/custom/jobs/job-123?token=returned" + } + """, + HttpStatusCode.Created + ); + + private static HttpResponseMessage JsonResponse( + string json, + HttpStatusCode statusCode = HttpStatusCode.OK + ) => + new(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + private sealed class SuccessfulFlowHandler( + Action? inspectRequest = null, + HttpStatusCode deleteStatusCode = HttpStatusCode.Accepted + ) : HttpMessageHandler + { + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + AssertApiKey(request); + var body = request.Content is null + ? null + : await request.Content.ReadAsByteArrayAsync(cancellationToken); + inspectRequest?.Invoke(request, body); + + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/upload") + { + return JsonResponse( + """{ "audio_url": "https://api.gladia.io/file/upload-456" }""" + ); + } + + if (request.Method == HttpMethod.Post + && request.RequestUri?.AbsolutePath == "/v2/pre-recorded") + { + return InitiationResponse(); + } + + if (request.Method == HttpMethod.Get + && request.RequestUri?.ToString() + == "https://results.gladia.test/custom/jobs/job-123?token=returned") + { + return JsonResponse(DoneResponse); + } + + if (request.Method == HttpMethod.Delete + && request.RequestUri?.ToString() + == "https://api.gladia.io/v2/pre-recorded/job-123") + { + return JsonResponse( + deleteStatusCode == HttpStatusCode.Accepted + ? "{}" + : """{ "message": "cleanup rejected" }""", + deleteStatusCode + ); + } + + throw new InvalidOperationException( + $"Unexpected request: {request.Method} {request.RequestUri}" + ); + } + } + + private sealed class CapturingHandler( + Func responder + ) : HttpMessageHandler + { + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + var body = request.Content is null + ? null + : await request.Content.ReadAsByteArrayAsync(cancellationToken); + return responder(request, body); + } + } + + private sealed class AsyncCapturingHandler( + Func< + HttpRequestMessage, + byte[]?, + CancellationToken, + Task + > responder + ) : HttpMessageHandler + { + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + var body = request.Content is null + ? null + : await request.Content.ReadAsByteArrayAsync(cancellationToken); + return await responder(request, body, cancellationToken); + } + } + + private sealed class CountingHandler : HttpMessageHandler + { + public int RequestCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + RequestCount++; + throw new InvalidOperationException( + $"Unexpected HTTP request: {request.Method} {request.RequestUri}" + ); + } + } + private sealed class TestHost : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() From 62e0e907bab3c188dca1f7909414c54ffc4ffbd0 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 00:01:04 +0000 Subject: [PATCH 171/226] Split additional-provider returns into non-owning role interfaces IAdditionalLlmProvidersProvider and IAdditionalTranscriptionEnginesProvider previously returned full plugin types whose ActivateAsync/DeactivateAsync/ Dispose the host never invoked, so the interface shape promised a lifecycle that did not happen and third-party plugins allocating real resources per additional provider would leak them. New SDK interfaces ILlmProviderRole and ITranscriptionEngineRole carry the selection identity and capability surface without lifecycle members; ILlmProviderPlugin and ITranscriptionEnginePlugin now derive from ITypeWhisperPlugin plus their role, so existing plugins compile unchanged. The additional-provider interfaces return the role types and document the ownership contract: the parent plugin owns child lifetime, the host never invokes lifecycle on returned objects, and returned instances must be stable across calls. PluginManager's capability indexes become role-typed and nine host consumers that only use the capability surface are re-typed mechanically. OpenAiCompatiblePlugin drops its no-op lifecycle wrappers in favor of a per-profile role cache guarded by a lock so repeated getters and capability rebuilds return the same instances, invalidated only when the owning profile changes or is removed. --- .../OpenAiCompatiblePlugin.cs | 115 +++++++-- .../Services/DictationOrchestrator.cs | 4 +- .../Services/HttpApiService.cs | 2 +- .../LinuxLiveTranscriptionStartupPolicy.cs | 4 +- .../Services/MemoryService.cs | 2 +- .../Services/ModelManagerService.cs | 12 +- .../Services/Plugins/PluginManager.cs | 20 +- .../Services/PromptProcessingService.cs | 8 +- .../StreamingTranscriptionCoordinator.cs | 6 +- .../Services/TranslationService.cs | 4 +- .../Sections/DictationSectionViewModel.cs | 2 +- .../IAdditionalLlmProvidersProvider.cs | 11 +- ...IAdditionalTranscriptionEnginesProvider.cs | 12 +- .../ILlmProviderPlugin.cs | 43 +--- src/TypeWhisper.PluginSDK/ILlmProviderRole.cs | 55 ++++ .../ITranscriptionEnginePlugin.cs | 223 +---------------- .../ITranscriptionEngineRole.cs | 234 ++++++++++++++++++ .../PluginSelectionExtensions.cs | 4 +- .../LlmCleanupServiceTests.cs | 2 +- .../PromptProcessingServiceTests.cs | 2 +- .../TestPluginManagerFactory.cs | 2 +- .../WelcomeWizardViewModelTests.cs | 2 +- .../ModelManagerServiceTests.cs | 2 +- .../OpenAiCompatiblePluginTests.cs | 68 ++++- .../PluginManagerTests.cs | 97 +++++++- 25 files changed, 620 insertions(+), 316 deletions(-) create mode 100644 src/TypeWhisper.PluginSDK/ILlmProviderRole.cs create mode 100644 src/TypeWhisper.PluginSDK/ITranscriptionEngineRole.cs diff --git a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs index a1c7c4709..ff243d06f 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs @@ -37,6 +37,13 @@ public sealed class OpenAiCompatiblePlugin private bool _streamResponses = true; private readonly List _additionalProfiles = []; private readonly Dictionary _additionalApiKeys = new(StringComparer.Ordinal); + private readonly Dictionary _profileRoles = + new(StringComparer.Ordinal); + + // Guards _profileRoles: the capability getters populate it lazily (a read that + // mutates) while model-selection, catalog refresh, and invalidation remove from it, + // and these run on different threads (host capability rebuilds vs. UI/async paths). + private readonly Lock _profileRolesLock = new(); public OpenAiCompatiblePlugin() : this(new HttpClient { Timeout = TimeSpan.FromMinutes(5) }) @@ -500,6 +507,11 @@ public async Task RefreshModelCatalogAsync(CancellationToken ct = default) continue; profile.FetchedModels = models; + lock (_profileRolesLock) + { + _profileRoles.Remove(profile.Id); + } + anyProfileChanged = true; } @@ -535,14 +547,14 @@ private static bool CatalogChanged(List fetched, List AdditionalTranscriptionEngines => + public IReadOnlyList AdditionalTranscriptionEngines => _additionalProfiles - .Select(ITranscriptionEnginePlugin (p) => new OpenAiCompatibleProfileRole(this, p.Id)) + .Select(ITranscriptionEngineRole (profile) => GetProfileRole(profile.Id)) .ToList(); - public IReadOnlyList AdditionalLlmProviders => + public IReadOnlyList AdditionalLlmProviders => _additionalProfiles - .Select(ILlmProviderPlugin (p) => new OpenAiCompatibleProfileRole(this, p.Id)) + .Select(ILlmProviderRole (profile) => GetProfileRole(profile.Id)) .ToList(); public IReadOnlyList GetCollectionDefinitions() => @@ -701,6 +713,7 @@ public async Task SetItemsAsync( profile.FetchedModels = models; } + InvalidateChangedProfileRoles(previousById, keyUpdates.Keys); PersistAdditionalProfiles(notify: true); return new PluginSettingsValidationResult(true, $"Saved {_additionalProfiles.Count} profile(s)."); @@ -746,7 +759,16 @@ internal void SelectProfileModel(string id, string modelId) if (profile is null) return; - profile.SelectedModelId = string.IsNullOrWhiteSpace(modelId) ? null : modelId.Trim(); + var selectedModelId = string.IsNullOrWhiteSpace(modelId) ? null : modelId.Trim(); + if (string.Equals(profile.SelectedModelId, selectedModelId, StringComparison.Ordinal)) + return; + + profile.SelectedModelId = selectedModelId; + lock (_profileRolesLock) + { + _profileRoles.Remove(id); + } + PersistAdditionalProfiles(notify: false); } @@ -848,6 +870,8 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct private async Task LoadAdditionalProfilesAsync(IPluginHostServices host) { + var previousById = _additionalProfiles.ToDictionary(p => p.Id, StringComparer.Ordinal); + var previousApiKeys = new Dictionary(_additionalApiKeys, StringComparer.Ordinal); _additionalProfiles.Clear(); _additionalApiKeys.Clear(); @@ -872,6 +896,17 @@ private async Task LoadAdditionalProfilesAsync(IPluginHostServices host) if (!string.IsNullOrEmpty(key)) _additionalApiKeys[profile.Id] = key; } + + var changedApiKeyIds = previousApiKeys + .Keys.Union(_additionalApiKeys.Keys, StringComparer.Ordinal) + .Where(id => + !string.Equals( + previousApiKeys.GetValueOrDefault(id), + _additionalApiKeys.GetValueOrDefault(id), + StringComparison.Ordinal + ) + ); + InvalidateChangedProfileRoles(previousById, changedApiKeyIds); } private void PersistAdditionalProfiles(bool notify) @@ -933,6 +968,61 @@ CancellationToken ct private string? GetProfileApiKey(string id) => _additionalApiKeys.GetValueOrDefault(id); + private OpenAiCompatibleProfileRole GetProfileRole(string id) + { + lock (_profileRolesLock) + { + if (!_profileRoles.TryGetValue(id, out var role)) + { + role = new OpenAiCompatibleProfileRole(this, id); + _profileRoles.Add(id, role); + } + + return role; + } + } + + private void InvalidateChangedProfileRoles( + Dictionary previousById, + IEnumerable changedSecretIds + ) + { + var changedSecrets = changedSecretIds.ToHashSet(StringComparer.Ordinal); + var currentById = _additionalProfiles.ToDictionary(p => p.Id, StringComparer.Ordinal); + + lock (_profileRolesLock) + { + foreach (var id in _profileRoles.Keys.ToList()) + { + if ( + !previousById.TryGetValue(id, out var previous) + || !currentById.TryGetValue(id, out var current) + || changedSecrets.Contains(id) + || !ProfilesEqual(previous, current) + ) + { + _profileRoles.Remove(id); + } + } + } + } + + private static bool ProfilesEqual( + OpenAiCompatibleProfile left, + OpenAiCompatibleProfile right + ) + { + return string.Equals(left.Name, right.Name, StringComparison.Ordinal) + && string.Equals(left.BaseUrl, right.BaseUrl, StringComparison.Ordinal) + && string.Equals(left.SelectedModelId, right.SelectedModelId, StringComparison.Ordinal) + && string.Equals( + left.SelectedLlmModelId, + right.SelectedLlmModelId, + StringComparison.Ordinal + ) + && left.FetchedModels.SequenceEqual(right.FetchedModels); + } + private OpenAiCompatibleProfile? FindAdditional(string id) => _additionalProfiles.FirstOrDefault(p => string.Equals(p.Id, id, StringComparison.Ordinal)); @@ -983,19 +1073,17 @@ private string CreateProfileId(HashSet taken) private static string? NullIfWhiteSpace(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - // Stateless wrapper that presents one additional profile as a standalone + // Cached wrapper that presents one additional profile as a standalone // transcription engine / LLM provider. Its selection identity is the profile // ID; PluginId stays the owner's so host lookups (enable-state, settings) - // still resolve to the real plugin. + // still resolve to the real plugin. The owner is its only lifetime authority. private sealed class OpenAiCompatibleProfileRole(OpenAiCompatiblePlugin owner, string profileId) - : ITranscriptionEnginePlugin, - ILlmProviderPlugin, + : ITranscriptionEngineRole, + ILlmProviderRole, ITranscriptionEngineSelectionIdentity, ILlmProviderSelectionIdentity { public string PluginId => owner.PluginId; - public string PluginName => owner.PluginName; - public string PluginVersion => owner.PluginVersion; public string TranscriptionSelectionId => profileId; public string LlmSelectionId => profileId; public string ProviderId => profileId; @@ -1008,10 +1096,6 @@ private sealed class OpenAiCompatibleProfileRole(OpenAiCompatiblePlugin owner, s public bool IsAvailable => owner.ProfileLlmAvailable(profileId); public IReadOnlyList SupportedModels => owner.ProfileLlmModels(profileId); - public Task ActivateAsync(IPluginHostServices host) => Task.CompletedTask; - - public Task DeactivateAsync() => Task.CompletedTask; - public void SelectModel(string modelId) => owner.SelectProfileModel(profileId, modelId); public Task TranscribeAsync( @@ -1036,7 +1120,6 @@ public IAsyncEnumerable ProcessStreamingAsync( CancellationToken ct ) => owner.ProcessStreamingForProfileAsync(profileId, systemPrompt, userText, model, ct); - public void Dispose() { } } } diff --git a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs index 55425ef28..e811d795f 100644 --- a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs +++ b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs @@ -3800,7 +3800,7 @@ AudioRecordingService.AudioCaptureSession captureSession } private void StartStreamingTranscriptionSession( - ITranscriptionEnginePlugin plugin, + ITranscriptionEngineRole plugin, string? language, int sessionVersion, AudioRecordingService.AudioCaptureSession captureSession @@ -4149,7 +4149,7 @@ private bool ShouldAutoStopForSilence() } private async Task PollPartialTranscriptOnceAsync( - ITranscriptionEnginePlugin plugin, + ITranscriptionEngineRole plugin, byte[] wav, int sessionVersion, AudioRecordingService.AudioCaptureSession captureSession, diff --git a/src/TypeWhisper.Linux/Services/HttpApiService.cs b/src/TypeWhisper.Linux/Services/HttpApiService.cs index 54b5b220b..ba540c779 100644 --- a/src/TypeWhisper.Linux/Services/HttpApiService.cs +++ b/src/TypeWhisper.Linux/Services/HttpApiService.cs @@ -217,7 +217,7 @@ internal static string ReadBearerToken(AppSettings settings) } internal static object? BuildAccelerationDto( - ITranscriptionEnginePlugin? plugin, + ITranscriptionEngineRole? plugin, AppSettings settings ) { diff --git a/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs b/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs index 9a7810966..8651d3f3c 100644 --- a/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs +++ b/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs @@ -17,7 +17,7 @@ internal static class LinuxLiveTranscriptionStartupPolicy { public static LiveTranscriptionMode Select( AppSettings settings, - ITranscriptionEnginePlugin? plugin) + ITranscriptionEngineRole? plugin) { if (!settings.LiveTranscriptionEnabled || plugin is null) { @@ -43,4 +43,4 @@ public static LiveTranscriptionMode Select( ? LiveTranscriptionMode.Polling : LiveTranscriptionMode.None; } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/MemoryService.cs b/src/TypeWhisper.Linux/Services/MemoryService.cs index 0df0ce9b2..c8ad099f6 100644 --- a/src/TypeWhisper.Linux/Services/MemoryService.cs +++ b/src/TypeWhisper.Linux/Services/MemoryService.cs @@ -115,7 +115,7 @@ public async Task ExtractAndStoreAsync( // is disabled). private LlmCallProvenance? RecordProvenance( LlmCallCapture? capture, - ILlmProviderPlugin provider, + ILlmProviderRole provider, string modelId, string userPrompt ) diff --git a/src/TypeWhisper.Linux/Services/ModelManagerService.cs b/src/TypeWhisper.Linux/Services/ModelManagerService.cs index aa655bdd7..d02e4ee89 100644 --- a/src/TypeWhisper.Linux/Services/ModelManagerService.cs +++ b/src/TypeWhisper.Linux/Services/ModelManagerService.cs @@ -92,7 +92,7 @@ public ITranscriptionEngine Engine } } - public ITranscriptionEnginePlugin? ActiveTranscriptionPlugin => GetTranscriptionPlugin(_activeModelId); + public ITranscriptionEngineRole? ActiveTranscriptionPlugin => GetTranscriptionPlugin(_activeModelId); /// /// Resolves the transcription plugin that owns (a @@ -100,7 +100,7 @@ public ITranscriptionEngine Engine /// plugin model or no matching engine is loaded. Lets callers target the engine for /// a specific (e.g. UI-selected) model rather than only the active one. /// - public ITranscriptionEnginePlugin? GetTranscriptionPlugin(string? modelId) + public ITranscriptionEngineRole? GetTranscriptionPlugin(string? modelId) { if (modelId is null || !IsPluginModel(modelId)) { @@ -1004,7 +1004,7 @@ public sealed class TranscriptionLease : IAsyncDisposable internal TranscriptionLease( SemaphoreSlim modelLock, - ITranscriptionEnginePlugin plugin, + ITranscriptionEngineRole plugin, ModelManagerService owner, bool keepModelWarm ) @@ -1016,7 +1016,7 @@ bool keepModelWarm } /// The plugin pinned for the lifetime of this lease. - public ITranscriptionEnginePlugin Plugin { get; } + public ITranscriptionEngineRole Plugin { get; } public ValueTask DisposeAsync() { @@ -1074,9 +1074,9 @@ public Task TranscribeAsync( internal sealed class PluginTranscriptionEngineAdapter : ITranscriptionEngine { - private readonly ITranscriptionEnginePlugin _plugin; + private readonly ITranscriptionEngineRole _plugin; - public PluginTranscriptionEngineAdapter(ITranscriptionEnginePlugin plugin) + public PluginTranscriptionEngineAdapter(ITranscriptionEngineRole plugin) { _plugin = plugin; } diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs index bef5154e7..913a4b12e 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs @@ -36,9 +36,9 @@ public sealed class PluginManager : IDisposable private bool _isRefreshingModels; private DateTime _lastModelRefresh = DateTime.MinValue; - private List _llmProviders = []; + private List _llmProviders = []; private List _postProcessors = []; - private List _transcriptionEngines = []; + private List _transcriptionEngines = []; private List _ttsProviders = []; public PluginManager( @@ -101,7 +101,7 @@ public IReadOnlyList AllPlugins } } - public IReadOnlyList LlmProviders + public IReadOnlyList LlmProviders { get { @@ -112,7 +112,7 @@ public IReadOnlyList LlmProviders } } - public IReadOnlyList TranscriptionEngines + public IReadOnlyList TranscriptionEngines { get { @@ -818,8 +818,8 @@ private void RebuildCapabilityIndices() PluginStateChanged?.Invoke(this, EventArgs.Empty); } - private IEnumerable<(ILlmProviderPlugin Provider, string SelectionId)> ValidLlmProviders( - IEnumerable providers + private IEnumerable<(ILlmProviderRole Provider, string SelectionId)> ValidLlmProviders( + IEnumerable providers ) { foreach (var provider in providers) @@ -845,9 +845,9 @@ IEnumerable providers } private IEnumerable<( - ITranscriptionEnginePlugin Provider, + ITranscriptionEngineRole Provider, string SelectionId - )> ValidTranscriptionEngines(IEnumerable providers) + )> ValidTranscriptionEngines(IEnumerable providers) { foreach (var provider in providers) { @@ -889,7 +889,7 @@ string errorCategory // capability rebuild: materialize each provider's additional roles inside a // try/catch so a throwing getter (or one that throws mid-enumeration) just // contributes nothing and is logged. Grouping/dedup downstream is unchanged. - private static IEnumerable SafeAdditionalLlmProviders( + private static IEnumerable SafeAdditionalLlmProviders( IAdditionalLlmProvidersProvider provider ) { @@ -911,7 +911,7 @@ IAdditionalLlmProvidersProvider provider } } - private static IEnumerable SafeAdditionalTranscriptionEngines( + private static IEnumerable SafeAdditionalTranscriptionEngines( IAdditionalTranscriptionEnginesProvider provider ) { diff --git a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs index a43c5b1fd..c076df643 100644 --- a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs +++ b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs @@ -203,7 +203,7 @@ public async Task ProcessSystemPromptAsync( private LlmCallProvenance? RecordProvenance( LlmCallCapture? capture, string stage, - ILlmProviderPlugin provider, + ILlmProviderRole provider, string modelId, string systemPrompt, string userPrompt, @@ -250,12 +250,12 @@ internal static string FormatPromptActionInput(string inputText) """; } - private (ILlmProviderPlugin? Provider, string ModelId) ResolveProvider(PromptAction action) + private (ILlmProviderRole? Provider, string ModelId) ResolveProvider(PromptAction action) { return ResolveProvider(action.ProviderOverride); } - private (ILlmProviderPlugin? Provider, string ModelId) ResolveProvider(string? providerOverride) + private (ILlmProviderRole? Provider, string ModelId) ResolveProvider(string? providerOverride) { if (!string.IsNullOrWhiteSpace(providerOverride)) { @@ -292,7 +292,7 @@ internal static string FormatPromptActionInput(string inputText) return (null, string.Empty); } - private (ILlmProviderPlugin? Provider, string ModelId) ResolvePluginModelId( + private (ILlmProviderRole? Provider, string ModelId) ResolvePluginModelId( string pluginModelId ) { diff --git a/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs b/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs index 1425b153f..40ae7511e 100644 --- a/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs +++ b/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs @@ -7,7 +7,7 @@ namespace TypeWhisper.Linux.Services; /// /// Owns the lifetime of a single : connects via -/// , accepts live PCM +/// , accepts live PCM /// audio frames from the audio tap, drives the session's sender on a single reader /// task, and exposes the joined final-segment text on . /// Mirrors upstream Windows StreamingHandler.cs's A9/A10 concurrency @@ -43,7 +43,7 @@ internal sealed class StreamingTranscriptionCoordinator : IAsyncDisposable private readonly Action _onPartial; private readonly Queue _pending = new(); - private readonly ITranscriptionEnginePlugin _plugin; + private readonly ITranscriptionEngineRole _plugin; private readonly int _sessionVersion; private Channel? _channel; private CancellationTokenSource? _cts; @@ -68,7 +68,7 @@ internal sealed class StreamingTranscriptionCoordinator : IAsyncDisposable private Action? _transcriptHandler; public StreamingTranscriptionCoordinator( - ITranscriptionEnginePlugin plugin, + ITranscriptionEngineRole plugin, string? language, int sessionVersion, Action onPartial, diff --git a/src/TypeWhisper.Linux/Services/TranslationService.cs b/src/TypeWhisper.Linux/Services/TranslationService.cs index ff131d6ca..b8c9b4ece 100644 --- a/src/TypeWhisper.Linux/Services/TranslationService.cs +++ b/src/TypeWhisper.Linux/Services/TranslationService.cs @@ -80,7 +80,7 @@ public async Task TranslateAsync( return translated; } - private ILlmProviderPlugin? GetConfiguredTranslationProvider() + private ILlmProviderRole? GetConfiguredTranslationProvider() { return _pluginManager.LlmProviders.FirstOrDefault(provider => provider.IsAvailable); } @@ -90,7 +90,7 @@ public async Task TranslateAsync( // attach the response (null when capture is disabled). private LlmCallProvenance? RecordProvenance( LlmCallCapture? capture, - ILlmProviderPlugin provider, + ILlmProviderRole provider, string modelId, string userPrompt ) diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs index 5af5c8bc1..d46e1309f 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs @@ -399,7 +399,7 @@ Func> getInputDevices // The engine that owns the model selected in the Dictation UI — the one a CUDA download // must target. Distinct from ActiveTranscriptionPlugin (the loaded engine), which is null // before any model loads (e.g. at startup) and can lag a freshly selected model. - private ITranscriptionEnginePlugin? SelectedModelPlugin => + private ITranscriptionEngineRole? SelectedModelPlugin => _models.GetTranscriptionPlugin(SelectedModel?.ModelId); // Offer the in-app download when there's a GPU but CUDA isn't usable yet and the diff --git a/src/TypeWhisper.PluginSDK/IAdditionalLlmProvidersProvider.cs b/src/TypeWhisper.PluginSDK/IAdditionalLlmProvidersProvider.cs index 7cb9c6d0f..43e73f025 100644 --- a/src/TypeWhisper.PluginSDK/IAdditionalLlmProvidersProvider.cs +++ b/src/TypeWhisper.PluginSDK/IAdditionalLlmProvidersProvider.cs @@ -4,11 +4,16 @@ namespace TypeWhisper.PluginSDK; /// -/// Optional capability expansion for plugins that expose additional LLM provider roles. +/// Optional capability expansion for plugins that expose additional LLM provider roles. +/// The parent plugin owns every returned role's lifetime; the host never activates, +/// deactivates, or disposes returned objects. /// // ReSharper disable once UnusedType.Global public interface IAdditionalLlmProvidersProvider { - /// Additional LLM provider roles exposed by this plugin. - IReadOnlyList AdditionalLlmProviders { get; } + /// + /// Additional LLM provider roles exposed by this plugin. Returned role instances + /// MUST be stable across calls so capability-index rebuilds reuse the same objects. + /// + IReadOnlyList AdditionalLlmProviders { get; } } diff --git a/src/TypeWhisper.PluginSDK/IAdditionalTranscriptionEnginesProvider.cs b/src/TypeWhisper.PluginSDK/IAdditionalTranscriptionEnginesProvider.cs index 6af4cc2d4..cca6a163c 100644 --- a/src/TypeWhisper.PluginSDK/IAdditionalTranscriptionEnginesProvider.cs +++ b/src/TypeWhisper.PluginSDK/IAdditionalTranscriptionEnginesProvider.cs @@ -4,11 +4,17 @@ namespace TypeWhisper.PluginSDK; /// -/// Optional capability expansion for plugins that expose additional transcription engine roles. +/// Optional capability expansion for plugins that expose additional transcription-engine +/// roles. The parent plugin owns every returned role's lifetime; the host never activates, +/// deactivates, or disposes returned objects. /// // ReSharper disable once UnusedType.Global public interface IAdditionalTranscriptionEnginesProvider { - /// Additional transcription engine roles exposed by this plugin. - IReadOnlyList AdditionalTranscriptionEngines { get; } + /// + /// Additional transcription-engine roles exposed by this plugin. Returned role + /// instances MUST be stable across calls so capability-index rebuilds reuse the + /// same objects. + /// + IReadOnlyList AdditionalTranscriptionEngines { get; } } diff --git a/src/TypeWhisper.PluginSDK/ILlmProviderPlugin.cs b/src/TypeWhisper.PluginSDK/ILlmProviderPlugin.cs index be5856769..af57ef079 100644 --- a/src/TypeWhisper.PluginSDK/ILlmProviderPlugin.cs +++ b/src/TypeWhisper.PluginSDK/ILlmProviderPlugin.cs @@ -1,47 +1,16 @@ // Public plugin-SDK surface. The per-item `disable once` directives below mark members // ReSharper/Qodana cannot see used from this project (they are consumed by external plugins/ // the host). Per-item, not file-level, so a genuinely-unused member added later still surfaces. -using System.Runtime.CompilerServices; -using TypeWhisper.PluginSDK.Models; - namespace TypeWhisper.PluginSDK; /// -/// Plugin that provides LLM chat-completion capabilities (e.g. for translation, course correction). +/// Owning plugin that provides an LLM role. The host manages lifecycle only through +/// and consumes LLM capabilities through +/// . /// // ReSharper disable once UnusedType.Global -public interface ILlmProviderPlugin : ITypeWhisperPlugin +public interface ILlmProviderPlugin : ITypeWhisperPlugin, ILlmProviderRole { - /// Provider name shown in the UI (e.g. "OpenAI", "Groq"). - string ProviderName { get; } - - /// Whether the provider is ready to accept requests (API key configured, etc.). - bool IsAvailable { get; } - - /// Models supported by this provider. - IReadOnlyList SupportedModels { get; } - - /// Sends a chat completion request and returns the response text. - Task ProcessAsync( - string systemPrompt, - string userText, - string model, - CancellationToken ct - ); - - /// - /// Streams the response token-by-token. The default implementation wraps - /// and yields a single chunk, so non-streaming - /// providers remain correct without overriding this method. - /// - async IAsyncEnumerable ProcessStreamingAsync( - string systemPrompt, - string userText, - string model, - [EnumeratorCancellation] - CancellationToken ct - ) - { - yield return await ProcessAsync(systemPrompt, userText, model, ct); - } + /// Unifies the plugin and role views of the owning plugin identifier. + new string PluginId { get; } } diff --git a/src/TypeWhisper.PluginSDK/ILlmProviderRole.cs b/src/TypeWhisper.PluginSDK/ILlmProviderRole.cs new file mode 100644 index 000000000..267d5b1d9 --- /dev/null +++ b/src/TypeWhisper.PluginSDK/ILlmProviderRole.cs @@ -0,0 +1,55 @@ +// Public plugin-SDK surface. The per-item `disable once` directives below mark members +// ReSharper/Qodana cannot see used from this project (they are consumed by external plugins/ +// the host). Per-item, not file-level, so a genuinely-unused member added later still surfaces. +using System.Runtime.CompilerServices; +using TypeWhisper.PluginSDK.Models; + +namespace TypeWhisper.PluginSDK; + +/// +/// Non-owning role that provides LLM chat-completion capabilities (e.g. for +/// translation and course correction). The owner is responsible for this role's +/// lifetime; hosts consume only the capability surface exposed here. +/// +// ReSharper disable once UnusedType.Global +public interface ILlmProviderRole +{ + /// + /// Identifier of the owning plugin. Additional roles use their selection identity + /// to distinguish selectable providers while retaining the owner's plugin ID. + /// + string PluginId { get; } + + /// Provider name shown in the UI (e.g. "OpenAI", "Groq"). + string ProviderName { get; } + + /// Whether the provider is ready to accept requests (API key configured, etc.). + bool IsAvailable { get; } + + /// Models supported by this provider. + IReadOnlyList SupportedModels { get; } + + /// Sends a chat completion request and returns the response text. + Task ProcessAsync( + string systemPrompt, + string userText, + string model, + CancellationToken ct + ); + + /// + /// Streams the response token-by-token. The default implementation wraps + /// and yields a single chunk, so non-streaming + /// providers remain correct without overriding this method. + /// + async IAsyncEnumerable ProcessStreamingAsync( + string systemPrompt, + string userText, + string model, + [EnumeratorCancellation] + CancellationToken ct + ) + { + yield return await ProcessAsync(systemPrompt, userText, model, ct); + } +} diff --git a/src/TypeWhisper.PluginSDK/ITranscriptionEnginePlugin.cs b/src/TypeWhisper.PluginSDK/ITranscriptionEnginePlugin.cs index 07d70a3ea..f1ea7b6a7 100644 --- a/src/TypeWhisper.PluginSDK/ITranscriptionEnginePlugin.cs +++ b/src/TypeWhisper.PluginSDK/ITranscriptionEnginePlugin.cs @@ -1,226 +1,17 @@ -// ReSharper disable UnusedMemberInSuper.Global -// PluginSDK contract members are implemented by out-of-solution plugin projects and invoked by -// the host; the analyzer sees no in-solution caller, so these .Global inspections misfire. - // Public plugin-SDK surface. The per-item `disable once` directives below mark members // ReSharper/Qodana cannot see used from this project (they are consumed by external plugins/ // the host). Per-item, not file-level, so a genuinely-unused member added later still surfaces. -using TypeWhisper.PluginSDK.Models; - namespace TypeWhisper.PluginSDK; /// -/// Plugin that provides audio transcription capabilities via a cloud or local engine. +/// Owning plugin that provides a transcription-engine role. The host manages lifecycle +/// only through and consumes transcription capabilities +/// through . /// // ReSharper disable once UnusedType.Global -public interface ITranscriptionEnginePlugin : ITypeWhisperPlugin +public interface ITranscriptionEnginePlugin : ITypeWhisperPlugin, ITranscriptionEngineRole { - /// Unique provider identifier (e.g. "openai", "groq"). - string ProviderId { get; } - - /// Human-readable provider name for the UI. - string ProviderDisplayName { get; } - - /// Whether the provider is configured and ready (API key set, etc.). - bool IsConfigured { get; } - - /// Available transcription models for this provider. - IReadOnlyList TranscriptionModels { get; } - - /// Currently selected model ID, or null if none selected. - string? SelectedModelId { get; } - - /// Whether this provider supports translation (audio to English). - bool SupportsTranslation { get; } - - /// Whether this engine supports downloading and managing local model files. - bool SupportsModelDownload => false; - - /// Whether this engine supports real-time streaming transcription via . - bool SupportsStreaming => false; - - /// ISO language codes supported by this engine, or empty for all. - IReadOnlyList SupportedLanguages => []; - - /// Acceleration backends this engine can run on. Default: CPU only. - IReadOnlyList SupportedAccelerationBackends => - [TranscriptionAccelerationBackend.Cpu]; - - /// - /// Whether this engine downloads and preloads its own CUDA runtime on demand - /// during , and falls back to CPU itself - /// (surfacing the reason via ) when the GPU - /// path can't be honored. When true, the host must not reject an - /// explicit load - /// just because the CUDA runtime libraries aren't already installed on the - /// host — the plugin provisions them. Default: false (the engine relies - /// on a host-provided CUDA runtime). - /// - bool ProvisionsCudaRuntimeOnDemand => false; - - /// - /// For a self-provisioning engine (), - /// whether the CUDA runtime it needs is already fully available — every - /// required library either provided by the host system or already downloaded - /// into the cache. Pure inspection (no driver probe, no download), so the host - /// can poll it to decide whether CUDA can be selected now (true) or the - /// runtime still needs fetching (false, including the partial-install - /// case where only some libraries are present). Default: false. - /// - bool IsCudaRuntimeProvisioned => false; - - /// - /// Downloads and preloads only the CUDA runtime libraries this engine is still - /// missing (a no-op when is already - /// true), reporting progress 0.0–1.0. Lets the host offer an explicit - /// "download CUDA runtime" action on a driver-only host instead of waiting for - /// the lazy - /// path. Throws if the NVIDIA driver is unusable or the download fails — the - /// host surfaces the message. Default: no-op (engines that rely on a - /// host-provided runtime have nothing to fetch). - /// - // ReSharper disable UnusedParameter.Global - Task EnsureCudaRuntimeReadyAsync(IProgress? progress, CancellationToken ct) - // ReSharper restore UnusedParameter.Global - { - return Task.CompletedTask; - } - - /// - /// Deletes this engine's provisioned CUDA runtime caches (the shared CUDA math - /// libraries plus any per-engine GPU build) so the next CUDA load re-provisions - /// from scratch. Best-effort. Note: libraries already dlopen'd this session are - /// held until process exit, so a restart is required for a fresh re-download to - /// take effect. Default: no-op for engines that rely on a host-provided runtime - /// (nothing to clear); a self-provisioning engine - /// () MUST override this — the default - /// throws rather than silently report a clear that never happened (which would - /// leave a corrupt cache in place and defeat the host's failure aggregation). - /// - // ReSharper disable once UnusedParameter.Global - Task ClearCudaRuntimeAsync(CancellationToken ct) - { - if (ProvisionsCudaRuntimeOnDemand) - { - throw new NotSupportedException( - $"{ProviderId} provisions its CUDA runtime on demand and must override " - + $"{nameof(ClearCudaRuntimeAsync)}." - ); - } - - return Task.CompletedTask; - } - - /// Acceleration preference last requested by the host. Default: Auto. - // ReSharper disable once UnusedMember.Global - TranscriptionAccelerationPreference AccelerationPreference => - TranscriptionAccelerationPreference.Auto; - - /// Reports what acceleration the engine actually loaded with. - TranscriptionAccelerationStatus AccelerationStatus => - new(TranscriptionAccelerationBackend.Cpu, "Using CPU"); - - /// Selects a transcription model by ID. - void SelectModel(string modelId); - - /// Configures the preferred compute backend. Common values: "cpu", "cuda". - // ReSharper disable once UnusedMember.Global - // ReSharper disable once UnusedParameter.Global - Task ConfigureComputeBackendAsync(string backend) - { - return Task.CompletedTask; - } - - /// - /// Sets the resolved acceleration preference. The host resolves Auto - /// before calling, so plugins only ever see Cpu or NvidiaCuda. - /// - void SetAccelerationPreference(TranscriptionAccelerationPreference preference) { } - - /// Transcribes WAV audio data and returns the result. - Task TranscribeAsync( - // ReSharper disable UnusedParameter.Global - byte[] wavAudio, - string? language, - bool translate, - string? prompt, - CancellationToken ct - // ReSharper restore UnusedParameter.Global - ); - - /// Whether the given model's files are downloaded and ready to use. - // ReSharper disable once UnusedParameter.Global - bool IsModelDownloaded(string modelId) - { - return true; - } - - /// Downloads model files for the given model ID, reporting progress 0.0–1.0. - // ReSharper disable UnusedParameter.Global - Task DownloadModelAsync(string modelId, IProgress? progress, CancellationToken ct) - // ReSharper restore UnusedParameter.Global - { - return Task.CompletedTask; - } - - /// Loads a downloaded model into memory, preparing it for transcription. - Task LoadModelAsync(string modelId, CancellationToken ct) - { - return Task.CompletedTask; - } - - /// - /// Loads a downloaded model into memory, reporting provisioning/download - /// progress 0.0–1.0 via — e.g. when a - /// self-provisioning engine fetches its CUDA runtime on first GPU use (see - /// ). The host surfaces this as a - /// download progress bar instead of a static spinner. Default delegates to - /// (no progress), so - /// engines with nothing slow to provision need not override it. - /// - // ReSharper disable once UnusedParameter.Global - Task LoadModelAsync(string modelId, IProgress? progress, CancellationToken ct) - { - return LoadModelAsync(modelId, ct); - } - - /// Deletes downloaded model files for the given model ID. - // ReSharper disable UnusedParameter.Global - Task DeleteModelAsync(string modelId, CancellationToken ct) - // ReSharper restore UnusedParameter.Global - { - return Task.CompletedTask; - } - - /// Opens a real-time streaming session; the host feeds PCM16 audio into it. - /// Only called when is true. - // ReSharper disable once UnusedParameter.Global - Task StartStreamingAsync(string? language, CancellationToken ct) - { - throw new NotSupportedException(); - } - - /// Unloads the currently loaded model from memory to free resources. - Task UnloadModelAsync() - { - return Task.CompletedTask; - } - - /// - /// Transcribes audio with streaming progress updates via , - /// which receives partial transcription text and returns false to cancel. - /// Default delegates to . - /// - Task TranscribeStreamingAsync( - byte[] wavAudio, - string? language, - bool translate, - string? prompt, - // ReSharper disable once UnusedParameter.Global - Func onProgress, - CancellationToken ct - ) - { - return TranscribeAsync(wavAudio, language, translate, prompt, ct); - } + /// Unifies the plugin and role views of the owning plugin identifier. + // ReSharper disable once UnusedMemberInSuper.Global -- consumed by out-of-solution plugins/host through this owning-plugin view; no in-solution caller is visible. + new string PluginId { get; } } diff --git a/src/TypeWhisper.PluginSDK/ITranscriptionEngineRole.cs b/src/TypeWhisper.PluginSDK/ITranscriptionEngineRole.cs new file mode 100644 index 000000000..f27ed482b --- /dev/null +++ b/src/TypeWhisper.PluginSDK/ITranscriptionEngineRole.cs @@ -0,0 +1,234 @@ +// ReSharper disable UnusedMemberInSuper.Global +// PluginSDK contract members are implemented by out-of-solution plugin projects and invoked by +// the host; the analyzer sees no in-solution caller, so these .Global inspections misfire. + +// Public plugin-SDK surface. The per-item `disable once` directives below mark members +// ReSharper/Qodana cannot see used from this project (they are consumed by external plugins/ +// the host). Per-item, not file-level, so a genuinely-unused member added later still surfaces. +using TypeWhisper.PluginSDK.Models; + +namespace TypeWhisper.PluginSDK; + +/// +/// Non-owning role that provides audio transcription capabilities via a cloud or +/// local engine. The owner is responsible for this role's lifetime; hosts consume +/// only the capability surface exposed here. +/// +// ReSharper disable once UnusedType.Global +public interface ITranscriptionEngineRole +{ + /// + /// Identifier of the owning plugin. Additional roles use their selection identity + /// to distinguish selectable engines while retaining the owner's plugin ID. + /// + string PluginId { get; } + + /// Unique provider identifier (e.g. "openai", "groq"). + string ProviderId { get; } + + /// Human-readable provider name for the UI. + string ProviderDisplayName { get; } + + /// Whether the provider is configured and ready (API key set, etc.). + bool IsConfigured { get; } + + /// Available transcription models for this provider. + IReadOnlyList TranscriptionModels { get; } + + /// Currently selected model ID, or null if none selected. + string? SelectedModelId { get; } + + /// Whether this provider supports translation (audio to English). + bool SupportsTranslation { get; } + + /// Whether this engine supports downloading and managing local model files. + bool SupportsModelDownload => false; + + /// Whether this engine supports real-time streaming transcription via . + bool SupportsStreaming => false; + + /// ISO language codes supported by this engine, or empty for all. + IReadOnlyList SupportedLanguages => []; + + /// Acceleration backends this engine can run on. Default: CPU only. + IReadOnlyList SupportedAccelerationBackends => + [TranscriptionAccelerationBackend.Cpu]; + + /// + /// Whether this engine downloads and preloads its own CUDA runtime on demand + /// during , and falls back to CPU itself + /// (surfacing the reason via ) when the GPU + /// path can't be honored. When true, the host must not reject an + /// explicit load + /// just because the CUDA runtime libraries aren't already installed on the + /// host — the plugin provisions them. Default: false (the engine relies + /// on a host-provided CUDA runtime). + /// + bool ProvisionsCudaRuntimeOnDemand => false; + + /// + /// For a self-provisioning engine (), + /// whether the CUDA runtime it needs is already fully available — every + /// required library either provided by the host system or already downloaded + /// into the cache. Pure inspection (no driver probe, no download), so the host + /// can poll it to decide whether CUDA can be selected now (true) or the + /// runtime still needs fetching (false, including the partial-install + /// case where only some libraries are present). Default: false. + /// + bool IsCudaRuntimeProvisioned => false; + + /// + /// Downloads and preloads only the CUDA runtime libraries this engine is still + /// missing (a no-op when is already + /// true), reporting progress 0.0–1.0. Lets the host offer an explicit + /// "download CUDA runtime" action on a driver-only host instead of waiting for + /// the lazy + /// path. Throws if the NVIDIA driver is unusable or the download fails — the + /// host surfaces the message. Default: no-op (engines that rely on a + /// host-provided runtime have nothing to fetch). + /// + // ReSharper disable UnusedParameter.Global + Task EnsureCudaRuntimeReadyAsync(IProgress? progress, CancellationToken ct) + // ReSharper restore UnusedParameter.Global + { + return Task.CompletedTask; + } + + /// + /// Deletes this engine's provisioned CUDA runtime caches (the shared CUDA math + /// libraries plus any per-engine GPU build) so the next CUDA load re-provisions + /// from scratch. Best-effort. Note: libraries already dlopen'd this session are + /// held until process exit, so a restart is required for a fresh re-download to + /// take effect. Default: no-op for engines that rely on a host-provided runtime + /// (nothing to clear); a self-provisioning engine + /// () MUST override this — the default + /// throws rather than silently report a clear that never happened (which would + /// leave a corrupt cache in place and defeat the host's failure aggregation). + /// + // ReSharper disable once UnusedParameter.Global + Task ClearCudaRuntimeAsync(CancellationToken ct) + { + if (ProvisionsCudaRuntimeOnDemand) + { + throw new NotSupportedException( + $"{ProviderId} provisions its CUDA runtime on demand and must override " + + $"{nameof(ClearCudaRuntimeAsync)}." + ); + } + + return Task.CompletedTask; + } + + /// Acceleration preference last requested by the host. Default: Auto. + // ReSharper disable once UnusedMember.Global + TranscriptionAccelerationPreference AccelerationPreference => + TranscriptionAccelerationPreference.Auto; + + /// Reports what acceleration the engine actually loaded with. + TranscriptionAccelerationStatus AccelerationStatus => + new(TranscriptionAccelerationBackend.Cpu, "Using CPU"); + + /// Selects a transcription model by ID. + void SelectModel(string modelId); + + /// Configures the preferred compute backend. Common values: "cpu", "cuda". + // ReSharper disable once UnusedMember.Global + // ReSharper disable once UnusedParameter.Global + Task ConfigureComputeBackendAsync(string backend) + { + return Task.CompletedTask; + } + + /// + /// Sets the resolved acceleration preference. The host resolves Auto + /// before calling, so plugins only ever see Cpu or NvidiaCuda. + /// + void SetAccelerationPreference(TranscriptionAccelerationPreference preference) { } + + /// Transcribes WAV audio data and returns the result. + Task TranscribeAsync( + // ReSharper disable UnusedParameter.Global + byte[] wavAudio, + string? language, + bool translate, + string? prompt, + CancellationToken ct + // ReSharper restore UnusedParameter.Global + ); + + /// Whether the given model's files are downloaded and ready to use. + // ReSharper disable once UnusedParameter.Global + bool IsModelDownloaded(string modelId) + { + return true; + } + + /// Downloads model files for the given model ID, reporting progress 0.0–1.0. + // ReSharper disable UnusedParameter.Global + Task DownloadModelAsync(string modelId, IProgress? progress, CancellationToken ct) + // ReSharper restore UnusedParameter.Global + { + return Task.CompletedTask; + } + + /// Loads a downloaded model into memory, preparing it for transcription. + Task LoadModelAsync(string modelId, CancellationToken ct) + { + return Task.CompletedTask; + } + + /// + /// Loads a downloaded model into memory, reporting provisioning/download + /// progress 0.0–1.0 via — e.g. when a + /// self-provisioning engine fetches its CUDA runtime on first GPU use (see + /// ). The host surfaces this as a + /// download progress bar instead of a static spinner. Default delegates to + /// (no progress), so + /// engines with nothing slow to provision need not override it. + /// + // ReSharper disable once UnusedParameter.Global + Task LoadModelAsync(string modelId, IProgress? progress, CancellationToken ct) + { + return LoadModelAsync(modelId, ct); + } + + /// Deletes downloaded model files for the given model ID. + // ReSharper disable UnusedParameter.Global + Task DeleteModelAsync(string modelId, CancellationToken ct) + // ReSharper restore UnusedParameter.Global + { + return Task.CompletedTask; + } + + /// Opens a real-time streaming session; the host feeds PCM16 audio into it. + /// Only called when is true. + // ReSharper disable once UnusedParameter.Global + Task StartStreamingAsync(string? language, CancellationToken ct) + { + throw new NotSupportedException(); + } + + /// Unloads the currently loaded model from memory to free resources. + Task UnloadModelAsync() + { + return Task.CompletedTask; + } + + /// + /// Transcribes audio with streaming progress updates via , + /// which receives partial transcription text and returns false to cancel. + /// Default delegates to . + /// + Task TranscribeStreamingAsync( + byte[] wavAudio, + string? language, + bool translate, + string? prompt, + // ReSharper disable once UnusedParameter.Global + Func onProgress, + CancellationToken ct + ) + { + return TranscribeAsync(wavAudio, language, translate, prompt, ct); + } +} diff --git a/src/TypeWhisper.PluginSDK/PluginSelectionExtensions.cs b/src/TypeWhisper.PluginSDK/PluginSelectionExtensions.cs index cc32fcb6f..d819ee7e3 100644 --- a/src/TypeWhisper.PluginSDK/PluginSelectionExtensions.cs +++ b/src/TypeWhisper.PluginSDK/PluginSelectionExtensions.cs @@ -27,7 +27,7 @@ public static class PluginSelectionExtensions /// // ReSharper disable once UnusedMember.Global // ReSharper disable once UnusedParameter.Global - public static string GetTranscriptionSelectionId(this ITranscriptionEnginePlugin plugin) + public static string GetTranscriptionSelectionId(this ITranscriptionEngineRole plugin) { var customSelectionId = plugin is ITranscriptionEngineSelectionIdentity identity ? identity.TranscriptionSelectionId @@ -48,7 +48,7 @@ public static string GetTranscriptionSelectionId(this ITranscriptionEnginePlugin /// // ReSharper disable once UnusedMember.Global // ReSharper disable once UnusedParameter.Global - public static string GetLlmSelectionId(this ILlmProviderPlugin plugin) + public static string GetLlmSelectionId(this ILlmProviderRole plugin) { var customSelectionId = plugin is ILlmProviderSelectionIdentity identity ? identity.LlmSelectionId diff --git a/tests/TypeWhisper.Linux.Tests/LlmCleanupServiceTests.cs b/tests/TypeWhisper.Linux.Tests/LlmCleanupServiceTests.cs index cb9e5ad61..f2964bddb 100644 --- a/tests/TypeWhisper.Linux.Tests/LlmCleanupServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LlmCleanupServiceTests.cs @@ -117,7 +117,7 @@ public async Task CleanAsync_Medium_FallsBackToLightWhenFailureStatusCallbackFai Assert.Equal("Hello", result); } - private static LlmCleanupService CreateService(IReadOnlyList providers) + private static LlmCleanupService CreateService(IReadOnlyList providers) { var pluginManager = TestPluginManagerFactory.Create(providers); var settings = new Mock(); diff --git a/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs index 6c994be84..ace1c570d 100644 --- a/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs @@ -471,7 +471,7 @@ private static Mock CreateSettings(AppSettings current) } private PluginManager CreatePluginManager( - IReadOnlyList llmProviders, + IReadOnlyList llmProviders, IReadOnlyList loadedPlugins ) { diff --git a/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs b/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs index 6754c6f7c..d18f8e3dc 100644 --- a/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs +++ b/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs @@ -12,7 +12,7 @@ namespace TypeWhisper.Linux.Tests; internal static class TestPluginManagerFactory { public static PluginManager Create( - IReadOnlyList? llmProviders = null, + IReadOnlyList? llmProviders = null, IReadOnlyList? actionPlugins = null, IReadOnlyList? ttsProviders = null, IReadOnlyList? loadedPlugins = null diff --git a/tests/TypeWhisper.Linux.Tests/WelcomeWizardViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/WelcomeWizardViewModelTests.cs index d5059d7d3..71136d32c 100644 --- a/tests/TypeWhisper.Linux.Tests/WelcomeWizardViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WelcomeWizardViewModelTests.cs @@ -276,7 +276,7 @@ private static SystemCommandAvailabilityService CreateCommandsWithoutHostProbes( private static void SetTranscriptionEngines( PluginManager pluginManager, - IReadOnlyList plugins + IReadOnlyList plugins ) { var field = diff --git a/tests/TypeWhisper.PluginSystem.Tests/ModelManagerServiceTests.cs b/tests/TypeWhisper.PluginSystem.Tests/ModelManagerServiceTests.cs index 1d7a19c7a..d76d98a1f 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/ModelManagerServiceTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/ModelManagerServiceTests.cs @@ -1127,7 +1127,7 @@ out FakeTranscriptionPlugin newPlugin } private PluginManager CreatePluginManager( - params ITranscriptionEnginePlugin[] transcriptionEngines + params ITranscriptionEngineRole[] transcriptionEngines ) { var pluginManager = new PluginManager( diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiCompatiblePluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiCompatiblePluginTests.cs index b451c97c1..c0b5a1bea 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenAiCompatiblePluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiCompatiblePluginTests.cs @@ -122,6 +122,68 @@ public async Task SetItemsAsync_AddsProfile_ExposesRoleWithProfileSelectionId() Assert.Equal(sut.PluginId, engine.PluginId); // role keeps the owner's plugin id } + [Fact] + public async Task AdditionalProfileRole_IsStableAcrossRepeatedGettersAndCapabilityRefresh() + { + var host = new TestPluginHostServices(); + using var httpClient = ModelsClient(); + var sut = new OpenAiCompatiblePlugin(httpClient); + await sut.ActivateAsync(host); + await sut.SetItemsAsync( + "profiles", + [ProfileItem("Local Ollama", "http://localhost:11434", llmModel: "m1")] + ); + + var firstLlmRole = Assert.Single(sut.AdditionalLlmProviders); + var firstTranscriptionRole = Assert.Single(sut.AdditionalTranscriptionEngines); + Assert.Same(firstLlmRole, firstTranscriptionRole); + Assert.Same(firstLlmRole, Assert.Single(sut.AdditionalLlmProviders)); + + var refreshCountBefore = host.CapabilitiesChangedCount; + var unchangedItems = await sut.GetItemsAsync("profiles"); + var result = await sut.SetItemsAsync("profiles", unchangedItems); + + Assert.True(result.IsSuccess); + Assert.True(host.CapabilitiesChangedCount > refreshCountBefore); + Assert.Same(firstLlmRole, Assert.Single(sut.AdditionalLlmProviders)); + Assert.Same( + firstTranscriptionRole, + Assert.Single(sut.AdditionalTranscriptionEngines) + ); + } + + [Fact] + public async Task AdditionalProfileRole_ChangedOrRemovedProfileInvalidatesCacheEntry() + { + var host = new TestPluginHostServices(); + using var httpClient = ModelsClient(); + var sut = new OpenAiCompatiblePlugin(httpClient); + await sut.ActivateAsync(host); + await sut.SetItemsAsync( + "profiles", + [ProfileItem("Original", "http://localhost:11434", llmModel: "m1")] + ); + + var originalRole = Assert.Single(sut.AdditionalLlmProviders); + var profileId = Assert.Single(await sut.GetItemsAsync("profiles")).Values["__id"]; + + await sut.SetItemsAsync( + "profiles", + [ProfileItem("Changed", "http://localhost:11434", llmModel: "m1", id: profileId)] + ); + var changedRole = Assert.Single(sut.AdditionalLlmProviders); + Assert.NotSame(originalRole, changedRole); + + await sut.SetItemsAsync("profiles", []); + Assert.Empty(sut.AdditionalLlmProviders); + + await sut.SetItemsAsync( + "profiles", + [ProfileItem("Changed", "http://localhost:11434", llmModel: "m1", id: profileId)] + ); + Assert.NotSame(changedRole, Assert.Single(sut.AdditionalLlmProviders)); + } + [Fact] public async Task GetItemsAsync_DoesNotEchoApiKey() { @@ -294,6 +356,7 @@ private sealed class TestPluginHostServices : IPluginHostServices private readonly Dictionary _settings = []; private Dictionary Secrets { get; } = []; + public int CapabilitiesChangedCount { get; private set; } public Task StoreSecretAsync(string key, string value) { @@ -322,7 +385,10 @@ public void SetSetting(string key, T value) => public IPluginEventBus EventBus { get; } = new TestPluginEventBus(); public IReadOnlyList AvailableProfileNames => []; public void Log(PluginLogLevel level, string message) { } - public void NotifyCapabilitiesChanged() { } + public void NotifyCapabilitiesChanged() + { + CapabilitiesChangedCount++; + } public IPluginLocalization Localization { get; } = new TestPluginLocalization(); } diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs index 68bb3fe7f..8585ec824 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginManagerTests.cs @@ -387,6 +387,32 @@ string customSelectionId ); } + [Fact] + public async Task CapabilityIndices_AdditionalNonOwningRolesSurfaceAndRemainStableAcrossRebuilds() + { + var parent = new FakeAdditionalRolesPlugin("com.test.additional-owner"); + var manager = await CreateManagerAsync(parent); + + IReadOnlyList llmRoles = manager.LlmProviders; + IReadOnlyList transcriptionRoles = + manager.TranscriptionEngines; + var llmRole = Assert.Single(llmRoles); + var transcriptionRole = Assert.Single(transcriptionRoles); + + Assert.Same(parent.Role, llmRole); + Assert.Same(parent.Role, transcriptionRole); + Assert.False( + // ReSharper disable once ConditionIsAlwaysTrueOrFalse -- the static always-false is the invariant under test: a non-owning additional role must never also implement the owning-plugin interface. + // ReSharper disable once CanSimplifyIsAssignableFrom -- the runtime reflection form is deliberate so a future type that breaks the ownership contract fails this assertion. + typeof(ITypeWhisperPlugin).IsAssignableFrom(parent.Role.GetType()) + ); + + parent.NotifyCapabilitiesChanged(); + + Assert.Same(llmRole, Assert.Single(manager.LlmProviders)); + Assert.Same(transcriptionRole, Assert.Single(manager.TranscriptionEngines)); + } + [Fact] public async Task Dispose_HangingDeactivation_ReturnsAndShutsDownLaterPlugin() { @@ -547,13 +573,82 @@ private abstract class FakeCapabilityPlugin(string pluginId) : ITypeWhisperPlugi public string PluginName => PluginId; public string PluginVersion => "1.0.0"; - public Task ActivateAsync(IPluginHostServices host) => Task.CompletedTask; + public virtual Task ActivateAsync(IPluginHostServices host) => Task.CompletedTask; public Task DeactivateAsync() => Task.CompletedTask; public void Dispose() { } } + private sealed class FakeAdditionalRolesPlugin(string pluginId) + : FakeCapabilityPlugin(pluginId), + IAdditionalLlmProvidersProvider, + IAdditionalTranscriptionEnginesProvider + { + private IPluginHostServices? _host; + + public FakeAdditionalRole Role { get; } = new(pluginId); + public IReadOnlyList AdditionalLlmProviders => [Role]; + public IReadOnlyList AdditionalTranscriptionEngines => [Role]; + + public override Task ActivateAsync(IPluginHostServices host) + { + _host = host; + return Task.CompletedTask; + } + + public void NotifyCapabilitiesChanged() + { + _host?.NotifyCapabilitiesChanged(); + } + } + + private sealed class FakeAdditionalRole(string ownerPluginId) + : ILlmProviderRole, + ITranscriptionEngineRole, + ILlmProviderSelectionIdentity, + ITranscriptionEngineSelectionIdentity + { + public string PluginId { get; } = ownerPluginId; + public string LlmSelectionId => "additional-llm"; + public string TranscriptionSelectionId => "additional-transcription"; + public string ProviderName => "Additional LLM"; + public bool IsAvailable => true; + public IReadOnlyList SupportedModels { get; } = + [new("llm-model", "LLM model")]; + public string ProviderId => TranscriptionSelectionId; + public string ProviderDisplayName => "Additional transcription"; + public bool IsConfigured => true; + public IReadOnlyList TranscriptionModels { get; } = + [new("transcription-model", "Transcription model")]; + // ReSharper disable once ReturnTypeCanBeNotNullable -- implements ITranscriptionEngineRole.SelectedModelId, whose contract is nullable. + public string? SelectedModelId => TranscriptionModels[0].Id; + public bool SupportsTranslation => false; + + public Task ProcessAsync( + string systemPrompt, + string userText, + string model, + CancellationToken ct + ) + { + return Task.FromResult(""); + } + + public void SelectModel(string modelId) { } + + public Task TranscribeAsync( + byte[] wavAudio, + string? language, + bool translate, + string? prompt, + CancellationToken ct + ) + { + return Task.FromResult(new PluginTranscriptionResult("", null, 0, null)); + } + } + private class FakeTranscriptionPlugin(string pluginId) : FakeCapabilityPlugin(pluginId), ITranscriptionEnginePlugin From 7c32defc8e00282288c057f02ca06303dd88f850 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 00:49:37 +0000 Subject: [PATCH 172/226] Track realtime pending audio with watermarks instead of one flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAI realtime session represented all uncommitted audio with a single _audioPendingCommit flag that any final transcription event cleared. Transcription completions are asynchronous per-item results, so a delayed completion for utterance A could clear the flag after audio for utterance B had been appended; FinalizeAsync then saw nothing pending, skipped the explicit input_audio_buffer.commit, and B's tail was silently lost — the host treats a nonfaulted nonempty streaming result as complete and never falls back to batch. Appended and committed byte watermarks guarded by a lock now track the buffer boundary. Only input_audio_buffer.committed events advance the committed boundary; transcription events never touch pending state. FinalizeAsync loops until all appended audio is committed, sending an explicit commit only when audio is actually pending (an empty-buffer commit draws a server error), then waits for the commit acknowledgement and that item's transcription terminal under the caller's token so the host finalize deadline stays authoritative. Receive-loop faults and socket closures before the terminal event now fault all pending waiters, so finalize surfaces a dead session instead of hanging. --- .../OpenAiRealtimeStreamingSession.cs | 306 +++++++++++++---- .../OpenAiPluginTests.cs | 310 +++++++++++++++++- 2 files changed, 552 insertions(+), 64 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs index 9bfebdd6b..1926dfb29 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs @@ -19,44 +19,44 @@ internal sealed class OpenAiRealtimeStreamingSession : IStreamingSession internal const int SourceSampleRate = 16_000; internal const int TargetSampleRate = 24_000; - private readonly ClientWebSocket _ws; + private readonly WebSocket _ws; private readonly OpenAiRealtimeTranscriptCollector _collector; private readonly CancellationTokenSource _receiveCts = new(); private readonly SemaphoreSlim _sendLock = new(1, 1); + private readonly Lock _audioStateLock = new(); + private readonly Dictionary> _transcriptionTerminals = []; // First non-cancellation fault the receive loop observed. Surfaced from // SendAudioAsync / FinalizeAsync so the coordinator's sender or finalize // path throws, the orchestrator's finalizeThrew flag flips, and batch // fallback fires. Without this, a server error event after one good // final segment would ship a truncated transcript as a clean success. // Mirrors XaiStreamingSession's _receiveLoopException pattern. - // - // Note: unlike xAI we do not block FinalizeAsync on a terminal signal. - // OpenAI's realtime protocol has no per-session "done" event — the - // socket stays open after `input_audio_buffer.commit` and TranscribeWavAsync - // would hang on the caller's token. Both downstream waiters handle - // tail events themselves: StreamingTranscriptionCoordinator has a - // 500 ms grace-window debounce, and TranscribeWavAsync polls - // `HasCompletedTranscript` via WaitForCompletedTranscriptAsync. private Exception? _receiveLoopException; - // Tracks whether any audio has been sent to the server since the last - // completed event (server-side commit watermark). FinalizeAsync skips - // the explicit `input_audio_buffer.commit` when this is 0 — required - // for server-VAD mode, where the server auto-commits per utterance - // and emptying the buffer manually after that yields a benign error - // event that the fault path would otherwise promote to a stream - // fault, forcing unnecessary batch fallback. Batch (manual-commit) - // mode always has pending audio at finalize time, so the flag stays - // set and commit fires as before. - private int _audioPendingCommit; + // Successful appends advance this monotonically. Only + // input_audio_buffer.committed advances the confirmed committed + // boundary; transcription completed/failed events are asynchronous + // per-item results and must never mutate either watermark. + private long _appendedAudioWatermark; + private long _committedAudioWatermark; + private PendingExplicitCommit? _pendingExplicitCommit; + private string? _lastCommittedItemId; private Task? _receiveTask; private bool _disposed; - private OpenAiRealtimeStreamingSession(ClientWebSocket ws, OpenAiRealtimeTranscriptCollector collector) + private OpenAiRealtimeStreamingSession(WebSocket ws, OpenAiRealtimeTranscriptCollector collector) { _ws = ws; _collector = collector; } + private sealed class PendingExplicitCommit(long watermark) + { + public long Watermark { get; } = watermark; + + public TaskCompletionSource CommittedItemId { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + } + public event Action? TranscriptReceived; public static async Task ConnectAsync( @@ -70,9 +70,25 @@ public static async Task ConnectAsync( await ws.ConnectAsync(BuildRealtimeUri(), ct); var collector = new OpenAiRealtimeTranscriptCollector(); + var session = CreateStartedSession(ws, collector); + await session.SendTextAsync(CreateSessionUpdatePayload(language, prompt, useServerVad), ct); + return session; + } + + internal static OpenAiRealtimeStreamingSession CreateConnectedSessionForTests(WebSocket ws) + { + if (ws.State != WebSocketState.Open) + throw new InvalidOperationException("The test WebSocket must already be open."); + + return CreateStartedSession(ws, new OpenAiRealtimeTranscriptCollector()); + } + + private static OpenAiRealtimeStreamingSession CreateStartedSession( + WebSocket ws, + OpenAiRealtimeTranscriptCollector collector) + { var session = new OpenAiRealtimeStreamingSession(ws, collector); session._receiveTask = session.ReceiveLoopAsync(session._receiveCts.Token); - await session.SendTextAsync(CreateSessionUpdatePayload(language, prompt, useServerVad), ct); return session; } @@ -202,11 +218,10 @@ public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationTo return; await SendTextAsync(CreateAudioAppendPayload(pcm16Audio.Span), ct); - // Mark "has uncommitted audio since the last completion." The - // receive loop clears this on each final event, so server-VAD - // mode sessions whose last utterance was already auto-committed - // skip the explicit commit in FinalizeAsync. - Volatile.Write(ref _audioPendingCommit, 1); + lock (_audioStateLock) + { + _appendedAudioWatermark += pcm16Audio.Length; + } } finally { @@ -218,40 +233,97 @@ public async Task FinalizeAsync(CancellationToken ct) { if (_disposed) return; - // Send commit only when there's pending uncommitted audio. With - // server VAD the server auto-commits per utterance — sending a - // redundant commit on an already-empty buffer produces a benign - // server error event that the fault path would otherwise promote - // into a stream fault and force unnecessary batch fallback. With - // manual-commit (batch / non-VAD), the flag is always set by the - // SendAudioAsync calls preceding FinalizeAsync, so commit fires. - if (_ws.State == WebSocketState.Open - && Volatile.Read(ref _audioPendingCommit) != 0) + while (true) { + ThrowIfReceiveLoopFaulted(); + + PendingExplicitCommit? pendingCommit = null; + Task? committedItemTranscription = null; + var sendCommit = false; + await _sendLock.WaitAsync(ct); try { - if (_ws.State == WebSocketState.Open - && Volatile.Read(ref _audioPendingCommit) != 0) + ThrowIfReceiveLoopFaulted(); + + lock (_audioStateLock) { - await SendTextAsync("""{"type":"input_audio_buffer.commit"}""", ct); - Volatile.Write(ref _audioPendingCommit, 0); + if (_appendedAudioWatermark > _committedAudioWatermark) + { + pendingCommit = _pendingExplicitCommit; + if (pendingCommit is null) + { + pendingCommit = new PendingExplicitCommit(_appendedAudioWatermark); + _pendingExplicitCommit = pendingCommit; + sendCommit = true; + } + } + else if (_lastCommittedItemId is { } itemId) + { + committedItemTranscription = GetTranscriptionTerminalLocked(itemId).Task; + } + } + + if (sendCommit) + { + if (_ws.State != WebSocketState.Open) + { + var exception = new InvalidOperationException( + "OpenAI realtime session closed before pending audio could be committed."); + AbandonPendingCommit(pendingCommit!, exception); + throw exception; + } + + try + { + await SendTextAsync("""{"type":"input_audio_buffer.commit"}""", ct); + } + catch (Exception ex) + { + AbandonPendingCommit(pendingCommit!, ex); + throw; + } } } finally { _sendLock.Release(); } - } - // Re-throw a captured receive-loop fault so the coordinator's - // FinalizeAsync rethrows and DictationOrchestrator's finalizeThrew - // flag triggers batch fallback. Faults arriving immediately after - // commit (race with the receive loop) are caught here; faults that - // arrive later during the coordinator's grace window land in - // _receiveLoopException but aren't re-surfaced — same gap upstream - // has, acceptable given how rare the timing is. - ThrowIfReceiveLoopFaulted(); + if (pendingCommit is not null) + { + var itemId = await pendingCommit.CommittedItemId.Task.WaitAsync(ct); + ThrowIfReceiveLoopFaulted(); + + if (!string.IsNullOrWhiteSpace(itemId)) + { + Task transcriptionTerminal; + lock (_audioStateLock) + { + transcriptionTerminal = GetTranscriptionTerminalLocked(itemId).Task; + } + + await transcriptionTerminal.WaitAsync(ct); + ThrowIfReceiveLoopFaulted(); + } + + lock (_audioStateLock) + { + if (_appendedAudioWatermark <= _committedAudioWatermark) + return; + } + + // Audio was appended after the commit boundary was captured. + // Loop and commit that later generation as well. + continue; + } + + if (committedItemTranscription is not null) + await committedItemTranscription.WaitAsync(ct); + + ThrowIfReceiveLoopFaulted(); + return; + } } private void ThrowIfReceiveLoopFaulted() @@ -268,6 +340,106 @@ private async Task SendTextAsync(string json, CancellationToken ct) await _ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct); } + private void AbandonPendingCommit(PendingExplicitCommit pendingCommit, Exception exception) + { + lock (_audioStateLock) + { + if (ReferenceEquals(_pendingExplicitCommit, pendingCommit)) + _pendingExplicitCommit = null; + } + + if (exception is OperationCanceledException canceled) + pendingCommit.CommittedItemId.TrySetCanceled(canceled.CancellationToken); + else + pendingCommit.CommittedItemId.TrySetException(exception); + } + + private TaskCompletionSource GetTranscriptionTerminalLocked(string itemId) + { + if (_transcriptionTerminals.TryGetValue(itemId, out var terminal)) + return terminal; + + terminal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _transcriptionTerminals[itemId] = terminal; + return terminal; + } + + private void HandleBufferCommitted(string? itemId) + { + PendingExplicitCommit? explicitCommit; + lock (_audioStateLock) + { + explicitCommit = _pendingExplicitCommit; + var boundary = explicitCommit?.Watermark ?? _appendedAudioWatermark; + _committedAudioWatermark = Math.Max(_committedAudioWatermark, boundary); + _pendingExplicitCommit = null; + + if (!string.IsNullOrWhiteSpace(itemId)) + { + _lastCommittedItemId = itemId; + GetTranscriptionTerminalLocked(itemId); + } + } + + explicitCommit?.CommittedItemId.TrySetResult(itemId); + } + + private void HandleTranscriptionCompleted(string? itemId) + { + if (string.IsNullOrWhiteSpace(itemId)) + return; + + lock (_audioStateLock) + { + GetTranscriptionTerminalLocked(itemId).TrySetResult(true); + } + } + + private void CaptureReceiveLoopException(Exception exception) + { + if (Interlocked.CompareExchange(ref _receiveLoopException, exception, null) is not null) + return; + + PendingExplicitCommit? pendingCommit; + TaskCompletionSource[] transcriptionTerminals; + lock (_audioStateLock) + { + pendingCommit = _pendingExplicitCommit; + transcriptionTerminals = _transcriptionTerminals.Values.ToArray(); + } + + pendingCommit?.CommittedItemId.TrySetException(exception); + foreach (var terminal in transcriptionTerminals) + terminal.TrySetException(exception); + } + + private void CaptureReceiveLoopClosure(CancellationToken ct) + { + // Deliberate disposal cancels the receive token — an orderly shutdown, + // not a fault. Any other exit strands finalize's commit/transcription + // waiters, so publish a terminal fault to release them. Idempotent: a + // real earlier fault wins via CaptureReceiveLoopException. + if (ct.IsCancellationRequested) + return; + CaptureReceiveLoopException(new InvalidOperationException( + "OpenAI realtime session closed before transcription completed.")); + } + + private static (string? Type, string? ItemId) GetProtocolEventMetadata(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var type = root.TryGetProperty("type", out var typeElement) + && typeElement.ValueKind == JsonValueKind.String + ? typeElement.GetString() + : null; + var itemId = root.TryGetProperty("item_id", out var itemIdElement) + && itemIdElement.ValueKind == JsonValueKind.String + ? itemIdElement.GetString() + : null; + return (type, itemId); + } + private async Task ReceiveLoopAsync(CancellationToken ct) { var buffer = new byte[8192]; @@ -283,7 +455,10 @@ private async Task ReceiveLoopAsync(CancellationToken ct) { result = await _ws.ReceiveAsync(buffer, ct); if (result.MessageType == WebSocketMessageType.Close) + { + CaptureReceiveLoopClosure(ct); return; + } messageBuffer.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); @@ -291,16 +466,22 @@ private async Task ReceiveLoopAsync(CancellationToken ct) continue; var json = Encoding.UTF8.GetString(messageBuffer.GetBuffer(), 0, (int)messageBuffer.Length); - if (_collector.ApplyEvent(json, out var transcriptEvent) && transcriptEvent is not null) + var (eventType, itemId) = GetProtocolEventMetadata(json); + var applied = _collector.ApplyEvent(json, out var transcriptEvent); + + switch (eventType) { - // A final event means the server processed everything up - // to that point — any subsequent FinalizeAsync only needs - // to commit if SendAudioAsync has fired since. - if (transcriptEvent.IsFinal) - Volatile.Write(ref _audioPendingCommit, 0); - TranscriptReceived?.Invoke(transcriptEvent); + case "input_audio_buffer.committed": + HandleBufferCommitted(itemId); + break; + case "conversation.item.input_audio_transcription.completed": + HandleTranscriptionCompleted(itemId); + break; } + if (applied && transcriptEvent is not null) + TranscriptReceived?.Invoke(transcriptEvent); + // ApplyEvent sets _collector.Error on `error` and // `conversation.item.input_audio_transcription.failed` // payloads but returns false — meaning we'd otherwise @@ -310,24 +491,27 @@ private async Task ReceiveLoopAsync(CancellationToken ct) // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (_collector.Error is { } providerError) { - Interlocked.CompareExchange( - ref _receiveLoopException, - new InvalidOperationException(providerError), - null); + CaptureReceiveLoopException(new InvalidOperationException(providerError)); return; } } + + // Loop exited because the socket left the Open state (peer Abort, + // CloseSent, etc.) rather than via a close frame, fault, or + // deliberate disposal. Fault pending finalize waiters so they + // don't hang until the caller's token. + CaptureReceiveLoopClosure(ct); } catch (OperationCanceledException) { } catch (WebSocketException ex) { Debug.WriteLine($"OpenAI realtime WebSocket error: {ex.Message}"); - Interlocked.CompareExchange(ref _receiveLoopException, ex, null); + CaptureReceiveLoopException(ex); } catch (JsonException ex) { Debug.WriteLine($"OpenAI realtime parse error: {ex.Message}"); - Interlocked.CompareExchange(ref _receiveLoopException, ex, null); + CaptureReceiveLoopException(ex); } } diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs index 296f33419..e9e820610 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs @@ -1,6 +1,8 @@ using System.Net; +using System.Net.WebSockets; using System.Text; using System.Text.Json; +using System.Threading.Channels; using TypeWhisper.Linux.Services.Plugins; using TypeWhisper.Plugin.OpenAi; using TypeWhisper.PluginSDK; @@ -863,9 +865,9 @@ public async Task RefreshAvailableLlmModels_ChatGptMode_ReturnsStaticCatalogWith // C5 Phase 7 — realtime streaming session // ---------------------------------------- - // Four tests ported verbatim from upstream `8683551` exercise the - // session's pure functions; the remaining two cover the fork-specific - // model + auth-mode gating in OpenAiPlugin itself. + // Pure-function tests exercise the protocol payloads and collector. + // Transport-backed tests below cover finalize ordering without network + // access, plus the fork-specific model + auth-mode gating. [Fact] public void RealtimeUri_UsesGAEndpointWithoutBetaHeader() @@ -1163,6 +1165,161 @@ public void RealtimeTranscriptCollector_ErrorEvent_CapturesErrorMessage() Assert.Equal("invalid_audio_format", collector.Error); } + [Fact] + public async Task RealtimeFinalize_AppendAfterEarlierCompletedItem_CommitsAndWaitsForTailItem() + { + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var socket = new FakeRealtimeWebSocket(); + await using var session = + OpenAiRealtimeStreamingSession.CreateConnectedSessionForTests(socket); + + await session.SendAudioAsync(new byte[] { 1, 0, 2, 0 }, timeoutCts.Token); + + // Synchronize through a later transcript delta: receive ordering + // guarantees committed-A was applied before this callback fires. + var firstDelta = WaitForTranscriptAsync( + session, + new StreamingTranscriptEvent("a", false), + timeoutCts.Token); + socket.QueueTextMessage( + """{"type":"input_audio_buffer.committed","item_id":"item_a"}"""); + socket.QueueTextMessage( + """{"type":"conversation.item.input_audio_transcription.delta","item_id":"item_a","delta":"a"}"""); + await firstDelta; + + await session.SendAudioAsync(new byte[] { 3, 0, 4, 0 }, timeoutCts.Token); + + var firstCompleted = WaitForTranscriptAsync( + session, + new StreamingTranscriptEvent("utterance A", true), + timeoutCts.Token); + socket.QueueTextMessage( + """{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_a","transcript":"utterance A"}"""); + await firstCompleted; + + var finalizeTask = session.FinalizeAsync(timeoutCts.Token); + await socket.WaitForSentMessageTypeAsync( + "input_audio_buffer.commit", + timeoutCts.Token); + + Assert.False(finalizeTask.IsCompleted); + Assert.Equal(1, socket.CountSentMessages("input_audio_buffer.commit")); + + // The explicit commit must bind finalize to item B. A committed + // acknowledgement alone is not enough; its transcription result + // is the terminal event finalize is waiting for. + var secondDelta = WaitForTranscriptAsync( + session, + new StreamingTranscriptEvent("b", false), + timeoutCts.Token); + socket.QueueTextMessage( + """{"type":"input_audio_buffer.committed","item_id":"item_b"}"""); + socket.QueueTextMessage( + """{"type":"conversation.item.input_audio_transcription.delta","item_id":"item_b","delta":"b"}"""); + await secondDelta; + + Assert.False(finalizeTask.IsCompleted); + + socket.QueueTextMessage( + """{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_b","transcript":"utterance B"}"""); + await finalizeTask; + } + + [Fact] + public async Task RealtimeFinalize_AllAudioAlreadyServerCommitted_SendsNoCommitAndWaitsForTranscription() + { + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var socket = new FakeRealtimeWebSocket(); + await using var session = + OpenAiRealtimeStreamingSession.CreateConnectedSessionForTests(socket); + + await session.SendAudioAsync(new byte[] { 1, 0, 2, 0 }, timeoutCts.Token); + + var delta = WaitForTranscriptAsync( + session, + new StreamingTranscriptEvent("ready", false), + timeoutCts.Token); + socket.QueueTextMessage( + """{"type":"input_audio_buffer.committed","item_id":"item_a"}"""); + socket.QueueTextMessage( + """{"type":"conversation.item.input_audio_transcription.delta","item_id":"item_a","delta":"ready"}"""); + await delta; + + var finalizeTask = session.FinalizeAsync(timeoutCts.Token); + + Assert.Equal(0, socket.CountSentMessages("input_audio_buffer.commit")); + Assert.False(finalizeTask.IsCompleted); + + socket.QueueTextMessage( + """{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_a","transcript":"ready"}"""); + await finalizeTask; + + Assert.Equal(0, socket.CountSentMessages("input_audio_buffer.commit")); + } + + [Fact] + public async Task RealtimeFinalize_ManualCommitMode_SendsOneCommitAndWaitsForTranscription() + { + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var socket = new FakeRealtimeWebSocket(); + await using var session = + OpenAiRealtimeStreamingSession.CreateConnectedSessionForTests(socket); + + // No server-VAD commit arrives in manual mode. Finalize retains the + // existing batch behavior of sending exactly one explicit commit. + await session.SendAudioAsync(new byte[] { 1, 0, 2, 0 }, timeoutCts.Token); + + var finalizeTask = session.FinalizeAsync(timeoutCts.Token); + await socket.WaitForSentMessageTypeAsync( + "input_audio_buffer.commit", + timeoutCts.Token); + + Assert.Equal(1, socket.CountSentMessages("input_audio_buffer.commit")); + Assert.False(finalizeTask.IsCompleted); + + var delta = WaitForTranscriptAsync( + session, + new StreamingTranscriptEvent("batch", false), + timeoutCts.Token); + socket.QueueTextMessage( + """{"type":"input_audio_buffer.committed","item_id":"item_batch"}"""); + socket.QueueTextMessage( + """{"type":"conversation.item.input_audio_transcription.delta","item_id":"item_batch","delta":"batch"}"""); + await delta; + + Assert.False(finalizeTask.IsCompleted); + + socket.QueueTextMessage( + """{"type":"conversation.item.input_audio_transcription.completed","item_id":"item_batch","transcript":"batch"}"""); + await finalizeTask; + + Assert.Equal(1, socket.CountSentMessages("input_audio_buffer.commit")); + } + + [Fact] + public async Task RealtimeFinalize_CancellationWhileWaitingForCommitAcknowledgement_Throws() + { + using var testTimeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var finalizeCts = new CancellationTokenSource(); + var socket = new FakeRealtimeWebSocket(); + await using var session = + OpenAiRealtimeStreamingSession.CreateConnectedSessionForTests(socket); + + await session.SendAudioAsync(new byte[] { 1, 0, 2, 0 }, testTimeoutCts.Token); + + var finalizeTask = session.FinalizeAsync(finalizeCts.Token); + await socket.WaitForSentMessageTypeAsync( + "input_audio_buffer.commit", + testTimeoutCts.Token); + Assert.False(finalizeTask.IsCompleted); + + // ReSharper disable once MethodHasAsyncOverload -- synchronous Cancel must trip the token before the assertion; CancelAsync would defer it. + finalizeCts.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await finalizeTask); + } + [Fact] public async Task SupportsStreaming_RequiresRealtimeModelAndApiKeyMode() { @@ -1207,6 +1364,31 @@ public async Task StartStreamingAsync_ThrowsWhenModelOrAuthModeIsWrong() Assert.Contains("API key", authEx.Message); } + private static async Task WaitForTranscriptAsync( + OpenAiRealtimeStreamingSession session, + StreamingTranscriptEvent expected, + CancellationToken ct) + { + var received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + void OnTranscript(StreamingTranscriptEvent transcriptEvent) + { + if (transcriptEvent == expected) + received.TrySetResult(true); + } + + session.TranscriptReceived += OnTranscript; + try + { + await received.Task.WaitAsync(ct); + } + finally + { + session.TranscriptReceived -= OnTranscript; + } + } + private static JsonElement LoadManifest() { var basePath = Path.GetFullPath(AppContext.BaseDirectory); @@ -1307,6 +1489,128 @@ protected override async Task SendAsync( } } + private sealed class FakeRealtimeWebSocket : WebSocket + { + private readonly Channel _incoming = Channel.CreateUnbounded(); + private readonly List _sentMessages = []; + private readonly SemaphoreSlim _sentSignal = new(0); + private readonly Lock _sentLock = new(); + private int _state = (int)WebSocketState.Open; + private WebSocketCloseStatus? _closeStatus; + private string? _closeStatusDescription; + + public override WebSocketCloseStatus? CloseStatus => _closeStatus; + public override string? CloseStatusDescription => _closeStatusDescription; + public override WebSocketState State => (WebSocketState)Volatile.Read(ref _state); + public override string? SubProtocol => null; + + public void QueueTextMessage(string json) + { + if (!_incoming.Writer.TryWrite(Encoding.UTF8.GetBytes(json))) + throw new InvalidOperationException("The fake WebSocket receive queue is closed."); + } + + public int CountSentMessages(string messageType) + { + lock (_sentLock) + { + return _sentMessages.Count(message => GetMessageType(message) == messageType); + } + } + + public async Task WaitForSentMessageTypeAsync( + string messageType, + CancellationToken ct) + { + while (true) + { + lock (_sentLock) + { + if (_sentMessages.Any(message => GetMessageType(message) == messageType)) + return; + } + + await _sentSignal.WaitAsync(ct); + } + } + + public override void Abort() + { + Interlocked.Exchange(ref _state, (int)WebSocketState.Aborted); + _incoming.Writer.TryComplete(); + } + + public override Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + _closeStatus = closeStatus; + _closeStatusDescription = statusDescription; + Interlocked.Exchange(ref _state, (int)WebSocketState.Closed); + _incoming.Writer.TryComplete(); + return Task.CompletedTask; + } + + public override Task CloseOutputAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) => + CloseAsync(closeStatus, statusDescription, cancellationToken); + + public override void Dispose() + { + Interlocked.Exchange(ref _state, (int)WebSocketState.Closed); + _incoming.Writer.TryComplete(); + _sentSignal.Dispose(); + } + + public override async Task ReceiveAsync( + ArraySegment buffer, + CancellationToken cancellationToken) + { + var message = await _incoming.Reader.ReadAsync(cancellationToken); + if (message.Length > buffer.Count) + throw new InvalidOperationException("The fake WebSocket receive buffer is too small."); + + message.CopyTo(buffer.Array!.AsSpan(buffer.Offset, buffer.Count)); + return new WebSocketReceiveResult( + message.Length, + WebSocketMessageType.Text, + endOfMessage: true); + } + + public override Task SendAsync( + ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (messageType != WebSocketMessageType.Text || !endOfMessage) + throw new InvalidOperationException("The fake WebSocket only accepts complete text messages."); + + var message = Encoding.UTF8.GetString( + buffer.Array!, + buffer.Offset, + buffer.Count); + lock (_sentLock) + { + _sentMessages.Add(message); + } + + _sentSignal.Release(); + return Task.CompletedTask; + } + + private static string? GetMessageType(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("type").GetString(); + } + } + private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() From 96bdcac93e3dc2464f8358809fad86e0fb12ed18 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 01:07:36 +0000 Subject: [PATCH 173/226] Propagate streaming faults in AssemblyAI, Deepgram, and ElevenLabs All three sessions swallowed receive-loop failures: closed sockets made SendAudioAsync and FinalizeAsync return successfully, and close frames, transport exceptions, malformed frames, and provider error messages were logged or ignored. A stream that died mid-dictation therefore surfaced as a successful partial transcript, and the coordinator - which only falls back to complete-WAV batch transcription when a session call throws - shipped the truncated prefix as a clean result. Each session now captures the first receive/provider/malformed/ abnormal-close fault and rethrows it from SendAudioAsync and FinalizeAsync even when the socket is already closed. Finalization awaits the provider's documented terminal event under the caller's token so the host finalize deadline stays authoritative: AssemblyAI sends the v3 {"type":"Terminate"} message (replacing the stale v2 terminate_session payload) and awaits Termination, committing only formatted terminal turns so unformatted end-of-turn duplicates stay interim; Deepgram sends CloseStream and awaits terminal Metadata; ElevenLabs awaits the committed result of its explicit final commit, which earlier VAD commits cannot satisfy, and recognizes the documented error message types instead of substring-matching "error". Cancellation caused by disposal remains clean; socket close before the terminal event is a fault. Sub-chunk residual flushing in AssemblyAI is deliberately unchanged. --- .../AssemblyAiStreamingSession.cs | 309 +++++++- .../TypeWhisper.Plugin.AssemblyAi.csproj | 3 + .../DeepgramStreamingSession.cs | 298 +++++++- .../TypeWhisper.Plugin.Deepgram.csproj | 3 + .../ElevenLabsStreamingSession.cs | 408 +++++++++-- ...treamingProviderFailurePropagationTests.cs | 659 ++++++++++++++++++ .../TypeWhisper.PluginSystem.Tests.csproj | 2 + 7 files changed, 1558 insertions(+), 124 deletions(-) create mode 100644 tests/TypeWhisper.PluginSystem.Tests/StreamingProviderFailurePropagationTests.cs diff --git a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs index f22b83ac4..54ac6e32b 100644 --- a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using System.Net.WebSockets; +using System.Runtime.ExceptionServices; using System.Text; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -7,14 +9,25 @@ namespace TypeWhisper.Plugin.AssemblyAi; internal sealed class AssemblyAiStreamingSession : IStreamingSession { - private readonly ClientWebSocket _ws = new(); + private readonly WebSocket _ws; private readonly CancellationTokenSource _receiveCts = new(); - private Task? _receiveTask; + private readonly TaskCompletionSource _terminalCompletion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly MemoryStream _audioBuffer = new(); + private Exception? _sessionFault; + private readonly Task? _receiveTask; + private int _terminationReceived; + private bool _disposed; // AssemblyAI requires chunks between 50-1000ms (800-16000 samples at 16kHz = 1600-32000 bytes) - private readonly MemoryStream _audioBuffer = new(); private const int MinChunkBytes = 1600; // 50ms at 16kHz, 16-bit + internal AssemblyAiStreamingSession(WebSocket ws) + { + _ws = ws; + _receiveTask = ReceiveLoopAsync(_receiveCts.Token); + } + public event Action? TranscriptReceived; public static async Task ConnectAsync( @@ -23,7 +36,7 @@ public static async Task ConnectAsync( CancellationToken ct ) { - var session = new AssemblyAiStreamingSession(); + var ws = new ClientWebSocket(); var url = "wss://streaming.assemblyai.com/v3/ws?sample_rate=16000&format_turns=true"; // The default streaming model is English-only; opt into the multilingual @@ -31,36 +44,102 @@ CancellationToken ct // so locale variants like "en-US" stay on the English model. if (!string.IsNullOrEmpty(language) && !language.StartsWith("en", StringComparison.OrdinalIgnoreCase)) + { url += "&speech_model=universal-streaming-multilingual"; + } - session._ws.Options.SetRequestHeader("Authorization", apiKey); - await session._ws.ConnectAsync(new Uri(url), ct); - session._receiveTask = session.ReceiveLoopAsync(session._receiveCts.Token); - return session; + ws.Options.SetRequestHeader("Authorization", apiKey); + try + { + await ws.ConnectAsync(new Uri(url), ct); + return new AssemblyAiStreamingSession(ws); + } + catch + { + ws.Dispose(); + throw; + } } public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationToken ct) { + if (_disposed) + return; + + ThrowIfFaulted(); if (_ws.State != WebSocketState.Open) + { + ThrowIfClosedBeforeTermination(); return; + } _audioBuffer.Write(pcm16Audio.Span); + // A residual smaller than MinChunkBytes is deliberately left unflushed + // here; flushing it is unchanged and out of scope for this change. if (_audioBuffer.Length < MinChunkBytes) return; var chunk = _audioBuffer.ToArray(); _audioBuffer.SetLength(0); - await _ws.SendAsync(chunk, WebSocketMessageType.Binary, true, ct); + try + { + await _ws.SendAsync(chunk, WebSocketMessageType.Binary, true, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + CaptureFault( + new InvalidOperationException("AssemblyAI streaming audio send failed.", ex) + ); + throw; + } } public async Task FinalizeAsync(CancellationToken ct) { - if (_ws.State != WebSocketState.Open) + if (_disposed) return; - var msg = """{"terminate_session":true}"""u8.ToArray(); - await _ws.SendAsync(msg, WebSocketMessageType.Text, true, ct); + + ThrowIfFaulted(); + if (Volatile.Read(ref _terminationReceived) == 0) + { + if (_ws.State != WebSocketState.Open) + { + ThrowIfClosedBeforeTermination(); + return; + } + + var msg = """{"type":"Terminate"}"""u8.ToArray(); + try + { + await _ws.SendAsync(msg, WebSocketMessageType.Text, true, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + CaptureFault( + new InvalidOperationException( + "AssemblyAI streaming termination send failed.", + ex + ) + ); + throw; + } + } + + // The coordinator supplies the finalization deadline through ct. Do not + // turn that cancellation into success: it must remain able to select the + // complete-WAV batch fallback. + await _terminalCompletion.Task.WaitAsync(ct); + ThrowIfFaulted(); } private async Task ReceiveLoopAsync(CancellationToken ct) @@ -70,7 +149,7 @@ private async Task ReceiveLoopAsync(CancellationToken ct) try { - while (!ct.IsCancellationRequested && _ws.State == WebSocketState.Open) + while (true) { messageBuffer.SetLength(0); WebSocketReceiveResult result; @@ -78,7 +157,11 @@ private async Task ReceiveLoopAsync(CancellationToken ct) { result = await _ws.ReceiveAsync(buffer, ct); if (result.MessageType == WebSocketMessageType.Close) + { + CaptureFault(CreatePrematureCloseException(result)); return; + } + messageBuffer.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); @@ -90,43 +173,204 @@ private async Task ReceiveLoopAsync(CancellationToken ct) 0, (int)messageBuffer.Length ); - ParseAndEmit(json); + ProcessMessage(json); + + if (Volatile.Read(ref _terminationReceived) != 0) + return; + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // DisposeAsync owns this token. Local teardown is not a stream fault. + } + catch (OperationCanceledException ex) + { + CaptureFault( + new InvalidOperationException("AssemblyAI streaming receive was canceled.", ex) + ); + } + catch (WebSocketException ex) + { + CaptureFault( + new InvalidOperationException("AssemblyAI streaming transport failed.", ex) + ); + } + catch (JsonException ex) + { + CaptureFault( + new InvalidOperationException("AssemblyAI sent malformed JSON.", ex) + ); + } + catch (InvalidOperationException ex) + { + CaptureFault(ex); + } + catch (Exception ex) + { + CaptureFault( + new InvalidOperationException("AssemblyAI streaming receive failed.", ex) + ); + } + finally + { + if (ct.IsCancellationRequested) + { + _terminalCompletion.TrySetResult(); + } + else if ( + Volatile.Read(ref _terminationReceived) == 0 + && Volatile.Read(ref _sessionFault) is null + ) + { + CaptureFault( + new InvalidOperationException( + "AssemblyAI streaming receive ended before Termination." + ) + ); } } - catch (OperationCanceledException) { } - catch (WebSocketException) { } } - private void ParseAndEmit(string json) + private void ProcessMessage(string json) { - try + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if ( + root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("type", out var typeEl) + || typeEl.ValueKind != JsonValueKind.String + ) { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; + throw new InvalidOperationException( + "AssemblyAI sent a malformed streaming message." + ); + } - if (!root.TryGetProperty("type", out var typeEl) || typeEl.GetString() != "Turn") - return; + switch (typeEl.GetString()) + { + case "Turn": + ProcessTurn(root); + break; + case "Termination": + Volatile.Write(ref _terminationReceived, 1); + _terminalCompletion.TrySetResult(); + break; + case "Error": + throw new InvalidOperationException( + $"AssemblyAI streaming provider error: {ExtractError(root)}" + ); + } + } - var transcript = root.TryGetProperty("transcript", out var textEl) - ? textEl.GetString() ?? "" - : ""; + private void ProcessTurn(JsonElement root) + { + if ( + !root.TryGetProperty("transcript", out var textEl) + || textEl.ValueKind != JsonValueKind.String + ) + { + throw new InvalidOperationException("AssemblyAI sent a malformed Turn message."); + } - if (string.IsNullOrWhiteSpace(transcript)) - return; + var transcript = textEl.GetString() ?? ""; + if (string.IsNullOrWhiteSpace(transcript)) + return; - var isFinal = root.TryGetProperty("end_of_turn", out var eotEl) && eotEl.GetBoolean(); + var isEndOfTurn = + root.TryGetProperty("end_of_turn", out var eotEl) + && eotEl.ValueKind is JsonValueKind.True or JsonValueKind.False + && eotEl.GetBoolean(); + var isFormatted = + root.TryGetProperty("turn_is_formatted", out var formattedEl) + && formattedEl.ValueKind is JsonValueKind.True or JsonValueKind.False + && formattedEl.GetBoolean(); - TranscriptReceived?.Invoke(new StreamingTranscriptEvent(transcript, isFinal)); + // With format_turns=true AssemblyAI sends an unformatted end-of-turn + // message followed by the formatted replacement. Expose the former only + // as interim text and commit the formatted terminal turn exactly once. + Emit(new StreamingTranscriptEvent(transcript, isEndOfTurn && isFormatted)); + } + + private void Emit(StreamingTranscriptEvent transcriptEvent) + { + try + { + TranscriptReceived?.Invoke(transcriptEvent); } - catch - { /* malformed message, skip */ + catch (Exception ex) + { + Debug.WriteLine($"AssemblyAI streaming subscriber failed: {ex.Message}"); } } + private static string ExtractError(JsonElement root) + { + foreach (var propertyName in new[] { "error", "message", "detail" }) + { + if ( + root.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(property.GetString()) + ) + { + return property.GetString()!; + } + } + + return "Unknown provider error."; + } + + private static InvalidOperationException CreatePrematureCloseException( + WebSocketReceiveResult result + ) + { + var status = result.CloseStatus is { } closeStatus + ? $"{(int)closeStatus} ({closeStatus})" + : "without a close status"; + var reason = string.IsNullOrWhiteSpace(result.CloseStatusDescription) + ? "" + : $": {result.CloseStatusDescription}"; + return new InvalidOperationException( + $"AssemblyAI streaming socket closed {status}{reason} before Termination." + ); + } + + private void ThrowIfClosedBeforeTermination() + { + ThrowIfFaulted(); + if (Volatile.Read(ref _terminationReceived) != 0) + return; + + CaptureFault( + new InvalidOperationException( + $"AssemblyAI streaming socket is {_ws.State} before Termination." + ) + ); + ThrowIfFaulted(); + } + + private void CaptureFault(Exception exception) + { + if (Interlocked.CompareExchange(ref _sessionFault, exception, null) is null) + _terminalCompletion.TrySetException(exception); + } + + private void ThrowIfFaulted() + { + var exception = Volatile.Read(ref _sessionFault); + if (exception is not null) + ExceptionDispatchInfo.Capture(exception).Throw(); + } + public async ValueTask DisposeAsync() { + if (_disposed) + return; + + _disposed = true; // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _receiveCts.Cancel(); + _terminalCompletion.TrySetResult(); if (_ws.State == WebSocketState.Open) { @@ -163,6 +407,9 @@ await _ws.CloseAsync( } } + _ = _terminalCompletion.Task.Exception; + // ReSharper disable once MethodHasAsyncOverload -- MemoryStream has no async disposal work; DisposeAsync would only add overhead here. + _audioBuffer.Dispose(); _receiveCts.Dispose(); _ws.Dispose(); } diff --git a/plugins/TypeWhisper.Plugin.AssemblyAi/TypeWhisper.Plugin.AssemblyAi.csproj b/plugins/TypeWhisper.Plugin.AssemblyAi/TypeWhisper.Plugin.AssemblyAi.csproj index 2bc69c400..7c6646431 100644 --- a/plugins/TypeWhisper.Plugin.AssemblyAi/TypeWhisper.Plugin.AssemblyAi.csproj +++ b/plugins/TypeWhisper.Plugin.AssemblyAi/TypeWhisper.Plugin.AssemblyAi.csproj @@ -6,6 +6,9 @@ latest TypeWhisper.Plugin.AssemblyAi + + + diff --git a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs index 518b8b79f..ccdbdcd3b 100644 --- a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using System.Net.WebSockets; +using System.Runtime.ExceptionServices; using System.Text; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -7,9 +9,20 @@ namespace TypeWhisper.Plugin.Deepgram; internal sealed class DeepgramStreamingSession : IStreamingSession { - private readonly ClientWebSocket _ws = new(); + private readonly WebSocket _ws; private readonly CancellationTokenSource _receiveCts = new(); - private Task? _receiveTask; + private readonly TaskCompletionSource _terminalCompletion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private Exception? _sessionFault; + private readonly Task? _receiveTask; + private int _metadataReceived; + private bool _disposed; + + internal DeepgramStreamingSession(WebSocket ws) + { + _ws = ws; + _receiveTask = ReceiveLoopAsync(_receiveCts.Token); + } public event Action? TranscriptReceived; @@ -20,7 +33,7 @@ public static async Task ConnectAsync( CancellationToken ct ) { - var session = new DeepgramStreamingSession(); + var ws = new ClientWebSocket(); // Deepgram's streaming WebSocket does not accept detect_language=true // (it's batch-only). For an unspecified language Nova-3 supports @@ -37,25 +50,85 @@ CancellationToken ct var url = $"wss://api.deepgram.com/v1/listen?model={Uri.EscapeDataString(model)}&encoding=linear16&sample_rate=16000&interim_results=true&punctuate=true&smart_format=true{langParam}"; - session._ws.Options.SetRequestHeader("Authorization", $"Token {apiKey}"); - await session._ws.ConnectAsync(new Uri(url), ct); - session._receiveTask = session.ReceiveLoopAsync(session._receiveCts.Token); - return session; + ws.Options.SetRequestHeader("Authorization", $"Token {apiKey}"); + try + { + await ws.ConnectAsync(new Uri(url), ct); + return new DeepgramStreamingSession(ws); + } + catch + { + ws.Dispose(); + throw; + } } public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationToken ct) { + if (_disposed) + return; + + ThrowIfFaulted(); if (_ws.State != WebSocketState.Open) + { + ThrowIfClosedBeforeMetadata(); return; - await _ws.SendAsync(pcm16Audio, WebSocketMessageType.Binary, true, ct); + } + + try + { + await _ws.SendAsync(pcm16Audio, WebSocketMessageType.Binary, true, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + CaptureFault( + new InvalidOperationException("Deepgram streaming audio send failed.", ex) + ); + throw; + } } public async Task FinalizeAsync(CancellationToken ct) { - if (_ws.State != WebSocketState.Open) + if (_disposed) return; - var msg = """{"type":"CloseStream"}"""u8.ToArray(); - await _ws.SendAsync(msg, WebSocketMessageType.Text, true, ct); + + ThrowIfFaulted(); + if (Volatile.Read(ref _metadataReceived) == 0) + { + if (_ws.State != WebSocketState.Open) + { + ThrowIfClosedBeforeMetadata(); + return; + } + + var msg = """{"type":"CloseStream"}"""u8.ToArray(); + try + { + await _ws.SendAsync(msg, WebSocketMessageType.Text, true, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + CaptureFault( + new InvalidOperationException( + "Deepgram CloseStream send failed.", + ex + ) + ); + throw; + } + } + + await _terminalCompletion.Task.WaitAsync(ct); + ThrowIfFaulted(); } private async Task ReceiveLoopAsync(CancellationToken ct) @@ -65,7 +138,7 @@ private async Task ReceiveLoopAsync(CancellationToken ct) try { - while (!ct.IsCancellationRequested && _ws.State == WebSocketState.Open) + while (true) { messageBuffer.SetLength(0); WebSocketReceiveResult result; @@ -73,7 +146,11 @@ private async Task ReceiveLoopAsync(CancellationToken ct) { result = await _ws.ReceiveAsync(buffer, ct); if (result.MessageType == WebSocketMessageType.Close) + { + CaptureFault(CreatePrematureCloseException(result)); return; + } + messageBuffer.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); @@ -85,46 +162,200 @@ private async Task ReceiveLoopAsync(CancellationToken ct) 0, (int)messageBuffer.Length ); - ParseAndEmit(json); + ProcessMessage(json); + + if (Volatile.Read(ref _metadataReceived) != 0) + return; + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // DisposeAsync owns this token. Local teardown is not a stream fault. + } + catch (OperationCanceledException ex) + { + CaptureFault( + new InvalidOperationException("Deepgram streaming receive was canceled.", ex) + ); + } + catch (WebSocketException ex) + { + CaptureFault( + new InvalidOperationException("Deepgram streaming transport failed.", ex) + ); + } + catch (JsonException ex) + { + CaptureFault(new InvalidOperationException("Deepgram sent malformed JSON.", ex)); + } + catch (InvalidOperationException ex) + { + CaptureFault(ex); + } + catch (Exception ex) + { + CaptureFault( + new InvalidOperationException("Deepgram streaming receive failed.", ex) + ); + } + finally + { + if (ct.IsCancellationRequested) + { + _terminalCompletion.TrySetResult(); + } + else if ( + Volatile.Read(ref _metadataReceived) == 0 + && Volatile.Read(ref _sessionFault) is null + ) + { + CaptureFault( + new InvalidOperationException( + "Deepgram streaming receive ended before Metadata." + ) + ); } } - catch (OperationCanceledException) { } - catch (WebSocketException) { } } - private void ParseAndEmit(string json) + private void ProcessMessage(string json) { - try + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if ( + root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("type", out var typeEl) + || typeEl.ValueKind != JsonValueKind.String + ) { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; + throw new InvalidOperationException( + "Deepgram sent a malformed streaming message." + ); + } - if (!root.TryGetProperty("type", out var typeEl) || typeEl.GetString() != "Results") - return; + switch (typeEl.GetString()) + { + case "Results": + ProcessResults(root); + break; + case "Metadata": + Volatile.Write(ref _metadataReceived, 1); + _terminalCompletion.TrySetResult(); + break; + case "Error": + throw new InvalidOperationException( + $"Deepgram streaming provider error: {ExtractError(root)}" + ); + } + } - var transcript = - root.GetProperty("channel") - .GetProperty("alternatives")[0] - .GetProperty("transcript") - .GetString() - ?? ""; + private void ProcessResults(JsonElement root) + { + if ( + !root.TryGetProperty("channel", out var channel) + || channel.ValueKind != JsonValueKind.Object + || !channel.TryGetProperty("alternatives", out var alternatives) + || alternatives.ValueKind != JsonValueKind.Array + || alternatives.GetArrayLength() == 0 + || alternatives[0].ValueKind != JsonValueKind.Object + || !alternatives[0].TryGetProperty("transcript", out var transcriptEl) + || transcriptEl.ValueKind != JsonValueKind.String + ) + { + throw new InvalidOperationException("Deepgram sent a malformed Results message."); + } - if (string.IsNullOrWhiteSpace(transcript)) - return; + var transcript = transcriptEl.GetString() ?? ""; + if (string.IsNullOrWhiteSpace(transcript)) + return; - var isFinal = root.TryGetProperty("is_final", out var finalEl) && finalEl.GetBoolean(); + var isFinal = + root.TryGetProperty("is_final", out var finalEl) + && finalEl.ValueKind is JsonValueKind.True or JsonValueKind.False + && finalEl.GetBoolean(); + Emit(new StreamingTranscriptEvent(transcript, isFinal)); + } - TranscriptReceived?.Invoke(new StreamingTranscriptEvent(transcript, isFinal)); + private void Emit(StreamingTranscriptEvent transcriptEvent) + { + try + { + TranscriptReceived?.Invoke(transcriptEvent); } - catch - { /* malformed message, skip */ + catch (Exception ex) + { + Debug.WriteLine($"Deepgram streaming subscriber failed: {ex.Message}"); } } + private static string ExtractError(JsonElement root) + { + foreach (var propertyName in new[] { "description", "message", "error" }) + { + if ( + root.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(property.GetString()) + ) + { + return property.GetString()!; + } + } + + return "Unknown provider error."; + } + + private static InvalidOperationException CreatePrematureCloseException( + WebSocketReceiveResult result + ) + { + var status = result.CloseStatus is { } closeStatus + ? $"{(int)closeStatus} ({closeStatus})" + : "without a close status"; + var reason = string.IsNullOrWhiteSpace(result.CloseStatusDescription) + ? "" + : $": {result.CloseStatusDescription}"; + return new InvalidOperationException( + $"Deepgram streaming socket closed {status}{reason} before Metadata." + ); + } + + private void ThrowIfClosedBeforeMetadata() + { + ThrowIfFaulted(); + if (Volatile.Read(ref _metadataReceived) != 0) + return; + + CaptureFault( + new InvalidOperationException( + $"Deepgram streaming socket is {_ws.State} before Metadata." + ) + ); + ThrowIfFaulted(); + } + + private void CaptureFault(Exception exception) + { + if (Interlocked.CompareExchange(ref _sessionFault, exception, null) is null) + _terminalCompletion.TrySetException(exception); + } + + private void ThrowIfFaulted() + { + var exception = Volatile.Read(ref _sessionFault); + if (exception is not null) + ExceptionDispatchInfo.Capture(exception).Throw(); + } + public async ValueTask DisposeAsync() { + if (_disposed) + return; + + _disposed = true; // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _receiveCts.Cancel(); + _terminalCompletion.TrySetResult(); if (_ws.State == WebSocketState.Open) { @@ -157,6 +388,7 @@ await _ws.CloseAsync( } } + _ = _terminalCompletion.Task.Exception; _receiveCts.Dispose(); _ws.Dispose(); } diff --git a/plugins/TypeWhisper.Plugin.Deepgram/TypeWhisper.Plugin.Deepgram.csproj b/plugins/TypeWhisper.Plugin.Deepgram/TypeWhisper.Plugin.Deepgram.csproj index f44c49cf9..6e779b41c 100644 --- a/plugins/TypeWhisper.Plugin.Deepgram/TypeWhisper.Plugin.Deepgram.csproj +++ b/plugins/TypeWhisper.Plugin.Deepgram/TypeWhisper.Plugin.Deepgram.csproj @@ -6,6 +6,9 @@ latest TypeWhisper.Plugin.Deepgram + + + diff --git a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs index db9537d43..9e47f5bd5 100644 --- a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Net.WebSockets; +using System.Runtime.ExceptionServices; using System.Text; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -14,13 +15,25 @@ internal sealed class ElevenLabsStreamingSession : IStreamingSession { internal const int MinimumBufferedChunkBytes = 3200; // 100ms at 16kHz, 16-bit mono - private readonly ClientWebSocket _ws = new(); + private readonly WebSocket _ws; private readonly CancellationTokenSource _receiveCts = new(); private readonly SemaphoreSlim _sendLock = new(1, 1); private readonly MemoryStream _audioBuffer = new(); - private Task? _receiveTask; + private readonly TaskCompletionSource _terminalCompletion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private Exception? _sessionFault; + private readonly Task? _receiveTask; + private int _finalCommitSent; + private int _finalCommitPending; + private int _terminalCommitReceived; private bool _disposed; + internal ElevenLabsStreamingSession(WebSocket ws) + { + _ws = ws; + _receiveTask = ReceiveLoopAsync(_receiveCts.Token); + } + public event Action? TranscriptReceived; public static async Task ConnectAsync( @@ -30,16 +43,33 @@ public static async Task ConnectAsync( CancellationToken ct ) { - var session = new ElevenLabsStreamingSession(); - session._ws.Options.SetRequestHeader("xi-api-key", apiKey); - await session._ws.ConnectAsync(BuildRealtimeUri(realtimeModelId, language), ct); - session._receiveTask = session.ReceiveLoopAsync(session._receiveCts.Token); - return session; + var ws = new ClientWebSocket(); + ws.Options.SetRequestHeader("xi-api-key", apiKey); + try + { + await ws.ConnectAsync(BuildRealtimeUri(realtimeModelId, language), ct); + return new ElevenLabsStreamingSession(ws); + } + catch + { + ws.Dispose(); + throw; + } } public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationToken ct) { - if (_disposed || _ws.State != WebSocketState.Open || pcm16Audio.Length == 0) + if (_disposed) + return; + + ThrowIfFaulted(); + if (_ws.State != WebSocketState.Open) + { + ThrowIfClosedBeforeTerminalCommit(); + return; + } + + if (pcm16Audio.Length == 0) return; try @@ -50,18 +80,43 @@ public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationTo { return; } + try { - if (_disposed || _ws.State != WebSocketState.Open) + if (_disposed) return; + ThrowIfFaulted(); + if (_ws.State != WebSocketState.Open) + { + ThrowIfClosedBeforeTerminalCommit(); + return; + } + _audioBuffer.Write(pcm16Audio.Span); if (_audioBuffer.Length < MinimumBufferedChunkBytes) return; var chunk = _audioBuffer.ToArray(); _audioBuffer.SetLength(0); - await SendAudioPayloadAsync(chunk, commit: false, ct); + try + { + await SendAudioPayloadAsync(chunk, commit: false, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + CaptureFault( + new InvalidOperationException( + "ElevenLabs streaming audio send failed.", + ex + ) + ); + throw; + } } finally { @@ -75,37 +130,79 @@ public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationTo public async Task FinalizeAsync(CancellationToken ct) { - if (_disposed || _ws.State != WebSocketState.Open) + if (_disposed) return; - try + ThrowIfFaulted(); + if (Volatile.Read(ref _finalCommitSent) == 0) { - await _sendLock.WaitAsync(ct); - } - catch (ObjectDisposedException) - { - return; - } - try - { - if (_disposed || _ws.State != WebSocketState.Open) + try + { + await _sendLock.WaitAsync(ct); + } + catch (ObjectDisposedException) + { return; + } - // Always send a terminal commit so the server knows the audio - // stream is done, even when the buffer happens to be empty - // because SendAudioAsync just flushed an exact-chunk boundary. - var chunk = _audioBuffer.Length == 0 ? [] : _audioBuffer.ToArray(); - _audioBuffer.SetLength(0); - await SendAudioPayloadAsync(chunk, commit: true, ct); - } - finally - { try { - _sendLock.Release(); + if (_disposed) + return; + + ThrowIfFaulted(); + if (Volatile.Read(ref _finalCommitSent) == 0) + { + if (_ws.State != WebSocketState.Open) + { + ThrowIfClosedBeforeTerminalCommit(); + return; + } + + // Arm the response waiter before sending so a fast provider + // response cannot race past it. Earlier VAD commits do not + // complete this source because it is armed only for the + // explicit final commit. + Volatile.Write(ref _finalCommitPending, 1); + Volatile.Write(ref _finalCommitSent, 1); + + // Always send a terminal commit, including an empty chunk. + // An exact chunk-boundary flush still needs a committed + // response before the coordinator may accept the stream. + var chunk = _audioBuffer.Length == 0 ? [] : _audioBuffer.ToArray(); + _audioBuffer.SetLength(0); + try + { + await SendAudioPayloadAsync(chunk, commit: true, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + CaptureFault( + new InvalidOperationException( + "ElevenLabs final commit send failed.", + ex + ) + ); + throw; + } + } + } + finally + { + try + { + _sendLock.Release(); + } + catch (ObjectDisposedException) { } } - catch (ObjectDisposedException) { } } + + await _terminalCompletion.Task.WaitAsync(ct); + ThrowIfFaulted(); } internal static Uri BuildRealtimeUri(string realtimeModelId, string? language) @@ -142,24 +239,51 @@ internal static bool TryParseTranscriptEvent( string json, out StreamingTranscriptEvent? transcriptEvent, out string? error + ) => + TryParseTranscriptEvent( + json, + out transcriptEvent, + out error, + out _ + ); + + private static bool TryParseTranscriptEvent( + string json, + out StreamingTranscriptEvent? transcriptEvent, + out string? error, + out bool isCommittedTranscript ) { transcriptEvent = null; error = null; + isCommittedTranscript = false; try { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - if (!root.TryGetProperty("message_type", out var messageTypeEl)) + if ( + root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("message_type", out var messageTypeEl) + || messageTypeEl.ValueKind != JsonValueKind.String + ) + { + error = "ElevenLabs sent a malformed message without a message_type."; return false; + } var messageType = messageTypeEl.GetString(); - if (string.IsNullOrWhiteSpace(messageType) || messageType == "session_started") + if (string.IsNullOrWhiteSpace(messageType)) + { + error = "ElevenLabs sent a malformed message with an empty message_type."; + return false; + } + + if (messageType == "session_started") return false; - if (messageType.Contains("error", StringComparison.OrdinalIgnoreCase)) + if (IsErrorMessageType(messageType)) { error = ExtractErrorMessage(root) ?? json; return false; @@ -168,7 +292,12 @@ out string? error // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here. if (messageType is "partial_transcript") { - var text = GetText(root); + if (!TryGetText(root, out var text)) + { + error = "ElevenLabs sent a malformed partial transcript."; + return false; + } + if (string.IsNullOrWhiteSpace(text)) return false; @@ -178,7 +307,15 @@ out string? error if (messageType is "committed_transcript" or "committed_transcript_with_timestamps") { - var text = GetText(root); + isCommittedTranscript = true; + if (!TryGetText(root, out var text)) + { + error = "ElevenLabs sent a malformed committed transcript."; + return false; + } + + // An empty committed transcript is still the acknowledgement for + // an empty final commit and must unblock FinalizeAsync. if (string.IsNullOrWhiteSpace(text)) return false; @@ -188,12 +325,44 @@ out string? error } catch (JsonException ex) { - error = ex.Message; + error = $"ElevenLabs sent malformed JSON: {ex.Message}"; } return false; } + private static bool IsErrorMessageType(string messageType) => + messageType switch + { + "auth_error" + or "quota_exceeded" + or "transcriber_error" + or "input_error" + or "error" + or "commit_throttled" + or "unaccepted_terms" + or "rate_limited" + or "queue_overflow" + or "resource_exhausted" + or "session_time_limit_exceeded" + or "chunk_size_exceeded" + or "insufficient_audio_activity" + or "scribe_auth_error" + or "scribe_quota_exceeded" + or "scribe_throttled" + or "scribe_unaccepted_terms" + or "scribe_rate_limited" + or "scribe_queue_overflow" + or "scribe_resource_exhausted" + or "scribe_session_time_limit_exceeded" + or "scribe_input_error" + or "scribe_chunk_size_exceeded" + or "scribe_insufficient_audio_activity" + or "scribe_transcriber_error" + or "scribe_error" => true, + _ => messageType.Contains("error", StringComparison.OrdinalIgnoreCase), + }; + private async Task SendAudioPayloadAsync(byte[] chunk, bool commit, CancellationToken ct) { var payload = Encoding.UTF8.GetBytes(BuildAudioChunkPayload(chunk, commit)); @@ -207,7 +376,7 @@ private async Task ReceiveLoopAsync(CancellationToken ct) try { - while (!ct.IsCancellationRequested && _ws.State == WebSocketState.Open) + while (true) { messageBuffer.SetLength(0); WebSocketReceiveResult result; @@ -215,7 +384,11 @@ private async Task ReceiveLoopAsync(CancellationToken ct) { result = await _ws.ReceiveAsync(buffer, ct); if (result.MessageType == WebSocketMessageType.Close) + { + CaptureFault(CreatePrematureCloseException(result)); return; + } + messageBuffer.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); @@ -227,35 +400,106 @@ private async Task ReceiveLoopAsync(CancellationToken ct) 0, (int)messageBuffer.Length ); - if (TryParseTranscriptEvent(json, out var transcriptEvent, out var error)) + if ( + TryParseTranscriptEvent( + json, + out var transcriptEvent, + out var error, + out var isCommittedTranscript + ) + ) { - // Isolate subscriber failures so a buggy handler can't - // tear down the WebSocket receive loop. - try - { - TranscriptReceived?.Invoke(transcriptEvent!); - } - catch (Exception ex) - { - Debug.WriteLine($"ElevenLabs realtime subscriber failed: {ex.Message}"); - } + Emit(transcriptEvent!); } - else if (!string.IsNullOrWhiteSpace(error)) + + if (!string.IsNullOrWhiteSpace(error)) + throw new InvalidOperationException( + $"ElevenLabs streaming provider error: {error}" + ); + + if ( + isCommittedTranscript + && Volatile.Read(ref _finalCommitPending) != 0 + ) { - Debug.WriteLine($"ElevenLabs realtime error: {error}"); + Volatile.Write(ref _terminalCommitReceived, 1); + _terminalCompletion.TrySetResult(); return; } } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // DisposeAsync owns this token. Local teardown is not a stream fault. + } + catch (OperationCanceledException ex) + { + CaptureFault( + new InvalidOperationException("ElevenLabs streaming receive was canceled.", ex) + ); + } catch (WebSocketException ex) { - Debug.WriteLine($"ElevenLabs realtime WebSocket error: {ex.Message}"); + CaptureFault( + new InvalidOperationException("ElevenLabs streaming transport failed.", ex) + ); + } + catch (InvalidOperationException ex) + { + CaptureFault(ex); + } + catch (Exception ex) + { + CaptureFault( + new InvalidOperationException("ElevenLabs streaming receive failed.", ex) + ); + } + finally + { + if (ct.IsCancellationRequested) + { + _terminalCompletion.TrySetResult(); + } + else if ( + Volatile.Read(ref _terminalCommitReceived) == 0 + && Volatile.Read(ref _sessionFault) is null + ) + { + CaptureFault( + new InvalidOperationException( + "ElevenLabs streaming receive ended before the final committed transcript." + ) + ); + } + } + } + + private void Emit(StreamingTranscriptEvent transcriptEvent) + { + try + { + TranscriptReceived?.Invoke(transcriptEvent); + } + catch (Exception ex) + { + Debug.WriteLine($"ElevenLabs realtime subscriber failed: {ex.Message}"); } } - private static string GetText(JsonElement root) => - root.TryGetProperty("text", out var textEl) ? textEl.GetString() ?? "" : ""; + private static bool TryGetText(JsonElement root, out string text) + { + text = ""; + if ( + !root.TryGetProperty("text", out var textEl) + || textEl.ValueKind != JsonValueKind.String + ) + { + return false; + } + + text = textEl.GetString() ?? ""; + return true; + } private static string? ExtractErrorMessage(JsonElement root) { @@ -274,19 +518,61 @@ private static string GetText(JsonElement root) => return null; } + private static InvalidOperationException CreatePrematureCloseException( + WebSocketReceiveResult result + ) + { + var status = result.CloseStatus is { } closeStatus + ? $"{(int)closeStatus} ({closeStatus})" + : "without a close status"; + var reason = string.IsNullOrWhiteSpace(result.CloseStatusDescription) + ? "" + : $": {result.CloseStatusDescription}"; + return new InvalidOperationException( + $"ElevenLabs streaming socket closed {status}{reason} before the final committed transcript." + ); + } + + private void ThrowIfClosedBeforeTerminalCommit() + { + ThrowIfFaulted(); + if (Volatile.Read(ref _terminalCommitReceived) != 0) + return; + + CaptureFault( + new InvalidOperationException( + $"ElevenLabs streaming socket is {_ws.State} before the final committed transcript." + ) + ); + ThrowIfFaulted(); + } + + private void CaptureFault(Exception exception) + { + if (Interlocked.CompareExchange(ref _sessionFault, exception, null) is null) + _terminalCompletion.TrySetException(exception); + } + + private void ThrowIfFaulted() + { + var exception = Volatile.Read(ref _sessionFault); + if (exception is not null) + ExceptionDispatchInfo.Capture(exception).Throw(); + } + public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. + _receiveCts.Cancel(); + _terminalCompletion.TrySetResult(); await _sendLock.WaitAsync(CancellationToken.None); try { - // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. - _receiveCts.Cancel(); - if (_ws.State == WebSocketState.Open) { // Bound the handshake: an unresponsive peer with CancellationToken.None @@ -318,7 +604,7 @@ await _ws.CloseAsync( } } - // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. + // ReSharper disable once MethodHasAsyncOverload -- MemoryStream has no async disposal work; DisposeAsync would only add overhead here. _audioBuffer.Dispose(); } finally @@ -328,5 +614,7 @@ await _ws.CloseAsync( _receiveCts.Dispose(); _ws.Dispose(); } + + _ = _terminalCompletion.Task.Exception; } } diff --git a/tests/TypeWhisper.PluginSystem.Tests/StreamingProviderFailurePropagationTests.cs b/tests/TypeWhisper.PluginSystem.Tests/StreamingProviderFailurePropagationTests.cs new file mode 100644 index 000000000..272391912 --- /dev/null +++ b/tests/TypeWhisper.PluginSystem.Tests/StreamingProviderFailurePropagationTests.cs @@ -0,0 +1,659 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; +using TypeWhisper.PluginSDK; +using AssemblyAiSession = TypeWhisper.Plugin.AssemblyAi.AssemblyAiStreamingSession; +using DeepgramSession = TypeWhisper.Plugin.Deepgram.DeepgramStreamingSession; +using ElevenLabsSession = TypeWhisper.Plugin.ElevenLabs.ElevenLabsStreamingSession; + +namespace TypeWhisper.PluginSystem.Tests; + +public sealed class StreamingProviderFailurePropagationTests +{ + private static readonly TimeSpan s_testTimeout = TimeSpan.FromSeconds(5); + + [Fact] + public async Task AssemblyAi_OneFinalThenTransportFault_SendAndFinalizeRethrow() + { + var socket = new FakeWebSocket(); + await using var session = new AssemblyAiSession(socket); + var finalReceived = FinalReceived(session); + + socket.EnqueueText( + """{"type":"Turn","transcript":"A complete prefix.","end_of_turn":true,"turn_is_formatted":true}""" + ); + await finalReceived.Task.WaitAsync(s_testTimeout); + socket.EnqueueFault(new WebSocketException("AssemblyAI transport failed.")); + await socket.LastReceiveConsumed.WaitAsync(s_testTimeout); + + await Assert.ThrowsAsync( + () => session.SendAudioAsync(new byte[1600], CancellationToken.None) + ); + await Assert.ThrowsAsync( + () => session.FinalizeAsync(CancellationToken.None) + ); + } + + [Fact] + public async Task AssemblyAi_TerminationPath_UsesV3TerminateAndCommitsOnlyFormattedTurn() + { + var socket = new FakeWebSocket(); + await using var session = new AssemblyAiSession(socket); + var events = new ConcurrentQueue(); + session.TranscriptReceived += events.Enqueue; + + var finalize = session.FinalizeAsync(CancellationToken.None); + var sent = await socket.NextSentAsync(); + Assert.Equal(WebSocketMessageType.Text, sent.MessageType); + Assert.Equal("""{"type":"Terminate"}""", sent.Text); + Assert.False(finalize.IsCompleted); + + socket.EnqueueText( + """{"type":"Turn","transcript":"unformatted ending","end_of_turn":true,"turn_is_formatted":false}""" + ); + socket.EnqueueText( + """{"type":"Turn","transcript":"Formatted ending.","end_of_turn":true,"turn_is_formatted":true}""" + ); + socket.EnqueueText( + """{"type":"Termination","audio_duration_seconds":1.0,"session_duration_seconds":1.1}""" + ); + + await finalize.WaitAsync(s_testTimeout); + Assert.Equal( + [new StreamingTranscriptEvent("Formatted ending.", true)], + events.Where(evt => evt.IsFinal) + ); + } + + [Fact] + public async Task AssemblyAi_AbnormalCloseBeforeTermination_FaultsFinalize() + { + var socket = new FakeWebSocket(); + await using var session = new AssemblyAiSession(socket); + + var finalize = session.FinalizeAsync(CancellationToken.None); + await socket.NextSentAsync(); + socket.EnqueueClose( + WebSocketCloseStatus.InternalServerError, + "provider restarted" + ); + + var exception = await Assert.ThrowsAsync( + () => finalize.WaitAsync(s_testTimeout) + ); + Assert.Contains("before Termination", exception.Message); + Assert.Contains("provider restarted", exception.Message); + } + + [Fact] + public async Task AssemblyAi_ProviderError_FaultsFinalize() + { + var socket = new FakeWebSocket(); + await using var session = new AssemblyAiSession(socket); + + var finalize = session.FinalizeAsync(CancellationToken.None); + await socket.NextSentAsync(); + socket.EnqueueText( + """{"type":"Error","error_code":3007,"error":"Audio transmission rate exceeded."}""" + ); + + var exception = await Assert.ThrowsAsync( + () => finalize.WaitAsync(s_testTimeout) + ); + Assert.Contains("Audio transmission rate exceeded", exception.Message); + } + + [Fact] + public async Task AssemblyAi_MalformedJson_FaultsFinalize() + { + var socket = new FakeWebSocket(); + await using var session = new AssemblyAiSession(socket); + + var finalize = session.FinalizeAsync(CancellationToken.None); + await socket.NextSentAsync(); + socket.EnqueueText("""{"type":"Turn","transcript":"""); + + var exception = await Assert.ThrowsAsync( + () => finalize.WaitAsync(s_testTimeout) + ); + Assert.Contains("malformed JSON", exception.Message); + } + + [Fact] + public async Task AssemblyAi_FinalizeWait_HonorsCallerCancellation() + { + var socket = new FakeWebSocket(); + await using var session = new AssemblyAiSession(socket); + using var cts = new CancellationTokenSource(); + + var finalize = session.FinalizeAsync(cts.Token); + await socket.NextSentAsync(); + // ReSharper disable once MethodHasAsyncOverload -- synchronous Cancel must trip the token before the assertion; CancelAsync would defer it. + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; using cts.Token would abort this wait on the cancellation the test triggers next. + () => finalize.WaitAsync(s_testTimeout) + ); + } + + [Fact] + public async Task AssemblyAi_DisposalCancellation_IsClean() + { + var socket = new FakeWebSocket(); + var session = new AssemblyAiSession(socket); + + await session.DisposeAsync().AsTask().WaitAsync(s_testTimeout); + Assert.Equal(WebSocketState.Closed, socket.State); + } + + [Fact] + public async Task Deepgram_OneFinalThenTransportFault_SendAndFinalizeRethrow() + { + var socket = new FakeWebSocket(); + await using var session = new DeepgramSession(socket); + var finalReceived = FinalReceived(session); + + socket.EnqueueText(DeepgramResult("A complete prefix.", isFinal: true)); + await finalReceived.Task.WaitAsync(s_testTimeout); + socket.EnqueueFault(new WebSocketException("Deepgram transport failed.")); + await socket.LastReceiveConsumed.WaitAsync(s_testTimeout); + + await Assert.ThrowsAsync( + () => session.SendAudioAsync(new byte[] { 1, 2 }, CancellationToken.None) + ); + await Assert.ThrowsAsync( + () => session.FinalizeAsync(CancellationToken.None) + ); + } + + [Fact] + public async Task Deepgram_MetadataPath_AwaitsFinalResults() + { + var socket = new FakeWebSocket(); + await using var session = new DeepgramSession(socket); + var finalReceived = FinalReceived(session); + + var finalize = session.FinalizeAsync(CancellationToken.None); + var sent = await socket.NextSentAsync(); + Assert.Equal("""{"type":"CloseStream"}""", sent.Text); + Assert.False(finalize.IsCompleted); + + socket.EnqueueText(DeepgramResult("Tail result.", isFinal: true)); + socket.EnqueueText( + """{"type":"Metadata","request_id":"request-id","duration":1.0}""" + ); + + await finalize.WaitAsync(s_testTimeout); + Assert.Equal("Tail result.", (await finalReceived.Task.WaitAsync(s_testTimeout)).Text); + } + + [Fact] + public async Task Deepgram_AbnormalCloseBeforeMetadata_FaultsFinalize() + { + var socket = new FakeWebSocket(); + await using var session = new DeepgramSession(socket); + + var finalize = session.FinalizeAsync(CancellationToken.None); + await socket.NextSentAsync(); + socket.EnqueueClose( + WebSocketCloseStatus.EndpointUnavailable, + "upstream unavailable" + ); + + var exception = await Assert.ThrowsAsync( + () => finalize.WaitAsync(s_testTimeout) + ); + Assert.Contains("before Metadata", exception.Message); + Assert.Contains("upstream unavailable", exception.Message); + } + + [Fact] + public async Task Deepgram_ProviderError_FaultsFinalize() + { + var socket = new FakeWebSocket(); + await using var session = new DeepgramSession(socket); + + var finalize = session.FinalizeAsync(CancellationToken.None); + await socket.NextSentAsync(); + socket.EnqueueText( + """{"type":"Error","description":"Project has insufficient credits."}""" + ); + + var exception = await Assert.ThrowsAsync( + () => finalize.WaitAsync(s_testTimeout) + ); + Assert.Contains("insufficient credits", exception.Message); + } + + [Fact] + public async Task Deepgram_MalformedResult_FaultsFinalize() + { + var socket = new FakeWebSocket(); + await using var session = new DeepgramSession(socket); + + var finalize = session.FinalizeAsync(CancellationToken.None); + await socket.NextSentAsync(); + socket.EnqueueText("""{"type":"Results","channel":{"alternatives":[]}}"""); + + var exception = await Assert.ThrowsAsync( + () => finalize.WaitAsync(s_testTimeout) + ); + Assert.Contains("malformed Results", exception.Message); + } + + [Fact] + public async Task Deepgram_FinalizeWait_HonorsCallerCancellation() + { + var socket = new FakeWebSocket(); + await using var session = new DeepgramSession(socket); + using var cts = new CancellationTokenSource(); + + var finalize = session.FinalizeAsync(cts.Token); + await socket.NextSentAsync(); + // ReSharper disable once MethodHasAsyncOverload -- synchronous Cancel must trip the token before the assertion; CancelAsync would defer it. + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; using cts.Token would abort this wait on the cancellation the test triggers next. + () => finalize.WaitAsync(s_testTimeout) + ); + } + + [Fact] + public async Task Deepgram_DisposalCancellation_IsClean() + { + var socket = new FakeWebSocket(); + var session = new DeepgramSession(socket); + + await session.DisposeAsync().AsTask().WaitAsync(s_testTimeout); + Assert.Equal(WebSocketState.Closed, socket.State); + } + + [Fact] + public async Task ElevenLabs_OneFinalThenTransportFault_SendAndFinalizeRethrow() + { + var socket = new FakeWebSocket(); + await using var session = new ElevenLabsSession(socket); + var finalReceived = FinalReceived(session); + + socket.EnqueueText( + """{"message_type":"committed_transcript","text":"A complete prefix."}""" + ); + await finalReceived.Task.WaitAsync(s_testTimeout); + socket.EnqueueFault(new WebSocketException("ElevenLabs transport failed.")); + await socket.LastReceiveConsumed.WaitAsync(s_testTimeout); + + await Assert.ThrowsAsync( + () => session.SendAudioAsync(new byte[3200], CancellationToken.None) + ); + await Assert.ThrowsAsync( + () => session.FinalizeAsync(CancellationToken.None) + ); + } + + [Fact] + public async Task ElevenLabs_CommittedResultPath_AwaitsFinalCommitResponse() + { + var socket = new FakeWebSocket(); + await using var session = new ElevenLabsSession(socket); + var finalReceived = FinalReceived(session); + + var finalize = session.FinalizeAsync(CancellationToken.None); + var sent = await socket.NextSentAsync(); + using (var payload = JsonDocument.Parse(sent.Text)) + { + Assert.True(payload.RootElement.GetProperty("commit").GetBoolean()); + Assert.Equal("", payload.RootElement.GetProperty("audio_base_64").GetString()); + } + + Assert.False(finalize.IsCompleted); + socket.EnqueueText( + """{"message_type":"committed_transcript","text":"Final tail."}""" + ); + + await finalize.WaitAsync(s_testTimeout); + Assert.Equal("Final tail.", (await finalReceived.Task.WaitAsync(s_testTimeout)).Text); + } + + [Fact] + public async Task ElevenLabs_EmptyFinalCommit_CompletesWithoutTranscriptText() + { + var socket = new FakeWebSocket(); + await using var session = new ElevenLabsSession(socket); + var events = new ConcurrentQueue(); + session.TranscriptReceived += events.Enqueue; + + var finalize = session.FinalizeAsync(CancellationToken.None); + await socket.NextSentAsync(); + Assert.False(finalize.IsCompleted); + socket.EnqueueText("""{"message_type":"committed_transcript","text":""}"""); + + await finalize.WaitAsync(s_testTimeout); + Assert.Empty(events); + } + + [Fact] + public async Task ElevenLabs_VadCommitBeforeFinalize_DoesNotSatisfyFinalCommitWait() + { + var socket = new FakeWebSocket(); + await using var session = new ElevenLabsSession(socket); + var vadFinalReceived = FinalReceived(session); + + socket.EnqueueText( + """{"message_type":"committed_transcript","text":"Earlier VAD segment."}""" + ); + await vadFinalReceived.Task.WaitAsync(s_testTimeout); + + var finalize = session.FinalizeAsync(CancellationToken.None); + await socket.NextSentAsync(); + Assert.False(finalize.IsCompleted); + + socket.EnqueueText("""{"message_type":"committed_transcript","text":""}"""); + await finalize.WaitAsync(s_testTimeout); + } + + [Fact] + public async Task ElevenLabs_AbnormalCloseBeforeCommittedResult_FaultsFinalize() + { + var socket = new FakeWebSocket(); + await using var session = new ElevenLabsSession(socket); + + var finalize = session.FinalizeAsync(CancellationToken.None); + await socket.NextSentAsync(); + socket.EnqueueClose( + WebSocketCloseStatus.InternalServerError, + "transcriber stopped" + ); + + var exception = await Assert.ThrowsAsync( + () => finalize.WaitAsync(s_testTimeout) + ); + Assert.Contains("before the final committed transcript", exception.Message); + Assert.Contains("transcriber stopped", exception.Message); + } + + [Theory] + [InlineData("auth_error")] + [InlineData("quota_exceeded")] + [InlineData("transcriber_error")] + [InlineData("input_error")] + [InlineData("error")] + [InlineData("commit_throttled")] + [InlineData("unaccepted_terms")] + [InlineData("rate_limited")] + [InlineData("queue_overflow")] + [InlineData("resource_exhausted")] + [InlineData("session_time_limit_exceeded")] + [InlineData("chunk_size_exceeded")] + [InlineData("insufficient_audio_activity")] + [InlineData("scribe_auth_error")] + [InlineData("scribe_error")] + public async Task ElevenLabs_DocumentedProviderErrorType_FaultsFinalize( + string messageType + ) + { + var socket = new FakeWebSocket(); + await using var session = new ElevenLabsSession(socket); + + var finalize = session.FinalizeAsync(CancellationToken.None); + await socket.NextSentAsync(); + socket.EnqueueText( + JsonSerializer.Serialize( + new + { + message_type = messageType, + error = "Provider rejected the stream.", + } + ) + ); + + var exception = await Assert.ThrowsAsync( + () => finalize.WaitAsync(s_testTimeout) + ); + Assert.Contains("Provider rejected the stream", exception.Message); + } + + [Fact] + public async Task ElevenLabs_MalformedJson_FaultsFinalize() + { + var socket = new FakeWebSocket(); + await using var session = new ElevenLabsSession(socket); + + var finalize = session.FinalizeAsync(CancellationToken.None); + await socket.NextSentAsync(); + socket.EnqueueText("""{"message_type":"partial_transcript","text":"""); + + var exception = await Assert.ThrowsAsync( + () => finalize.WaitAsync(s_testTimeout) + ); + Assert.Contains("malformed JSON", exception.Message); + } + + [Fact] + public async Task ElevenLabs_FinalizeWait_HonorsCallerCancellation() + { + var socket = new FakeWebSocket(); + await using var session = new ElevenLabsSession(socket); + using var cts = new CancellationTokenSource(); + + var finalize = session.FinalizeAsync(cts.Token); + await socket.NextSentAsync(); + // ReSharper disable once MethodHasAsyncOverload -- synchronous Cancel must trip the token before the assertion; CancelAsync would defer it. + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; using cts.Token would abort this wait on the cancellation the test triggers next. + () => finalize.WaitAsync(s_testTimeout) + ); + } + + [Fact] + public async Task ElevenLabs_DisposalCancellation_IsClean() + { + var socket = new FakeWebSocket(); + var session = new ElevenLabsSession(socket); + + await session.DisposeAsync().AsTask().WaitAsync(s_testTimeout); + Assert.Equal(WebSocketState.Closed, socket.State); + } + + private static TaskCompletionSource FinalReceived( + IStreamingSession session + ) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + session.TranscriptReceived += transcriptEvent => + { + if (transcriptEvent.IsFinal) + completion.TrySetResult(transcriptEvent); + }; + return completion; + } + + private static string DeepgramResult(string text, bool isFinal) => + JsonSerializer.Serialize( + new + { + type = "Results", + is_final = isFinal, + channel = new + { + alternatives = new[] { new { transcript = text } }, + }, + } + ); + + private sealed record SentFrame(byte[] Payload, WebSocketMessageType MessageType) + { + public string Text => Encoding.UTF8.GetString(Payload); + } + + private abstract record ReceiveItem + { + public sealed record Frame( + byte[] Payload, + WebSocketMessageType MessageType, + WebSocketCloseStatus? CloseStatus = null, + string? CloseDescription = null + ) : ReceiveItem; + + public sealed record Fault(Exception Exception) : ReceiveItem; + } + + private sealed class FakeWebSocket : WebSocket + { + private readonly Channel _receives = + Channel.CreateUnbounded(); + private readonly Channel _sends = + Channel.CreateUnbounded(); + private TaskCompletionSource _lastReceiveConsumed = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private WebSocketState _state = WebSocketState.Open; + private WebSocketCloseStatus? _closeStatus; + private string? _closeDescription; + + public Task LastReceiveConsumed => _lastReceiveConsumed.Task; + public override WebSocketCloseStatus? CloseStatus => _closeStatus; + public override string? CloseStatusDescription => _closeDescription; + public override WebSocketState State => _state; + public override string? SubProtocol => null; + + public void EnqueueText(string json) => + Enqueue( + new ReceiveItem.Frame( + Encoding.UTF8.GetBytes(json), + WebSocketMessageType.Text + ) + ); + + public void EnqueueClose( + WebSocketCloseStatus closeStatus, + string? closeDescription + ) => + Enqueue( + new ReceiveItem.Frame( + [], + WebSocketMessageType.Close, + closeStatus, + closeDescription + ) + ); + + public void EnqueueFault(Exception exception) => + Enqueue(new ReceiveItem.Fault(exception)); + + public async Task NextSentAsync() => + await _sends.Reader.ReadAsync().AsTask().WaitAsync(s_testTimeout); + + private void Enqueue(ReceiveItem item) + { + _lastReceiveConsumed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + Assert.True(_receives.Writer.TryWrite(item)); + } + + public override void Abort() + { + _state = WebSocketState.Aborted; + } + + public override Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + _closeStatus = closeStatus; + _closeDescription = statusDescription; + _state = WebSocketState.Closed; + return Task.CompletedTask; + } + + public override Task CloseOutputAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + _closeStatus = closeStatus; + _closeDescription = statusDescription; + _state = WebSocketState.CloseSent; + return Task.CompletedTask; + } + + public override void Dispose() + { + _state = WebSocketState.Closed; + _receives.Writer.TryComplete(); + _sends.Writer.TryComplete(); + } + + public override async Task ReceiveAsync( + ArraySegment buffer, + CancellationToken cancellationToken + ) + { + var item = await _receives.Reader.ReadAsync(cancellationToken); + _lastReceiveConsumed.TrySetResult(); + + if (item is ReceiveItem.Fault fault) + { + _state = WebSocketState.Aborted; + ExceptionDispatchInfo.Capture(fault.Exception).Throw(); + } + + var frame = Assert.IsType(item); + if (frame.MessageType == WebSocketMessageType.Close) + { + _closeStatus = frame.CloseStatus; + _closeDescription = frame.CloseDescription; + _state = WebSocketState.CloseReceived; + return new WebSocketReceiveResult( + 0, + WebSocketMessageType.Close, + true, + frame.CloseStatus, + frame.CloseDescription + ); + } + + Assert.True( + frame.Payload.Length <= buffer.Count, + "Fake WebSocket frame exceeds the session receive buffer." + ); + frame.Payload.CopyTo(buffer.Array!, buffer.Offset); + return new WebSocketReceiveResult( + frame.Payload.Length, + frame.MessageType, + true + ); + } + + public override Task SendAsync( + ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_state != WebSocketState.Open) + throw new WebSocketException("The fake WebSocket is not open."); + + Assert.True(endOfMessage); + Assert.True( + _sends.Writer.TryWrite(new SentFrame(buffer.AsSpan().ToArray(), messageType)) + ); + return Task.CompletedTask; + } + } +} diff --git a/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj b/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj index f7702cc44..50737423e 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj +++ b/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj @@ -16,9 +16,11 @@ + + From 4d2675d37676eaf1364763e88ebd423f39fbafd2 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 01:10:48 +0000 Subject: [PATCH 174/226] Require terminal events before LLM stream readers return success The Claude, xAI, and ChatGPT stream readers returned accumulated deltas as a successful result whenever the stream ended, so a connection cut mid-generation - or a provider failure frame - shipped a silently truncated response. LlmStreamPump only marks a stream faulted when enumeration throws, so the host never fell back to batch processing. Each reader now tracks per-request terminal state and fails closed: - Claude requires a well-formed message_stop frame (Anthropic's documented final event) before EOF; error frames still throw immediately and unknown event types remain ignored. - xAI records response.completed as semantic success and throws on [DONE] or EOF without it. ParseStreamError additionally recognizes response.incomplete, both cancelled spellings, and any frame whose nested response.status is a terminal failure, extracting the provider's error message or incomplete reason. - The ChatGPT client separates ordinary JSON parsing from SSE parsing so plain JSON bodies never hit the SSE terminator requirement. SSE requires [DONE] (the terminator the captured fixture for the private chatgpt.com endpoint establishes), rejects error/failed/incomplete/ cancelled events, and validates response.completed's nested status when present. Failure exceptions carry event type and status only, never response text. --- .../TypeWhisper.Plugin.Claude/ClaudePlugin.cs | 30 +++- .../OpenAiChatGptClient.cs | 83 +++++++++-- .../XaiResponsesClient.cs | 129 ++++++++++++++++-- .../ClaudePluginTests.cs | 35 +++++ .../OpenAiChatGptClientTests.cs | 106 ++++++++++++++ .../XaiPluginTests.cs | 120 ++++++++++++++++ 6 files changed, 478 insertions(+), 25 deletions(-) create mode 100644 tests/TypeWhisper.PluginSystem.Tests/OpenAiChatGptClientTests.cs diff --git a/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs b/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs index 1496ca94c..a4f0a540c 100644 --- a/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs +++ b/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs @@ -168,8 +168,9 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct using var reader = new StreamReader(stream); // The Anthropic Messages stream has no [DONE] sentinel; it ends with a - // message_stop frame and then EOF, so the loop runs until ReadLineAsync - // returns null. + // message_stop frame and then EOF. Treat that frame as the semantic + // success marker so a truncated stream cannot commit its partial text. + var receivedMessageStop = false; while (await reader.ReadLineAsync(ct) is { } rawLine) { var line = rawLine.Trim(); @@ -185,9 +186,34 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct if (ParseStreamError(payload) is { } error) throw new InvalidOperationException(error); + if (IsMessageStop(payload)) + receivedMessageStop = true; + if (ParseStreamDelta(payload) is { Length: > 0 } delta) yield return delta; } + + if (!receivedMessageStop) + { + throw new InvalidOperationException( + "Anthropic stream ended before a message_stop event was received."); + } + } + + private static bool IsMessageStop(string dataPayload) + { + try + { + using var doc = JsonDocument.Parse(dataPayload); + var root = doc.RootElement; + return root.TryGetProperty("type", out var type) + && type.ValueKind == JsonValueKind.String + && type.GetString() == "message_stop"; + } + catch (JsonException) + { + return false; + } } /// diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs index 36abf317f..89f106421 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs @@ -83,13 +83,19 @@ internal static Dictionary CreateRequestBody( return body; } - internal static string? ParseResponseText(string body) => - ParseJsonResponseText(body) ?? ParseEventStreamResponseText(body); + internal static string? ParseResponseText(string body) + { + if (TryParseJsonResponseText(body, out var responseText)) + return responseText; + + return ParseEventStreamResponseText(body); + } private static string? ParseEventStreamResponseText(string body) { var deltaBuffer = new StringBuilder(); var completedParts = new List(); + var receivedDone = false; foreach (var rawLine in body.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) { @@ -99,7 +105,10 @@ internal static Dictionary CreateRequestBody( var payload = line[6..]; if (payload == "[DONE]") - continue; + { + receivedDone = true; + break; + } JsonDocument doc; try @@ -115,10 +124,13 @@ internal static Dictionary CreateRequestBody( using (doc) { var root = doc.RootElement; - if (!root.TryGetProperty("type", out var typeEl)) + if (GetString(root, "type") is not { } type) continue; - switch (typeEl.GetString()) + if (GetSseFailure(root, type) is { } failure) + throw new InvalidOperationException(failure); + + switch (type) { case "response.output_text.delta": if (GetString(root, "delta") is { } delta) @@ -139,6 +151,12 @@ internal static Dictionary CreateRequestBody( } } + if (!receivedDone) + { + throw new InvalidOperationException( + "ChatGPT SSE stream ended before [DONE] was received."); + } + if (deltaBuffer.Length > 0) return deltaBuffer.ToString().Trim(); @@ -146,7 +164,39 @@ internal static Dictionary CreateRequestBody( return string.IsNullOrEmpty(completed) ? null : completed; } - private static string? ParseJsonResponseText(string json) + private static string? GetSseFailure(JsonElement root, string type) + { + var status = root.TryGetProperty("response", out var response) + && response.ValueKind == JsonValueKind.Object + ? GetString(response, "status") + : GetString(root, "status"); + + if (type == "response.completed") + { + if (!string.Equals(status, "completed", StringComparison.OrdinalIgnoreCase)) + { + return $"ChatGPT SSE event 'response.completed' had non-completed status " + + $"'{status ?? "missing"}'."; + } + + return null; + } + + if (type is not ("error" + or "response.failed" + or "response.incomplete" + or "response.cancelled" + or "response.canceled")) + { + return null; + } + + return status is null + ? $"ChatGPT SSE event '{type}' indicated failure." + : $"ChatGPT SSE event '{type}' indicated terminal status '{status}'."; + } + + private static bool TryParseJsonResponseText(string json, out string? responseText) { try { @@ -154,7 +204,10 @@ internal static Dictionary CreateRequestBody( var root = doc.RootElement; if (GetString(root, "output_text") is { Length: > 0 } outputText) - return outputText.Trim(); + { + responseText = outputText.Trim(); + return true; + } if (root.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array @@ -162,7 +215,8 @@ internal static Dictionary CreateRequestBody( && choices[0].TryGetProperty("message", out var message) && GetString(message, "content") is { Length: > 0 } messageContent) { - return messageContent.Trim(); + responseText = messageContent.Trim(); + return true; } if (root.TryGetProperty("output", out var output) @@ -184,15 +238,20 @@ internal static Dictionary CreateRequestBody( var joined = string.Join("\n", parts).Trim(); if (!string.IsNullOrEmpty(joined)) - return joined; + { + responseText = joined; + return true; + } } + + responseText = null; + return true; } catch (JsonException) { - return null; + responseText = null; + return false; } - - return null; } private static string ParseErrorMessage(string body, int statusCode) diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs b/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs index 799835c4b..1e9cfeb2c 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs @@ -88,6 +88,7 @@ public async IAsyncEnumerable ProcessStreamingAsync( await using var stream = await response.Content.ReadAsStreamAsync(ct); using var reader = new StreamReader(stream); + var receivedCompleted = false; while (await reader.ReadLineAsync(ct) is { } rawLine) { var line = rawLine.Trim(); @@ -96,19 +97,35 @@ public async IAsyncEnumerable ProcessStreamingAsync( var payload = line[6..]; if (payload == "[DONE]") + { + if (!receivedCompleted) + { + throw new InvalidOperationException( + "xAI stream ended with [DONE] before response.completed was received."); + } + yield break; + } // The Responses stream returns 200 before generation finishes, so a - // mid-stream failure arrives as a typed `error` / `response.failed` - // frame rather than an HTTP error. Throw on those so the pump faults - // and the caller falls back to batch, instead of silently committing - // the partial deltas seen so far as a successful result. + // mid-stream failure arrives as a typed lifecycle frame rather than + // an HTTP error. Throw so the pump faults and the caller falls back + // to batch instead of silently committing partial deltas. if (ParseStreamError(payload) is { } error) throw new InvalidOperationException(error); + if (IsStreamCompletion(payload)) + receivedCompleted = true; + if (ParseStreamDelta(payload) is { Length: > 0 } delta) yield return delta; } + + if (!receivedCompleted) + { + throw new InvalidOperationException( + "xAI stream ended before response.completed was received."); + } } /// @@ -147,8 +164,9 @@ public async IAsyncEnumerable ProcessStreamingAsync( /// /// Returns a provider error message when a single Responses SSE - /// data: payload is a failure frame — a top-level error event - /// or a response.failed lifecycle frame — otherwise null. + /// data: payload is a failure frame — a top-level error event, + /// a failed/incomplete/cancelled lifecycle frame, or a nested terminal + /// failure status — otherwise null. /// Used by the streaming reader to surface a post-200 stream failure as a /// thrown exception. Reflection-free (A18) via . /// @@ -173,22 +191,111 @@ public async IAsyncEnumerable ProcessStreamingAsync( return null; } + var type = typeEl.GetString(); + if (TryGetResponse(root, out var response) + && TryGetString(response, "status") is { } status + && IsFailureStatus(status)) + { + return ExtractFailureDetail(root) + ?? $"xAI response ended with status '{status}'."; + } + // ReSharper disable once ConvertSwitchStatementToSwitchExpression -- subjective style; the statement switch reads fine here. - switch (typeEl.GetString()) + switch (type) { case "error": return ExtractErrorMessage(root) ?? "xAI streaming error."; case "response.failed": - return root.TryGetProperty("response", out var resp) - && resp.ValueKind == JsonValueKind.Object - ? ExtractErrorMessage(resp) ?? "xAI response failed." - : "xAI response failed."; + return ExtractFailureDetail(root) ?? "xAI response failed."; + case "response.incomplete": + return ExtractFailureDetail(root) ?? "xAI response incomplete."; + case "response.cancelled": + return ExtractFailureDetail(root) ?? "xAI response cancelled."; + case "response.canceled": + return ExtractFailureDetail(root) ?? "xAI response canceled."; + case "response.completed": + if (TryGetResponse(root, out response) + && TryGetString(response, "status") is { } completedStatus + && !completedStatus.Equals("completed", StringComparison.OrdinalIgnoreCase)) + { + return ExtractFailureDetail(root) + ?? $"xAI response.completed had non-completed status '{completedStatus}'."; + } + + return null; default: return null; } } } + private static bool IsStreamCompletion(string dataPayload) + { + try + { + using var doc = JsonDocument.Parse(dataPayload); + var root = doc.RootElement; + return TryGetString(root, "type") == "response.completed"; + } + catch (JsonException) + { + return false; + } + } + + private static bool IsFailureStatus(string status) => + status.Equals("failed", StringComparison.OrdinalIgnoreCase) + || status.Equals("incomplete", StringComparison.OrdinalIgnoreCase) + || status.Equals("cancelled", StringComparison.OrdinalIgnoreCase) + || status.Equals("canceled", StringComparison.OrdinalIgnoreCase); + + private static string? ExtractFailureDetail(JsonElement root) + { + if (ExtractErrorMessage(root) is { } rootError) + return rootError; + + if (TryGetResponse(root, out var response)) + { + if (ExtractErrorMessage(response) is { } responseError) + return responseError; + + if (ExtractIncompleteReason(response) is { } responseReason) + return responseReason; + } + + return ExtractIncompleteReason(root); + } + + private static string? ExtractIncompleteReason(JsonElement element) + { + if (element.TryGetProperty("incomplete_details", out var details) + && details.ValueKind == JsonValueKind.Object + && TryGetString(details, "reason") is { } nestedReason) + { + return nestedReason; + } + + return TryGetString(element, "reason"); + } + + private static bool TryGetResponse(JsonElement root, out JsonElement response) + { + if (root.TryGetProperty("response", out response) + && response.ValueKind == JsonValueKind.Object) + { + return true; + } + + response = default; + return false; + } + + private static string? TryGetString(JsonElement element, string propertyName) => + element.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + private static string? ExtractErrorMessage(JsonElement element) { // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. diff --git a/tests/TypeWhisper.PluginSystem.Tests/ClaudePluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/ClaudePluginTests.cs index 70a8a3395..c73d0a0a6 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/ClaudePluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/ClaudePluginTests.cs @@ -139,6 +139,41 @@ public async Task ProcessStreamingAsync_ThrowsOnErrorFrameAfterPartialDeltas() Assert.Equal("Overloaded", ex.Message); } + [Fact] + public async Task ProcessStreamingAsync_ThrowsWhenEofPrecedesMessageStop() + { + var sse = string.Join( + "\n", + "event: content_block_delta", + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}", + ""); + var handler = new CapturingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), + }); + + var host = new TestPluginHostServices { Secrets = { ["api-key"] = "sk-ant-test" } }; + using var httpClient = new HttpClient(handler); + httpClient.Timeout = TimeSpan.FromSeconds(5); + var sut = new ClaudePlugin(httpClient); + await sut.ActivateAsync(host); + + var chunks = new List(); + var ex = await Assert.ThrowsAsync(async () => + { + await foreach (var chunk in sut.ProcessStreamingAsync( + "system", "user", "model", CancellationToken.None)) + { + chunks.Add(chunk); + } + }); + + Assert.Equal(["partial"], chunks); + Assert.Equal( + "Anthropic stream ended before a message_stop event was received.", + ex.Message); + } + [Theory] [InlineData("""{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}""", "hi")] [InlineData("""{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{"}}""", null)] diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiChatGptClientTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiChatGptClientTests.cs new file mode 100644 index 000000000..e256c384d --- /dev/null +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiChatGptClientTests.cs @@ -0,0 +1,106 @@ +using TypeWhisper.Plugin.OpenAi; + +namespace TypeWhisper.PluginSystem.Tests; + +public sealed class OpenAiChatGptClientTests +{ + [Fact] + public void ParseResponseText_SseDeltaThenEof_Throws() + { + const string stream = """ + data: {"type":"response.output_text.delta","delta":"partial-secret"} + + """; + + var ex = Assert.Throws(() => + OpenAiChatGptClient.ParseResponseText(stream)); + + Assert.Equal( + "ChatGPT SSE stream ended before [DONE] was received.", + ex.Message); + Assert.DoesNotContain("partial-secret", ex.Message); + } + + [Theory] + [InlineData( + """{"type":"error","error":{"message":"provider-secret"}}""", + "error", + null)] + [InlineData( + """{"type":"response.failed","response":{"status":"failed","error":{"message":"provider-secret"}}}""", + "response.failed", + "failed")] + [InlineData( + """{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"provider-secret"}}}""", + "response.incomplete", + "incomplete")] + [InlineData( + """{"type":"response.cancelled","response":{"status":"cancelled"}}""", + "response.cancelled", + "cancelled")] + [InlineData( + """{"type":"response.canceled","response":{"status":"canceled"}}""", + "response.canceled", + "canceled")] + public void ParseResponseText_SseFailureEventAfterDelta_Throws( + string failurePayload, + string eventType, + string? status) + { + var stream = string.Join( + "\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial-secret\"}", + "", + $"data: {failurePayload}", + "", + "data: [DONE]", + ""); + + var ex = Assert.Throws(() => + OpenAiChatGptClient.ParseResponseText(stream)); + + Assert.Contains(eventType, ex.Message); + if (status is not null) + Assert.Contains(status, ex.Message); + Assert.DoesNotContain("partial-secret", ex.Message); + Assert.DoesNotContain("provider-secret", ex.Message); + } + + [Fact] + public void ParseResponseText_SseDoneTerminatedStream_ReturnsText() + { + const string stream = """ + data: {"type":"response.output_text.delta","delta":"Hello"} + + data: {"type":"response.output_text.delta","delta":" world"} + + data: [DONE] + + """; + + var result = OpenAiChatGptClient.ParseResponseText(stream); + + Assert.Equal("Hello world", result); + } + + [Fact] + public void ParseResponseText_SseResponseCompletedWithNonCompletedNestedStatus_Throws() + { + const string stream = """ + data: {"type":"response.output_text.delta","delta":"partial-secret"} + + data: {"type":"response.completed","response":{"status":"incomplete","output_text":"response-secret"}} + + data: [DONE] + + """; + + var ex = Assert.Throws(() => + OpenAiChatGptClient.ParseResponseText(stream)); + + Assert.Contains("response.completed", ex.Message); + Assert.Contains("incomplete", ex.Message); + Assert.DoesNotContain("partial-secret", ex.Message); + Assert.DoesNotContain("response-secret", ex.Message); + } +} diff --git a/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs index 701d45d2b..a0edb6e9d 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs @@ -269,6 +269,126 @@ public async Task ProcessStreamingAsync_ThrowsOnResponseFailedFrameAfterPartialD Assert.Contains("server overloaded", ex.Message); } + [Fact] + public async Task ProcessStreamingAsync_ThrowsWhenEofPrecedesResponseCompleted() + { + var sse = string.Join( + "\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}", + ""); + var handler = new CapturingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), + }); + + var host = new TestPluginHostServices { Secrets = { ["api-key"] = "xai-key" } }; + using var httpClient = new HttpClient(handler); + httpClient.Timeout = TimeSpan.FromSeconds(5); + var sut = new XaiPlugin(httpClient); + await sut.ActivateAsync(host); + + var chunks = new List(); + var ex = await Assert.ThrowsAsync(async () => + { + await foreach (var chunk in sut.ProcessStreamingAsync( + "system", "user", "", CancellationToken.None)) + { + chunks.Add(chunk); + } + }); + + Assert.Equal(["partial"], chunks); + Assert.Equal( + "xAI stream ended before response.completed was received.", + ex.Message); + } + + [Fact] + public async Task ProcessStreamingAsync_ThrowsWhenDonePrecedesResponseCompleted() + { + var sse = string.Join( + "\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}", + "", + "data: [DONE]", + ""); + var handler = new CapturingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), + }); + + var host = new TestPluginHostServices { Secrets = { ["api-key"] = "xai-key" } }; + using var httpClient = new HttpClient(handler); + httpClient.Timeout = TimeSpan.FromSeconds(5); + var sut = new XaiPlugin(httpClient); + await sut.ActivateAsync(host); + + var chunks = new List(); + var ex = await Assert.ThrowsAsync(async () => + { + await foreach (var chunk in sut.ProcessStreamingAsync( + "system", "user", "", CancellationToken.None)) + { + chunks.Add(chunk); + } + }); + + Assert.Equal(["partial"], chunks); + Assert.Equal( + "xAI stream ended with [DONE] before response.completed was received.", + ex.Message); + } + + [Theory] + [InlineData( + """{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}""", + "max_output_tokens")] + [InlineData( + """{"type":"response.cancelled","response":{"status":"cancelled","error":{"message":"cancelled upstream"}}}""", + "cancelled upstream")] + [InlineData( + """{"type":"response.canceled","response":{"status":"canceled"}}""", + "canceled")] + [InlineData( + """{"type":"response.completed","response":{"status":"cancelled"}}""", + "cancelled")] + public async Task ProcessStreamingAsync_ThrowsOnIncompleteOrCancelledTerminalFrame( + string terminalPayload, + string expectedDetail) + { + var sse = string.Join( + "\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}", + "", + $"data: {terminalPayload}", + "", + "data: [DONE]", + ""); + var handler = new CapturingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), + }); + + var host = new TestPluginHostServices { Secrets = { ["api-key"] = "xai-key" } }; + using var httpClient = new HttpClient(handler); + httpClient.Timeout = TimeSpan.FromSeconds(5); + var sut = new XaiPlugin(httpClient); + await sut.ActivateAsync(host); + + var chunks = new List(); + var ex = await Assert.ThrowsAsync(async () => + { + await foreach (var chunk in sut.ProcessStreamingAsync( + "system", "user", "", CancellationToken.None)) + { + chunks.Add(chunk); + } + }); + + Assert.Equal(["partial"], chunks); + Assert.Contains(expectedDetail, ex.Message); + } + [Fact] public void XaiResponsesClient_ParseResponse_ExtractsNestedOutputText() { From 77176e7519de7ea3e8138f2ea1c406e174a9badb Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 01:41:47 +0000 Subject: [PATCH 175/226] Segment long Google Cloud STT audio into sequential sync requests Google's synchronous v1 speech:recognize route caps local audio at 60 seconds, so the plugin rejected longer recordings with a local NotSupportedException even though TypeWhisper and the advertised latest_long model allow them. The async long-running route is out of reach for this plugin: it requires Cloud Storage upload and OAuth cloud-platform credentials, and the plugin authenticates with a plain API key. The gate is gone. PCM is split into sequential sample-aligned chunks of at most 55 seconds - margin under both the 60-second and 10 MB limits - with each cut placed at the center of the quietest 20 ms window in the final 5 seconds of the chunk to avoid splitting words. Chunk transcripts concatenate in request order, billed durations sum, and the first detected language wins. Any chunk failure, malformed response, or cancellation fails the whole transcription so a plausible prefix is never returned, and response parsing is now fail-closed on structure. Audio at or under the chunk limit still sends exactly one request. The manifest and locale descriptions drop the incorrect v2 claim - the code calls v1 - in favor of version-neutral wording. --- .../GoogleCloudSttPlugin.cs | 191 ++++++-- .../Localization/de.json | 2 +- .../Localization/en.json | 2 +- .../Localization/es.json | 2 +- .../Localization/ru.json | 2 +- .../manifest.json | 2 +- .../GoogleCloudSttPluginTests.cs | 425 ++++++++++++++++-- 7 files changed, 535 insertions(+), 91 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs index 67eb9ef27..9574ddc21 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs @@ -1,4 +1,5 @@ // ReSharper disable MemberCanBePrivate.Global +// ReSharper disable UnusedMember.Global // Plugin types are instantiated by the host via reflection and invoked through plugin interfaces // and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. @@ -25,7 +26,16 @@ public sealed class GoogleCloudSttPlugin IPluginLocalizationAware { private const string ApiEndpoint = "https://speech.googleapis.com/v1/speech:recognize"; - private const int MaxSyncSeconds = 60; + private const int SampleRateHertz = 16000; + private const int BytesPerSample = sizeof(short); + private const int BytesPerSecond = SampleRateHertz * BytesPerSample; + private const int MaxChunkSeconds = 55; + private const int MaxChunkBytes = MaxChunkSeconds * BytesPerSecond; + private const int BoundarySearchSeconds = 5; + private const int BoundarySearchBytes = BoundarySearchSeconds * BytesPerSecond; + private const int QuietWindowMilliseconds = 20; + private const int QuietWindowBytes = + SampleRateHertz * BytesPerSample * QuietWindowMilliseconds / 1000; private readonly HttpClient _httpClient; private IPluginHostServices? _host; @@ -34,9 +44,8 @@ public sealed class GoogleCloudSttPlugin public GoogleCloudSttPlugin() : this(new HttpClientHandler()) { } - // Bounds the whole round trip, not the audio length — that is MaxSyncSeconds. Matching the two - // left a max-length clip no headroom for its ~2.6 MB base64 upload; 120s matches the other - // cloud STT plugins here. + // Bounds each request round trip, not the total segmented transcription. A 55s chunk has + // ample headroom for its ~2.3 MB base64 upload; 120s matches the other cloud STT plugins here. private static readonly TimeSpan s_requestTimeout = TimeSpan.FromSeconds(120); // Test seam: lets a stub handler answer requests without hitting the network. @@ -96,18 +105,10 @@ CancellationToken ct // locate the data chunk instead of stripping a fixed 44 bytes. var (pcmOffset, pcmByteCount) = LocatePcmData(wavAudio); - // Google's sync API caps audio at 60s (long-running API is a follow-up). - // Ceiling the duration so a just-over-limit clip never displays as "60". - var durationSeconds = pcmByteCount / 32000.0; - if (durationSeconds > MaxSyncSeconds) - { - throw new NotSupportedException( - $"Google Cloud STT (synchronous API) supports at most {MaxSyncSeconds} seconds of audio; " - + $"this recording is {Math.Ceiling(durationSeconds)} seconds. Use a different engine for long recordings." + if (pcmByteCount % BytesPerSample != 0) + throw new InvalidOperationException( + "Google Cloud STT requires sample-aligned 16-bit PCM audio." ); - } - - var audioBase64 = Convert.ToBase64String(wavAudio, pcmOffset, pcmByteCount); var langCode = !string.IsNullOrEmpty(language) && language != "auto" ? language : "en-US"; // Google requires BCP-47; the rest of the app uses ISO-639-1 ("en"), @@ -115,12 +116,59 @@ CancellationToken ct if (langCode.Length == 2) langCode = MapToGoogleLanguageCode(langCode); + var transcripts = new List(); + string? detectedLanguage = null; + double totalDuration = 0; + var chunkOffset = pcmOffset; + var pcmEnd = checked(pcmOffset + pcmByteCount); + + // Preserve the existing behavior for an empty payload: it still makes one request. + do + { + ct.ThrowIfCancellationRequested(); + + var remaining = pcmEnd - chunkOffset; + var chunkByteCount = + remaining <= MaxChunkBytes + ? remaining + : FindQuietBoundary(wavAudio, chunkOffset); + var chunkResult = await TranscribeChunkAsync( + wavAudio, + chunkOffset, + chunkByteCount, + langCode, + ct + ); + + if (!string.IsNullOrEmpty(chunkResult.Text)) + transcripts.Add(chunkResult.Text); + detectedLanguage ??= chunkResult.DetectedLanguage; + totalDuration += chunkResult.DurationSeconds; + chunkOffset += chunkByteCount; + } while (chunkOffset < pcmEnd); + + return new PluginTranscriptionResult( + string.Join(' ', transcripts), + detectedLanguage ?? langCode, + totalDuration + ); + } + + private async Task TranscribeChunkAsync( + byte[] wavAudio, + int pcmOffset, + int pcmByteCount, + string langCode, + CancellationToken ct + ) + { + var audioBase64 = Convert.ToBase64String(wavAudio, pcmOffset, pcmByteCount); var requestBody = new { config = new { encoding = "LINEAR16", - sampleRateHertz = 16000, + sampleRateHertz = SampleRateHertz, languageCode = langCode, model = "latest_long", }, @@ -128,14 +176,57 @@ CancellationToken ct }; var json = JsonSerializer.Serialize(requestBody); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var request = new HttpRequestMessage(HttpMethod.Post, $"{ApiEndpoint}?key={_apiKey}"); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + request.Content = content; + // Default ResponseContentRead keeps HttpClient.Timeout covering the response-body read; + // ResponseHeadersRead would end the timeout at the headers and let a stalled body hang + // when the caller passes CancellationToken.None. using var response = await _httpClient.SendAsync(request, ct); response.EnsureSuccessStatusCode(); var responseJson = await response.Content.ReadAsStringAsync(ct); - return ParseResponse(responseJson, langCode); + return ParseResponse(responseJson); + } + + private static int FindQuietBoundary(byte[] wavAudio, int chunkOffset) + { + var nominalEnd = chunkOffset + MaxChunkBytes; + var searchStart = nominalEnd - BoundarySearchBytes; + var quietestWindowStart = nominalEnd - QuietWindowBytes; + var quietestScore = long.MaxValue; + + for ( + var windowStart = searchStart; + windowStart + QuietWindowBytes <= nominalEnd; + windowStart += QuietWindowBytes + ) + { + long score = 0; + for ( + var sampleOffset = windowStart; + sampleOffset < windowStart + QuietWindowBytes; + sampleOffset += BytesPerSample + ) + { + var sample = BinaryPrimitives.ReadInt16LittleEndian( + wavAudio.AsSpan(sampleOffset, BytesPerSample) + ); + score += Math.Abs((int)sample); + } + + // Prefer the later window when scores tie so uniformly quiet audio stays + // as close as possible to the nominal 55-second boundary. + if (score <= quietestScore) + { + quietestScore = score; + quietestWindowStart = windowStart; + } + } + + // Splitting at the center leaves 10 ms of the quiet window on both chunks. + return quietestWindowStart - chunkOffset + QuietWindowBytes / 2; } // ffmpeg's piped WAV output writes 0xffffffff placeholder chunk sizes (it @@ -173,34 +264,38 @@ private static (int Offset, int Length) LocatePcmData(byte[] wavAudio) totalLength > 44 ? (44, totalLength - 44) : (0, totalLength); } - private static PluginTranscriptionResult ParseResponse(string json, string requestedLanguage) + private static ChunkTranscriptionResult ParseResponse(string json) { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + throw InvalidResponse("the root value must be an object"); var sb = new StringBuilder(); - if ( - root.TryGetProperty("results", out var results) - && results.ValueKind == JsonValueKind.Array - ) + if (root.TryGetProperty("results", out var results)) { + if (results.ValueKind != JsonValueKind.Array) + throw InvalidResponse("'results' must be an array"); + foreach (var result in results.EnumerateArray()) { - if ( - !result.TryGetProperty("alternatives", out var alternatives) - || alternatives.ValueKind != JsonValueKind.Array - ) - { - continue; - } + if (result.ValueKind != JsonValueKind.Object) + throw InvalidResponse("each result must be an object"); + if (!result.TryGetProperty("alternatives", out var alternatives)) + throw InvalidResponse("each result must contain 'alternatives'"); + if (alternatives.ValueKind != JsonValueKind.Array) + throw InvalidResponse("'alternatives' must be an array"); foreach (var alt in alternatives.EnumerateArray()) { - if (!alt.TryGetProperty("transcript", out var transcript)) - { - continue; - } + if (alt.ValueKind != JsonValueKind.Object) + throw InvalidResponse("each alternative must be an object"); + if ( + !alt.TryGetProperty("transcript", out var transcript) + || transcript.ValueKind != JsonValueKind.String + ) + throw InvalidResponse("each alternative must contain a string transcript"); if (sb.Length > 0) sb.Append(' '); @@ -214,7 +309,10 @@ private static PluginTranscriptionResult ParseResponse(string json, string reque double duration = 0; if (root.TryGetProperty("totalBilledTime", out var billedTime)) { - var billedStr = billedTime.GetString() ?? ""; + if (billedTime.ValueKind != JsonValueKind.String) + throw InvalidResponse("'totalBilledTime' must be a duration string"); + + var billedStr = billedTime.GetString() ?? string.Empty; if ( billedStr.EndsWith('s') && double.TryParse( @@ -227,6 +325,10 @@ out var secs { duration = secs; } + else + { + throw InvalidResponse("'totalBilledTime' must be a duration string"); + } } string? detectedLang = null; @@ -239,16 +341,25 @@ out var secs { var first = resultsForLang[0]; if (first.TryGetProperty("languageCode", out var lc)) + { + if (lc.ValueKind != JsonValueKind.String) + throw InvalidResponse("'languageCode' must be a string"); detectedLang = lc.GetString(); + } } - return new PluginTranscriptionResult( - sb.ToString().Trim(), - detectedLang ?? requestedLanguage, - duration - ); + return new ChunkTranscriptionResult(sb.ToString().Trim(), detectedLang, duration); + + static InvalidOperationException InvalidResponse(string detail) => + new($"Invalid Google Cloud STT response: {detail}."); } + private sealed record ChunkTranscriptionResult( + string Text, + string? DetectedLanguage, + double DurationSeconds + ); + private static string MapToGoogleLanguageCode(string iso) => iso.ToLowerInvariant() switch { diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/de.json b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/de.json index c4e097b48..6764999e3 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/de.json +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/de.json @@ -5,5 +5,5 @@ "Settings.ModelDescription": "Wählen Sie das Google Cloud STT-Modell.", "Settings.NotConfiguredApiKeyRequired": "Plugin nicht konfiguriert. API-Schlüssel erforderlich.", "Manifest.Name": "Google Cloud STT", - "Manifest.Description": "Google Cloud Speech-to-Text v2 Transkription" + "Manifest.Description": "Transkription mit Google Cloud Speech-to-Text" } diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/en.json b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/en.json index b941221be..57925d7d4 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/en.json +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/en.json @@ -5,5 +5,5 @@ "Settings.ModelDescription": "Choose the Google Cloud STT model.", "Settings.NotConfiguredApiKeyRequired": "Plugin not configured. API key required.", "Manifest.Name": "Google Cloud STT", - "Manifest.Description": "Google Cloud Speech-to-Text v2 transcription" + "Manifest.Description": "Transcription with Google Cloud Speech-to-Text" } diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/es.json b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/es.json index 755fb8619..871b40dfc 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/es.json +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/es.json @@ -5,5 +5,5 @@ "Settings.ModelDescription": "Elige el modelo de Google Cloud STT.", "Settings.NotConfiguredApiKeyRequired": "Plugin no configurado. Se requiere la clave de API.", "Manifest.Name": "Google Cloud STT", - "Manifest.Description": "Transcripción con Google Cloud Speech-to-Text v2" + "Manifest.Description": "Transcripción con Google Cloud Speech-to-Text" } diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/ru.json b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/ru.json index 4eb61bed9..03970fa8c 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/ru.json +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/ru.json @@ -5,5 +5,5 @@ "Settings.ModelDescription": "Выберите модель Google Cloud STT.", "Settings.NotConfiguredApiKeyRequired": "Плагин не настроен. Требуется API-ключ.", "Manifest.Name": "Google Cloud STT", - "Manifest.Description": "Транскрипция Google Cloud Speech-to-Text v2" + "Manifest.Description": "Транскрипция с помощью Google Cloud Speech-to-Text" } diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json b/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json index 48a3a5f7a..744a1b6be 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cloud STT", "version": "1.0.0", "author": "TypeWhisper", - "description": "Google Cloud Speech-to-Text v2 transcription", + "description": "Transcription with Google Cloud Speech-to-Text", "networkAccess": "network", "categories": ["transcription"], "assemblyName": "TypeWhisper.Plugin.GoogleCloudStt.dll", diff --git a/tests/TypeWhisper.PluginSystem.Tests/GoogleCloudSttPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/GoogleCloudSttPluginTests.cs index 6113a40c2..824443e00 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/GoogleCloudSttPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/GoogleCloudSttPluginTests.cs @@ -1,7 +1,9 @@ using System.Buffers.Binary; using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; using Moq; -using Moq.Protected; using TypeWhisper.Plugin.GoogleCloudStt; using TypeWhisper.PluginSDK; @@ -9,72 +11,312 @@ namespace TypeWhisper.PluginSystem.Tests; public class GoogleCloudSttPluginTests { + private const int SampleRateHertz = 16000; + private const int BytesPerSample = sizeof(short); + private const int BytesPerSecond = SampleRateHertz * BytesPerSample; + private const int ChunkLimitSeconds = 55; + private const int QuietWindowMilliseconds = 20; + private const int QuietWindowBytes = + SampleRateHertz * BytesPerSample * QuietWindowMilliseconds / 1000; + private const int ProviderDurationLimitBytes = 60 * BytesPerSecond; + private const int ProviderRequestLimitBytes = 10 * 1024 * 1024; + + // Single-chunk boundary regression guard: audio at the plugin's conservative + // chunk limit must not be split merely because Google itself allows up to 60s. [Fact] - public async Task TranscribeAsync_ThrowsForAudioLongerThanSynchronousLimit() + public async Task TranscribeAsync_AtChunkLimit_SendsOneRequestBelowProviderLimits() { - var host = new Mock(); - host.Setup(service => service.LoadSecretAsync("api-key")).ReturnsAsync("dummy-key"); + var handler = CreateSuccessfulHandler(); + using var sut = await CreateConfiguredPluginAsync(handler); + var wavAudio = BuildFfmpegStyleWav(ChunkLimitSeconds * BytesPerSecond); - using var sut = new GoogleCloudSttPlugin(); - await sut.ActivateAsync(host.Object); + var result = await sut.TranscribeAsync( + wavAudio, + null, + translate: false, + prompt: null, + CancellationToken.None + ); - var wavAudio = new byte[44 + 61 * 32000]; + Assert.NotNull(result); + var request = Assert.Single(handler.Requests); + Assert.Equal(ChunkLimitSeconds * BytesPerSecond, request.Audio.Length); + AssertRequestWithinProviderLimits(request); + } - var exception = await Assert.ThrowsAsync( - () => sut.TranscribeAsync(wavAudio, null, false, null, CancellationToken.None) + [Fact] + public async Task TranscribeAsync_SixtyOneSeconds_SendsTwoSampleAlignedRequestsAtQuietBoundary() + { + const int audioBytes = 61 * BytesPerSecond; + const int quietWindowStart = 53 * BytesPerSecond; + var wavAudio = BuildFfmpegStyleWav(audioBytes, amplitude: 1200); + var pcmOffset = wavAudio.Length - audioBytes; + wavAudio.AsSpan(pcmOffset + quietWindowStart, QuietWindowBytes).Clear(); + + var handler = CreateSuccessfulHandler(); + using var sut = await CreateConfiguredPluginAsync(handler); + + await sut.TranscribeAsync( + wavAudio, + "en", + translate: false, + prompt: null, + CancellationToken.None + ); + + Assert.Equal(2, handler.Requests.Count); + var expectedFirstChunkBytes = quietWindowStart + QuietWindowBytes / 2; + Assert.Equal(expectedFirstChunkBytes, handler.Requests[0].Audio.Length); + Assert.Equal(audioBytes - expectedFirstChunkBytes, handler.Requests[1].Audio.Length); + Assert.All(handler.Requests, request => + { + Assert.Equal(0, request.Audio.Length % BytesPerSample); + AssertRequestWithinProviderLimits(request); + }); + + var reconstructedAudio = handler.Requests.SelectMany(request => request.Audio).ToArray(); + Assert.Equal(wavAudio.AsSpan(pcmOffset, audioBytes).ToArray(), reconstructedAudio); + } + + [Fact] + public async Task TranscribeAsync_MultipleChunks_ConcatenatesTranscriptsAndSumsDurationsInOrder() + { + var transcripts = new[] { "first", "second", "third" }; + var billedTimes = new[] { "1.250s", "2.500s", "3.750s" }; + var handler = new CapturingHandler((callNumber, _, _) => + Task.FromResult( + JsonResponse( + RecognitionResponse( + transcripts[callNumber - 1], + billedTimes[callNumber - 1], + "en-US" + ) + ) + ) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + var wavAudio = BuildFfmpegStyleWav(121 * BytesPerSecond); + + var result = await sut.TranscribeAsync( + wavAudio, + "en", + translate: false, + prompt: null, + CancellationToken.None + ); + + Assert.Equal(3, handler.Requests.Count); + Assert.Equal("first second third", result.Text); + Assert.Equal("en-US", result.DetectedLanguage); + Assert.Equal(7.5, result.DurationSeconds); + } + + [Fact] + public async Task TranscribeAsync_LaterChunkHttpFailure_FailsWholeTranscription() + { + var handler = new CapturingHandler((callNumber, _, _) => + Task.FromResult( + callNumber == 1 + ? JsonResponse(RecognitionResponse("prefix", "55s", "en-US")) + : JsonResponse("""{"error":{"message":"quota failure"}}""", HttpStatusCode.BadGateway) + ) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + var wavAudio = BuildFfmpegStyleWav(61 * BytesPerSecond); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + wavAudio, + null, + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Equal(HttpStatusCode.BadGateway, exception.StatusCode); + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task TranscribeAsync_LaterChunkMalformedResponse_FailsWholeTranscription() + { + var handler = new CapturingHandler((callNumber, _, _) => + Task.FromResult( + callNumber == 1 + ? JsonResponse(RecognitionResponse("prefix", "55s", "en-US")) + : JsonResponse("""{"results":"not-an-array"}""") + ) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + var wavAudio = BuildFfmpegStyleWav(61 * BytesPerSecond); + + var exception = await Assert.ThrowsAsync( + () => sut.TranscribeAsync( + wavAudio, + null, + translate: false, + prompt: null, + CancellationToken.None + ) ); Assert.Equal( - "Google Cloud STT (synchronous API) supports at most 60 seconds of audio; " - + "this recording is 61 seconds. Use a different engine for long recordings.", + "Invalid Google Cloud STT response: 'results' must be an array.", exception.Message ); + Assert.Equal(2, handler.Requests.Count); } - // A real 60s ffmpeg import has an extra LIST chunk (78-byte header, not 44); - // duration must come from the data chunk or this boundary file rounds past 60. [Fact] - public async Task TranscribeAsync_AcceptsExactSixtySecondsWithExtendedHeader() + public async Task TranscribeAsync_CancellationBetweenChunks_StopsBeforeNextRequest() { - var host = new Mock(); - host.Setup(service => service.LoadSecretAsync("api-key")).ReturnsAsync("dummy-key"); + using var cancellation = new CancellationTokenSource(); + var handler = new CapturingHandler((_, _, _) => + Task.FromResult( + JsonResponse( + RecognitionResponse("prefix", "55s", "en-US"), + // ReSharper disable once AccessToDisposedClosure -- Cancel runs synchronously while TranscribeAsync below is awaited, before the using disposes cancellation at scope end. + afterContentRead: cancellation.Cancel + ) + ) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + var wavAudio = BuildFfmpegStyleWav(61 * BytesPerSecond); - var handler = new Mock(); - handler - .Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() + await Assert.ThrowsAnyAsync( + () => sut.TranscribeAsync( + wavAudio, + null, + translate: false, + prompt: null, + cancellation.Token ) - .ReturnsAsync( - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"results\":[]}"), - } - ); + ); - using var sut = new GoogleCloudSttPlugin(handler.Object); - await sut.ActivateAsync(host.Object); + Assert.True(cancellation.IsCancellationRequested); + Assert.Single(handler.Requests); + } - var wavAudio = BuildFfmpegStyleWav(60 * 32000); + [Fact] + public async Task TranscribeAsync_EveryChunkRequestStaysBelowProviderLimits() + { + var handler = CreateSuccessfulHandler(); + using var sut = await CreateConfiguredPluginAsync(handler); + var wavAudio = BuildFfmpegStyleWav(181 * BytesPerSecond); - var result = await sut.TranscribeAsync(wavAudio, null, false, null, CancellationToken.None); + await sut.TranscribeAsync( + wavAudio, + null, + translate: false, + prompt: null, + CancellationToken.None + ); - Assert.NotNull(result); - handler - .Protected() - .Verify( - "SendAsync", - Times.Once(), - ItExpr.IsAny(), - ItExpr.IsAny() - ); + Assert.True(handler.Requests.Count >= 4); + Assert.All(handler.Requests, AssertRequestWithinProviderLimits); + } + + // Single-chunk transport regression guard: segmentation must not alter the + // established v1 route, API-key query authentication, or recognition config. + [Fact] + public async Task TranscribeAsync_SingleChunk_PreservesV1RouteApiKeyAndRecognitionConfig() + { + var handler = new CapturingHandler((_, _, _) => + Task.FromResult( + JsonResponse(RecognitionResponse("Guten Tag", "1.500s", "de-DE")) + ) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + var wavAudio = BuildFfmpegStyleWav(BytesPerSecond, amplitude: 400); + var pcmOffset = wavAudio.Length - BytesPerSecond; + + var result = await sut.TranscribeAsync( + wavAudio, + "de", + translate: false, + prompt: "unchanged ignored prompt", + CancellationToken.None + ); + + var request = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Post.Method, request.Method); + Assert.Equal( + "https://speech.googleapis.com/v1/speech:recognize?key=dummy-key", + request.Uri + ); + Assert.Null(request.Authorization); + Assert.Equal("application/json", request.MediaType); + Assert.Equal("LINEAR16", request.AudioEncoding); + Assert.Equal(SampleRateHertz, request.SampleRateHertz); + Assert.Equal("de-DE", request.LanguageCode); + Assert.Equal("latest_long", request.Model); + Assert.Equal( + wavAudio.AsSpan(pcmOffset, BytesPerSecond).ToArray(), + request.Audio + ); + Assert.Equal("Guten Tag", result.Text); + Assert.Equal("de-DE", result.DetectedLanguage); + Assert.Equal(1.5, result.DurationSeconds); + } + + private static void AssertRequestWithinProviderLimits(CapturedRequest request) + { + Assert.True(request.Audio.Length < ProviderDurationLimitBytes); + Assert.True(request.BodyByteCount < ProviderRequestLimitBytes); + } + + private static CapturingHandler CreateSuccessfulHandler() => + new((_, _, _) => Task.FromResult(JsonResponse("""{"results":[]}"""))); + + private static async Task CreateConfiguredPluginAsync( + CapturingHandler handler + ) + { + var host = new Mock(); + host.Setup(service => service.LoadSecretAsync("api-key")).ReturnsAsync("dummy-key"); + + var sut = new GoogleCloudSttPlugin(handler); + await sut.ActivateAsync(host.Object); + return sut; + } + + private static string RecognitionResponse( + string transcript, + string billedTime, + string detectedLanguage + ) => + JsonSerializer.Serialize( + new + { + results = new[] + { + new + { + alternatives = new[] { new { transcript } }, + languageCode = detectedLanguage, + }, + }, + totalBilledTime = billedTime, + } + ); + + private static HttpResponseMessage JsonResponse( + string json, + HttpStatusCode statusCode = HttpStatusCode.OK, + Action? afterContentRead = null + ) + { + HttpContent content = + afterContentRead is null + ? new StringContent(json, Encoding.UTF8, "application/json") + : new CallbackJsonContent(json, afterContentRead); + return new HttpResponseMessage(statusCode) { Content = content }; } // Mirrors ffmpeg's `-f wav pipe:1` output: RIFF/WAVE + fmt + LIST(INFO) + data, // with 0xffffffff placeholder sizes (a pipe can't be seeked to backfill them). - private static byte[] BuildFfmpegStyleWav(int dataBytes) + private static byte[] BuildFfmpegStyleWav(int dataBytes, short amplitude = 0) { var listBody = "INFOISFT\u000e\0\0\0Lavf62.12.102\0"u8.ToArray(); var buffer = new byte[12 + 24 + 8 + listBody.Length + 8 + dataBytes]; @@ -89,9 +331,9 @@ private static byte[] BuildFfmpegStyleWav(int dataBytes) BinaryPrimitives.WriteUInt32LittleEndian(span[(offset + 4)..], 16); BinaryPrimitives.WriteUInt16LittleEndian(span[(offset + 8)..], 1); // PCM BinaryPrimitives.WriteUInt16LittleEndian(span[(offset + 10)..], 1); // mono - BinaryPrimitives.WriteUInt32LittleEndian(span[(offset + 12)..], 16000); - BinaryPrimitives.WriteUInt32LittleEndian(span[(offset + 16)..], 32000); - BinaryPrimitives.WriteUInt16LittleEndian(span[(offset + 20)..], 2); + BinaryPrimitives.WriteUInt32LittleEndian(span[(offset + 12)..], SampleRateHertz); + BinaryPrimitives.WriteUInt32LittleEndian(span[(offset + 16)..], BytesPerSecond); + BinaryPrimitives.WriteUInt16LittleEndian(span[(offset + 20)..], BytesPerSample); BinaryPrimitives.WriteUInt16LittleEndian(span[(offset + 22)..], 16); offset += 24; @@ -102,7 +344,98 @@ private static byte[] BuildFfmpegStyleWav(int dataBytes) "data"u8.CopyTo(span[offset..]); BinaryPrimitives.WriteUInt32LittleEndian(span[(offset + 4)..], 0xFFFFFFFF); + offset += 8; + + if (amplitude != 0) + { + for (var sampleOffset = offset; sampleOffset < buffer.Length; sampleOffset += 2) + { + BinaryPrimitives.WriteInt16LittleEndian( + span.Slice(sampleOffset, BytesPerSample), + amplitude + ); + } + } return buffer; } + + private sealed record CapturedRequest( + string Method, + string Uri, + string? Authorization, + string? MediaType, + int BodyByteCount, + byte[] Audio, + string AudioEncoding, + // ReSharper disable once MemberHidesStaticFromOuterClass -- captured request field, only read as request.SampleRateHertz; the name mirrors the outer const deliberately. + int SampleRateHertz, + string LanguageCode, + string Model + ); + + private sealed class CapturingHandler( + Func> responder + ) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + var body = await Assert + .IsAssignableFrom(request.Content) + .ReadAsStringAsync(cancellationToken); + using var json = JsonDocument.Parse(body); + var root = json.RootElement; + var config = root.GetProperty("config"); + var captured = new CapturedRequest( + request.Method.Method, + request.RequestUri?.AbsoluteUri ?? string.Empty, + request.Headers.Authorization?.ToString(), + request.Content?.Headers.ContentType?.MediaType, + Encoding.UTF8.GetByteCount(body), + Convert.FromBase64String(root.GetProperty("audio").GetProperty("content").GetString()!), + config.GetProperty("encoding").GetString()!, + config.GetProperty("sampleRateHertz").GetInt32(), + config.GetProperty("languageCode").GetString()!, + config.GetProperty("model").GetString()! + ); + Requests.Add(captured); + return await responder(Requests.Count, captured, cancellationToken); + } + } + + private sealed class CallbackJsonContent : HttpContent + { + private readonly Action _afterContentRead; + private readonly byte[] _content; + + public CallbackJsonContent(string json, Action afterContentRead) + { + _content = Encoding.UTF8.GetBytes(json); + _afterContentRead = afterContentRead; + Headers.ContentType = new MediaTypeHeaderValue("application/json") + { + CharSet = "utf-8", + }; + } + + protected override async Task SerializeToStreamAsync( + Stream stream, + TransportContext? context + ) + { + await stream.WriteAsync(_content); + _afterContentRead(); + } + + protected override bool TryComputeLength(out long length) + { + length = _content.Length; + return true; + } + } } From 7ccc7e08df7d752ecf83876e7cc9de0a464add8b Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 02:14:13 +0000 Subject: [PATCH 176/226] Honor cancellation around Sherpa native decode and CUDA provisioning Transcription passed the caller's token only to Task.Run, so once the synchronous native Decode started nothing observed cancellation and _sync stayed held until the whole recording finished. Cancelling CUDA provisioning was worse: the OperationCanceledException fell into the general fallback catch, which silently rewrote the compute backend to CPU while the saved acceleration preference still said CUDA. Provisioning now rethrows token-associated cancellation before the fallback catch, leaving backend, preference, selection, and status untouched. Decode moves into SherpaDecodeCoordinator with an injectable decode delegate: audio over fifteen seconds is decoded in silence-aware chunks (the cut lands on the lowest-energy 20 ms window in the final two seconds, with 500 ms of overlap), chunk texts are stitched by longest token suffix/prefix match, Canary payloads are parsed per chunk and aggregated, and cancellation is checked before and after every native call and before publication. Short recordings keep the previous single-call path. sherpa-onnx 1.12.23 exposes only a synchronous Decode, so checkpoints cannot interrupt one in-flight native call - chunking bounds that uncancellable window instead. _sync is held for the whole decode transaction and releases promptly on cancellation. --- .../SherpaDecodeCoordinator.cs | 203 ++++++++++++++ .../SherpaOnnxPlugin.cs | 93 +++---- .../SherpaOnnxCancellationTests.cs | 249 ++++++++++++++++++ 3 files changed, 501 insertions(+), 44 deletions(-) create mode 100644 plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaDecodeCoordinator.cs create mode 100644 tests/TypeWhisper.PluginSystem.Tests/SherpaOnnxCancellationTests.cs diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaDecodeCoordinator.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaDecodeCoordinator.cs new file mode 100644 index 000000000..798d568fa --- /dev/null +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaDecodeCoordinator.cs @@ -0,0 +1,203 @@ +using System.Text.Json; + +namespace TypeWhisper.Plugin.SherpaOnnx; + +internal delegate string SherpaDecodeDelegate(float[] audioSamples); + +internal readonly record struct SherpaDecodeResult(string Text, string? DetectedLanguage); + +internal sealed class SherpaDecodeCoordinator +{ + internal const int SampleRate = 16000; + internal const int MaximumChunkDurationSeconds = 15; + internal const int MaximumChunkSampleCount = SampleRate * MaximumChunkDurationSeconds; + + private const int BoundarySearchDurationSeconds = 2; + private const int BoundarySearchSampleCount = SampleRate * BoundarySearchDurationSeconds; + private const int EnergyWindowMilliseconds = 20; + private const int EnergyWindowSampleCount = SampleRate * EnergyWindowMilliseconds / 1000; + private const int EnergySearchStrideMilliseconds = 10; + private const int EnergySearchStrideSampleCount = + SampleRate * EnergySearchStrideMilliseconds / 1000; + private const int OverlapDurationMilliseconds = 500; + private const int OverlapSampleCount = SampleRate * OverlapDurationMilliseconds / 1000; + + private readonly SherpaDecodeDelegate _decode; + + internal SherpaDecodeCoordinator(SherpaDecodeDelegate decode) + { + ArgumentNullException.ThrowIfNull(decode); + _decode = decode; + } + + internal SherpaDecodeResult Decode( + float[] audioSamples, + bool parseCanaryPayload, + CancellationToken ct + ) + { + ArgumentNullException.ThrowIfNull(audioSamples); + ct.ThrowIfCancellationRequested(); + + string? stitchedText = null; + string? detectedLanguage = null; + foreach (var chunk in CreateChunks(audioSamples, ct)) + { + // sherpa-onnx 1.12.23 exposes only a synchronous Decode call. These + // checkpoints cannot interrupt that call, but chunking bounds normal + // uncancellable work and stops before the next native invocation. + ct.ThrowIfCancellationRequested(); + var rawText = _decode(chunk); + ct.ThrowIfCancellationRequested(); + + var result = parseCanaryPayload + ? ParseCanaryResult(rawText) + : new SherpaDecodeResult(rawText.Trim(), null); + stitchedText = stitchedText is null + ? result.Text + : StitchTokenOverlap(stitchedText, result.Text); + detectedLanguage ??= result.DetectedLanguage; + } + + // Do not publish a completed aggregate after cancellation raced the final + // chunk's parsing/stitching work. + ct.ThrowIfCancellationRequested(); + return new SherpaDecodeResult(stitchedText ?? string.Empty, detectedLanguage); + } + + private static IEnumerable CreateChunks( + float[] audioSamples, + CancellationToken ct + ) + { + ct.ThrowIfCancellationRequested(); + + // Preserve the existing single-call path for short recordings, including an + // empty recording. Only long audio pays the copy/overlap cost. + if (audioSamples.Length <= MaximumChunkSampleCount) + { + yield return audioSamples; + yield break; + } + + var start = 0; + while (audioSamples.Length - start > MaximumChunkSampleCount) + { + ct.ThrowIfCancellationRequested(); + var hardEnd = start + MaximumChunkSampleCount; + var cut = FindLowEnergyCut(audioSamples, start, hardEnd); + yield return audioSamples.AsSpan(start, cut - start).ToArray(); + start = cut - OverlapSampleCount; + } + + ct.ThrowIfCancellationRequested(); + yield return audioSamples.AsSpan(start).ToArray(); + } + + private static int FindLowEnergyCut(float[] audioSamples, int start, int hardEnd) + { + var searchStart = Math.Max( + start + OverlapSampleCount + EnergyWindowSampleCount, + hardEnd - BoundarySearchSampleCount + ); + var halfWindow = EnergyWindowSampleCount / 2; + var bestCut = hardEnd; + var bestEnergy = double.MaxValue; + + for ( + var candidate = searchStart; + candidate <= hardEnd; + candidate += EnergySearchStrideSampleCount + ) + { + var windowStart = Math.Max(start, candidate - halfWindow); + var windowEnd = Math.Min(audioSamples.Length, candidate + halfWindow); + double energy = 0; + for (var i = windowStart; i < windowEnd; i++) + energy += audioSamples[i] * audioSamples[i]; + + energy /= Math.Max(1, windowEnd - windowStart); + if (energy < bestEnergy) + { + bestEnergy = energy; + bestCut = candidate; + } + } + + return bestCut; + } + + private static string StitchTokenOverlap(string accumulated, string next) + { + if (string.IsNullOrWhiteSpace(accumulated)) + return next.Trim(); + if (string.IsNullOrWhiteSpace(next)) + return accumulated.Trim(); + + var accumulatedTokens = SplitTokens(accumulated); + var nextTokens = SplitTokens(next); + var maximumOverlap = Math.Min(accumulatedTokens.Length, nextTokens.Length); + var overlap = 0; + + for (var length = maximumOverlap; length > 0; length--) + { + var matches = true; + for (var i = 0; i < length; i++) + { + if ( + !string.Equals( + accumulatedTokens[accumulatedTokens.Length - length + i], + nextTokens[i], + StringComparison.Ordinal + ) + ) + { + matches = false; + break; + } + } + + if (matches) + { + overlap = length; + break; + } + } + + return string.Join(' ', accumulatedTokens.Concat(nextTokens.Skip(overlap))); + } + + private static string[] SplitTokens(string text) => + text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + + private static SherpaDecodeResult ParseCanaryResult(string rawText) + { + if (string.IsNullOrWhiteSpace(rawText)) + return new SherpaDecodeResult(string.Empty, null); + + try + { + using var json = JsonDocument.Parse(rawText); + if (json.RootElement.ValueKind != JsonValueKind.Object) + return new SherpaDecodeResult(rawText.Trim(), null); + + var text = rawText.Trim(); + if (json.RootElement.TryGetProperty("text", out var textNode)) + text = textNode.GetString()?.Trim() ?? string.Empty; + + string? language = null; + if (json.RootElement.TryGetProperty("lang", out var languageNode)) + { + var parsed = languageNode.GetString(); + if (!string.IsNullOrWhiteSpace(parsed)) + language = parsed; + } + + return new SherpaDecodeResult(text, language); + } + catch (JsonException) + { + return new SherpaDecodeResult(rawText.Trim(), null); + } + } +} diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs index b804f8951..05a81e1f9 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs @@ -2,7 +2,6 @@ using System.Globalization; using System.Runtime.InteropServices; using System.Text; -using System.Text.Json; using SherpaOnnx; using TypeWhisper.Plugins.Shared.Cuda; using TypeWhisper.Plugins.Shared.Net; @@ -383,6 +382,10 @@ public async Task LoadModelAsync(string modelId, IProgress? progress, Ca { await EnsureCudaRuntimeReadyAsync(progress, ct).ConfigureAwait(false); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } catch (Exception ex) { _host?.Log( @@ -649,8 +652,10 @@ CancellationToken ct return Task.Run( () => { + ct.ThrowIfCancellationRequested(); var audioSamples = DecodeWav(wavAudio); var audioDuration = audioSamples.Length / 16000.0; + ct.ThrowIfCancellationRequested(); lock (_sync) { @@ -664,19 +669,25 @@ CancellationToken ct if (model.SupportsTranslation) EnsureCanaryLanguage(language, translate); - using var stream = _recognizer.CreateStream(); - stream.AcceptWaveform(16000, audioSamples); - _recognizer.Decode(stream); - - var rawText = stream.Result.Text.Trim(); - - var (text, detectedLanguage) = model.SupportsTranslation - ? ParseCanaryResult(rawText) - : (rawText, (string?)null); + var coordinator = new SherpaDecodeCoordinator(chunk => + { + using var stream = _recognizer.CreateStream(); + stream.AcceptWaveform(SherpaDecodeCoordinator.SampleRate, chunk); + ct.ThrowIfCancellationRequested(); + _recognizer.Decode(stream); + ct.ThrowIfCancellationRequested(); + return stream.Result.Text; + }); + var decoded = coordinator.Decode( + audioSamples, + model.SupportsTranslation, + ct + ); + ct.ThrowIfCancellationRequested(); return new PluginTranscriptionResult( - text, - detectedLanguage, + decoded.Text, + decoded.DetectedLanguage, audioDuration, NoSpeechProbability: null ); @@ -775,6 +786,32 @@ internal void SetHostForTests(IPluginHostServices host) internal void RunArtifactPreflightForTests(string modelId, string modelDir) => VerifyModelArtifacts(GetModelDefinition(modelId), modelDir); + internal string ComputeBackendForTests + { + get + { + lock (_sync) + return _computeBackend; + } + } + + // Test seam: exercise the production lock boundary with a managed delegate, so + // cancellation and lock release need no native runtime. + internal SherpaDecodeResult RunDecodeTransactionForTests( + float[] audioSamples, + bool parseCanaryPayload, + SherpaDecodeDelegate decode, + CancellationToken ct + ) + { + lock (_sync) + return new SherpaDecodeCoordinator(decode).Decode( + audioSamples, + parseCanaryPayload, + ct + ); + } + private static ModelDefinition GetModelDefinition(string modelId) => s_models.FirstOrDefault(m => m.Id == modelId) ?? throw new ArgumentException($"Unknown model: {modelId}"); @@ -1184,38 +1221,6 @@ private static string NormalizeCanaryLanguage(string? language) return s_canarySupportedLanguages.Contains(normalized) ? normalized : "en"; } - private static (string Text, string? DetectedLanguage) ParseCanaryResult(string rawText) - { - if (string.IsNullOrWhiteSpace(rawText)) - return (string.Empty, null); - - try - { - using var json = JsonDocument.Parse(rawText); - if (json.RootElement.ValueKind != JsonValueKind.Object) - return (rawText.Trim(), null); - - var text = rawText.Trim(); - if (json.RootElement.TryGetProperty("text", out var textNode)) - text = textNode.GetString()?.Trim() ?? string.Empty; - - string? lang = null; - // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. - if (json.RootElement.TryGetProperty("lang", out var langNode)) - { - var parsed = langNode.GetString(); - if (!string.IsNullOrWhiteSpace(parsed)) - lang = parsed; - } - - return (text, lang); - } - catch (JsonException) - { - return (rawText.Trim(), null); - } - } - private static float[] DecodeWav(byte[] wavData) { if (wavData.Length < 44) diff --git a/tests/TypeWhisper.PluginSystem.Tests/SherpaOnnxCancellationTests.cs b/tests/TypeWhisper.PluginSystem.Tests/SherpaOnnxCancellationTests.cs new file mode 100644 index 000000000..f49676794 --- /dev/null +++ b/tests/TypeWhisper.PluginSystem.Tests/SherpaOnnxCancellationTests.cs @@ -0,0 +1,249 @@ +extern alias SherpaOnnx; + +using System.Runtime.InteropServices; +using Moq; +using SherpaOnnx::TypeWhisper.Plugin.SherpaOnnx; +using SherpaCuda = SherpaOnnx::TypeWhisper.Plugins.Shared.Cuda; +using TypeWhisper.PluginSDK; +using TypeWhisper.PluginSDK.Models; + +namespace TypeWhisper.PluginSystem.Tests; + +public sealed class SherpaOnnxCancellationTests +{ + // Regression: before the fix, cancellation entered the general CUDA fallback + // catch, changed the backend to CPU, and continued toward recognizer creation. + [Fact] + public async Task LoadModelAsync_ProvisioningCancellation_PreservesCudaState() + { + if (!OperatingSystem.IsLinux() || RuntimeInformation.ProcessArchitecture != Architecture.X64) + return; + + using var temp = new TempAssetDirectory(); + using var plugin = new SherpaOnnxPlugin(); + var provisioner = new CancelableProvisioner(); + plugin.SetCudaDependenciesForTests(provisioner, new NoopInstaller(temp.Path)); + plugin.SetHostForTests(CreateHost(temp.Path).Object); + plugin.SetParakeetRecognizerFactoryForTests( + (_, _) => throw new InvalidOperationException( + "Recognizer creation must not run after cancellation." + ) + ); + WriteParakeetModelFiles(temp.Path); + plugin.SelectModel("parakeet-tdt-0.6b"); + plugin.SetAccelerationPreference(TranscriptionAccelerationPreference.NvidiaCuda); + + var preferenceBefore = plugin.AccelerationPreference; + var statusBefore = plugin.AccelerationStatus; + using var cts = new CancellationTokenSource(); + var loadTask = plugin.LoadModelAsync("parakeet-tdt-0.6b", cts.Token); + + await provisioner.Started; + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => loadTask); + Assert.Equal("cuda", plugin.ComputeBackendForTests); + Assert.Equal(preferenceBefore, plugin.AccelerationPreference); + Assert.Equal(statusBefore, plugin.AccelerationStatus); + Assert.Equal("parakeet-tdt-0.6b", plugin.SelectedModelId); + } + + // New-contract test: the coordinator did not exist before this fix. It pins the + // checkpoint immediately after each synchronous native-call delegate returns. + [Fact] + public void Decode_CancellationAfterFirstChunk_PreventsLaterDelegateCalls() + { + using var cts = new CancellationTokenSource(); + var calls = 0; + var coordinator = new SherpaDecodeCoordinator(_ => + { + calls++; + // ReSharper disable once AccessToDisposedClosure -- lambda runs synchronously inside coordinator.Decode below, before the `using var cts` is disposed at scope end. + cts.Cancel(); + return "first chunk"; + }); + var audio = new float[SherpaDecodeCoordinator.MaximumChunkSampleCount + 1]; + + Assert.ThrowsAny( + () => coordinator.Decode(audio, parseCanaryPayload: false, cts.Token) + ); + Assert.Equal(1, calls); + } + + // New-contract test: cancellation must unwind the plugin's production _sync + // transaction so unload/configuration work cannot remain blocked. + [Fact] + public async Task DecodeTransaction_Cancellation_ReleasesSync() + { + using var plugin = new SherpaOnnxPlugin(); + using var cts = new CancellationTokenSource(); + var audio = new float[SherpaDecodeCoordinator.MaximumChunkSampleCount + 1]; + + // ReSharper disable once MethodSupportsCancellation -- the delegate must run and cancel from within; passing cts.Token to Task.Run would cancel scheduling instead. + // ReSharper disable AccessToDisposedClosure -- the task is awaited via Assert.ThrowsAnyAsync below, so the closure completes before the `using var plugin`/`using var cts` are disposed at scope end. + var canceledDecode = Task.Run( + () => + plugin.RunDecodeTransactionForTests( + audio, + parseCanaryPayload: false, + _ => + { + cts.Cancel(); + return "first chunk"; + }, + cts.Token + ) + ); + // ReSharper restore AccessToDisposedClosure + await Assert.ThrowsAnyAsync(() => canceledDecode); + + // ReSharper disable once MethodSupportsCancellation -- no ambient token belongs here; this decode uses CancellationToken.None to prove the lock was released, so a token would only cancel scheduling. + // ReSharper disable AccessToDisposedClosure -- the task is awaited via WaitAsync below, so the closure completes before the `using var plugin` is disposed at scope end. + var nextDecode = Task.Run( + () => + plugin.RunDecodeTransactionForTests( + [], + parseCanaryPayload: false, + _ => "lock released", + CancellationToken.None + ) + ); + // ReSharper restore AccessToDisposedClosure + // ReSharper disable once MethodSupportsCancellation -- deliberately a time-bounded hang guard; a token isn't wanted here. + var result = await nextDecode.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.Equal("lock released", result.Text); + } + + // New-contract test: chunking bounds every synchronous delegate invocation to + // the named 15-second maximum. + [Fact] + public void Decode_LongAudio_EveryChunkRespectsNamedMaximum() + { + var chunkLengths = new List(); + var coordinator = new SherpaDecodeCoordinator(chunk => + { + chunkLengths.Add(chunk.Length); + return string.Empty; + }); + var audio = new float[SherpaDecodeCoordinator.MaximumChunkSampleCount * 3 + 123]; + + _ = coordinator.Decode(audio, parseCanaryPayload: false, CancellationToken.None); + + Assert.Equal(15, SherpaDecodeCoordinator.MaximumChunkDurationSeconds); + Assert.True(chunkLengths.Count > 1); + Assert.All( + chunkLengths, + length => Assert.InRange( + length, + 1, + SherpaDecodeCoordinator.MaximumChunkSampleCount + ) + ); + } + + // New-contract test: crafted Canary payloads prove that every chunk is parsed + // before longest-token overlap stitching, preserving one copy of boundary text. + [Fact] + public void Decode_CanaryChunkOverlap_StitchesWithoutLossOrDuplication() + { + var payloads = new Queue( + [ + """{"text":"the quick brown fox","lang":"en"}""", + """{"text":"brown fox jumps high","lang":"en"}""", + ] + ); + var coordinator = new SherpaDecodeCoordinator(_ => payloads.Dequeue()); + var audio = new float[SherpaDecodeCoordinator.MaximumChunkSampleCount + 1]; + + var result = coordinator.Decode( + audio, + parseCanaryPayload: true, + CancellationToken.None + ); + + Assert.Equal("the quick brown fox jumps high", result.Text); + Assert.Equal("en", result.DetectedLanguage); + Assert.Empty(payloads); + } + + private static Mock CreateHost(string assetDirectory) + { + var host = new Mock(); + host.Setup(h => h.PluginDataDirectory).Returns(assetDirectory); + host.Setup(h => h.PluginAssetDirectory).Returns(assetDirectory); + return host; + } + + private static void WriteParakeetModelFiles(string assetDirectory) + { + var directory = Path.Join(assetDirectory, "Models", "parakeet-tdt-0.6b"); + Directory.CreateDirectory(directory); + foreach ( + var fileName in new[] + { + "encoder.int8.onnx", + "decoder.int8.onnx", + "joiner.int8.onnx", + } + ) + File.WriteAllBytes( + Path.Join(directory, fileName), + [0x08, 0x09, 0x3a, 0x02, 0x12, 0x00] + ); + + File.WriteAllText(Path.Join(directory, "tokens.txt"), " 0\n"); + } + + private sealed class CancelableProvisioner : SherpaCuda.CudaRuntimeProvisioner + { + private readonly TaskCompletionSource _started = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + internal CancelableProvisioner() + : base(Path.GetTempPath(), new HttpClient()) { } + + internal Task Started => _started.Task; + + public override async Task EnsureReadyAsync( + SherpaCuda.CudaRuntimeProfile profile, + IProgress? progress, + CancellationToken ct + ) + { + _started.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + } + } + + private sealed class NoopInstaller : SherpaCudaRuntimeInstaller + { + internal NoopInstaller(string root) + : base(root, new HttpClient()) { } + + public override Task EnsureInstalledAsync( + IProgress? progress, + CancellationToken ct + ) => Task.CompletedTask; + } + + private sealed class TempAssetDirectory : IDisposable + { + internal string Path { get; } = System.IO.Path.Join( + System.IO.Path.GetTempPath(), + "tw-sherpa-cancel-" + Guid.NewGuid().ToString("N") + ); + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + Directory.Delete(Path, recursive: true); + } + catch + { + // Best effort. + } + } + } +} From b84a0e17b83489fbb6d5067d3a4ee5153ff5a74b Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 02:24:41 +0000 Subject: [PATCH 177/226] Make concurrent Obsidian saves race-safe with atomic claims and a lock Individual notes used second-resolution default filenames and a check-then-write uniqueness helper, then wrote with an overwrite-capable call - two completions in the same second could both select the same absent path and one silently overwrote the other. Daily-note creation had the same existence/write race, and concurrent appends were entirely unsynchronized. Individual notes now build their full content first and claim a path atomically with FileMode.CreateNew/FileShare.None in a bounded suffix loop; only a CreateNew collision advances the suffix, other I/O failures propagate, and a failed or cancelled owned write deletes its partial file. Daily notes serialize create/append across processes with the shared InterProcessFileLock, named by the SHA-256 of the normalized absolute note path and stored under the plugin data directory so lock sentinels never appear as vault notes; the wait honors the caller's token. Appends allow concurrent readers (FileShare.Read) since the sentinel already serializes TypeWhisper writers, and a failed append truncates the note back to its pre-append length so no partial entry survives. The advisory lock coordinates TypeWhisper instances only - editors that ignore it remain outside its guarantees. --- .../ObsidianPlugin.cs | 240 +++++++++++++++-- .../TypeWhisper.Plugin.Obsidian.csproj | 7 + .../ObsidianPluginTests.cs | 243 ++++++++++++++++++ .../TypeWhisper.PluginSystem.Tests.csproj | 1 + 4 files changed, 463 insertions(+), 28 deletions(-) create mode 100644 tests/TypeWhisper.PluginSystem.Tests/ObsidianPluginTests.cs diff --git a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs index 41bc8972e..1e87d343a 100644 --- a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs @@ -5,17 +5,33 @@ // Plugin types are instantiated by the host via reflection and invoked through plugin interfaces // and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire. +using System.Security.Cryptography; using System.Text; using System.Text.Json; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Models; +using TypeWhisper.Plugins.Shared.Net; namespace TypeWhisper.Plugin.Obsidian; public sealed class ObsidianPlugin : IActionPlugin, IPluginSettingsProvider, IPluginLocalizationAware { + private const int MaxIndividualNotePathAttempts = 10_000; + private const int UnixFileExistsError = 17; + private const int WindowsFileExistsError = 80; + private const int WindowsAlreadyExistsError = 183; + + private readonly Func> _detectVaults; private List _detectedVaults = []; + public ObsidianPlugin() : this(DetectVaults) { } + + internal ObsidianPlugin(Func> detectVaults) + { + ArgumentNullException.ThrowIfNull(detectVaults); + _detectVaults = detectVaults; + } + public string PluginId => "com.typewhisper.obsidian"; public string PluginName => "Obsidian"; public string PluginVersion => "1.0.0"; @@ -40,7 +56,7 @@ public void SetLocalization(IPluginLocalization localization) => public Task ActivateAsync(IPluginHostServices host) { Host = host; - _detectedVaults = DetectVaults(); + _detectedVaults = _detectVaults(); return Task.CompletedTask; } @@ -77,8 +93,6 @@ CancellationToken ct string filePath; string filename; - // ReSharper disable once TooWideLocalVariableScope -- declared with its siblings; both branches assign it before the shared use below. - string content; if (dailyNoteMode) { @@ -86,26 +100,18 @@ CancellationToken ct filePath = Path.Join(targetDir, filename); var entry = BuildDailyNoteEntry(input, context, now); - - if (File.Exists(filePath)) - { - await File.AppendAllTextAsync(filePath, entry, Encoding.UTF8, ct); - } - else - { - var header = $"# {now:yyyy-MM-dd}\n\n"; - await File.WriteAllTextAsync(filePath, header + entry, Encoding.UTF8, ct); - } + var header = $"# {now:yyyy-MM-dd}\n\n"; + var lockPath = GetDailyNoteLockPath(Host.PluginDataDirectory, filePath); + await WriteDailyNoteAsync(filePath, lockPath, header, entry, ct); } else { filename = BuildFilename(filenameTemplate, context, now) + ".md"; filePath = Path.Join(targetDir, filename); - filePath = EnsureUniqueFilePath(filePath); - filename = Path.GetFileName(filePath); - content = BuildNoteContent(input, context, now); - await File.WriteAllTextAsync(filePath, content, Encoding.UTF8, ct); + var content = BuildNoteContent(input, context, now); + filePath = await WriteIndividualNoteAsync(filePath, content, ct); + filename = Path.GetFileName(filePath); } Host.Log(PluginLogLevel.Info, $"Saved transcription to {filePath}"); @@ -185,24 +191,202 @@ private static string SanitizeFilename(string filename) return string.IsNullOrWhiteSpace(result) ? "Transcription" : result; } - private static string EnsureUniqueFilePath(string filePath) + private static Task WriteIndividualNoteAsync( + string filePath, + string content, + CancellationToken ct + ) => + WriteIndividualNoteAsync(filePath, content, ct, WriteUtf8TextAsync); + + internal static async Task WriteIndividualNoteAsync( + string filePath, + string content, + CancellationToken ct, + Func writeAsync + ) { - if (!File.Exists(filePath)) - return filePath; - var dir = Path.GetDirectoryName(filePath)!; var nameWithoutExt = Path.GetFileNameWithoutExtension(filePath); var ext = Path.GetExtension(filePath); - var counter = 2; - string candidate; - do + for (var attempt = 0; attempt < MaxIndividualNotePathAttempts; attempt++) + { + var candidate = attempt == 0 + ? filePath + : Path.Join(dir, $"{nameWithoutExt} {attempt + 1}{ext}"); + FileStream claimedStream; + + try + { + claimedStream = new FileStream( + candidate, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + FileOptions.Asynchronous + ); + } + catch (IOException ex) when (IsCreateNewCollision(ex)) + { + continue; + } + + try + { + await using (claimedStream) + { + await writeAsync(claimedStream, content, ct); + await claimedStream.FlushAsync(ct); + } + + return candidate; + } + catch + { + TryDeleteOwnedFile(candidate); + throw; + } + } + + throw new IOException( + $"Could not create a unique Obsidian note after {MaxIndividualNotePathAttempts} attempts." + ); + } + + internal static string GetDailyNoteLockPath(string pluginDataDirectory, string notePath) + { + var normalizedNotePath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(notePath)); + if (OperatingSystem.IsWindows()) + normalizedNotePath = normalizedNotePath.ToUpperInvariant(); + + var hash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(normalizedNotePath)) + ); + return Path.Join(pluginDataDirectory, "locks", $"{hash}.lock"); + } + + private static async Task WriteDailyNoteAsync( + string filePath, + string lockPath, + string header, + string entry, + CancellationToken ct + ) + { + Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + + await using (await InterProcessFileLock.AcquireAsync(lockPath, ct)) { - candidate = Path.Join(dir, $"{nameWithoutExt} {counter}{ext}"); - counter++; - } while (File.Exists(candidate)); + if (File.Exists(filePath)) + { + // The sentinel already serializes TypeWhisper writers, so allow + // read sharing: an editor/sync client holding the note open for + // reading must not turn this append into a sharing violation. + var originalLength = new FileInfo(filePath).Length; + try + { + await using var appendStream = new FileStream( + filePath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + FileOptions.Asynchronous + ); + await WriteUtf8TextAsync(appendStream, entry, ct); + await appendStream.FlushAsync(ct); + } + catch + { + // Roll the note back to its pre-append length so a failed or + // cancelled write leaves no partial entry behind. + TryTruncateFile(filePath, originalLength); + throw; + } + + return; + } + + FileStream claimedStream = new( + filePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + FileOptions.Asynchronous + ); - return candidate; + try + { + await using (claimedStream) + { + await WriteUtf8TextAsync(claimedStream, header + entry, ct); + await claimedStream.FlushAsync(ct); + } + } + catch + { + TryDeleteOwnedFile(filePath); + throw; + } + } + } + + private static async Task WriteUtf8TextAsync( + FileStream stream, + string content, + CancellationToken ct + ) + { + await using var writer = new StreamWriter( + stream, + Encoding.UTF8, + bufferSize: 1024, + leaveOpen: true + ); + await writer.WriteAsync(content.AsMemory(), ct); + await writer.FlushAsync(ct); + } + + private static bool IsCreateNewCollision(IOException exception) + { + var errorCode = exception.HResult & 0xFFFF; + return errorCode is + UnixFileExistsError + or WindowsFileExistsError + or WindowsAlreadyExistsError; + } + + private static void TryDeleteOwnedFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Preserve the original cancellation/write failure if best-effort cleanup fails. + } + } + + private static void TryTruncateFile(string path, long length) + { + try + { + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Write, + FileShare.None + ); + if (stream.Length > length) + stream.SetLength(length); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Preserve the original cancellation/write failure if best-effort cleanup fails. + } } // ReSharper disable once UseVerbatimString -- the mixed backslash/quote escapes read no better as a verbatim string. diff --git a/plugins/TypeWhisper.Plugin.Obsidian/TypeWhisper.Plugin.Obsidian.csproj b/plugins/TypeWhisper.Plugin.Obsidian/TypeWhisper.Plugin.Obsidian.csproj index fb9679588..3037d7794 100644 --- a/plugins/TypeWhisper.Plugin.Obsidian/TypeWhisper.Plugin.Obsidian.csproj +++ b/plugins/TypeWhisper.Plugin.Obsidian/TypeWhisper.Plugin.Obsidian.csproj @@ -6,9 +6,16 @@ latest TypeWhisper.Plugin.Obsidian + + + + + + PreserveNewest diff --git a/tests/TypeWhisper.PluginSystem.Tests/ObsidianPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/ObsidianPluginTests.cs new file mode 100644 index 000000000..c3b893988 --- /dev/null +++ b/tests/TypeWhisper.PluginSystem.Tests/ObsidianPluginTests.cs @@ -0,0 +1,243 @@ +using System.Collections.Concurrent; +using System.Text; +using TypeWhisper.Plugin.Obsidian; +using TypeWhisper.PluginSDK; +using TypeWhisper.PluginSDK.Models; +using TypeWhisper.Tests; + +namespace TypeWhisper.PluginSystem.Tests; + +public sealed class ObsidianPluginTests : IDisposable +{ + private const string NotesSubfolder = "Test Notes"; + + private readonly string _tempDir = TestPaths.CreateTempDirectory( + "TypeWhisper.ObsidianPluginTests" + ); + + private string VaultDirectory => Path.Join(_tempDir, "vault"); + private string PluginDataDirectory => Path.Join(_tempDir, "plugin-data"); + private string NotesDirectory => Path.Join(VaultDirectory, NotesSubfolder); + + public ObsidianPluginTests() + { + Directory.CreateDirectory(VaultDirectory); + Directory.CreateDirectory(PluginDataDirectory); + } + + public void Dispose() + { + try + { + TestPaths.DeleteDirectory(_tempDir); + } + catch + { + /* best effort */ + } + } + + [Fact] + public async Task ConcurrentIndividualSaves_WithFixedStem_PreserveEveryInputInDistinctFiles() + { + const int saveCount = 32; + var (sut, _) = await CreatePluginAsync(dailyNoteMode: false); + var inputs = Enumerable.Range(0, saveCount) + .Select(index => $"individual-input-{index:D3}-{Guid.NewGuid():N}") + .ToArray(); + + var results = await RunConcurrentlyAsync( + inputs, + input => sut.ExecuteAsync(input, EmptyContext(), CancellationToken.None) + ); + + Assert.All(results, result => Assert.True(result.Success)); + var notePaths = Directory.GetFiles(NotesDirectory, "*.md"); + Assert.Equal(saveCount, notePaths.Length); + + var noteLines = await ReadAllLinesAsync(notePaths); + Assert.All(inputs, input => Assert.Equal(1, noteLines.Count(line => line == input))); + } + + [Fact] + public async Task ConcurrentDailyAppends_WriteOneHeaderAndEveryEntryExactlyOnce() + { + const int saveCount = 32; + var (sut, _) = await CreatePluginAsync(dailyNoteMode: true); + var inputs = Enumerable.Range(0, saveCount) + .Select(index => $"daily-input-{index:D3}-{Guid.NewGuid():N}") + .ToArray(); + + var results = await RunConcurrentlyAsync( + inputs, + input => sut.ExecuteAsync(input, EmptyContext(), CancellationToken.None) + ); + + Assert.All(results, result => Assert.True(result.Success)); + var notePath = Assert.Single(Directory.GetFiles(NotesDirectory, "*.md")); + var lines = await File.ReadAllLinesAsync(notePath); + Assert.Equal(1, lines.Count(line => line.StartsWith("# ", StringComparison.Ordinal))); + Assert.All(inputs, input => Assert.Equal(1, lines.Count(line => line == input))); + } + + [Fact] + public async Task DailyAppend_CanceledWhileWaitingForLock_ThrowsAndLeavesNoteUntouched() + { + var (sut, _) = await CreatePluginAsync(dailyNoteMode: true); + Directory.CreateDirectory(NotesDirectory); + var notePath = Path.Join(NotesDirectory, $"{DateTime.Now:yyyy-MM-dd}.md"); + const string originalContent = "# Existing daily note\n\nOriginal entry\n"; + await File.WriteAllTextAsync(notePath, originalContent, Encoding.UTF8); + + var lockPath = ObsidianPlugin.GetDailyNoteLockPath(PluginDataDirectory, notePath); + Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + await using var heldLock = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None + ); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + await Assert.ThrowsAnyAsync(() => + sut.ExecuteAsync("must-not-be-written", EmptyContext(), cancellation.Token) + ); + + // ReSharper disable once MethodSupportsCancellation -- the only token in scope is the already-canceled cancellation.Token; passing it would abort this verification read that must succeed. + Assert.Equal(originalContent, await File.ReadAllTextAsync(notePath, Encoding.UTF8)); + Assert.DoesNotContain( + Directory.EnumerateFiles(VaultDirectory, "*", SearchOption.AllDirectories), + path => Path.GetExtension(path).Equals(".lock", StringComparison.OrdinalIgnoreCase) + ); + } + + [Fact] + public async Task IndividualSave_FailedNewlyOwnedWrite_DeletesPartialFile() + { + Directory.CreateDirectory(NotesDirectory); + var notePath = Path.Join(NotesDirectory, "Failed Note.md"); + + var exception = await Assert.ThrowsAsync(() => + ObsidianPlugin.WriteIndividualNoteAsync( + notePath, + "complete content", + CancellationToken.None, + async (stream, _, ct) => + { + await stream.WriteAsync("partial"u8.ToArray(), ct); + throw new IOException("Injected write failure."); + } + ) + ); + + Assert.Equal("Injected write failure.", exception.Message); + Assert.False(File.Exists(notePath)); + Assert.Empty(Directory.EnumerateFiles(NotesDirectory)); + } + + private async Task<(ObsidianPlugin Plugin, TestPluginHostServices Host)> CreatePluginAsync( + bool dailyNoteMode + ) + { + var host = new TestPluginHostServices(PluginDataDirectory); + host.SetSetting("vault-path", VaultDirectory); + host.SetSetting("subfolder", NotesSubfolder); + host.SetSetting("daily-note-mode", dailyNoteMode); + host.SetSetting("filename-template", "Fixed Individual Note"); + + var plugin = new ObsidianPlugin(() => []); + await plugin.ActivateAsync(host); + return (plugin, host); + } + + private static ActionContext EmptyContext() => new(null, null, null, null, null); + + private static async Task RunConcurrentlyAsync( + string[] inputs, + Func> saveAsync + ) + { + using var ready = new CountdownEvent(inputs.Length); + using var start = new ManualResetEventSlim(initialState: false); + + var tasks = inputs.Select(input => + Task.Factory.StartNew( + async () => + { + // ReSharper disable AccessToDisposedClosure -- Task.WhenAll below awaits every task before the `using var ready`/`start` are disposed at scope end. + ready.Signal(); + start.Wait(); + // ReSharper restore AccessToDisposedClosure + return await saveAsync(input); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default + ).Unwrap() + ) + .ToArray(); + + Assert.True(ready.Wait(TimeSpan.FromSeconds(15))); + start.Set(); + return await Task.WhenAll(tasks); + } + + private static async Task> ReadAllLinesAsync(IEnumerable paths) + { + var lines = new ConcurrentBag(); + await Parallel.ForEachAsync( + paths, + async (path, ct) => + { + foreach (var line in await File.ReadAllLinesAsync(path, ct)) + lines.Add(line); + } + ); + return lines.ToList(); + } + + private sealed class TestPluginHostServices(string pluginDataDirectory) + : IPluginHostServices + { + private readonly Dictionary _settings = []; + + public string PluginDataDirectory { get; } = pluginDataDirectory; + public string? ActiveAppProcessName => null; + public string? ActiveAppName => null; + public IPluginEventBus EventBus { get; } = new TestPluginEventBus(); + public IReadOnlyList AvailableProfileNames => []; + public IPluginLocalization Localization { get; } = new TestPluginLocalization(); + + public Task StoreSecretAsync(string key, string value) => Task.CompletedTask; + public Task LoadSecretAsync(string key) => Task.FromResult(null); + public Task DeleteSecretAsync(string key) => Task.CompletedTask; + + public T? GetSetting(string key) => + _settings.TryGetValue(key, out var value) ? (T?)value : default; + + public void SetSetting(string key, T value) => _settings[key] = value; + public void Log(PluginLogLevel level, string message) { } + public void NotifyCapabilitiesChanged() { } + } + + private sealed class TestPluginLocalization : IPluginLocalization + { + public string CurrentLanguage => "en"; + public IReadOnlyList AvailableLanguages => ["en"]; + public string GetString(string key) => key; + public string GetString(string key, params object[] args) => key; + } + + private sealed class TestPluginEventBus : IPluginEventBus + { + public void Publish(T pluginEvent) where T : PluginEvent { } + + public IDisposable Subscribe(Func handler) where T : PluginEvent => + new NoOpDisposable(); + } + + private sealed class NoOpDisposable : IDisposable + { + public void Dispose() { } + } +} diff --git a/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj b/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj index 50737423e..811f41aed 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj +++ b/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj @@ -31,6 +31,7 @@ + true + + + diff --git a/tests/TypeWhisper.PluginSystem.Tests/GemmaLocalPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/GemmaLocalPluginTests.cs new file mode 100644 index 000000000..ea6581cb3 --- /dev/null +++ b/tests/TypeWhisper.PluginSystem.Tests/GemmaLocalPluginTests.cs @@ -0,0 +1,174 @@ +using System.Collections.Immutable; +using TypeWhisper.Plugin.GemmaLocal; +using TypeWhisper.PluginSDK.Models; + +namespace TypeWhisper.PluginSystem.Tests; + +public sealed class GemmaLocalPluginTests +{ + private const string ModelA = "gemma4-e2b-it-q4"; + private const string ModelB = "gemma4-e4b-it-q4"; + + [Fact] + public void SupportedModels_NoActiveModel_IsEmptyAndImmutable() + { + using var sut = new GemmaLocalPlugin(); + + var models = sut.SupportedModels; + + Assert.Empty(models); + Assert.IsType>(models); + } + + [Fact] + public void SupportedModels_ActiveModel_ContainsExactlyActiveModel() + { + using var sut = new GemmaLocalPlugin( + ModelA, + GemmaLocalPlugin.EnsureRequestedModelIsActive + ); + + var model = Assert.Single(sut.SupportedModels); + + Assert.Equal(ModelA, model.Id); + Assert.IsType>(sut.SupportedModels); + } + + [Fact] + public void EnsureRequestedModelIsActive_MatchingModel_IsAccepted() + { + var exception = Record.Exception( + () => GemmaLocalPlugin.EnsureRequestedModelIsActive(ModelA, ModelA) + ); + + Assert.Null(exception); + } + + [Fact] + public void EnsureRequestedModelIsActive_MismatchedModel_ThrowsWithBothModelIds() + { + var exception = Assert.Throws( + () => GemmaLocalPlugin.EnsureRequestedModelIsActive(ModelB, ModelA) + ); + + Assert.Equal( + $"Requested Gemma model '{ModelB}' does not match the active Gemma model '{ModelA}'.", + exception.Message + ); + } + + [Fact] + public void EnsureRequestedModelIsActive_UnknownModel_ThrowsWithRequestedAndActiveIds() + { + const string unknownModel = "not-a-gemma-model"; + + var exception = Assert.Throws( + () => GemmaLocalPlugin.EnsureRequestedModelIsActive(unknownModel, ModelA) + ); + + Assert.Equal( + $"Requested Gemma model '{unknownModel}' is unknown; " + + $"the active Gemma model is '{ModelA}'.", + exception.Message + ); + } + + [Fact] + public void EnsureRequestedModelIsActive_NoActiveModel_ThrowsWithRequestedAndNoActiveId() + { + var exception = Assert.Throws( + () => GemmaLocalPlugin.EnsureRequestedModelIsActive(ModelA, null) + ); + + Assert.Equal( + $"Requested Gemma model '{ModelA}' cannot run because " + + "the active Gemma model is '(none)'.", + exception.Message + ); + } + + [Fact] + public async Task ProcessAsync_InvokesRoutingGuardBeforeNativeInference() + { + var observation = new RoutingObservation(); + using var sut = CreatePluginWithRoutingProbe(observation); + + await Assert.ThrowsAsync( + () => sut.ProcessAsync("system", "user", ModelB, CancellationToken.None) + ); + + Assert.Equal(ModelB, observation.RequestedModelId); + Assert.Equal(ModelA, observation.ActiveModelId); + Assert.Equal(1, observation.CallCount); + } + + [Fact] + public async Task ProcessStreamingAsync_InvokesRoutingGuardBeforeNativeInference() + { + var observation = new RoutingObservation(); + using var sut = CreatePluginWithRoutingProbe(observation); + + await Assert.ThrowsAsync(async () => + { + await foreach ( + var _ in sut.ProcessStreamingAsync( + "system", + "user", + ModelB, + CancellationToken.None + ) + ) { } + }); + + Assert.Equal(ModelB, observation.RequestedModelId); + Assert.Equal(ModelA, observation.ActiveModelId); + Assert.Equal(1, observation.CallCount); + } + + [Fact] + public async Task ProcessStreamingAsync_WhenStreamingDisabled_DelegatesToGuardedBatchPath() + { + var observation = new RoutingObservation(); + using var sut = CreatePluginWithRoutingProbe(observation); + sut.SetStreamResponses(false); + + await Assert.ThrowsAsync(async () => + { + await foreach ( + var _ in sut.ProcessStreamingAsync( + "system", + "user", + ModelB, + CancellationToken.None + ) + ) { } + }); + + Assert.Equal(ModelB, observation.RequestedModelId); + Assert.Equal(ModelA, observation.ActiveModelId); + Assert.Equal(1, observation.CallCount); + } + + private static GemmaLocalPlugin CreatePluginWithRoutingProbe( + RoutingObservation observation + ) => + new( + ModelA, + (requestedModelId, activeModelId) => + { + observation.RequestedModelId = requestedModelId; + observation.ActiveModelId = activeModelId; + observation.CallCount++; + throw new RoutingGuardObservedException(); + } + ); + + private sealed class RoutingObservation + { + public string? RequestedModelId { get; set; } + public string? ActiveModelId { get; set; } + public int CallCount { get; set; } + } + + private sealed class RoutingGuardObservedException : Exception; +} diff --git a/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj b/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj index 811f41aed..34ca8a399 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj +++ b/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj @@ -25,6 +25,7 @@ + From ba3eee9dff5cd2983b8a43942e2e3dac33b795b9 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 04:10:55 +0000 Subject: [PATCH 181/226] Serialize ChatGPT OAuth refresh behind a single-flight credential gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expiry checking, token refresh, in-memory replacement, the individual secret and setting writes, and capability notification all ran without synchronization. Two requests near expiry could both refresh the same token - refresh-token rotation can revoke the grant when the old token is reused (RFC 9700 §4.14) - or interleave their persistence so the stored access/refresh/ID tokens and expiry came from different responses. OAuth state is now an immutable OAuthCredentialSnapshot replaced atomically behind a SemaphoreSlim gate. Token acquisition does a cheap pre-gate validity check, rechecks under the gate (a preceding waiter may already have refreshed), performs at most one refresh, and commits the complete in-memory and persisted snapshot before releasing - the gate is never held across the downstream ChatGPT request. Activation loading, browser-login commits, existing-login imports, and login clearing route through the same gate, and clearing accepts the caller's token so validation cancellation is not blocked behind an in-flight refresh. A refresh response that omits refresh_token still retains the previous one per RFC 6749 §6. Persistence is serialized in-process; the host offers no transactional multi-key store, so crash atomicity remains out of scope. --- .../TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs | 217 ++++++++++---- .../OpenAiPluginTests.cs | 274 +++++++++++++++++- 2 files changed, 426 insertions(+), 65 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs index 5a805d188..2c564a2f6 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs @@ -54,10 +54,8 @@ public sealed class OpenAiPlugin private string _selectedResponseFormat = "verbose_json"; private string? _selectedVoiceId; private List _fetchedLlmModels = []; - private string? _oauthAccessToken; - private string? _oauthRefreshToken; - private string? _oauthAccountId; - private DateTimeOffset? _oauthExpiresAt; + private readonly SemaphoreSlim _oauthCredentialGate = new(1, 1); + private OAuthCredentialSnapshot _oauthCredentials = OAuthCredentialSnapshot.Empty; private bool _forgetChatGptLogin; private bool _streamResponses = true; @@ -141,17 +139,32 @@ public async Task ActivateAsync(IPluginHostServices host) { _host = host; ApiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); - _oauthAccessToken = NormalizeApiKey(await host.LoadSecretAsync(OAuthAccessTokenSecretName)); - _oauthRefreshToken = NormalizeApiKey(await host.LoadSecretAsync(OAuthRefreshTokenSecretName)); + + await _oauthCredentialGate.WaitAsync(); + try + { + Volatile.Write( + ref _oauthCredentials, + new OAuthCredentialSnapshot( + NormalizeApiKey(await host.LoadSecretAsync(OAuthAccessTokenSecretName)), + NormalizeApiKey(await host.LoadSecretAsync(OAuthRefreshTokenSecretName)), + NormalizeApiKey(await host.LoadSecretAsync(OAuthIdTokenSecretName)), + host.GetSetting(OAuthAccountIdSettingName), + host.GetSetting(OAuthPlanTypeSettingName), + LoadExpiresAt(host) + )); + } + finally + { + _oauthCredentialGate.Release(); + } + AuthMode = OpenAiAuthModeExtensions.Parse(host.GetSetting(AuthModeSettingName)); SelectedLlmModelId = host.GetSetting(SelectedLlmModelSettingName); _selectedVoiceId = NormalizeVoiceId(host.GetSetting(SelectedVoiceSettingName)); TtsInstructions = host.GetSetting(TtsInstructionsSettingName) ?? ""; ReasoningEffort = NormalizeReasoningEffort(host.GetSetting(ReasoningEffortSettingName)); _fetchedLlmModels = host.GetSetting>(FetchedLlmModelsSettingName) ?? []; - _oauthAccountId = host.GetSetting(OAuthAccountIdSettingName); - ChatGptPlanType = host.GetSetting(OAuthPlanTypeSettingName); - _oauthExpiresAt = LoadExpiresAt(host); TemperatureMode = NormalizeTemperatureMode(host.GetSetting(TemperatureModeSettingName)); TemperatureValue = NormalizeTemperatureValue(host.GetSetting(TemperatureValueSettingName)); _streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true; @@ -291,8 +304,12 @@ CancellationToken ct if (AuthMode == OpenAiAuthMode.ChatGpt) { - var accessToken = await ValidOAuthAccessTokenAsync(ct); - var client = new OpenAiChatGptClient(_httpClient, accessToken, _oauthAccountId); + var credentials = await ValidOAuthCredentialsAsync(ct); + var client = new OpenAiChatGptClient( + _httpClient, + credentials.AccessToken!, + credentials.AccountId + ); return await client.ProcessAsync( systemPrompt, userText, @@ -429,11 +446,17 @@ public async Task SpeakAsync(TtsSpeakRequest request, Cance internal OpenAiAuthMode AuthMode { get; private set; } = OpenAiAuthMode.ApiKey; - internal bool HasChatGptCredentials => - !string.IsNullOrWhiteSpace(_oauthRefreshToken) - || !string.IsNullOrWhiteSpace(_oauthAccessToken); + internal bool HasChatGptCredentials + { + get + { + var credentials = Volatile.Read(ref _oauthCredentials); + return !string.IsNullOrWhiteSpace(credentials.RefreshToken) + || !string.IsNullOrWhiteSpace(credentials.AccessToken); + } + } - internal string? ChatGptPlanType { get; private set; } + internal string? ChatGptPlanType => Volatile.Read(ref _oauthCredentials).PlanType; internal string? SelectedLlmModelId { get; private set; } @@ -689,7 +712,7 @@ internal async Task LoginWithChatGptInBrowserAsync(CancellationToken ct = defaul var code = await server.WaitForCodeAsync(ct); var tokens = await OpenAiOAuthClient.ExchangeAuthorizationCodeAsync(_httpClient, code, pkce, ct); - await StoreOAuthTokensAsync(tokens, preferredAccountId: null); + await StoreOAuthTokensAsync(tokens, preferredAccountId: null, ct: ct); SetAuthMode(OpenAiAuthMode.ChatGpt); } @@ -718,23 +741,16 @@ internal async Task ImportExistingLoginAsync(string? authFilePath = null) SetAuthMode(OpenAiAuthMode.ChatGpt); } - internal async Task ClearChatGptLoginAsync() + internal async Task ClearChatGptLoginAsync(CancellationToken ct = default) { - _oauthAccessToken = null; - _oauthRefreshToken = null; - _oauthAccountId = null; - ChatGptPlanType = null; - _oauthExpiresAt = null; - - if (_host is not null) + await _oauthCredentialGate.WaitAsync(ct); + try { - await _host.DeleteSecretAsync(OAuthAccessTokenSecretName); - await _host.DeleteSecretAsync(OAuthRefreshTokenSecretName); - await _host.DeleteSecretAsync(OAuthIdTokenSecretName); - _host.SetSetting(OAuthAccountIdSettingName, null); - _host.SetSetting(OAuthPlanTypeSettingName, null); - _host.SetSetting(OAuthExpiresAtSettingName, null); - _host.NotifyCapabilitiesChanged(); + await CommitOAuthCredentialSnapshotUnderGateAsync(OAuthCredentialSnapshot.Empty); + } + finally + { + _oauthCredentialGate.Release(); } } @@ -825,56 +841,119 @@ private HttpRequestMessage CreateTtsRequest(string text) return request; } - private async Task ValidOAuthAccessTokenAsync(CancellationToken ct) + private async Task ValidOAuthCredentialsAsync(CancellationToken ct) { - if (!string.IsNullOrWhiteSpace(_oauthAccessToken) - && _oauthExpiresAt is { } expiresAt - && expiresAt > DateTimeOffset.UtcNow.AddSeconds(60)) + var credentials = Volatile.Read(ref _oauthCredentials); + if (HasValidOAuthAccessToken(credentials)) + return credentials; + + await _oauthCredentialGate.WaitAsync(ct); + try { - return _oauthAccessToken; + // A preceding waiter may have refreshed and atomically replaced + // the credential snapshot while this request waited for the gate. + credentials = Volatile.Read(ref _oauthCredentials); + if (HasValidOAuthAccessToken(credentials)) + return credentials; + + if (string.IsNullOrWhiteSpace(credentials.RefreshToken)) + throw new InvalidOperationException(Loc.L("Settings.ChatGptLoginNotConfigured")); + + var refreshed = await OpenAiOAuthClient.RefreshTokenAsync( + _httpClient, + credentials.RefreshToken, + ct); + var refreshedCredentials = CreateOAuthCredentialSnapshot( + refreshed, + credentials.AccountId, + credentials.RefreshToken); + await CommitOAuthCredentialSnapshotUnderGateAsync(refreshedCredentials); + return refreshedCredentials; } + finally + { + _oauthCredentialGate.Release(); + } + } - if (string.IsNullOrWhiteSpace(_oauthRefreshToken)) - throw new InvalidOperationException(Loc.L("Settings.ChatGptLoginNotConfigured")); - - var refreshed = await OpenAiOAuthClient.RefreshTokenAsync(_httpClient, _oauthRefreshToken, ct); - await StoreOAuthTokensAsync(refreshed, _oauthAccountId); - return refreshed.AccessToken; + private async Task StoreOAuthTokensAsync( + OpenAiOAuthTokenResponse tokens, + string? preferredAccountId, + CancellationToken ct = default) + { + await _oauthCredentialGate.WaitAsync(ct); + try + { + var currentCredentials = Volatile.Read(ref _oauthCredentials); + var credentials = CreateOAuthCredentialSnapshot( + tokens, + preferredAccountId, + currentCredentials.RefreshToken); + await CommitOAuthCredentialSnapshotUnderGateAsync(credentials); + } + finally + { + _oauthCredentialGate.Release(); + } } - private async Task StoreOAuthTokensAsync(OpenAiOAuthTokenResponse tokens, string? preferredAccountId) + private static OAuthCredentialSnapshot CreateOAuthCredentialSnapshot( + OpenAiOAuthTokenResponse tokens, + string? preferredAccountId, + string? existingRefreshToken) { var metadata = OpenAiOAuthClient.ExtractMetadata(tokens, preferredAccountId); - _oauthAccessToken = tokens.AccessToken; // RFC 6749 §6: a refresh response MAY omit `refresh_token`, meaning // "keep using the previously issued one". Unconditionally assigning // tokens.RefreshToken here would null out the only usable refresh // token on the first refresh that doesn't rotate it. var effectiveRefreshToken = string.IsNullOrEmpty(tokens.RefreshToken) - ? _oauthRefreshToken + ? existingRefreshToken : tokens.RefreshToken; - _oauthRefreshToken = effectiveRefreshToken; - _oauthAccountId = metadata.AccountId; - ChatGptPlanType = metadata.PlanType; - _oauthExpiresAt = metadata.ExpiresAt; - if (_host is null) + return new OAuthCredentialSnapshot( + tokens.AccessToken, + effectiveRefreshToken, + tokens.IdToken, + metadata.AccountId, + metadata.PlanType, + metadata.ExpiresAt + ); + } + + private async Task CommitOAuthCredentialSnapshotUnderGateAsync( + OAuthCredentialSnapshot credentials) + { + Volatile.Write(ref _oauthCredentials, credentials); + + var host = _host; + if (host is null) return; - await _host.StoreSecretAsync(OAuthAccessTokenSecretName, tokens.AccessToken); - if (!string.IsNullOrEmpty(effectiveRefreshToken)) - await _host.StoreSecretAsync(OAuthRefreshTokenSecretName, effectiveRefreshToken); - if (string.IsNullOrWhiteSpace(tokens.IdToken)) - await _host.DeleteSecretAsync(OAuthIdTokenSecretName); + if (string.IsNullOrWhiteSpace(credentials.AccessToken)) + await host.DeleteSecretAsync(OAuthAccessTokenSecretName); + else + await host.StoreSecretAsync(OAuthAccessTokenSecretName, credentials.AccessToken); + if (string.IsNullOrWhiteSpace(credentials.RefreshToken)) + await host.DeleteSecretAsync(OAuthRefreshTokenSecretName); else - await _host.StoreSecretAsync(OAuthIdTokenSecretName, tokens.IdToken); - _host.SetSetting(OAuthAccountIdSettingName, _oauthAccountId); - _host.SetSetting(OAuthPlanTypeSettingName, ChatGptPlanType); - _host.SetSetting(OAuthExpiresAtSettingName, _oauthExpiresAt); + await host.StoreSecretAsync(OAuthRefreshTokenSecretName, credentials.RefreshToken); + if (string.IsNullOrWhiteSpace(credentials.IdToken)) + await host.DeleteSecretAsync(OAuthIdTokenSecretName); + else + await host.StoreSecretAsync(OAuthIdTokenSecretName, credentials.IdToken); + host.SetSetting(OAuthAccountIdSettingName, credentials.AccountId); + host.SetSetting(OAuthPlanTypeSettingName, credentials.PlanType); + host.SetSetting(OAuthExpiresAtSettingName, credentials.ExpiresAt); NormalizeSelectedLlmModel(persist: true); - _host.NotifyCapabilitiesChanged(); + host.NotifyCapabilitiesChanged(); } + private static bool HasValidOAuthAccessToken(OAuthCredentialSnapshot credentials) => + !string.IsNullOrWhiteSpace(credentials.AccessToken) + && credentials.ExpiresAt is { } expiresAt + && expiresAt > DateTimeOffset.UtcNow.AddSeconds(60); + internal double? ResolvedTemperature(string modelId) { // When the model rejects temperature outright (e.g. GPT-5 with a @@ -1170,7 +1249,7 @@ internal void SetStreamResponses(bool enabled) { if (_forgetChatGptLogin) { - await ClearChatGptLoginAsync(); + await ClearChatGptLoginAsync(ct); _forgetChatGptLogin = false; return new PluginSettingsValidationResult(true, Loc.L("Settings.ChatGptLoginRemoved")); } @@ -1178,12 +1257,12 @@ internal void SetStreamResponses(bool enabled) if (HasChatGptCredentials) { // Stored credentials might have been revoked or expired beyond refresh. - // ValidOAuthAccessTokenAsync returns the cached access token if it's + // ValidOAuthCredentialsAsync returns the cached credentials if the access token is // still valid, otherwise hits the refresh endpoint — either way, a // failure means the credentials no longer work. try { - _ = await ValidOAuthAccessTokenAsync(ct); + _ = await ValidOAuthCredentialsAsync(ct); return new PluginSettingsValidationResult(true, ChatGptConnectedMessage()); } catch (Exception ex) @@ -1249,6 +1328,18 @@ private string ChatGptConnectedMessage() => private static bool ParseBool(string? value) => string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + private sealed record OAuthCredentialSnapshot( + string? AccessToken, + string? RefreshToken, + string? IdToken, + string? AccountId, + string? PlanType, + DateTimeOffset? ExpiresAt) + { + public static OAuthCredentialSnapshot Empty { get; } = + new(null, null, null, null, null, null); + } + private sealed record TranscriptionModelEntry( string Id, string DisplayName, diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs index e9e820610..b3673a478 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs @@ -504,6 +504,260 @@ public async Task ChatGptRefresh_PreservesExistingRefreshTokenWhenResponseOmitsR Assert.Equal("new-access-token", host.Secrets["oauth-access-token"]); } + [Fact] + public async Task ConcurrentChatGptRequests_RefreshOnceAndUseOneCoherentCredentialSnapshot() + { + const long expiresAtUnixSeconds = 4_102_444_800; + var firstAccessToken = CreateJwt(""" + { + "exp": 4102444800 + } + """); + var firstIdToken = CreateJwt(""" + { + "chatgpt_account_id": "acct_single_refresh", + "chatgpt_plan_type": "pro" + } + """); + var secondAccessToken = CreateJwt(""" + { + "exp": 4102444800, + "jti": "duplicate" + } + """); + var secondIdToken = CreateJwt(""" + { + "chatgpt_account_id": "acct_duplicate_refresh", + "chatgpt_plan_type": "free" + } + """); + var firstRefreshResponse = JsonSerializer.Serialize(new + { + access_token = firstAccessToken, + refresh_token = "rotated-refresh-token", + id_token = firstIdToken, + expires_in = 3600, + }); + var duplicateRefreshResponse = JsonSerializer.Serialize(new + { + access_token = secondAccessToken, + refresh_token = "duplicate-rotated-refresh-token", + id_token = secondIdToken, + expires_in = 3600, + }); + var firstRefreshStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirstRefresh = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var downstreamAccessTokens = new List(); + var tokenPostCount = 0; + var handler = new CapturingHandler(async (request, _) => + { + if (request.RequestUri?.AbsoluteUri == "https://auth.openai.com/oauth/token") + { + // ReSharper disable once AccessToModifiedClosure -- intentional shared counter across handler invocations; Interlocked.Increment coordinates the concurrent-refresh dedup this test asserts. + var refreshNumber = Interlocked.Increment(ref tokenPostCount); + if (refreshNumber == 1) + { + firstRefreshStarted.TrySetResult(true); + await releaseFirstRefresh.Task; + return JsonResponse(firstRefreshResponse); + } + + return JsonResponse(duplicateRefreshResponse); + } + + lock (downstreamAccessTokens) + { + downstreamAccessTokens.Add(request.Headers.Authorization?.Parameter); + } + + return JsonResponse("""{"output_text":"OK"}"""); + }); + var host = new TestPluginHostServices(); + host.SetSetting("authMode", "chatgpt"); + host.SetSetting("oauthExpiresAt", DateTimeOffset.UtcNow.AddMinutes(-5)); + host.Secrets["oauth-access-token"] = "expired-access-token"; + host.Secrets["oauth-refresh-token"] = "original-refresh-token"; + + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var httpClient = new HttpClient(handler); + var sut = new OpenAiPlugin(httpClient, _ => new FakeTtsPlaybackSession()); + await sut.ActivateAsync(host); + + var firstRequest = sut.ProcessAsync( + "system", + "first", + "gpt-5.5", + timeoutCts.Token); + await firstRefreshStarted.Task.WaitAsync(timeoutCts.Token); + var secondRequest = sut.ProcessAsync( + "system", + "second", + "gpt-5.5", + timeoutCts.Token); + + releaseFirstRefresh.TrySetResult(true); + await Task.WhenAll(firstRequest, secondRequest).WaitAsync(timeoutCts.Token); + + Assert.Equal(1, Volatile.Read(ref tokenPostCount)); + lock (downstreamAccessTokens) + { + Assert.Equal(2, downstreamAccessTokens.Count); + Assert.All( + downstreamAccessTokens, + accessToken => Assert.Equal(firstAccessToken, accessToken)); + } + Assert.Equal(firstAccessToken, host.Secrets["oauth-access-token"]); + Assert.Equal("rotated-refresh-token", host.Secrets["oauth-refresh-token"]); + Assert.Equal(firstIdToken, host.Secrets["oauth-id-token"]); + Assert.Equal("acct_single_refresh", host.GetSetting("oauthAccountID")); + Assert.Equal("pro", host.GetSetting("oauthPlanType")); + Assert.Equal( + DateTimeOffset.FromUnixTimeSeconds(expiresAtUnixSeconds), + host.GetSetting("oauthExpiresAt")); + } + + [Fact] + public async Task ChatGptRefresh_FailureReleasesCredentialGateForWaitingRequest() + { + var firstRefreshStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirstRefresh = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var tokenPostCount = 0; + var handler = new CapturingHandler(async (request, _) => + { + if (request.RequestUri?.AbsoluteUri != "https://auth.openai.com/oauth/token") + return JsonResponse("""{"output_text":"OK"}"""); + + // ReSharper disable once AccessToModifiedClosure -- intentional shared counter across handler invocations; Interlocked.Increment coordinates the concurrent-refresh dedup this test asserts. + var refreshNumber = Interlocked.Increment(ref tokenPostCount); + if (refreshNumber == 1) + { + firstRefreshStarted.TrySetResult(true); + await releaseFirstRefresh.Task; + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent( + """{"error":"rejected refresh token"}""", + Encoding.UTF8, + "application/json"), + }; + } + + return JsonResponse( + """{"access_token":"recovered-access-token","refresh_token":"recovered-refresh-token","expires_in":3600}"""); + }); + var host = new TestPluginHostServices(); + host.SetSetting("authMode", "chatgpt"); + host.SetSetting("oauthExpiresAt", DateTimeOffset.UtcNow.AddMinutes(-5)); + host.Secrets["oauth-access-token"] = "expired-access-token"; + host.Secrets["oauth-refresh-token"] = "original-refresh-token"; + + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var httpClient = new HttpClient(handler); + var sut = new OpenAiPlugin(httpClient, _ => new FakeTtsPlaybackSession()); + await sut.ActivateAsync(host); + + var failingRequest = sut.ProcessAsync( + "system", + "first", + "gpt-5.5", + timeoutCts.Token); + await firstRefreshStarted.Task.WaitAsync(timeoutCts.Token); + var waitingRequest = sut.ProcessAsync( + "system", + "second", + "gpt-5.5", + timeoutCts.Token); + + try + { + for (var i = 0; i < 10; i++) + await Task.Yield(); + Assert.Equal(1, Volatile.Read(ref tokenPostCount)); + + releaseFirstRefresh.TrySetResult(true); + await Assert.ThrowsAsync(() => failingRequest); + Assert.Equal("OK", await waitingRequest.WaitAsync(timeoutCts.Token)); + Assert.Equal(2, Volatile.Read(ref tokenPostCount)); + Assert.Equal("recovered-access-token", host.Secrets["oauth-access-token"]); + Assert.Equal("recovered-refresh-token", host.Secrets["oauth-refresh-token"]); + } + finally + { + releaseFirstRefresh.TrySetResult(true); + } + } + + [Fact] + public async Task ChatGptRefresh_CancellationReleasesCredentialGateForWaitingRequest() + { + var firstRefreshStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var tokenPostCount = 0; + var handler = new CapturingHandler(async (request, _, cancellationToken) => + { + if (request.RequestUri?.AbsoluteUri != "https://auth.openai.com/oauth/token") + return JsonResponse("""{"output_text":"OK"}"""); + + // ReSharper disable once AccessToModifiedClosure -- intentional shared counter across handler invocations; Interlocked.Increment coordinates the concurrent-refresh dedup this test asserts. + var refreshNumber = Interlocked.Increment(ref tokenPostCount); + if (refreshNumber == 1) + { + firstRefreshStarted.TrySetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + + return JsonResponse( + """{"access_token":"recovered-access-token","refresh_token":"recovered-refresh-token","expires_in":3600}"""); + }); + var host = new TestPluginHostServices(); + host.SetSetting("authMode", "chatgpt"); + host.SetSetting("oauthExpiresAt", DateTimeOffset.UtcNow.AddMinutes(-5)); + host.Secrets["oauth-access-token"] = "expired-access-token"; + host.Secrets["oauth-refresh-token"] = "original-refresh-token"; + + using var firstRequestCts = new CancellationTokenSource(); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var httpClient = new HttpClient(handler); + var sut = new OpenAiPlugin(httpClient, _ => new FakeTtsPlaybackSession()); + await sut.ActivateAsync(host); + + var canceledRequest = sut.ProcessAsync( + "system", + "first", + "gpt-5.5", + firstRequestCts.Token); + await firstRefreshStarted.Task.WaitAsync(timeoutCts.Token); + var waitingRequest = sut.ProcessAsync( + "system", + "second", + "gpt-5.5", + timeoutCts.Token); + + try + { + for (var i = 0; i < 10; i++) + await Task.Yield(); + Assert.Equal(1, Volatile.Read(ref tokenPostCount)); + + // ReSharper disable once MethodHasAsyncOverload -- synchronous Cancel must trip the token before the assertion; CancelAsync would defer it. + firstRequestCts.Cancel(); + await Assert.ThrowsAnyAsync(() => canceledRequest); + Assert.Equal("OK", await waitingRequest.WaitAsync(timeoutCts.Token)); + Assert.Equal(2, Volatile.Read(ref tokenPostCount)); + Assert.Equal("recovered-access-token", host.Secrets["oauth-access-token"]); + Assert.Equal("recovered-refresh-token", host.Secrets["oauth-refresh-token"]); + } + finally + { + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in this teardown path; CancelAsync() only defers callbacks, with no benefit here. + firstRequestCts.Cancel(); + } + } + [Fact] public async Task ImportExistingLogin_LoadsTokensFromCodexAuthFile() { @@ -1475,9 +1729,25 @@ private static HttpResponseMessage JsonResponse(string json) => Content = new StringContent(json, Encoding.UTF8, "application/json"), }; + private static string CreateJwt(string payload) + { + var encodedPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + return $"e30.{encodedPayload}.signature"; + } + private sealed class CapturingHandler( - Func> responder) : HttpMessageHandler + Func> responder) + : HttpMessageHandler { + public CapturingHandler( + Func> responder) + : this((request, body, _) => responder(request, body)) + { + } + protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) @@ -1485,7 +1755,7 @@ protected override async Task SendAsync( var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); - return await responder(request, body); + return await responder(request, body, cancellationToken); } } From d8dc5b2347f8c0adf0b1c10f1a505dcb4c7e3d13 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 04:19:26 +0000 Subject: [PATCH 182/226] Gate xAI streaming on the provider's transcript.created readiness event ConnectAsync returned as soon as the WebSocket handshake finished and SendAudioAsync checked only socket state, so audio could be sent before xAI's documented readiness event. The collector discarded transcript.created entirely. A readiness signal now completes when the receive loop sees transcript.created. ConnectAsync starts the receive loop and awaits readiness under the caller's token plus a named ten-second timeout; any pre-ready failure - timeout, provider error, abnormal close, EOF - aborts and disposes the socket and surfaces from ConnectAsync so StartStreamingAsync fails and the coordinator falls back to batch. SendAudioAsync defensively awaits the same signal. Caller cancellation during startup stays clean cancellation. A stream closure before transcript.done now faults the session regardless of readiness - previously a graceful server close mid-stream let the coordinator commit the partial transcript as success instead of taking the complete-WAV batch fallback; a close after transcript.done remains the normal end of stream. The socket field is typed as the abstract WebSocket with connected-session test seams following the OpenAI realtime pattern. --- .../XaiStreamingSession.cs | 201 ++++++++++- .../XaiPluginTests.cs | 314 ++++++++++++++++++ 2 files changed, 503 insertions(+), 12 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs b/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs index c11492026..9d3e03fa1 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs @@ -9,10 +9,14 @@ namespace TypeWhisper.Plugin.Xai; internal sealed class XaiStreamingSession : IStreamingSession { - private readonly ClientWebSocket _ws; + private const int ProviderReadinessTimeoutSeconds = 10; + + private readonly WebSocket _ws; private readonly XaiTranscriptCollector _collector; private readonly CancellationTokenSource _receiveCts = new(); private readonly SemaphoreSlim _sendLock = new(1, 1); + private readonly TaskCompletionSource _readinessSignal = + new(TaskCreationOptions.RunContinuationsAsynchronously); // Set by the receive loop when transcript.done arrives (or when the loop // exits for any reason via the finally block). FinalizeAsync awaits this // before returning so the coordinator does not tear the session down @@ -29,7 +33,7 @@ internal sealed class XaiStreamingSession : IStreamingSession private Task? _receiveTask; private bool _disposed; - private XaiStreamingSession(ClientWebSocket ws, XaiTranscriptCollector collector) + private XaiStreamingSession(WebSocket ws, XaiTranscriptCollector collector) { _ws = ws; _collector = collector; @@ -43,14 +47,66 @@ public static async Task ConnectAsync( CancellationToken ct) { var ws = CreateConfiguredWebSocket(apiKey); - await ws.ConnectAsync(BuildStreamingUri(language, interimResults: true), ct); + try + { + await ws.ConnectAsync(BuildStreamingUri(language, interimResults: true), ct); + } + catch + { + ws.Dispose(); + throw; + } + + return await CreateReadySessionAsync( + ws, + TimeSpan.FromSeconds(ProviderReadinessTimeoutSeconds), + ct); + } + + internal static XaiStreamingSession CreateConnectedSessionForTests(WebSocket ws) + { + if (ws.State != WebSocketState.Open) + throw new InvalidOperationException("The test WebSocket must already be open."); + + return CreateStartedSession(ws); + } + + internal static Task CreateConnectedSessionForTests( + WebSocket ws, + TimeSpan readinessTimeout, + CancellationToken ct) + { + if (ws.State != WebSocketState.Open) + throw new InvalidOperationException("The test WebSocket must already be open."); + + return CreateReadySessionAsync(ws, readinessTimeout, ct); + } - var collector = new XaiTranscriptCollector(); - var session = new XaiStreamingSession(ws, collector); + private static XaiStreamingSession CreateStartedSession(WebSocket ws) + { + var session = new XaiStreamingSession(ws, new XaiTranscriptCollector()); session._receiveTask = session.ReceiveLoopAsync(session._receiveCts.Token); return session; } + private static async Task CreateReadySessionAsync( + WebSocket ws, + TimeSpan readinessTimeout, + CancellationToken ct) + { + var session = CreateStartedSession(ws); + try + { + await session.WaitForProviderReadinessAsync(readinessTimeout, ct); + return session; + } + catch + { + await session.AbortStartupAsync(); + throw; + } + } + public static Uri BuildStreamingUri(string? language, bool interimResults) { var query = new List @@ -85,13 +141,22 @@ private static ClientWebSocket CreateConfiguredWebSocket(string apiKey) public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationToken ct) { - if (_disposed) return; + if (_disposed || pcm16Audio.Length == 0) + return; + + // ConnectAsync normally returns only after transcript.created, but + // keep the protocol invariant here too for test seams and defensive + // safety if construction changes in the future. + await _readinessSignal.Task.WaitAsync(ct); + + if (_disposed) + return; // Receive loop saw a protocol/transport error: surface it so the // coordinator's sender task faults and triggers batch fallback. ThrowIfReceiveLoopFaulted(); - if (_ws.State != WebSocketState.Open || pcm16Audio.Length == 0) + if (_ws.State != WebSocketState.Open) return; await _sendLock.WaitAsync(ct); @@ -166,6 +231,81 @@ private async Task SendTextAsync(string json, CancellationToken ct) await _ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct); } + private async Task WaitForProviderReadinessAsync( + TimeSpan readinessTimeout, + CancellationToken ct) + { + var timeoutException = new TimeoutException( + $"xAI did not send transcript.created within {readinessTimeout.TotalSeconds:g} seconds."); + using var timeoutCts = new CancellationTokenSource(readinessTimeout); + await using var callerCancellation = ct.Register( + () => _readinessSignal.TrySetCanceled(ct)); + await using var providerTimeout = timeoutCts.Token.Register( + () => _readinessSignal.TrySetException(timeoutException)); + + await _readinessSignal.Task; + } + + private async Task AbortStartupAsync() + { + // Startup failures cannot leave a receive blocked on a socket that no + // caller owns. Cancel first so teardown-driven receive exits remain + // clean cancellation rather than overwriting the readiness failure. + // ReSharper disable once MethodHasAsyncOverload -- Cancel() must synchronously release the pending receive before disposal awaits it. + _receiveCts.Cancel(); + try { _ws.Abort(); } + catch (Exception ex) + { + Debug.WriteLine($"xAI STT startup abort error: {ex.Message}"); + } + + try { await DisposeAsync(); } + catch (Exception ex) + { + Debug.WriteLine($"xAI STT startup disposal error: {ex.Message}"); + } + } + + private void CaptureReceiveLoopException(Exception exception) + { + Interlocked.CompareExchange(ref _receiveLoopException, exception, null); + _readinessSignal.TrySetException(exception); + } + + private void CaptureClosure( + WebSocketReceiveResult? closeResult, + CancellationToken ct) + { + if (ct.IsCancellationRequested) + { + _readinessSignal.TrySetCanceled(ct); + return; + } + + // A close after transcript.done is the normal end of the stream — + // nothing to fault. Before the terminal event the stream was truncated + // (whether readiness was reached or not): record the fault so + // SendAudioAsync/FinalizeAsync surface it and the coordinator falls + // back to the complete-WAV batch path instead of committing a partial + // transcript as success. Readiness health is tracked independently: + // if transcript.created never arrived, also fault the readiness signal + // so ConnectAsync fails. + if (_collector.IsTerminal) + return; + + var boundary = _collector.IsReady ? "transcript.done" : "transcript.created"; + var detail = closeResult is null + ? "" + : $" Status: {closeResult.CloseStatus?.ToString() ?? "unknown"}" + + (string.IsNullOrWhiteSpace(closeResult.CloseStatusDescription) + ? "." + : $"; reason: {closeResult.CloseStatusDescription}."); + var exception = new InvalidOperationException( + $"xAI streaming session ended before {boundary}.{detail}"); + Interlocked.CompareExchange(ref _receiveLoopException, exception, null); + _readinessSignal.TrySetException(exception); + } + private async Task ReceiveLoopAsync(CancellationToken ct) { var buffer = new byte[8192]; @@ -181,7 +321,10 @@ private async Task ReceiveLoopAsync(CancellationToken ct) { result = await _ws.ReceiveAsync(buffer, ct); if (result.MessageType == WebSocketMessageType.Close) + { + CaptureClosure(result, ct); return; + } messageBuffer.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); @@ -190,36 +333,58 @@ private async Task ReceiveLoopAsync(CancellationToken ct) var json = Encoding.UTF8.GetString(messageBuffer.GetBuffer(), 0, (int)messageBuffer.Length); var transcriptEvent = _collector.ApplyEvent(json); + if (_collector.IsReady) + _readinessSignal.TrySetResult(true); if (transcriptEvent is not null) TranscriptReceived?.Invoke(transcriptEvent); if (_collector.IsTerminal) _terminalSignal.TrySetResult(true); } } - catch (OperationCanceledException ex) + catch (OperationCanceledException ex) when (ct.IsCancellationRequested) { // Normal teardown — DisposeAsync cancelled _receiveCts. Not a fault. Debug.WriteLine($"xAI STT receive loop canceled: {ex.Message}"); + _readinessSignal.TrySetCanceled(ct); + } + catch (Exception ex) when (ct.IsCancellationRequested) + { + // Abort/Dispose can make a fake or provider transport complete its + // receive with a non-cancellation exception. The owning token still + // makes this normal teardown, not a provider fault. + Debug.WriteLine($"xAI STT receive loop stopped during cancellation: {ex.Message}"); } catch (WebSocketException ex) { Debug.WriteLine($"xAI STT WebSocket error: {ex.Message}"); - Interlocked.CompareExchange(ref _receiveLoopException, ex, null); + CaptureReceiveLoopException(ex); } catch (JsonException ex) { Debug.WriteLine($"xAI STT parse error: {ex.Message}"); - Interlocked.CompareExchange(ref _receiveLoopException, ex, null); + CaptureReceiveLoopException(ex); } catch (InvalidOperationException ex) { // Raised by XaiTranscriptCollector for "error"-typed events and // malformed payloads — propagate as a session fault. Debug.WriteLine($"xAI STT stream error: {ex.Message}"); - Interlocked.CompareExchange(ref _receiveLoopException, ex, null); + CaptureReceiveLoopException(ex); + } + catch (Exception ex) + { + Debug.WriteLine($"xAI STT receive error: {ex.Message}"); + CaptureReceiveLoopException(ex); } finally { + // Records a truncation fault if the loop exited before + // transcript.done via any path that didn't already capture one + // (and faults the readiness signal if transcript.created never + // arrived). No-op after a normal terminal completion, an + // already-captured fault, or caller cancellation. + CaptureClosure(closeResult: null, ct); + // "No more events will arrive" is true whether we exited via // transcript.done, a Close frame, cancellation, or any error. // Unblock FinalizeAsync in all paths. @@ -235,6 +400,7 @@ public async ValueTask DisposeAsync() _disposed = true; // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. _receiveCts.Cancel(); + _readinessSignal.TrySetCanceled(_receiveCts.Token); await _sendLock.WaitAsync(CancellationToken.None); try @@ -310,6 +476,11 @@ internal sealed class XaiTranscriptCollector /// public bool IsTerminal { get; private set; } + /// + /// True once xAI has declared the streaming transcript ready for audio. + /// + public bool IsReady { get; private set; } + public StreamingTranscriptEvent? ApplyEvent(string json) { using var doc = JsonDocument.Parse(json); @@ -323,7 +494,7 @@ internal sealed class XaiTranscriptCollector return typeEl.GetString() switch { - "transcript.created" => null, + "transcript.created" => ApplyCreatedEvent(), "transcript.partial" => ApplyPartialEvent(root), "transcript.done" => ApplyDoneEvent(root), "error" => throw new InvalidOperationException(ExtractErrorMessage(root) ?? "Unknown xAI STT error"), @@ -331,6 +502,12 @@ internal sealed class XaiTranscriptCollector }; } + private StreamingTranscriptEvent? ApplyCreatedEvent() + { + IsReady = true; + return null; + } + public PluginTranscriptionResult FinalResult(string? fallbackLanguage) { var text = !string.IsNullOrWhiteSpace(_doneText) diff --git a/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs index a0edb6e9d..6d040bbf0 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs @@ -1,6 +1,8 @@ using System.Net; +using System.Net.WebSockets; using System.Text; using System.Text.Json; +using System.Threading.Channels; using TypeWhisper.Plugin.Xai; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Models; @@ -484,6 +486,154 @@ public void StreamingSession_BuildsExpectedUriAndExposesAuthHeader() Assert.Equal("Bearer xai-key", headers["Authorization"]); } + [Fact] + public async Task StreamingSession_SendAudioWaitsForTranscriptCreated() + { + var socket = new FakeStreamingWebSocket(); + await using var session = + XaiStreamingSession.CreateConnectedSessionForTests(socket); + + var sendTask = session.SendAudioAsync( + new byte[] { 1, 2, 3, 4 }, + CancellationToken.None); + + Assert.False(sendTask.IsCompleted); + Assert.Empty(socket.SentFrames); + + socket.EnqueueText("""{"type":"transcript.created"}"""); + + await sendTask.WaitAsync(TimeSpan.FromSeconds(5)); + var sent = Assert.Single(socket.SentFrames); + Assert.Equal(WebSocketMessageType.Binary, sent.MessageType); + Assert.Equal([1, 2, 3, 4], sent.Payload); + } + + [Fact] + public async Task StreamingSession_ConnectedFactoryWaitsForTranscriptCreated() + { + var socket = new FakeStreamingWebSocket(); + var connectTask = XaiStreamingSession.CreateConnectedSessionForTests( + socket, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.False(connectTask.IsCompleted); + + socket.EnqueueText("""{"type":"transcript.created"}"""); + + await using var session = await connectTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(WebSocketState.Open, socket.State); + } + + [Theory] + [InlineData(false, "before transcript.created")] + [InlineData(true, "quota exceeded")] + public async Task StreamingSession_CloseOrErrorBeforeReadinessFaultsConnect( + bool providerError, + string expectedMessage) + { + var socket = new FakeStreamingWebSocket(); + var connectTask = XaiStreamingSession.CreateConnectedSessionForTests( + socket, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + if (providerError) + { + socket.EnqueueText( + """{"type":"error","error":{"message":"quota exceeded"}}"""); + } + else + { + socket.EnqueueClose( + WebSocketCloseStatus.EndpointUnavailable, + "provider unavailable"); + } + + var exception = await Assert.ThrowsAsync( + async () => await connectTask.WaitAsync(TimeSpan.FromSeconds(5))); + + Assert.Contains(expectedMessage, exception.Message); + Assert.True(socket.AbortCalled); + Assert.True(socket.DisposeCalled); + } + + [Fact] + public async Task StreamingSession_CallerCancellationDuringReadinessWaitIsCleanAndTearsDown() + { + using var startupCts = new CancellationTokenSource(); + var socket = new FakeStreamingWebSocket(); + var connectTask = XaiStreamingSession.CreateConnectedSessionForTests( + socket, + TimeSpan.FromSeconds(5), + startupCts.Token); + + // ReSharper disable once MethodHasAsyncOverload -- the assertion requires cancellation to be observable immediately. + startupCts.Cancel(); + + var exception = await Assert.ThrowsAnyAsync( + // ReSharper disable once MethodSupportsCancellation -- must not pass startupCts.Token: it is already canceled here, so WaitAsync would throw before connectTask propagates its own cancellation, hollowing out the token assertion below. The TimeSpan is only a hang guard. + async () => await connectTask.WaitAsync(TimeSpan.FromSeconds(5))); + + Assert.Equal(startupCts.Token, exception.CancellationToken); + Assert.True(socket.AbortCalled); + Assert.True(socket.DisposeCalled); + Assert.True(socket.ReceiveExited.IsCompletedSuccessfully); + } + + [Fact] + public async Task StreamingSession_ReadinessTimeoutFaultsAndTearsDown() + { + var socket = new FakeStreamingWebSocket(); + var connectTask = XaiStreamingSession.CreateConnectedSessionForTests( + socket, + TimeSpan.FromMilliseconds(50), + CancellationToken.None); + + var exception = await Assert.ThrowsAsync( + async () => await connectTask.WaitAsync(TimeSpan.FromSeconds(5))); + + Assert.Contains("transcript.created", exception.Message); + Assert.True(socket.AbortCalled); + Assert.True(socket.DisposeCalled); + Assert.True(socket.ReceiveExited.IsCompletedSuccessfully); + } + + [Fact] + public async Task StreamingSession_CloseBeforeTranscriptDoneFaultsFinalize() + { + // Regression: after transcript.created the readiness signal is already + // completed, so a graceful Close frame arriving before transcript.done + // must still be recorded as a session fault. Otherwise FinalizeAsync + // returns cleanly and the coordinator commits the partial transcript + // as success instead of falling back to the complete-WAV batch path, + // silently truncating dictation. + var socket = new FakeStreamingWebSocket(); + var connectTask = XaiStreamingSession.CreateConnectedSessionForTests( + socket, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + socket.EnqueueText("""{"type":"transcript.created"}"""); + await using var session = await connectTask.WaitAsync(TimeSpan.FromSeconds(5)); + + // A final segment lands, then FinalizeAsync parks on the terminal wait + // (socket still open) before the server closes mid-stream — no + // transcript.done ever arrives. + socket.EnqueueText( + """{"type":"transcript.partial","text":"hello","is_final":true,"speech_final":false}"""); + var finalizeTask = session.FinalizeAsync(CancellationToken.None); + socket.EnqueueClose( + WebSocketCloseStatus.EndpointUnavailable, + "mid-stream disconnect"); + + var exception = await Assert.ThrowsAsync( + async () => await finalizeTask.WaitAsync(TimeSpan.FromSeconds(5))); + + Assert.Contains("faulted", exception.Message); + Assert.Contains("transcript.done", exception.Message); + } + [Fact] public void TranscriptCollector_EmitsPerSegmentDeltasAndSuppressesCumulativeFinals() { @@ -889,6 +1039,170 @@ protected override async Task SendAsync( } } + private abstract record StreamingReceiveItem + { + public sealed record Frame( + byte[] Payload, + WebSocketMessageType MessageType, + WebSocketCloseStatus? CloseStatus = null, + string? CloseDescription = null) : StreamingReceiveItem; + } + + private sealed record SentStreamingFrame( + byte[] Payload, + WebSocketMessageType MessageType); + + private sealed class FakeStreamingWebSocket : WebSocket + { + private readonly Channel _receives = + Channel.CreateUnbounded(); + private readonly List _sentFrames = []; + private readonly Lock _sentLock = new(); + private readonly TaskCompletionSource _receiveExited = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private WebSocketState _state = WebSocketState.Open; + private WebSocketCloseStatus? _closeStatus; + private string? _closeDescription; + + public IReadOnlyList SentFrames + { + get + { + lock (_sentLock) + { + return _sentFrames.ToArray(); + } + } + } + + public Task ReceiveExited => _receiveExited.Task; + public bool AbortCalled { get; private set; } + public bool DisposeCalled { get; private set; } + public override WebSocketCloseStatus? CloseStatus => _closeStatus; + public override string? CloseStatusDescription => _closeDescription; + public override WebSocketState State => _state; + public override string? SubProtocol => null; + + public void EnqueueText(string json) => + _receives.Writer.TryWrite(new StreamingReceiveItem.Frame( + Encoding.UTF8.GetBytes(json), + WebSocketMessageType.Text)); + + public void EnqueueClose( + WebSocketCloseStatus closeStatus, + string? closeDescription) => + _receives.Writer.TryWrite(new StreamingReceiveItem.Frame( + [], + WebSocketMessageType.Close, + closeStatus, + closeDescription)); + + public override void Abort() + { + AbortCalled = true; + _state = WebSocketState.Aborted; + _receives.Writer.TryComplete(); + } + + public override Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + _closeStatus = closeStatus; + _closeDescription = statusDescription; + _state = WebSocketState.Closed; + _receives.Writer.TryComplete(); + return Task.CompletedTask; + } + + public override Task CloseOutputAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) => + CloseAsync(closeStatus, statusDescription, cancellationToken); + + public override void Dispose() + { + DisposeCalled = true; + _state = WebSocketState.Closed; + _receives.Writer.TryComplete(); + } + + public override async Task ReceiveAsync( + ArraySegment buffer, + CancellationToken cancellationToken) + { + try + { + var item = await _receives.Reader.ReadAsync(cancellationToken); + var frame = Assert.IsType(item); + if (frame.MessageType == WebSocketMessageType.Close) + { + _closeStatus = frame.CloseStatus; + _closeDescription = frame.CloseDescription; + _state = WebSocketState.CloseReceived; + return new WebSocketReceiveResult( + 0, + WebSocketMessageType.Close, + endOfMessage: true, + frame.CloseStatus, + frame.CloseDescription); + } + + Assert.True(frame.Payload.Length <= buffer.Count); + frame.Payload.CopyTo(buffer.Array!, buffer.Offset); + return new WebSocketReceiveResult( + frame.Payload.Length, + frame.MessageType, + endOfMessage: true); + } + finally + { + _receiveExited.TrySetResult(); + } + } + + public override Task SendAsync( + ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Assert.Equal(WebSocketState.Open, _state); + Assert.True(endOfMessage); + lock (_sentLock) + { + _sentFrames.Add(new SentStreamingFrame( + buffer.AsSpan().ToArray(), + messageType)); + } + + return Task.CompletedTask; + } + + public override ValueTask SendAsync( + ReadOnlyMemory buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Assert.Equal(WebSocketState.Open, _state); + Assert.True(endOfMessage); + lock (_sentLock) + { + _sentFrames.Add(new SentStreamingFrame( + buffer.ToArray(), + messageType)); + } + + return ValueTask.CompletedTask; + } + } + private sealed class TestPluginHostServices : IPluginHostServices { private static readonly JsonSerializerOptions s_jsonOptions = new() From 873d62f69503c1d65cc4c34b234aaf7acf615a1e Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 04:49:21 +0000 Subject: [PATCH 183/226] Stop forcing Gemma output into the input language FormatGemmaPrompt appended a blanket instruction to every nonempty system prompt telling Gemma to respond only in the same language as the user's input. TranslationService selects Gemma with a system prompt explicitly requesting a different target language, so the two instructions conflicted and translations could come back in the source language. Only the same-language sentence is removed - the output-hygiene guidance (respond with only the requested result, no explanations) does not conflict with any caller and still protects every Gemma action from preamble chatter, so it stays in the same position. The caller-provided system prompt now passes through verbatim inside Gemma's chat framing; each task's prompt owns its output language. FormatGemmaPrompt becomes internal so the framing is pinned by deterministic tests covering both the translation pair and a plain prompt, shared by the batch and streaming paths. --- .../GemmaLocalPlugin.cs | 4 +- .../GemmaLocalPluginTests.cs | 69 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs index 18b65cbdf..bb18de5b4 100644 --- a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs @@ -654,7 +654,7 @@ internal void UnloadModel() // Helpers - private static string FormatGemmaPrompt(string systemPrompt, string userText) + internal static string FormatGemmaPrompt(string systemPrompt, string userText) { // Gemma instruction-tuned chat format with proper system turn var sb = new System.Text.StringBuilder(); @@ -664,7 +664,7 @@ private static string FormatGemmaPrompt(string systemPrompt, string userText) sb.Append("system\n"); sb.Append(systemPrompt).Append('\n'); sb.Append( - "IMPORTANT: Respond ONLY in the same language as the user's input. Output ONLY the requested result, nothing else. No explanations, no extra text." + "Output ONLY the requested result, nothing else. No explanations, no extra text." ); sb.Append("\n"); } diff --git a/tests/TypeWhisper.PluginSystem.Tests/GemmaLocalPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/GemmaLocalPluginTests.cs index ea6581cb3..3fec6c6cf 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/GemmaLocalPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/GemmaLocalPluginTests.cs @@ -149,6 +149,75 @@ var _ in sut.ProcessStreamingAsync( Assert.Equal(1, observation.CallCount); } + [Fact] + public void FormatGemmaPrompt_TranslationPrompt_PreservesTargetLanguageAndAppendsOutputHygiene() + { + const string systemPrompt = + "Translate the following text from English to German. Output only the German translation."; + const string userText = "Where is the train station?"; + const string outputHygieneInstruction = + "Output ONLY the requested result, nothing else. No explanations, no extra text."; + const string expectedSystemTurn = + "system\n" + + systemPrompt + + "\n" + + outputHygieneInstruction + + "\n"; + const string expectedPrompt = + expectedSystemTurn + + "user\n" + + userText + + "\n" + + "model\n"; + + var formattedPrompt = GemmaLocalPlugin.FormatGemmaPrompt(systemPrompt, userText); + + Assert.Equal(expectedPrompt, formattedPrompt); + var actualSystemTurn = formattedPrompt[..expectedSystemTurn.Length]; + Assert.Equal(expectedSystemTurn, actualSystemTurn); + Assert.Contains(systemPrompt, actualSystemTurn, StringComparison.Ordinal); + Assert.Contains(outputHygieneInstruction, actualSystemTurn, StringComparison.Ordinal); + Assert.DoesNotContain( + "IMPORTANT: Respond ONLY in the same language as the user's input.", + actualSystemTurn, + StringComparison.Ordinal + ); + } + + [Fact] + public void FormatGemmaPrompt_PlainSystemPrompt_PreservesCallerPromptAndAppendsOutputHygiene() + { + const string systemPrompt = "Answer concisely and use complete sentences."; + const string userText = "Explain gravity."; + const string outputHygieneInstruction = + "Output ONLY the requested result, nothing else. No explanations, no extra text."; + const string expectedSystemTurn = + "system\n" + + systemPrompt + + "\n" + + outputHygieneInstruction + + "\n"; + const string expectedPrompt = + expectedSystemTurn + + "user\n" + + userText + + "\n" + + "model\n"; + + var formattedPrompt = GemmaLocalPlugin.FormatGemmaPrompt(systemPrompt, userText); + + Assert.Equal(expectedPrompt, formattedPrompt); + var actualSystemTurn = formattedPrompt[..expectedSystemTurn.Length]; + Assert.Equal(expectedSystemTurn, actualSystemTurn); + Assert.Contains(systemPrompt, actualSystemTurn, StringComparison.Ordinal); + Assert.Contains(outputHygieneInstruction, actualSystemTurn, StringComparison.Ordinal); + Assert.DoesNotContain( + "IMPORTANT: Respond ONLY in the same language as the user's input.", + actualSystemTurn, + StringComparison.Ordinal + ); + } + private static GemmaLocalPlugin CreatePluginWithRoutingProbe( RoutingObservation observation ) => From 1e556390f277413073d8e817af25cdc0a6ff32ad Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 04:58:37 +0000 Subject: [PATCH 184/226] Bound Soniox cleanup with one shared budget instead of two 30s clocks Every transcription awaits cleanup in finally, and cleanup issued two sequential DELETE requests, each with its own fresh 30-second CancellationTokenSource unlinked to the caller. Cancelling a transcription once both resource IDs existed could take roughly another minute before control returned. Cleanup now creates a single CancellationTokenSource from one injectable budget (default five seconds) and passes that token through every DELETE it attempts, so caller cancellation is delayed by at most one budget while short best-effort cleanup still runs after the caller's token has already fired. Deletion is outcome-aware per Soniox's documented contract: a successful transcription DELETE also removes the associated file, so cleanup stops there; with no transcription ID (or a failed transcription DELETE, e.g. 404/409) the uploaded file is deleted directly while budget remains. Cleanup faults never replace the primary result - HTTP, timeout, cancellation, and unexpected failures are logged, which matters because undeleted files count against Soniox storage quota. --- .../TypeWhisper.Plugin.Soniox/SonioxPlugin.cs | 83 ++++-- .../SonioxPluginTests.cs | 247 +++++++++++++++++- 2 files changed, 302 insertions(+), 28 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs b/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs index 0fcf975df..647301384 100644 --- a/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs @@ -24,6 +24,7 @@ public sealed class SonioxPlugin : ITranscriptionEnginePlugin, IPluginSettingsPr private const double SubtitleSegmentPauseSplitSeconds = 0.75; private static readonly TimeSpan s_defaultPollDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan s_defaultCleanupBudget = TimeSpan.FromSeconds(5); private static readonly IReadOnlyList s_models = [ @@ -36,6 +37,7 @@ public sealed class SonioxPlugin : ITranscriptionEnginePlugin, IPluginSettingsPr private readonly HttpClient _httpClient; private readonly TimeSpan _pollDelay; private readonly int _maxPollAttempts; + private readonly TimeSpan _cleanupBudget; private readonly SemaphoreSlim _apiKeyWriteLock = new(1, 1); private IPluginHostServices? _host; @@ -49,14 +51,20 @@ public SonioxPlugin() internal SonioxPlugin( HttpClient httpClient, TimeSpan? pollDelay = null, - int maxPollAttempts = DefaultMaxPollAttempts) + int maxPollAttempts = DefaultMaxPollAttempts, + TimeSpan? cleanupBudget = null) { if (maxPollAttempts <= 0) throw new ArgumentOutOfRangeException(nameof(maxPollAttempts), "Poll attempts must be positive."); + var resolvedCleanupBudget = cleanupBudget ?? s_defaultCleanupBudget; + if (resolvedCleanupBudget <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(cleanupBudget), "Cleanup budget must be positive."); + _httpClient = httpClient; _pollDelay = pollDelay ?? s_defaultPollDelay; _maxPollAttempts = maxPollAttempts; + _cleanupBudget = resolvedCleanupBudget; } // ITypeWhisperPlugin @@ -363,38 +371,79 @@ private async Task SendJsonAsync(HttpRequestMessage request, string oper private async Task CleanupAsync(string? transcriptionId, string? fileId, string apiKey) { + using var cleanupCts = new CancellationTokenSource(_cleanupBudget); + var cleanupToken = cleanupCts.Token; + if (transcriptionId is not null) - await DeleteBestEffortAsync($"{BaseUrl}/v1/transcriptions/{transcriptionId}", "transcription", apiKey); + { + var transcriptionDeleted = await DeleteBestEffortAsync( + $"{BaseUrl}/v1/transcriptions/{transcriptionId}", + "transcription", + apiKey, + cleanupToken); + if (transcriptionDeleted) + return; + } - if (fileId is not null) - await DeleteBestEffortAsync($"{BaseUrl}/v1/files/{fileId}", "file", apiKey); + if (fileId is not null && !cleanupToken.IsCancellationRequested) + { + await DeleteBestEffortAsync( + $"{BaseUrl}/v1/files/{fileId}", + "file", + apiKey, + cleanupToken); + } } - private async Task DeleteBestEffortAsync(string uri, string resourceName, string apiKey) + private async Task DeleteBestEffortAsync( + string uri, + string resourceName, + string apiKey, + CancellationToken cleanupToken) { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var request = new HttpRequestMessage(HttpMethod.Delete, uri); AddAuthorization(request, apiKey); try { - using var response = await _httpClient.SendAsync(request, cts.Token); - if (!response.IsSuccessStatusCode) - { - var json = await response.Content.ReadAsStringAsync(cts.Token); - _host?.Log( - PluginLogLevel.Warning, - $"Soniox cleanup could not delete {resourceName}: {(int)response.StatusCode} {ExtractApiError(json)}"); - } + using var response = await _httpClient.SendAsync(request, cleanupToken); + if (response.IsSuccessStatusCode) + return true; + + var json = await response.Content.ReadAsStringAsync(cleanupToken); + _host?.Log( + PluginLogLevel.Warning, + $"Soniox cleanup could not delete {resourceName}: {(int)response.StatusCode} {ExtractApiError(json)}"); } catch (HttpRequestException ex) { - _host?.Log(PluginLogLevel.Warning, $"Soniox cleanup could not delete {resourceName}: {ex.Message}"); + _host?.Log( + PluginLogLevel.Warning, + $"Soniox cleanup could not delete {resourceName} because the HTTP request failed: {ex.Message}"); + } + catch (TimeoutException ex) + { + _host?.Log( + PluginLogLevel.Warning, + $"Soniox cleanup timed out while deleting {resourceName}: {ex.Message}"); } - catch (TaskCanceledException ex) + catch (OperationCanceledException ex) { - _host?.Log(PluginLogLevel.Warning, $"Soniox cleanup could not delete {resourceName}: {ex.Message}"); + var reason = cleanupToken.IsCancellationRequested + ? "the cleanup budget expired" + : $"the request was canceled: {ex.Message}"; + _host?.Log( + PluginLogLevel.Warning, + $"Soniox cleanup could not delete {resourceName} because {reason}."); } + catch (Exception ex) + { + _host?.Log( + PluginLogLevel.Warning, + $"Soniox cleanup could not delete {resourceName} because an unexpected error occurred: {ex.Message}"); + } + + return false; } internal static PluginTranscriptionResult ParseTranscript( diff --git a/tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs index 82b0f8650..48dfe8145 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs @@ -361,7 +361,7 @@ public async Task ValidateApiKeyAsync_UsesModelsEndpointAndBearerHeader() } [Fact] - public async Task TranscribeAsync_UsesAsyncTranscriptionFlowAndCleansUp() + public async Task TranscribeAsync_UsesAsyncTranscriptionFlowAndDeletesOnlyTranscription() { var seen = new List(); var handler = new CapturingHandler((request, body) => @@ -412,7 +412,7 @@ public async Task TranscribeAsync_UsesAsyncTranscriptionFlowAndCleansUp() """); } - return request.Method == HttpMethod.Delete ? JsonResponse("{}") + return request.Method == HttpMethod.Delete ? NoContentResponse() : throw new InvalidOperationException($"Unexpected request: {request.Method} {request.RequestUri}"); }); @@ -429,7 +429,7 @@ public async Task TranscribeAsync_UsesAsyncTranscriptionFlowAndCleansUp() // Tokens are now grouped into subtitle-sized segments rather than one cue per token. Assert.Equal(["Hallo Welt"], result.Segments.Select(s => s.Text).ToArray()); Assert.Contains("DELETE https://api.soniox.com/v1/transcriptions/73d4357d-cad2-4338-a60d-ec6f2044f721", seen); - Assert.Contains("DELETE https://api.soniox.com/v1/files/84c32fc6-4fb5-4e7a-b656-b5ec70493753", seen); + Assert.DoesNotContain("DELETE https://api.soniox.com/v1/files/84c32fc6-4fb5-4e7a-b656-b5ec70493753", seen); } [Fact] @@ -460,7 +460,7 @@ public async Task TranscribeAsync_UsesInitialApiKeyForWholeAsyncFlow() if (request.Method == HttpMethod.Get && request.RequestUri?.AbsolutePath == "/v1/transcriptions/73d4357d-cad2-4338-a60d-ec6f2044f721/transcript") return JsonResponse("""{ "text": "Hello", "tokens": [] }"""); - return request.Method == HttpMethod.Delete ? JsonResponse("{}") + return request.Method == HttpMethod.Delete ? NoContentResponse() : throw new InvalidOperationException($"Unexpected request: {request.Method} {request.RequestUri}"); }); @@ -530,7 +530,7 @@ public async Task TranscribeAsync_RejectsTranslation() } [Fact] - public async Task TranscribeAsync_StatusErrorIncludesSonioxDetailsAndCleansUp() + public async Task TranscribeAsync_StatusErrorIncludesDetailsAndFallsBackToFileDeleteAfterConflict() { var seen = new List(); var handler = new CapturingHandler((request, body) => @@ -555,8 +555,18 @@ public async Task TranscribeAsync_StatusErrorIncludesSonioxDetailsAndCleansUp() """); } - return request.Method == HttpMethod.Delete ? JsonResponse("{}") - : throw new InvalidOperationException($"Unexpected request: {request.Method} {request.RequestUri}; body={Encoding.UTF8.GetString(body ?? [])}"); + if (request.Method == HttpMethod.Delete + && request.RequestUri?.AbsolutePath == "/v1/transcriptions/73d4357d-cad2-4338-a60d-ec6f2044f721") + { + return JsonResponse( + """{ "error_type": "conflict", "message": "Transcription is still processing" }""", + HttpStatusCode.Conflict); + } + + return request.Method == HttpMethod.Delete + && request.RequestUri?.AbsolutePath == "/v1/files/84c32fc6-4fb5-4e7a-b656-b5ec70493753" + ? NoContentResponse() + : throw new InvalidOperationException($"Unexpected request: {request.Method} {request.RequestUri}; body={Encoding.UTF8.GetString(body ?? [])}"); }); var host = new TestPluginHostServices { Secrets = { ["api-key"] = "soniox-key" } }; @@ -572,6 +582,10 @@ public async Task TranscribeAsync_StatusErrorIncludesSonioxDetailsAndCleansUp() Assert.Contains("req-1", ex.Message); Assert.Contains("DELETE https://api.soniox.com/v1/transcriptions/73d4357d-cad2-4338-a60d-ec6f2044f721", seen); Assert.Contains("DELETE https://api.soniox.com/v1/files/84c32fc6-4fb5-4e7a-b656-b5ec70493753", seen); + Assert.Contains( + host.Logs, + log => log.Level == PluginLogLevel.Warning + && log.Message.Contains("409", StringComparison.Ordinal)); } [Fact] @@ -611,7 +625,9 @@ public async Task TranscribeAsync_PollTimeoutThrowsTimeoutException() if (request.Method == HttpMethod.Post && request.RequestUri?.AbsolutePath == "/v1/transcriptions") return JsonResponse("""{ "id": "73d4357d-cad2-4338-a60d-ec6f2044f721", "status": "queued" }""", HttpStatusCode.Created); - return JsonResponse(request.Method == HttpMethod.Get ? """{ "status": "processing" }""" : "{}"); + return request.Method == HttpMethod.Delete + ? NoContentResponse() + : JsonResponse("""{ "status": "processing" }"""); }); var host = new TestPluginHostServices { Secrets = { ["api-key"] = "soniox-key" } }; @@ -625,6 +641,204 @@ public async Task TranscribeAsync_PollTimeoutThrowsTimeoutException() Assert.Contains("did not complete", ex.Message); } + [Fact] + public async Task TranscribeAsync_CleanupIsBoundedBySingleInjectedBudget() + { + var deleteStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var deleteCanceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var deletedPaths = new List(); + var handler = new AsyncCapturingHandler(async (request, _, cancellationToken) => + { + if (request.Method == HttpMethod.Post && request.RequestUri?.AbsolutePath == "/v1/files") + return JsonResponse("""{ "id": "file-1" }""", HttpStatusCode.Created); + + if (request.Method == HttpMethod.Post && request.RequestUri?.AbsolutePath == "/v1/transcriptions") + return JsonResponse("""{ "id": "transcription-1", "status": "queued" }""", HttpStatusCode.Created); + + if (request.Method == HttpMethod.Get + && request.RequestUri?.AbsolutePath == "/v1/transcriptions/transcription-1") + { + return JsonResponse("""{ "status": "error", "error_message": "Primary failure" }"""); + } + + if (request.Method == HttpMethod.Delete) + { + deletedPaths.Add(request.RequestUri!.AbsolutePath); + deleteStarted.TrySetResult(true); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + finally + { + if (cancellationToken.IsCancellationRequested) + deleteCanceled.TrySetResult(true); + } + + return NoContentResponse(); + } + + throw new InvalidOperationException($"Unexpected request: {request.Method} {request.RequestUri}"); + }); + + var host = new TestPluginHostServices { Secrets = { ["api-key"] = "soniox-key" } }; + using var httpClient = new HttpClient(handler); + var sut = new SonioxPlugin( + httpClient, + pollDelay: TimeSpan.Zero, + maxPollAttempts: 2, + cleanupBudget: TimeSpan.FromMilliseconds(100)); + await sut.ActivateAsync(host); + + var transcriptionTask = sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + CancellationToken.None); + + await deleteStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + var ex = await Assert.ThrowsAsync( + () => transcriptionTask.WaitAsync(TimeSpan.FromSeconds(5))); + await deleteCanceled.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.Contains("Primary failure", ex.Message); + Assert.Equal(["/v1/transcriptions/transcription-1"], deletedPaths); + } + + [Fact] + public async Task TranscribeAsync_CancellationAfterResourceCreationSurfacesWithinCleanupBudget() + { + var pollStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleanupStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleanupCanceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cleanupTokenWasCanceledAtStart = false; + var handler = new AsyncCapturingHandler(async (request, _, cancellationToken) => + { + if (request.Method == HttpMethod.Post && request.RequestUri?.AbsolutePath == "/v1/files") + return JsonResponse("""{ "id": "file-1" }""", HttpStatusCode.Created); + + if (request.Method == HttpMethod.Post && request.RequestUri?.AbsolutePath == "/v1/transcriptions") + return JsonResponse("""{ "id": "transcription-1", "status": "queued" }""", HttpStatusCode.Created); + + if (request.Method == HttpMethod.Get + && request.RequestUri?.AbsolutePath == "/v1/transcriptions/transcription-1") + { + pollStarted.TrySetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return JsonResponse("""{ "status": "completed" }"""); + } + + if (request.Method == HttpMethod.Delete + && request.RequestUri?.AbsolutePath == "/v1/transcriptions/transcription-1") + { + cleanupTokenWasCanceledAtStart = cancellationToken.IsCancellationRequested; + cleanupStarted.TrySetResult(true); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + finally + { + if (cancellationToken.IsCancellationRequested) + cleanupCanceled.TrySetResult(true); + } + + return NoContentResponse(); + } + + throw new InvalidOperationException($"Unexpected request: {request.Method} {request.RequestUri}"); + }); + + var host = new TestPluginHostServices { Secrets = { ["api-key"] = "soniox-key" } }; + using var httpClient = new HttpClient(handler); + var sut = new SonioxPlugin( + httpClient, + pollDelay: TimeSpan.Zero, + maxPollAttempts: 2, + cleanupBudget: TimeSpan.FromMilliseconds(100)); + await sut.ActivateAsync(host); + using var callerCts = new CancellationTokenSource(); + + var transcriptionTask = sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + callerCts.Token); + + // ReSharper disable once MethodSupportsCancellation -- wall-clock hang guard; the only in-scope token is callerCts.Token (the token under test), not something to forward here. + await pollStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + // ReSharper disable once MethodHasAsyncOverload -- synchronous Cancel must trip the token before the assertion; CancelAsync would defer it. + callerCts.Cancel(); + // ReSharper disable once MethodSupportsCancellation -- wall-clock hang guard; forwarding callerCts.Token (already cancelled above) would throw immediately instead of awaiting cleanup start. + await cleanupStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); + await Assert.ThrowsAnyAsync( + // ReSharper disable once MethodSupportsCancellation -- wall-clock hang guard; forwarding callerCts.Token (already cancelled) would pre-empt the transcription task's own cancellation under test. + () => transcriptionTask.WaitAsync(TimeSpan.FromSeconds(5))); + // ReSharper disable once MethodSupportsCancellation -- wall-clock hang guard; forwarding callerCts.Token (already cancelled) would throw immediately instead of awaiting cleanup cancellation. + await cleanupCanceled.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.False(cleanupTokenWasCanceledAtStart); + } + + [Fact] + public async Task TranscribeAsync_DeletesUploadedFileWhenTranscriptionCreationFails() + { + var deletedPaths = new List(); + var handler = new CapturingHandler((request, _) => + { + if (request.Method == HttpMethod.Post && request.RequestUri?.AbsolutePath == "/v1/files") + return JsonResponse("""{ "id": "file-1" }""", HttpStatusCode.Created); + + if (request.Method == HttpMethod.Post && request.RequestUri?.AbsolutePath == "/v1/transcriptions") + { + return JsonResponse( + """{ "error_type": "invalid_request", "message": "Creation failed" }""", + HttpStatusCode.BadRequest); + } + + if (request.Method == HttpMethod.Delete) + { + deletedPaths.Add(request.RequestUri!.AbsolutePath); + return NoContentResponse(); + } + + throw new InvalidOperationException($"Unexpected request: {request.Method} {request.RequestUri}"); + }); + + var host = new TestPluginHostServices { Secrets = { ["api-key"] = "soniox-key" } }; + using var httpClient = new HttpClient(handler); + var sut = new SonioxPlugin(httpClient); + await sut.ActivateAsync(host); + + var ex = await Assert.ThrowsAsync(() => + sut.TranscribeAsync([1, 2, 3], "en", translate: false, prompt: null, CancellationToken.None)); + + Assert.Contains("Creation failed", ex.Message); + Assert.Equal(["/v1/files/file-1"], deletedPaths); + } + + [Fact] + public async Task TranscribeAsync_SuccessfulTranscriptionDeleteDoesNotDeleteFile() + { + var handler = new SonioxFlowHandler(_ => { }); + var host = new TestPluginHostServices { Secrets = { ["api-key"] = "soniox-key" } }; + using var httpClient = new HttpClient(handler); + var sut = new SonioxPlugin(httpClient, pollDelay: TimeSpan.Zero, maxPollAttempts: 2); + await sut.ActivateAsync(host); + + var result = await sut.TranscribeAsync( + [1, 2, 3], + "en", + translate: false, + prompt: null, + CancellationToken.None); + + Assert.Equal("Hello", result.Text); + Assert.Equal(["/v1/transcriptions/73d4357d-cad2-4338-a60d-ec6f2044f721"], handler.DeletedPaths); + } + private static SonioxSession.SonioxMessage Final(string text) => new([new SonioxSession.SonioxToken(text, true)], Finished: false, ErrorMessage: null); @@ -645,8 +859,13 @@ private static HttpResponseMessage JsonResponse(string json, HttpStatusCode stat Content = new StringContent(json, Encoding.UTF8, "application/json"), }; + private static HttpResponseMessage NoContentResponse() => + new(HttpStatusCode.NoContent); + private sealed class SonioxFlowHandler(Action inspectCreateBody) : HttpMessageHandler { + public List DeletedPaths { get; } = []; + protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) @@ -667,8 +886,13 @@ protected override async Task SendAsync( if (request.Method == HttpMethod.Get && request.RequestUri?.AbsolutePath == "/v1/transcriptions/73d4357d-cad2-4338-a60d-ec6f2044f721/transcript") return JsonResponse("""{ "text": "Hello", "tokens": [] }"""); - return request.Method == HttpMethod.Delete ? JsonResponse("{}") - : throw new InvalidOperationException($"Unexpected request: {request.Method} {request.RequestUri}"); + if (request.Method == HttpMethod.Delete) + { + DeletedPaths.Add(request.RequestUri!.AbsolutePath); + return NoContentResponse(); + } + + throw new InvalidOperationException($"Unexpected request: {request.Method} {request.RequestUri}"); } } @@ -709,6 +933,7 @@ private sealed class TestPluginHostServices : IPluginHostServices private readonly Dictionary _settings = []; public Dictionary Secrets { get; } = []; + public List<(PluginLogLevel Level, string Message)> Logs { get; } = []; public int NotifyCapabilitiesChangedCount { get; private set; } public Exception? StoreSecretException { get; init; } public Exception? DeleteSecretException { get; set; } @@ -747,7 +972,7 @@ public void SetSetting(string key, T value) => public string? ActiveAppName => null; public IPluginEventBus EventBus { get; } = new TestPluginEventBus(); public IReadOnlyList AvailableProfileNames => []; - public void Log(PluginLogLevel level, string message) { } + public void Log(PluginLogLevel level, string message) => Logs.Add((level, message)); public void NotifyCapabilitiesChanged() => NotifyCapabilitiesChangedCount++; public IPluginLocalization Localization { get; } = new TestPluginLocalization(); } From 43021f4851ca110f56312db4bfa669ca5cd2120e Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 05:57:17 +0000 Subject: [PATCH 185/226] Bound Smallest AI and Reson8 socket disposal with a teardown budget Smallest AI's DisposeAsync waited indefinitely for its send lock, closed with CancellationToken.None, and awaited the receive task without a deadline. Reson8 bounded only the lock wait and then closed unbounded even when it had failed to acquire the lock - a close running concurrently with an in-flight send, which ClientWebSocket does not support. A stuck send or unresponsive peer could hang dictation stop or application exit; Reson8's own streaming transcription path owns its session directly and bypasses the coordinator's bounded disposal helper entirely. Both sessions now share one single-flight disposal design under a two-second teardown budget: the receive loop is cancelled and terminal sources completed first, the send lock is acquired only within the budget, graceful CloseAsync runs only under lock ownership with the bounded token, and lock timeout, close timeout, or close failure invokes Abort. Send and finalize operations are lifetime-tracked, and a cleanup task that observes close, receive, and operation-drain owns all resource disposal - detached as a single observed task if the budget expires, so nothing is disposed while an in-flight operation can still reference it and no exception goes unobserved. Sockets are typed as the abstract WebSocket with connected-session test seams. --- .../Reson8StreamingSession.cs | 283 ++++++++++++++---- .../SmallestAiStreamingSession.cs | 282 +++++++++++++---- .../StreamingProviderDisposalTests.cs | 283 ++++++++++++++++++ 3 files changed, 745 insertions(+), 103 deletions(-) create mode 100644 tests/TypeWhisper.PluginSystem.Tests/StreamingProviderDisposalTests.cs diff --git a/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs b/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs index 9ff265b29..dc1606e7f 100644 --- a/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs @@ -8,15 +8,22 @@ namespace TypeWhisper.Plugin.Reson8; internal sealed class Reson8StreamingSession : IStreamingSession { - private readonly ClientWebSocket _ws; + private const int TeardownTimeoutMs = 2000; + + private readonly WebSocket _ws; private readonly Reson8TranscriptCollector _collector; private readonly CancellationTokenSource _receiveCts = new(); private readonly SemaphoreSlim _sendLock = new(1, 1); private readonly TaskCompletionSource _flushConfirmed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _operationsDrained = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Lock _disposeGate = new(); + private readonly Lock _operationGate = new(); private Task? _receiveTask; + private Task? _disposeTask; + private int _activeOperations; private bool _disposed; - private Reson8StreamingSession(ClientWebSocket ws, Reson8TranscriptCollector collector) + private Reson8StreamingSession(WebSocket ws, Reson8TranscriptCollector collector) { _ws = ws; _collector = collector; @@ -38,6 +45,19 @@ public static async Task ConnectAsync( await ws.ConnectAsync(BuildRealtimeUri(baseUrl, modelId, language), ct); + return CreateStartedSession(ws); + } + + internal static Reson8StreamingSession CreateConnectedSessionForTests(WebSocket ws) + { + if (ws.State != WebSocketState.Open) + throw new InvalidOperationException("The test WebSocket must already be open."); + + return CreateStartedSession(ws); + } + + private static Reson8StreamingSession CreateStartedSession(WebSocket ws) + { var session = new Reson8StreamingSession(ws, new Reson8TranscriptCollector()); session._receiveTask = session.ReceiveLoopAsync(session._receiveCts.Token); return session; @@ -92,42 +112,62 @@ public static IReadOnlyDictionary CreateStreamingHeaders(string public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationToken ct) { - if (_disposed || _ws.State != WebSocketState.Open || pcm16Audio.Length == 0) + if (!TryBeginOperation()) return; - await _sendLock.WaitAsync(ct); try { - if (_ws.State == WebSocketState.Open) - await _ws.SendAsync(pcm16Audio, WebSocketMessageType.Binary, true, ct); + if (_ws.State != WebSocketState.Open || pcm16Audio.Length == 0) + return; + + await _sendLock.WaitAsync(ct); + try + { + if (_ws.State == WebSocketState.Open) + await _ws.SendAsync(pcm16Audio, WebSocketMessageType.Binary, true, ct); + } + finally + { + _sendLock.Release(); + } } finally { - _sendLock.Release(); + EndOperation(); } } public async Task FinalizeAsync(CancellationToken ct) { - if (_disposed || _ws.State != WebSocketState.Open) + if (!TryBeginOperation()) return; - var json = $$"""{"type":"flush_request","id":"{{Guid.NewGuid()}}"}"""; - await _sendLock.WaitAsync(ct); try { - if (_ws.State == WebSocketState.Open) + if (_ws.State != WebSocketState.Open) + return; + + var json = $$"""{"type":"flush_request","id":"{{Guid.NewGuid()}}"}"""; + await _sendLock.WaitAsync(ct); + try { - var payload = Encoding.UTF8.GetBytes(json); - await _ws.SendAsync(payload, WebSocketMessageType.Text, true, ct); + if (_ws.State == WebSocketState.Open) + { + var payload = Encoding.UTF8.GetBytes(json); + await _ws.SendAsync(payload, WebSocketMessageType.Text, true, ct); + } } + finally + { + _sendLock.Release(); + } + + await _flushConfirmed.Task.WaitAsync(ct); } finally { - _sendLock.Release(); + EndOperation(); } - - await _flushConfirmed.Task.WaitAsync(ct); } private async Task ReceiveLoopAsync(CancellationToken ct) @@ -185,35 +225,101 @@ private async Task ReceiveLoopAsync(CancellationToken ct) } } - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - if (_disposed) - return; + Task disposeTask; + TaskCompletionSource? disposeCompletion = null; + lock (_disposeGate) + { + if (_disposeTask is null) + { + disposeCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + _disposeTask = disposeCompletion.Task; + } + + disposeTask = _disposeTask; + } + + if (disposeCompletion is not null) + _ = CompleteDisposalAsync(disposeCompletion); + + return new ValueTask(disposeTask); + } - _disposed = true; - // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. - _receiveCts.Cancel(); + private async Task CompleteDisposalAsync(TaskCompletionSource completion) + { + try + { + await DisposeCoreAsync(); + } + catch (Exception ex) + { + Debug.WriteLine($"Reson8 disposal error: {ex.Message}"); + } + finally + { + completion.TrySetResult(); + } + } + + private async Task DisposeCoreAsync() + { + BeginDisposal(); + using var teardownCts = new CancellationTokenSource( + TimeSpan.FromMilliseconds(TeardownTimeoutMs) + ); + var teardownToken = teardownCts.Token; + + try + { + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. + _receiveCts.Cancel(); + } + catch (Exception ex) + { + Debug.WriteLine($"Reson8 receive cancellation error: {ex.Message}"); + } _flushConfirmed.TrySetResult(); + _ = _flushConfirmed.Task.Exception; - // Bound the wait so a stalled in-flight send can't hang Dispose forever. var sendLockAcquired = false; + var abortInvoked = false; + Task? closeTask = null; + try { - sendLockAcquired = await _sendLock.WaitAsync(TimeSpan.FromSeconds(5)); - if (_ws.State == WebSocketState.Open) + try + { + await _sendLock.WaitAsync(teardownToken); + sendLockAcquired = true; + } + catch (OperationCanceledException) when (teardownToken.IsCancellationRequested) + { + AbortSocket(ref abortInvoked); + } + + if (sendLockAcquired && _ws.State == WebSocketState.Open) { - try { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None); } - catch (OperationCanceledException ex) + try { - Debug.WriteLine($"Reson8 WebSocket close canceled: {ex.Message}"); + closeTask = _ws.CloseAsync( + WebSocketCloseStatus.NormalClosure, + null, + teardownToken + ); + await closeTask.WaitAsync(teardownToken); } - catch (WebSocketException ex) + catch (OperationCanceledException) when (teardownToken.IsCancellationRequested) { - Debug.WriteLine($"Reson8 WebSocket close error: {ex.Message}"); + Debug.WriteLine("Reson8 WebSocket close timed out."); + AbortSocket(ref abortInvoked); } - catch (InvalidOperationException ex) + catch (Exception ex) { - Debug.WriteLine($"Reson8 WebSocket close skipped: {ex.Message}"); + Debug.WriteLine($"Reson8 WebSocket close error: {ex.Message}"); + AbortSocket(ref abortInvoked); } } } @@ -223,30 +329,101 @@ public async ValueTask DisposeAsync() _sendLock.Release(); } - if (_receiveTask is not null) + var cleanupTask = CleanupResourcesAsync(closeTask); + try { - try { await _receiveTask; } - catch (OperationCanceledException ex) - { - Debug.WriteLine($"Reson8 receive loop canceled during dispose: {ex.Message}"); - } - catch (WebSocketException ex) - { - Debug.WriteLine($"Reson8 receive loop closed during dispose: {ex.Message}"); - } - catch (JsonException ex) - { - Debug.WriteLine($"Reson8 receive loop parse error during dispose: {ex.Message}"); - } - catch (InvalidOperationException ex) - { - Debug.WriteLine($"Reson8 receive loop stopped during dispose: {ex.Message}"); - } + await cleanupTask.WaitAsync(teardownToken); + } + catch (OperationCanceledException) when (teardownToken.IsCancellationRequested) + { + AbortSocket(ref abortInvoked); + // Cleanup is deliberately detached after the shared deadline. It + // observes every operation and owns all resource disposal. + _ = cleanupTask; + } + } + + private void BeginDisposal() + { + lock (_operationGate) + { + _disposed = true; + if (_activeOperations == 0) + _operationsDrained.TrySetResult(); + } + } + + private bool TryBeginOperation() + { + lock (_operationGate) + { + if (_disposed) + return false; + + _activeOperations++; + return true; + } + } + + private void EndOperation() + { + lock (_operationGate) + { + _activeOperations--; + if (_disposed && _activeOperations == 0) + _operationsDrained.TrySetResult(); + } + } + + private void AbortSocket(ref bool abortInvoked) + { + if (abortInvoked) + return; + + abortInvoked = true; + try { _ws.Abort(); } + catch (Exception ex) + { + Debug.WriteLine($"Reson8 WebSocket abort error: {ex.Message}"); } + } + + private async Task CleanupResourcesAsync(Task? closeTask) + { + var closeObservation = ObserveOperationAsync(closeTask, "close"); + var sendObservation = ObserveOperationAsync(_operationsDrained.Task, "send"); + var receiveObservation = ObserveOperationAsync(_receiveTask, "receive"); + await Task.WhenAll(closeObservation, sendObservation, receiveObservation); + + TryDispose(_sendLock, "send semaphore"); + TryDispose(_receiveCts, "receive cancellation source"); + TryDispose(_ws, "WebSocket"); + } - _sendLock.Dispose(); - _receiveCts.Dispose(); - _ws.Dispose(); + private static async Task ObserveOperationAsync(Task? operation, string operationName) + { + if (operation is null) + return; + + try + { + await operation; + } + catch (Exception ex) + { + Debug.WriteLine( + $"Reson8 {operationName} operation stopped during disposal: {ex.Message}" + ); + } + } + + private static void TryDispose(IDisposable resource, string resourceName) + { + try { resource.Dispose(); } + catch (Exception ex) + { + Debug.WriteLine($"Reson8 {resourceName} disposal error: {ex.Message}"); + } } } diff --git a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs index 5224098ed..15fc53a1e 100644 --- a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs @@ -8,15 +8,22 @@ namespace TypeWhisper.Plugin.SmallestAi; internal sealed class SmallestAiStreamingSession : IStreamingSession { - private readonly ClientWebSocket _ws; + private const int TeardownTimeoutMs = 2000; + + private readonly WebSocket _ws; private readonly SmallestAiTranscriptCollector _collector; private readonly CancellationTokenSource _receiveCts = new(); private readonly SemaphoreSlim _sendLock = new(1, 1); private readonly TaskCompletionSource _lastResponseReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _operationsDrained = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Lock _disposeGate = new(); + private readonly Lock _operationGate = new(); private Task? _receiveTask; + private Task? _disposeTask; + private int _activeOperations; private bool _disposed; - private SmallestAiStreamingSession(ClientWebSocket ws, SmallestAiTranscriptCollector collector) + private SmallestAiStreamingSession(WebSocket ws, SmallestAiTranscriptCollector collector) { _ws = ws; _collector = collector; @@ -32,6 +39,19 @@ public static async Task ConnectAsync( var ws = CreateConfiguredWebSocket(apiKey); await ws.ConnectAsync(BuildStreamingUri(language, wordTimestamps: true), ct); + return CreateStartedSession(ws); + } + + internal static SmallestAiStreamingSession CreateConnectedSessionForTests(WebSocket ws) + { + if (ws.State != WebSocketState.Open) + throw new InvalidOperationException("The test WebSocket must already be open."); + + return CreateStartedSession(ws); + } + + private static SmallestAiStreamingSession CreateStartedSession(WebSocket ws) + { var session = new SmallestAiStreamingSession(ws, new SmallestAiTranscriptCollector()); session._receiveTask = session.ReceiveLoopAsync(session._receiveCts.Token); return session; @@ -71,42 +91,62 @@ private static ClientWebSocket CreateConfiguredWebSocket(string apiKey) public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationToken ct) { - if (_disposed || _ws.State != WebSocketState.Open || pcm16Audio.Length == 0) + if (!TryBeginOperation()) return; - await _sendLock.WaitAsync(ct); try { - if (_ws.State != WebSocketState.Open) + if (_ws.State != WebSocketState.Open || pcm16Audio.Length == 0) return; - await _ws.SendAsync(pcm16Audio, WebSocketMessageType.Binary, true, ct); + await _sendLock.WaitAsync(ct); + try + { + if (_ws.State != WebSocketState.Open) + return; + + await _ws.SendAsync(pcm16Audio, WebSocketMessageType.Binary, true, ct); + } + finally + { + _sendLock.Release(); + } } finally { - _sendLock.Release(); + EndOperation(); } } public async Task FinalizeAsync(CancellationToken ct) { - if (_disposed || _ws.State != WebSocketState.Open) + if (!TryBeginOperation()) return; - await _sendLock.WaitAsync(ct); try { if (_ws.State != WebSocketState.Open) return; - await SendTextAsync("""{"type":"close_stream"}""", ct); + await _sendLock.WaitAsync(ct); + try + { + if (_ws.State != WebSocketState.Open) + return; + + await SendTextAsync("""{"type":"close_stream"}""", ct); + } + finally + { + _sendLock.Release(); + } + + await _lastResponseReceived.Task.WaitAsync(ct); } finally { - _sendLock.Release(); + EndOperation(); } - - await _lastResponseReceived.Task.WaitAsync(ct); } private async Task SendTextAsync(string json, CancellationToken ct) @@ -172,65 +212,207 @@ private async Task ReceiveLoopAsync(CancellationToken ct) } } - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - if (_disposed) - return; + Task disposeTask; + TaskCompletionSource? disposeCompletion = null; + lock (_disposeGate) + { + if (_disposeTask is null) + { + disposeCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + _disposeTask = disposeCompletion.Task; + } + + disposeTask = _disposeTask; + } - _disposed = true; - // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. - _receiveCts.Cancel(); + if (disposeCompletion is not null) + _ = CompleteDisposalAsync(disposeCompletion); + + return new ValueTask(disposeTask); + } + + private async Task CompleteDisposalAsync(TaskCompletionSource completion) + { + try + { + await DisposeCoreAsync(); + } + catch (Exception ex) + { + Debug.WriteLine($"Smallest AI Pulse disposal error: {ex.Message}"); + } + finally + { + completion.TrySetResult(); + } + } + + private async Task DisposeCoreAsync() + { + BeginDisposal(); + using var teardownCts = new CancellationTokenSource( + TimeSpan.FromMilliseconds(TeardownTimeoutMs) + ); + var teardownToken = teardownCts.Token; + + try + { + // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here. + _receiveCts.Cancel(); + } + catch (Exception ex) + { + Debug.WriteLine($"Smallest AI Pulse receive cancellation error: {ex.Message}"); + } _lastResponseReceived.TrySetResult(); + _ = _lastResponseReceived.Task.Exception; + + var sendLockAcquired = false; + var abortInvoked = false; + Task? closeTask = null; - await _sendLock.WaitAsync(CancellationToken.None); try { - if (_ws.State == WebSocketState.Open) + try + { + await _sendLock.WaitAsync(teardownToken); + sendLockAcquired = true; + } + catch (OperationCanceledException) when (teardownToken.IsCancellationRequested) + { + AbortSocket(ref abortInvoked); + } + + if (sendLockAcquired && _ws.State == WebSocketState.Open) { - try { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None); } - catch (OperationCanceledException ex) + try { - Debug.WriteLine($"Smallest AI Pulse WebSocket close canceled: {ex.Message}"); + closeTask = _ws.CloseAsync( + WebSocketCloseStatus.NormalClosure, + null, + teardownToken + ); + await closeTask.WaitAsync(teardownToken); } - catch (WebSocketException ex) + catch (OperationCanceledException) when (teardownToken.IsCancellationRequested) { - Debug.WriteLine($"Smallest AI Pulse WebSocket close error: {ex.Message}"); + Debug.WriteLine("Smallest AI Pulse WebSocket close timed out."); + AbortSocket(ref abortInvoked); } - catch (InvalidOperationException ex) + catch (Exception ex) { - Debug.WriteLine($"Smallest AI Pulse WebSocket close skipped: {ex.Message}"); + Debug.WriteLine($"Smallest AI Pulse WebSocket close error: {ex.Message}"); + AbortSocket(ref abortInvoked); } } } finally { - _sendLock.Release(); + if (sendLockAcquired) + _sendLock.Release(); } - if (_receiveTask is not null) + var cleanupTask = CleanupResourcesAsync(closeTask); + try { - try { await _receiveTask; } - catch (OperationCanceledException ex) - { - Debug.WriteLine($"Smallest AI Pulse receive loop canceled during dispose: {ex.Message}"); - } - catch (WebSocketException ex) - { - Debug.WriteLine($"Smallest AI Pulse receive loop closed during dispose: {ex.Message}"); - } - catch (JsonException ex) - { - Debug.WriteLine($"Smallest AI Pulse receive loop parse error during dispose: {ex.Message}"); - } - catch (InvalidOperationException ex) - { - Debug.WriteLine($"Smallest AI Pulse receive loop stopped during dispose: {ex.Message}"); - } + await cleanupTask.WaitAsync(teardownToken); + } + catch (OperationCanceledException) when (teardownToken.IsCancellationRequested) + { + AbortSocket(ref abortInvoked); + // Cleanup is deliberately detached after the shared deadline. It + // observes every operation and owns all resource disposal. + _ = cleanupTask; + } + } + + private void BeginDisposal() + { + lock (_operationGate) + { + _disposed = true; + if (_activeOperations == 0) + _operationsDrained.TrySetResult(); + } + } + + private bool TryBeginOperation() + { + lock (_operationGate) + { + if (_disposed) + return false; + + _activeOperations++; + return true; + } + } + + private void EndOperation() + { + lock (_operationGate) + { + _activeOperations--; + if (_disposed && _activeOperations == 0) + _operationsDrained.TrySetResult(); + } + } + + private void AbortSocket(ref bool abortInvoked) + { + if (abortInvoked) + return; + + abortInvoked = true; + try { _ws.Abort(); } + catch (Exception ex) + { + Debug.WriteLine($"Smallest AI Pulse WebSocket abort error: {ex.Message}"); } + } + + private async Task CleanupResourcesAsync(Task? closeTask) + { + var closeObservation = ObserveOperationAsync(closeTask, "close"); + var sendObservation = ObserveOperationAsync(_operationsDrained.Task, "send"); + var receiveObservation = ObserveOperationAsync(_receiveTask, "receive"); + await Task.WhenAll(closeObservation, sendObservation, receiveObservation); + + TryDispose(_sendLock, "send semaphore"); + TryDispose(_receiveCts, "receive cancellation source"); + TryDispose(_ws, "WebSocket"); + } - _sendLock.Dispose(); - _receiveCts.Dispose(); - _ws.Dispose(); + private static async Task ObserveOperationAsync(Task? operation, string operationName) + { + if (operation is null) + return; + + try + { + await operation; + } + catch (Exception ex) + { + Debug.WriteLine( + $"Smallest AI Pulse {operationName} operation stopped during disposal: {ex.Message}" + ); + } + } + + private static void TryDispose(IDisposable resource, string resourceName) + { + try { resource.Dispose(); } + catch (Exception ex) + { + Debug.WriteLine( + $"Smallest AI Pulse {resourceName} disposal error: {ex.Message}" + ); + } } } diff --git a/tests/TypeWhisper.PluginSystem.Tests/StreamingProviderDisposalTests.cs b/tests/TypeWhisper.PluginSystem.Tests/StreamingProviderDisposalTests.cs new file mode 100644 index 000000000..830de753e --- /dev/null +++ b/tests/TypeWhisper.PluginSystem.Tests/StreamingProviderDisposalTests.cs @@ -0,0 +1,283 @@ +using System.Net.WebSockets; +using TypeWhisper.PluginSDK; +using Reson8Session = TypeWhisper.Plugin.Reson8.Reson8StreamingSession; +using SmallestAiSession = TypeWhisper.Plugin.SmallestAi.SmallestAiStreamingSession; + +namespace TypeWhisper.PluginSystem.Tests; + +public sealed class StreamingProviderDisposalTests +{ + private static readonly TimeSpan s_signalTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan s_disposalGuard = TimeSpan.FromSeconds(5); + + [Fact] + public Task SmallestAi_SendHoldingLock_AbortsWithoutConcurrentClose() => + AssertSendHoldingLockAsync(SmallestAiSession.CreateConnectedSessionForTests); + + [Fact] + public Task Reson8_SendHoldingLock_AbortsWithoutConcurrentClose() => + AssertSendHoldingLockAsync(Reson8Session.CreateConnectedSessionForTests); + + [Fact] + public Task SmallestAi_CloseNeverCompletes_AbortsWithinTeardownBudget() => + AssertCloseNeverCompletesAsync(SmallestAiSession.CreateConnectedSessionForTests); + + [Fact] + public Task Reson8_CloseNeverCompletes_AbortsWithinTeardownBudget() => + AssertCloseNeverCompletesAsync(Reson8Session.CreateConnectedSessionForTests); + + [Fact] + public Task SmallestAi_GracefulClose_CompletesWithoutAbort() => + AssertGracefulCloseAsync(SmallestAiSession.CreateConnectedSessionForTests); + + [Fact] + public Task Reson8_GracefulClose_CompletesWithoutAbort() => + AssertGracefulCloseAsync(Reson8Session.CreateConnectedSessionForTests); + + [Fact] + public Task SmallestAi_DeferredCleanup_ObservesLateFaultAndDefersResourceDisposal() => + AssertDeferredCleanupAsync(SmallestAiSession.CreateConnectedSessionForTests); + + [Fact] + public Task Reson8_DeferredCleanup_ObservesLateFaultAndDefersResourceDisposal() => + AssertDeferredCleanupAsync(Reson8Session.CreateConnectedSessionForTests); + + private static async Task AssertSendHoldingLockAsync( + Func createSession) + { + var socket = new FakeWebSocket(blockSend: true); + var session = createSession(socket); + var sendTask = session.SendAudioAsync(new byte[] { 1, 2 }, CancellationToken.None); + await socket.SendStarted.WaitAsync(s_signalTimeout); + + var disposal = session.DisposeAsync().AsTask(); + await disposal.WaitAsync(s_disposalGuard); + + Assert.True(socket.AbortCalled); + Assert.Equal(0, socket.CloseCallCount); + Assert.False(socket.CloseCalledWhileSendActive); + Assert.False(socket.DisposeCalled); + + socket.ReleaseSend(); + await sendTask.WaitAsync(s_signalTimeout); + await socket.Disposed.WaitAsync(s_signalTimeout); + + Assert.False(socket.DisposedWhileSendActive); + await session.DisposeAsync(); + Assert.Equal(1, socket.AbortCallCount); + Assert.Equal(1, socket.DisposeCallCount); + } + + private static async Task AssertCloseNeverCompletesAsync( + Func createSession) + { + var socket = new FakeWebSocket(blockCloseUntilAbort: true); + var session = createSession(socket); + + var disposal = session.DisposeAsync().AsTask(); + await socket.CloseStarted.WaitAsync(s_signalTimeout); + await disposal.WaitAsync(s_disposalGuard); + await socket.Disposed.WaitAsync(s_signalTimeout); + + Assert.True(socket.AbortCalled); + Assert.Equal(1, socket.CloseCallCount); + Assert.False(socket.CloseCalledWhileSendActive); + Assert.Equal(1, socket.DisposeCallCount); + } + + private static async Task AssertGracefulCloseAsync( + Func createSession) + { + var socket = new FakeWebSocket(); + var session = createSession(socket); + + var firstDisposal = session.DisposeAsync().AsTask(); + var secondDisposal = session.DisposeAsync().AsTask(); + await Task.WhenAll(firstDisposal, secondDisposal).WaitAsync(s_disposalGuard); + await session.DisposeAsync(); + + Assert.Equal(1, socket.CloseCallCount); + Assert.Equal(0, socket.AbortCallCount); + Assert.Equal(1, socket.DisposeCallCount); + Assert.False(socket.CloseCalledWhileSendActive); + } + + private static async Task AssertDeferredCleanupAsync( + Func createSession) + { + var socket = new FakeWebSocket( + blockSend: true, + deferReceiveFailure: true + ); + var session = createSession(socket); + await socket.ReceiveStarted.WaitAsync(s_signalTimeout); + + var sendTask = session.SendAudioAsync(new byte[] { 1, 2 }, CancellationToken.None); + await socket.SendStarted.WaitAsync(s_signalTimeout); + + await session.DisposeAsync().AsTask().WaitAsync(s_disposalGuard); + Assert.True(socket.AbortCalled); + Assert.False(socket.DisposeCalled); + Assert.False(socket.CloseCalledWhileSendActive); + + socket.FailReceive(); + socket.ReleaseSend(); + await sendTask.WaitAsync(s_signalTimeout); + await socket.Disposed.WaitAsync(s_signalTimeout); + + Assert.False(socket.DisposedWhileSendActive); + Assert.Equal(1, socket.DisposeCallCount); + } + + private sealed class FakeWebSocket( + bool blockSend = false, + bool blockCloseUntilAbort = false, + bool deferReceiveFailure = false) : WebSocket + { + private readonly TaskCompletionSource _abortSignal = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _closeStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _disposed = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _receiveStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _receiveRelease = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _sendStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _sendRelease = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _abortCallCount; + private int _activeSends; + private int _closeCallCount; + private int _disposeCallCount; + private int _state = (int)WebSocketState.Open; + + public Task CloseStarted => _closeStarted.Task; + public Task Disposed => _disposed.Task; + public Task ReceiveStarted => _receiveStarted.Task; + public Task SendStarted => _sendStarted.Task; + public int AbortCallCount => Volatile.Read(ref _abortCallCount); + public bool AbortCalled => AbortCallCount > 0; + public int CloseCallCount => Volatile.Read(ref _closeCallCount); + public bool CloseCalledWhileSendActive { get; private set; } + public int DisposeCallCount => Volatile.Read(ref _disposeCallCount); + public bool DisposeCalled => DisposeCallCount > 0; + public bool DisposedWhileSendActive { get; private set; } + public override WebSocketCloseStatus? CloseStatus => null; + public override string? CloseStatusDescription => null; + public override WebSocketState State => + (WebSocketState)Volatile.Read(ref _state); + public override string? SubProtocol => null; + + public void FailReceive() + { + Assert.True( + ReceiveStarted.IsCompleted, + "The receive operation did not reach its test signal." + ); + _receiveRelease.TrySetResult(); + } + + public void ReleaseSend() + { + Assert.True( + SendStarted.IsCompleted, + "The send operation did not reach its test signal." + ); + _sendRelease.TrySetResult(); + } + + public override void Abort() + { + Interlocked.Increment(ref _abortCallCount); + Interlocked.Exchange(ref _state, (int)WebSocketState.Aborted); + _abortSignal.TrySetResult(); + } + + public override async Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref _closeCallCount); + _closeStarted.TrySetResult(); + if (Volatile.Read(ref _activeSends) > 0) + CloseCalledWhileSendActive = true; + + if (blockCloseUntilAbort) + { + // Model a peer that ignores the close cancellation. Abort is + // what finally unwinds the pending WebSocket operation. + await _abortSignal.Task; + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Exchange(ref _state, (int)WebSocketState.Closed); + } + + public override Task CloseOutputAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) => + CloseAsync(closeStatus, statusDescription, cancellationToken); + + public override void Dispose() + { + if (Volatile.Read(ref _activeSends) > 0) + DisposedWhileSendActive = true; + + Interlocked.Increment(ref _disposeCallCount); + Interlocked.Exchange(ref _state, (int)WebSocketState.Closed); + _disposed.TrySetResult(); + } + + public override async Task ReceiveAsync( + ArraySegment buffer, + CancellationToken cancellationToken) + { + _receiveStarted.TrySetResult(); + if (deferReceiveFailure) + { + await _receiveRelease.Task; + throw new ApplicationException("Synthetic late receive failure."); + } + + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("The cancellable receive unexpectedly completed."); + } + + public override Task SendAsync( + ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken) => + SendCoreAsync(cancellationToken); + + public override ValueTask SendAsync( + ReadOnlyMemory buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken) => + new(SendCoreAsync(cancellationToken)); + + private async Task SendCoreAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref _activeSends); + _sendStarted.TrySetResult(); + try + { + if (blockSend) + await _sendRelease.Task; + } + finally + { + Interlocked.Decrement(ref _activeSends); + } + } + + } +} From 2d17b7f67a8eceee9de0b89382e11717a03ce544 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 06:09:39 +0000 Subject: [PATCH 186/226] Store webhook header values as host secrets instead of plaintext Webhook header values - including Authorization bearer tokens - were serialized verbatim into webhooks.json, echoed unredacted back to the settings UI, and the atomic-replace temp file was created with default permissions. Configurations now persist only header names and deterministic secret references (webhook-header:{id}:{lowercase-name}); every user-entered value goes through the host secret store, with references resolved immediately before building the outbound request and delivery failing closed when a secret is missing. The UI receives a redaction placeholder; submitting it unchanged preserves the existing reference for that webhook/header, while a placeholder on a new header is a literal value. Saves store replacement secrets first, atomically commit the configuration through a CreateNew temp file created 0600 on Unix, then delete obsolete secrets - a deletion failure orphans a secret rather than breaking a webhook. Activation tightens the legacy file to 0600 before reading, migrates plaintext headers into secrets, and rewrites the configuration; while unmigrated legacy headers exist, saves fail closed instead of silently dropping them (editing a never-activated instance previously erased legacy auth headers on any unrelated save). Missing host services and secret-store or config-write failures all fail closed with no plaintext fallback. --- .../TypeWhisper.Plugin.Webhook.csproj | 3 + .../WebhookPlugin.cs | 443 +++++++++++++-- .../WebhookCollectionSettingsTests.cs | 527 +++++++++++++++++- 3 files changed, 917 insertions(+), 56 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.Webhook/TypeWhisper.Plugin.Webhook.csproj b/plugins/TypeWhisper.Plugin.Webhook/TypeWhisper.Plugin.Webhook.csproj index c2303d8e5..49743baab 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/TypeWhisper.Plugin.Webhook.csproj +++ b/plugins/TypeWhisper.Plugin.Webhook/TypeWhisper.Plugin.Webhook.csproj @@ -6,6 +6,9 @@ latest TypeWhisper.Plugin.Webhook + + + diff --git a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs index 0a2912010..52db07ed0 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs @@ -19,7 +19,9 @@ public sealed record WebhookConfig public string Name { get; init; } = ""; public string Url { get; init; } = ""; public string HttpMethod { get; init; } = "POST"; - public Dictionary Headers { get; init; } = []; + public Dictionary HeaderSecretReferences { get; init; } = []; + [JsonIgnore] + internal Dictionary LegacyHeaders { get; init; } = []; public bool IsEnabled { get; init; } = true; // ReSharper disable once TypeWithSuspiciousEqualityIsUsedInRecord.Global -- config record identity is its Id; the collection members are never compared by value. public List ProfileFilter { get; init; } = []; @@ -42,6 +44,42 @@ public sealed record DeliveryLogEntry /// internal sealed class WebhookStore { + // ReSharper disable AutoPropertyCanBeMadeGetOnly.Local -- init accessors are set by System.Text.Json deserialization via reflection, invisible to ReSharper's usage analysis. + // ReSharper disable MemberCanBePrivate.Local -- properties must stay public for System.Text.Json to deserialize into them. + // ReSharper disable once ClassNeverInstantiated.Local -- instantiated by System.Text.Json deserialization, which ReSharper cannot see. + private sealed record StoredWebhookConfig + { + public Guid Id { get; init; } = Guid.NewGuid(); + public string Name { get; init; } = ""; + public string Url { get; init; } = ""; + public string HttpMethod { get; init; } = "POST"; + public Dictionary HeaderSecretReferences { get; init; } = []; + public Dictionary Headers { get; init; } = []; + public bool IsEnabled { get; init; } = true; + public List ProfileFilter { get; init; } = []; + + public WebhookConfig ToConfig() => + new() + { + Id = Id, + Name = Name, + Url = Url, + HttpMethod = HttpMethod, + HeaderSecretReferences = new Dictionary( + HeaderSecretReferences, + StringComparer.OrdinalIgnoreCase + ), + LegacyHeaders = new Dictionary( + Headers, + StringComparer.OrdinalIgnoreCase + ), + IsEnabled = IsEnabled, + ProfileFilter = ProfileFilter, + }; + } + // ReSharper restore MemberCanBePrivate.Local + // ReSharper restore AutoPropertyCanBeMadeGetOnly.Local + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true, @@ -60,20 +98,34 @@ public WebhookStore(string dataDir) /// Loads stored configs; returns an empty list only when the file does not /// exist. Read or JSON-parse failures propagate so the caller can log them /// rather than mistaking a corrupt file for "no webhooks" and overwriting it. + /// Legacy plaintext headers deserialize into . + /// When is true, the file is set to + /// 0600 before it is read. /// - public List Load() + public List Load(bool protectExistingFile = false) { if (!File.Exists(_configPath)) return []; + if (protectExistingFile && !OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + _configPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite + ); + } + var json = File.ReadAllText(_configPath); - return JsonSerializer.Deserialize>(json, s_jsonOptions) ?? []; + return ( + JsonSerializer.Deserialize>(json, s_jsonOptions) ?? [] + ).Select(config => config.ToConfig()).ToList(); } /// /// Persists the supplied configs, creating the data directory if needed. /// Writes through a sibling temp file and renames it over the target so a - /// crash or kill mid-write can't truncate webhooks.json. + /// crash or kill mid-write can't truncate webhooks.json. The temp file is + /// created with 0600 permissions before the rename. /// public void Save(IEnumerable configs) { @@ -85,7 +137,22 @@ public void Save(IEnumerable configs) try { - File.WriteAllText(tempPath, json); + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + }; + if (!OperatingSystem.IsWindows()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + } + + using (var stream = new FileStream(tempPath, options)) + using (var writer = new StreamWriter(stream, new UTF8Encoding(false))) + { + writer.Write(json); + } if (File.Exists(_configPath)) { @@ -125,7 +192,7 @@ public sealed class WebhookService DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; - private readonly HttpClient _httpClient = new(); + private readonly HttpClient _httpClient; private readonly IPluginHostServices _host; private readonly WebhookStore _store; // Guards every mutation and enumeration of Webhooks so the @@ -146,10 +213,25 @@ public sealed class WebhookService public ObservableCollection DeliveryLog { get; } = []; public WebhookService(IPluginHostServices host, string dataDirectory) + : this(host, dataDirectory, new HttpClient()) { } + + internal WebhookService( + IPluginHostServices host, + string dataDirectory, + HttpMessageHandler handler + ) + : this(host, dataDirectory, new HttpClient(handler)) { } + + private WebhookService( + IPluginHostServices host, + string dataDirectory, + HttpClient httpClient + ) { _host = host; _store = new WebhookStore(dataDirectory); - Load(); + _httpClient = httpClient; + Load(protectExistingFile: true); } public void AddWebhook(WebhookConfig config) @@ -305,10 +387,22 @@ bool retryOnFailure ? HttpMethod.Put : HttpMethod.Post; + var resolvedHeaders = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + foreach (var header in webhook.HeaderSecretReferences) + { + resolvedHeaders[header.Key] = + await _host.LoadSecretAsync(header.Value) + ?? throw new InvalidOperationException( + $"Secure value for webhook header '{header.Key}' is unavailable." + ); + } + using var request = new HttpRequestMessage(method, webhook.Url); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - foreach (var header in webhook.Headers) + foreach (var header in resolvedHeaders) request.Headers.TryAddWithoutValidation(header.Key, header.Value); using var response = await _httpClient.SendAsync(request); @@ -373,12 +467,89 @@ private void AddLogEntry(DeliveryLogEntry entry) DeliveryLog.RemoveAt(DeliveryLog.Count - 1); } - private void Load() + internal async Task MigrateLegacyHeadersAsync() + { + List previous; + lock (_webhooksLock) + previous = Webhooks.ToList(); + + if (previous.All(config => config.LegacyHeaders.Count == 0)) + return; + + try + { + var migrated = new List(previous.Count); + foreach (var config in previous) + { + var references = new Dictionary( + config.HeaderSecretReferences, + StringComparer.OrdinalIgnoreCase + ); + foreach (var header in config.LegacyHeaders) + { + var reference = WebhookPlugin.GetHeaderSecretReference( + config.Id, + header.Key + ); + await _host.StoreSecretAsync(reference, header.Value); + references[header.Key] = reference; + } + + migrated.Add( + config with + { + HeaderSecretReferences = references, + LegacyHeaders = [], + } + ); + } + + Save(migrated); + lock (_webhooksLock) + { + Webhooks.Clear(); + foreach (var config in migrated) + Webhooks.Add(config); + } + + var obsoleteReferences = previous + .SelectMany(config => config.HeaderSecretReferences.Values) + .Except( + migrated.SelectMany(config => config.HeaderSecretReferences.Values), + StringComparer.Ordinal + ) + .ToList(); + foreach (var reference in obsoleteReferences) + { + try + { + await _host.DeleteSecretAsync(reference); + } + catch (Exception ex) + { + _host.Log( + PluginLogLevel.Warning, + $"Failed to delete obsolete webhook header secret: {ex.Message}" + ); + } + } + } + catch (Exception ex) + { + _host.Log( + PluginLogLevel.Warning, + $"Failed to migrate webhook header secrets: {ex.Message}" + ); + throw; + } + } + + private void Load(bool protectExistingFile) { List loaded; try { - loaded = _store.Load(); + loaded = _store.Load(protectExistingFile); } catch (Exception ex) { @@ -436,8 +607,11 @@ public sealed class WebhookPlugin IPluginDataLocationAware, IPluginLocalizationAware { + internal const string StoredHeaderPlaceholder = ""; + private IDisposable? _subscription; private string? _dataDirectory; + private readonly SemaphoreSlim _settingsSaveLock = new(1, 1); public string PluginId => "com.typewhisper.webhook"; public string PluginName => "Webhook"; @@ -445,7 +619,7 @@ public sealed class WebhookPlugin public WebhookService? Service { get; private set; } - public Task ActivateAsync(IPluginHostServices host) + public async Task ActivateAsync(IPluginHostServices host) { Host = host; // Single canonical data dir: prefer the one set via SetDataDirectory @@ -455,11 +629,21 @@ public Task ActivateAsync(IPluginHostServices host) // the live service and any on-disk fallback path reading/writing the // same webhooks.json. _dataDirectory ??= host.PluginDataDirectory; - Service = new WebhookService(host, _dataDirectory); - _subscription = host.EventBus.Subscribe( - OnTranscriptionCompleted - ); - return Task.CompletedTask; + var service = new WebhookService(host, _dataDirectory); + try + { + await service.MigrateLegacyHeadersAsync(); + Service = service; + _subscription = host.EventBus.Subscribe( + OnTranscriptionCompleted + ); + } + catch + { + service.Dispose(); + Host = null; + throw; + } } public Task DeactivateAsync() @@ -529,6 +713,7 @@ public IReadOnlyList GetCollectionDefinitions() => new PluginSettingDefinition( "headers", Loc.L("Settings.Headers"), + IsSecret: true, Description: Loc.L("Settings.HeadersDescription"), Kind: PluginSettingKind.Multiline ), @@ -570,7 +755,7 @@ public Task> GetItemsAsync( { try { - source = new WebhookStore(ResolveDataDir()).Load(); + source = new WebhookStore(ResolveDataDir()).Load(protectExistingFile: true); } catch (Exception ex) { @@ -586,7 +771,7 @@ public Task> GetItemsAsync( ["name"] = c.Name, ["url"] = c.Url, ["method"] = c.HttpMethod, - ["headers"] = SerializeHeaders(c.Headers), + ["headers"] = SerializeStoredHeaders(c), ["profiles"] = SerializeProfiles(c.ProfileFilter), ["enabled"] = c.IsEnabled ? "true" : "false", ["__id"] = c.Id.ToString("D"), @@ -597,18 +782,19 @@ public Task> GetItemsAsync( return Task.FromResult(items); } - public Task SetItemsAsync( + public async Task SetItemsAsync( string collectionKey, IReadOnlyList items, CancellationToken ct = default ) { if (collectionKey != "webhooks") - return Task.FromResult( - new PluginSettingsValidationResult(false, Loc.L("Settings.UnknownCollection")) + return new PluginSettingsValidationResult( + false, + Loc.L("Settings.UnknownCollection") ); - var configs = new List(items.Count); + var parsedItems = new List(items.Count); foreach (var item in items) { @@ -642,45 +828,165 @@ public Task SetItemsAsync( var id = Guid.TryParse(Get(item, "__id"), out var parsedId) ? parsedId : Guid.NewGuid(); - configs.Add( - new WebhookConfig - { - Id = id, - Name = name, - Url = url, - HttpMethod = method, - Headers = headers, - ProfileFilter = ParseProfiles(Get(item, "profiles") ?? ""), - IsEnabled = enabled, - } + parsedItems.Add( + new ParsedWebhookItem( + new WebhookConfig + { + Id = id, + Name = name, + Url = url, + HttpMethod = method, + ProfileFilter = ParseProfiles(Get(item, "profiles") ?? ""), + IsEnabled = enabled, + }, + headers + ) ); } + await _settingsSaveLock.WaitAsync(ct); try { - if (Service is not null) - Service.ReplaceAll(configs); - else - new WebhookStore(ResolveDataDir()).Save(configs); - } - catch (Exception ex) - { - return Task.FromResult( - new PluginSettingsValidationResult( + try + { + var existing = Service is not null + ? Service.SnapshotWebhooks() + : new WebhookStore(ResolveDataDir()).Load(protectExistingFile: true); + + // Legacy plaintext headers only load into LegacyHeaders and are + // never surfaced by GetItems, so an unrelated edit saved before + // activation would rewrite the config without them and silently + // destroy the headers. Fail closed until the plugin is activated + // and MigrateLegacyHeadersAsync moves them into the secret store. + if (existing.Any(config => config.LegacyHeaders.Count > 0)) + { + throw new InvalidOperationException( + "Webhook header values require activated host secret services." + ); + } + + var existingById = existing + .GroupBy(config => config.Id) + .ToDictionary(group => group.Key, group => group.First()); + + var configs = new List(parsedItems.Count); + var pendingSecretWrites = new List<(string Reference, string Value)>(); + + foreach (var parsedItem in parsedItems) + { + existingById.TryGetValue(parsedItem.Config.Id, out var existingConfig); + var references = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + + foreach (var header in parsedItem.HeaderValues) + { + string? existingReference = null; + var hasExistingReference = + existingConfig is not null + && TryGetHeaderSecretReference( + existingConfig.HeaderSecretReferences, + header.Key, + out existingReference + ); + + string reference; + if ( + header.Value == StoredHeaderPlaceholder + && hasExistingReference + ) + { + reference = existingReference!; + } + else + { + reference = GetHeaderSecretReference( + parsedItem.Config.Id, + header.Key + ); + pendingSecretWrites.Add((reference, header.Value)); + } + + references[header.Key] = reference; + } + + configs.Add( + parsedItem.Config with + { + HeaderSecretReferences = references, + } + ); + } + + var newReferences = configs + .SelectMany(config => config.HeaderSecretReferences.Values) + .ToHashSet(StringComparer.Ordinal); + var obsoleteReferences = existing + .SelectMany(config => config.HeaderSecretReferences.Values) + .Where(reference => !newReferences.Contains(reference)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if ( + Host is null + && (pendingSecretWrites.Count > 0 || obsoleteReferences.Count > 0) + ) + { + throw new InvalidOperationException( + "Webhook header values require activated host secret services." + ); + } + + foreach (var (reference, value) in pendingSecretWrites) + await Host!.StoreSecretAsync(reference, value); + + if (Service is not null) + Service.ReplaceAll(configs); + else + new WebhookStore(ResolveDataDir()).Save(configs); + + foreach (var reference in obsoleteReferences) + { + try + { + await Host!.DeleteSecretAsync(reference); + } + catch (Exception ex) + { + Host!.Log( + PluginLogLevel.Warning, + $"Failed to delete obsolete webhook header secret: {ex.Message}" + ); + } + } + } + catch (Exception ex) + { + return new PluginSettingsValidationResult( false, Loc.L("Settings.FailedToSaveSettings", ex.Message) - ) - ); + ); + } + } + finally + { + _settingsSaveLock.Release(); } - return Task.FromResult(new PluginSettingsValidationResult(true, Loc.L("Settings.Saved"))); + return new PluginSettingsValidationResult(true, Loc.L("Settings.Saved")); - Task Fail(string label, string reason) => - Task.FromResult( - new PluginSettingsValidationResult(false, Loc.L("Settings.WebhookLabelReason", label, reason)) + PluginSettingsValidationResult Fail(string label, string reason) => + new( + false, + Loc.L("Settings.WebhookLabelReason", label, reason) ); } + private sealed record ParsedWebhookItem( + WebhookConfig Config, + Dictionary HeaderValues + ); + private static string? Get(PluginCollectionItem item, string key) => item.Values.GetValueOrDefault(key); @@ -693,9 +999,42 @@ private static bool TryGetBool(PluginCollectionItem item, string key, out bool v return false; } - /// Serializes headers to one Name: Value line each. - internal static string SerializeHeaders(IReadOnlyDictionary headers) => - string.Join("\n", headers.Select(h => $"{h.Key}: {h.Value}")); + internal static string GetHeaderSecretReference(Guid webhookId, string headerName) => + $"webhook-header:{webhookId:N}:{NormalizeHeaderName(headerName)}"; + + private static string NormalizeHeaderName(string headerName) => + headerName.Trim().ToLowerInvariant(); + + private static bool TryGetHeaderSecretReference( + IReadOnlyDictionary references, + string headerName, + out string? reference + ) + { + var normalizedName = NormalizeHeaderName(headerName); + foreach (var candidate in references) + { + if (NormalizeHeaderName(candidate.Key) == normalizedName) + { + reference = candidate.Value; + return true; + } + } + + reference = null; + return false; + } + + /// Serializes stored header names with a redacted value placeholder. + internal static string SerializeStoredHeaders(WebhookConfig config) + { + return string.Join( + "\n", + config.HeaderSecretReferences.Keys.Select( + name => $"{name}: {StoredHeaderPlaceholder}" + ) + ); + } /// /// Parses multiline header text. Each non-blank line is split on the first @@ -708,7 +1047,7 @@ internal static bool TryParseHeaders( IPluginLocalization? loc = null ) { - headers = []; + headers = new Dictionary(StringComparer.OrdinalIgnoreCase); error = ""; if (string.IsNullOrWhiteSpace(text)) diff --git a/tests/TypeWhisper.PluginSystem.Tests/WebhookCollectionSettingsTests.cs b/tests/TypeWhisper.PluginSystem.Tests/WebhookCollectionSettingsTests.cs index 10ebc992a..8c4006317 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/WebhookCollectionSettingsTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/WebhookCollectionSettingsTests.cs @@ -1,7 +1,10 @@ +using System.Net; +using System.Text.Json; using Moq; using TypeWhisper.Linux.Services.Plugins; using TypeWhisper.Plugin.Webhook; using TypeWhisper.PluginSDK; +using TypeWhisper.PluginSDK.Models; namespace TypeWhisper.PluginSystem.Tests; @@ -206,19 +209,454 @@ await plugin.SetItemsAsync(CollectionKey, [Item("H", "ws://example.com")]) [Fact] public async Task Headers_RoundTrip_ValueContainingColon_SplitsOnFirstColonOnly() { + var host = new TestHost(_tempDir); var plugin = new WebhookPlugin(); plugin.SetDataDirectory(_tempDir); + await plugin.ActivateAsync(host); const string headerText = "Authorization: Bearer abc123\nX-Url: https://a.b/c"; await plugin.SetItemsAsync(CollectionKey, [Item("H", headers: headerText)]); var items = await plugin.GetItemsAsync(CollectionKey); var roundTripped = items[0].Values["headers"] ?? ""; + var webhookId = Guid.Parse(items[0].Values["__id"]!); var lines = roundTripped.Split('\n'); - Assert.Contains("Authorization: Bearer abc123", lines); - // The colon inside the value must survive intact. - Assert.Contains("X-Url: https://a.b/c", lines); + Assert.Contains("Authorization: ", lines); + Assert.Contains("X-Url: ", lines); + Assert.Equal( + "Bearer abc123", + host.Secrets[WebhookPlugin.GetHeaderSecretReference(webhookId, "Authorization")] + ); + // Splitting on the first colon keeps the colon inside the stored value. + Assert.Equal( + "https://a.b/c", + host.Secrets[WebhookPlugin.GetHeaderSecretReference(webhookId, "X-Url")] + ); + } + + [Fact] + public void HeadersField_IsSecretMultiline() + { + var plugin = new WebhookPlugin(); + + var field = Assert.Single( + Assert.Single(plugin.GetCollectionDefinitions()).ItemFields, + definition => definition.Key == "headers" + ); + + Assert.True(field.IsSecret); + Assert.Equal(PluginSettingKind.Multiline, field.Kind); + } + + [Fact] + public async Task SetItems_HeaderValuesStoredAsSecretsAndAbsentFromJson() + { + var host = new TestHost(_tempDir); + var plugin = await ActivateAsync(host); + var id = Guid.NewGuid(); + + var result = await plugin.SetItemsAsync( + CollectionKey, + [ + Item( + "Secure", + headers: "Authorization: Bearer top-secret\nX-Trace: trace-secret", + id: id.ToString("D") + ), + ] + ); + + Assert.True(result.IsSuccess); + var json = await File.ReadAllTextAsync(ConfigPath); + Assert.DoesNotContain("Bearer top-secret", json); + Assert.DoesNotContain("trace-secret", json); + Assert.DoesNotContain("\"headers\"", json); + Assert.Contains("\"headerSecretReferences\"", json); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal( + UnixFileMode.UserRead | UnixFileMode.UserWrite, + File.GetUnixFileMode(ConfigPath) + ); + } + Assert.Equal( + "Bearer top-secret", + host.Secrets[WebhookPlugin.GetHeaderSecretReference(id, "authorization")] + ); + Assert.Equal( + "trace-secret", + host.Secrets[WebhookPlugin.GetHeaderSecretReference(id, "x-trace")] + ); + } + + [Fact] + public async Task GetItems_StoredHeadersUseRedactionPlaceholder() + { + var host = new TestHost(_tempDir); + var plugin = await ActivateAsync(host); + + await plugin.SetItemsAsync( + CollectionKey, + [Item("Secure", headers: "Authorization: Bearer hidden\nX-Token: also-hidden")] + ); + await plugin.DeactivateAsync(); + var reader = new WebhookPlugin(); + reader.SetDataDirectory(_tempDir); + + var item = Assert.Single(await reader.GetItemsAsync(CollectionKey)); + var lines = item.Values["headers"]!.Split('\n'); + Assert.Contains("Authorization: ", lines); + Assert.Contains("X-Token: ", lines); + Assert.DoesNotContain("hidden", item.Values["headers"]); + } + + [Fact] + public async Task SetItems_UnchangedPlaceholderPreservesExistingSecretReference() + { + var host = new TestHost(_tempDir); + var plugin = await ActivateAsync(host); + var id = Guid.NewGuid(); + await plugin.SetItemsAsync( + CollectionKey, + [ + Item( + "Secure", + headers: "Authorization: original-value", + id: id.ToString("D") + ), + ] + ); + var reference = WebhookPlugin.GetHeaderSecretReference(id, "Authorization"); + host.StoreCalls.Clear(); + + var item = Assert.Single(await plugin.GetItemsAsync(CollectionKey)); + var updatedValues = item.Values.ToDictionary(pair => pair.Key, pair => pair.Value); + updatedValues["name"] = "Renamed"; + var result = await plugin.SetItemsAsync( + CollectionKey, + [new PluginCollectionItem(updatedValues)] + ); + + Assert.True(result.IsSuccess); + Assert.Empty(host.StoreCalls); + Assert.Equal("original-value", host.Secrets[reference]); + Assert.Contains(reference, await File.ReadAllTextAsync(ConfigPath)); + } + + [Fact] + public async Task SetItems_ChangedHeaderValueReplacesSecret() + { + var host = new TestHost(_tempDir); + var plugin = await ActivateAsync(host); + var id = Guid.NewGuid(); + await plugin.SetItemsAsync( + CollectionKey, + [Item("Secure", headers: "X-Key: old-value", id: id.ToString("D"))] + ); + var reference = WebhookPlugin.GetHeaderSecretReference(id, "X-Key"); + host.StoreCalls.Clear(); + + var result = await plugin.SetItemsAsync( + CollectionKey, + [Item("Secure", headers: "x-key: new:value", id: id.ToString("D"))] + ); + + Assert.True(result.IsSuccess); + Assert.Equal(new[] { (reference, "new:value") }, host.StoreCalls); + Assert.Equal("new:value", host.Secrets[reference]); + Assert.Contains(reference, await File.ReadAllTextAsync(ConfigPath)); + } + + [Fact] + public async Task SetItems_RemovedHeaderDeletesSecretAfterConfigCommit() + { + var host = new TestHost(_tempDir); + var plugin = await ActivateAsync(host); + var id = Guid.NewGuid(); + await plugin.SetItemsAsync( + CollectionKey, + [Item("Secure", headers: "Authorization: old-value", id: id.ToString("D"))] + ); + var reference = WebhookPlugin.GetHeaderSecretReference(id, "Authorization"); + var configWasCommittedBeforeDelete = false; + host.BeforeDelete = deletedReference => + { + if (deletedReference == reference) + { + configWasCommittedBeforeDelete = !File.ReadAllText(ConfigPath) + .Contains(reference, StringComparison.Ordinal); + } + }; + + var result = await plugin.SetItemsAsync( + CollectionKey, + [Item("Secure", headers: "", id: id.ToString("D"))] + ); + + Assert.True(result.IsSuccess); + Assert.True(configWasCommittedBeforeDelete); + Assert.Equal([reference], host.DeleteCalls); + Assert.DoesNotContain(reference, host.Secrets.Keys); + } + + [Fact] + public async Task SetItems_DeleteFailureLeavesOrphanAfterSuccessfulCommit() + { + var host = new TestHost(_tempDir); + var plugin = await ActivateAsync(host); + var id = Guid.NewGuid(); + await plugin.SetItemsAsync( + CollectionKey, + [Item("Secure", headers: "Authorization: old-value", id: id.ToString("D"))] + ); + var reference = WebhookPlugin.GetHeaderSecretReference(id, "Authorization"); + host.DeleteSecretException = new IOException("delete failed"); + + var result = await plugin.SetItemsAsync( + CollectionKey, + [Item("Secure", headers: "", id: id.ToString("D"))] + ); + + Assert.True(result.IsSuccess); + Assert.Contains(reference, host.Secrets.Keys); + Assert.DoesNotContain(reference, await File.ReadAllTextAsync(ConfigPath)); + } + + [Fact] + public async Task SetItems_NewHeaderPlaceholderStoresLiteralValue() + { + var host = new TestHost(_tempDir); + var plugin = await ActivateAsync(host); + var id = Guid.NewGuid(); + + var result = await plugin.SetItemsAsync( + CollectionKey, + [ + Item( + "Secure", + headers: "X-Literal: ", + id: id.ToString("D") + ), + ] + ); + + Assert.True(result.IsSuccess); + Assert.Equal( + WebhookPlugin.StoredHeaderPlaceholder, + host.Secrets[WebhookPlugin.GetHeaderSecretReference(id, "X-Literal")] + ); + } + + [Fact] + public async Task SetItems_HeaderWithoutActivationFailsWithoutPlaintextFallback() + { + var plugin = new WebhookPlugin(); + plugin.SetDataDirectory(_tempDir); + + var result = await plugin.SetItemsAsync( + CollectionKey, + [Item("Secure", headers: "Authorization: must-not-leak")] + ); + + Assert.False(result.IsSuccess); + Assert.False(File.Exists(ConfigPath)); + Assert.Empty(Directory.EnumerateFiles(_tempDir)); + } + + [Fact] + public async Task SetItems_SecretStoreFailureLeavesConfigurationUnchanged() + { + var host = new TestHost(_tempDir); + var plugin = await ActivateAsync(host); + await plugin.SetItemsAsync(CollectionKey, [Item("Original")]); + var originalBytes = await File.ReadAllBytesAsync(ConfigPath); + host.StoreSecretException = new IOException("secret store failed"); + + var result = await plugin.SetItemsAsync( + CollectionKey, + [Item("Changed", headers: "Authorization: must-not-leak")] + ); + + Assert.False(result.IsSuccess); + Assert.Equal(originalBytes, await File.ReadAllBytesAsync(ConfigPath)); + Assert.DoesNotContain("must-not-leak", await File.ReadAllTextAsync(ConfigPath)); + Assert.Equal("Original", plugin.Service!.SnapshotWebhooks().Single().Name); + } + + [Fact] + public async Task SetItems_ConfigWriteFailureFailsClosed() + { + var host = new TestHost(_tempDir); + var plugin = await ActivateAsync(host); + var id = Guid.NewGuid(); + await plugin.SetItemsAsync( + CollectionKey, + [Item("Secure", headers: "Authorization: old-value", id: id.ToString("D"))] + ); + var reference = WebhookPlugin.GetHeaderSecretReference(id, "Authorization"); + var backupPath = Path.Join(_tempDir, "webhooks.backup.json"); + File.Move(ConfigPath, backupPath); + Directory.CreateDirectory(ConfigPath); + + var result = await plugin.SetItemsAsync( + CollectionKey, + [ + Item( + "Changed", + headers: "Authorization: replacement-value", + id: id.ToString("D") + ), + ] + ); + + Assert.False(result.IsSuccess); + Assert.Equal("replacement-value", host.Secrets[reference]); + Assert.Empty(host.DeleteCalls); + Assert.DoesNotContain("replacement-value", await File.ReadAllTextAsync(backupPath)); + Assert.Empty(Directory.EnumerateFiles(_tempDir, "*.tmp")); + Assert.Equal("Secure", plugin.Service!.SnapshotWebhooks().Single().Name); + } + + [Fact] + public async Task SendWebhooksAsync_ResolvesSecretHeaderBeforeDelivery() + { + var host = new TestHost(_tempDir); + var plugin = await ActivateAsync(host); + await plugin.SetItemsAsync( + CollectionKey, + [Item("Secure", headers: "Authorization: Bearer delivered-secret")] + ); + + var handler = new CapturingHandler(request => + { + Assert.Equal( + "Bearer delivered-secret", + request.Headers.GetValues("Authorization").Single() + ); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + var service = new WebhookService(host, _tempDir, handler); + + try + { + await service.SendWebhooksAsync( + new TranscriptionCompletedEvent { Text = "hello" } + ); + } + finally + { + service.Dispose(); + } + + Assert.Equal(1, handler.CallCount); + } + + [Fact] + public async Task ActivateAsync_MigratesLegacyPlaintextHeadersAndSecuresConfigFile() + { + var id = Guid.NewGuid(); + var legacyJson = JsonSerializer.Serialize( + new[] + { + new + { + id, + name = "Legacy", + url = "https://example.com/hook", + httpMethod = "POST", + headers = new Dictionary + { + ["Authorization"] = "Bearer legacy-secret", + ["X-Url"] = "https://a.example/value", + }, + isEnabled = true, + profileFilter = Array.Empty(), + }, + } + ); + await File.WriteAllTextAsync(ConfigPath, legacyJson); + var expectedMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + ConfigPath, + expectedMode | UnixFileMode.GroupRead | UnixFileMode.OtherRead + ); + } + + var host = new TestHost(_tempDir) + { + BeforeStore = (_, _) => + { + if (!OperatingSystem.IsWindows()) + Assert.Equal(expectedMode, File.GetUnixFileMode(ConfigPath)); + }, + }; + var plugin = new WebhookPlugin(); + plugin.SetDataDirectory(_tempDir); + + await plugin.ActivateAsync(host); + + Assert.Equal( + "Bearer legacy-secret", + host.Secrets[WebhookPlugin.GetHeaderSecretReference(id, "Authorization")] + ); + Assert.Equal( + "https://a.example/value", + host.Secrets[WebhookPlugin.GetHeaderSecretReference(id, "X-Url")] + ); + var migratedJson = await File.ReadAllTextAsync(ConfigPath); + Assert.DoesNotContain("legacy-secret", migratedJson); + Assert.DoesNotContain("https://a.example/value", migratedJson); + Assert.DoesNotContain("\"headers\"", migratedJson); + Assert.Contains("\"headerSecretReferences\"", migratedJson); + if (!OperatingSystem.IsWindows()) + Assert.Equal(expectedMode, File.GetUnixFileMode(ConfigPath)); + } + + [Fact] + public async Task SetItems_BeforeActivation_PreservesUnmigratedLegacyHeaders() + { + var id = Guid.NewGuid(); + var legacyJson = JsonSerializer.Serialize( + new[] + { + new + { + id, + name = "Legacy", + url = "https://example.com/hook", + httpMethod = "POST", + headers = new Dictionary + { + ["Authorization"] = "Bearer legacy-secret", + }, + isEnabled = true, + profileFilter = Array.Empty(), + }, + } + ); + await File.WriteAllTextAsync(ConfigPath, legacyJson); + + // Disabled plugin: never activated, so migration has not run and the + // legacy plaintext headers are not visible in the settings UI. + var plugin = new WebhookPlugin(); + plugin.SetDataDirectory(_tempDir); + + var items = await plugin.GetItemsAsync(CollectionKey); + var updatedValues = items.Single().Values.ToDictionary(pair => pair.Key, pair => pair.Value); + updatedValues["name"] = "Renamed"; + + var result = await plugin.SetItemsAsync( + CollectionKey, + [new PluginCollectionItem(updatedValues)] + ); + + // The save must fail closed rather than silently drop the plaintext + // headers, and the on-disk secret must survive untouched. + Assert.False(result.IsSuccess); + Assert.Contains("Bearer legacy-secret", await File.ReadAllTextAsync(ConfigPath)); } [Fact] @@ -294,6 +732,14 @@ public async Task GetItems_UnknownCollection_ReturnsEmpty() private string ConfigPath => Path.Join(_tempDir, "webhooks.json"); + private async Task ActivateAsync(TestHost host) + { + var plugin = new WebhookPlugin(); + plugin.SetDataDirectory(_tempDir); + await plugin.ActivateAsync(host); + return plugin; + } + private static PluginCollectionItem Item( string name, string url = "https://example.com/hook", @@ -328,4 +774,77 @@ private static IPluginHostServices CreateHost(string dataDir) host.SetupGet(h => h.EventBus).Returns(new PluginEventBus()); return host.Object; } -} \ No newline at end of file + + private sealed class CapturingHandler( + Func responder + ) : HttpMessageHandler + { + public int CallCount { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + CallCount++; + return Task.FromResult(responder(request)); + } + } + + private sealed class TestHost(string dataDirectory) : IPluginHostServices + { + public Dictionary Secrets { get; } = []; + public List<(string Reference, string Value)> StoreCalls { get; } = []; + public List DeleteCalls { get; } = []; + public Exception? StoreSecretException { get; set; } + public Exception? DeleteSecretException { get; set; } + public Action? BeforeStore { get; init; } + public Action? BeforeDelete { get; set; } + + public Task StoreSecretAsync(string key, string value) + { + BeforeStore?.Invoke(key, value); + if (StoreSecretException is not null) + throw StoreSecretException; + + StoreCalls.Add((key, value)); + Secrets[key] = value; + return Task.CompletedTask; + } + + public Task LoadSecretAsync(string key) => + Task.FromResult(Secrets.GetValueOrDefault(key)); + + public Task DeleteSecretAsync(string key) + { + BeforeDelete?.Invoke(key); + DeleteCalls.Add(key); + if (DeleteSecretException is not null) + throw DeleteSecretException; + + Secrets.Remove(key); + return Task.CompletedTask; + } + + public T? GetSetting(string key) => default; + public void SetSetting(string key, T value) { } + public string PluginDataDirectory => dataDirectory; + public string? ActiveAppProcessName => null; + public string? ActiveAppName => null; + public IPluginEventBus EventBus { get; } = new PluginEventBus(); + public IReadOnlyList AvailableProfileNames => []; + public IPluginLocalization Localization { get; } = new TestLocalization(); + public void Log(PluginLogLevel level, string message) { } + public void NotifyCapabilitiesChanged() { } + } + + private sealed class TestLocalization : IPluginLocalization + { + public string CurrentLanguage => "en"; + public IReadOnlyList AvailableLanguages => ["en"]; + public string GetString(string key) => key; + public string GetString(string key, params object[] args) => + args.Length == 0 ? key : $"{key}: {string.Join(", ", args)}"; + } +} From 0aee26f35ad3c58b8454ed674485230b88ebb810 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 06:11:22 +0000 Subject: [PATCH 187/226] Run the CLI test suite in PR CI and release workflows TypeWhisper.slnx includes TypeWhisper.Cli.Tests, so both workflows compiled the suite, but neither ever executed it - PR CI ran only the Core, PluginSystem, and Linux suites, and the release workflow ran the same three before packaging. The twelve CLI tests (argument parser and stdin audio sniffer) gated nothing. PR CI gains a Test TypeWhisper.Cli step matching the sibling steps: Release configuration, --no-build, TRX results into the existing artifacts/test-results directory the upload-artifact glob already covers. The release workflow's unit-test block gains the CLI project before package construction so a failure blocks publication. --- .github/workflows/pr-ci-linux.yml | 3 +++ .github/workflows/release-linux.yml | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/pr-ci-linux.yml b/.github/workflows/pr-ci-linux.yml index 036fada05..e79290dcc 100644 --- a/.github/workflows/pr-ci-linux.yml +++ b/.github/workflows/pr-ci-linux.yml @@ -58,6 +58,9 @@ jobs: - name: Test TypeWhisper.Linux run: dotnet test tests/TypeWhisper.Linux.Tests/TypeWhisper.Linux.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Linux.Tests.trx" --results-directory artifacts/test-results + - name: Test TypeWhisper.Cli + run: dotnet test tests/TypeWhisper.Cli.Tests/TypeWhisper.Cli.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Cli.Tests.trx" --results-directory artifacts/test-results + - uses: actions/upload-artifact@v7 if: always() with: diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 3cf5bef0b..d80f1c53f 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -124,6 +124,7 @@ jobs: dotnet test tests/TypeWhisper.Core.Tests/TypeWhisper.Core.Tests.csproj -c Release --no-build dotnet test tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj -c Release --no-build dotnet test tests/TypeWhisper.Linux.Tests/TypeWhisper.Linux.Tests.csproj -c Release --no-build + dotnet test tests/TypeWhisper.Cli.Tests/TypeWhisper.Cli.Tests.csproj -c Release --no-build - name: Build all Linux packages run: bash scripts/build-linux-packages.sh "$VERSION" dist From 4eb4b24842da51ece08d9a8479fa4de7f233181f Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 06:28:04 +0000 Subject: [PATCH 188/226] Roll back the vector-memory cache when a store fails to persist StoreAsync added the new entry to the in-memory cache and then saved; a failed or cancelled save escaped without restoring the cache, so GetAll and Search immediately exposed an entry whose store had reported failure, and the next successful mutation serialized that same cache and persisted it. Delete and ClearAll already used the snapshot-rollback pattern. StoreAsync now snapshots the list after duplicate detection and before adding; every save failure - including cancellation - restores the snapshot and rethrows unchanged. The atomic writer and its temp-file cleanup are untouched. The plugin gains an internal HttpMessageHandler seam (the public parameterless constructor the host instantiates via reflection is preserved) so the new regression tests drive embedding responses deterministically without the network. --- .../OpenAiVectorMemoryPlugin.cs | 26 ++- ...peWhisper.Plugin.OpenAiVectorMemory.csproj | 3 + .../OpenAiVectorMemoryPluginTests.cs | 153 ++++++++++++++++++ .../TypeWhisper.PluginSystem.Tests.csproj | 1 + 4 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 tests/TypeWhisper.PluginSystem.Tests/OpenAiVectorMemoryPluginTests.cs diff --git a/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs index 42f724646..c02a1c3a8 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs @@ -18,13 +18,24 @@ public sealed class OpenAiVectorMemoryPlugin : IMemoryStoragePlugin, IPluginSett private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; - private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; + private readonly HttpClient _httpClient; private readonly SemaphoreSlim _lock = new(1, 1); private IPluginHostServices? _host; private string? _apiKey; private string? _filePath; private List? _entries; + // ReSharper disable once UnusedMember.Global -- the host instantiates the plugin through this public parameterless constructor via reflection, which the analyzer cannot see. + public OpenAiVectorMemoryPlugin() + : this(new HttpClientHandler()) + { + } + + internal OpenAiVectorMemoryPlugin(HttpMessageHandler handler) + { + _httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; + } + public string PluginId => "com.typewhisper.openai-vector-memory"; public string PluginName => "OpenAI Vector Memory"; public string PluginVersion => "1.0.0"; @@ -118,8 +129,19 @@ public async Task StoreAsync(string content, CancellationToken ct) } var embedding = await GetEmbeddingAsync(content, ct); + var snapshot = new List(entries); entries.Add(new VectorMemoryEntry(content, embedding, DateTime.UtcNow)); - await SaveEntriesAsync(ct); + + try + { + await SaveEntriesAsync(ct); + } + catch + { + _entries = snapshot; + throw; + } + _host?.Log(PluginLogLevel.Debug, $"Stored vector memory (total={entries.Count})"); } finally diff --git a/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/TypeWhisper.Plugin.OpenAiVectorMemory.csproj b/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/TypeWhisper.Plugin.OpenAiVectorMemory.csproj index 5b5745228..ac802d9a1 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/TypeWhisper.Plugin.OpenAiVectorMemory.csproj +++ b/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/TypeWhisper.Plugin.OpenAiVectorMemory.csproj @@ -6,6 +6,9 @@ latest TypeWhisper.Plugin.OpenAiVectorMemory + + + diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiVectorMemoryPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiVectorMemoryPluginTests.cs new file mode 100644 index 000000000..586b58463 --- /dev/null +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiVectorMemoryPluginTests.cs @@ -0,0 +1,153 @@ +using System.Net; +using System.Text; +using TypeWhisper.Plugin.OpenAiVectorMemory; +using TypeWhisper.PluginSDK; +using TypeWhisper.PluginSDK.Models; +using TypeWhisper.Tests; + +namespace TypeWhisper.PluginSystem.Tests; + +public sealed class OpenAiVectorMemoryPluginTests : IDisposable +{ + private const string EmbeddingResponse = """ + {"data":[{"embedding":[0.25,0.5]}]} + """; + + private readonly string _tempDir = TestPaths.CreateTempDirectory( + "TypeWhisper.OpenAiVectorMemoryPluginTests" + ); + + private string MemoryPath => Path.Join(_tempDir, "vector-memories.json"); + + public void Dispose() + { + try + { + TestPaths.DeleteDirectory(_tempDir); + } + catch + { + /* best effort */ + } + } + + [Fact] + public async Task StoreAsync_WhenAtomicMoveFails_RollsBackCacheAndPersistsOnlyLaterSuccess() + { + Directory.CreateDirectory(MemoryPath); + using var plugin = await CreatePluginAsync(new EmbeddingHandler()); + + await Assert.ThrowsAnyAsync(() => + plugin.StoreAsync("Failed memory", CancellationToken.None) + ); + Assert.Empty(await plugin.GetAllAsync(CancellationToken.None)); + + Directory.Delete(MemoryPath); + await plugin.StoreAsync("Successful memory", CancellationToken.None); + + using var reloaded = await CreatePluginAsync(new EmbeddingHandler()); + Assert.Equal( + ["Successful memory"], + await reloaded.GetAllAsync(CancellationToken.None) + ); + } + + [Fact] + public async Task StoreAsync_WhenSaveIsCanceled_RollsBackCacheAndRethrowsCancellation() + { + using var cancellation = new CancellationTokenSource(); + using var plugin = await CreatePluginAsync( + new EmbeddingHandler(cancellation) + ); + + var exception = await Assert.ThrowsAnyAsync(() => + plugin.StoreAsync("Canceled memory", cancellation.Token) + ); + + Assert.Equal(cancellation.Token, exception.CancellationToken); + Assert.Empty(await plugin.GetAllAsync(CancellationToken.None)); + } + + private async Task CreatePluginAsync(HttpMessageHandler handler) + { + var plugin = new OpenAiVectorMemoryPlugin(handler); + await plugin.ActivateAsync(new TestPluginHostServices(_tempDir)); + return plugin; + } + + private sealed class EmbeddingHandler( + CancellationTokenSource? cancelOnResponseDispose = null + ) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + HttpContent content = cancelOnResponseDispose is null + ? new StringContent(EmbeddingResponse, Encoding.UTF8, "application/json") + : new CancelOnDisposeContent(EmbeddingResponse, cancelOnResponseDispose); + + return Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = content, + } + ); + } + } + + private sealed class CancelOnDisposeContent( + string content, + CancellationTokenSource cancellation + ) : StringContent(content, Encoding.UTF8, "application/json") + { + protected override void Dispose(bool disposing) + { + if (disposing) + cancellation.Cancel(); + + base.Dispose(disposing); + } + } + + private sealed class TestPluginHostServices(string pluginDataDirectory) + : IPluginHostServices + { + public string PluginDataDirectory { get; } = pluginDataDirectory; + public string? ActiveAppProcessName => null; + public string? ActiveAppName => null; + public IPluginEventBus EventBus { get; } = new TestPluginEventBus(); + public IReadOnlyList AvailableProfileNames => []; + public IPluginLocalization Localization { get; } = new TestPluginLocalization(); + + public Task StoreSecretAsync(string key, string value) => Task.CompletedTask; + public Task LoadSecretAsync(string key) => Task.FromResult("test-key"); + public Task DeleteSecretAsync(string key) => Task.CompletedTask; + public T? GetSetting(string key) => default; + public void SetSetting(string key, T value) { } + public void Log(PluginLogLevel level, string message) { } + public void NotifyCapabilitiesChanged() { } + } + + private sealed class TestPluginLocalization : IPluginLocalization + { + public string CurrentLanguage => "en"; + public IReadOnlyList AvailableLanguages => ["en"]; + public string GetString(string key) => key; + public string GetString(string key, params object[] args) => string.Format(key, args); + } + + private sealed class TestPluginEventBus : IPluginEventBus + { + public void Publish(T pluginEvent) where T : PluginEvent { } + + public IDisposable Subscribe(Func handler) where T : PluginEvent => + new NoOpDisposable(); + } + + private sealed class NoOpDisposable : IDisposable + { + public void Dispose() { } + } +} diff --git a/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj b/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj index 34ca8a399..fdc3a2862 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj +++ b/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj @@ -31,6 +31,7 @@ + From b114ee22a363cabe537984d9345ee7e981cb4e89 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 07:10:06 +0000 Subject: [PATCH 189/226] Bound test synchronization waits and add CI hang budgets Several concurrency tests waited forever when an expected transition never occurred: the file-lock test awaited its second acquisition with no deadline, both CUDA backend-switch tests awaited a fake provisioner's start and load tasks unbounded, and the Supertonic deactivation test waited on synthesis start, speech completion, and deactivation without limits. Neither CI workflow had job or step timeouts or hang diagnostics, so one wedged test could consume an entire six-hour runner. The cited waits now carry five-second coordination deadlines with cancellation tokens threaded into the underlying operations, and finally blocks release fixtures, cancel, and boundedly observe the orphaned tasks so nothing leaks into later tests - assertions are unchanged. A wait that would previously hang forever now fails within the bound with clean teardown. PR CI gains a 45-minute job budget, 15-minute per-suite step budgets, and blame-hang mini-dump collection (5-minute hang timeout) on all four suites, with the artifact upload widened to include dumps and sequence files. The release build gains a 60-minute job budget, a 30-minute unit-test step budget, and the same blame-hang flags - deliberately without a diagnostics upload there, because the release job's unfiltered merge-multiple artifact download feeds the public release asset glob and a process-memory dump must never land in it. --- .github/workflows/pr-ci-linux.yml | 15 ++- .github/workflows/release-linux.yml | 10 +- .../InterProcessFileLockTests.cs | 66 ++++++++- .../SupertonicTtsPluginTests.cs | 62 +++++++-- .../WhisperCppPluginTests.cs | 126 ++++++++++++++---- 5 files changed, 226 insertions(+), 53 deletions(-) diff --git a/.github/workflows/pr-ci-linux.yml b/.github/workflows/pr-ci-linux.yml index e79290dcc..a8363ce99 100644 --- a/.github/workflows/pr-ci-linux.yml +++ b/.github/workflows/pr-ci-linux.yml @@ -19,6 +19,7 @@ concurrency: jobs: build-and-test: runs-on: ubuntu-latest + timeout-minutes: 45 steps: - uses: actions/checkout@v7 @@ -50,16 +51,20 @@ jobs: run: dotnet build src/TypeWhisper.Cli/TypeWhisper.Cli.csproj -c Release --no-restore -bl:artifacts/logs/typewhisper-cli.binlog - name: Test TypeWhisper.Core - run: dotnet test tests/TypeWhisper.Core.Tests/TypeWhisper.Core.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Core.Tests.trx" --results-directory artifacts/test-results + timeout-minutes: 15 + run: dotnet test tests/TypeWhisper.Core.Tests/TypeWhisper.Core.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Core.Tests.trx" --results-directory artifacts/test-results --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini - name: Test plugin system - run: dotnet test tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.PluginSystem.Tests.trx" --results-directory artifacts/test-results + timeout-minutes: 15 + run: dotnet test tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.PluginSystem.Tests.trx" --results-directory artifacts/test-results --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini - name: Test TypeWhisper.Linux - run: dotnet test tests/TypeWhisper.Linux.Tests/TypeWhisper.Linux.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Linux.Tests.trx" --results-directory artifacts/test-results + timeout-minutes: 15 + run: dotnet test tests/TypeWhisper.Linux.Tests/TypeWhisper.Linux.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Linux.Tests.trx" --results-directory artifacts/test-results --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini - name: Test TypeWhisper.Cli - run: dotnet test tests/TypeWhisper.Cli.Tests/TypeWhisper.Cli.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Cli.Tests.trx" --results-directory artifacts/test-results + timeout-minutes: 15 + run: dotnet test tests/TypeWhisper.Cli.Tests/TypeWhisper.Cli.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Cli.Tests.trx" --results-directory artifacts/test-results --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini - uses: actions/upload-artifact@v7 if: always() @@ -67,5 +72,5 @@ jobs: name: pr-ci-linux-artifacts path: | artifacts/logs/*.binlog - artifacts/test-results/*.trx + artifacts/test-results/** if-no-files-found: ignore diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index d80f1c53f..935d167f2 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -95,6 +95,7 @@ jobs: build: needs: prepare runs-on: ubuntu-latest + timeout-minutes: 60 env: VERSION: ${{ needs.prepare.outputs.version }} @@ -118,13 +119,14 @@ jobs: dpkg-dev rpm wget desktop-file-utils - name: Run unit tests + timeout-minutes: 30 run: | dotnet restore TypeWhisper.slnx dotnet build TypeWhisper.slnx -c Release --no-restore -p:Version=${VERSION} - dotnet test tests/TypeWhisper.Core.Tests/TypeWhisper.Core.Tests.csproj -c Release --no-build - dotnet test tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj -c Release --no-build - dotnet test tests/TypeWhisper.Linux.Tests/TypeWhisper.Linux.Tests.csproj -c Release --no-build - dotnet test tests/TypeWhisper.Cli.Tests/TypeWhisper.Cli.Tests.csproj -c Release --no-build + dotnet test tests/TypeWhisper.Core.Tests/TypeWhisper.Core.Tests.csproj -c Release --no-build --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini + dotnet test tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj -c Release --no-build --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini + dotnet test tests/TypeWhisper.Linux.Tests/TypeWhisper.Linux.Tests.csproj -c Release --no-build --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini + dotnet test tests/TypeWhisper.Cli.Tests/TypeWhisper.Cli.Tests.csproj -c Release --no-build --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini - name: Build all Linux packages run: bash scripts/build-linux-packages.sh "$VERSION" dist diff --git a/tests/TypeWhisper.Core.Tests/InterProcessFileLockTests.cs b/tests/TypeWhisper.Core.Tests/InterProcessFileLockTests.cs index b298db9a9..d0520b2a2 100644 --- a/tests/TypeWhisper.Core.Tests/InterProcessFileLockTests.cs +++ b/tests/TypeWhisper.Core.Tests/InterProcessFileLockTests.cs @@ -8,6 +8,8 @@ namespace TypeWhisper.Core.Tests; /// public sealed class InterProcessFileLockTests { + private static readonly TimeSpan s_coordinationTimeout = TimeSpan.FromSeconds(5); + private static string NewTempDir() { var dir = Path.Join(Path.GetTempPath(), "tw-lock-" + Guid.NewGuid().ToString("N")); @@ -19,22 +21,76 @@ private static string NewTempDir() public async Task SecondAcquire_WaitsUntilFirstReleases() { var dir = NewTempDir(); + using var acquisitionCts = new CancellationTokenSource(); + FileStream? first = null; + FileStream? acquired = null; + Task? firstAcquisition = null; + Task? second = null; try { var lockPath = Path.Join(dir, "artifact.lock"); - var first = await InterProcessFileLock.AcquireAsync(lockPath, CancellationToken.None); + firstAcquisition = InterProcessFileLock.AcquireAsync( + lockPath, + acquisitionCts.Token + ); + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; wiring acquisitionCts.Token here would make the deadline racy with the finally's teardown cancel instead of fixed. + first = await firstAcquisition.WaitAsync(s_coordinationTimeout); - var second = InterProcessFileLock.AcquireAsync(lockPath, CancellationToken.None); + second = InterProcessFileLock.AcquireAsync(lockPath, acquisitionCts.Token); // The second acquire must NOT complete while the first holds the lock. + // ReSharper disable once MethodSupportsCancellation -- Task.Delay is a fixed probe window proving the second acquire stays pending; a token would defeat the check. var winner = await Task.WhenAny(second, Task.Delay(500)); Assert.NotSame(second, winner); // Releasing the first lets the second acquire (within a poll interval). await first.DisposeAsync(); - var acquired = await second; - await acquired.DisposeAsync(); + first = null; + firstAcquisition = null; + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; wiring acquisitionCts.Token here would make the deadline racy with the finally's teardown cancel instead of fixed. + acquired = await second.WaitAsync(s_coordinationTimeout); + } + finally + { + // ReSharper disable once MethodHasAsyncOverload -- synchronous Cancel is the teardown signal; CancelAsync buys nothing in cleanup. + acquisitionCts.Cancel(); + first ??= await CompleteAcquireBestEffort(firstAcquisition); + try + { + if (first is not null) + await first.DisposeAsync(); + } + finally + { + acquired ??= await CompleteAcquireBestEffort(second); + try + { + if (acquired is not null) + await acquired.DisposeAsync(); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + } + } + + private static async Task CompleteAcquireBestEffort( + Task? acquisition + ) + { + if (acquisition is null) + return null; + + try + { + return await acquisition.WaitAsync(s_coordinationTimeout); + } + catch + { + // Best-effort bounded observation after cancellation. + return null; } - finally { Directory.Delete(dir, recursive: true); } } [Fact] diff --git a/tests/TypeWhisper.PluginSystem.Tests/SupertonicTtsPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/SupertonicTtsPluginTests.cs index 5963f912f..45c24650c 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/SupertonicTtsPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/SupertonicTtsPluginTests.cs @@ -9,6 +9,8 @@ namespace TypeWhisper.PluginSystem.Tests; public class SupertonicTtsPluginTests { + private static readonly TimeSpan s_coordinationTimeout = TimeSpan.FromSeconds(5); + [Fact] public void Manifest_DeclaresLocalTtsPlugin() { @@ -291,20 +293,58 @@ public async Task DeactivateAsync_WaitsForInFlightSynthesisBeforeDisposingSynthe var assets = new FakeSupertonicAssets { AreAssetsReadyValue = true }; var sut = new SupertonicTtsPlugin(assets, _ => synth, (_, _) => new FakeTtsPlaybackSession()); await sut.ActivateAsync(new TestPluginHostServices()); + using var speakCts = new CancellationTokenSource(); + Task? speak = null; + Task? deactivate = null; - var speak = Task.Run(() => sut.SpeakAsync(new TtsSpeakRequest("Hello", "en"), CancellationToken.None)); - await synth.Started; - - var deactivate = sut.DeactivateAsync(); - await Task.Delay(50); - Assert.False(deactivate.IsCompleted); - Assert.False(synth.Disposed); + try + { + // ReSharper disable once MethodSupportsCancellation -- Task.Run must schedule unconditionally; speakCts.Token would cancel scheduling, not the synthesis under test. + // ReSharper disable once AccessToDisposedClosure -- the task is awaited (speak.WaitAsync / CompleteBestEffort) before the using var speakCts is disposed at scope end. + speak = Task.Run(() => + sut.SpeakAsync(new TtsSpeakRequest("Hello", "en"), speakCts.Token)); + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; wiring a token here would make the deadline racy with the finally's teardown cancel instead of fixed. + await synth.Started.WaitAsync(s_coordinationTimeout); + + deactivate = sut.DeactivateAsync(); + // ReSharper disable once MethodSupportsCancellation -- fixed settle window proving DeactivateAsync stays pending while synthesis is in flight; a token would defeat the check. + await Task.Delay(50); + Assert.False(deactivate.IsCompleted); + Assert.False(synth.Disposed); + + gate.SetResult(); + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; wiring a token here would make the deadline racy with the finally's teardown cancel instead of fixed. + await speak.WaitAsync(s_coordinationTimeout); + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; wiring a token here would make the deadline racy with the finally's teardown cancel instead of fixed. + await deactivate.WaitAsync(s_coordinationTimeout); + + Assert.True(synth.Disposed); + } + finally + { + gate.TrySetResult(); + // ReSharper disable once MethodHasAsyncOverload -- synchronous Cancel is the teardown signal; CancelAsync buys nothing in cleanup. + speakCts.Cancel(); + await CompleteBestEffort(speak); + deactivate ??= sut.DeactivateAsync(); + await CompleteBestEffort(deactivate); + } + } - gate.SetResult(); - await speak; - await deactivate; + private static async Task CompleteBestEffort(params Task?[] tasks) + { + var activeTasks = tasks.Where(task => task is not null).Cast().ToArray(); + if (activeTasks.Length == 0) + return; - Assert.True(synth.Disposed); + try + { + await Task.WhenAll(activeTasks).WaitAsync(s_coordinationTimeout); + } + catch + { + // Best-effort bounded observation after releasing the gate and canceling speech. + } } private static string FindRepoFile(params string[] parts) diff --git a/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs index 0be4874e7..560035670 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs @@ -19,6 +19,7 @@ namespace TypeWhisper.PluginSystem.Tests; public partial class WhisperCppPluginTests { private const float TranscriptionNoSpeechThreshold = 0.8f; + private static readonly TimeSpan s_coordinationTimeout = TimeSpan.FromSeconds(5); [Fact] public void InitializeCudaDependencies_CustomPluginAssetDirectory_UsesSharedConfiguredRoot() @@ -278,22 +279,39 @@ public async Task LoadModelAsync_BackendSwitchedDuringProvision_AbortsLoad() var host = CreateHostMock(temp.Path); var provisioner = new BlockingProvisioner(); var installer = new NoopWhisperInstaller(temp.Path); + using var loadCts = new CancellationTokenSource(); + Task? loadTask = null; - var plugin = new WhisperCppPlugin(); - plugin.SetCudaDependenciesForTests(provisioner, installer); - await plugin.ActivateAsync(host.Object); - WriteDummyModel(temp.Path, "ggml-tiny.bin"); - - plugin.SetAccelerationPreference(TranscriptionAccelerationPreference.NvidiaCuda); - var loadTask = plugin.LoadModelAsync("tiny", CancellationToken.None); - - await provisioner.Started; - // User switches to CPU while the (blocked) CUDA provision is in flight. - plugin.SetAccelerationPreference(TranscriptionAccelerationPreference.Cpu); - provisioner.Release(); - - var ex = await Assert.ThrowsAsync(() => loadTask); - Assert.Contains("Compute backend changed", ex.Message); + try + { + var plugin = new WhisperCppPlugin(); + plugin.SetCudaDependenciesForTests(provisioner, installer); + await plugin.ActivateAsync(host.Object); + WriteDummyModel(temp.Path, "ggml-tiny.bin"); + + plugin.SetAccelerationPreference(TranscriptionAccelerationPreference.NvidiaCuda); + var runningLoadTask = plugin.LoadModelAsync("tiny", loadCts.Token); + loadTask = runningLoadTask; + + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; wiring loadCts.Token here would make the deadline racy with the finally's teardown cancel instead of fixed. + await provisioner.Started.WaitAsync(s_coordinationTimeout); + // User switches to CPU while the (blocked) CUDA provision is in flight. + plugin.SetAccelerationPreference(TranscriptionAccelerationPreference.Cpu); + provisioner.Release(); + + var ex = await Assert.ThrowsAsync( + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; wiring loadCts.Token here would make the deadline racy with the finally's teardown cancel instead of fixed. + () => runningLoadTask.WaitAsync(s_coordinationTimeout) + ); + Assert.Contains("Compute backend changed", ex.Message); + } + finally + { + provisioner.Release(); + // ReSharper disable once MethodHasAsyncOverload -- synchronous Cancel is the teardown signal; CancelAsync buys nothing in cleanup. + loadCts.Cancel(); + await CompleteBestEffort(loadTask); + } } // Once Whisper.net's one-shot native loader has failed (poisoned static), a subsequent @@ -347,6 +365,21 @@ params WhisperCppTranscriptionSegment[] segments } } + private static async Task CompleteBestEffort(Task? task) + { + if (task is null) + return; + + try + { + await task.WaitAsync(s_coordinationTimeout); + } + catch + { + // Cleanup is observation-only and must remain bounded. + } + } + // A provisioner that blocks inside EnsureReadyAsync until released, signaling when it // has started so a test can deterministically interleave a backend switch. private sealed class BlockingProvisioner : CudaRuntimeProvisioner @@ -420,6 +453,8 @@ private static string WhisperCppCsprojPath([CallerFilePath] string thisFile = "" public partial class SherpaOnnxPluginTests { + private static readonly TimeSpan s_coordinationTimeout = TimeSpan.FromSeconds(5); + [Fact] public void InitializeCudaDependencies_CustomPluginAssetDirectory_UsesSharedConfiguredRoot() { @@ -625,6 +660,21 @@ private static IPluginHostServices CreateHost() return host.Object; } + private static async Task CompleteBestEffort(Task? task) + { + if (task is null) + return; + + try + { + await task.WaitAsync(s_coordinationTimeout); + } + catch + { + // Cleanup is observation-only and must remain bounded. + } + } + // CI-portable state-machine test mirroring the whisper one: a CUDA load whose backend // is switched to CPU mid-provision must abort. The injected provisioner blocks inside // EnsureReadyAsync; once the backend is switched to CPU the wiring guard skips @@ -644,22 +694,42 @@ public async Task LoadModelAsync_BackendSwitchedDuringProvision_AbortsLoad() var host = CreateHostMock(temp.Path); var provisioner = new SherpaBlockingProvisioner(); var installer = new NoopSherpaInstaller(temp.Path); + using var loadCts = new CancellationTokenSource(); + Task? loadTask = null; - var plugin = new SherpaOnnxPlugin(); - plugin.SetCudaDependenciesForTests(provisioner, installer); - await plugin.ActivateAsync(host.Object); - WriteParakeetModelFiles(temp.Path); - - plugin.SetAccelerationPreference(TranscriptionAccelerationPreference.NvidiaCuda); - var loadTask = plugin.LoadModelAsync("parakeet-tdt-0.6b", CancellationToken.None); + try + { + var plugin = new SherpaOnnxPlugin(); + plugin.SetCudaDependenciesForTests(provisioner, installer); + await plugin.ActivateAsync(host.Object); + WriteParakeetModelFiles(temp.Path); + + plugin.SetAccelerationPreference(TranscriptionAccelerationPreference.NvidiaCuda); + var runningLoadTask = plugin.LoadModelAsync( + "parakeet-tdt-0.6b", + loadCts.Token + ); + loadTask = runningLoadTask; - await provisioner.Started; - // User switches to CPU while the (blocked) CUDA provision is in flight. - plugin.SetAccelerationPreference(TranscriptionAccelerationPreference.Cpu); - provisioner.Release(); + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; wiring loadCts.Token here would make the deadline racy with the finally's teardown cancel instead of fixed. + await provisioner.Started.WaitAsync(s_coordinationTimeout); + // User switches to CPU while the (blocked) CUDA provision is in flight. + plugin.SetAccelerationPreference(TranscriptionAccelerationPreference.Cpu); + provisioner.Release(); - var ex = await Assert.ThrowsAsync(() => loadTask); - Assert.Contains("Compute backend changed", ex.Message); + var ex = await Assert.ThrowsAsync( + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; wiring loadCts.Token here would make the deadline racy with the finally's teardown cancel instead of fixed. + () => runningLoadTask.WaitAsync(s_coordinationTimeout) + ); + Assert.Contains("Compute backend changed", ex.Message); + } + finally + { + provisioner.Release(); + // ReSharper disable once MethodHasAsyncOverload -- synchronous Cancel is the teardown signal; CancelAsync buys nothing in cleanup. + loadCts.Cancel(); + await CompleteBestEffort(loadTask); + } } [Fact] From 0462b2c254ac14cc9b3e85b98509858e50620335 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 07:15:07 +0000 Subject: [PATCH 190/226] Make plugin release publication a draft-first resumable transaction The publish workflow created the public GitHub release first and then rewrote and pushed the gh-pages registry. A late registry failure left a downloadable public release that plugins.json never referenced, and rerunning the tag died at the unconditional gh release create without ever reaching registry repair. Release and registry orchestration move into scripts/publish-plugin-release.ps1 as a fail-closed state machine: the ZIP, embedded manifest, and staged registry entry are validated before anything is exposed; release state is queried with query errors treated as failures, never as missing; a missing release becomes a draft pinned to the workflow commit; an existing draft is reused with verified asset upload; an existing public release (the legacy failure being repaired) is verified by tag target and asset identity and then resumes registry repair without another create. The staged registry is pushed with bounded retries and the draft is published only after that push succeeds - a short window where the registry references a draft asset is accepted in preference to a public release missing from the registry. Replaying an older tag after a newer version shipped is refused rather than downgrading the live registry entry. A Pester regression drives the transaction against recorded gh/git stubs - draft-first creation, publish-strictly-after-push ordering, rerun resumption, public-release repair, downgrade refusal, and query-error fail-closure - and runs in the always-on discovery portion of the plugin smoke workflow. --- .github/workflows/plugins-smoke.yml | 25 + .github/workflows/publish-plugins.yml | 101 +- scripts/publish-plugin-release.ps1 | 872 ++++++++++++++++++ .../PublishPluginReleaseTransaction.Tests.ps1 | 294 ++++++ 4 files changed, 1201 insertions(+), 91 deletions(-) create mode 100644 scripts/publish-plugin-release.ps1 create mode 100644 tests/workflows/PublishPluginReleaseTransaction.Tests.ps1 diff --git a/.github/workflows/plugins-smoke.yml b/.github/workflows/plugins-smoke.yml index d083449b0..5d6b7bf06 100644 --- a/.github/workflows/plugins-smoke.yml +++ b/.github/workflows/plugins-smoke.yml @@ -30,6 +30,31 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Run plugin release transaction regression + shell: pwsh + run: | + $minimumPesterVersion = [version]'5.5.0' + $pester = Get-Module -ListAvailable Pester | + Where-Object Version -ge $minimumPesterVersion | + Sort-Object Version -Descending | + Select-Object -First 1 + if (-not $pester) { + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force + $pester = Get-Module -ListAvailable Pester | + Where-Object Version -ge $minimumPesterVersion | + Sort-Object Version -Descending | + Select-Object -First 1 + } + Import-Module $pester.Path -Force + + $result = Invoke-Pester ` + -Path tests/workflows/PublishPluginReleaseTransaction.Tests.ps1 ` + -Output Detailed ` + -PassThru + if ($result.FailedCount -gt 0) { + throw "$($result.FailedCount) plugin release transaction test(s) failed." + } + - id: discover shell: pwsh run: | diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index 8c1bbda5e..a8c568c8a 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -161,98 +161,17 @@ jobs: echo "PLUGIN_ID=$pluginId" >> $env:GITHUB_ENV echo "Created $zipName ($zipSize bytes)" - - name: Create GitHub Release + - name: Publish plugin release transaction env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: pwsh run: | - $tag = "${{ github.ref_name }}" - gh release create $tag $env:ZIP_NAME ` - --title "$env:PROJECT_NAME v$env:PLUGIN_VERSION" ` - --notes "" - - - name: Update plugins.json on gh-pages - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: pwsh - run: | - $tag = "${{ github.ref_name }}" - $repo = "${{ github.repository }}" - $downloadUrl = "https://github.com/$repo/releases/download/$tag/$env:ZIP_NAME" - - git fetch origin gh-pages - git worktree add -B gh-pages gh-pages-work origin/gh-pages - - $maxRetries = 5 - for ($i = 1; $i -le $maxRetries; $i++) { - try { - Push-Location gh-pages-work - git fetch origin gh-pages - git reset --hard origin/gh-pages - - $registry = @( - Get-Content plugins.json | ConvertFrom-Json - ) - $updated = $false - - for ($j = 0; $j -lt $registry.Count; $j++) { - if ($registry[$j].id -eq $env:PLUGIN_ID) { - $registry[$j].version = $env:PLUGIN_VERSION - $registry[$j].size = [long]$env:ZIP_SIZE - $registry[$j].downloadUrl = $downloadUrl - $updated = $true - Write-Host "Updated $($registry[$j].id) to v$env:PLUGIN_VERSION" - break - } - } - - if (-not $updated) { - $manifestPath = Join-Path ".." "plugins/$env:PROJECT_NAME/manifest.json" - $manifest = Get-Content $manifestPath | ConvertFrom-Json - - $registry += [pscustomobject]@{ - id = $manifest.id - name = $manifest.name - version = $env:PLUGIN_VERSION - minHostVersion = $manifest.minHostVersion - author = $manifest.author - description = $manifest.description - category = $manifest.category - size = [long]$env:ZIP_SIZE - downloadUrl = $downloadUrl - iconSystemName = $manifest.iconSystemName - requiresApiKey = [bool]($manifest.requiresApiKey) - descriptions = $manifest.descriptions - } - - Write-Host "Added $env:PLUGIN_ID to registry at v$env:PLUGIN_VERSION" - } - - $registry | ConvertTo-Json -Depth 4 | Set-Content plugins.json - - # Also copy the ZIP to plugins/ directory on gh-pages - New-Item -ItemType Directory -Path "plugins" -Force | Out-Null - Copy-Item (Join-Path .. $env:ZIP_NAME) "plugins/$env:ZIP_NAME" -Force - - git add -A - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - $diff = git diff --cached --quiet 2>&1; $hasChanges = $LASTEXITCODE -ne 0 - if ($hasChanges) { - git commit -m "Update $env:PROJECT_NAME to v$env:PLUGIN_VERSION" - git push origin gh-pages - Write-Host "Successfully updated plugins.json (attempt $i)" - } else { - Write-Host "No changes to deploy" - } - - Pop-Location - break - } catch { - Pop-Location - if ($i -eq $maxRetries) { throw } - Write-Host "Push failed (attempt $i), retrying in ${i}s..." - Start-Sleep -Seconds $i - } - } + ./scripts/publish-plugin-release.ps1 ` + -Tag $env:GITHUB_REF_NAME ` + -Repository $env:GITHUB_REPOSITORY ` + -CommitSha $env:GITHUB_SHA ` + -ProjectName $env:PROJECT_NAME ` + -PluginVersion $env:PLUGIN_VERSION ` + -PluginId $env:PLUGIN_ID ` + -ZipPath $env:ZIP_NAME ` + -ManifestPath "plugins/$env:PROJECT_NAME/manifest.json" diff --git a/scripts/publish-plugin-release.ps1 b/scripts/publish-plugin-release.ps1 new file mode 100644 index 000000000..d9be21727 --- /dev/null +++ b/scripts/publish-plugin-release.ps1 @@ -0,0 +1,872 @@ +param( + [string]$Tag, + [string]$Repository, + [string]$CommitSha, + [string]$ProjectName, + [string]$PluginVersion, + [string]$PluginId, + [string]$ZipPath, + [string]$ManifestPath, + [string]$RegistryWorktreePath = 'gh-pages-work', + [ValidateRange(1, 20)] + [int]$MaxPushAttempts = 5 +) + +function Invoke-GhCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string[]]$Arguments, + [switch]$AllowFailure + ) + + $PSNativeCommandUseErrorActionPreference = $false + $output = @(& gh @Arguments 2>&1 | ForEach-Object { $_.ToString() }) + $exitCode = $LASTEXITCODE + + if (-not $AllowFailure -and $exitCode -ne 0) { + $detail = ($output -join [Environment]::NewLine).Trim() + throw "gh $($Arguments -join ' ') failed with exit code ${exitCode}: $detail" + } + + [pscustomobject]@{ + ExitCode = $exitCode + Output = $output + } +} + +function Invoke-GitCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string[]]$Arguments, + [string]$WorkingDirectory, + [switch]$AllowFailure + ) + + $PSNativeCommandUseErrorActionPreference = $false + $pushedLocation = $false + try { + if (-not [string]::IsNullOrWhiteSpace($WorkingDirectory)) { + Push-Location -LiteralPath $WorkingDirectory + $pushedLocation = $true + } + + $output = @(& git @Arguments 2>&1 | ForEach-Object { $_.ToString() }) + $exitCode = $LASTEXITCODE + } finally { + if ($pushedLocation) { + Pop-Location + } + } + + if (-not $AllowFailure -and $exitCode -ne 0) { + $detail = ($output -join [Environment]::NewLine).Trim() + throw "git $($Arguments -join ' ') failed with exit code ${exitCode}: $detail" + } + + [pscustomobject]@{ + ExitCode = $exitCode + Output = $output + } +} + +function Get-JsonPropertyValue { + param( + [Parameter(Mandatory)] + [object]$InputObject, + [Parameter(Mandatory)] + [string]$Name, + [object]$DefaultValue = $null + ) + + $property = $InputObject.PSObject.Properties[$Name] + if ($null -eq $property) { + return $DefaultValue + } + + return $property.Value +} + +function Read-JsonObjectFile { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $rawJson = Get-Content -LiteralPath $Path -Raw + $jsonDocument = $null + try { + $jsonDocument = [System.Text.Json.JsonDocument]::Parse($rawJson) + if ($jsonDocument.RootElement.ValueKind -ne [System.Text.Json.JsonValueKind]::Object) { + throw "JSON file must contain a top-level object: $Path" + } + } catch { + if ($_.Exception.Message -like 'JSON file must contain*') { + throw + } + throw "Invalid JSON in ${Path}: $($_.Exception.Message)" + } finally { + if ($null -ne $jsonDocument) { + $jsonDocument.Dispose() + } + } + + try { + return $rawJson | ConvertFrom-Json + } catch { + throw "Invalid JSON in ${Path}: $($_.Exception.Message)" + } +} + +function Assert-RequiredText { + param( + [Parameter(Mandatory)] + [string]$Name, + [AllowNull()] + [object]$Value + ) + + if ([string]::IsNullOrWhiteSpace([string]$Value)) { + throw "$Name must not be empty." + } +} + +function Assert-ZipPackage { + param( + [Parameter(Mandatory)] + [string]$Path, + [Parameter(Mandatory)] + [object]$Manifest, + [Parameter(Mandatory)] + [string]$ExpectedPluginId, + [Parameter(Mandatory)] + [string]$ExpectedVersion + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Plugin ZIP does not exist: $Path" + } + + $zipItem = Get-Item -LiteralPath $Path + if ($zipItem.Length -le 0) { + throw "Plugin ZIP is empty: $Path" + } + + $archive = $null + try { + $archive = [System.IO.Compression.ZipFile]::OpenRead($zipItem.FullName) + if ($archive.Entries.Count -eq 0) { + throw "Plugin ZIP contains no entries: $Path" + } + + foreach ($entry in $archive.Entries) { + $normalizedName = $entry.FullName.Replace('\', '/') + $segments = @($normalizedName.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)) + if ( + $normalizedName.StartsWith('/') -or + $normalizedName -match '^[A-Za-z]:' -or + $segments -contains '..' + ) { + throw "Plugin ZIP contains an unsafe entry path: $($entry.FullName)" + } + } + + $manifestEntries = @($archive.Entries | Where-Object { $_.FullName.Replace('\', '/') -eq 'manifest.json' }) + if ($manifestEntries.Count -ne 1) { + throw "Plugin ZIP must contain exactly one root manifest.json." + } + + $reader = [System.IO.StreamReader]::new($manifestEntries[0].Open()) + try { + $archiveManifest = $reader.ReadToEnd() | ConvertFrom-Json + } catch { + throw "Plugin ZIP contains an invalid manifest.json: $($_.Exception.Message)" + } finally { + $reader.Dispose() + } + + if ([string]$archiveManifest.id -ne $ExpectedPluginId) { + throw "Plugin ZIP manifest id '$($archiveManifest.id)' does not match '$ExpectedPluginId'." + } + if ([string]$archiveManifest.version -ne $ExpectedVersion) { + throw "Plugin ZIP manifest version '$($archiveManifest.version)' does not match '$ExpectedVersion'." + } + + $assemblyName = [string](Get-JsonPropertyValue -InputObject $Manifest -Name 'assemblyName') + Assert-RequiredText -Name 'Manifest assemblyName' -Value $assemblyName + if ([string]$archiveManifest.assemblyName -ne $assemblyName) { + throw "Plugin ZIP manifest assemblyName does not match the source manifest." + } + + $assemblyEntries = @($archive.Entries | Where-Object { $_.FullName.Replace('\', '/') -eq $assemblyName }) + if ($assemblyEntries.Count -ne 1 -or $assemblyEntries[0].Length -le 0) { + throw "Plugin ZIP does not contain the expected root assembly '$assemblyName'." + } + } catch { + if ($_.Exception.Message -like 'Plugin ZIP*') { + throw + } + throw "Plugin ZIP could not be validated: $($_.Exception.Message)" + } finally { + if ($null -ne $archive) { + $archive.Dispose() + } + } +} + +function Get-RegistryEntries { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $rawRegistry = Get-Content -LiteralPath $Path -Raw + $jsonDocument = $null + try { + $jsonDocument = [System.Text.Json.JsonDocument]::Parse($rawRegistry) + if ($jsonDocument.RootElement.ValueKind -ne [System.Text.Json.JsonValueKind]::Array) { + throw "Registry must contain a top-level JSON array: $Path" + } + } catch { + if ($_.Exception.Message -like 'Registry must contain*') { + throw + } + throw "Invalid JSON in ${Path}: $($_.Exception.Message)" + } finally { + if ($null -ne $jsonDocument) { + $jsonDocument.Dispose() + } + } + + try { + $entries = @($rawRegistry | ConvertFrom-Json) + } catch { + throw "Invalid JSON in ${Path}: $($_.Exception.Message)" + } + $ids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($entry in $entries) { + $id = [string](Get-JsonPropertyValue -InputObject $entry -Name 'id') + Assert-RequiredText -Name "Registry id in $Path" -Value $id + if (-not $ids.Add($id)) { + throw "Registry contains duplicate plugin id '$id': $Path" + } + } + + return $entries +} + +function Set-RegistryProperty { + param( + [Parameter(Mandatory)] + [object]$Entry, + [Parameter(Mandatory)] + [string]$Name, + [AllowNull()] + [object]$Value + ) + + $Entry | Add-Member -MemberType NoteProperty -Name $Name -Value $Value -Force +} + +function Write-StagedRegistry { + param( + [Parameter(Mandatory)] + [string]$WorktreePath, + [Parameter(Mandatory)] + [object]$Manifest, + [Parameter(Mandatory)] + [string]$ExpectedPluginId, + [Parameter(Mandatory)] + [string]$ExpectedVersion, + [Parameter(Mandatory)] + [long]$ExpectedZipSize, + [Parameter(Mandatory)] + [string]$ExpectedDownloadUrl, + [Parameter(Mandatory)] + [string]$SourceZipPath + ) + + $registryPath = Join-Path $WorktreePath 'plugins.json' + if (-not (Test-Path -LiteralPath $registryPath -PathType Leaf)) { + throw "Registry file is missing from gh-pages: $registryPath" + } + + $registry = @(Get-RegistryEntries -Path $registryPath) + $matches = @($registry | Where-Object { [string]$_.id -eq $ExpectedPluginId }) + if ($matches.Count -gt 1) { + throw "Registry contains duplicate plugin id '$ExpectedPluginId'." + } + + if ($matches.Count -eq 1) { + $entry = $matches[0] + + $existingVersion = $null + $incomingVersion = $null + if ( + [version]::TryParse([string]$entry.version, [ref]$existingVersion) -and + [version]::TryParse($ExpectedVersion, [ref]$incomingVersion) -and + $existingVersion -gt $incomingVersion + ) { + throw "Registry already contains a newer version '$($entry.version)' for '$ExpectedPluginId'; refusing to downgrade to '$ExpectedVersion'." + } + + Set-RegistryProperty -Entry $entry -Name 'version' -Value $ExpectedVersion + Set-RegistryProperty -Entry $entry -Name 'size' -Value $ExpectedZipSize + Set-RegistryProperty -Entry $entry -Name 'downloadUrl' -Value $ExpectedDownloadUrl + Write-Host "Staged registry update for $ExpectedPluginId v$ExpectedVersion." + } else { + $entry = [pscustomobject][ordered]@{ + id = [string](Get-JsonPropertyValue -InputObject $Manifest -Name 'id') + name = [string](Get-JsonPropertyValue -InputObject $Manifest -Name 'name') + version = $ExpectedVersion + minHostVersion = [string](Get-JsonPropertyValue -InputObject $Manifest -Name 'minHostVersion') + author = [string](Get-JsonPropertyValue -InputObject $Manifest -Name 'author') + description = [string](Get-JsonPropertyValue -InputObject $Manifest -Name 'description') + category = [string](Get-JsonPropertyValue -InputObject $Manifest -Name 'category') + size = $ExpectedZipSize + downloadUrl = $ExpectedDownloadUrl + iconSystemName = [string](Get-JsonPropertyValue -InputObject $Manifest -Name 'iconSystemName') + requiresApiKey = [bool](Get-JsonPropertyValue -InputObject $Manifest -Name 'requiresApiKey' -DefaultValue $false) + descriptions = Get-JsonPropertyValue -InputObject $Manifest -Name 'descriptions' + } + + $registry += $entry + Write-Host "Staged new registry entry for $ExpectedPluginId v$ExpectedVersion." + } + + ConvertTo-Json -InputObject $registry -Depth 20 | + Set-Content -LiteralPath $registryPath -Encoding utf8NoBOM + + $pluginsDirectory = Join-Path $WorktreePath 'plugins' + New-Item -ItemType Directory -Path $pluginsDirectory -Force | Out-Null + $stagedZipPath = Join-Path $pluginsDirectory ([System.IO.Path]::GetFileName($SourceZipPath)) + Copy-Item -LiteralPath $SourceZipPath -Destination $stagedZipPath -Force + + $stagedRegistry = @(Get-RegistryEntries -Path $registryPath) + $stagedMatches = @($stagedRegistry | Where-Object { [string]$_.id -eq $ExpectedPluginId }) + if ($stagedMatches.Count -ne 1) { + throw "The staged registry does not contain exactly one '$ExpectedPluginId' entry." + } + + $stagedEntry = $stagedMatches[0] + if ( + [string]$stagedEntry.version -ne $ExpectedVersion -or + [long]$stagedEntry.size -ne $ExpectedZipSize -or + [string]$stagedEntry.downloadUrl -ne $ExpectedDownloadUrl + ) { + throw "The staged registry entry for '$ExpectedPluginId' failed validation." + } + + $stagedZip = Get-Item -LiteralPath $stagedZipPath + if ($stagedZip.Length -ne $ExpectedZipSize) { + throw "The staged ZIP size does not match the validated source ZIP." + } + + $sourceHash = (Get-FileHash -LiteralPath $SourceZipPath -Algorithm SHA256).Hash + $stagedHash = (Get-FileHash -LiteralPath $stagedZipPath -Algorithm SHA256).Hash + if ($sourceHash -ne $stagedHash) { + throw "The staged ZIP does not match the validated source ZIP." + } +} + +function Sync-RegistryWorktree { + param( + [Parameter(Mandatory)] + [string]$RepositoryRoot, + [Parameter(Mandatory)] + [string]$WorktreePath + ) + + Invoke-GitCommand -WorkingDirectory $RepositoryRoot -Arguments @('fetch', 'origin', 'gh-pages') | Out-Null + + if (-not (Test-Path -LiteralPath $WorktreePath)) { + Invoke-GitCommand -WorkingDirectory $RepositoryRoot -Arguments @( + 'worktree', + 'add', + '-B', + 'gh-pages', + $WorktreePath, + 'origin/gh-pages' + ) | Out-Null + } else { + $worktreeCheck = Invoke-GitCommand -WorkingDirectory $WorktreePath -Arguments @( + 'rev-parse', + '--is-inside-work-tree' + ) -AllowFailure + if ($worktreeCheck.ExitCode -ne 0 -or ($worktreeCheck.Output -join '').Trim() -ne 'true') { + throw "Registry worktree path is not a Git worktree: $WorktreePath" + } + } + + Invoke-GitCommand -WorkingDirectory $WorktreePath -Arguments @('fetch', 'origin', 'gh-pages') | Out-Null + Invoke-GitCommand -WorkingDirectory $WorktreePath -Arguments @('reset', '--hard', 'origin/gh-pages') | Out-Null +} + +function Get-TagCommitSha { + param( + [Parameter(Mandatory)] + [string]$Repository, + [Parameter(Mandatory)] + [string]$Tag + ) + + $encodedTag = [Uri]::EscapeDataString($Tag) + $result = Invoke-GhCommand -Arguments @( + 'api', + '--method', + 'GET', + "repos/$Repository/commits/$encodedTag", + '--jq', + '.sha' + ) + + $sha = @($result.Output | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })[-1].Trim() + if ($sha -notmatch '^[0-9a-fA-F]{40}$') { + throw "GitHub returned an invalid commit SHA for tag '$Tag'." + } + + return $sha +} + +function Get-ReleaseForTag { + param( + [Parameter(Mandatory)] + [string]$Repository, + [Parameter(Mandatory)] + [string]$Tag + ) + + $encodedTag = [Uri]::EscapeDataString($Tag) + $result = Invoke-GhCommand -Arguments @( + 'api', + '--include', + '--method', + 'GET', + "repos/$Repository/releases/tags/$encodedTag" + ) -AllowFailure + + $statusCode = $null + foreach ($line in $result.Output) { + if ($line.Trim() -match '^HTTP/\S+\s+([0-9]{3})(?:\s|$)') { + $statusCode = [int]$Matches[1] + } + } + + if ($null -eq $statusCode) { + $detail = ($result.Output -join [Environment]::NewLine).Trim() + throw "Release query for '$Tag' did not return an HTTP status: $detail" + } + + if ($statusCode -eq 404 -and $result.ExitCode -ne 0) { + return $null + } + + if ($result.ExitCode -ne 0 -or $statusCode -lt 200 -or $statusCode -ge 300) { + $detail = ($result.Output -join [Environment]::NewLine).Trim() + throw "Release query for '$Tag' failed with HTTP $statusCode and exit code $($result.ExitCode): $detail" + } + + $bodyStart = -1 + for ($i = 0; $i -lt $result.Output.Count; $i++) { + if ($result.Output[$i].TrimStart().StartsWith('{')) { + $bodyStart = $i + break + } + } + if ($bodyStart -lt 0) { + throw "Release query for '$Tag' returned no JSON body." + } + + try { + $body = $result.Output[$bodyStart..($result.Output.Count - 1)] -join [Environment]::NewLine + return $body | ConvertFrom-Json + } catch { + throw "Release query for '$Tag' returned invalid JSON: $($_.Exception.Message)" + } +} + +function Assert-ReleaseTag { + param( + [Parameter(Mandatory)] + [object]$Release, + [Parameter(Mandatory)] + [string]$ExpectedTag, + [Parameter(Mandatory)] + [string]$ResolvedTagSha, + [Parameter(Mandatory)] + [string]$ExpectedCommitSha, + [switch]$RequirePinnedTarget + ) + + if ([string]$Release.tag_name -ne $ExpectedTag) { + throw "Release tag '$($Release.tag_name)' does not match '$ExpectedTag'." + } + if ($ResolvedTagSha -ne $ExpectedCommitSha) { + throw "Tag '$ExpectedTag' resolves to '$ResolvedTagSha', not '$ExpectedCommitSha'." + } + if ($RequirePinnedTarget -and [string]$Release.target_commitish -ne $ExpectedCommitSha) { + throw "Draft release target '$($Release.target_commitish)' is not pinned to '$ExpectedCommitSha'." + } +} + +function Assert-ReleaseAsset { + param( + [Parameter(Mandatory)] + [object]$Release, + [Parameter(Mandatory)] + [string]$ExpectedAssetName, + [Parameter(Mandatory)] + [long]$ExpectedAssetSize + ) + + $assets = @(Get-JsonPropertyValue -InputObject $Release -Name 'assets' -DefaultValue @()) + $matches = @($assets | Where-Object { [string]$_.name -eq $ExpectedAssetName }) + if ($matches.Count -ne 1) { + throw "Release must contain exactly one asset named '$ExpectedAssetName'." + } + + $asset = $matches[0] + if ([long]$asset.size -ne $ExpectedAssetSize) { + throw "Release asset '$ExpectedAssetName' has size $($asset.size), expected $ExpectedAssetSize." + } + + $state = [string](Get-JsonPropertyValue -InputObject $asset -Name 'state') + if (-not [string]::IsNullOrWhiteSpace($state) -and $state -ne 'uploaded') { + throw "Release asset '$ExpectedAssetName' is not fully uploaded (state: $state)." + } +} + +function Set-DraftReleaseAsset { + param( + [Parameter(Mandatory)] + [object]$Release, + [Parameter(Mandatory)] + [string]$Repository, + [Parameter(Mandatory)] + [string]$Tag, + [Parameter(Mandatory)] + [string]$ZipPath + ) + + $assetName = [System.IO.Path]::GetFileName($ZipPath) + $assets = @(Get-JsonPropertyValue -InputObject $Release -Name 'assets' -DefaultValue @()) + $sameNameAssets = @($assets | Where-Object { [string]$_.name -eq $assetName }) + if ($sameNameAssets.Count -gt 1) { + throw "Draft release contains duplicate assets named '$assetName'; refusing to clobber." + } + + $arguments = @('release', 'upload', $Tag, $ZipPath, '--repo', $Repository) + if ($sameNameAssets.Count -eq 1) { + $arguments += '--clobber' + Write-Host "Replacing the controlled draft asset '$assetName'." + } else { + Write-Host "Uploading draft asset '$assetName'." + } + + Invoke-GhCommand -Arguments $arguments | Out-Null +} + +function Push-StagedRegistry { + param( + [Parameter(Mandatory)] + [string]$RepositoryRoot, + [Parameter(Mandatory)] + [string]$WorktreePath, + [Parameter(Mandatory)] + [object]$Manifest, + [Parameter(Mandatory)] + [string]$PluginId, + [Parameter(Mandatory)] + [string]$PluginVersion, + [Parameter(Mandatory)] + [long]$ZipSize, + [Parameter(Mandatory)] + [string]$DownloadUrl, + [Parameter(Mandatory)] + [string]$ZipPath, + [Parameter(Mandatory)] + [string]$ProjectName, + [Parameter(Mandatory)] + [int]$MaxAttempts + ) + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + if ($attempt -gt 1) { + Sync-RegistryWorktree -RepositoryRoot $RepositoryRoot -WorktreePath $WorktreePath + Write-StagedRegistry ` + -WorktreePath $WorktreePath ` + -Manifest $Manifest ` + -ExpectedPluginId $PluginId ` + -ExpectedVersion $PluginVersion ` + -ExpectedZipSize $ZipSize ` + -ExpectedDownloadUrl $DownloadUrl ` + -SourceZipPath $ZipPath + } + + Invoke-GitCommand -WorkingDirectory $WorktreePath -Arguments @('add', '--all') | Out-Null + Invoke-GitCommand -WorkingDirectory $WorktreePath -Arguments @( + 'config', + 'user.name', + 'github-actions[bot]' + ) | Out-Null + Invoke-GitCommand -WorkingDirectory $WorktreePath -Arguments @( + 'config', + 'user.email', + 'github-actions[bot]@users.noreply.github.com' + ) | Out-Null + + $diff = Invoke-GitCommand -WorkingDirectory $WorktreePath -Arguments @( + 'diff', + '--cached', + '--quiet' + ) -AllowFailure + if ($diff.ExitCode -gt 1) { + throw "git diff failed with exit code $($diff.ExitCode)." + } + + if ($diff.ExitCode -eq 1) { + Invoke-GitCommand -WorkingDirectory $WorktreePath -Arguments @( + 'commit', + '-m', + "Update $ProjectName to v$PluginVersion" + ) | Out-Null + Invoke-GitCommand -WorkingDirectory $WorktreePath -Arguments @( + 'push', + 'origin', + 'gh-pages' + ) | Out-Null + Write-Host "Successfully pushed the plugin registry (attempt $attempt)." + } else { + Write-Host "The plugin registry already contains the staged release." + } + + return + } catch { + if ($attempt -eq $MaxAttempts) { + throw "Registry push failed after $MaxAttempts attempt(s): $($_.Exception.Message)" + } + + Write-Warning "Registry push failed on attempt $attempt; retrying after ${attempt}s." + Start-Sleep -Seconds $attempt + } + } +} + +function Invoke-PluginReleaseTransaction { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Tag, + [Parameter(Mandatory)] + [string]$Repository, + [Parameter(Mandatory)] + [string]$CommitSha, + [Parameter(Mandatory)] + [string]$ProjectName, + [Parameter(Mandatory)] + [string]$PluginVersion, + [Parameter(Mandatory)] + [string]$PluginId, + [Parameter(Mandatory)] + [string]$ZipPath, + [Parameter(Mandatory)] + [string]$ManifestPath, + [string]$RegistryWorktreePath = 'gh-pages-work', + [ValidateRange(1, 20)] + [int]$MaxPushAttempts = 5 + ) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + + $requiredValues = [ordered]@{ + Tag = $Tag + Repository = $Repository + CommitSha = $CommitSha + ProjectName = $ProjectName + PluginVersion = $PluginVersion + PluginId = $PluginId + ZipPath = $ZipPath + ManifestPath = $ManifestPath + RegistryWorktreePath = $RegistryWorktreePath + } + foreach ($requiredValue in $requiredValues.GetEnumerator()) { + Assert-RequiredText -Name $requiredValue.Key -Value $requiredValue.Value + } + + if ($Repository -notmatch '^[^/\s]+/[^/\s]+$') { + throw "Repository must use the 'owner/name' format." + } + if ($CommitSha -notmatch '^[0-9a-fA-F]{40}$') { + throw "CommitSha must be a full 40-character commit SHA." + } + + $repositoryRoot = (Get-Location).ProviderPath + $resolvedZipPath = (Resolve-Path -LiteralPath $ZipPath).ProviderPath + $resolvedManifestPath = (Resolve-Path -LiteralPath $ManifestPath).ProviderPath + $resolvedWorktreePath = if ([System.IO.Path]::IsPathRooted($RegistryWorktreePath)) { + [System.IO.Path]::GetFullPath($RegistryWorktreePath) + } else { + [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot $RegistryWorktreePath)) + } + + $manifest = Read-JsonObjectFile -Path $resolvedManifestPath + if ([string](Get-JsonPropertyValue -InputObject $manifest -Name 'id') -ne $PluginId) { + throw "Manifest id does not match PluginId '$PluginId'." + } + if ([string](Get-JsonPropertyValue -InputObject $manifest -Name 'version') -ne $PluginVersion) { + throw "Manifest version does not match PluginVersion '$PluginVersion'." + } + + $expectedZipName = "$PluginId-$PluginVersion.zip" + if ([System.IO.Path]::GetFileName($resolvedZipPath) -ne $expectedZipName) { + throw "ZIP name must be '$expectedZipName'." + } + + Assert-ZipPackage ` + -Path $resolvedZipPath ` + -Manifest $manifest ` + -ExpectedPluginId $PluginId ` + -ExpectedVersion $PluginVersion + + $zipSize = (Get-Item -LiteralPath $resolvedZipPath).Length + $encodedTag = [Uri]::EscapeDataString($Tag) + $encodedZipName = [Uri]::EscapeDataString($expectedZipName) + $downloadUrl = "https://github.com/$Repository/releases/download/$encodedTag/$encodedZipName" + + Sync-RegistryWorktree -RepositoryRoot $repositoryRoot -WorktreePath $resolvedWorktreePath + Write-StagedRegistry ` + -WorktreePath $resolvedWorktreePath ` + -Manifest $manifest ` + -ExpectedPluginId $PluginId ` + -ExpectedVersion $PluginVersion ` + -ExpectedZipSize $zipSize ` + -ExpectedDownloadUrl $downloadUrl ` + -SourceZipPath $resolvedZipPath + + Write-Host "Validated the plugin ZIP and prospective registry before release mutation." + + $resolvedTagSha = Get-TagCommitSha -Repository $Repository -Tag $Tag + if ($resolvedTagSha -ne $CommitSha) { + throw "Tag '$Tag' resolves to '$resolvedTagSha', not '$CommitSha'." + } + + $release = Get-ReleaseForTag -Repository $Repository -Tag $Tag + $draftTransaction = $false + + if ($null -eq $release) { + Write-Host "No release exists for '$Tag'; creating a draft pinned to $CommitSha." + Invoke-GhCommand -Arguments @( + 'release', + 'create', + $Tag, + '--repo', + $Repository, + '--draft', + '--target', + $CommitSha, + '--verify-tag', + '--title', + "$ProjectName v$PluginVersion", + '--notes', + '' + ) | Out-Null + + $release = Get-ReleaseForTag -Repository $Repository -Tag $Tag + if ($null -eq $release) { + throw "Draft release creation completed but the release cannot be queried." + } + $draftTransaction = $true + } elseif ([bool]$release.draft) { + Write-Host "Reusing the existing draft release for '$Tag'." + $draftTransaction = $true + } else { + Write-Host "A public release already exists for '$Tag'; entering registry-repair mode." + } + + Assert-ReleaseTag ` + -Release $release ` + -ExpectedTag $Tag ` + -ResolvedTagSha $resolvedTagSha ` + -ExpectedCommitSha $CommitSha ` + -RequirePinnedTarget:$draftTransaction + + if ($draftTransaction) { + Set-DraftReleaseAsset ` + -Release $release ` + -Repository $Repository ` + -Tag $Tag ` + -ZipPath $resolvedZipPath + + $release = Get-ReleaseForTag -Repository $Repository -Tag $Tag + if ($null -eq $release -or -not [bool]$release.draft) { + throw "Release '$Tag' is no longer a draft after asset upload." + } + Assert-ReleaseTag ` + -Release $release ` + -ExpectedTag $Tag ` + -ResolvedTagSha $resolvedTagSha ` + -ExpectedCommitSha $CommitSha ` + -RequirePinnedTarget + Assert-ReleaseAsset ` + -Release $release ` + -ExpectedAssetName $expectedZipName ` + -ExpectedAssetSize $zipSize + } else { + Assert-ReleaseAsset ` + -Release $release ` + -ExpectedAssetName $expectedZipName ` + -ExpectedAssetSize $zipSize + } + + Push-StagedRegistry ` + -RepositoryRoot $repositoryRoot ` + -WorktreePath $resolvedWorktreePath ` + -Manifest $manifest ` + -PluginId $PluginId ` + -PluginVersion $PluginVersion ` + -ZipSize $zipSize ` + -DownloadUrl $downloadUrl ` + -ZipPath $resolvedZipPath ` + -ProjectName $ProjectName ` + -MaxAttempts $MaxPushAttempts + + if ($draftTransaction) { + $release = Get-ReleaseForTag -Repository $Repository -Tag $Tag + if ($null -eq $release) { + throw "Draft release disappeared after the registry push." + } + + Assert-ReleaseTag ` + -Release $release ` + -ExpectedTag $Tag ` + -ResolvedTagSha $resolvedTagSha ` + -ExpectedCommitSha $CommitSha ` + -RequirePinnedTarget + Assert-ReleaseAsset ` + -Release $release ` + -ExpectedAssetName $expectedZipName ` + -ExpectedAssetSize $zipSize + + if ([bool]$release.draft) { + Write-Host "Registry push succeeded; publishing draft release '$Tag'." + Invoke-GhCommand -Arguments @( + 'release', + 'edit', + $Tag, + '--repo', + $Repository, + '--draft=false' + ) | Out-Null + } else { + Write-Host "Release '$Tag' was already published after the registry push." + } + } +} + +if ($MyInvocation.InvocationName -ne '.') { + Invoke-PluginReleaseTransaction @PSBoundParameters +} diff --git a/tests/workflows/PublishPluginReleaseTransaction.Tests.ps1 b/tests/workflows/PublishPluginReleaseTransaction.Tests.ps1 new file mode 100644 index 000000000..b29a5fab2 --- /dev/null +++ b/tests/workflows/PublishPluginReleaseTransaction.Tests.ps1 @@ -0,0 +1,294 @@ +BeforeAll { + $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).ProviderPath + . (Join-Path $repositoryRoot 'scripts/publish-plugin-release.ps1') + + function New-CommandResult { + param( + [int]$ExitCode = 0, + [string[]]$Output = @() + ) + + [pscustomobject]@{ + ExitCode = $ExitCode + Output = $Output + } + } +} + +Describe 'Publish plugin release transaction' { + BeforeEach { + $script:tag = 'plugin-example-v2.0.0' + $script:repository = 'example/typewhisper' + $script:commitSha = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + $script:projectName = 'TypeWhisper.Plugin.Example' + $script:pluginId = 'example' + $script:pluginVersion = '2.0.0' + $script:releaseState = 'missing' + $script:releaseTarget = $script:commitSha + $script:releaseAssets = @() + $script:releaseQueryError = $false + $script:remainingPushFailures = 0 + $script:ghCalls = [System.Collections.Generic.List[string]]::new() + $script:gitCalls = [System.Collections.Generic.List[string]]::new() + $script:invocations = [System.Collections.Generic.List[string]]::new() + $script:events = [System.Collections.Generic.List[string]]::new() + + $script:fixtureRoot = Join-Path $TestDrive ([Guid]::NewGuid().ToString('N')) + $script:packageRoot = Join-Path $script:fixtureRoot 'package' + $script:worktreePath = Join-Path $script:fixtureRoot 'gh-pages-work' + New-Item -ItemType Directory -Path $script:packageRoot -Force | Out-Null + New-Item -ItemType Directory -Path $script:worktreePath -Force | Out-Null + + $manifest = [ordered]@{ + id = $script:pluginId + name = 'Example' + version = $script:pluginVersion + assemblyName = 'TypeWhisper.Plugin.Example.dll' + minHostVersion = '1.0.0' + author = 'TypeWhisper' + description = 'Example plugin' + category = 'Utility' + iconSystemName = 'PuzzlePiece' + requiresApiKey = $false + descriptions = [ordered]@{ + en = 'Example plugin' + } + } + + $script:manifestPath = Join-Path $script:fixtureRoot 'manifest.json' + ConvertTo-Json -InputObject $manifest -Depth 10 | + Set-Content -LiteralPath $script:manifestPath -Encoding utf8NoBOM + Copy-Item -LiteralPath $script:manifestPath -Destination (Join-Path $script:packageRoot 'manifest.json') + Set-Content ` + -LiteralPath (Join-Path $script:packageRoot 'TypeWhisper.Plugin.Example.dll') ` + -Value 'deterministic test assembly' ` + -Encoding utf8NoBOM + + $script:zipPath = Join-Path $script:fixtureRoot "$($script:pluginId)-$($script:pluginVersion).zip" + Compress-Archive -Path (Join-Path $script:packageRoot '*') -DestinationPath $script:zipPath + $script:zipSize = (Get-Item -LiteralPath $script:zipPath).Length + + $oldRegistry = @( + [ordered]@{ + id = $script:pluginId + name = 'Example' + version = '1.0.0' + size = 10 + downloadUrl = 'https://example.invalid/old.zip' + } + ) + ConvertTo-Json -InputObject $oldRegistry -Depth 10 | + Set-Content -LiteralPath (Join-Path $script:worktreePath 'plugins.json') -Encoding utf8NoBOM + + Mock Invoke-GitCommand { + param( + [string[]]$Arguments, + [string]$WorkingDirectory, + [switch]$AllowFailure + ) + + $command = $Arguments -join ' ' + $script:gitCalls.Add($command) + $script:invocations.Add("git $command") + + if ($command -eq 'rev-parse --is-inside-work-tree') { + return New-CommandResult -Output @('true') + } + if ($command -eq 'diff --cached --quiet') { + return New-CommandResult -ExitCode 1 + } + if ($command -eq 'push origin gh-pages') { + if ($script:remainingPushFailures -gt 0) { + $script:remainingPushFailures-- + $script:events.Add('registry-push-failed') + throw 'simulated registry push failure' + } + + $script:events.Add('registry-pushed') + } + + return New-CommandResult + } + + Mock Invoke-GhCommand { + param( + [string[]]$Arguments, + [switch]$AllowFailure + ) + + $command = $Arguments -join ' ' + $script:ghCalls.Add($command) + $script:invocations.Add("gh $command") + + if ($command -match '^api --method GET repos/.+/commits/.+ --jq \.sha$') { + return New-CommandResult -Output @($script:commitSha) + } + + if ($command -match '^api --include --method GET repos/.+/releases/tags/') { + if ($script:releaseQueryError) { + return New-CommandResult ` + -ExitCode 1 ` + -Output @('HTTP/2 503 Service Unavailable', '', '{"message":"unavailable"}') + } + if ($script:releaseState -eq 'missing') { + return New-CommandResult ` + -ExitCode 1 ` + -Output @('HTTP/2 404 Not Found', '', '{"message":"Not Found"}') + } + + $releaseJson = [ordered]@{ + id = 42 + tag_name = $script:tag + target_commitish = $script:releaseTarget + draft = $script:releaseState -eq 'draft' + assets = @($script:releaseAssets) + } | ConvertTo-Json -Depth 10 -Compress + return New-CommandResult -Output @('HTTP/2 200 OK', '', $releaseJson) + } + + if ($command -match '^release create ') { + if ($script:releaseState -ne 'missing') { + throw 'duplicate release creation' + } + + $script:releaseState = if ($Arguments -contains '--draft') { 'draft' } else { 'public' } + $script:releaseTarget = $script:commitSha + $script:releaseAssets = @() + $script:events.Add("$($script:releaseState)-created") + return New-CommandResult + } + + if ($command -match '^release upload ') { + if ($script:releaseState -ne 'draft') { + throw 'asset upload attempted for a non-draft release' + } + + $script:releaseAssets = @( + [ordered]@{ + name = [System.IO.Path]::GetFileName($script:zipPath) + size = $script:zipSize + state = 'uploaded' + } + ) + $script:events.Add('asset-uploaded') + return New-CommandResult + } + + if ($command -match '^release edit ') { + if ($script:releaseState -ne 'draft') { + throw 'publication attempted for a non-draft release' + } + + $script:releaseState = 'public' + $script:events.Add('release-published') + return New-CommandResult + } + + throw "Unexpected gh command: $command" + } + + $script:transactionParameters = @{ + Tag = $script:tag + Repository = $script:repository + CommitSha = $script:commitSha + ProjectName = $script:projectName + PluginVersion = $script:pluginVersion + PluginId = $script:pluginId + ZipPath = $script:zipPath + ManifestPath = $script:manifestPath + RegistryWorktreePath = $script:worktreePath + MaxPushAttempts = 1 + } + } + + It 'resumes a draft after a registry push failure and publishes only after repair' { + $script:remainingPushFailures = 1 + + { Invoke-PluginReleaseTransaction @script:transactionParameters } | + Should -Throw '*Registry push failed*' + + $script:releaseState | Should -Be 'draft' + $createCalls = @($script:ghCalls | Where-Object { $_ -match '^release create ' }) + $createCalls.Count | Should -Be 1 + $createCalls[0] | Should -Match ' --draft(?: |$)' + $createCalls[0] | Should -Match " --target $([regex]::Escape($script:commitSha))(?: |$)" + $createCalls[0] | Should -Match ' --verify-tag(?: |$)' + @($script:ghCalls | Where-Object { $_ -match '^release edit .* --draft=false(?: |$)' }).Count | + Should -Be 0 + + Invoke-PluginReleaseTransaction @script:transactionParameters + + $script:releaseState | Should -Be 'public' + @($script:ghCalls | Where-Object { $_ -match '^release create ' }).Count | Should -Be 1 + @($script:ghCalls | Where-Object { $_ -match '^release upload .* --clobber$' }).Count | Should -Be 1 + $publishCall = "gh release edit $($script:tag) --repo $($script:repository) --draft=false" + @($script:invocations | Where-Object { $_ -eq $publishCall }).Count | Should -Be 1 + $successfulPushIndex = $script:invocations.LastIndexOf('git push origin gh-pages') + $publishIndex = $script:invocations.IndexOf($publishCall) + $successfulPushIndex | Should -BeGreaterThan -1 + $publishIndex | Should -BeGreaterThan $successfulPushIndex + $script:events[-2] | Should -Be 'registry-pushed' + $script:events[-1] | Should -Be 'release-published' + + $registry = Get-Content -LiteralPath (Join-Path $script:worktreePath 'plugins.json') -Raw | + ConvertFrom-Json + $registry[0].version | Should -Be $script:pluginVersion + [long]$registry[0].size | Should -Be $script:zipSize + } + + It 'repairs the registry for a verified existing public release without recreating it' { + $script:releaseState = 'public' + $script:releaseTarget = 'linux' + $script:releaseAssets = @( + [ordered]@{ + name = [System.IO.Path]::GetFileName($script:zipPath) + size = $script:zipSize + state = 'uploaded' + } + ) + + Invoke-PluginReleaseTransaction @script:transactionParameters + + $script:events[-1] | Should -Be 'registry-pushed' + @($script:ghCalls | Where-Object { $_ -match '^release create ' }).Count | Should -Be 0 + @($script:ghCalls | Where-Object { $_ -match '^release upload ' }).Count | Should -Be 0 + @($script:ghCalls | Where-Object { $_ -match '^release edit ' }).Count | Should -Be 0 + } + + It 'refuses to downgrade the registry when a newer version is already published' { + $newerRegistry = @( + [ordered]@{ + id = $script:pluginId + name = 'Example' + version = '3.0.0' + size = 20 + downloadUrl = 'https://example.invalid/new.zip' + } + ) + ConvertTo-Json -InputObject $newerRegistry -Depth 10 | + Set-Content -LiteralPath (Join-Path $script:worktreePath 'plugins.json') -Encoding utf8NoBOM + + { Invoke-PluginReleaseTransaction @script:transactionParameters } | + Should -Throw '*refusing to downgrade*' + + @($script:ghCalls | Where-Object { $_ -match '^release create ' }).Count | Should -Be 0 + @($script:gitCalls | Where-Object { $_ -eq 'push origin gh-pages' }).Count | Should -Be 0 + @($script:ghCalls | Where-Object { $_ -match '^release edit ' }).Count | Should -Be 0 + + $registry = Get-Content -LiteralPath (Join-Path $script:worktreePath 'plugins.json') -Raw | + ConvertFrom-Json + $registry[0].version | Should -Be '3.0.0' + } + + It 'fails closed when the release query errors' { + $script:releaseQueryError = $true + + { Invoke-PluginReleaseTransaction @script:transactionParameters } | + Should -Throw '*Release query*failed*' + + $script:releaseState | Should -Be 'missing' + @($script:ghCalls | Where-Object { $_ -match '^release create ' }).Count | Should -Be 0 + @($script:gitCalls | Where-Object { $_ -eq 'push origin gh-pages' }).Count | Should -Be 0 + @($script:ghCalls | Where-Object { $_ -match '^release edit ' }).Count | Should -Be 0 + } +} From 71977da063816d76172160d71291fba6d672464e Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Sun, 26 Jul 2026 08:12:39 +0000 Subject: [PATCH 191/226] Gate plugin-tag releases on tests and cover the last untested plugins Every plugin-*-v* tag went from checkout and manifest patching straight to build, ZIP, and publication with no test step, and branch CI runs only on the linux branch - so a plugin release never proved its tagged commit passed anything. CloudflareAsr, Linear, and Qwen3Stt were the last three deployable plugins with no direct tests. The publish workflow now runs the PluginSystem suite in Release before manifest patching, with the same blame-hang diagnostics and step timeout as branch CI; the test build skips the bundled-plugin deploy hook (DeployBundledLinuxPlugins=false) so the gate does not publish all 32 plugins as a side effect of building its Linux dependency. A failed or hung test stops ZIP creation and publication. All three plugins gain internal HttpClient seams and direct protocol tests. CloudflareAsr also gets three behavior fixes matching the audit-wide patterns: a 200 response without a string result.text is a protocol failure instead of a silently successful empty transcription, the unconfigured error uses the localized message, and translate=true is rejected up front before any HTTP call. Linear and Qwen3Stt are pinned as-is - Qwen's parsing rides the already-hardened shared OpenAI transcription helper. --- .github/workflows/publish-plugins.yml | 4 + .../CloudflareAsrPlugin.cs | 36 +- .../TypeWhisper.Plugin.CloudflareAsr.csproj | 3 + .../TypeWhisper.Plugin.Linear/LinearPlugin.cs | 12 +- .../TypeWhisper.Plugin.Linear.csproj | 3 + .../Qwen3SttPlugin.cs | 12 +- .../TypeWhisper.Plugin.Qwen3Stt.csproj | 3 + .../CloudflareAsrPluginTests.cs | 391 ++++++++++++++++ .../LinearPluginTests.cs | 358 +++++++++++++++ .../Qwen3SttPluginTests.cs | 417 ++++++++++++++++++ .../TypeWhisper.PluginSystem.Tests.csproj | 3 + 11 files changed, 1231 insertions(+), 11 deletions(-) create mode 100644 tests/TypeWhisper.PluginSystem.Tests/CloudflareAsrPluginTests.cs create mode 100644 tests/TypeWhisper.PluginSystem.Tests/LinearPluginTests.cs create mode 100644 tests/TypeWhisper.PluginSystem.Tests/Qwen3SttPluginTests.cs diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index a8c568c8a..ddf3d488a 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -81,6 +81,10 @@ jobs: with: dotnet-version: '10.0.x' + - name: Test plugin system + timeout-minutes: 15 + run: dotnet test tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj -c Release -p:DeployBundledLinuxPlugins=false --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini + - name: Patch manifest version shell: pwsh run: | diff --git a/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs b/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs index 672128884..e0386f93b 100644 --- a/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs +++ b/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs @@ -15,11 +15,21 @@ public sealed class CloudflareAsrPlugin IPluginSettingsProvider, IPluginLocalizationAware { - private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(120) }; + private readonly HttpClient _httpClient; private IPluginHostServices? _host; private string? _apiToken; private string? _accountId; + public CloudflareAsrPlugin() + : this(new HttpClient { Timeout = TimeSpan.FromSeconds(120) }) + { + } + + internal CloudflareAsrPlugin(HttpClient httpClient) + { + _httpClient = httpClient; + } + private static readonly IReadOnlyList s_models = [ new("whisper", "Whisper (Cloudflare)"), @@ -76,11 +86,14 @@ public async Task TranscribeAsync( CancellationToken ct ) { - if (!IsConfigured) - throw new InvalidOperationException( - "Plugin not configured. Account ID and API token required." + if (translate) + throw new NotSupportedException( + "Translation is not supported by the Cloudflare ASR plugin." ); + if (!IsConfigured) + throw new InvalidOperationException(Loc.L("Settings.EnterAccountIdAndApiToken")); + var url = $"https://api.cloudflare.com/client/v4/accounts/{_accountId}/ai/run/@cf/openai/whisper"; @@ -110,16 +123,21 @@ CancellationToken ct using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - var text = ""; if ( - root.TryGetProperty("result", out var result) - && result.ValueKind == JsonValueKind.Object - && result.TryGetProperty("text", out var textEl) + root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("result", out var result) + || result.ValueKind != JsonValueKind.Object + || !result.TryGetProperty("text", out var textEl) + || textEl.ValueKind != JsonValueKind.String ) { - text = textEl.GetString() ?? ""; + throw new InvalidOperationException( + "Invalid Cloudflare transcription response: required field 'result.text' must be a string." + ); } + var text = textEl.GetString() ?? ""; + // Language and duration are nested under result.language / result.duration; // both fields are optional and absent when Cloudflare can't determine them. string? detectedLanguage = null; diff --git a/plugins/TypeWhisper.Plugin.CloudflareAsr/TypeWhisper.Plugin.CloudflareAsr.csproj b/plugins/TypeWhisper.Plugin.CloudflareAsr/TypeWhisper.Plugin.CloudflareAsr.csproj index 8b4ab707e..4b0f299fd 100644 --- a/plugins/TypeWhisper.Plugin.CloudflareAsr/TypeWhisper.Plugin.CloudflareAsr.csproj +++ b/plugins/TypeWhisper.Plugin.CloudflareAsr/TypeWhisper.Plugin.CloudflareAsr.csproj @@ -6,6 +6,9 @@ latest TypeWhisper.Plugin.CloudflareAsr + + + diff --git a/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs b/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs index be8a4f71d..4bd69b674 100644 --- a/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs @@ -22,9 +22,19 @@ public sealed class LinearPlugin : IActionPlugin, IPluginSettingsProvider, IPlug DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; - private readonly HttpClient _httpClient = new(); + private readonly HttpClient _httpClient; private List _cachedTeams = []; + public LinearPlugin() + : this(new HttpClient()) + { + } + + internal LinearPlugin(HttpClient httpClient) + { + _httpClient = httpClient; + } + public string PluginId => "com.typewhisper.linear"; public string PluginName => "Linear"; public string PluginVersion => "1.0.0"; diff --git a/plugins/TypeWhisper.Plugin.Linear/TypeWhisper.Plugin.Linear.csproj b/plugins/TypeWhisper.Plugin.Linear/TypeWhisper.Plugin.Linear.csproj index 612e2a453..ed4b04ffa 100644 --- a/plugins/TypeWhisper.Plugin.Linear/TypeWhisper.Plugin.Linear.csproj +++ b/plugins/TypeWhisper.Plugin.Linear/TypeWhisper.Plugin.Linear.csproj @@ -6,6 +6,9 @@ latest TypeWhisper.Plugin.Linear + + + diff --git a/plugins/TypeWhisper.Plugin.Qwen3Stt/Qwen3SttPlugin.cs b/plugins/TypeWhisper.Plugin.Qwen3Stt/Qwen3SttPlugin.cs index faea7f05f..259f459af 100644 --- a/plugins/TypeWhisper.Plugin.Qwen3Stt/Qwen3SttPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Qwen3Stt/Qwen3SttPlugin.cs @@ -14,11 +14,21 @@ public sealed class Qwen3SttPlugin : ITranscriptionEnginePlugin, IPluginSettings private const string DefaultBaseUrl = "http://localhost:8000"; private const string DefaultModel = "Qwen/Qwen3-ASR"; - private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; + private readonly HttpClient _httpClient; private IPluginHostServices? _host; private string? _apiKey; private string? _baseUrl; + public Qwen3SttPlugin() + : this(new HttpClient { Timeout = TimeSpan.FromSeconds(60) }) + { + } + + internal Qwen3SttPlugin(HttpClient httpClient) + { + _httpClient = httpClient; + } + public string PluginId => "com.typewhisper.qwen3-stt"; public string PluginName => "Qwen3 STT"; public string PluginVersion => "1.0.0"; diff --git a/plugins/TypeWhisper.Plugin.Qwen3Stt/TypeWhisper.Plugin.Qwen3Stt.csproj b/plugins/TypeWhisper.Plugin.Qwen3Stt/TypeWhisper.Plugin.Qwen3Stt.csproj index 846346d46..df74f0631 100644 --- a/plugins/TypeWhisper.Plugin.Qwen3Stt/TypeWhisper.Plugin.Qwen3Stt.csproj +++ b/plugins/TypeWhisper.Plugin.Qwen3Stt/TypeWhisper.Plugin.Qwen3Stt.csproj @@ -6,6 +6,9 @@ latest TypeWhisper.Plugin.Qwen3Stt + + + diff --git a/tests/TypeWhisper.PluginSystem.Tests/CloudflareAsrPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/CloudflareAsrPluginTests.cs new file mode 100644 index 000000000..380fc9846 --- /dev/null +++ b/tests/TypeWhisper.PluginSystem.Tests/CloudflareAsrPluginTests.cs @@ -0,0 +1,391 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using TypeWhisper.Plugin.CloudflareAsr; +using TypeWhisper.PluginSDK; +using TypeWhisper.PluginSDK.Models; + +namespace TypeWhisper.PluginSystem.Tests; + +public class CloudflareAsrPluginTests +{ + [Fact] + public async Task ActivateAsync_RestoresNormalizedCredentialsAndSelectedModel() + { + var host = new TestPluginHostServices + { + Secrets = + { + ["account-id"] = " account-123 ", + ["api-token"] = " token-123 ", + }, + }; + host.SetSetting("selectedModel", "whisper"); + + using var sut = new CloudflareAsrPlugin(); + await sut.ActivateAsync(host); + + Assert.True(sut.IsConfigured); + Assert.Equal("whisper", sut.SelectedModelId); + Assert.Equal("account-123", await sut.GetSettingValueAsync("account-id")); + Assert.Equal("token-123", await sut.GetSettingValueAsync("api-token")); + Assert.False(sut.SupportsTranslation); + } + + [Fact] + public async Task SetSettingValueAsync_PersistsCredentialsAndSelectedModel() + { + var host = new TestPluginHostServices(); + using var sut = new CloudflareAsrPlugin(); + await sut.ActivateAsync(host); + + await sut.SetSettingValueAsync("account-id", " account-456 "); + await sut.SetSettingValueAsync("api-token", " token-456 "); + await sut.SetSettingValueAsync("selectedModel", "whisper"); + + Assert.Equal("account-456", host.Secrets["account-id"]); + Assert.Equal("token-456", host.Secrets["api-token"]); + Assert.Equal("whisper", host.GetSetting("selectedModel")); + Assert.Equal(2, host.NotifyCapabilitiesChangedCount); + } + + [Fact] + public async Task TranscribeAsync_PostsRawAudioWithBearerAuthAndParsesResult() + { + var handler = new StubHttpMessageHandler(async (request, ct) => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal( + "https://api.cloudflare.com/client/v4/accounts/account-123/ai/run/@cf/openai/whisper", + request.RequestUri?.ToString() + ); + Assert.Equal("Bearer token-123", request.Headers.Authorization?.ToString()); + Assert.Equal("application/octet-stream", request.Content?.Headers.ContentType?.MediaType); + Assert.Equal([1, 2, 3, 4], await request.Content!.ReadAsByteArrayAsync(ct)); + + return JsonResponse( + """ + { + "result": { + "text": " Hallo Welt ", + "language": "de", + "duration": 1.25 + } + } + """ + ); + }); + using var sut = await CreateConfiguredPluginAsync(handler); + + var result = await sut.TranscribeAsync( + [1, 2, 3, 4], + "de", + translate: false, + prompt: "ignored", + CancellationToken.None + ); + + Assert.Equal("Hallo Welt", result.Text); + Assert.Equal("de", result.DetectedLanguage); + Assert.Equal(1.25, result.DurationSeconds); + Assert.Null(result.NoSpeechProbability); + Assert.Equal(1, handler.CallCount); + } + + [Fact] + public async Task TranscribeAsync_AcceptsExplicitEmptyResultText() + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult(JsonResponse("""{ "result": { "text": "" } }""")) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var result = await sut.TranscribeAsync( + [1], + null, + translate: false, + prompt: null, + CancellationToken.None + ); + + Assert.Equal(string.Empty, result.Text); + } + + [Theory] + [InlineData("""{}""")] + [InlineData("""{ "result": null }""")] + [InlineData("""{ "result": {} }""")] + [InlineData("""{ "result": { "text": null } }""")] + [InlineData("""{ "result": { "text": 42 } }""")] + public async Task TranscribeAsync_RejectsResponseWithoutStringResultText(string responseBody) + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult(JsonResponse(responseBody)) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => + sut.TranscribeAsync( + [1], + null, + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Equal( + "Invalid Cloudflare transcription response: required field 'result.text' must be a string.", + exception.Message + ); + } + + [Fact] + public async Task TranscribeAsync_SurfacesProviderHttpError() + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult( + JsonResponse( + """{ "errors": [{ "message": "invalid audio" }] }""", + HttpStatusCode.UnprocessableEntity + ) + ) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => + sut.TranscribeAsync( + [1], + null, + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Equal( + "Cloudflare API error 422: Unprocessable Entity", + exception.Message + ); + } + + [Fact] + public async Task TranscribeAsync_PropagatesCancellation() + { + var requestStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var handler = new StubHttpMessageHandler(async (_, ct) => + { + requestStarted.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return JsonResponse("""{ "result": { "text": "unreachable" } }"""); + }); + using var sut = await CreateConfiguredPluginAsync(handler); + using var cancellation = new CancellationTokenSource(); + + var transcription = sut.TranscribeAsync( + [1], + null, + translate: false, + prompt: null, + cancellation.Token + ); + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; the only in-scope token is cancellation.Token (the token under test), which the next line cancels, so forwarding it here would abort this wait instead of guarding it. + await requestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => transcription); + } + + [Fact] + public async Task TranscribeAsync_RejectsTranslationBeforeSendingHttpRequest() + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult(JsonResponse("""{ "result": { "text": "unexpected" } }""")) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => + sut.TranscribeAsync( + [1], + "en", + translate: true, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Equal( + "Translation is not supported by the Cloudflare ASR plugin.", + exception.Message + ); + Assert.Equal(0, handler.CallCount); + } + + [Fact] + public void SelectModel_RejectsUnknownModel() + { + using var sut = new CloudflareAsrPlugin(); + + var exception = Assert.Throws(() => sut.SelectModel("unknown")); + + Assert.Equal("Unknown model: unknown", exception.Message); + } + + [Fact] + public async Task TranscribeAsync_WhenUnconfigured_UsesLocalizedMessage() + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult(JsonResponse("""{ "result": { "text": "unexpected" } }""")) + ); + using var sut = new CloudflareAsrPlugin(new HttpClient(handler)); + sut.SetLocalization(new TestPluginLocalization()); + + var exception = await Assert.ThrowsAsync( + () => + sut.TranscribeAsync( + [1], + null, + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Equal("Localized Cloudflare credentials are required.", exception.Message); + Assert.Equal(0, handler.CallCount); + } + + private static async Task CreateConfiguredPluginAsync( + StubHttpMessageHandler handler + ) + { + var host = new TestPluginHostServices + { + Secrets = + { + ["account-id"] = "account-123", + ["api-token"] = "token-123", + }, + }; + var sut = new CloudflareAsrPlugin(new HttpClient(handler)); + await sut.ActivateAsync(host); + return sut; + } + + private static HttpResponseMessage JsonResponse( + string json, + HttpStatusCode statusCode = HttpStatusCode.OK + ) => + new(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + private sealed class StubHttpMessageHandler( + Func> responder + ) : HttpMessageHandler + { + private int _callCount; + + public int CallCount => Volatile.Read(ref _callCount); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + Interlocked.Increment(ref _callCount); + return responder(request, cancellationToken); + } + } + + private sealed class TestPluginHostServices : IPluginHostServices + { + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly Dictionary _settings = []; + + public Dictionary Secrets { get; } = []; + public int NotifyCapabilitiesChangedCount { get; private set; } + + public Task StoreSecretAsync(string key, string value) + { + Secrets[key] = value; + return Task.CompletedTask; + } + + public Task LoadSecretAsync(string key) => + Task.FromResult(Secrets.GetValueOrDefault(key)); + + public Task DeleteSecretAsync(string key) + { + Secrets.Remove(key); + return Task.CompletedTask; + } + + public T? GetSetting(string key) => + _settings.TryGetValue(key, out var value) + ? value.Deserialize(s_jsonOptions) + : default; + + public void SetSetting(string key, T value) => + _settings[key] = JsonSerializer.SerializeToElement(value, s_jsonOptions); + + public string PluginDataDirectory => Path.GetTempPath(); + public string? ActiveAppProcessName => null; + public string? ActiveAppName => null; + public IPluginEventBus EventBus { get; } = new TestPluginEventBus(); + public IReadOnlyList AvailableProfileNames => []; + public IPluginLocalization Localization { get; } = new TestPluginLocalization(); + + public void Log(PluginLogLevel level, string message) + { + } + + public void NotifyCapabilitiesChanged() + { + NotifyCapabilitiesChangedCount++; + } + } + + private sealed class TestPluginLocalization : IPluginLocalization + { + public string CurrentLanguage => "en"; + public IReadOnlyList AvailableLanguages => ["en"]; + + public string GetString(string key) => + key == "Settings.EnterAccountIdAndApiToken" + ? "Localized Cloudflare credentials are required." + : key; + + public string GetString(string key, params object[] args) => + string.Format(GetString(key), args); + } + + private sealed class TestPluginEventBus : IPluginEventBus + { + public void Publish(T pluginEvent) + where T : PluginEvent + { + } + + public IDisposable Subscribe(Func handler) + where T : PluginEvent => + new NoOpDisposable(); + } + + private sealed class NoOpDisposable : IDisposable + { + public void Dispose() + { + } + } +} diff --git a/tests/TypeWhisper.PluginSystem.Tests/LinearPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/LinearPluginTests.cs new file mode 100644 index 000000000..8a43e76b1 --- /dev/null +++ b/tests/TypeWhisper.PluginSystem.Tests/LinearPluginTests.cs @@ -0,0 +1,358 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using TypeWhisper.Plugin.Linear; +using TypeWhisper.PluginSDK; +using TypeWhisper.PluginSDK.Models; + +// The StubHttpMessageHandler lambdas assert on the outgoing request (method, URI, +// headers, body) and return a canned response. ReSharper reads xUnit asserts +// as precondition checks and concludes those parameters are only validated, +// never used — but asserting on the request is exactly what these tests +// verify, so the inspection is a false positive here. +// ReSharper disable ParameterOnlyUsedForPreconditionCheck.Local + +namespace TypeWhisper.PluginSystem.Tests; + +public class LinearPluginTests +{ + [Fact] + public async Task ActivateAsync_RestoresCredentialsDefaultsAndCachedTeams() + { + var host = new TestPluginHostServices + { + Secrets = + { + ["api-key"] = "linear-key", + }, + }; + host.SetSetting("default-team-id", "team-123"); + host.SetSetting("default-project-id", "project-456"); + host.SetSetting( + "cached-teams", + """[{"id":"team-123","name":"Engineering","key":"ENG"}]""" + ); + + using var sut = new LinearPlugin(); + await sut.ActivateAsync(host); + + Assert.Equal("linear-key", sut.ApiKey); + Assert.Equal("team-123", sut.DefaultTeamId); + Assert.Equal("project-456", sut.DefaultProjectId); + var teamSetting = Assert.Single( + sut.GetSettingDefinitions(), + definition => definition.Key == "default-team-id" + ); + var option = Assert.Single(teamSetting.Options!); + Assert.Equal("team-123", option.Value); + Assert.Equal("ENG - Engineering", option.Label); + } + + [Fact] + public async Task SetSettingValueAsync_PersistsApiKeyTeamAndProject() + { + var host = new TestPluginHostServices(); + using var sut = new LinearPlugin(); + await sut.ActivateAsync(host); + + await sut.SetSettingValueAsync("api-key", " linear-key "); + await sut.SetSettingValueAsync("default-team-id", " team-123 "); + await sut.SetSettingValueAsync("default-project-id", " project-456 "); + + Assert.Equal("linear-key", host.Secrets["api-key"]); + Assert.Equal("team-123", host.GetSetting("default-team-id")); + Assert.Equal("project-456", host.GetSetting("default-project-id")); + Assert.Equal(1, host.NotifyCapabilitiesChangedCount); + } + + [Fact] + public async Task ExecuteAsync_PostsGraphQlMutationWithBearerAuthAndParsesIssue() + { + var handler = new StubHttpMessageHandler(async (request, ct) => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://api.linear.app/graphql", request.RequestUri?.ToString()); + Assert.Equal("Bearer linear-key", request.Headers.Authorization?.ToString()); + Assert.Contains( + request.Headers.Accept, + value => value.MediaType == "application/json" + ); + Assert.Equal("application/json", request.Content?.Headers.ContentType?.MediaType); + + var body = await request.Content!.ReadAsStringAsync(ct); + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + Assert.Contains("mutation IssueCreate", root.GetProperty("query").GetString()); + var variables = root.GetProperty("variables"); + Assert.Equal("First issue", variables.GetProperty("title").GetString()); + Assert.Equal( + "First issue\nFull issue description", + variables.GetProperty("description").GetString() + ); + Assert.Equal("team-123", variables.GetProperty("teamId").GetString()); + Assert.Equal("project-456", variables.GetProperty("projectId").GetString()); + + return JsonResponse( + """ + { + "data": { + "issueCreate": { + "success": true, + "issue": { + "id": "issue-789", + "identifier": "ENG-42", + "url": "https://linear.app/acme/issue/ENG-42" + } + } + } + } + """ + ); + }); + using var sut = await CreateConfiguredPluginAsync(handler); + + var result = await sut.ExecuteAsync( + "First issue\nFull issue description", + EmptyContext(), + CancellationToken.None + ); + + Assert.True(result.Success); + Assert.Equal("Localized Linear issue created: First issue", result.Message); + Assert.Equal("https://linear.app/acme/issue/ENG-42", result.Url); + Assert.Equal(5.0, result.DisplayDuration); + Assert.Equal(1, handler.CallCount); + } + + [Theory] + [InlineData(200, """{ "data": {} }""")] + [InlineData(200, """{ "errors": [{ "message": "mutation rejected" }] }""")] + [InlineData(502, """{ "error": "upstream failure" }""")] + public async Task ExecuteAsync_MalformedOrNonSuccessResponseReturnsLocalizedFailure( + int statusCode, + string responseBody + ) + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult(JsonResponse(responseBody, (HttpStatusCode)statusCode)) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var result = await sut.ExecuteAsync( + "Issue title", + EmptyContext(), + CancellationToken.None + ); + + Assert.False(result.Success); + Assert.Equal("Localized Linear issue creation failed.", result.Message); + Assert.Equal(1, handler.CallCount); + } + + [Fact] + public async Task ExecuteAsync_CancellationReturnsLocalizedCancelledResult() + { + var requestStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var handler = new StubHttpMessageHandler(async (_, ct) => + { + requestStarted.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return JsonResponse("""{ "data": {} }"""); + }); + using var sut = await CreateConfiguredPluginAsync(handler); + using var cancellation = new CancellationTokenSource(); + + var execution = sut.ExecuteAsync( + "Issue title", + EmptyContext(), + cancellation.Token + ); + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; the only in-scope token is cancellation.Token (the token under test), which the next line cancels, so forwarding it here would abort this wait instead of guarding it. + await requestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await cancellation.CancelAsync(); + + var result = await execution; + + Assert.False(result.Success); + Assert.Equal("Localized Linear issue creation was cancelled.", result.Message); + Assert.Equal(1, handler.CallCount); + } + + [Theory] + [InlineData(false, true, "Localized Linear API key is required.")] + [InlineData(true, false, "Localized Linear default team is required.")] + public async Task ExecuteAsync_WhenUnconfigured_UsesLocalizedMessage( + bool hasApiKey, + bool hasDefaultTeam, + string expectedMessage + ) + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult(JsonResponse("""{ "data": {} }""")) + ); + var host = new TestPluginHostServices(); + if (hasApiKey) + host.Secrets["api-key"] = "linear-key"; + if (hasDefaultTeam) + host.SetSetting("default-team-id", "team-123"); + + using var sut = new LinearPlugin(new HttpClient(handler)); + await sut.ActivateAsync(host); + + var result = await sut.ExecuteAsync( + "Issue title", + EmptyContext(), + CancellationToken.None + ); + + Assert.False(result.Success); + Assert.Equal(expectedMessage, result.Message); + Assert.Equal(0, handler.CallCount); + } + + private static async Task CreateConfiguredPluginAsync( + StubHttpMessageHandler handler + ) + { + var host = new TestPluginHostServices + { + Secrets = + { + ["api-key"] = "linear-key", + }, + }; + host.SetSetting("default-team-id", "team-123"); + host.SetSetting("default-project-id", "project-456"); + + var sut = new LinearPlugin(new HttpClient(handler)); + await sut.ActivateAsync(host); + return sut; + } + + private static ActionContext EmptyContext() => new(null, null, null, null, null); + + private static HttpResponseMessage JsonResponse( + string json, + HttpStatusCode statusCode = HttpStatusCode.OK + ) => + new(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + private sealed class StubHttpMessageHandler( + Func> responder + ) : HttpMessageHandler + { + private int _callCount; + + public int CallCount => Volatile.Read(ref _callCount); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + Interlocked.Increment(ref _callCount); + return responder(request, cancellationToken); + } + } + + private sealed class TestPluginHostServices : IPluginHostServices + { + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly Dictionary _settings = []; + + public Dictionary Secrets { get; } = []; + public int NotifyCapabilitiesChangedCount { get; private set; } + + public Task StoreSecretAsync(string key, string value) + { + Secrets[key] = value; + return Task.CompletedTask; + } + + public Task LoadSecretAsync(string key) => + Task.FromResult(Secrets.GetValueOrDefault(key)); + + public Task DeleteSecretAsync(string key) + { + Secrets.Remove(key); + return Task.CompletedTask; + } + + public T? GetSetting(string key) => + _settings.TryGetValue(key, out var value) + ? value.Deserialize(s_jsonOptions) + : default; + + public void SetSetting(string key, T value) => + _settings[key] = JsonSerializer.SerializeToElement(value, s_jsonOptions); + + public string PluginDataDirectory => Path.GetTempPath(); + public string? ActiveAppProcessName => null; + public string? ActiveAppName => null; + public IPluginEventBus EventBus { get; } = new TestPluginEventBus(); + public IReadOnlyList AvailableProfileNames => []; + public IPluginLocalization Localization { get; } = new TestPluginLocalization(); + + public void Log(PluginLogLevel level, string message) + { + } + + public void NotifyCapabilitiesChanged() + { + NotifyCapabilitiesChangedCount++; + } + } + + private sealed class TestPluginLocalization : IPluginLocalization + { + private static readonly IReadOnlyDictionary s_values = + new Dictionary + { + ["Settings.ApiKeyNotConfigured"] = "Localized Linear API key is required.", + ["Settings.DefaultTeamNotConfigured"] = + "Localized Linear default team is required.", + ["Settings.IssueCreated"] = "Localized Linear issue created: {0}", + ["Settings.IssueCreateFailed"] = + "Localized Linear issue creation failed.", + ["Settings.IssueCreateCancelled"] = + "Localized Linear issue creation was cancelled.", + ["Settings.IssueCreateError"] = "Localized Linear issue creation error: {0}", + }; + + public string CurrentLanguage => "en"; + public IReadOnlyList AvailableLanguages => ["en"]; + + public string GetString(string key) => s_values.GetValueOrDefault(key, key); + + public string GetString(string key, params object[] args) => + string.Format(GetString(key), args); + } + + private sealed class TestPluginEventBus : IPluginEventBus + { + public void Publish(T pluginEvent) + where T : PluginEvent + { + } + + public IDisposable Subscribe(Func handler) + where T : PluginEvent => + new NoOpDisposable(); + } + + private sealed class NoOpDisposable : IDisposable + { + public void Dispose() + { + } + } +} diff --git a/tests/TypeWhisper.PluginSystem.Tests/Qwen3SttPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/Qwen3SttPluginTests.cs new file mode 100644 index 000000000..8e2cc95c1 --- /dev/null +++ b/tests/TypeWhisper.PluginSystem.Tests/Qwen3SttPluginTests.cs @@ -0,0 +1,417 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using TypeWhisper.Plugin.Qwen3Stt; +using TypeWhisper.PluginSDK; +using TypeWhisper.PluginSDK.Models; + +namespace TypeWhisper.PluginSystem.Tests; + +public class Qwen3SttPluginTests +{ + [Fact] + public async Task ActivateAsync_RestoresEndpointCredentialsAndSelectedModel() + { + var host = new TestPluginHostServices + { + Secrets = + { + ["api-key"] = "qwen-key", + }, + }; + host.SetSetting("baseUrl", "https://qwen.example"); + host.SetSetting("selectedModel", "Qwen/Qwen3-ASR"); + + using var sut = new Qwen3SttPlugin(); + await sut.ActivateAsync(host); + + Assert.True(sut.IsConfigured); + Assert.Equal("https://qwen.example", await sut.GetSettingValueAsync("baseUrl")); + Assert.Equal("qwen-key", await sut.GetSettingValueAsync("api-key")); + Assert.Equal("Qwen/Qwen3-ASR", sut.SelectedModelId); + Assert.False(sut.SupportsTranslation); + } + + [Fact] + public async Task SetSettingValueAsync_NormalizesAndPersistsEndpointCredentialsAndModel() + { + var host = new TestPluginHostServices(); + using var sut = new Qwen3SttPlugin(); + await sut.ActivateAsync(host); + + await sut.SetSettingValueAsync("baseUrl", " https://qwen.example/v1/ "); + await sut.SetSettingValueAsync("api-key", " qwen-key "); + await sut.SetSettingValueAsync("selectedModel", "Qwen/Qwen3-ASR"); + + Assert.Equal("https://qwen.example", host.GetSetting("baseUrl")); + Assert.Equal("qwen-key", host.Secrets["api-key"]); + Assert.Equal("Qwen/Qwen3-ASR", host.GetSetting("selectedModel")); + Assert.Equal(2, host.NotifyCapabilitiesChangedCount); + } + + [Fact] + public async Task TranscribeAsync_PostsOpenAiMultipartRequestAndParsesVerboseJson() + { + var handler = new StubHttpMessageHandler(async (request, ct) => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal( + "https://qwen.example/v1/audio/transcriptions", + request.RequestUri?.ToString() + ); + Assert.Equal("Bearer qwen-key", request.Headers.Authorization?.ToString()); + + var content = Assert.IsType(request.Content); + var parts = content.ToArray(); + Assert.Equal( + ["file", "model", "response_format", "language", "prompt"], + parts.Select(GetPartName).ToArray() + ); + + var file = Assert.Single(parts, part => GetPartName(part) == "file"); + Assert.Equal("audio.wav", file.Headers.ContentDisposition?.FileName?.Trim('"')); + Assert.Equal("audio/wav", file.Headers.ContentType?.MediaType); + Assert.Equal([1, 2, 3], await file.ReadAsByteArrayAsync(ct)); + + var model = Assert.Single(parts, part => GetPartName(part) == "model"); + Assert.Equal("Qwen/Qwen3-ASR", await model.ReadAsStringAsync(ct)); + var responseFormat = Assert.Single( + parts, + part => GetPartName(part) == "response_format" + ); + Assert.Equal("verbose_json", await responseFormat.ReadAsStringAsync(ct)); + var language = Assert.Single(parts, part => GetPartName(part) == "language"); + Assert.Equal("de", await language.ReadAsStringAsync(ct)); + var prompt = Assert.Single(parts, part => GetPartName(part) == "prompt"); + Assert.Equal("TypeWhisper vocabulary", await prompt.ReadAsStringAsync(ct)); + + return JsonResponse( + """ + { + "text": " Hallo Welt ", + "language": "de", + "duration": 1.5, + "segments": [ + { + "text": "Hallo", + "start": 0.0, + "end": 0.6, + "no_speech_prob": 0.2 + }, + { + "text": " Welt", + "start": 0.6, + "end": 1.5, + "no_speech_prob": 0.1 + } + ] + } + """ + ); + }); + using var sut = await CreateConfiguredPluginAsync(handler); + + var result = await sut.TranscribeAsync( + [1, 2, 3], + "de", + translate: false, + prompt: "TypeWhisper vocabulary", + CancellationToken.None + ); + + Assert.Equal("Hallo Welt", result.Text); + Assert.Equal("de", result.DetectedLanguage); + Assert.Equal(1.5, result.DurationSeconds); + Assert.Equal(0.1f, result.NoSpeechProbability); + Assert.Collection( + result.Segments, + segment => Assert.Equal(("Hallo", 0.0, 0.6), (segment.Text, segment.Start, segment.End)), + segment => + Assert.Equal((" Welt", 0.6, 1.5), (segment.Text, segment.Start, segment.End)) + ); + Assert.Equal(1, handler.CallCount); + } + + [Theory] + [InlineData("""{}""")] + [InlineData("""{ "text": null }""")] + [InlineData("""{ "text": 42 }""")] + [InlineData("""{ "error": { "message": "model failed" } }""")] + public async Task TranscribeAsync_RejectsResponseWithoutStringText(string responseBody) + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult(JsonResponse(responseBody)) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => + sut.TranscribeAsync( + [1], + null, + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Contains( + "Invalid transcription response: required field 'text' must be a string.", + exception.Message + ); + } + + [Fact] + public async Task TranscribeAsync_SurfacesProviderHttpError() + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult( + JsonResponse( + """{ "error": { "message": "model unavailable" } }""", + HttpStatusCode.ServiceUnavailable + ) + ) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => + sut.TranscribeAsync( + [1], + null, + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Equal("API error 503: model unavailable", exception.Message); + } + + [Fact] + public async Task TranscribeAsync_PropagatesCancellation() + { + var requestStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + var handler = new StubHttpMessageHandler(async (_, ct) => + { + requestStarted.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return JsonResponse("""{ "text": "unreachable" }"""); + }); + using var sut = await CreateConfiguredPluginAsync(handler); + using var cancellation = new CancellationTokenSource(); + + var transcription = sut.TranscribeAsync( + [1], + null, + translate: false, + prompt: null, + cancellation.Token + ); + // ReSharper disable once MethodSupportsCancellation -- fixed hang-guard; the only in-scope token is cancellation.Token (the token under test), which the next line cancels, so forwarding it here would abort this wait instead of guarding it. + await requestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => transcription); + } + + [Fact] + public async Task TranscribeAsync_RejectsTranslationBeforeSendingHttpRequest() + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult(JsonResponse("""{ "text": "unexpected" }""")) + ); + using var sut = await CreateConfiguredPluginAsync(handler); + + var exception = await Assert.ThrowsAsync( + () => + sut.TranscribeAsync( + [1], + "en", + translate: true, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Equal( + "Translation is not supported by the Qwen3 STT plugin.", + exception.Message + ); + Assert.Equal(0, handler.CallCount); + } + + [Fact] + public void SelectModel_RejectsUnknownModel() + { + using var sut = new Qwen3SttPlugin(); + + var exception = Assert.Throws(() => sut.SelectModel("unknown")); + + Assert.Equal("Unknown model: unknown", exception.Message); + } + + [Fact] + public async Task TranscribeAsync_WhenUnconfigured_UsesLocalizedMessage() + { + var handler = new StubHttpMessageHandler((_, _) => + Task.FromResult(JsonResponse("""{ "text": "unexpected" }""")) + ); + using var sut = new Qwen3SttPlugin(new HttpClient(handler)); + sut.SetLocalization(new TestPluginLocalization()); + + var exception = await Assert.ThrowsAsync( + () => + sut.TranscribeAsync( + [1], + null, + translate: false, + prompt: null, + CancellationToken.None + ) + ); + + Assert.Equal("Localized Qwen base URL is required.", exception.Message); + Assert.Equal(0, handler.CallCount); + } + + private static async Task CreateConfiguredPluginAsync( + StubHttpMessageHandler handler + ) + { + var host = new TestPluginHostServices + { + Secrets = + { + ["api-key"] = "qwen-key", + }, + }; + host.SetSetting("baseUrl", "https://qwen.example"); + host.SetSetting("selectedModel", "Qwen/Qwen3-ASR"); + + var sut = new Qwen3SttPlugin(new HttpClient(handler)); + await sut.ActivateAsync(host); + return sut; + } + + private static string GetPartName(HttpContent content) + { + var name = content.Headers.ContentDisposition?.Name; + Assert.NotNull(name); + return name.Trim('"'); + } + + private static HttpResponseMessage JsonResponse( + string json, + HttpStatusCode statusCode = HttpStatusCode.OK + ) => + new(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + private sealed class StubHttpMessageHandler( + Func> responder + ) : HttpMessageHandler + { + private int _callCount; + + public int CallCount => Volatile.Read(ref _callCount); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + Interlocked.Increment(ref _callCount); + return responder(request, cancellationToken); + } + } + + private sealed class TestPluginHostServices : IPluginHostServices + { + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly Dictionary _settings = []; + + public Dictionary Secrets { get; } = []; + public int NotifyCapabilitiesChangedCount { get; private set; } + + public Task StoreSecretAsync(string key, string value) + { + Secrets[key] = value; + return Task.CompletedTask; + } + + public Task LoadSecretAsync(string key) => + Task.FromResult(Secrets.GetValueOrDefault(key)); + + public Task DeleteSecretAsync(string key) + { + Secrets.Remove(key); + return Task.CompletedTask; + } + + public T? GetSetting(string key) => + _settings.TryGetValue(key, out var value) + ? value.Deserialize(s_jsonOptions) + : default; + + public void SetSetting(string key, T value) => + _settings[key] = JsonSerializer.SerializeToElement(value, s_jsonOptions); + + public string PluginDataDirectory => Path.GetTempPath(); + public string? ActiveAppProcessName => null; + public string? ActiveAppName => null; + public IPluginEventBus EventBus { get; } = new TestPluginEventBus(); + public IReadOnlyList AvailableProfileNames => []; + public IPluginLocalization Localization { get; } = new TestPluginLocalization(); + + public void Log(PluginLogLevel level, string message) + { + } + + public void NotifyCapabilitiesChanged() + { + NotifyCapabilitiesChangedCount++; + } + } + + private sealed class TestPluginLocalization : IPluginLocalization + { + public string CurrentLanguage => "en"; + public IReadOnlyList AvailableLanguages => ["en"]; + + public string GetString(string key) => + key == "Settings.NotConfiguredBaseUrlRequired" + ? "Localized Qwen base URL is required." + : key; + + public string GetString(string key, params object[] args) => + string.Format(GetString(key), args); + } + + private sealed class TestPluginEventBus : IPluginEventBus + { + public void Publish(T pluginEvent) + where T : PluginEvent + { + } + + public IDisposable Subscribe(Func handler) + where T : PluginEvent => + new NoOpDisposable(); + } + + private sealed class NoOpDisposable : IDisposable + { + public void Dispose() + { + } + } +} diff --git a/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj b/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj index fdc3a2862..51376ab3b 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj +++ b/tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj @@ -19,6 +19,7 @@ + @@ -29,11 +30,13 @@ + + + true + full + false diff --git a/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs b/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs index 5790711ce..bee96fddd 100644 --- a/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs +++ b/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs @@ -18,13 +18,25 @@ public sealed class AudioPlaybackService : IDisposable private static readonly Lock s_paInitLock = new(); private readonly Lock _gate = new(); + private readonly bool _portAudioReady; private int _position; private float[] _samples = []; private PaStream? _stream; public AudioPlaybackService() { - EnsurePortAudioInitialized(); + // DI resolves this during startup, so a missing native audio stack must not throw + // here: the exception would unwind out of the app before a window ever shows. Play + // already treats PortAudio failing at call time as a no-op with a trace line. + try + { + EnsurePortAudioInitialized(); + _portAudioReady = true; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + Trace.WriteLine($"[AudioPlaybackService] PortAudio unavailable: {ex.Message}"); + } } public string? CurrentFile { get; private set; } @@ -33,7 +45,11 @@ public AudioPlaybackService() public void Dispose() { Stop(); - EnsurePortAudioTerminated(); + // Only balance the reference count we actually took. + if (_portAudioReady) + { + EnsurePortAudioTerminated(); + } } // ReSharper disable once UnusedMember.Global — public API (pre-flight playback check); not currently called in-tree. diff --git a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs index 74cc0de02..20708f758 100644 --- a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs @@ -49,6 +49,7 @@ int CaptureSampleRate private static int s_paInitCount; private static readonly Lock s_paInitLock = new(); + private static string? s_nativeAudioUnavailable; private readonly Lock _captureLock = new(); private readonly Func _defaultInputDeviceIndexProvider; @@ -259,9 +260,30 @@ public void Dispose() } } + /// + /// Why the native audio stack could not be loaded, or null while it is fine. + /// Set the first time fails to initialize PortAudio. + /// + public static string? NativeAudioUnavailableReason => Volatile.Read(ref s_nativeAudioUnavailable); + public static IReadOnlyList GetInputDevices() { - EnsurePortAudioInitialized(); + // PortAudio is a native library resolved on first use, so this throws when the + // audio stack is missing — no libportaudio, or its libjack/libasound dependencies + // absent. Enumeration is a query, and its callers run from constructors and UI + // commands where an escaping exception unwinds straight out of the app; hand back + // an empty table instead. Capture still fails loudly through the recording paths. + try + { + EnsurePortAudioInitialized(); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + Volatile.Write(ref s_nativeAudioUnavailable, ex.Message); + Trace.WriteLine($"[AudioRecordingService] PortAudio unavailable: {ex.Message}"); + return []; + } + var result = new List(); for (var i = 0; i < PortAudio.DeviceCount; i++) { diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs index e1fa09706..f00569ad0 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs @@ -26,6 +26,7 @@ public partial class DictationSectionViewModel : ObservableObject private readonly Func> _getInputDevices; private readonly SystemCommandAvailabilityService _commands; private readonly DictationOrchestrator _dictation; + private readonly IErrorLogService? _errorLog; private readonly ModelManagerService _models; private readonly PluginManager _pluginManager; private readonly ISettingsService _settings; @@ -174,6 +175,7 @@ public partial class DictationSectionViewModel : ObservableObject // True when the Dictation page is visible; restarts mic preview after recording // ends so the level meter doesn't go dark while the page is still open. private bool _previewAttached; + private bool _reportedAudioUnavailable; [ObservableProperty] private double _previewLevel; @@ -216,7 +218,8 @@ public DictationSectionViewModel( PluginManager pluginManager, SystemCommandAvailabilityService commands, // ReSharper disable once InconsistentNaming -- "a11y" is the standard accessibility numeronym mirroring org.a11y.Bus; ReSharper's camelCase splitter mis-reads "11y". - IAccessibilityBusActivation a11yBus + IAccessibilityBusActivation a11yBus, + IErrorLogService? errorLog = null ) : this( dictation, @@ -226,7 +229,8 @@ IAccessibilityBusActivation a11yBus pluginManager, commands, a11yBus, - AudioRecordingService.GetInputDevices + AudioRecordingService.GetInputDevices, + errorLog ) { } @@ -240,12 +244,14 @@ internal DictationSectionViewModel( SystemCommandAvailabilityService commands, // ReSharper disable once InconsistentNaming -- "a11y" is the standard accessibility numeronym mirroring org.a11y.Bus; ReSharper's camelCase splitter mis-reads "11y". IAccessibilityBusActivation a11yBus, - Func> getInputDevices + Func> getInputDevices, + IErrorLogService? errorLog = null ) { _dictation = dictation; _models = models; _audio = audio; + _errorLog = errorLog; _getInputDevices = getInputDevices; _settings = settings; _pluginManager = pluginManager; @@ -653,6 +659,22 @@ private void RefreshDevices() Devices.Add(d); } + // Enumeration yields an empty table rather than throwing when the native audio + // stack is missing, so say why in the error log — otherwise an empty microphone + // list looks like the app simply found no hardware. Once per session: this also + // runs from the refresh command. + if ( + !_reportedAudioUnavailable + && AudioRecordingService.NativeAudioUnavailableReason is { } audioFailure + ) + { + _reportedAudioUnavailable = true; + _errorLog?.AddEntry( + $"Audio device enumeration unavailable: {audioFailure}", + ErrorCategory.Recording + ); + } + SelectedDevice = ResolveSelectedDeviceOption( _settings.Current.SelectedMicrophoneDevice, _settings.Current.SelectedMicrophoneDeviceId diff --git a/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs index 5a1b1d049..2b1980bad 100644 --- a/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/ProfileServiceTests.cs @@ -85,7 +85,7 @@ public async Task ToggleProfileEnabled_ConcurrentSameProfile_AppliesBothInversio var secondCompletion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously ); - var firstToggle = Task.Run(() => service.ToggleProfileEnabled(original.Id)); + var firstToggle = RunOnDedicatedThread(() => service.ToggleProfileEnabled(original.Id)); Thread? secondThread = null; bool secondReachedGateOrWriter; @@ -174,7 +174,7 @@ public async Task ToggleProfileEnabled_ConcurrentDifferentProfiles_PreservesBoth var secondCompletion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously ); - var firstToggle = Task.Run(() => service.ToggleProfileEnabled(profileA.Id)); + var firstToggle = RunOnDedicatedThread(() => service.ToggleProfileEnabled(profileA.Id)); Thread? secondThread = null; bool secondReachedGateOrWriter; @@ -533,6 +533,34 @@ private static TaskCompletionSource CreateCompletionSource() return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } + // The first toggle blocks inside BlockingAfterCommitWriter while holding the service + // gate, so it must not run on a thread-pool thread. On a loaded CI runner the pool adds + // threads roughly once per second, so a queued work item can sit unstarted past + // s_testGuard and the test times out waiting for a commit that never got a thread to + // happen on. A dedicated thread starts regardless of pool pressure, matching how the + // second caller already runs. + private static Task RunOnDedicatedThread(Func toggle) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + new Thread(() => + { + try + { + completion.TrySetResult(toggle()); + } + catch (Exception ex) + { + completion.TrySetException(ex); + } + }) + { + IsBackground = true, + }.Start(); + return completion.Task; + } + private static bool IsWaiting(Thread thread) { return (thread.ThreadState & ThreadState.WaitSleepJoin) != 0; From b50d83eb1fe464d9d94f0356dadb7868caa73167 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 27 Jul 2026 17:59:10 -0400 Subject: [PATCH 202/226] Assert the uninstall contract the tarball installer actually promises With the GUI probe no longer crashing, the tarball smoke test got far enough to run its uninstall assertions for the first time, and assert_removed "$app_root" failed. The installer is right and the test was wrong. A plain --uninstall preserves user data, and in the tarball layout INSTALL_ROOT is also the directory the running app fills. Plugins/ is on the installer's KEEP list because it holds user-installed plugins alongside the bundled ones, so the root always survives an ordinary uninstall. The old assertion only ever passed because nothing had launched the app: with no data written, the payload sweep emptied the directory and the trailing rmdir removed it. Assert the program payload is gone instead, and add a --purge pass that asserts the root itself is removed, so "leaves nothing behind" is still covered by the flag that actually promises it. Validated: all four package formats pass the full container smoke test locally (tarball, AppImage, deb, rpm). --- scripts/smoke-test-linux-packages.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-test-linux-packages.sh b/scripts/smoke-test-linux-packages.sh index f0e57af52..d6bb7c231 100755 --- a/scripts/smoke-test-linux-packages.sh +++ b/scripts/smoke-test-linux-packages.sh @@ -303,8 +303,18 @@ container_smoke_tarball() { assert_removed "$SMOKE_HOME_ROOT/.local/bin/typewhisper" assert_removed "$SMOKE_DATA_ROOT/applications/typewhisper.desktop" assert_removed "$SMOKE_DATA_ROOT/icons/hicolor/128x128/apps/typewhisper.png" - assert_removed "$app_root" + # Not assert_removed "$app_root": a plain --uninstall preserves user data, and in + # this layout INSTALL_ROOT is also where the app writes it. Plugins/ is on the + # installer's KEEP list (it holds user-installed plugins alongside the bundled + # ones), so the root legitimately survives. Assert the program payload is gone. + assert_removed "$app_root/typewhisper" + assert_removed "$app_root/Cli/typewhisper-cli" require_file "$SMOKE_DATA_ROOT/TypeWhisper/smoke-sentinel" + + # --purge is the path that must leave nothing behind. + echo "==> Purging tarball install from isolated HOME/XDG roots" + env "${SMOKE_PROFILE_ENV[@]}" bash "$install_script" --uninstall --purge + assert_removed "$app_root" } container_smoke_appimage() { From 2ca3c4c2e99d4fcf643789ef5f2d2711acd1dc31 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 3 Aug 2026 10:04:06 -0400 Subject: [PATCH 203/226] Add trailing commas (part 1 of 2) Mechanical split of ec14e81b for reviewability: the first 91 of 181 files, formatting only, no semantic change. --- src/TypeWhisper.Cli/Models/CliOptions.cs | 2 +- src/TypeWhisper.Cli/Output/JsonFormatting.cs | 2 +- src/TypeWhisper.Cli/Program.cs | 2 +- src/TypeWhisper.Core/Models/CleanupLevel.cs | 2 +- .../Models/DictionaryEntrySource.cs | 2 +- .../Models/DictionaryEntryType.cs | 2 +- src/TypeWhisper.Core/Models/DiffSegment.cs | 2 +- src/TypeWhisper.Core/Models/ErrorLogEntry.cs | 2 +- .../Models/HistoryRetentionMode.cs | 2 +- src/TypeWhisper.Core/Models/IndustryPreset.cs | 2 +- .../LocalModelStorageUnavailableReason.cs | 2 +- src/TypeWhisper.Core/Models/MatchKind.cs | 2 +- src/TypeWhisper.Core/Models/ModelStatus.cs | 2 +- .../Models/ModelStatusType.cs | 2 +- .../Models/OverlayPosition.cs | 2 +- src/TypeWhisper.Core/Models/OverlayWidget.cs | 2 +- .../Models/ProfileHotkeyBehavior.cs | 2 +- .../Models/ProfileStylePreset.cs | 2 +- .../Models/RecentTranscriptionSource.cs | 2 +- src/TypeWhisper.Core/Models/RecordingMode.cs | 2 +- .../Models/SnippetTriggerMode.cs | 2 +- src/TypeWhisper.Core/Models/TermPack.cs | 32 +++++++++---------- .../Models/TextInsertionStatus.cs | 2 +- .../Models/TextInsertionStrategy.cs | 2 +- .../Models/TranscriptionTask.cs | 2 +- .../Models/TranslationModelInfo.cs | 8 ++--- .../Services/AppFormatterService.cs | 4 +-- .../Services/CorrectionSuggestionService.cs | 4 +-- .../Services/DetectionFailureTracker.cs | 2 +- .../Services/DeveloperFormattingService.cs | 4 +-- .../Services/FirstRunDefaults.cs | 4 +-- .../Services/HistoryInsightsService.cs | 2 +- .../Services/HistoryService.Export.cs | 2 +- .../Services/ProfileStylePresetService.cs | 4 +-- .../Services/WhisperHallucinationFilter.cs | 2 +- .../Cli/CommandLineParser.cs | 2 +- .../Cli/Commands/RecordCommand.cs | 2 +- src/TypeWhisper.Linux/DiffKindConverters.cs | 4 +-- .../AccessibilityBusActivationService.cs | 2 +- .../Services/ActiveWindow/AtSpiEventClient.cs | 8 ++--- .../ActiveWindow/AtSpiUrlExtractor.cs | 10 +++--- .../ActiveWindow/GnomeWindowCallsProvider.cs | 4 +-- .../ActiveWindow/ProviderProcessRunner.cs | 4 +-- src/TypeWhisper.Linux/Services/AppVersion.cs | 2 +- .../Services/AudioDuckingService.cs | 2 +- .../Services/AudioFileService.cs | 4 +-- .../Services/AudioPlaybackService.cs | 2 +- .../Services/AudioRecordingService.cs | 2 +- .../BrowserAccessibilitySetupHelper.cs | 8 ++--- .../Services/DictationToggleGate.cs | 2 +- .../Services/FileTranscriptionProcessor.cs | 6 ++-- .../Services/GnomeWindowCallsSetupHelper.cs | 4 +-- .../Services/HistoryRetentionCoordinator.cs | 2 +- .../Hotkey/DeSetup/DesktopDetector.cs | 4 +-- .../DeSetup/DictationShortcutSpecFactory.cs | 2 +- .../Hotkey/DeSetup/GnomeShortcutWriter.cs | 4 +-- .../Hotkey/Evdev/InputAccessSetupHelper.cs | 2 +- .../Services/Hotkey/Evdev/LinuxKeyMap.cs | 4 +-- .../Evdev/LogindSessionActivityMonitor.cs | 6 ++-- .../Hotkey/SharpHookGlobalShortcutBackend.cs | 2 +- .../Services/Hotkey/ShortcutDispatcher.cs | 4 +-- .../Services/Hotkey/ShortcutMatcher.cs | 2 +- .../Services/HotkeyService.cs | 26 +++++++-------- .../Services/Ipc/ControlSocketServer.cs | 4 +-- .../Services/Ipc/JsonControlProtocol.cs | 2 +- .../LearnedCorrectionsNotificationService.cs | 4 +-- .../LinuxDictationReadbackLanguagePolicy.cs | 4 +-- .../LinuxDictationShortSpeechPolicy.cs | 2 +- .../LinuxLiveTranscriptionStartupPolicy.cs | 2 +- .../Services/LinuxSystemTtsProvider.cs | 2 +- .../Services/Localization/Loc.cs | 4 +-- .../Services/MemoryService.cs | 2 +- .../Services/ModelManagerService.cs | 8 ++--- .../Plugins/PluginLocalityClassifier.cs | 2 +- .../Services/Plugins/PluginManager.cs | 8 ++--- .../Services/Plugins/PluginRegistryService.cs | 2 +- .../Services/Plugins/RegistryPlugin.cs | 2 +- .../Services/ProcessPriority.cs | 4 +-- .../Services/ProcessRunner.cs | 2 +- .../Services/PromptProcessingService.cs | 2 +- .../Services/RecordingNotificationService.cs | 6 ++-- .../Services/SettingsBackupService.cs | 22 ++++++------- .../Services/Setup/ISetupTask.cs | 4 +-- .../SpokenCommand/SpokenCommandIntent.cs | 6 ++-- .../SpokenCommand/SpokenCommandKeyphrase.cs | 2 +- .../SpokenCommand/SpokenCommandText.cs | 2 +- .../StreamingTranscriptionCoordinator.cs | 2 +- .../SystemCommandAvailabilityService.cs | 10 +++--- .../Services/TranslationService.cs | 10 +++--- .../Services/TrayIconService.cs | 4 +-- .../Services/UpdateCheckService.cs | 10 +++--- 91 files changed, 188 insertions(+), 188 deletions(-) diff --git a/src/TypeWhisper.Cli/Models/CliOptions.cs b/src/TypeWhisper.Cli/Models/CliOptions.cs index 4a5b41fd4..6ba37306e 100644 --- a/src/TypeWhisper.Cli/Models/CliOptions.cs +++ b/src/TypeWhisper.Cli/Models/CliOptions.cs @@ -183,7 +183,7 @@ public static CliOptions Parse(string[] args) Prompt = prompt, Engine = engine, Model = model, - AwaitDownload = awaitDownload + AwaitDownload = awaitDownload, }; } diff --git a/src/TypeWhisper.Cli/Output/JsonFormatting.cs b/src/TypeWhisper.Cli/Output/JsonFormatting.cs index 23f99982d..362ae1d00 100644 --- a/src/TypeWhisper.Cli/Output/JsonFormatting.cs +++ b/src/TypeWhisper.Cli/Output/JsonFormatting.cs @@ -28,7 +28,7 @@ public static string Prop(JsonElement el, string name) JsonValueKind.Number => value.ToString(), JsonValueKind.True => "true", JsonValueKind.False => "false", - _ => "" + _ => "", }; } diff --git a/src/TypeWhisper.Cli/Program.cs b/src/TypeWhisper.Cli/Program.cs index 1627e1531..df35d5365 100644 --- a/src/TypeWhisper.Cli/Program.cs +++ b/src/TypeWhisper.Cli/Program.cs @@ -57,7 +57,7 @@ private static async Task Main(string[] args) "status" => await StatusCommand.RunAsync(api, options.Json), "models" => await ModelsCommand.RunAsync(api, options.Json), "transcribe" => await TranscribeCommand.RunAsync(api, options), - _ => ConsoleOutput.Error($"Unknown command: {options.Command}") + _ => ConsoleOutput.Error($"Unknown command: {options.Command}"), }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Models/CleanupLevel.cs b/src/TypeWhisper.Core/Models/CleanupLevel.cs index e03f8ff3a..ad7ee444b 100644 --- a/src/TypeWhisper.Core/Models/CleanupLevel.cs +++ b/src/TypeWhisper.Core/Models/CleanupLevel.cs @@ -6,5 +6,5 @@ public enum CleanupLevel None, Light, Medium, - High + High, } diff --git a/src/TypeWhisper.Core/Models/DictionaryEntrySource.cs b/src/TypeWhisper.Core/Models/DictionaryEntrySource.cs index 7b16bc8f3..0087300e8 100644 --- a/src/TypeWhisper.Core/Models/DictionaryEntrySource.cs +++ b/src/TypeWhisper.Core/Models/DictionaryEntrySource.cs @@ -7,5 +7,5 @@ public enum DictionaryEntrySource Manual, Import, CorrectionSuggestion, - AutoLearned + AutoLearned, } diff --git a/src/TypeWhisper.Core/Models/DictionaryEntryType.cs b/src/TypeWhisper.Core/Models/DictionaryEntryType.cs index cc2059db6..d62bcab95 100644 --- a/src/TypeWhisper.Core/Models/DictionaryEntryType.cs +++ b/src/TypeWhisper.Core/Models/DictionaryEntryType.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum DictionaryEntryType { Term, - Correction + Correction, } diff --git a/src/TypeWhisper.Core/Models/DiffSegment.cs b/src/TypeWhisper.Core/Models/DiffSegment.cs index 035a5ab3c..fad784ca5 100644 --- a/src/TypeWhisper.Core/Models/DiffSegment.cs +++ b/src/TypeWhisper.Core/Models/DiffSegment.cs @@ -10,7 +10,7 @@ public enum DiffKind Added, /// Present in the raw text but not the final text. - Removed + Removed, } /// diff --git a/src/TypeWhisper.Core/Models/ErrorLogEntry.cs b/src/TypeWhisper.Core/Models/ErrorLogEntry.cs index 973a66839..6e817a94b 100644 --- a/src/TypeWhisper.Core/Models/ErrorLogEntry.cs +++ b/src/TypeWhisper.Core/Models/ErrorLogEntry.cs @@ -17,7 +17,7 @@ public static ErrorLogEntry Create(string message, string category = ErrorCatego { return new ErrorLogEntry { - Id = Guid.NewGuid().ToString("N"), Timestamp = DateTime.UtcNow, Message = message, Category = category + Id = Guid.NewGuid().ToString("N"), Timestamp = DateTime.UtcNow, Message = message, Category = category, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Models/HistoryRetentionMode.cs b/src/TypeWhisper.Core/Models/HistoryRetentionMode.cs index e111282e2..e74d42585 100644 --- a/src/TypeWhisper.Core/Models/HistoryRetentionMode.cs +++ b/src/TypeWhisper.Core/Models/HistoryRetentionMode.cs @@ -5,5 +5,5 @@ public enum HistoryRetentionMode { Duration, Forever, - UntilAppCloses + UntilAppCloses, } diff --git a/src/TypeWhisper.Core/Models/IndustryPreset.cs b/src/TypeWhisper.Core/Models/IndustryPreset.cs index 3e1301582..5b8cc7bf2 100644 --- a/src/TypeWhisper.Core/Models/IndustryPreset.cs +++ b/src/TypeWhisper.Core/Models/IndustryPreset.cs @@ -33,7 +33,7 @@ public sealed record IndustryPreset(string Id, string Name, string Description, "Legal", "Contract, compliance, and litigation terms.", "legal" - ) + ), ]; public static string[] MergeIntoEnabledPackIds(string[] enabledPackIds, string presetId) diff --git a/src/TypeWhisper.Core/Models/LocalModelStorageUnavailableReason.cs b/src/TypeWhisper.Core/Models/LocalModelStorageUnavailableReason.cs index 80c5423a7..06a965bd1 100644 --- a/src/TypeWhisper.Core/Models/LocalModelStorageUnavailableReason.cs +++ b/src/TypeWhisper.Core/Models/LocalModelStorageUnavailableReason.cs @@ -14,5 +14,5 @@ public enum LocalModelStorageUnavailableReason NotWritable, /// The chosen target folder is nested inside the current storage folder. - NestedUnderCurrentFolder + NestedUnderCurrentFolder, } diff --git a/src/TypeWhisper.Core/Models/MatchKind.cs b/src/TypeWhisper.Core/Models/MatchKind.cs index 49c3ed857..593dd508b 100644 --- a/src/TypeWhisper.Core/Models/MatchKind.cs +++ b/src/TypeWhisper.Core/Models/MatchKind.cs @@ -8,5 +8,5 @@ public enum MatchKind App, Global, ManualOverride, - NoMatch + NoMatch, } diff --git a/src/TypeWhisper.Core/Models/ModelStatus.cs b/src/TypeWhisper.Core/Models/ModelStatus.cs index d7696fba7..11f2527a1 100644 --- a/src/TypeWhisper.Core/Models/ModelStatus.cs +++ b/src/TypeWhisper.Core/Models/ModelStatus.cs @@ -23,7 +23,7 @@ public static ModelStatus DownloadingModel(double progress, double? bytesPerSeco { return new ModelStatus { - Type = ModelStatusType.Downloading, Progress = progress, BytesPerSecond = bytesPerSecond + Type = ModelStatusType.Downloading, Progress = progress, BytesPerSecond = bytesPerSecond, }; } diff --git a/src/TypeWhisper.Core/Models/ModelStatusType.cs b/src/TypeWhisper.Core/Models/ModelStatusType.cs index 30df24e9c..341bc024e 100644 --- a/src/TypeWhisper.Core/Models/ModelStatusType.cs +++ b/src/TypeWhisper.Core/Models/ModelStatusType.cs @@ -7,5 +7,5 @@ public enum ModelStatusType Downloading, Loading, Ready, - Error + Error, } diff --git a/src/TypeWhisper.Core/Models/OverlayPosition.cs b/src/TypeWhisper.Core/Models/OverlayPosition.cs index f2fc0b27d..7a356e027 100644 --- a/src/TypeWhisper.Core/Models/OverlayPosition.cs +++ b/src/TypeWhisper.Core/Models/OverlayPosition.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum OverlayPosition { Top, - Bottom + Bottom, } diff --git a/src/TypeWhisper.Core/Models/OverlayWidget.cs b/src/TypeWhisper.Core/Models/OverlayWidget.cs index 5a1af6686..d00d97ac2 100644 --- a/src/TypeWhisper.Core/Models/OverlayWidget.cs +++ b/src/TypeWhisper.Core/Models/OverlayWidget.cs @@ -10,5 +10,5 @@ public enum OverlayWidget Clock, Profile, HotkeyMode, - AppName + AppName, } diff --git a/src/TypeWhisper.Core/Models/ProfileHotkeyBehavior.cs b/src/TypeWhisper.Core/Models/ProfileHotkeyBehavior.cs index 0028f3e69..8691d6087 100644 --- a/src/TypeWhisper.Core/Models/ProfileHotkeyBehavior.cs +++ b/src/TypeWhisper.Core/Models/ProfileHotkeyBehavior.cs @@ -14,5 +14,5 @@ namespace TypeWhisper.Core.Models; public enum ProfileHotkeyBehavior { StartDictation, - ProcessSelectedText + ProcessSelectedText, } diff --git a/src/TypeWhisper.Core/Models/ProfileStylePreset.cs b/src/TypeWhisper.Core/Models/ProfileStylePreset.cs index fd9cca40d..59d3c51d5 100644 --- a/src/TypeWhisper.Core/Models/ProfileStylePreset.cs +++ b/src/TypeWhisper.Core/Models/ProfileStylePreset.cs @@ -10,5 +10,5 @@ public enum ProfileStylePreset CasualMessage, Developer, TerminalSafe, - MeetingNotes + MeetingNotes, } diff --git a/src/TypeWhisper.Core/Models/RecentTranscriptionSource.cs b/src/TypeWhisper.Core/Models/RecentTranscriptionSource.cs index 53857ff9d..4d880b769 100644 --- a/src/TypeWhisper.Core/Models/RecentTranscriptionSource.cs +++ b/src/TypeWhisper.Core/Models/RecentTranscriptionSource.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum RecentTranscriptionSource { Session, - History + History, } diff --git a/src/TypeWhisper.Core/Models/RecordingMode.cs b/src/TypeWhisper.Core/Models/RecordingMode.cs index 9cff360b1..0a41d7a2d 100644 --- a/src/TypeWhisper.Core/Models/RecordingMode.cs +++ b/src/TypeWhisper.Core/Models/RecordingMode.cs @@ -5,5 +5,5 @@ public enum RecordingMode { Toggle, PushToTalk, - Hybrid + Hybrid, } diff --git a/src/TypeWhisper.Core/Models/SnippetTriggerMode.cs b/src/TypeWhisper.Core/Models/SnippetTriggerMode.cs index 92d0081e7..461fa4d28 100644 --- a/src/TypeWhisper.Core/Models/SnippetTriggerMode.cs +++ b/src/TypeWhisper.Core/Models/SnippetTriggerMode.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum SnippetTriggerMode { Anywhere, - ExactPhrase + ExactPhrase, } diff --git a/src/TypeWhisper.Core/Models/TermPack.cs b/src/TypeWhisper.Core/Models/TermPack.cs index feb394f56..678bb1de5 100644 --- a/src/TypeWhisper.Core/Models/TermPack.cs +++ b/src/TypeWhisper.Core/Models/TermPack.cs @@ -38,7 +38,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "SvelteKit", "Vercel", "Netlify", - "Supabase" + "Supabase", ] ), new( @@ -65,7 +65,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Moq", "CommunityToolkit", "Avalonia", - "Orleans" + "Orleans", ] ), new( @@ -87,7 +87,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "ArgoCD", "Pulumi", "Vault", - "Consul" + "Consul", ] ), new( @@ -109,7 +109,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Pandas", "NumPy", "Scikit-learn", - "RAG" + "RAG", ] ), new( @@ -131,7 +131,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Accessibility", "Responsive", "Breakpoint", - "Viewport" + "Viewport", ] ), new( @@ -153,7 +153,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Sprite", "Tilemap", "NavMesh", - "GameLoop" + "GameLoop", ] ), new( @@ -174,7 +174,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Room", "Firebase", "TestFlight", - "CocoaPods" + "CocoaPods", ] ), new( @@ -196,7 +196,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "SIEM", "SOC", "Ransomware", - "Phishing" + "Phishing", ] ), // These packs originated upstream with German display names and German @@ -221,7 +221,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Supabase", "PlanetScale", "Prisma", - "Drizzle" + "Drizzle", ] ), new( @@ -243,7 +243,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Orthopedics", "Neurology", "Pediatrics", - "Radiology" + "Radiology", ] ), new( @@ -265,7 +265,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Civil law", "Arbitration", "Data protection", - "Warranty" + "Warranty", ] ), new( @@ -287,7 +287,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Cryptocurrency", "Blockchain", "Fintech", - "Liquidity" + "Liquidity", ] ), new( @@ -309,7 +309,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Limiter", "Chorus", "Phaser", - "Arpeggiator" + "Arpeggiator", ] ), new( @@ -366,7 +366,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "ARM", "PITI", "Disclosure", - "Zoning" + "Zoning", ] ), new( @@ -423,9 +423,9 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Grasshopper", "RFI", "Schematic design", - "Construction documents" + "Construction documents", ] - ) + ), ]; public static TermPack? FindById(string id) diff --git a/src/TypeWhisper.Core/Models/TextInsertionStatus.cs b/src/TypeWhisper.Core/Models/TextInsertionStatus.cs index d854f9d99..f0ef74dc6 100644 --- a/src/TypeWhisper.Core/Models/TextInsertionStatus.cs +++ b/src/TypeWhisper.Core/Models/TextInsertionStatus.cs @@ -17,5 +17,5 @@ public enum TextInsertionStatus // Appended after Failed to preserve the persisted numeric ordinals of the // members above: history.json serializes this enum by value (no string // converter), so inserting mid-enum would reinterpret existing records. - ActionUnavailable + ActionUnavailable, } diff --git a/src/TypeWhisper.Core/Models/TextInsertionStrategy.cs b/src/TypeWhisper.Core/Models/TextInsertionStrategy.cs index f4dadea3e..e30bd1cd4 100644 --- a/src/TypeWhisper.Core/Models/TextInsertionStrategy.cs +++ b/src/TypeWhisper.Core/Models/TextInsertionStrategy.cs @@ -6,5 +6,5 @@ public enum TextInsertionStrategy Auto, ClipboardPaste, DirectTyping, - CopyOnly + CopyOnly, } diff --git a/src/TypeWhisper.Core/Models/TranscriptionTask.cs b/src/TypeWhisper.Core/Models/TranscriptionTask.cs index 09a13e65e..9d7ef627c 100644 --- a/src/TypeWhisper.Core/Models/TranscriptionTask.cs +++ b/src/TypeWhisper.Core/Models/TranscriptionTask.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum TranscriptionTask { Transcribe, - Translate + Translate, } diff --git a/src/TypeWhisper.Core/Models/TranslationModelInfo.cs b/src/TypeWhisper.Core/Models/TranslationModelInfo.cs index e7311411c..863f40c0a 100644 --- a/src/TypeWhisper.Core/Models/TranslationModelInfo.cs +++ b/src/TypeWhisper.Core/Models/TranslationModelInfo.cs @@ -52,7 +52,7 @@ public sealed record TranslationModelInfo new("ar", "العربية"), new("hi", "हिन्दी"), new("vi", "Tiếng Việt"), - new("id", "Bahasa Indonesia") + new("id", "Bahasa Indonesia"), ]; // The OPUS-MT models that actually exist (confirmed Xenova ONNX exports). The @@ -102,7 +102,7 @@ public sealed record TranslationModelInfo Pair("en", "hu"), Pair("en", "id"), // Direct non-English pairs - Pair("de", "es") + Pair("de", "es"), ]; // Distinct target languages across every model pair — the targets we can @@ -196,8 +196,8 @@ private static TranslationModelInfo Pair(string src, string tgt, string? repoOve $"{Hf}/opus-mt-{repo}/resolve/main/onnx/decoder_model_quantized.onnx" ), new TranslationFileInfo("tokenizer.json", $"{Hf}/opus-mt-{repo}/resolve/main/tokenizer.json"), - new TranslationFileInfo("config.json", $"{Hf}/opus-mt-{repo}/resolve/main/config.json") - ] + new TranslationFileInfo("config.json", $"{Hf}/opus-mt-{repo}/resolve/main/config.json"), + ], }; } } diff --git a/src/TypeWhisper.Core/Services/AppFormatterService.cs b/src/TypeWhisper.Core/Services/AppFormatterService.cs index d08ceea1d..100068099 100644 --- a/src/TypeWhisper.Core/Services/AppFormatterService.cs +++ b/src/TypeWhisper.Core/Services/AppFormatterService.cs @@ -30,7 +30,7 @@ public static class AppFormatterService ["cmd"] = "code", ["powershell"] = "code", ["pwsh"] = "code", - ["cursor"] = "code" + ["cursor"] = "code", }; /// @@ -48,7 +48,7 @@ public static string Format(string text, string? processName) return format switch { "markdown" => FormatAsMarkdown(text), - _ => text // code + plaintext = passthrough + _ => text, // code + plaintext = passthrough }; } diff --git a/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs b/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs index 55ba14f95..065a69063 100644 --- a/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs +++ b/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs @@ -63,8 +63,8 @@ string correctedText [ new CorrectionSuggestion { - Original = original, Replacement = replacement, Confidence = Math.Round(confidence, 2) - } + Original = original, Replacement = replacement, Confidence = Math.Round(confidence, 2), + }, ]; } diff --git a/src/TypeWhisper.Core/Services/DetectionFailureTracker.cs b/src/TypeWhisper.Core/Services/DetectionFailureTracker.cs index b32a6335f..713e2f1a3 100644 --- a/src/TypeWhisper.Core/Services/DetectionFailureTracker.cs +++ b/src/TypeWhisper.Core/Services/DetectionFailureTracker.cs @@ -97,7 +97,7 @@ private static string AugmentReason(string compositor, string reason) "hyprland" or "sway" => $"{reason}. Compositor command failed unexpectedly.", "xdotool" => $"{reason}. xdotool only works on X11/XWayland — install a Wayland-native compositor for better detection.", - _ => reason + _ => reason, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Services/DeveloperFormattingService.cs b/src/TypeWhisper.Core/Services/DeveloperFormattingService.cs index 54c08a80d..fbfe8d860 100644 --- a/src/TypeWhisper.Core/Services/DeveloperFormattingService.cs +++ b/src/TypeWhisper.Core/Services/DeveloperFormattingService.cs @@ -32,7 +32,7 @@ private static readonly (Regex Pattern, string Replacement)[] s_symbolReplacemen (SemicolonRegex(), ";"), (CommaRegex(), ","), (UnderscoreRegex(), "_"), - (EqualsRegex(), "=") + (EqualsRegex(), "="), ]; public static string Format(string text) @@ -119,7 +119,7 @@ private static string ReplaceRepeated(string text, Regex regex, string replaceme "camel" => words[0] + string.Concat(words.Skip(1).Select(ToTitleInvariant)), "snake" => string.Join('_', words), "kebab" => string.Join('-', words), - _ => null + _ => null, }; } diff --git a/src/TypeWhisper.Core/Services/FirstRunDefaults.cs b/src/TypeWhisper.Core/Services/FirstRunDefaults.cs index 209a72aad..974531c15 100644 --- a/src/TypeWhisper.Core/Services/FirstRunDefaults.cs +++ b/src/TypeWhisper.Core/Services/FirstRunDefaults.cs @@ -66,7 +66,7 @@ public static PromptAction CreateAutoCleanupAction() IsPreset = false, IsEnabled = false, SortOrder = 0, - ProviderOverride = null + ProviderOverride = null, }; } @@ -85,7 +85,7 @@ public static Profile CreateAutoFormatProfile() PromptActionId = AutoCleanupActionId, HotkeyData = "Ctrl + Alt + E", HotkeyBehavior = ProfileHotkeyBehavior.StartDictation, - StylePreset = ProfileStylePreset.Raw + StylePreset = ProfileStylePreset.Raw, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Services/HistoryInsightsService.cs b/src/TypeWhisper.Core/Services/HistoryInsightsService.cs index c27e0fc24..d42de0587 100644 --- a/src/TypeWhisper.Core/Services/HistoryInsightsService.cs +++ b/src/TypeWhisper.Core/Services/HistoryInsightsService.cs @@ -79,7 +79,7 @@ or TextInsertionStatus.MissingPasteTool ), PromptActionAppliedCount = records.Count(record => record.PromptActionApplied), TranslationAppliedCount = records.Count(record => record.TranslationApplied), - TopApps = topApps + TopApps = topApps, }; } } diff --git a/src/TypeWhisper.Core/Services/HistoryService.Export.cs b/src/TypeWhisper.Core/Services/HistoryService.Export.cs index 7f173c1ea..5268969a5 100644 --- a/src/TypeWhisper.Core/Services/HistoryService.Export.cs +++ b/src/TypeWhisper.Core/Services/HistoryService.Export.cs @@ -129,7 +129,7 @@ public string ExportToJson(IReadOnlyList records) profile = r.ProfileName, insertion_status = r.InsertionStatus.ToString(), insertion_failure_reason = r.InsertionFailureReason, - words = r.WordCount + words = r.WordCount, }); return JsonSerializer.Serialize(data, s_jsonOptions); diff --git a/src/TypeWhisper.Core/Services/ProfileStylePresetService.cs b/src/TypeWhisper.Core/Services/ProfileStylePresetService.cs index 06997af6f..2f40ee498 100644 --- a/src/TypeWhisper.Core/Services/ProfileStylePresetService.cs +++ b/src/TypeWhisper.Core/Services/ProfileStylePresetService.cs @@ -45,7 +45,7 @@ public static ProfileStyleSettings Resolve(ProfileStylePreset preset) CleanupLevel.Medium, true ), - _ => Settings(ProfileStylePreset.Raw, CleanupLevel.None) + _ => Settings(ProfileStylePreset.Raw, CleanupLevel.None), }; } @@ -63,7 +63,7 @@ private static ProfileStyleSettings Settings( CleanupLevel = cleanupLevel, SmartFormattingEnabled = smartFormatting, DeveloperFormattingEnabled = developerFormatting, - TerminalSafe = terminalSafe + TerminalSafe = terminalSafe, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Services/WhisperHallucinationFilter.cs b/src/TypeWhisper.Core/Services/WhisperHallucinationFilter.cs index 73ef1961c..289568d6c 100644 --- a/src/TypeWhisper.Core/Services/WhisperHallucinationFilter.cs +++ b/src/TypeWhisper.Core/Services/WhisperHallucinationFilter.cs @@ -35,7 +35,7 @@ public static class WhisperHallucinationFilter "bye", "bye bye", "goodbye", - "you" + "you", }; /// diff --git a/src/TypeWhisper.Linux/Cli/CommandLineParser.cs b/src/TypeWhisper.Linux/Cli/CommandLineParser.cs index a965f9fa4..26466c18d 100644 --- a/src/TypeWhisper.Linux/Cli/CommandLineParser.cs +++ b/src/TypeWhisper.Linux/Cli/CommandLineParser.cs @@ -19,7 +19,7 @@ internal enum CliActionKind Status, /// Args didn't parse; the driver should print usage and exit non-zero. - Invalid + Invalid, } /// Result of parsing the command line. diff --git a/src/TypeWhisper.Linux/Cli/Commands/RecordCommand.cs b/src/TypeWhisper.Linux/Cli/Commands/RecordCommand.cs index ae3178d93..4b6101e56 100644 --- a/src/TypeWhisper.Linux/Cli/Commands/RecordCommand.cs +++ b/src/TypeWhisper.Linux/Cli/Commands/RecordCommand.cs @@ -19,7 +19,7 @@ public static int Run(string verb) "stop" => JsonControlProtocol.CmdRecordStop, "toggle" => JsonControlProtocol.CmdRecordToggle, "cancel" => JsonControlProtocol.CmdRecordCancel, - _ => null + _ => null, }; if (cmd is null) { diff --git a/src/TypeWhisper.Linux/DiffKindConverters.cs b/src/TypeWhisper.Linux/DiffKindConverters.cs index e1f94fdd5..a9cc38bd3 100644 --- a/src/TypeWhisper.Linux/DiffKindConverters.cs +++ b/src/TypeWhisper.Linux/DiffKindConverters.cs @@ -23,7 +23,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture { DiffKind.Added => s_added, DiffKind.Removed => s_removed, - _ => s_unchanged + _ => s_unchanged, }; public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => @@ -69,7 +69,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture { "Background" => local ? s_localBackground : s_networkBackground, "Border" => local ? s_localBorder : s_networkBorder, - _ => local ? s_localForeground : s_networkForeground + _ => local ? s_localForeground : s_networkForeground, }; } diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs index 2274e1d62..9e5e4c992 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs @@ -117,7 +117,7 @@ private async Task SetPropertyAsync(string property, bool value, Cancellat StatusInterface, property, "b", - value ? "true" : "false" + value ? "true" : "false", ], timeout: s_timeout, ct: ct diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs index c5f5764bd..a5ea5e92f 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -1245,7 +1245,7 @@ private async Task TryStartAsync() // body arg, so Arg0="focused" lets the bus daemon filter to focus changes // for us instead of waking us for every state change session-wide. The // in-handler detail/detail1 checks below stay as defense in depth. - Arg0 = FocusedStateName + Arg0 = FocusedStateName, }, s_readSignal, HandleStateChanged, @@ -1258,7 +1258,7 @@ private async Task TryStartAsync() { Type = MessageType.Signal, Interface = EventObjectInterface, - Member = "TextChanged" + Member = "TextChanged", }, s_readSignal, HandleTextChanged, @@ -1278,7 +1278,7 @@ private async Task TryStartAsync() Sender = "org.freedesktop.DBus", Interface = "org.freedesktop.DBus", Member = "NameOwnerChanged", - Arg0 = RegistryBusName + Arg0 = RegistryBusName, }, s_readNameOwnerChanged, HandleRegistryOwnerChanged, @@ -1688,7 +1688,7 @@ int end "org.freedesktop.DBus.Error.UnknownMethod", "org.freedesktop.DBus.Error.ServiceUnknown", // app's a11y bridge went away "org.freedesktop.DBus.Error.NoReply", // app busy / not responding - "org.freedesktop.DBus.Error.Disconnected" + "org.freedesktop.DBus.Error.Disconnected", ]; // at-spi2-core 2.52 (Ubuntu/Mint) answers a property Get for an interface the element does not diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs index 455d7e012..de8f43899 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs @@ -52,7 +52,7 @@ public sealed partial class AtSpiUrlExtractor "opera", "zen", "zen-browser", - "zen-bin" + "zen-bin", }; private static readonly TimeSpan s_cacheTtl = TimeSpan.FromSeconds(10); @@ -685,7 +685,7 @@ params string[] signatureAndArgs destination, path, @interface, - method + method, }; args.AddRange(signatureAndArgs); @@ -716,7 +716,7 @@ private static bool CheckCommandAvailable(string command, string args) using var p = Process.Start( new ProcessStartInfo(command, args) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, } ); p?.WaitForExit(1000); @@ -737,7 +737,7 @@ private static int RunProcess(string fileName, string args, out string? output) using var p = Process.Start( new ProcessStartInfo(fileName, args) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, } ); if (p is null) @@ -779,7 +779,7 @@ private static int RunProcess(string fileName, IReadOnlyList args, out s { var startInfo = new ProcessStartInfo(fileName) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; foreach (var arg in args) { diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/GnomeWindowCallsProvider.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/GnomeWindowCallsProvider.cs index 52ab2b494..31995219c 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/GnomeWindowCallsProvider.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/GnomeWindowCallsProvider.cs @@ -25,7 +25,7 @@ public sealed class GnomeWindowCallsProvider : IActiveWindowProvider private static readonly (string Path, string Interface)[] s_endpoints = [ ("/org/gnome/Shell/Extensions/Windows", "org.gnome.Shell.Extensions.Windows"), - ("/org/gnome/Shell/Extensions/WindowsExt", "org.gnome.Shell.Extensions.WindowsExt") + ("/org/gnome/Shell/Extensions/WindowsExt", "org.gnome.Shell.Extensions.WindowsExt"), ]; public string Name => "gnome-window-calls"; @@ -162,7 +162,7 @@ public bool IsApplicable() { JsonValueKind.Number => idProp.GetInt64().ToString(), JsonValueKind.String => idProp.GetString(), - _ => null + _ => null, }; } diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/ProviderProcessRunner.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/ProviderProcessRunner.cs index 102d547a3..f853a3a36 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/ProviderProcessRunner.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/ProviderProcessRunner.cs @@ -18,7 +18,7 @@ CancellationToken ct { var psi = new ProcessStartInfo(fileName, args) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; return RunAsync(psi, ct); } @@ -36,7 +36,7 @@ CancellationToken ct { var psi = new ProcessStartInfo(fileName) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; foreach (var a in args) { diff --git a/src/TypeWhisper.Linux/Services/AppVersion.cs b/src/TypeWhisper.Linux/Services/AppVersion.cs index a0830b614..c80a0ef84 100644 --- a/src/TypeWhisper.Linux/Services/AppVersion.cs +++ b/src/TypeWhisper.Linux/Services/AppVersion.cs @@ -107,7 +107,7 @@ private static int CompareIdentifier(string a, string b) // Numeric identifiers rank below alphanumeric (SemVer §11.4). (true, _) => -1, (_, true) => 1, - _ => string.CompareOrdinal(a, b) + _ => string.CompareOrdinal(a, b), }; } diff --git a/src/TypeWhisper.Linux/Services/AudioDuckingService.cs b/src/TypeWhisper.Linux/Services/AudioDuckingService.cs index ba9ace70d..34176ffe4 100644 --- a/src/TypeWhisper.Linux/Services/AudioDuckingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioDuckingService.cs @@ -162,7 +162,7 @@ private ProcessRunResult SetSinkInputVolume(string inputId, string[] volumes) var arguments = new List(2 + volumes.Length) { "set-sink-input-volume", - inputId + inputId, }; arguments.AddRange(volumes); return RunPactl(arguments); diff --git a/src/TypeWhisper.Linux/Services/AudioFileService.cs b/src/TypeWhisper.Linux/Services/AudioFileService.cs index 53c3eb3df..346c96d09 100644 --- a/src/TypeWhisper.Linux/Services/AudioFileService.cs +++ b/src/TypeWhisper.Linux/Services/AudioFileService.cs @@ -18,7 +18,7 @@ public sealed class AudioFileService ".mkv", ".avi", ".mov", - ".webm" + ".webm", }; private readonly SystemCommandAvailabilityService _commands; @@ -65,7 +65,7 @@ public async Task LoadAudioAsWavAsync( $"-v error -i \"{filePath}\" -vn -ac 1 -ar 16000 -f wav pipe:1" ) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; process.Start(); diff --git a/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs b/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs index 902ff1b13..5790711ce 100644 --- a/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs +++ b/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs @@ -92,7 +92,7 @@ public void Play(string audioFileName) channelCount = Channels, sampleFormat = SampleFormat.Float32, suggestedLatency = outputInfo.defaultLowOutputLatency, - hostApiSpecificStreamInfo = IntPtr.Zero + hostApiSpecificStreamInfo = IntPtr.Zero, }; _stream = new PaStream( diff --git a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs index 30dec1d2b..1be5f896e 100644 --- a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs @@ -938,7 +938,7 @@ PaStream.Callback callback channelCount = Channels, sampleFormat = SampleFormat.Float32, suggestedLatency = inputInfo.defaultLowInputLatency, - hostApiSpecificStreamInfo = IntPtr.Zero + hostApiSpecificStreamInfo = IntPtr.Zero, }; return new PaStream( diff --git a/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs b/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs index d57556476..9aaf96693 100644 --- a/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs @@ -49,7 +49,7 @@ public sealed partial class BrowserAccessibilitySetupHelper "microsoft-edge.desktop", "brave-browser.desktop", "vivaldi-stable.desktop", - "opera.desktop" + "opera.desktop", ]; private static readonly string[] s_firefoxLauncherNames = @@ -61,13 +61,13 @@ public sealed partial class BrowserAccessibilitySetupHelper "io.gitlab.librewolf-community.desktop", "zen.desktop", "app.zen_browser.zen.desktop", - "io.github.zen_browser.zen.desktop" + "io.github.zen_browser.zen.desktop", ]; private static readonly string[] s_systemLauncherDirectories = [ "/usr/share/applications", - "/var/lib/flatpak/exports/share/applications" + "/var/lib/flatpak/exports/share/applications", ]; /// @@ -561,7 +561,7 @@ private static IEnumerable EnumerateFirefoxProfileDirs() Path.Join(home, ".var", "app", "app.zen_browser.zen", ".zen"), Path.Join(home, ".var", "app", "io.github.zen_browser.zen", ".zen"), Path.Join(home, ".zen"), Path.Join(home, ".var", "app", "io.gitlab.librewolf-community", ".librewolf"), - Path.Join(home, ".librewolf") + Path.Join(home, ".librewolf"), }; foreach (var root in roots) { diff --git a/src/TypeWhisper.Linux/Services/DictationToggleGate.cs b/src/TypeWhisper.Linux/Services/DictationToggleGate.cs index 68ba4474e..72723c54b 100644 --- a/src/TypeWhisper.Linux/Services/DictationToggleGate.cs +++ b/src/TypeWhisper.Linux/Services/DictationToggleGate.cs @@ -4,7 +4,7 @@ internal enum DictationStopGateResult { Acquired, PendingStartupCompletion, - Busy + Busy, } /// diff --git a/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs b/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs index 8e94938b9..6d55c8ba4 100644 --- a/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs +++ b/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs @@ -119,7 +119,7 @@ CancellationToken cancellationToken segment.Start, segment.End )) - .ToArray() + .ToArray(), }; var pipelineResult = await pipeline.ProcessAsync( @@ -129,7 +129,7 @@ CancellationToken cancellationToken VocabularyBooster = currentSettings.VocabularyBoostingEnabled ? vocabularyBoosting.Apply : null, - DictionaryCorrector = dictionary.ApplyCorrections + DictionaryCorrector = dictionary.ApplyCorrections, }, cancellationToken ); @@ -206,7 +206,7 @@ CancellationToken cancellationToken $"Ambiguous transcription model '{options.ModelId}': provided by multiple engines. " + "Specify the engine explicitly or use the full plugin-qualified model id." ), - _ => ModelManagerService.GetPluginModelId(matches[0].GetTranscriptionSelectionId(), options.ModelId) + _ => ModelManagerService.GetPluginModelId(matches[0].GetTranscriptionSelectionId(), options.ModelId), }; } } diff --git a/src/TypeWhisper.Linux/Services/GnomeWindowCallsSetupHelper.cs b/src/TypeWhisper.Linux/Services/GnomeWindowCallsSetupHelper.cs index 7d336178b..71db5b47e 100644 --- a/src/TypeWhisper.Linux/Services/GnomeWindowCallsSetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/GnomeWindowCallsSetupHelper.cs @@ -20,7 +20,7 @@ public sealed class GnomeWindowCallsSetupHelper private static readonly (string Path, string Interface)[] s_endpoints = [ ("/org/gnome/Shell/Extensions/Windows", "org.gnome.Shell.Extensions.Windows"), - ("/org/gnome/Shell/Extensions/WindowsExt", "org.gnome.Shell.Extensions.WindowsExt") + ("/org/gnome/Shell/Extensions/WindowsExt", "org.gnome.Shell.Extensions.WindowsExt"), ]; // kept instance: injected as a DI/test seam by callers @@ -104,7 +104,7 @@ public bool TryOpenInstallPage() using var p = Process.Start( new ProcessStartInfo("xdg-open", ExtensionInstallUrl) { - UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true + UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, } ); return p is not null; diff --git a/src/TypeWhisper.Linux/Services/HistoryRetentionCoordinator.cs b/src/TypeWhisper.Linux/Services/HistoryRetentionCoordinator.cs index 57110ef2f..b1baee6f3 100644 --- a/src/TypeWhisper.Linux/Services/HistoryRetentionCoordinator.cs +++ b/src/TypeWhisper.Linux/Services/HistoryRetentionCoordinator.cs @@ -117,6 +117,6 @@ private enum HistoryRetentionTrigger Startup, SettingsChanged, HistoryChanged, - Shutdown + Shutdown, } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DesktopDetector.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DesktopDetector.cs index e3b101739..686406ad8 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DesktopDetector.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DesktopDetector.cs @@ -98,7 +98,7 @@ public static string DisplayName(string? id = null) "kde" => "KDE Plasma", "hyprland" => "Hyprland", "sway" => "Sway", - _ => RawXdgFallback() + _ => RawXdgFallback(), }; } @@ -176,7 +176,7 @@ private static string RawXdgFallback() "Pantheon" => "Pantheon", "Budgie" => "Budgie", "Deepin" => "Deepin", - _ => tokens[^1] + _ => tokens[^1], }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs index 9bebaa44d..b50541fb0 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs @@ -47,7 +47,7 @@ public static class DictationShortcutSpecFactory cancelTrigger, cancelTrigger is null ? null : $"{gui} record cancel" ), - _ => null + _ => null, }; } diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs index c53057f57..e44ae11a0 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs @@ -113,7 +113,7 @@ public async Task WriteAsync(DeShortcutSpec spec, Cancell var (key, value) in new[] { ("name", spec.DisplayName), ("command", spec.OnPressCommand), - ("binding", FormatGnomeAccel(spec.Trigger)) + ("binding", FormatGnomeAccel(spec.Trigger)), } ) { @@ -475,7 +475,7 @@ public static string FormatGnomeAccel(string trigger) "shift" => "Shift", "alt" => "Alt", "super" or "win" or "windows" or "cmd" or "meta" => "Super", - _ => null + _ => null, }; if (modifier is null) { diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs index 774d3d393..52910b8ed 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs @@ -35,7 +35,7 @@ public sealed class InputAccessSetupHelper private static readonly string[] s_seatManagerDirectoryPaths = [ "/run/systemd/seats", - "/run/elogind/seats" + "/run/elogind/seats", ]; // System config dir holding the udev rule. Always /etc in production. Tests diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LinuxKeyMap.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LinuxKeyMap.cs index bcd179aef..5fcae929f 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LinuxKeyMap.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LinuxKeyMap.cs @@ -33,7 +33,7 @@ public static ModifierMask ToModifier(int linuxCode) KeyRightalt => ModifierMask.RightAlt, KeyLeftmeta => ModifierMask.LeftMeta, KeyRightmeta => ModifierMask.RightMeta, - _ => ModifierMask.None + _ => ModifierMask.None, }; } @@ -141,7 +141,7 @@ public static bool IsModifier(int linuxCode) KeyLeftmeta => KeyCode.VcLeftMeta, KeyRightmeta => KeyCode.VcRightMeta, - _ => null + _ => null, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LogindSessionActivityMonitor.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LogindSessionActivityMonitor.cs index 09bd8c60b..2749c4230 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LogindSessionActivityMonitor.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LogindSessionActivityMonitor.cs @@ -195,7 +195,7 @@ private static bool IndicatesLogindAbsent(Exception ex) dbus.ErrorName is "org.freedesktop.DBus.Error.ServiceUnknown" or "org.freedesktop.DBus.Error.NameHasNoOwner" or "org.freedesktop.DBus.Error.FileNotFound", - _ => false + _ => false, }; } @@ -332,7 +332,7 @@ string sessionPath Interface = PropertiesInterface, Path = sessionPath, Member = "PropertiesChanged", - Arg0 = SessionInterface + Arg0 = SessionInterface, }, s_readPropertiesChanged, HandlePropertiesChanged, @@ -355,7 +355,7 @@ bool locked Sender = LoginService, Interface = SessionInterface, Path = sessionPath, - Member = member + Member = member, }, locked ? s_readLockSignal : s_readUnlockSignal, locked ? HandleLockSignal : HandleUnlockSignal, diff --git a/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs b/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs index 32675a727..2eadcdd83 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs @@ -203,7 +203,7 @@ private static ModifierMask NormalizeMask(KeyCode key, ModifierMask mask) KeyCode.VcRightAlt => ModifierMask.RightAlt, KeyCode.VcLeftMeta => ModifierMask.LeftMeta, KeyCode.VcRightMeta => ModifierMask.RightMeta, - _ => ModifierMask.None + _ => ModifierMask.None, }; return modBit == ModifierMask.None ? mask : mask & ~modBit; } diff --git a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs index 3a2891957..ce90208ad 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs @@ -306,7 +306,7 @@ private void HandleRelease(KeyCode key, ModifierMask mods, GlobalShortcutSet set { _pendingSelectionWorkflows[key] = releasedWorkflow with { - TriggerReleased = true + TriggerReleased = true, }; } @@ -543,7 +543,7 @@ private enum SelectionWorkflowKind PromptPalette, PromptAction, ProfileTextProcessing, - TransformSelection + TransformSelection, } private readonly record struct PendingSelectionWorkflow( diff --git a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutMatcher.cs b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutMatcher.cs index c0305a54f..c5dcaa3c2 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutMatcher.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutMatcher.cs @@ -16,7 +16,7 @@ internal enum ShortcutMatchKind TransformSelection, Cancel, PromptAction, - Profile + Profile, } /// diff --git a/src/TypeWhisper.Linux/Services/HotkeyService.cs b/src/TypeWhisper.Linux/Services/HotkeyService.cs index 28f091564..07c3f3175 100644 --- a/src/TypeWhisper.Linux/Services/HotkeyService.cs +++ b/src/TypeWhisper.Linux/Services/HotkeyService.cs @@ -12,7 +12,7 @@ public enum HotkeyCandidateValidationStatus CollidesWithFixedBinding, CollidesWithPromptAction, CollidesWithProfile, - MissingEnabledPromptAction + MissingEnabledPromptAction, } public sealed record HotkeyCandidateValidationResult( @@ -552,7 +552,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithFixedBinding, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -567,7 +567,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithPromptAction, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -581,7 +581,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithProfile, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -623,7 +623,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.MissingEnabledPromptAction, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -637,7 +637,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithFixedBinding, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -651,7 +651,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithPromptAction, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -666,7 +666,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithProfile, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -1213,7 +1213,7 @@ KeyCode.VcLeftAlt or KeyCode.VcRightAlt => ModifierMask.LeftAlt | ModifierMask.RightAlt, KeyCode.VcLeftMeta or KeyCode.VcRightMeta => ModifierMask.LeftMeta | ModifierMask.RightMeta, - _ => ModifierMask.None + _ => ModifierMask.None, }; } @@ -1296,7 +1296,7 @@ private static string FormatHotkey(KeyCode key, ModifierMask mods) KeyCode.VcRightAlt => "Right Alt", KeyCode.VcLeftMeta => "Left Meta", KeyCode.VcRightMeta => "Right Meta", - _ => null + _ => null, }; if (sideSpecific is not null) { @@ -1419,7 +1419,7 @@ private static bool TryParseHotkey(string text, out KeyCode? key, out ModifierMa "right" => KeyCode.VcRight, "up" => KeyCode.VcUp, "down" => KeyCode.VcDown, - _ => (KeyCode?)null + _ => (KeyCode?)null, }; if (named is not null) { @@ -1467,7 +1467,7 @@ private static bool TryParseSideSpecificSingleModifier(string token, out KeyCode "right alt" => KeyCode.VcRightAlt, "left meta" or "left super" or "left win" => KeyCode.VcLeftMeta, "right meta" or "right super" or "right win" => KeyCode.VcRightMeta, - _ => KeyCode.VcUndefined + _ => KeyCode.VcUndefined, }; return key != KeyCode.VcUndefined; } @@ -1478,6 +1478,6 @@ private enum HotkeyBinding PromptPalette, RecentTranscriptions, CopyLastTranscription, - TransformSelection + TransformSelection, } } diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs index e3bac0300..2626900d5 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs @@ -576,7 +576,7 @@ await writer JsonControlProtocol.CmdRecordCancel => await HandleCancelAsync() .ConfigureAwait(false), JsonControlProtocol.CmdStatus => HandleStatus(), - _ => JsonControlProtocol.SerializeError(JsonControlProtocol.ErrUnknownCommand) + _ => JsonControlProtocol.SerializeError(JsonControlProtocol.ErrUnknownCommand), }; await writer.WriteLineAsync(response).ConfigureAwait(false); @@ -684,7 +684,7 @@ private string HandleStatus() Backend = _hotkey?.ActiveBackendId, SupportsPressRelease = _hotkey?.ActiveBackendSupportsPressRelease ?? false, ActiveBinding = _hotkey?.CurrentHotkeyString, - Mode = _settings?.Current.Mode.ToString() + Mode = _settings?.Current.Mode.ToString(), }; return JsonControlProtocol.SerializeStatus(response); } diff --git a/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs b/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs index eb6e272a0..1aa1f471c 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs @@ -54,7 +54,7 @@ internal static class JsonControlProtocol // the documented response shape (camelCase would not match the spec). PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false + WriteIndented = false, }; public static string SerializeError(string code) diff --git a/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs b/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs index 5809463d0..2e3081511 100644 --- a/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs +++ b/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs @@ -534,7 +534,7 @@ private async Task EnsureConnectedAsync() Sender = NotificationsService, Interface = NotificationsInterface, Path = NotificationsPath, - Member = "ActionInvoked" + Member = "ActionInvoked", }, s_readActionInvoked, HandleActionInvoked, @@ -549,7 +549,7 @@ private async Task EnsureConnectedAsync() Sender = NotificationsService, Interface = NotificationsInterface, Path = NotificationsPath, - Member = "NotificationClosed" + Member = "NotificationClosed", }, s_readClosed, HandleClosed, diff --git a/src/TypeWhisper.Linux/Services/LinuxDictationReadbackLanguagePolicy.cs b/src/TypeWhisper.Linux/Services/LinuxDictationReadbackLanguagePolicy.cs index 2cd797ad1..3fa4b3689 100644 --- a/src/TypeWhisper.Linux/Services/LinuxDictationReadbackLanguagePolicy.cs +++ b/src/TypeWhisper.Linux/Services/LinuxDictationReadbackLanguagePolicy.cs @@ -61,7 +61,7 @@ target is not null { FinalLanguage.TranslatedToTarget => target, FinalLanguage.Rewritten => null, - _ => engineTranslatedToEnglish ? "en" : sourceLanguage + _ => engineTranslatedToEnglish ? "en" : sourceLanguage, }; } @@ -103,6 +103,6 @@ private enum FinalLanguage { Unchanged, // No post-processing step changed the language. TranslatedToTarget, // Translation step ran and changed the language. - Rewritten // Prompt/plugin rewrote into an unknown language. + Rewritten, // Prompt/plugin rewrote into an unknown language. } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/LinuxDictationShortSpeechPolicy.cs b/src/TypeWhisper.Linux/Services/LinuxDictationShortSpeechPolicy.cs index 207523e7c..97a145c21 100644 --- a/src/TypeWhisper.Linux/Services/LinuxDictationShortSpeechPolicy.cs +++ b/src/TypeWhisper.Linux/Services/LinuxDictationShortSpeechPolicy.cs @@ -4,7 +4,7 @@ internal enum LinuxShortSpeechDecision { DiscardTooShort, DiscardNoSpeech, - Transcribe + Transcribe, } internal static class LinuxDictationShortSpeechPolicy diff --git a/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs b/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs index e3e1df619..9a7810966 100644 --- a/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs +++ b/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs @@ -7,7 +7,7 @@ internal enum LiveTranscriptionMode { None, Polling, - Streaming + Streaming, } // Selects the live-transcription mode for the recording loop. Ported from diff --git a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs index 901c7e8ed..fca292fa4 100644 --- a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs +++ b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs @@ -152,7 +152,7 @@ private static IReadOnlyList BuildArguments( { "espeak" or "espeak-ng" => ["-v", language, text], "spd-say" => ["--wait", "-l", language, text], - _ => BuildDefaultArguments(command, text) + _ => BuildDefaultArguments(command, text), }; } diff --git a/src/TypeWhisper.Linux/Services/Localization/Loc.cs b/src/TypeWhisper.Linux/Services/Localization/Loc.cs index 91d0d503e..cb1a4089f 100644 --- a/src/TypeWhisper.Linux/Services/Localization/Loc.cs +++ b/src/TypeWhisper.Linux/Services/Localization/Loc.cs @@ -25,7 +25,7 @@ public sealed class Loc : INotifyPropertyChanged private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary> _strings = []; @@ -193,7 +193,7 @@ private static List BuildUiLanguageOptions(List codes) ["ru"] = "Русский", ["ja"] = "日本語", ["zh"] = "中文", - ["ko"] = "한국어" + ["ko"] = "한국어", }; var options = new List { new(null, "Auto (System)") }; diff --git a/src/TypeWhisper.Linux/Services/MemoryService.cs b/src/TypeWhisper.Linux/Services/MemoryService.cs index c90db2876..a26d6f980 100644 --- a/src/TypeWhisper.Linux/Services/MemoryService.cs +++ b/src/TypeWhisper.Linux/Services/MemoryService.cs @@ -138,7 +138,7 @@ string userPrompt ProviderId = providerId, ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = null + InjectedMemoryContext = null, }; capture.Add(provenance); return provenance; diff --git a/src/TypeWhisper.Linux/Services/ModelManagerService.cs b/src/TypeWhisper.Linux/Services/ModelManagerService.cs index c339732b2..aa655bdd7 100644 --- a/src/TypeWhisper.Linux/Services/ModelManagerService.cs +++ b/src/TypeWhisper.Linux/Services/ModelManagerService.cs @@ -581,7 +581,7 @@ public void MigrateSettings() ), "plugin:com.typewhisper.voxtral:mistral-whisper" => GetPluginModelId("com.typewhisper.voxtral", "voxtral-mini-latest"), - _ => modelId + _ => modelId, }; } @@ -596,7 +596,7 @@ public void MigrateSettings() { "plugin:com.typewhisper.voxtral:mistral-whisper" => GetPluginModelId("com.typewhisper.voxtral", "voxtral-mini-latest"), - _ => modelId + _ => modelId, }; } @@ -608,7 +608,7 @@ private static TranscriptionAccelerationPreference GetAccelerationPreference(str AppSettings.LocalModelAccelerationNvidiaCuda => TranscriptionAccelerationPreference.NvidiaCuda, AppSettings.LocalModelAccelerationCpu => TranscriptionAccelerationPreference.Cpu, - _ => TranscriptionAccelerationPreference.Auto + _ => TranscriptionAccelerationPreference.Auto, }; } @@ -1111,7 +1111,7 @@ public async Task TranscribeAsync( Text = result.Text, DetectedLanguage = result.DetectedLanguage, Duration = result.DurationSeconds, - NoSpeechProbability = result.NoSpeechProbability + NoSpeechProbability = result.NoSpeechProbability, }; } } diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs index 1e7972e5a..b4b916d6e 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs @@ -24,7 +24,7 @@ public static class PluginLocalityClassifier "com.typewhisper.file-memory", "com.typewhisper.obsidian", "com.typewhisper.script", - "com.typewhisper.webhook" + "com.typewhisper.webhook", ]; public static bool IsLocal(PluginManifest manifest) => diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs index f061063cf..32581c1ae 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs @@ -19,7 +19,7 @@ public sealed class PluginManager : IDisposable private static readonly HashSet s_defaultEnabledPluginIds = new(StringComparer.Ordinal) { "com.typewhisper.whisper-cpp", // offline transcription (recommended default) - "com.typewhisper.sherpa-onnx" // offline transcription + "com.typewhisper.sherpa-onnx", // offline transcription }; private readonly HashSet _activatedPlugins = []; @@ -579,8 +579,8 @@ private static string ResolveErrorCategory(LoadedPlugin plugin) { ITranscriptionEnginePlugin => ErrorCategory.Transcription, ILlmProviderPlugin => ErrorCategory.Prompt, - _ => ErrorCategory.Plugin - } + _ => ErrorCategory.Plugin, + }, }; } @@ -757,7 +757,7 @@ private async Task MigrateApiKeysAsync() current with { GroqApiKey = migratedGroq ? "" : current.GroqApiKey, - OpenAiApiKey = migratedOpenAi ? "" : current.OpenAiApiKey + OpenAiApiKey = migratedOpenAi ? "" : current.OpenAiApiKey, } ); } diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs index 2828d1fb7..c04749141 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs @@ -48,7 +48,7 @@ public sealed class PluginRegistryService "com.typewhisper.qwen3-stt", "com.typewhisper.obsidian", "com.typewhisper.linear", - "com.typewhisper.openai-compatible" + "com.typewhisper.openai-compatible", }; private readonly HttpClient _httpClient; diff --git a/src/TypeWhisper.Linux/Services/Plugins/RegistryPlugin.cs b/src/TypeWhisper.Linux/Services/Plugins/RegistryPlugin.cs index 60a3146f3..4e0b384eb 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/RegistryPlugin.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/RegistryPlugin.cs @@ -35,5 +35,5 @@ public enum PluginInstallState UpdateAvailable, // ReSharper disable once UnusedMember.Global member of the JsonStringEnumConverter-serialized install-state vocabulary (PluginInstallState); kept for completeness, not currently produced in-tree - Bundled + Bundled, } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/ProcessPriority.cs b/src/TypeWhisper.Linux/Services/ProcessPriority.cs index 9e5e2cfaa..135ebd611 100644 --- a/src/TypeWhisper.Linux/Services/ProcessPriority.cs +++ b/src/TypeWhisper.Linux/Services/ProcessPriority.cs @@ -21,7 +21,7 @@ public static string ResetToDefaults() var results = new List { Run("renice", $"-n 0 -p {pid}"), - Run("ionice", $"-c 2 -n 4 -p {pid}") + Run("ionice", $"-c 2 -n 4 -p {pid}"), }; return string.Join("; ", results); @@ -39,7 +39,7 @@ private static string Run(string file, string args) RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); if (p is null) diff --git a/src/TypeWhisper.Linux/Services/ProcessRunner.cs b/src/TypeWhisper.Linux/Services/ProcessRunner.cs index af913a477..b91a12f83 100644 --- a/src/TypeWhisper.Linux/Services/ProcessRunner.cs +++ b/src/TypeWhisper.Linux/Services/ProcessRunner.cs @@ -94,7 +94,7 @@ public async Task RunAsync( RedirectStandardError = true, RedirectStandardInput = standardInput is not null, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, }; foreach (var arg in args) { diff --git a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs index 30c971c98..9594a36a5 100644 --- a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs +++ b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs @@ -228,7 +228,7 @@ public async Task ProcessSystemPromptAsync( ProviderId = providerId, ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = injectedMemoryContext + InjectedMemoryContext = injectedMemoryContext, }; capture.Add(provenance); return provenance; diff --git a/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs b/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs index 60273b89f..ce01f7e42 100644 --- a/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs +++ b/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs @@ -103,7 +103,7 @@ public static string BodyFor(RecordingMode mode) { RecordingMode.Toggle => Loc.Instance["Notify.BodyToggle"], RecordingMode.PushToTalk => Loc.Instance["Notify.BodyPushToTalk"], - _ => Loc.Instance["Notify.BodyHybrid"] + _ => Loc.Instance["Notify.BodyHybrid"], }; } @@ -281,7 +281,7 @@ uint replaceId "TypeWhisper", replaceId.ToString(), ResolveIconPath(), presentation.Summary, presentation.Body, "[]", // actions "{}", // hints - presentation.ExpireTimeout.ToString() + presentation.ExpireTimeout.ToString(), ], timeout: s_callTimeout ) @@ -317,7 +317,7 @@ await _runner [ "call", "--session", "--dest", "org.freedesktop.Notifications", "--object-path", "/org/freedesktop/Notifications", "--method", - "org.freedesktop.Notifications.CloseNotification", id.ToString() + "org.freedesktop.Notifications.CloseNotification", id.ToString(), ], timeout: s_callTimeout ) diff --git a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs index 5a28bb6db..e982803cb 100644 --- a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs +++ b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs @@ -15,7 +15,7 @@ internal enum StartupRestoreStatus Applied, PriorGenerationRestored, LockUnavailable, - UnresolvedFailure + UnresolvedFailure, } internal sealed record StartupRestoreResult( @@ -60,7 +60,7 @@ public sealed class SettingsBackupService [ "settings.json", "settings.json.bak", - "linux-preferences.json" + "linux-preferences.json", ]; private static readonly string[] s_backupDirectoryRoots = ["Data", "PluginData"]; @@ -78,7 +78,7 @@ public sealed class SettingsBackupService private static readonly JsonSerializerOptions s_transactionJsonOptions = new() { WriteIndented = true, - Converters = { new JsonStringEnumConverter() } + Converters = { new JsonStringEnumConverter() }, }; private readonly string _basePath; @@ -133,7 +133,7 @@ public SettingsBackupResult CreateBackup(string destinationZipPath) kind = ManifestKind, createdUtc = DateTimeOffset.UtcNow, includes = s_manifestIncludes, - excludes = s_manifestExcludes + excludes = s_manifestExcludes, }; var manifestEntry = archive.CreateEntry(ManifestEntryName, CompressionLevel.Optimal); using (var writer = new StreamWriter(manifestEntry.Open())) @@ -254,7 +254,7 @@ public SettingsBackupResult StageRestore(string sourceZipPath) { Version = PendingStateVersion, FileCount = fileCount, - UncompressedBytes = bytes + UncompressedBytes = bytes, } ); @@ -369,7 +369,7 @@ private StartupRestoreResult ApplyPendingRestoreUnderLock() ), RestoreJournalPhase.Committed => FinishCommittedTransaction(), RestoreJournalPhase.RolledBack => FinishRolledBackTransaction(), - _ => throw new InvalidDataException("The settings restore journal phase is invalid.") + _ => throw new InvalidDataException("The settings restore journal phase is invalid."), }; } @@ -379,7 +379,7 @@ private StartupRestoreResult ApplyPendingRestoreUnderLock() .Select(relativePath => new RestoreJournalItem { RelativePath = relativePath, - OriginallyExisted = File.Exists(GetLiveTargetPath(relativePath)) + OriginallyExisted = File.Exists(GetLiveTargetPath(relativePath)), }) .ToArray(); @@ -397,7 +397,7 @@ private StartupRestoreResult ApplyPendingRestoreUnderLock() { Version = JournalVersion, Phase = RestoreJournalPhase.Prepared, - Items = items + Items = items, }; try @@ -563,7 +563,7 @@ private void MarkUncommittedRequestRolledBackBestEffort(RestoreJournalItem[] ite { Version = JournalVersion, Phase = RestoreJournalPhase.RolledBack, - Items = items + Items = items, } ); TryCleanupPendingDirectory(); @@ -683,7 +683,7 @@ RestoreJournalPhase phase { Version = journal.Version, Phase = phase, - Items = journal.Items + Items = journal.Items, }; } @@ -1005,7 +1005,7 @@ private enum RestoreJournalPhase { Prepared, Committed, - RolledBack + RolledBack, } private sealed class PendingState diff --git a/src/TypeWhisper.Linux/Services/Setup/ISetupTask.cs b/src/TypeWhisper.Linux/Services/Setup/ISetupTask.cs index 59f564cbb..2303f4389 100644 --- a/src/TypeWhisper.Linux/Services/Setup/ISetupTask.cs +++ b/src/TypeWhisper.Linux/Services/Setup/ISetupTask.cs @@ -7,7 +7,7 @@ namespace TypeWhisper.Linux.Services.Setup; public enum SetupTaskSeverity { Required, - Recommended + Recommended, } /// @@ -25,7 +25,7 @@ public enum SetupTaskStatusKind Working, /// The last action failed; the user can retry or fall back to the manual command. - Failed + Failed, } /// diff --git a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandIntent.cs b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandIntent.cs index c05ec9342..0b178e1a4 100644 --- a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandIntent.cs +++ b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandIntent.cs @@ -17,7 +17,7 @@ public static class SpokenCommandIntent private static readonly HashSet s_selectionReferents = new(StringComparer.OrdinalIgnoreCase) { - "this", "that", "it", "these", "those", "them", "selection", "highlighted", "selected" + "this", "that", "it", "these", "those", "them", "selection", "highlighted", "selected", }; private static readonly string[] s_selectionPhrases = @@ -32,7 +32,7 @@ public static class SpokenCommandIntent "translate", "shorten", "lengthen", "summarize", "summarise", "rewrite", "rephrase", "reword", "reformat", "format", "fix", "correct", "proofread", "simplify", "condense", "expand", "capitalize", "capitalise", "uppercase", "lowercase", "bold", "italicize", "italicise", - "punctuate" + "punctuate", }; // A command that opens with one of these asks for new text from scratch ("write an email", @@ -42,7 +42,7 @@ public static class SpokenCommandIntent // demoting those to create would hijack a legitimate invocation of that saved action. private static readonly HashSet s_leadingCreationVerbs = new(StringComparer.OrdinalIgnoreCase) { - "write", "draft", "compose", "create", "generate" + "write", "draft", "compose", "create", "generate", }; public static bool RefersToSelection(string command) diff --git a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandKeyphrase.cs b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandKeyphrase.cs index 909f9886c..ece602578 100644 --- a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandKeyphrase.cs +++ b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandKeyphrase.cs @@ -45,7 +45,7 @@ public static bool TryStrip(string rawText, string keyphrase, out string command { <= 3 => 0, <= 6 => 1, - _ => 2 + _ => 2, }; var tokens = Tokenize(rawText); diff --git a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandText.cs b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandText.cs index 0fa240ab6..bea7290bc 100644 --- a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandText.cs +++ b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandText.cs @@ -15,7 +15,7 @@ internal static class SpokenCommandText public static readonly IReadOnlySet LeadingFillers = new HashSet(StringComparer.OrdinalIgnoreCase) { - "please", "pls", "kindly", "just", "can", "could", "would", "you" + "please", "pls", "kindly", "just", "can", "could", "would", "you", }; // Splits on whitespace and keeps only alphanumerics per token, dropping empties. Casing is diff --git a/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs b/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs index 4fa46061a..1425b153f 100644 --- a/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs +++ b/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs @@ -195,7 +195,7 @@ public async Task StartAsync(CancellationToken ct) var channel = Channel.CreateBounded(new BoundedChannelOptions(ChannelCapacity) { - FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false + FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false, }); var handler = OnTranscriptReceived; diff --git a/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs index 73794a4a2..292cb0303 100644 --- a/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs +++ b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs @@ -37,12 +37,12 @@ public sealed partial class SystemCommandAvailabilityService "/usr/local/cuda-12.1/lib64", "/usr/local/cuda-12.1/targets/x86_64-linux/lib", "/usr/local/cuda-12.0/lib64", - "/usr/local/cuda-12.0/targets/x86_64-linux/lib" + "/usr/local/cuda-12.0/targets/x86_64-linux/lib", ]; private static readonly string[] s_requiredCuda12RuntimeLibraries = [ "libcudart.so.12", - "libcublas.so.12" + "libcublas.so.12", ]; private static readonly Lock s_cudaPreloadLock = new(); @@ -349,7 +349,7 @@ public async Task RunCudaBenchmarkAsync( RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); @@ -598,7 +598,7 @@ private static LinuxCapabilitySnapshot BuildSnapshot() RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); if (p is null) @@ -721,7 +721,7 @@ private static bool FindInLdCache(string libraryName) RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); diff --git a/src/TypeWhisper.Linux/Services/TranslationService.cs b/src/TypeWhisper.Linux/Services/TranslationService.cs index c534cdf66..191731652 100644 --- a/src/TypeWhisper.Linux/Services/TranslationService.cs +++ b/src/TypeWhisper.Linux/Services/TranslationService.cs @@ -113,7 +113,7 @@ string userPrompt ProviderId = providerId, ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = null + InjectedMemoryContext = null, }; capture.Add(provenance); return provenance; @@ -263,7 +263,7 @@ private static LoadedTranslationModel LoadModel(string modelDir) { GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL, InterOpNumThreads = 1, - IntraOpNumThreads = Environment.ProcessorCount + IntraOpNumThreads = Environment.ProcessorCount, }; var encoder = new InferenceSession( @@ -299,7 +299,7 @@ private static string RunInference(LoadedTranslationModel model, string text) using var encoderResults = model.Encoder.Run([ NamedOnnxValue.CreateFromTensor("input_ids", inputIdsTensor), - NamedOnnxValue.CreateFromTensor("attention_mask", attentionMask) + NamedOnnxValue.CreateFromTensor("attention_mask", attentionMask), ]); var encoderHidden = @@ -321,7 +321,7 @@ encoderResults[0].Value as DenseTensor { NamedOnnxValue.CreateFromTensor("input_ids", decoderInputIds), NamedOnnxValue.CreateFromTensor("encoder_attention_mask", attentionMask), - NamedOnnxValue.CreateFromTensor("encoder_hidden_states", encoderHidden) + NamedOnnxValue.CreateFromTensor("encoder_hidden_states", encoderHidden), }; using var decoderResults = model.Decoder.Run(decoderInputs); @@ -383,7 +383,7 @@ private static void RegisterOnnxRuntimeResolver() var rid = RuntimeInformation.ProcessArchitecture switch { Architecture.Arm64 => "linux-arm64", - _ => "linux-x64" + _ => "linux-x64", }; var candidate = Path.Join( diff --git a/src/TypeWhisper.Linux/Services/TrayIconService.cs b/src/TypeWhisper.Linux/Services/TrayIconService.cs index 5acab0070..a04e53725 100644 --- a/src/TypeWhisper.Linux/Services/TrayIconService.cs +++ b/src/TypeWhisper.Linux/Services/TrayIconService.cs @@ -58,7 +58,7 @@ public void Initialize() { _trayIcon = new TrayIcon { - ToolTipText = "TypeWhisper", IsVisible = true, Menu = BuildMenu(), Icon = LoadIcon() + ToolTipText = "TypeWhisper", IsVisible = true, Menu = BuildMenu(), Icon = LoadIcon(), }; _trayIcon.Clicked += (_, _) => ShowSettingsRequested?.Invoke(this, EventArgs.Empty); @@ -110,7 +110,7 @@ internal bool ProbeTrayAvailable() "--method", "org.freedesktop.DBus.Properties.Get", "org.kde.StatusNotifierWatcher", - "IsStatusNotifierHostRegistered" + "IsStatusNotifierHostRegistered", ], timeout: TimeSpan.FromSeconds(2) ) diff --git a/src/TypeWhisper.Linux/Services/UpdateCheckService.cs b/src/TypeWhisper.Linux/Services/UpdateCheckService.cs index 06e932f76..d030c73dc 100644 --- a/src/TypeWhisper.Linux/Services/UpdateCheckService.cs +++ b/src/TypeWhisper.Linux/Services/UpdateCheckService.cs @@ -101,7 +101,7 @@ public async Task CheckOnStartupAsync(CancellationToken cancellationToken = defa LatestVersion = known, ReleaseUrl = string.IsNullOrWhiteSpace(_prefs.Current.LastKnownLatestUrl) ? ReleasesPage - : _prefs.Current.LastKnownLatestUrl + : _prefs.Current.LastKnownLatestUrl, } ); } @@ -140,7 +140,7 @@ public async Task CheckAsync(CancellationToken cancellationTo Checked = true, Faulted = true, CurrentVersion = current, - Error = "No published release was found." + Error = "No published release was found.", }; } else @@ -151,7 +151,7 @@ public async Task CheckAsync(CancellationToken cancellationTo UpdateAvailable = AppVersion.Compare(current, latest) < 0, CurrentVersion = current, LatestVersion = latest, - ReleaseUrl = string.IsNullOrWhiteSpace(latestUrl) ? ReleasesPage : latestUrl + ReleaseUrl = string.IsNullOrWhiteSpace(latestUrl) ? ReleasesPage : latestUrl, }; } } @@ -166,7 +166,7 @@ public async Task CheckAsync(CancellationToken cancellationTo Debug.WriteLine($"[UpdateCheckService] Check failed: {ex.Message}"); result = new UpdateCheckResult { - Checked = true, Faulted = true, CurrentVersion = current, Error = ex.Message + Checked = true, Faulted = true, CurrentVersion = current, Error = ex.Message, }; } @@ -180,7 +180,7 @@ preferences with { LastUpdateCheckUtc = DateTime.UtcNow, LastKnownLatestVersion = result.LatestVersion, - LastKnownLatestUrl = result.ReleaseUrl + LastKnownLatestUrl = result.ReleaseUrl, } ); } From ccc517800607834428857ee35b55f033020b40f8 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 3 Aug 2026 11:50:00 -0400 Subject: [PATCH 204/226] Harden shutdown, rollback and teardown paths from the QA review - AtSpiEventClient: serialize disposal with startup under a new lifecycle lock so a start racing Dispose cannot republish IsRunning over a torn-down connection or leak the connection it just built; stop disposing _startGate (fire-and-forget reconciles can be mid-wait at shutdown); fold the three duplicated teardown blocks into one TearDownConnection - DictationOrchestrator: isolate each step of the session-locked start rollback so a throw cannot skip coordinator detachment and report the dictation finished while buffered audio keeps flowing - StreamingTranscriptionCoordinator: defer sessionCts disposal until an abandoned finalize task settles, so the plugin sees cancellation rather than ObjectDisposed - TargetAppCorrectionLearningService: disarm the superseded arm when no focused element is found, matching every sibling abort path - SettingsBackupService: enforce the manifest cap on decompressed bytes instead of the zip's declared length - BrowserAccessibilitySetupHelper: accept single-quoted accessibility.force_disabled so an already-effective user.js is not rewritten - DictationSectionViewModel: apply a confirmed bridge toggle directly instead of relying on a follow-up read that applies nothing on a bus timeout - YdotoolSetupHelper: share the udev rule line between the file content and the install script's grep - Tests: stale auto-hide generation guard, ResetState held-key reset, positive EnsureIsolated case; isolated temp dir and real disposal in the notification tests; Volatile on cross-thread fakes; atomic RegisterCount --- .../Services/ActiveWindow/AtSpiEventClient.cs | 138 ++++++++++++------ .../ActiveWindow/IAtSpiEventClient.cs | 3 +- .../BrowserAccessibilitySetupHelper.cs | 6 +- .../Services/DictationOrchestrator.cs | 43 +++++- .../Hotkey/Evdev/InputAccessSetupHelper.cs | 3 + .../Services/Insertion/YdotoolSetupHelper.cs | 15 +- .../Services/SettingsBackupService.cs | 29 +++- .../StreamingTranscriptionCoordinator.cs | 23 ++- .../TargetAppCorrectionLearningService.cs | 3 + .../TypeWhisper.Linux.csproj | 5 +- .../Sections/DictationSectionViewModel.cs | 11 ++ .../EvdevGlobalShortcutBackendTests.cs | 27 +++- ...earnedCorrectionsFeedbackPresenterTests.cs | 45 +++++- ...rnedCorrectionsNotificationServiceTests.cs | 17 ++- .../ShortcutDispatcherTests.cs | 21 +++ .../TypeWhisper.Linux.Tests/TestPathsTests.cs | 9 ++ .../TestShortcutBackend.cs | 8 +- 17 files changed, 328 insertions(+), 78 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs index 48447a612..fe2595e59 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -219,6 +219,12 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable private readonly Lock _focusLock = new(); private readonly SemaphoreSlim _startGate = new(1, 1); + // Guards the {_disposed, _started, IsRunning} triple and the connection/subscription fields. + // Dispose is synchronous and must not block on _startGate (a shutdown landing mid-connect would + // stall on the bus), so it cannot serialize with a start that way; this lock is what keeps a + // start that raced disposal from republishing IsRunning = true over the torn-down connection. + private readonly Lock _lifecycleLock = new(); + // Guards _textChangedRefCount and _textChangedRegistered. A dedicated lock (not _focusLock) so // an acquire/release from a commit or paste path never contends with the focus-event fast path // on the dispatch thread. @@ -288,26 +294,60 @@ public AtSpiEventClient(IErrorLogService errorLog) public void Dispose() { - if (_disposed) + lock (_lifecycleLock) { - return; + if (_disposed) + { + return; + } + + _disposed = true; + // Consumers that must not start the listeners themselves gate on IsRunning; leaving it + // true after disposal would send them at a torn-down connection. + _started = false; + IsRunning = false; } - _disposed = true; + TearDownConnection(); + + // _startGate is deliberately NOT disposed: fire-and-forget reconciles and arms can still be + // mid-wait at shutdown, and a disposed semaphore would fault their WaitAsync or their + // finally's Release. SemaphoreSlim needs disposal only when its AvailableWaitHandle is used + // — same rationale as TargetAppCorrectionLearningService's listen gate. + } + + // The single teardown point, so the startup-failure, stop, disposal and + // disposed-while-connecting paths can't drift apart. Detaches under the lock and disposes + // outside it, so racing callers can't double-dispose or run bus teardown while holding it. + private void TearDownConnection() + { + IDisposable? stateSubscription; + IDisposable? textSubscription; + IDisposable? registryOwnerSubscription; + DBusConnection? connection; + lock (_lifecycleLock) + { + stateSubscription = _stateSubscription; + textSubscription = _textSubscription; + registryOwnerSubscription = _registryOwnerSubscription; + connection = _connection; + _stateSubscription = null; + _textSubscription = null; + _registryOwnerSubscription = null; + _connection = null; + } try { - _stateSubscription?.Dispose(); - _textSubscription?.Dispose(); - _registryOwnerSubscription?.Dispose(); - _connection?.Dispose(); + stateSubscription?.Dispose(); + textSubscription?.Dispose(); + registryOwnerSubscription?.Dispose(); + connection?.Dispose(); } catch { // best effort — teardown of a dying bus connection must not throw. } - - _startGate.Dispose(); } public event Action? FocusChanged; @@ -336,6 +376,12 @@ public IReadOnlyList GetRecentFocusedElements() public async Task EnsureStartedAsync() { + // Fast path only — the authoritative check happens under the gate below. + if (_disposed) + { + return false; + } + if (_started) { return IsRunning; @@ -344,18 +390,39 @@ public async Task EnsureStartedAsync() await _startGate.WaitAsync().ConfigureAwait(false); try { + // Dispose can land during the wait; connecting after its teardown would build a + // connection nothing ever closes. + if (_disposed) + { + return false; + } + if (_started) { return IsRunning; } var started = await TryStartAsync().ConfigureAwait(false); - IsRunning = started; - // Only cache success. On failure TryStartAsync has already torn down any partial - // connection, so leaving _started false lets a later call retry (e.g. the a11y bus - // became available, or a transient connect error cleared). - _started = started; - return started; + + // TryStartAsync awaits, so Dispose may have swept past this brand-new connection while + // it was being built. Test and publish together, or this overwrites its cleared state. + lock (_lifecycleLock) + { + if (!_disposed) + { + IsRunning = started; + // Only cache success. On failure TryStartAsync has already torn down any + // partial connection, so leaving _started false lets a later call retry + // (e.g. the a11y bus became available, or a transient connect error cleared). + _started = started; + return started; + } + } + + // Disposed while connecting: drop what we just built. Idempotent, so it's safe even + // when Dispose's own teardown already claimed it. + TearDownConnection(); + return false; } finally { @@ -532,25 +599,17 @@ private static async Task DeregisterTextChangedAsync(DBusConnection conn) public async Task StopAsync() { + // Dispose already tore the connection down; a late reconcile or observer-error reset has + // nothing left to stop. + if (_disposed) + { + return; + } + await _startGate.WaitAsync().ConfigureAwait(false); try { - try - { - _stateSubscription?.Dispose(); - _textSubscription?.Dispose(); - _registryOwnerSubscription?.Dispose(); - _connection?.Dispose(); - } - catch - { - // best effort — teardown of a dying bus connection must not throw. - } - - _stateSubscription = null; - _textSubscription = null; - _registryOwnerSubscription = null; - _connection = null; + TearDownConnection(); // Reset so the next EnsureStartedAsync reconnects fresh rather than returning // the stale cached availability. _started = false; @@ -1322,22 +1381,7 @@ private async Task TryStartAsync() // A connection may have been made before AddMatchAsync/RegisterEventAsync threw. // Tear down any partial state so we don't leak a live connection/match, and so the // next EnsureStartedAsync retries from a clean slate. - try - { - _stateSubscription?.Dispose(); - _textSubscription?.Dispose(); - _registryOwnerSubscription?.Dispose(); - _connection?.Dispose(); - } - catch - { - // best effort — teardown of a half-open connection must not throw. - } - - _stateSubscription = null; - _textSubscription = null; - _registryOwnerSubscription = null; - _connection = null; + TearDownConnection(); return false; } } diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs index 476efa138..26293443b 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/IAtSpiEventClient.cs @@ -58,7 +58,8 @@ public interface IAtSpiEventClient /// Connects to the a11y bus and registers event listeners on first call. /// Returns true when the bus is reachable and listeners are live, /// false when AT-SPI is unavailable (headless/minimal/remote sessions). - /// Idempotent — subsequent calls return the cached availability. + /// Idempotent — only a successful start is cached; a failed attempt leaves the + /// client able to retry, so a later call reconnects once the bus becomes available. /// Task EnsureStartedAsync(); diff --git a/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs b/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs index d57556476..0392f5c0c 100644 --- a/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs @@ -1124,8 +1124,10 @@ bool FirefoxProfileFound } // accessibility.force_disabled = -1 pref line, matched per-line across full user.js content - // (Multiline so the line is recognized even when our attribution comment precedes it). - [GeneratedRegex("""^\s*user_pref\(\s*"accessibility\.force_disabled"\s*,\s*-1\s*\)\s*;""", RegexOptions.Multiline)] + // (Multiline so the line is recognized even when our attribution comment precedes it). Accepts + // either quote style, like ForceDisabledAnyValueLineRegex: Firefox's pref parser takes both, so + // a single-quoted user-authored -1 is already effective and must not be rewritten/preserved. + [GeneratedRegex("""^\s*user_pref\(\s*(?["'])accessibility\.force_disabled\k\s*,\s*-1\s*\)\s*;""", RegexOptions.Multiline)] private static partial Regex ForceDisabledNegOneMultilineRegex(); // Any live accessibility.force_disabled line, captured verbatim (minus its diff --git a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs index 707fdb726..c384cc1a5 100644 --- a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs +++ b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs @@ -873,8 +873,27 @@ rematch.Profile is not null if (!_sessionActivityMonitor.IsInputAllowed) { Trace.WriteLine("[Dictation] Session locked during start; rolling back recording."); - RollBackStartedRecording(); - _ = await StopPartialTranscriptionSessionAsync(); + + // Every step is isolated (as in Dispose): a throw from one must not skip the rest. + // Clearing the session id with a coordinator still attached would report the + // dictation finished while buffered audio kept flowing behind the lock screen. + try + { + RollBackStartedRecording(); + } + catch (Exception ex) + { + Trace.WriteLine($"[Dictation] Recording rollback on lock failed: {ex.Message}"); + } + + try + { + _ = await StopPartialTranscriptionSessionAsync(); + } + catch (Exception ex) + { + Trace.WriteLine($"[Dictation] Partial-loop stop on lock failed: {ex.Message}"); + } StreamingTranscriptionCoordinator? rolledBackCoordinator; CancellationTokenSource? rolledBackStartupCts; @@ -893,12 +912,20 @@ rematch.Profile is not null } _audio.LiveFrameSink = null; - _ = await TeardownStreamingSessionAsync( - rolledBackCoordinator, - rolledBackStartupCts, - false, - CancellationToken.None - ); + + try + { + _ = await TeardownStreamingSessionAsync( + rolledBackCoordinator, + rolledBackStartupCts, + false, + CancellationToken.None + ); + } + catch (Exception ex) + { + Trace.WriteLine($"[Dictation] Streaming teardown on lock failed: {ex.Message}"); + } _hotkey.IsCancelShortcutEnabled = false; _activeDictationCts?.Dispose(); diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs index 774d3d393..b73f01e76 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs @@ -223,6 +223,9 @@ private static string BuildPrivilegedInstallScript() + $" exit {UdevRuleConflictExitCode}\n" + " fi\n" + "fi\n" + // Deliberately NO mkdir -p: a missing rules.d means no systemd-udev, so the udevadm + // calls below would fail anyway, and aborting on the redirect keeps this + // all-or-nothing instead of leaving a root-owned rule behind after a reported failure. + "cat > \"$udev_path\" <<'EOF'\n" + UdevRuleContent + "EOF\n" diff --git a/src/TypeWhisper.Linux/Services/Insertion/YdotoolSetupHelper.cs b/src/TypeWhisper.Linux/Services/Insertion/YdotoolSetupHelper.cs index 8d100d2fe..cf4e3e4c9 100644 --- a/src/TypeWhisper.Linux/Services/Insertion/YdotoolSetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/Insertion/YdotoolSetupHelper.cs @@ -46,6 +46,11 @@ public sealed partial class YdotoolSetupHelper private const string ModulesLoadSymlinkToken = "TYPEWHISPER_MODULES_LOAD_SYMLINK"; private const string UdevRuleSymlinkToken = "TYPEWHISPER_UDEV_RULE_SYMLINK"; + // Shared with BuildPrivilegedInstallScript, which greps for this exact line to detect a foreign + // rules file that already grants the access; a copy there would silently stop matching. + private const string UdevRuleLine = + "KERNEL==\"uinput\", TAG+=\"uaccess\", GROUP=\"input\", MODE=\"0660\", OPTIONS+=\"static_node=uinput\""; + private const string UdevRuleContent = "# " + OwnershipMarker @@ -56,7 +61,8 @@ public sealed partial class YdotoolSetupHelper + "# active seat read/write without group membership or logout.\n" + "# The GROUP=\"input\" fallback covers init systems without\n" + "# logind (Devuan, Alpine without elogind, etc.).\n" - + "KERNEL==\"uinput\", TAG+=\"uaccess\", GROUP=\"input\", MODE=\"0660\", OPTIONS+=\"static_node=uinput\"\n"; + + UdevRuleLine + + "\n"; // The udev rule above can only grant access to a device whose kernel // module is actually loaded. Distros like Arch / Omarchy do NOT auto-load @@ -744,14 +750,17 @@ private static string BuildPrivilegedInstallScript() + $" exit {UdevRuleConflictExitCode}\n" + " elif first=$(head -n 1 \"$udev_path\") && case \"$first\" in \"$marker\"|\"$marker \"*) true;; *) false;; esac; then\n" + " udev_action=write\n" - + " elif grep -Fqx 'KERNEL==\"uinput\", TAG+=\"uaccess\", GROUP=\"input\", MODE=\"0660\", OPTIONS+=\"static_node=uinput\"' \"$udev_path\"; then\n" + + $" elif grep -Fqx '{UdevRuleLine}' \"$udev_path\"; then\n" + " udev_action=skip # Foreign file already contains the required rule; preserve it.\n" + " else\n" + $" echo '{UdevRuleConflictToken}' >&2\n" + $" exit {UdevRuleConflictExitCode}\n" + " fi\n" + "fi\n" - // --- Both targets validated; apply the recorded decisions. + // --- Both targets validated; apply the recorded decisions. Deliberately NO mkdir -p: + // a missing directory means no systemd-udev, so udevadm below would fail anyway, and + // aborting on the redirect keeps this all-or-nothing instead of reporting failure + // with a root-owned rule left behind. + "if [ \"$modules_action\" = write ]; then\n" + " cat > \"$modules_path\" <<'EOF'\n" + ModulesLoadContent diff --git a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs index e872c6db7..66cdfb522 100644 --- a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs +++ b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs @@ -337,14 +337,20 @@ private static void ValidateManifest(ZipArchiveEntry manifestEntry) BackupManifest? manifest; try { + // The declared Length above is only the zip's own claim; the deflate stream can expand + // far past it, so enforce the cap on the bytes actually read before deserializing. using var stream = manifestEntry.Open(); - manifest = JsonSerializer.Deserialize(stream); + using var bounded = ReadBounded(stream, MaxManifestBytes); + manifest = JsonSerializer.Deserialize(bounded); } catch (Exception ex) when (ex is JsonException or IOException or InvalidDataException) { throw new InvalidDataException(Loc.Instance["About.BackupInvalidManifest"], ex); } + // The exact Includes/Excludes match is the manifest's only cross-version gate (there is no + // schema version): it stops an older build restoring a newer archive over live data whose + // per-file schemas it can't read. Don't relax it without adding a real version field. if ( manifest is null || !string.Equals(manifest.App, ManifestApp, StringComparison.Ordinal) @@ -360,6 +366,27 @@ manifest is null } } + // Copies at most maxBytes from source, throwing once a byte beyond the cap arrives. + private static MemoryStream ReadBounded(Stream source, long maxBytes) + { + var buffer = new MemoryStream(); + var chunk = new byte[8192]; + int read; + while ((read = source.Read(chunk, 0, chunk.Length)) > 0) + { + if (buffer.Length + read > maxBytes) + { + buffer.Dispose(); + throw new InvalidDataException(Loc.Instance["About.BackupInvalidManifest"]); + } + + buffer.Write(chunk, 0, read); + } + + buffer.Position = 0; + return buffer; + } + private static bool IsAllowedEntry(string entryName, bool isDirectory) { if (!isDirectory && s_rootFiles.Contains(entryName, StringComparer.Ordinal)) diff --git a/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs b/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs index 4fa46061a..d139e1c78 100644 --- a/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs +++ b/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs @@ -385,7 +385,7 @@ void RecordSessionFinalizeTimeout(Exception? innerException = null) if (session is not null) { - using var sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct); Task? sessionFinalizeTask = null; try { @@ -465,6 +465,27 @@ void RecordSessionFinalizeTimeout(Exception? innerException = null) Trace.WriteLine($"[StreamingCoordinator] FinalizeAsync session fault: {ex.Message}"); sessionFinalizeFault = ex; } + finally + { + // Both abandonment paths (deadline win, caller cancel) leave the finalize task + // running with this token. Disposing now would turn its next Register / + // Task.Delay(token) into an ObjectDisposedException instead of the cancellation we + // just requested, so defer until it settles. + if (sessionFinalizeTask is null || sessionFinalizeTask.IsCompleted) + { + sessionCts.Dispose(); + } + else + { + _ = sessionFinalizeTask.ContinueWith( + static (_, state) => ((CancellationTokenSource)state!).Dispose(), + sessionCts, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + } } // Grace window: wait for FinalizeGraceQuietMs of silence after the latest final so diff --git a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs index 0cbcaee6c..32c982738 100644 --- a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs +++ b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs @@ -286,6 +286,9 @@ public async Task ArmAsync(string insertedText) LogSkipOnce( "No focused element found on the accessibility bus; correction learning skipped this dictation." ); + // Like every other abort below: this arm supersedes the previous one, so drop its + // state (and its text-changed lease) instead of leaving it tracking a stale field. + DisarmIfCurrent(armSequence); return; } diff --git a/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj b/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj index 118ef7845..a3c6f0f6d 100644 --- a/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj +++ b/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj @@ -59,7 +59,10 @@ 12.0.1 depends on and resolves at startup. Newer releases (e.g. 0.94.2) change the Tmds.DBus.Protocol types Avalonia loads during X11/IME init, so a higher pin makes Avalonia throw TypeLoadException before the window - ever appears. --> + ever appears. + MAINTENANCE: recheck this pin after every Avalonia.FreeDesktop upgrade — it + must track whatever version that package depends on. A stale pin fails at + runtime (blank launch), not at build time. --> diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs index 4c6608186..f40b8ca88 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs @@ -1443,6 +1443,17 @@ private async Task ToggleAccessibilityBridgeAsync(bool enable) } } + if (ok) + { + // A confirmed write is authoritative — the follow-up read applies nothing when it + // returns null (bus timeout), leaving Setup/Remove on the pre-toggle state after a + // toggle that succeeded. Claiming the newest generation stops a slower refresh undoing it. + _accessibilityBridgeAppliedGeneration = ++_accessibilityBridgeRefreshGeneration; + _accessibilityBridgeStateKnown = true; + AccessibilityBridgeActivated = enable; + OnPropertyChanged(nameof(ShowAccessibilityBridgeSetup)); + } + await RefreshAccessibilityBridgeStateAsync(); OnPropertyChanged(nameof(ShowAccessibilityBridgeRemove)); AccessibilityBridgeStatus = ok diff --git a/tests/TypeWhisper.Linux.Tests/EvdevGlobalShortcutBackendTests.cs b/tests/TypeWhisper.Linux.Tests/EvdevGlobalShortcutBackendTests.cs index 951db5044..8cfa0a511 100644 --- a/tests/TypeWhisper.Linux.Tests/EvdevGlobalShortcutBackendTests.cs +++ b/tests/TypeWhisper.Linux.Tests/EvdevGlobalShortcutBackendTests.cs @@ -196,9 +196,17 @@ private static GlobalShortcutSet DefaultShortcuts() private static async Task WaitUntilAsync(Func predicate) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - while (!predicate()) + try { - await Task.Delay(10, timeout.Token); + while (!predicate()) + { + await Task.Delay(10, timeout.Token); + } + } + catch (OperationCanceledException) + { + // A bare "task was canceled" says nothing about which condition never held. + Assert.Fail("Timed out after 2s waiting for the predicate to become true."); } } @@ -206,7 +214,11 @@ private sealed class FakeSessionActivityMonitor(bool inputAllowed) : ISessionAct { private EventHandler? _inputAllowedChanged; - public bool IsInputAllowed { get; private set; } = inputAllowed; + // Written by the test thread, read by the backend's reader threads and WaitUntilAsync's + // polling loop, so both sides go through Volatile. + private bool _isInputAllowed = inputAllowed; + + public bool IsInputAllowed => Volatile.Read(ref _isInputAllowed); public int SubscriberCount => _inputAllowedChanged?.GetInvocationList().Length ?? 0; @@ -233,7 +245,7 @@ public void SetInputAllowed(bool allowed) return; } - IsInputAllowed = allowed; + Volatile.Write(ref _isInputAllowed, allowed); _inputAllowedChanged?.Invoke(this, EventArgs.Empty); } } @@ -333,8 +345,11 @@ Action onFailure private sealed class FakeReader(string path, Action onKeyEvent) : IEvdevDeviceReader { + // Set on whichever thread tears the backend down, polled from the test thread. + private bool _isDisposed; + public string Path { get; } = path; - public bool IsDisposed { get; private set; } + public bool IsDisposed => Volatile.Read(ref _isDisposed); public bool TryStart() { @@ -343,7 +358,7 @@ public bool TryStart() public ValueTask DisposeAsync() { - IsDisposed = true; + Volatile.Write(ref _isDisposed, true); return ValueTask.CompletedTask; } diff --git a/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs b/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs index c38598d47..a1985ce25 100644 --- a/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs @@ -2,6 +2,7 @@ using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; using TypeWhisper.Linux.Services; +using TypeWhisper.Linux.Services.Localization; using Xunit; namespace TypeWhisper.Linux.Tests; @@ -105,7 +106,9 @@ public void Undo_RemovesExactBatchAndShowsConfirmation() Assert.False(presenter.HasPendingBatch); var confirmation = Assert.Single(emitted); Assert.False(confirmation.ShowUndo); - Assert.Equal("Correction learning undone.", confirmation.Text); + // Catalog, not the English copy: still fails on a wrong key (which resolves to itself) + // without breaking on a wording edit. + Assert.Equal(Loc.Instance["Feedback.CorrectionLearningUndone"], confirmation.Text); Assert.Equal(TimeSpan.FromSeconds(2), scheduler.LastDelay); } @@ -158,6 +161,46 @@ public void ReArm_CancelsPreviousAutoHide() Assert.False(presenter.HasPendingBatch); } + [Fact] + public void StaleAutoHide_AfterReArm_LeavesFreshBatchVisible() + { + // FakeScheduler models disposal as cancellation, so it can't reproduce the real hazard: in + // production the superseded callback is already queued when the re-arm disposes its handle, + // and disposal can't retract it. Invoke the raw callbacks to exercise _feedbackGeneration. + var dictionary = new Mock(); + var callbacks = new List(); + var presenter = new LearnedCorrectionsFeedbackPresenter( + dictionary.Object, + (_, callback) => + { + callbacks.Add(callback); + return new NoopHandle(); + }); + var emitted = new List(); + presenter.FeedbackChanged += emitted.Add; + + presenter.ShowLearned([Correction("1", "a", "A")]); + presenter.ShowLearned([Correction("2", "b", "B")]); + emitted.Clear(); + + callbacks[0](); + + // The stale hide belongs to the superseded generation: the fresh batch and its Undo stay. + Assert.True(presenter.HasPendingBatch); + Assert.Empty(emitted); + + callbacks[1](); + Assert.False(presenter.HasPendingBatch); + Assert.Equal(string.Empty, Assert.Single(emitted).Text); + } + + private sealed class NoopHandle : IDisposable + { + public void Dispose() + { + } + } + [Fact] public void Reset_ClearsPendingSilentlyWithoutEmitting() { diff --git a/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsNotificationServiceTests.cs b/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsNotificationServiceTests.cs index cb4b1dfb6..87c628813 100644 --- a/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsNotificationServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsNotificationServiceTests.cs @@ -4,6 +4,7 @@ using TypeWhisper.Core.Services; using TypeWhisper.Linux.Services; using TypeWhisper.Linux.Services.ActiveWindow; +using TypeWhisper.Tests; using Xunit; namespace TypeWhisper.Linux.Tests; @@ -34,6 +35,10 @@ public sealed class LearnedCorrectionsNotificationServiceTests : IDisposable private readonly string _dictionaryPath; private readonly string _tempDir; + // Every service CreateSut hands out, so teardown exercises its disposal path (unsubscribe + + // channel close) instead of leaving it wired to the test's learning service. + private readonly List _services = []; + public LearnedCorrectionsNotificationServiceTests() { // Clear the other detector signals, then set only the Hyprland one, so every test's @@ -44,16 +49,17 @@ public LearnedCorrectionsNotificationServiceTests() } Environment.SetEnvironmentVariable(HyprlandSignatureEnv, "test-session"); - _tempDir = Path.Join( - Path.GetTempPath(), - "TypeWhisper.LearnedNotify.Tests_" + Guid.NewGuid().ToString("N") - ); - Directory.CreateDirectory(_tempDir); + _tempDir = TestPaths.CreateTempDirectory("TypeWhisper.LearnedNotify.Tests"); _dictionaryPath = Path.Join(_tempDir, "dictionary.json"); } public void Dispose() { + foreach (var service in _services) + { + service.Dispose(); + } + foreach (var (name, value) in _originalDetectorEnv) { Environment.SetEnvironmentVariable(name, value); @@ -201,6 +207,7 @@ public void Disabled_OnDesktopEnvironment_IsFullyInert() post: action => action(), scheduleDelay: scheduler.Schedule ); + _services.Add(service); return (learning, dictionary, channel, scheduler, service); } diff --git a/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs b/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs index 79b7c5d48..ea56963fb 100644 --- a/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs @@ -182,6 +182,27 @@ public void ResetState_AfterToggleRecordingStarted_FiresDiscard() Assert.Equal(1, discard); } + [Fact] + public void ResetState_ClearsHeldKeyBookkeeping() + { + // The lock closed the fd while the key was held, so no release ever arrives. ResetState + // must clear the held-key state too — otherwise the next press reads as OS auto-repeat and + // is suppressed, leaving the hotkey dead until the user presses twice. + var d = new ShortcutDispatcher(); + d.UpdateShortcuts(Set(RecordingMode.Toggle)); + var toggle = 0; + var discard = 0; + d.DictationToggleRequested += () => toggle++; + d.DictationDiscardRequested += () => discard++; + + d.Handle(KeyCode.VcSpace, ModifierMask.LeftCtrl | ModifierMask.LeftShift, true); + d.ResetState(); + d.Handle(KeyCode.VcSpace, ModifierMask.LeftCtrl | ModifierMask.LeftShift, true); + + Assert.Equal(2, toggle); + Assert.Equal(1, discard); + } + [Fact] public void OsAutoRepeat_DoesNotDoubleFire() { diff --git a/tests/TypeWhisper.Linux.Tests/TestPathsTests.cs b/tests/TypeWhisper.Linux.Tests/TestPathsTests.cs index ea7612af3..abf63abcd 100644 --- a/tests/TypeWhisper.Linux.Tests/TestPathsTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TestPathsTests.cs @@ -21,4 +21,13 @@ public void EnsureIsolated_RejectsPathUnderProductionRoot() TestPaths.EnsureIsolated(Path.Join(TypeWhisperEnvironment.BasePath, "Audio")) ); } + + [Fact] + public void EnsureIsolated_AcceptsPathOutsideProductionRoot() + { + // The guard is worthless if it also rejects the temp paths every test actually uses. + var path = Path.Join(Path.GetTempPath(), $"TypeWhisper.Isolation-{Guid.NewGuid():N}"); + + Assert.Equal(Path.GetFullPath(path), TestPaths.EnsureIsolated(path)); + } } diff --git a/tests/TypeWhisper.Linux.Tests/TestShortcutBackend.cs b/tests/TypeWhisper.Linux.Tests/TestShortcutBackend.cs index ef8d9c198..71be90e2a 100644 --- a/tests/TypeWhisper.Linux.Tests/TestShortcutBackend.cs +++ b/tests/TypeWhisper.Linux.Tests/TestShortcutBackend.cs @@ -7,6 +7,7 @@ internal sealed class TestShortcutBackend : IGlobalShortcutBackend { private readonly TaskCompletionSource _gate = new(); private int _pending; + private int _registerCount; public GlobalShortcutRegistrationResult NextResult { get; init; } = new( @@ -17,7 +18,10 @@ internal sealed class TestShortcutBackend : IGlobalShortcutBackend null ); - public int RegisterCount { get; private set; } + // HotkeyService applies through chained thread-pool continuations, so the increment must be + // atomic and the read must not see a stale cached value. + public int RegisterCount => Volatile.Read(ref _registerCount); + public GlobalShortcutSet? LastSet { get; private set; } public bool Disposed { get; private set; } @@ -131,7 +135,7 @@ CancellationToken ct { _gate.TrySetResult(); Interlocked.Increment(ref _pending); - RegisterCount++; + Interlocked.Increment(ref _registerCount); LastSet = shortcuts; Interlocked.Decrement(ref _pending); return Task.FromResult(NextResult); From c985cbd011de1206eb5fd6ef725d4f3735e565ed Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 3 Aug 2026 12:27:46 -0400 Subject: [PATCH 205/226] Harden plugin state and atomic writes from review feedback - GemmaLocal: guard SelectedModelId with a leaf lock shared by SelectModel and the load commit, so a model switch landing mid-load can no longer be overwritten by the stale load's own persisted selection. - OpenAiCompatible: survive persisted nulls in additionalProfiles. A null record, baseUrl, or fetchedModels entry threw an NRE inside ActivateAsync, failing activation with no route back through the UI. - AtomicFileWrite: retry via File.Replace when a concurrent writer creates the destination after the existence check, so a replaceExisting write stays unconditional; stop filtering the post-link cleanup catch, which could fail an already-published write. - Gemini, SmallestAi: persist the API key before assigning it in memory. - RecentTranscriptions: unrecognized insertion results now report failure instead of "Done." - Obsidian: trailing separators no longer leave a vault label empty. - SherpaOnnx: correct the migration doc's legacy path. Adds regression tests for the persisted-null profiles and for streaming cancellation, the latter bounded by a watchdog independent of the token under test. --- .../TypeWhisper.Plugin.Gemini/GeminiPlugin.cs | 7 +- .../GemmaLocalPlugin.cs | 30 +++-- .../ObsidianPlugin.cs | 12 +- .../OpenAiCompatiblePlugin.cs | 20 +++- .../SherpaOnnxPlugin.cs | 2 +- .../SmallestAiPlugin.cs | 10 +- .../Services/AtomicFileWrite.cs | 45 ++++++-- .../Services/RecentTranscriptionsService.cs | 11 +- .../OpenAiCompatiblePluginTests.cs | 108 ++++++++++++++++++ 9 files changed, 207 insertions(+), 38 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs b/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs index 257f25371..fead8ddec 100644 --- a/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs @@ -131,16 +131,17 @@ public void SetLocalization(IPluginLocalization localization) => internal async Task SetApiKeyAsync(string apiKey) { var trimmed = apiKey.Trim(); - ApiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed; + // Persist first: a failed write must not leave a key in memory that won't survive restart. if (_host is not null) { if (string.IsNullOrEmpty(trimmed)) await _host.DeleteSecretAsync("api-key"); else await _host.StoreSecretAsync("api-key", trimmed); - - _host.NotifyCapabilitiesChanged(); } + + ApiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed; + _host?.NotifyCapabilitiesChanged(); } internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken ct = default) diff --git a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs index da960eb1a..76bdae362 100644 --- a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs @@ -48,6 +48,10 @@ public sealed class GemmaLocalPlugin : ILlmProviderPlugin, IPluginSettingsProvid private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromHours(2) }; private readonly SemaphoreSlim _inferenceLock = new(1, 1); + + // Guards SelectedModelId only: _inferenceLock is held across the multi-second native load, so + // it can't also serialize selection without freezing the settings UI. Never held across await. + private readonly Lock _selectionLock = new(); private IPluginHostServices? _host; private LLamaWeights? _weights; private LLamaContext? _context; @@ -216,7 +220,11 @@ public async Task SetSettingValueAsync( await _inferenceLock.WaitAsync(ct).ConfigureAwait(false); try { - SelectedModelId = null; + lock (_selectionLock) + { + SelectedModelId = null; + } + _host?.SetSetting("selectedModel", string.Empty); UnloadModel(); } @@ -389,7 +397,11 @@ public void SetLocalization(IPluginLocalization localization) => internal void SelectModel(string modelId) { _ = GetModelDefinition(modelId); - SelectedModelId = modelId; + lock (_selectionLock) + { + SelectedModelId = modelId; + } + _host?.SetSetting("selectedModel", modelId); _host?.NotifyCapabilitiesChanged(); } @@ -554,16 +566,18 @@ internal Task LoadModelAsync(string modelId, CancellationToken ct) // so the user can switch selections while we're loading. If // that happened, drop what we just loaded instead of letting // the late finish silently roll back their newer choice. - if (SelectedModelId != modelId) + lock (_selectionLock) + { + loaded = SelectedModelId == modelId; + if (loaded) + LoadedModelId = modelId; + } + + if (!loaded) { UnloadModel(); return; } - - LoadedModelId = modelId; - SelectedModelId = modelId; - _host?.SetSetting("selectedModel", modelId); - loaded = true; } finally { diff --git a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs index 16161fa63..f6ed4fa8d 100644 --- a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs @@ -240,8 +240,16 @@ internal static List DetectVaults() // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (!string.IsNullOrEmpty(path) && Directory.Exists(path)) { - var name = Path.GetFileName(path); - vaults.Add(new ObsidianVaultInfo(name, path)); + // A trailing separator would make GetFileName return "". + var name = Path.GetFileName( + path.TrimEnd( + Path.DirectorySeparatorChar, + Path.AltDirectorySeparatorChar + ) + ); + vaults.Add( + new ObsidianVaultInfo(string.IsNullOrEmpty(name) ? path : name, path) + ); } } } diff --git a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs index a1c7c4709..92cb6e252 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs @@ -851,19 +851,31 @@ private async Task LoadAdditionalProfilesAsync(IPluginHostServices host) _additionalProfiles.Clear(); _additionalApiKeys.Clear(); - var stored = host.GetSetting>(AdditionalProfilesSettingKey) ?? []; + // Nullable elements deliberately: the persisted JSON is user-editable and the deserializer + // ignores the declared types, so nulls reach us — and an NRE here fails activation with no + // way back through the UI. + var stored = host.GetSetting>(AdditionalProfilesSettingKey) ?? []; var seen = new HashSet(StringComparer.Ordinal); foreach (var profile in stored) { + if (profile is null) + continue; + profile.Id = NormalizeProfileId(profile.Id, seen); profile.Name = string.IsNullOrWhiteSpace(profile.Name) ? "Custom Server" : profile.Name.Trim(); - profile.BaseUrl = NormalizeBaseUrl(profile.BaseUrl); + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract -- the annotation states the C# contract; the deserializer that produced this value ignores it. + profile.BaseUrl = NormalizeBaseUrl(profile.BaseUrl ?? ""); + profile.SelectedModelId = NullIfWhiteSpace(profile.SelectedModelId); profile.SelectedLlmModelId = NullIfWhiteSpace(profile.SelectedLlmModelId); - profile.FetchedModels = profile.FetchedModels - .Where(m => !string.IsNullOrWhiteSpace(m.Id)) + + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract -- same reason as BaseUrl above. + IEnumerable fetched = profile.FetchedModels ?? []; + profile.FetchedModels = fetched + .Where(m => !string.IsNullOrWhiteSpace(m?.Id)) + .Select(m => m!) .ToList(); _additionalProfiles.Add(profile); diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs index a994b6b5d..0a1cb9523 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs @@ -928,7 +928,7 @@ private static float[] DecodeWav(byte[] wavData) /// /// One-shot migration from the pre-plugin layout - /// (%LocalAppData%/TypeWhisper/s_models/) into the per-plugin data + /// (%LocalAppData%/TypeWhisper/Models/) into the per-plugin data /// directory. Best-effort: failures are logged and a stale source /// directory is left alone rather than blocking activation. /// diff --git a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs index 80c71416b..ea2c74b39 100644 --- a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs @@ -145,17 +145,19 @@ internal async Task SetApiKeyAsync(string apiKey) var wasConfigured = IsConfigured; var changed = !string.Equals(ApiKey, normalized, StringComparison.Ordinal); - ApiKey = normalized; + // Persist first: a failed write must not leave a key in memory that won't survive restart. if (_host is not null) { if (normalized is null) await _host.DeleteSecretAsync(ApiKeySecretName); else await _host.StoreSecretAsync(ApiKeySecretName, normalized); - - if (changed && wasConfigured != IsConfigured) - hostToNotify = _host; } + + ApiKey = normalized; + + if (_host is not null && changed && wasConfigured != IsConfigured) + hostToNotify = _host; } finally { diff --git a/src/TypeWhisper.Core/Services/AtomicFileWrite.cs b/src/TypeWhisper.Core/Services/AtomicFileWrite.cs index ded872e85..426fc9515 100644 --- a/src/TypeWhisper.Core/Services/AtomicFileWrite.cs +++ b/src/TypeWhisper.Core/Services/AtomicFileWrite.cs @@ -46,9 +46,9 @@ private static void PublishCreateNew(string tempPath, string path) { File.Delete(tempPath); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch { - // Best-effort: an extra hard link is harmless, the content is already published. + // Best-effort and deliberately unfiltered: an extra hard link is harmless. } return; @@ -65,6 +65,36 @@ private static void PublishCreateNew(string tempPath, string path) File.Move(tempPath, path); } + /// + /// Publishes a temporary file over , existing or not. + /// + private static void PublishReplace(string tempPath, string path) + { + if (!File.Exists(path)) + { + try + { + PublishCreateNew(tempPath, path); + return; + } + catch (IOException) when (File.Exists(path)) + { + // A concurrent writer created the destination between the check and the link. + // Replacement is unconditional here, so fall through rather than surfacing the + // create-new path's "already exists" failure. + } + } + + // File.Replace brings the temp file's inode (and mode) into the destination, so copy the + // destination's mode over first to preserve its permissions. + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(tempPath, File.GetUnixFileMode(path)); + } + + File.Replace(tempPath, path, null); + } + public static void WriteAllText(string path, string contents) { WriteCore(path, replaceExisting: true, tempPath => File.WriteAllText(tempPath, contents)); @@ -133,16 +163,9 @@ Action writeTemporaryFile writeTemporaryFile(tempPath); } - if (replaceExisting && File.Exists(path)) + if (replaceExisting) { - // File.Replace brings the temp file's inode (and mode) into the destination, so - // copy the destination's mode over first to preserve its permissions. - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(tempPath, File.GetUnixFileMode(path)); - } - - File.Replace(tempPath, path, null); + PublishReplace(tempPath, path); } else { diff --git a/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs b/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs index c8b64e796..6ea48621d 100644 --- a/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs +++ b/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs @@ -131,10 +131,12 @@ private async Task InsertEntryAsync(RecentTranscriptionEntry entry, string? targ private static bool IsError(InsertionResult result) { + // Inverted so an unrecognized result reports failure instead of claiming the text landed. return result - is InsertionResult.Failed - or InsertionResult.MissingClipboardTool - or InsertionResult.MissingPasteTool; + is not (InsertionResult.Typed + or InsertionResult.Pasted + or InsertionResult.CopiedToClipboard + or InsertionResult.NoText); } private string StatusTextFor(InsertionResult result) @@ -147,8 +149,7 @@ private string StatusTextFor(InsertionResult result) InsertionResult.NoText => Localization.Loc.Instance["Overlay.NoRecentTranscriptions"], InsertionResult.MissingClipboardTool => ClipboardToolMissingMessage(), InsertionResult.MissingPasteTool => _commands.GetSnapshot().PasteToolInstallHint, - InsertionResult.Failed => "Text insertion failed.", - _ => "Done.", + _ => "Text insertion failed.", }; } diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiCompatiblePluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiCompatiblePluginTests.cs index b451c97c1..6a614137f 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenAiCompatiblePluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiCompatiblePluginTests.cs @@ -272,6 +272,114 @@ await sut.SetItemsAsync( Assert.Equal(["Hel", "lo"], chunks); } + [Fact] + public async Task ActivateAsync_PersistedProfilesContainNulls_SkipsThemAndKeepsValidOnes() + { + // Hand-edited or partially-written settings can carry nulls the declared types forbid. + var host = new TestPluginHostServices(); + host.SetSetting( + "additionalProfiles", + JsonSerializer.Deserialize( + """ + [ + null, + {"id":"profile-a","name":"A","baseUrl":null,"fetchedModels":null}, + {"id":"profile-b","name":"B","baseUrl":"http://localhost:11434", + "fetchedModels":[null,{"id":"m1","ownedBy":null},{"id":" "}]} + ] + """)); + using var httpClient = ModelsClient(); + var sut = new OpenAiCompatiblePlugin(httpClient); + + await sut.ActivateAsync(host); + + var roles = sut.AdditionalLlmProviders; + Assert.Equal(2, roles.Count); + Assert.Equal(["A", "B"], roles.Select(r => r.ProviderName)); + // Only the null base URL was unusable, so only that profile is unconfigured. + Assert.Equal([false, true], roles.Select(r => r.IsAvailable || r.SupportedModels.Count > 0)); + Assert.Equal(["m1"], roles[1].SupportedModels.Select(m => m.Id)); + } + + [Fact] + public async Task ProcessStreamingAsync_TokenCancelledMidStream_StopsConsumingResponse() + { + // The token reaches the enumerator as a plain parameter rather than through + // WithCancellation, so pin that it still interrupts an unfinished stream. + using var cts = new CancellationTokenSource(); + var handler = new CapturingHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent( + new StalledSseStream("data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n")), + }); + + var host = new TestPluginHostServices(); + host.SetSetting("baseUrl", "http://localhost:11434"); + host.SetSetting("selectedLlmModel", "llama3"); + using var httpClient = new HttpClient(handler); + var sut = new OpenAiCompatiblePlugin(httpClient); + await sut.ActivateAsync(host); + + var chunks = new List(); + var consume = Assert.ThrowsAnyAsync(async () => + { + await foreach (var chunk in sut.ProcessStreamingAsync("sys", "user", "llama3", cts.Token)) + { + chunks.Add(chunk); + await cts.CancelAsync(); + } + }); + + // Bounded independently of the token under test, so a propagation regression fails here + // instead of hanging the run. + // ReSharper disable once MethodSupportsCancellation -- the cancellation-aware overload takes the token under test, the one dependency this bound must not have. + await consume.WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal(["Hel"], chunks); + } + + /// Serves one SSE frame, then stalls like a server still generating tokens. + private sealed class StalledSseStream(string firstFrame) : Stream + { + private readonly byte[] _frame = Encoding.UTF8.GetBytes(firstFrame); + private int _offset; + + public override async ValueTask ReadAsync( + Memory buffer, CancellationToken cancellationToken = default) + { + if (_offset < _frame.Length) + { + var count = Math.Min(buffer.Length, _frame.Length - _offset); + _frame.AsSpan(_offset, count).CopyTo(buffer.Span); + _offset += count; + return count; + } + + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + } + private sealed class CapturingHandler(Func responder) : HttpMessageHandler { From 7dc6f20f658c45272632fc53cdf49bdfd794b659 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 3 Aug 2026 12:31:31 -0400 Subject: [PATCH 206/226] Correct the _lifecycleLock comment to match what it actually guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed the lock guards the connection/subscription fields, but TryStartAsync assigns them without holding it. Behavior is unchanged and already correct — starts serialize on _startGate, and anything published after a disposal is swept by EnsureStartedAsync's post-start _disposed re-check — so document the exception rather than widen the lock (those assignments are await results, which System.Threading.Lock cannot span anyway). --- .../Services/ActiveWindow/AtSpiEventClient.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs index fe2595e59..4d0d6608a 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -219,10 +219,10 @@ public sealed class AtSpiEventClient : IAtSpiEventClient, IDisposable private readonly Lock _focusLock = new(); private readonly SemaphoreSlim _startGate = new(1, 1); - // Guards the {_disposed, _started, IsRunning} triple and the connection/subscription fields. - // Dispose is synchronous and must not block on _startGate (a shutdown landing mid-connect would - // stall on the bus), so it cannot serialize with a start that way; this lock is what keeps a - // start that raced disposal from republishing IsRunning = true over the torn-down connection. + // Guards the {_disposed, _started, IsRunning} triple and TearDownConnection's snapshot-and-null + // of the connection/subscription fields. TryStartAsync writes them outside it: starts serialize + // on _startGate, and a post-disposal publish is swept by EnsureStartedAsync's _disposed + // re-check. Dispose can't use _startGate — it is synchronous and would stall mid-connect. private readonly Lock _lifecycleLock = new(); // Guards _textChangedRefCount and _textChangedRegistered. A dedicated lock (not _focusLock) so From 6c18bfea6c23c8f7513bebd723ca8d65e1570396 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 3 Aug 2026 13:07:13 -0400 Subject: [PATCH 207/226] Address review findings across hotkeys, IPC, and preferences Fixes verified against the code from a QA batch plus four Codex review passes on the working tree. Hotkeys: - ClearShortcuts now drops every press-time guard, so the first press of a rebound key is no longer swallowed after unregister/re-register. - Pending selection workflows are keyed by full workflow identity, so two bindings sharing a key under different modifiers both dispatch; one physical press still claims only one workflow across auto-repeat. - Keep the app-owned cancel key when the native PushToTalk trigger ends in Escape (the desktop spec derives no cancel bind there), except when the trigger is the bare Escape cancel chord itself. - Serialize fixed-binding check-and-set with dynamic reconciliation under one lock, and stop blanking the published dynamic lists mid-rebuild. IPC and processes: - Create the control-socket directory owner-only in the mkdir itself. - Kill and reap the child on any post-Start failure, not just cancellation. - Drain admitted handlers before disposing the HTTP request semaphore. Preferences and services: - Raise ProfilesChanged outside the profile gate and serialize delivery; App reconciliation now reads and applies both snapshots under one lock. - Synchronize LinuxPreferencesService.Load, and handle write failures at every caller, rolling the close-to-tray toggle back when a write fails. - Guard media pausing on its own flag so an un-resumable player can no longer disable pausing, and bound resume retries. Localization: - Add the 19 keys missing from de/es/ru with real translations. Also documents the single managed-block invariant for the Hyprland/Sway writers, the JSON request-size contract, and the "starting" control-protocol state. Solution-wide ReSharper inspections remain at zero. --- .../Services/ProfileService.cs | 35 ++- src/TypeWhisper.Linux/App.axaml.cs | 16 +- src/TypeWhisper.Linux/Program.cs | 1 + .../Resources/Localization/de.json | 19 ++ .../Resources/Localization/es.json | 19 ++ .../Resources/Localization/ru.json | 19 ++ .../DeSetup/DictationShortcutSpecFactory.cs | 9 +- .../Hotkey/DeSetup/GnomeShortcutWriter.cs | 18 +- .../Hotkey/DeSetup/HyprlandShortcutWriter.cs | 2 + .../Hotkey/DeSetup/IDeShortcutWriter.cs | 3 + .../Hotkey/DeSetup/SwayShortcutWriter.cs | 2 + .../Services/Hotkey/ShortcutDispatcher.cs | 85 +++--- .../Services/HotkeyService.cs | 255 +++++++++++------- .../Services/HttpApiService.cs | 38 ++- .../Services/Ipc/ControlSocketOwnership.cs | 5 +- .../Services/Ipc/ControlSocketServer.cs | 1 + .../Services/Ipc/JsonControlProtocol.cs | 10 +- .../Services/Ipc/SocketPathResolver.cs | 11 +- .../Services/LinuxPreferencesService.cs | 11 +- .../Services/MediaPauseService.cs | 104 +++++-- .../Services/ProcessRunner.cs | 12 +- .../Services/UpdateCheckService.cs | 40 ++- .../Sections/GeneralSectionViewModel.cs | 18 +- .../Sections/ShortcutsSectionViewModel.cs | 5 + .../AudioRecordingServiceTests.cs | 11 +- .../ControlSocketOwnershipTests.cs | 7 + .../EvdevDeviceReaderTests.cs | 6 +- .../GlobalHotkeySetupTaskTests.cs | 7 +- .../HotkeyServiceTests.cs | 45 ++++ .../HttpApiRequestDispatcherTests.cs | 24 ++ .../LocalizationResourcesTests.cs | 21 ++ .../MediaPauseServiceTests.cs | 28 ++ .../ShortcutDispatcherTests.cs | 100 +++++++ .../SoundFeedbackServiceTests.cs | 10 +- .../SpeechFeedbackServiceTests.cs | 5 +- .../TextInsertionServiceTests.cs | 1 + .../WatchFolderServiceTests.cs | 12 +- 37 files changed, 807 insertions(+), 208 deletions(-) diff --git a/src/TypeWhisper.Core/Services/ProfileService.cs b/src/TypeWhisper.Core/Services/ProfileService.cs index 054b0df36..6c9474162 100644 --- a/src/TypeWhisper.Core/Services/ProfileService.cs +++ b/src/TypeWhisper.Core/Services/ProfileService.cs @@ -15,6 +15,9 @@ public sealed class ProfileService : IProfileService private readonly Action _atomicWrite; private readonly string _filePath; private readonly Lock _gate = new(); + + // Serializes notification delivery; never taken while _gate is held. + private readonly Lock _notifyGate = new(); private List _cache = []; private bool _cacheLoaded; @@ -61,6 +64,8 @@ public void SeedFirstRunDefaultsIfMissing() var newCache = new List(_cache) { FirstRunDefaults.CreateAutoFormatProfile() }; CommitLocked(newCache); } + + NotifyProfilesChanged(); } public void AddProfile(Profile profile) @@ -71,6 +76,8 @@ public void AddProfile(Profile profile) var newCache = new List(_cache) { profile }; CommitLocked(newCache); } + + NotifyProfilesChanged(); } public void UpdateProfile(Profile profile) @@ -89,6 +96,8 @@ public void UpdateProfile(Profile profile) newCache[idx] = updated; CommitLocked(newCache); } + + NotifyProfilesChanged(); } public void DeleteProfile(string id) @@ -100,10 +109,13 @@ public void DeleteProfile(string id) newCache.RemoveAll(p => p.Id == id); CommitLocked(newCache); } + + NotifyProfilesChanged(); } public Profile? ToggleProfileEnabled(string id) { + Profile updated; lock (_gate) { EnsureCacheLoadedLocked(); @@ -114,15 +126,17 @@ public void DeleteProfile(string id) return null; } - var updated = newCache[idx] with + updated = newCache[idx] with { IsEnabled = !newCache[idx].IsEnabled, UpdatedAt = DateTime.UtcNow }; newCache[idx] = updated; CommitLocked(newCache); - return updated; } + + NotifyProfilesChanged(); + return updated; } public MatchResult MatchProfile( @@ -331,9 +345,22 @@ private void CommitLocked(List newCache) { SortList(newCache); // Persist before swapping _cache so a save failure can't leave a published-but-unsaved - // cache; on throw _cache stays on its prior committed list and ProfilesChanged never fires. + // cache; on throw _cache keeps its prior list and the caller never reaches its notify. SaveToDisk(newCache); _cache = newCache; - ProfilesChanged?.Invoke(); + } + + /// + /// Raised outside _gate because subscribers run arbitrary code and re-enter this + /// service, and serialized on _notifyGate so two callbacks can't interleave. + /// Subscribers re-read , so whichever runs last still sees the + /// newest list. + /// + private void NotifyProfilesChanged() + { + lock (_notifyGate) + { + ProfilesChanged?.Invoke(); + } } } diff --git a/src/TypeWhisper.Linux/App.axaml.cs b/src/TypeWhisper.Linux/App.axaml.cs index 2d72319f6..a573687d5 100644 --- a/src/TypeWhisper.Linux/App.axaml.cs +++ b/src/TypeWhisper.Linux/App.axaml.cs @@ -265,23 +265,21 @@ main.DataContext as MainWindowViewModel // ActionsChanged fires on the UI thread while ProfilesChanged can fire off the // HTTP worker thread (e.g. /v1/profiles/toggle), so the two subscriptions can enter - // this reconcile concurrently. Serialize the apply so the candidate lists are replaced - // atomically for each independently captured service-state snapshot. - // Never read gate-guarded service state (Profiles/Actions) while holding reconcileLock — snapshot first. - // ProfilesChanged/ActionsChanged fire under their service gates, so a read-under-reconcileLock inverts - // the lock order and deadlocks. + // this reconcile concurrently. Both services are read AND applied under reconcileLock + // so a callback that snapshots first and is then preempted can't overwrite a newer + // reconciliation with its stale lists. + // Lock order is reconcileLock -> service gate, one direction only: neither service + // raises its change event while holding its own gate. var reconcileLock = new object(); void ReconcileDynamicHotkeys() { - var actionsSnapshot = promptActions.Actions; - var profilesSnapshot = profileService.Profiles; IReadOnlyList rejections; lock (reconcileLock) { rejections = hotkey.SetDynamicHotkeys( - HotkeyService.ParsePromptActionHotkeys(actionsSnapshot), - HotkeyService.ParseProfileHotkeys(profilesSnapshot) + HotkeyService.ParsePromptActionHotkeys(promptActions.Actions), + HotkeyService.ParseProfileHotkeys(profileService.Profiles) ); } diff --git a/src/TypeWhisper.Linux/Program.cs b/src/TypeWhisper.Linux/Program.cs index c4e303d9f..b3937c9b8 100644 --- a/src/TypeWhisper.Linux/Program.cs +++ b/src/TypeWhisper.Linux/Program.cs @@ -130,6 +130,7 @@ public static int Main(string[] args) break; case StartupRestoreStatus.Applied: + Console.WriteLine("Applied the staged settings restore."); Trace.WriteLine("[Program] Applied the staged settings restore."); BootTrace.Stage("staged settings restore applied"); break; diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index f474cd8c5..efac56437 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -6,6 +6,7 @@ "About.BackupInvalid": "Diese Datei ist keine gültige TypeWhisper-Einstellungssicherung.", "About.BackupInvalidManifest": "Das Manifest der TypeWhisper-Sicherung ist ungültig oder nicht lesbar. Die Wiederherstellung wurde abgebrochen.", "About.BackupRestored": "Backup aus {0} Datei(en) wiederhergestellt. Einige wiederhergestellte Einstellungen erfordern möglicherweise einen Neustart der App.", + "About.BackupStaged": "Sicherung aus {0} Datei(en) geprüft und vorbereitet. Beenden Sie TypeWhisper und öffnen Sie es erneut, um sie anzuwenden.", "About.BackupStatusDefault": "Einstellungen, Profile, Textbausteine und Plugin-Daten sichern.", "About.BackupTooLarge": "Diese Sicherung entpackt weit mehr Daten, als eine Einstellungssicherung je enthält, und wurde möglicherweise manipuliert. Die Wiederherstellung wurde abgebrochen.", "About.BackupUnsafePath": "Diese Sicherung enthält einen unsicheren Pfad und wurde möglicherweise manipuliert: {0}", @@ -518,6 +519,9 @@ "Profiles.Enabled": "Aktiv", "Profiles.HotkeyBehaviorProcessSelectedText": "Markierten Text verarbeiten", "Profiles.HotkeyBehaviorStartDictation": "Diktat starten", + "Profiles.HotkeyCollision": "Dieses Tastenkürzel steht in Konflikt mit einem anderen aktivierten Kürzel.", + "Profiles.HotkeyMalformed": "Dieses Tastenkürzel konnte nicht gelesen werden. Versuchen Sie z.B. Ctrl+Alt+E oder Meta+F9.", + "Profiles.HotkeyPromptActionRequired": "Wählen Sie eine aktivierte Prompt-Aktion aus, bevor Sie ein Tastenkürzel für markierten Text zuweisen.", "Profiles.HotkeyWatermark": "z.B. Ctrl+Alt+E", "Profiles.InstallWindowCallsExtension": "Window Calls-Erweiterung installieren", "Profiles.Language": "Sprache", @@ -590,6 +594,8 @@ "Prompts.EmptyState": "Noch keine Prompts vorhanden.", "Prompts.Hint": "KI-Prompts für die Prompt-Palette. Text markieren + Hotkey = KI verarbeitet den Text.", "Prompts.Hotkey": "Hotkey", + "Prompts.HotkeyCollision": "Dieses Tastenkürzel steht in Konflikt mit einem anderen aktivierten Kürzel.", + "Prompts.HotkeyMalformed": "Dieses Tastenkürzel konnte nicht gelesen werden. Versuchen Sie z.B. Ctrl+Alt+R oder Meta+F9.", "Prompts.HotkeyPlaceholder": "z.B. Ctrl+Alt+R", "Prompts.InsertTextNormally": "Text normal einfügen", "Prompts.ManualOnly": "Nur manuell", @@ -659,6 +665,10 @@ "Setup.GlobalHotkeyAlreadyActive": "Globales Tastenkürzel bereits aktiv.", "Setup.GlobalHotkeyNeedsInputGroup": "Globales Tastenkürzel benötigt Tastaturzugriff.", "Setup.GlobalHotkeyNeedsInputGroupHint": "Unter Wayland liest der Hotkey die Tastatureingaben direkt, damit Hold-to-Talk funktioniert. Dies installiert eine kleine udev-Regel, die Ihrer aktuellen Sitzung Lesezugriff auf Tastaturgeräte gewährt – eine Admin-Abfrage, sofort wirksam, ohne Abmelden oder Neustart. Sie ist auf Tastaturen und Ihre Sitzung beschränkt und damit enger gefasst als die Mitgliedschaft in der „input“-Gruppe.", + "Setup.GlobalHotkeyOptedOut": "Kürzel nur im Fokus aktiv — das direkte Lesen der Tastatur ist unter „Tastenkürzel“ deaktiviert.", + "Setup.GlobalHotkeyOptedOutRuleInstalled": "Kürzel nur im Fokus aktiv, aber eine Tastaturzugriffsregel von vor der Deaktivierung ist weiterhin installiert.", + "Setup.GlobalHotkeyOptedOutRuleInstalledDetail": "TypeWhisper liest keine Tastaturereignisse mehr direkt, aber die udev-Regel, die Ihrer Sitzung den Zugriff gewährt hat, liegt weiterhin auf der Festplatte. Widerrufen Sie sie, um die Berechtigung vollständig zurückzunehmen.", + "Setup.GlobalHotkeyRevokeButton": "Tastaturzugriff widerrufen", "Setup.GlobalHotkeyReloginToActivate": "Einmal abmelden und wieder anmelden (oder neu starten), um das globale Tastenkürzel zu aktivieren.", "Setup.GlobalHotkeyTitle": "Globales Diktat-Tastenkürzel", "Setup.InstallFailed": "Installation fehlgeschlagen (Exit {0}).", @@ -688,6 +698,7 @@ "Shortcuts.ActivationModeHint": "Umschalten startet und stoppt bei wiederholtem Drücken. „Zum Sprechen halten“ nimmt auf, solange die Taste gedrückt wird. Hybrid startet sofort, nimmt nach kurzem Drücken weiter auf und stoppt beim Loslassen nach längerem Halten.", "Shortcuts.AlreadyRunningHint": "Wenn TypeWhisper bereits läuft, schaltet dieser Befehl das Diktat in der bestehenden Instanz um — Ihr Tastenkürzel startet keine zweite Kopie.", "Shortcuts.AutoSetupHint": "Schreibt das Diktat-Tastenkürzel direkt in die Einstellungen Ihres Desktops. Die Zeilen unten zeigen genau, was hinzugefügt wird — Ihre bestehenden Tastenkürzel bleiben erhalten.", + "Shortcuts.AutoSetupModeUnsupported": "{0} kann kein natives Tastenkürzel für den Modus {1} einrichten. Wechseln Sie zum Modus „Umschalten“ oder verwenden Sie stattdessen das integrierte Kürzel von TypeWhisper (Einstellungen → Tastenkürzel).", "Shortcuts.Backend": "Backend", "Shortcuts.BackendSwitchFailed": "Backend-Wechsel fehlgeschlagen: {0}", "Shortcuts.BindCustom": "Eigenes Tastenkürzel zuweisen", @@ -715,6 +726,9 @@ "Shortcuts.DesktopInstructionsMate": "Öffnen Sie Systemeinstellungen → Tastenkürzel → Hinzufügen.\nFügen Sie den obigen Befehl ein und weisen Sie eine Tastenkombination zu.", "Shortcuts.DesktopInstructionsSway": "Bearbeiten Sie ~/.config/sway/config und fügen Sie ein bindsym hinzu, z.B.:\n bindsym $mod+space exec typewhisper\nMit `swaymsg reload` neu laden.", "Shortcuts.DesktopInstructionsXfce": "Öffnen Sie Einstellungen → Tastatur → Anwendungskürzel → Hinzufügen.\nFügen Sie den obigen Befehl ein und wählen Sie auf Nachfrage die Tastenkombination.", + "Shortcuts.DesktopIntegrationStale": "⚠ Die Desktop-Integration für das Diktat ist veraltet", + "Shortcuts.DesktopIntegrationStaleHint": "Die Desktop-Integration verwendet noch ein älteres Tastenkürzel oder einen älteren Aktivierungsmodus. Das alte Desktop-Kürzel bleibt möglicherweise aktiv, bis Sie es aktualisieren oder entfernen.", + "Shortcuts.DesktopIntegrationStaleUnsupported": "Die Desktop-Integration verwendet noch ein älteres Tastenkürzel oder einen älteren Aktivierungsmodus. {0} kann sie für den Modus {1} nicht aktualisieren, und das alte Desktop-Kürzel bleibt möglicherweise aktiv. Wechseln Sie zu einem unterstützten Modus oder entfernen Sie die alte Integration.", "Shortcuts.DetectedDesktop": "Erkannter Desktop: {0}", "Shortcuts.Done": "Fertig.", "Shortcuts.EvdevNoKeyboardAccess": "Es ist noch keine Tastatur lesbar. Aktivieren Sie den Tastaturzugriff unter Einstellungen → Tastenkürzel (installiert eine udev-Regel, kein Neustart).", @@ -735,6 +749,10 @@ "Shortcuts.MainCapture": "Hauptaufnahme", "Shortcuts.MainHotkey": "Haupt-Hotkey für Diktat", "Shortcuts.MainHotkeyHint": "Globales Tastenkürzel, das das Diktat startet. Sein Verhalten hängt vom Aktivierungsmodus unten ab.", + "Shortcuts.NativeDictationInstallDeferred": "TypeWhisper verwaltet das Diktat weiter, bis ein späterer Start die Desktop-Bindung bestätigt.", + "Shortcuts.NativeDictationOwnershipActive": "Der Desktop verwaltet jetzt das Diktat-Tastenkürzel von TypeWhisper; alle übrigen App-Kürzel bleiben in TypeWhisper aktiv.", + "Shortcuts.NativeDictationRemovalActive": "TypeWhisper verwaltet sein Diktat-Tastenkürzel wieder selbst.", + "Shortcuts.NativeDictationRemovalDeferred": "Der Desktop besitzt das aktive Diktat-Kürzel möglicherweise bis zum Neuladen oder zur erneuten Anmeldung; der nächste Start gleicht das ab.", "Shortcuts.ModeHybridStatus": "Startet sofort. Kurzes Drücken nimmt weiter auf; Halten über ~600 ms stoppt beim Loslassen.", "Shortcuts.ModePushToTalkStatus": "Halten Sie den Hotkey zum Aufnehmen; loslassen zum Stoppen und Transkribieren.", "Shortcuts.ModeToggleStatus": "Hotkey drücken zum Starten, erneut drücken zum Stoppen.", @@ -753,6 +771,7 @@ "Shortcuts.RecentTranscriptionsHotkeySet": "Hotkey für letzte Transkriptionen auf {0} gesetzt.", "Shortcuts.RemovalFailed": "Entfernen fehlgeschlagen: {0}", "Shortcuts.RemovingShortcut": "Tastenkürzel wird aus {0} entfernt…", + "Shortcuts.RefreshDesktopIntegrationOn": "Desktop-Integration aktualisieren ({0})", "Shortcuts.ScopeFocusedOnly": "Nur fokussiert (TypeWhisper-Fenster)", "Shortcuts.ScopeGlobal": "Global (funktioniert in jedem fokussierten Fenster)", "Shortcuts.SetupAutomaticallyOn": "Automatisch einrichten ({0})", diff --git a/src/TypeWhisper.Linux/Resources/Localization/es.json b/src/TypeWhisper.Linux/Resources/Localization/es.json index 5a41d2aa8..d2a94998a 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -6,6 +6,7 @@ "About.BackupInvalid": "Este archivo no es una copia de seguridad válida de los ajustes de TypeWhisper.", "About.BackupInvalidManifest": "El manifiesto de la copia de seguridad de TypeWhisper no es válido o no se puede leer. Se canceló la restauración.", "About.BackupRestored": "Copia de seguridad restaurada desde {0} archivo(s). Es posible que algunos ajustes restaurados requieran reiniciar la aplicación.", + "About.BackupStaged": "Copia de seguridad validada y preparada desde {0} archivo(s). Cierra TypeWhisper y vuelve a abrirlo para aplicarla.", "About.BackupStatusDefault": "Haz una copia de seguridad de ajustes, perfiles, fragmentos y datos de plugins.", "About.BackupTooLarge": "Esta copia de seguridad se expande a muchos más datos de los que contiene una copia de ajustes y puede haber sido manipulada. Se canceló la restauración.", "About.BackupUnsafePath": "Esta copia de seguridad contiene una ruta no segura y puede haber sido manipulada: {0}", @@ -518,6 +519,9 @@ "Profiles.Enabled": "Activado", "Profiles.HotkeyBehaviorProcessSelectedText": "Procesar el texto seleccionado", "Profiles.HotkeyBehaviorStartDictation": "Iniciar dictado", + "Profiles.HotkeyCollision": "Este atajo entra en conflicto con otro atajo activado.", + "Profiles.HotkeyMalformed": "No se pudo interpretar este atajo. Prueba p. ej. Ctrl+Alt+E o Meta+F9.", + "Profiles.HotkeyPromptActionRequired": "Selecciona una acción de prompt activada antes de asignar un atajo para el texto seleccionado.", "Profiles.HotkeyWatermark": "p. ej. Ctrl+Alt+E", "Profiles.InstallWindowCallsExtension": "Instalar la extensión Window Calls", "Profiles.Language": "Idioma", @@ -590,6 +594,8 @@ "Prompts.EmptyState": "Aún no hay prompts.", "Prompts.Hint": "Prompts de IA para la Paleta de prompts. Seleccionar texto + atajo = la IA procesa el texto.", "Prompts.Hotkey": "Atajo", + "Prompts.HotkeyCollision": "Este atajo entra en conflicto con otro atajo activado.", + "Prompts.HotkeyMalformed": "No se pudo interpretar este atajo. Prueba p. ej. Ctrl+Alt+R o Meta+F9.", "Prompts.HotkeyPlaceholder": "p. ej. Ctrl+Alt+R", "Prompts.InsertTextNormally": "Insertar el texto normalmente", "Prompts.ManualOnly": "Solo manual", @@ -659,6 +665,10 @@ "Setup.GlobalHotkeyAlreadyActive": "El atajo global ya está activo.", "Setup.GlobalHotkeyNeedsInputGroup": "El atajo global necesita acceso al teclado.", "Setup.GlobalHotkeyNeedsInputGroupHint": "En Wayland el atajo lee la entrada del teclado directamente para poder mantener para hablar. Esto instala una pequeña regla de udev que concede a tu sesión actual acceso de lectura a los dispositivos de teclado: una sola solicitud de administrador, aplicada de inmediato, sin cerrar sesión ni reiniciar. Está limitada a teclados y a tu sesión, por lo que es más restringida que unirse al grupo 'input'.", + "Setup.GlobalHotkeyOptedOut": "Atajo solo con la ventana enfocada: la lectura directa del teclado está desactivada en Atajos.", + "Setup.GlobalHotkeyOptedOutRuleInstalled": "Atajo solo con la ventana enfocada, pero sigue instalada una regla de acceso al teclado anterior a la desactivación.", + "Setup.GlobalHotkeyOptedOutRuleInstalledDetail": "TypeWhisper ya no lee eventos de teclado directamente, pero la regla de udev que concedió el acceso a tu sesión sigue en el disco. Revócala para deshacer por completo el permiso.", + "Setup.GlobalHotkeyRevokeButton": "Revocar acceso al teclado", "Setup.GlobalHotkeyReloginToActivate": "Cierra sesión y vuelve a iniciarla (o reinicia) una vez para activar el atajo global.", "Setup.GlobalHotkeyTitle": "Atajo global de dictado", "Setup.InstallFailed": "La instalación falló (salida {0}).", @@ -688,6 +698,7 @@ "Shortcuts.ActivationModeHint": "Alternar inicia y detiene con pulsaciones repetidas. Pulsar para hablar graba mientras se mantiene. Híbrido inicia de inmediato, sigue grabando tras una pulsación corta y se detiene al soltar después de mantener un rato.", "Shortcuts.AlreadyRunningHint": "Cuando TypeWhisper ya está en ejecución, invocar este comando alterna el dictado en la instancia existente: tu atajo no abrirá una segunda copia.", "Shortcuts.AutoSetupHint": "Escribe el atajo de dictado directamente en los ajustes de tu escritorio. Las líneas de abajo muestran exactamente qué se añadirá: tus atajos existentes se conservan.", + "Shortcuts.AutoSetupModeUnsupported": "{0} no puede instalar un atajo nativo para el modo {1}. Cambia al modo Alternar o usa el atajo integrado de TypeWhisper (Ajustes → Atajos).", "Shortcuts.Backend": "Backend", "Shortcuts.BackendSwitchFailed": "El cambio de backend falló: {0}", "Shortcuts.BindCustom": "Asignar un atajo personalizado", @@ -715,6 +726,9 @@ "Shortcuts.DesktopInstructionsMate": "Abre Configuración del sistema → Atajos de teclado → Añadir.\nPega el comando de arriba y asígnale una combinación de teclas.", "Shortcuts.DesktopInstructionsSway": "Edita ~/.config/sway/config y añade un bindsym, p. ej.:\n bindsym $mod+space exec typewhisper\nRecarga con `swaymsg reload`.", "Shortcuts.DesktopInstructionsXfce": "Abre Ajustes → Teclado → Atajos de aplicación → Añadir.\nPega el comando de arriba y elige la combinación de teclas cuando se te solicite.", + "Shortcuts.DesktopIntegrationStale": "⚠ La integración de dictado con el escritorio está desactualizada", + "Shortcuts.DesktopIntegrationStaleHint": "La integración con el escritorio todavía tiene un atajo o un modo de activación anterior. El antiguo atajo del escritorio puede seguir activo hasta que lo actualices o lo elimines.", + "Shortcuts.DesktopIntegrationStaleUnsupported": "La integración con el escritorio todavía tiene un atajo o un modo de activación anterior. {0} no puede actualizarla para el modo {1} y el antiguo atajo del escritorio puede seguir activo. Cambia a un modo compatible o elimina la integración anterior.", "Shortcuts.DetectedDesktop": "Escritorio detectado: {0}", "Shortcuts.Done": "Listo.", "Shortcuts.EvdevNoKeyboardAccess": "Aún no se puede leer ningún teclado. Activa el acceso al teclado en Ajustes → Atajos (instala una regla de udev, sin reiniciar).", @@ -735,6 +749,10 @@ "Shortcuts.MainCapture": "Captura principal", "Shortcuts.MainHotkey": "Atajo principal de dictado", "Shortcuts.MainHotkeyHint": "Atajo global que inicia el dictado. Su comportamiento depende del modo de activación de abajo.", + "Shortcuts.NativeDictationInstallDeferred": "TypeWhisper seguirá gestionando el dictado hasta que un inicio posterior verifique la asignación del escritorio.", + "Shortcuts.NativeDictationOwnershipActive": "Ahora el escritorio gestiona el atajo de dictado de TypeWhisper; TypeWhisper mantiene activos todos los demás atajos de la aplicación.", + "Shortcuts.NativeDictationRemovalActive": "TypeWhisper vuelve a gestionar su atajo de dictado.", + "Shortcuts.NativeDictationRemovalDeferred": "Puede que el escritorio conserve el atajo de dictado activo hasta recargar o volver a iniciar sesión; el próximo inicio lo reconciliará.", "Shortcuts.ModeHybridStatus": "Inicia de inmediato. Una pulsación corta sigue grabando; mantener más de ~600 ms se detiene al soltar.", "Shortcuts.ModePushToTalkStatus": "Mantén el atajo para grabar; suelta para detener y transcribir.", "Shortcuts.ModeToggleStatus": "Pulsa el atajo para iniciar, pulsa de nuevo para detener.", @@ -753,6 +771,7 @@ "Shortcuts.RecentTranscriptionsHotkeySet": "Atajo de transcripciones recientes establecido en {0}.", "Shortcuts.RemovalFailed": "La eliminación falló: {0}", "Shortcuts.RemovingShortcut": "Eliminando el atajo de {0}…", + "Shortcuts.RefreshDesktopIntegrationOn": "Actualizar la integración con el escritorio ({0})", "Shortcuts.ScopeFocusedOnly": "Solo en foco (ventana de TypeWhisper)", "Shortcuts.ScopeGlobal": "Global (funciona en cualquier ventana en foco)", "Shortcuts.SetupAutomaticallyOn": "Configurar automáticamente ({0})", diff --git a/src/TypeWhisper.Linux/Resources/Localization/ru.json b/src/TypeWhisper.Linux/Resources/Localization/ru.json index 44c0eacfa..02c10b7b8 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -6,6 +6,7 @@ "About.BackupInvalid": "Этот файл не является допустимой резервной копией настроек TypeWhisper.", "About.BackupInvalidManifest": "Манифест резервной копии TypeWhisper повреждён или не читается. Восстановление отменено.", "About.BackupRestored": "Резервная копия восстановлена, файлов: {0}. Некоторые восстановленные настройки могут потребовать перезапуска приложения.", + "About.BackupStaged": "Резервная копия проверена и подготовлена, файлов: {0}. Закройте и снова откройте TypeWhisper, чтобы применить её.", "About.BackupStatusDefault": "Резервное копирование настроек, профилей, сниппетов и данных плагинов.", "About.BackupTooLarge": "Эта резервная копия распаковывается в гораздо больший объём данных, чем содержит копия настроек, и, возможно, была подменена. Восстановление отменено.", "About.BackupUnsafePath": "Эта резервная копия содержит небезопасный путь и, возможно, была подменена: {0}", @@ -518,6 +519,9 @@ "Profiles.Enabled": "Включено", "Profiles.HotkeyBehaviorProcessSelectedText": "Обработать выделенный текст", "Profiles.HotkeyBehaviorStartDictation": "Начать диктовку", + "Profiles.HotkeyCollision": "Это сочетание клавиш конфликтует с другим включённым сочетанием.", + "Profiles.HotkeyMalformed": "Не удалось разобрать это сочетание клавиш. Попробуйте, например, Ctrl+Alt+E или Meta+F9.", + "Profiles.HotkeyPromptActionRequired": "Выберите включённое действие промпта, прежде чем назначать сочетание для выделенного текста.", "Profiles.HotkeyWatermark": "например, Ctrl+Alt+E", "Profiles.InstallWindowCallsExtension": "Установить расширение Window Calls", "Profiles.Language": "Язык", @@ -590,6 +594,8 @@ "Prompts.EmptyState": "Пока нет промптов.", "Prompts.Hint": "ИИ-промпты для палитры промптов. Выделите текст + горячая клавиша = ИИ обрабатывает текст.", "Prompts.Hotkey": "Горячая клавиша", + "Prompts.HotkeyCollision": "Это сочетание клавиш конфликтует с другим включённым сочетанием.", + "Prompts.HotkeyMalformed": "Не удалось разобрать это сочетание клавиш. Попробуйте, например, Ctrl+Alt+R или Meta+F9.", "Prompts.HotkeyPlaceholder": "например, Ctrl+Alt+R", "Prompts.InsertTextNormally": "Вставлять текст обычным образом", "Prompts.ManualOnly": "Только вручную", @@ -659,6 +665,10 @@ "Setup.GlobalHotkeyAlreadyActive": "Глобальное сочетание уже активно.", "Setup.GlobalHotkeyNeedsInputGroup": "Глобальному сочетанию нужен доступ к клавиатуре.", "Setup.GlobalHotkeyNeedsInputGroupHint": "В Wayland горячая клавиша читает ввод с клавиатуры напрямую, чтобы работал режим удержания для речи. Это установит небольшое правило udev, которое предоставляет текущему сеансу доступ на чтение устройств клавиатуры — один запрос администратора, применяется сразу, без выхода из системы и перезагрузки. Оно ограничено клавиатурами и вашим сеансом, поэтому уже, чем членство в группе «input».", + "Setup.GlobalHotkeyOptedOut": "Сочетание работает только в фокусе — прямое чтение клавиатуры отключено в разделе «Сочетания клавиш».", + "Setup.GlobalHotkeyOptedOutRuleInstalled": "Сочетание работает только в фокусе, но правило доступа к клавиатуре, созданное до отключения, всё ещё установлено.", + "Setup.GlobalHotkeyOptedOutRuleInstalledDetail": "TypeWhisper больше не читает события клавиатуры напрямую, но правило udev, предоставившее доступ вашему сеансу, всё ещё есть на диске. Отзовите его, чтобы полностью убрать разрешение.", + "Setup.GlobalHotkeyRevokeButton": "Отозвать доступ к клавиатуре", "Setup.GlobalHotkeyReloginToActivate": "Один раз выйдите из системы и войдите снова (или перезагрузитесь), чтобы активировать глобальное сочетание.", "Setup.GlobalHotkeyTitle": "Глобальное сочетание для диктовки", "Setup.InstallFailed": "Не удалось установить (код выхода {0}).", @@ -688,6 +698,7 @@ "Shortcuts.ActivationModeHint": "«Переключение» запускает и останавливает повторными нажатиями. «Удерживать для речи» записывает, пока клавиша удерживается. «Гибрид» запускается сразу, продолжает запись после короткого нажатия и останавливается при отпускании после долгого удержания.", "Shortcuts.AlreadyRunningHint": "Когда TypeWhisper уже запущен, вызов этой команды переключает диктовку в существующем экземпляре — ваше сочетание не запустит вторую копию.", "Shortcuts.AutoSetupHint": "Записать сочетание для диктовки напрямую в настройки вашего рабочего стола. Строки ниже показывают, что именно будет добавлено — ваши существующие сочетания сохраняются.", + "Shortcuts.AutoSetupModeUnsupported": "{0} не может установить системное сочетание для режима «{1}». Переключитесь на режим «Переключение» или используйте встроенное сочетание TypeWhisper (Настройки → Сочетания клавиш).", "Shortcuts.Backend": "Бэкенд", "Shortcuts.BackendSwitchFailed": "Не удалось переключить бэкенд: {0}", "Shortcuts.BindCustom": "Назначить своё сочетание", @@ -715,6 +726,9 @@ "Shortcuts.DesktopInstructionsMate": "Откройте «Параметры системы» → «Комбинации клавиш» → «Добавить».\nВставьте команду выше и назначьте комбинацию клавиш.", "Shortcuts.DesktopInstructionsSway": "Отредактируйте ~/.config/sway/config и добавьте bindsym, например:\n bindsym $mod+space exec typewhisper\nПерезагрузите командой `swaymsg reload`.", "Shortcuts.DesktopInstructionsXfce": "Откройте «Настройки» → «Клавиатура» → «Сочетания приложений» → «Добавить».\nВставьте команду выше и выберите комбинацию клавиш по запросу.", + "Shortcuts.DesktopIntegrationStale": "⚠ Интеграция диктовки с рабочим столом устарела", + "Shortcuts.DesktopIntegrationStaleHint": "В интеграции с рабочим столом всё ещё старое сочетание клавиш или режим активации. Старое сочетание рабочего стола может оставаться активным, пока вы не обновите или не удалите его.", + "Shortcuts.DesktopIntegrationStaleUnsupported": "В интеграции с рабочим столом всё ещё старое сочетание клавиш или режим активации. {0} не может обновить её для режима «{1}», и старое сочетание рабочего стола может оставаться активным. Переключитесь на поддерживаемый режим или удалите старую интеграцию.", "Shortcuts.DetectedDesktop": "Обнаружен рабочий стол: {0}", "Shortcuts.Done": "Готово.", "Shortcuts.EvdevNoKeyboardAccess": "Пока не удаётся прочитать ни одну клавиатуру. Включите доступ к клавиатуре в Настройки → Сочетания (устанавливает правило udev, без перезагрузки).", @@ -735,6 +749,10 @@ "Shortcuts.MainCapture": "Основной захват", "Shortcuts.MainHotkey": "Основная горячая клавиша диктовки", "Shortcuts.MainHotkeyHint": "Глобальное сочетание, запускающее диктовку. Его поведение зависит от режима активации ниже.", + "Shortcuts.NativeDictationInstallDeferred": "TypeWhisper продолжит управлять диктовкой, пока следующий запуск не подтвердит привязку рабочего стола.", + "Shortcuts.NativeDictationOwnershipActive": "Теперь сочетанием для диктовки управляет рабочий стол; все остальные сочетания приложения остаются активными в TypeWhisper.", + "Shortcuts.NativeDictationRemovalActive": "TypeWhisper снова управляет своим сочетанием для диктовки.", + "Shortcuts.NativeDictationRemovalDeferred": "Рабочий стол может удерживать активное сочетание для диктовки до перезагрузки конфигурации или повторного входа; следующий запуск приведёт всё в соответствие.", "Shortcuts.ModeHybridStatus": "Запускается сразу. Короткое нажатие продолжает запись; удержание дольше ~600 мс останавливает при отпускании.", "Shortcuts.ModePushToTalkStatus": "Удерживайте горячую клавишу для записи; отпустите, чтобы остановить и транскрибировать.", "Shortcuts.ModeToggleStatus": "Нажмите горячую клавишу, чтобы начать, нажмите снова, чтобы остановить.", @@ -753,6 +771,7 @@ "Shortcuts.RecentTranscriptionsHotkeySet": "Сочетание недавних транскрипций задано: {0}.", "Shortcuts.RemovalFailed": "Не удалось удалить: {0}", "Shortcuts.RemovingShortcut": "Удаление сочетания из {0}…", + "Shortcuts.RefreshDesktopIntegrationOn": "Обновить интеграцию с рабочим столом ({0})", "Shortcuts.ScopeFocusedOnly": "Только активное (окно TypeWhisper)", "Shortcuts.ScopeGlobal": "Глобально (работает в любом активном окне)", "Shortcuts.SetupAutomaticallyOn": "Настроить автоматически ({0})", diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs index 9bebaa44d..acfd70d6b 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs @@ -85,8 +85,13 @@ private static string ResolveGuiCommand() return "Ctrl+Shift+Escape"; } - // Compare against the trigger rebuilt from the same parts so spacing and casing - // differences ("ctrl + shift + escape") can't hide a collision. + // Compare against the trigger rebuilt from the same parts so spacing, casing, and the + // "Esc" alias ("ctrl + shift + esc") can't hide a collision. + if (string.Equals(parts[^1], "Esc", StringComparison.OrdinalIgnoreCase)) + { + parts[^1] = "Escape"; + } + var normalizedTrigger = string.Join('+', parts); parts[^1] = "Escape"; var cancel = string.Join('+', parts); diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs index c53057f57..cef464f46 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs @@ -173,10 +173,19 @@ await RunAsync("gsettings", ["reset", schemaWithPath, key], ct) .ConfigureAwait(false); } + // Mirrors WriteAsync: report the backup only when one was actually written. + var removed = new List(); + if (mutation.BackupPath is not null) + { + removed.Add(mutation.BackupPath); + } + + removed.Add($"{MediaKeysSchema}.{ListKey}"); + return new DeShortcutWriteResult( true, "GNOME shortcut removed.", - [mutation.BackupPath!, $"{MediaKeysSchema}.{ListKey}"] + removed ); } @@ -589,12 +598,7 @@ private async Task IsManagedPathListedAsync(string path, CancellationToken return false; } - var (ok, listOut, _) = await RunAsync( - "gsettings", - ["get", MediaKeysSchema, ListKey], - ct - ) - .ConfigureAwait(false); + var (ok, listOut, _) = await ReadListAsync(ct).ConfigureAwait(false); if (!ok) { return false; diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs index 39a2867a3..7f73bf1a8 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs @@ -83,6 +83,8 @@ public async Task IsInstalledAsync(DeShortcutSpec spec, CancellationToken return inner is not null && inner.SequenceEqual(expected); } + // hyprland.conf holds one managed sentinel block carrying no shortcut id, so this answers + // for the only shortcut this writer installs. public async Task IsManagedShortcutPresentAsync( string shortcutId, CancellationToken ct diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/IDeShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/IDeShortcutWriter.cs index 091a4e78c..5e59e860d 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/IDeShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/IDeShortcutWriter.cs @@ -60,6 +60,9 @@ public interface IDeShortcutWriter /// , regardless of its current trigger or commands. Unlike /// , this detects stale managed entries. Never mutates; /// normal absence, malformed ownership markers, and read errors return false. + /// GNOME and KDE store one entry per id and scope the lookup to it. Hyprland and Sway + /// store a single unscoped sentinel block carrying no id, so they answer for the one + /// shortcut they can hold; scoping them would break already-installed blocks. /// Task IsManagedShortcutPresentAsync(string shortcutId, CancellationToken ct); diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs index 7e19e157b..fc0dc0dae 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs @@ -79,6 +79,8 @@ public async Task IsInstalledAsync(DeShortcutSpec spec, CancellationToken return inner is not null && inner.SequenceEqual(expected); } + // The sway config holds one managed sentinel block carrying no shortcut id, so this answers + // for the only shortcut this writer installs. public async Task IsManagedShortcutPresentAsync( string shortcutId, CancellationToken ct diff --git a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs index 3a2891957..c0906d37b 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs @@ -27,8 +27,10 @@ internal sealed class ShortcutDispatcher private readonly Dictionary _profileDictationKeyDown = new(); - private readonly Dictionary _pendingSelectionWorkflows = - new(); + // Keyed by the complete workflow identity, not the physical key alone: two workflows can share + // one key under different modifiers (Ctrl+Shift+P vs Ctrl+Alt+P), and a KeyCode key would let + // the first claim swallow the second. The value is the trigger-released flag. + private readonly Dictionary _pendingSelectionWorkflows = new(); private bool _cancelKeyDown; private bool _copyLastKeyDown; private (KeyCode Key, RecordingMode Mode, DateTime DownAt)? _mainDictationHeld; @@ -45,13 +47,17 @@ public void ClearShortcuts() { Volatile.Write(ref _shortcuts, null); - // Drop any release-gated selection workflow that was queued before the unregister. - // Handle ignores releases while the set is null, so a pending entry would otherwise - // survive into the next registration and either suppress the rebound key (stale TryAdd) - // or dispatch its pre-unregister payload against the current selection on release. + // Drop every press-time guard queued before the unregister. Handle ignores releases while + // the set is null, so surviving state would carry into the next registration and suppress + // the rebound keys — or dispatch a pending workflow's pre-unregister payload. lock (_lock) { _pendingSelectionWorkflows.Clear(); + _profileDictationKeyDown.Clear(); + _cancelKeyDown = false; + _copyLastKeyDown = false; + _mainDictationHeld = null; + _recentKeyDown = false; } } @@ -298,36 +304,44 @@ out profileBehavior private void HandleRelease(KeyCode key, ModifierMask mods, GlobalShortcutSet set) { - List? readySelectionWorkflows = null; + List? readySelectionWorkflows = null; lock (_lock) { - if (_pendingSelectionWorkflows.TryGetValue(key, out var releasedWorkflow)) + List? justReleased = null; + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- explicit loop keeps the Dictionary enumerator (no boxing) and reads clearly under _lock; the LINQ form would switch enumerators for no gain. + foreach (var (workflow, triggerReleased) in _pendingSelectionWorkflows) + { + if (!triggerReleased && workflow.TriggerKey == key) + { + (justReleased ??= []).Add(workflow); + } + } + + if (justReleased is not null) { - _pendingSelectionWorkflows[key] = releasedWorkflow with + foreach (var workflow in justReleased) { - TriggerReleased = true - }; + _pendingSelectionWorkflows[workflow] = true; + } } if (ShortcutMatcher.ModifiersMatch(mods, ModifierMask.None)) { - // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- explicit loop keeps the Dictionary.ValueCollection enumerator (no boxing) and reads clearly under _lock; the LINQ form would switch enumerators for no gain. - foreach (var pending in _pendingSelectionWorkflows.Values) + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- see above; the explicit loop keeps the non-boxing enumerator. + foreach (var (workflow, triggerReleased) in _pendingSelectionWorkflows) { - if (!pending.TriggerReleased) + if (triggerReleased) { - continue; + (readySelectionWorkflows ??= []).Add(workflow); } - - (readySelectionWorkflows ??= []).Add(pending); } if (readySelectionWorkflows is not null) { - foreach (var pending in readySelectionWorkflows) + foreach (var workflow in readySelectionWorkflows) { - _pendingSelectionWorkflows.Remove(pending.TriggerKey); + _pendingSelectionWorkflows.Remove(workflow); } } } @@ -354,9 +368,9 @@ set.CopyLastTranscriptionKey is not null if (readySelectionWorkflows is not null) { - foreach (var pending in readySelectionWorkflows) + foreach (var workflow in readySelectionWorkflows) { - DispatchSelectionWorkflow(pending); + DispatchSelectionWorkflow(workflow); } } @@ -453,28 +467,40 @@ private bool TryClaimSelectionWorkflow( { lock (_lock) { + // One physical press claims one workflow: dropping a modifier mid-hold makes the + // auto-repeat presses match a different binding on the same key, and the release would + // dispatch both. A genuine second press is allowed — the first entry is released by then. + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- explicit loop keeps the Dictionary enumerator (no boxing) and reads clearly under _lock. + foreach (var (workflow, triggerReleased) in _pendingSelectionWorkflows) + { + if (!triggerReleased && workflow.TriggerKey == key) + { + return false; + } + } + return _pendingSelectionWorkflows.TryAdd( - key, - new PendingSelectionWorkflow(key, kind, payload, false) + new SelectionWorkflowId(key, kind, payload), + false ); } } - private void DispatchSelectionWorkflow(PendingSelectionWorkflow pending) + private void DispatchSelectionWorkflow(SelectionWorkflowId workflow) { // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault -- all defined SelectionWorkflowKind values are handled; the default (out-of-range) branch is intentionally omitted. - switch (pending.Kind) + switch (workflow.Kind) { case SelectionWorkflowKind.PromptPalette: Raise(PromptPaletteRequested, nameof(PromptPaletteRequested)); break; case SelectionWorkflowKind.PromptAction: - RaisePromptAction(pending.Payload!); + RaisePromptAction(workflow.Payload!); break; case SelectionWorkflowKind.ProfileTextProcessing: RaiseProfile( ProfileTextProcessingRequested, - pending.Payload!, + workflow.Payload!, nameof(ProfileTextProcessingRequested) ); break; @@ -546,10 +572,9 @@ private enum SelectionWorkflowKind TransformSelection } - private readonly record struct PendingSelectionWorkflow( + private readonly record struct SelectionWorkflowId( KeyCode TriggerKey, SelectionWorkflowKind Kind, - string? Payload, - bool TriggerReleased + string? Payload ); } diff --git a/src/TypeWhisper.Linux/Services/HotkeyService.cs b/src/TypeWhisper.Linux/Services/HotkeyService.cs index 28f091564..155cae2ff 100644 --- a/src/TypeWhisper.Linux/Services/HotkeyService.cs +++ b/src/TypeWhisper.Linux/Services/HotkeyService.cs @@ -345,35 +345,47 @@ public void SetHotkey(KeyCode key, ModifierMask modifiers) // collisions), but the raw setter is reachable from tests and any // future direct caller. Silently no-op rather than throw so call // sites don't need try/catch. - if (HotkeyMatchesAny(key, modifiers, GetBoundHotkeys(HotkeyBinding.Dictation))) + // Check and assignment share one critical section with dynamic reconciliation, so a + // concurrent rebuild can't validate against bindings this setter is about to replace. + lock (_lock) { - Trace.WriteLine( - "[HotkeyService] Refusing dictation hotkey that collides with another shortcut." - ); - return; - } + if (HotkeyMatchesAny(key, modifiers, GetBoundHotkeys(HotkeyBinding.Dictation))) + { + Trace.WriteLine( + "[HotkeyService] Refusing dictation hotkey that collides with another shortcut." + ); + return; + } - _key = key; - _modifiers = modifiers; - PushShortcutsIfRunning(); + _key = key; + _modifiers = modifiers; + PushShortcutsIfRunning(); + } } public void SetPromptPaletteHotkey(KeyCode? key, ModifierMask modifiers) { - if ( - key is not null - && HotkeyMatchesAny(key.Value, modifiers, GetBoundHotkeys(HotkeyBinding.PromptPalette)) - ) + lock (_lock) { - Trace.WriteLine( - "[HotkeyService] Refusing prompt palette hotkey that collides with another shortcut." - ); - return; - } + if ( + key is not null + && HotkeyMatchesAny( + key.Value, + modifiers, + GetBoundHotkeys(HotkeyBinding.PromptPalette) + ) + ) + { + Trace.WriteLine( + "[HotkeyService] Refusing prompt palette hotkey that collides with another shortcut." + ); + return; + } - _promptPaletteKey = key; - _promptPaletteModifiers = key is null ? ModifierMask.None : modifiers; - PushShortcutsIfRunning(); + _promptPaletteKey = key; + _promptPaletteModifiers = key is null ? ModifierMask.None : modifiers; + PushShortcutsIfRunning(); + } } /// @@ -392,14 +404,18 @@ public bool TrySetHotkeyFromString(string text) // Don't let the dictation hotkey collide with another configured // binding — the matcher orders cancel/palette/etc. ahead of dictation - // so a collision would shadow this key. - if (HotkeyMatchesAny(key!.Value, modifiers, GetBoundHotkeys(HotkeyBinding.Dictation))) + // so a collision would shadow this key. SetHotkey re-checks under _lock, + // which is where the check-and-set is actually made atomic. + lock (_lock) { - return false; - } + if (HotkeyMatchesAny(key!.Value, modifiers, GetBoundHotkeys(HotkeyBinding.Dictation))) + { + return false; + } - SetHotkey(key.Value, modifiers); - return true; + SetHotkey(key.Value, modifiers); + return true; + } } public bool TrySetPromptPaletteHotkeyFromString(string? text) @@ -415,22 +431,35 @@ public bool TrySetPromptPaletteHotkeyFromString(string? text) return false; } - if (HotkeyMatchesAny(key!.Value, modifiers, GetBoundHotkeys(HotkeyBinding.PromptPalette))) + lock (_lock) { - return false; - } + if ( + HotkeyMatchesAny( + key!.Value, + modifiers, + GetBoundHotkeys(HotkeyBinding.PromptPalette) + ) + ) + { + return false; + } - SetPromptPaletteHotkey(key, modifiers); - return true; + SetPromptPaletteHotkey(key, modifiers); + return true; + } } public bool TrySetRecentTranscriptionsHotkeyFromString(string? text) { if (string.IsNullOrWhiteSpace(text)) { - _recentTranscriptionsKey = null; - _recentTranscriptionsModifiers = ModifierMask.None; - PushShortcutsIfRunning(); + lock (_lock) + { + _recentTranscriptionsKey = null; + _recentTranscriptionsModifiers = ModifierMask.None; + PushShortcutsIfRunning(); + } + return true; } @@ -439,30 +468,37 @@ public bool TrySetRecentTranscriptionsHotkeyFromString(string? text) return false; } - if ( - HotkeyMatchesAny( - key!.Value, - modifiers, - GetBoundHotkeys(HotkeyBinding.RecentTranscriptions) - ) - ) + lock (_lock) { - return false; - } + if ( + HotkeyMatchesAny( + key!.Value, + modifiers, + GetBoundHotkeys(HotkeyBinding.RecentTranscriptions) + ) + ) + { + return false; + } - _recentTranscriptionsKey = key; - _recentTranscriptionsModifiers = modifiers; - PushShortcutsIfRunning(); - return true; + _recentTranscriptionsKey = key; + _recentTranscriptionsModifiers = modifiers; + PushShortcutsIfRunning(); + return true; + } } public bool TrySetCopyLastTranscriptionHotkeyFromString(string? text) { if (string.IsNullOrWhiteSpace(text)) { - _copyLastTranscriptionKey = null; - _copyLastTranscriptionModifiers = ModifierMask.None; - PushShortcutsIfRunning(); + lock (_lock) + { + _copyLastTranscriptionKey = null; + _copyLastTranscriptionModifiers = ModifierMask.None; + PushShortcutsIfRunning(); + } + return true; } @@ -471,30 +507,37 @@ public bool TrySetCopyLastTranscriptionHotkeyFromString(string? text) return false; } - if ( - HotkeyMatchesAny( - key!.Value, - modifiers, - GetBoundHotkeys(HotkeyBinding.CopyLastTranscription) - ) - ) + lock (_lock) { - return false; - } + if ( + HotkeyMatchesAny( + key!.Value, + modifiers, + GetBoundHotkeys(HotkeyBinding.CopyLastTranscription) + ) + ) + { + return false; + } - _copyLastTranscriptionKey = key; - _copyLastTranscriptionModifiers = modifiers; - PushShortcutsIfRunning(); - return true; + _copyLastTranscriptionKey = key; + _copyLastTranscriptionModifiers = modifiers; + PushShortcutsIfRunning(); + return true; + } } public bool TrySetTransformSelectionHotkeyFromString(string? text) { if (string.IsNullOrWhiteSpace(text)) { - _transformSelectionKey = null; - _transformSelectionModifiers = ModifierMask.None; - PushShortcutsIfRunning(); + lock (_lock) + { + _transformSelectionKey = null; + _transformSelectionModifiers = ModifierMask.None; + PushShortcutsIfRunning(); + } + return true; } @@ -503,21 +546,24 @@ public bool TrySetTransformSelectionHotkeyFromString(string? text) return false; } - if ( - HotkeyMatchesAny( - key!.Value, - modifiers, - GetBoundHotkeys(HotkeyBinding.TransformSelection) - ) - ) + lock (_lock) { - return false; - } + if ( + HotkeyMatchesAny( + key!.Value, + modifiers, + GetBoundHotkeys(HotkeyBinding.TransformSelection) + ) + ) + { + return false; + } - _transformSelectionKey = key; - _transformSelectionModifiers = modifiers; - PushShortcutsIfRunning(); - return true; + _transformSelectionKey = key; + _transformSelectionModifiers = modifiers; + PushShortcutsIfRunning(); + return true; + } } /// @@ -684,8 +730,11 @@ IReadOnlyList entries { ArgumentNullException.ThrowIfNull(entries); - _promptActionHotkeyCandidates = entries.ToArray(); - return ReconcileDynamicHotkeys(); + lock (_lock) + { + _promptActionHotkeyCandidates = entries.ToArray(); + return ReconcileDynamicHotkeysLocked(); + } } /// @@ -733,8 +782,11 @@ public IReadOnlyList SetProfileHotkeys(IReadOnlyList entr { ArgumentNullException.ThrowIfNull(entries); - _profileHotkeyCandidates = entries.ToArray(); - return ReconcileDynamicHotkeys(); + lock (_lock) + { + _profileHotkeyCandidates = entries.ToArray(); + return ReconcileDynamicHotkeysLocked(); + } } /// @@ -748,22 +800,25 @@ IReadOnlyList profiles ArgumentNullException.ThrowIfNull(promptActions); ArgumentNullException.ThrowIfNull(profiles); - _promptActionHotkeyCandidates = promptActions.ToArray(); - _profileHotkeyCandidates = profiles.ToArray(); - return ReconcileDynamicHotkeys(); + lock (_lock) + { + _promptActionHotkeyCandidates = promptActions.ToArray(); + _profileHotkeyCandidates = profiles.ToArray(); + return ReconcileDynamicHotkeysLocked(); + } } /// /// Rebuilds accepted dynamic bindings under one deterministic priority: existing fixed /// bindings first, then prompt actions in source order, then profiles in source order. /// - private List ReconcileDynamicHotkeys() + /// Callers must hold _lock. + private List ReconcileDynamicHotkeysLocked() { - // Exclude both previously accepted dynamic lists before capturing fixed bindings. This - // prevents unchanged candidates from colliding with themselves during a rebuild. - _promptActionHotkeys = []; - _profileHotkeys = []; - var fixedBindings = GetBoundHotkeys().ToArray(); + // Capture fixed bindings only, so unchanged candidates can't collide with themselves + // during a rebuild. Reading them directly rather than clearing the published dynamic + // lists first keeps a concurrent BuildShortcutSet from seeing an empty dynamic set. + var fixedBindings = GetFixedHotkeys().ToArray(); var acceptedActions = new List( _promptActionHotkeyCandidates.Length ); @@ -1039,7 +1094,17 @@ private void UnsubscribeBackendHandlers(IGlobalShortcutBackend? backend) private GlobalShortcutSet BuildShortcutSet() { var nativeDictationBindingActive = _nativeDictationBindingActive; - var suppressCancel = nativeDictationBindingActive && _mode == RecordingMode.PushToTalk; + // A native PushToTalk binding owns cancel only when the desktop spec could derive a + // distinct accelerator. DictationShortcutSpecFactory drops that bind when the trigger + // already ends in Escape, so keep the app's own cancel key — else there is no cancel. + var nativeOwnsCancel = _key != CancelKey; + // ...unless the trigger IS the app's bare cancel chord. Nothing distinguishes the two + // routes then, so one press would start a native recording and cancel it at once. + var nativeTriggerIsCancelChord = + _key == CancelKey && ShortcutMatcher.ModifiersMatch(_modifiers, CancelModifiers); + var suppressCancel = nativeDictationBindingActive + && _mode == RecordingMode.PushToTalk + && (nativeOwnsCancel || nativeTriggerIsCancelChord); return new GlobalShortcutSet( nativeDictationBindingActive ? KeyCode.VcUndefined : _key, nativeDictationBindingActive ? ModifierMask.None : _modifiers, @@ -1228,7 +1293,7 @@ KeyCode.VcLeftMeta or KeyCode.VcRightMeta // Dynamic prompt-action bindings make collision detection symmetric: fixed-binding // changes that would shadow a prompt-action chord are also rejected. Dynamic - // reconciliation clears both accepted lists before it captures fixed bindings. + // reconciliation reads GetFixedHotkeys directly so it never collides with itself. foreach (var entry in _promptActionHotkeys) { yield return (entry.Key, entry.Modifiers); diff --git a/src/TypeWhisper.Linux/Services/HttpApiService.cs b/src/TypeWhisper.Linux/Services/HttpApiService.cs index a2e779082..3c1945c22 100644 --- a/src/TypeWhisper.Linux/Services/HttpApiService.cs +++ b/src/TypeWhisper.Linux/Services/HttpApiService.cs @@ -10,14 +10,18 @@ namespace TypeWhisper.Linux.Services; -internal sealed class HttpApiRequestDispatcher +internal sealed class HttpApiRequestDispatcher : IDisposable { + private static readonly TimeSpan s_drainTimeout = TimeSpan.FromSeconds(1); + + private readonly int _capacity; private readonly Action _reportException; private readonly SemaphoreSlim _slots; public HttpApiRequestDispatcher(int capacity, Action? reportException = null) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + _capacity = capacity; _slots = new SemaphoreSlim(capacity, capacity); _reportException = reportException ?? (ex => Trace.WriteLine($"[HttpApiService] Dispatched request failed: {ex}")); @@ -29,6 +33,31 @@ public HttpApiRequestDispatcher(int capacity, Action? reportException return _slots.Wait(0) ? RunAsync(handler) : null; } + /// + /// Reclaims every slot first, proving no admitted handler is still in flight: a handler + /// releases its slot in a finally block, so disposing underneath one would surface an + /// as an unobserved fault. A handler that outlasts + /// the drain leaves the semaphore undisposed, which is harmless — the Wait(0) path never + /// allocates a wait handle. + /// + public void Dispose() + { + for (var acquired = 0; acquired < _capacity; acquired++) + { + if (_slots.Wait(s_drainTimeout)) + { + continue; + } + + Trace.WriteLine( + "[HttpApiService] Request slots still in use at dispose; leaving them undisposed." + ); + return; + } + + _slots.Dispose(); + } + private async Task RunAsync(Func handler) { try @@ -61,6 +90,10 @@ public sealed class HttpApiService : IDisposable { internal const int MaxConcurrentRequests = 2; internal const long MaxTranscribeRequestBytes = 100 * 1024 * 1024; + + // Applies to every JSON endpoint, including bulk PUT /v1/dictionary/terms uploads. A body + // over this limit is rejected with 413 "Request body too large" rather than truncated, so + // clients with a larger dictionary must split it across requests. internal const long MaxJsonRequestBytes = 1 * 1024 * 1024; private const string AllowedCorsHeaders = @@ -145,6 +178,9 @@ public void Dispose() // Best-effort wait for the listener loop to drain during dispose. } + // Waiting on _listenTask only drains the accept loop; admitted handlers run detached, + // so the dispatcher does its own bounded drain before releasing the semaphore. + _requestDispatcher.Dispose(); _disposed = true; } diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs index 80728ec7a..2c062d1c5 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs @@ -119,7 +119,7 @@ internal static ControlSocketCleanupResult TryCleanupStaleSocket(string socketPa catch (Exception ex) { Trace.WriteLine( - $"[ControlSocketOwnership] Could not acquire cleanup ownership for {socketPath}: {ex.Message}" + $"[ControlSocketOwnership] Stale-socket cleanup for {socketPath} failed: {ex.Message}" ); return ControlSocketCleanupResult.Indeterminate; } @@ -127,6 +127,9 @@ internal static ControlSocketCleanupResult TryCleanupStaleSocket(string socketPa /// /// Re-probes and, only on ECONNREFUSED, unlinks a stale socket while ownership is held. + /// Both lifecycle callers (Start and Dispose) hold the server's lifecycle gate across the + /// probe, but a live peer answers or refuses immediately — only a wedged peer costs the + /// full . /// internal ControlSocketCleanupResult CleanupStaleSocket() { diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs index e3bac0300..55a32f287 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs @@ -292,6 +292,7 @@ private static SocketException AddressAlreadyInUse() return new SocketException((int)SocketError.AddressAlreadyInUse); } + /// Callers must hold _lifecycleGate. private void CleanupFailedStart( ControlSocketOwnership ownership, Socket? listener, diff --git a/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs b/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs index eb6e272a0..0a5a3b9c3 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs @@ -24,7 +24,15 @@ internal static class JsonControlProtocol /// public const int MaxLineBytes = 4 * 1024; - /// Current protocol version. Bumped only on breaking changes. + /// + /// Current protocol version. Bumped only on breaking changes — a widened + /// state/prev vocabulary is additive, so this stays at 1. + /// + /// + /// state/prev may carry starting alongside idle and + /// recording: an accepted start reports it until capture is observably open or + /// the start settles. + /// public const int CurrentVersion = 1; public const string CmdRecordStart = "record.start"; diff --git a/src/TypeWhisper.Linux/Services/Ipc/SocketPathResolver.cs b/src/TypeWhisper.Linux/Services/Ipc/SocketPathResolver.cs index 7d706ef96..8f6a417f0 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/SocketPathResolver.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/SocketPathResolver.cs @@ -91,7 +91,14 @@ private static void PreparePrivateDirectory(string directory, int uid) { try { - Directory.CreateDirectory(directory); + // Create owner-only in the mkdir itself so a newly created directory is never + // briefly world-traversable between creation and the chmod below. +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + Directory.CreateDirectory( + directory, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + ); +#pragma warning restore CA1416 } catch (Exception ex) { @@ -101,6 +108,8 @@ private static void PreparePrivateDirectory(string directory, int uid) ); } + // Still required for a directory that already existed — CreateDirectory only applies + // the mode to directories it creates. TryChmod(directory, 0b111_000_000); // 0700 if (!IsDirectoryPrivateAndOwned(directory, uid)) { diff --git a/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs b/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs index d14083e7c..00d0f598e 100644 --- a/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs +++ b/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs @@ -79,6 +79,16 @@ internal LinuxPreferencesService( // ReSharper disable once UnusedMethodReturnValue.Global -- returns Current so callers that reload on demand get the fresh value. // ReSharper disable once MemberCanBePrivate.Global -- public reload entry point mirroring ISettingsService.Load(); only the constructor calls it in-tree. public LinuxPreferences Load() + { + // Serialize with Save/Update so a reload can't clobber (or be clobbered by) a + // concurrent write's Current assignment. + lock (_gate) + { + return LoadLocked(); + } + } + + private LinuxPreferences LoadLocked() { if (!File.Exists(_path)) { @@ -88,7 +98,6 @@ public LinuxPreferences Load() try { var json = File.ReadAllText(_path); - // ReSharper disable once InconsistentlySynchronizedField -- s_jsonOptions is an immutable static readonly options instance; reads require no synchronization. Current = JsonSerializer.Deserialize(json, s_jsonOptions) ?? LinuxPreferences.Default; diff --git a/src/TypeWhisper.Linux/Services/MediaPauseService.cs b/src/TypeWhisper.Linux/Services/MediaPauseService.cs index 6c85b266e..434f106d6 100644 --- a/src/TypeWhisper.Linux/Services/MediaPauseService.cs +++ b/src/TypeWhisper.Linux/Services/MediaPauseService.cs @@ -14,9 +14,23 @@ public sealed class MediaPauseService : IMediaPauseService, IDisposable private static readonly IReadOnlyDictionary s_playerctlEnvironment = new Dictionary(StringComparer.Ordinal) { ["LC_ALL"] = "C" }; + // A player that never resumes would otherwise pin _pausedPlayers non-empty and disable + // pausing for the rest of the session, so each one is dropped after this many failures. + private const int MaxResumeAttempts = 3; + private readonly IProcessRunner _processRunner; private readonly IErrorLogService _errorLog; - private readonly HashSet _pausedPlayers = new(StringComparer.OrdinalIgnoreCase); + + // Paused player name -> consecutive failed resume attempts. Guarded by _playersGate; + // playerctl itself is always invoked outside the lock. + private readonly Dictionary _pausedPlayers = + new(StringComparer.OrdinalIgnoreCase); + + private readonly Lock _playersGate = new(); + + // True between a completed pause scan and the next resume. Kept separate from + // _pausedPlayers so a player we still owe a resume can't suppress future pause scans. + private bool _pauseActive; public MediaPauseService(IProcessRunner processRunner, IErrorLogService errorLog) { @@ -26,9 +40,14 @@ public MediaPauseService(IProcessRunner processRunner, IErrorLogService errorLog public void PauseMedia() { - if (_pausedPlayers.Count > 0) + lock (_playersGate) { - return; + if (_pauseActive) + { + return; + } + + _pauseActive = true; } try @@ -64,48 +83,101 @@ var line in playersResult.StandardOutput.Split( continue; } - if (RunPlayerctl(["-p", parts[0], "pause"]).Succeeded) + if (!RunPlayerctl(["-p", parts[0], "pause"]).Succeeded) + { + continue; + } + + lock (_playersGate) { - _pausedPlayers.Add(parts[0]); + _pausedPlayers[parts[0]] = 0; } } } catch (Exception ex) { Debug.WriteLine($"[MediaPauseService] Pause failed: {ex.Message}"); - _pausedPlayers.Clear(); + lock (_playersGate) + { + _pausedPlayers.Clear(); + _pauseActive = false; + } } } public void ResumeMedia() { - if (_pausedPlayers.Count == 0) + string[] players; + lock (_playersGate) { - return; + _pauseActive = false; + if (_pausedPlayers.Count == 0) + { + return; + } + + players = [.. _pausedPlayers.Keys]; } - foreach (var player in _pausedPlayers.ToArray()) + foreach (var player in players) { + string failure; try { var result = RunPlayerctl(["-p", player, "play"]); if (result.Succeeded) { - _pausedPlayers.Remove(player); + lock (_playersGate) + { + _pausedPlayers.Remove(player); + } + continue; } - ReportResumeFailure( - $"Failed to resume media player {player}: {DescribeFailure(result)}" - ); + failure = DescribeFailure(result); } catch (Exception ex) { - ReportResumeFailure( - $"Failed to resume media player {player}: exception: {ex.Message}" - ); + failure = $"exception: {ex.Message}"; + } + + RecordResumeFailure(player, failure); + } + } + + /// + /// Reports the failure and stops retrying the player after + /// attempts — typically one that exited while paused, which would otherwise cost a + /// playerctl round trip on every later recording. + /// + private void RecordResumeFailure(string player, string failure) + { + bool retired; + lock (_playersGate) + { + if (!_pausedPlayers.TryGetValue(player, out var attempts)) + { + return; + } + + attempts++; + retired = attempts >= MaxResumeAttempts; + if (retired) + { + _pausedPlayers.Remove(player); + } + else + { + _pausedPlayers[player] = attempts; } } + + ReportResumeFailure( + retired + ? $"Failed to resume media player {player}: {failure}. Giving up after {MaxResumeAttempts} attempts." + : $"Failed to resume media player {player}: {failure}" + ); } public void Dispose() diff --git a/src/TypeWhisper.Linux/Services/ProcessRunner.cs b/src/TypeWhisper.Linux/Services/ProcessRunner.cs index af913a477..55d58a073 100644 --- a/src/TypeWhisper.Linux/Services/ProcessRunner.cs +++ b/src/TypeWhisper.Linux/Services/ProcessRunner.cs @@ -242,14 +242,16 @@ await Task.WhenAll(stdoutTask, stderrTask) } catch (Exception ex) { + // Any failure after Start leaves a live child: Process.Dispose only releases the + // handle, so without this the child keeps running past the failed run. + if (process is not null) + { + await KillAndReapProcessTreeAsync(process).ConfigureAwait(false); + } + // ReSharper disable once InvertIf -- inverting would duplicate the `return ProcessRunResult.NotStarted(...)` tail. if (ct.IsCancellationRequested) { - if (process is not null) - { - await KillAndReapProcessTreeAsync(process).ConfigureAwait(false); - } - if (standardOutputReader is not null && stdoutTask is not null) { AbandonRead(standardOutputReader, stdoutTask); diff --git a/src/TypeWhisper.Linux/Services/UpdateCheckService.cs b/src/TypeWhisper.Linux/Services/UpdateCheckService.cs index 06e932f76..3af066649 100644 --- a/src/TypeWhisper.Linux/Services/UpdateCheckService.cs +++ b/src/TypeWhisper.Linux/Services/UpdateCheckService.cs @@ -174,15 +174,26 @@ public async Task CheckAsync(CancellationToken cancellationTo // the rate-limit clock or wipe the cached latest version. if (!result.Faulted) { - _prefs.Update( - preferences => - preferences with - { - LastUpdateCheckUtc = DateTime.UtcNow, - LastKnownLatestVersion = result.LatestVersion, - LastKnownLatestUrl = result.ReleaseUrl - } - ); + try + { + _prefs.Update( + preferences => + preferences with + { + LastUpdateCheckUtc = DateTime.UtcNow, + LastKnownLatestVersion = result.LatestVersion, + LastKnownLatestUrl = result.ReleaseUrl + } + ); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A successful check still publishes its result; only the rate-limit + // bookkeeping is lost when preferences can't be written. + Trace.WriteLine( + $"[UpdateCheck] Could not persist the check timestamp: {ex.Message}" + ); + } } Publish(result); @@ -202,7 +213,16 @@ public void DismissUpdate(string? version) return; } - _prefs.Update(current => current with { DismissedUpdateVersion = version }); + try + { + _prefs.Update(current => current with { DismissedUpdateVersion = version }); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Dismissal is a UI command; a write failure must not tear down the banner path. + Trace.WriteLine($"[UpdateCheck] Could not persist the dismissal: {ex.Message}"); + return; + } // Re-raise so banner listeners recompute visibility. ResultChanged?.Invoke(LastResult); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs index 834e2a3f9..1b411c64c 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs @@ -220,7 +220,23 @@ partial void OnCloseToTrayChanged(bool value) return; } - _linuxPrefs.Update(current => current with { CloseToTray = value }); + try + { + _linuxPrefs.Update(current => current with { CloseToTray = value }); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A read-only or full disk must not throw out of a property setter into the binding. + System.Diagnostics.Trace.WriteLine( + $"[General] Could not persist the close-to-tray preference: {ex.Message}" + ); + + // The generated setter already published `value`, but the close handler reads + // LinuxPreferences.Current, which a failed write leaves untouched — roll the toggle + // back so it can't advertise behavior the app won't honor. The re-entrant call stops + // at the equality guard above. + CloseToTray = _linuxPrefs.Current.CloseToTray; + } } } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs index 8ba308583..f7c6b8db0 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs @@ -502,6 +502,11 @@ internal async Task RefreshDesktopIntegrationStateAsync(CancellationToken ct) } catch (OperationCanceledException) { + // Rethrown for callers passing a real token; traced first so an internal probe + // timeout doesn't fault ScheduleDesktopIntegrationRefresh's task silently. + System.Diagnostics.Trace.WriteLine( + "[Shortcuts] Desktop integration status probe was canceled." + ); throw; } catch (Exception ex) diff --git a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs index b3d1d9974..84b1e082b 100644 --- a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs @@ -41,19 +41,16 @@ public void ApplyWhisperModeGain_LeavesAudioUnchangedWhenDisabled() } [Theory] - [InlineData(480, 48000, 16000)] - [InlineData(441, 44100, 16000)] + [InlineData(480, 48000, 16000, 160)] + [InlineData(441, 44100, 16000, 160)] public void ResampleToSampleRate_DownsamplesToRoundedTargetLength( int inputLength, int sourceSampleRate, - int targetSampleRate + int targetSampleRate, + int expectedLength ) { var samples = new float[inputLength]; - var expectedLength = Math.Max( - 1, - (int)Math.Round(inputLength * (double)targetSampleRate / sourceSampleRate) - ); var processed = AudioRecordingService.ResampleToSampleRate( samples, diff --git a/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs b/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs index 581aa8496..cc98d8449 100644 --- a/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs @@ -242,6 +242,13 @@ await client [Fact] public void IndeterminateProbe_LeavesSocketPathIntact() { + // The probe is driven indeterminate by a mode-000 socket, and root bypasses that + // permission check entirely — as root the connect would succeed and report Live. + if (Environment.IsPrivilegedProcess) + { + return; + } + var tempDirectory = TestPaths.CreateTempDirectory("ipc-m7"); var socketPath = Path.Join(tempDirectory, "control.sock"); ControlSocketOwnership? ownership = null; diff --git a/tests/TypeWhisper.Linux.Tests/EvdevDeviceReaderTests.cs b/tests/TypeWhisper.Linux.Tests/EvdevDeviceReaderTests.cs index 8c45048cb..d0ab17860 100644 --- a/tests/TypeWhisper.Linux.Tests/EvdevDeviceReaderTests.cs +++ b/tests/TypeWhisper.Linux.Tests/EvdevDeviceReaderTests.cs @@ -7,6 +7,8 @@ namespace TypeWhisper.Linux.Tests; public sealed class EvdevDeviceReaderTests { + private static readonly TimeSpan s_testGuard = TimeSpan.FromSeconds(2); + [Fact] public async Task NormalStream_DeliversEdgesAndSuppressesRepeatsAndDuplicates() { @@ -115,7 +117,7 @@ public async Task SynDroppedSnapshotFailure_TerminatesThroughFailureCallback() device.Enqueue(InputEvent.EvKey, 57, InputEvent.Pressed); device.Enqueue(InputEvent.EvSyn, InputEvent.SynReport, 0); - var actualFailure = await failure.Task.WaitAsync(TimeSpan.FromSeconds(2)); + var actualFailure = await failure.Task.WaitAsync(s_testGuard); Assert.Same(snapshotFailure, actualFailure); Assert.Equal(1, device.QueryCount); @@ -184,7 +186,7 @@ public KeyEdge[] Snapshot() public async Task WaitForCountAsync(int count) { - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + using var timeout = new CancellationTokenSource(s_testGuard); while (Snapshot().Length < count) { await _updated.WaitAsync(timeout.Token); diff --git a/tests/TypeWhisper.Linux.Tests/GlobalHotkeySetupTaskTests.cs b/tests/TypeWhisper.Linux.Tests/GlobalHotkeySetupTaskTests.cs index e35ec5133..61f37e0fb 100644 --- a/tests/TypeWhisper.Linux.Tests/GlobalHotkeySetupTaskTests.cs +++ b/tests/TypeWhisper.Linux.Tests/GlobalHotkeySetupTaskTests.cs @@ -174,7 +174,7 @@ public async Task RunAction_when_opted_out_and_no_rule_installed_is_a_noop() public async Task RunAction_when_opted_out_but_rule_still_installed_revokes_it_via_the_helper() { using var env = new PkexecOnPath(); - PkexecOnPath.WriteOwnedRule(); + env.WriteOwnedRule(); var runner = new FakeProcessRunner(); var task = Build( isWayland: true, @@ -391,8 +391,11 @@ public PkexecOnPath(bool installPkexec = true) } } - public static void WriteOwnedRule() + // Instance method asserting on this instance's temp dir, so the rule can only ever be + // written under the redirect the constructor installed — never the real system path. + public void WriteOwnedRule() { + Assert.Equal(_sysConfDir, InputAccessSetupHelper.SysConfDirOverride); Directory.CreateDirectory(Path.GetDirectoryName(InputAccessSetupHelper.UdevRulePath)!); File.WriteAllText( InputAccessSetupHelper.UdevRulePath, diff --git a/tests/TypeWhisper.Linux.Tests/HotkeyServiceTests.cs b/tests/TypeWhisper.Linux.Tests/HotkeyServiceTests.cs index 8a82e3800..ad07e1c51 100644 --- a/tests/TypeWhisper.Linux.Tests/HotkeyServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HotkeyServiceTests.cs @@ -1415,6 +1415,51 @@ bool expectedCancelEnabled Assert.True(hotkey.IsCancelShortcutEnabled); } + [Fact] + public async Task NativeDictationActive_KeepsAppCancelWhenToggleHotkeyEndsInEscape() + { + // DictationShortcutSpecFactory drops the native cancel bind when the trigger already + // ends in Escape, so suppressing the app-owned cancel would leave no way to cancel. + var backend = new TestShortcutBackend(); + using var hotkey = new HotkeyService(new BackendSelector(() => backend)); + hotkey.Mode = RecordingMode.PushToTalk; + hotkey.IsCancelShortcutEnabled = true; + Assert.True(hotkey.TrySetHotkeyFromString("Ctrl+Shift+Escape")); + hotkey.Initialize(); + + hotkey.SetNativeDictationBindingActive(true); + await backend.WaitUntilSettledAsync(); + + var snapshot = backend.LastSet; + Assert.NotNull(snapshot); + Assert.Equal(KeyCode.VcUndefined, snapshot.DictationKey); + Assert.Equal(KeyCode.VcEscape, snapshot.CancelKey); + Assert.Equal(ModifierMask.None, snapshot.CancelModifiers); + Assert.True(snapshot.IsCancelEnabled); + } + + [Fact] + public async Task NativeDictationActive_SuppressesAppCancelWhenToggleHotkeyIsBareEscape() + { + // Nothing distinguishes the app's bare-Escape cancel from a native trigger that is also + // bare Escape, so one press would start a native recording and cancel it at the same time. + var backend = new TestShortcutBackend(); + using var hotkey = new HotkeyService(new BackendSelector(() => backend)); + hotkey.Mode = RecordingMode.PushToTalk; + hotkey.IsCancelShortcutEnabled = true; + Assert.True(hotkey.TrySetHotkeyFromString("Escape")); + hotkey.Initialize(); + + hotkey.SetNativeDictationBindingActive(true); + await backend.WaitUntilSettledAsync(); + + var snapshot = backend.LastSet; + Assert.NotNull(snapshot); + Assert.Equal(KeyCode.VcUndefined, snapshot.DictationKey); + Assert.Equal(KeyCode.VcUndefined, snapshot.CancelKey); + Assert.False(snapshot.IsCancelEnabled); + } + [Fact] public async Task NativeDictationInactive_RestoresConfiguredDictationAndCancelWithoutChangingOthers() { diff --git a/tests/TypeWhisper.Linux.Tests/HttpApiRequestDispatcherTests.cs b/tests/TypeWhisper.Linux.Tests/HttpApiRequestDispatcherTests.cs index c51a1d9ac..81dd29053 100644 --- a/tests/TypeWhisper.Linux.Tests/HttpApiRequestDispatcherTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HttpApiRequestDispatcherTests.cs @@ -95,6 +95,30 @@ public async Task TryRun_HandlerFailureIsObservedAndDoesNotLeakSlot() await Task.WhenAll(firstFresh, secondFresh); } + [Fact] + public async Task Dispose_WhileHandlerInFlight_LeavesTheHandlerAbleToReleaseItsSlot() + { + var dispatcher = new HttpApiRequestDispatcher(HttpApiService.MaxConcurrentRequests); + var entered = NewSignal(); + var release = NewSignal(); + var inFlight = dispatcher.TryRun(async () => + { + entered.SetResult(); + await release.Task; + }); + + Assert.NotNull(inFlight); + await entered.Task; + + // The drain can't reclaim the busy slot, so disposal backs off rather than pulling the + // semaphore out from under the handler's finally block. + dispatcher.Dispose(); + release.SetResult(); + await inFlight; + + Assert.True(inFlight.IsCompletedSuccessfully); + } + [Fact] public void OverCapacityResponseMetadata_IsPinned() { diff --git a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs index 1bc52b64e..5bc9bfbca 100644 --- a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs @@ -48,6 +48,27 @@ public void CanonicalCatalog_HasNativeDictationDisclosuresWithoutObsoleteEvdevCl Assert.DoesNotContain("Shortcuts.EvdevStillOffAfterRemoval", en.Keys); } + [Fact] + public void CanonicalCatalog_HasGlobalHotkeyOptOutMessages() + { + // GlobalHotkeySetupTaskTests assert through Loc.Instance, which returns the key itself + // when it is missing — so the catalog needs its own explicit coverage. + var en = Load(CanonicalLanguage); + var keys = new[] + { + "Setup.GlobalHotkeyOptedOut", + "Setup.GlobalHotkeyOptedOutRuleInstalled", + "Setup.GlobalHotkeyOptedOutRuleInstalledDetail", + "Setup.GlobalHotkeyRevokeButton" + }; + + foreach (var key in keys) + { + Assert.True(en.TryGetValue(key, out var value), $"Missing canonical key: {key}"); + Assert.False(string.IsNullOrWhiteSpace(value), $"Canonical key is empty: {key}"); + } + } + [Fact] public void CanonicalCatalog_HasDesktopIntegrationStaleAndRefreshMessages() { diff --git a/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs b/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs index b15b85eb0..1cc907c8b 100644 --- a/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs @@ -161,6 +161,34 @@ public void ResumeMedia_retains_timed_out_player_even_with_zero_exit_code() errorLog.VerifyNoOtherCalls(); } + [Fact] + public void PauseMedia_StillRunsWhileAnEarlierPlayerIsAwaitingResume() + { + const string players = "vlc Playing"; + string[] status = ["-a", "--format", "{{playerName}} {{status}}", "status"]; + string[] pause = ["-p", "vlc", "pause"]; + string[] play = ["-p", "vlc", "play"]; + var runner = new FakeProcessRunner(); + runner.RespondWith( + (fileName, args) => fileName == "playerctl" && args.SequenceEqual(status), + players + ); + runner.FailWhen( + (fileName, args) => fileName == "playerctl" && args.SequenceEqual(play), + "resume keeps failing" + ); + var service = new MediaPauseService(runner, Mock.Of()); + + // vlc stays owed a resume after the first cycle; the next recording must still pause. + service.PauseMedia(); + service.ResumeMedia(); + service.PauseMedia(); + + Assert.Equal(2, CountInvocations(runner, status)); + Assert.Equal(2, CountInvocations(runner, pause)); + Assert.Equal(1, CountInvocations(runner, play)); + } + private static int CountInvocations(FakeProcessRunner runner, IReadOnlyList args) { return runner.Invocations.Count(invocation => invocation.Args.SequenceEqual(args)); diff --git a/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs b/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs index 0eefa7a2b..0a7c799cb 100644 --- a/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs @@ -397,6 +397,106 @@ public void ClearShortcuts_DropsPendingWorkflow_OnlyReboundActionFiresAfterReReg Assert.Equal(["beta"], observed); } + [Fact] + public void ClearShortcuts_DropsHeldDictationGuard_ReboundKeyStillTogglesAfterReRegister() + { + var d = new ShortcutDispatcher(); + d.UpdateShortcuts(Set(RecordingMode.Toggle)); + var toggles = 0; + d.DictationToggleRequested += () => toggles++; + + // Unregister mid-hold: the matching release never reaches the dispatcher. + d.Handle(KeyCode.VcSpace, ModifierMask.LeftCtrl | ModifierMask.LeftShift, true); + Assert.Equal(1, toggles); + d.ClearShortcuts(); + + // Re-register with a different dictation key; its first press must not be swallowed + // by the held-key guard left behind by the unregistered binding. + d.UpdateShortcuts(Set(RecordingMode.Toggle) with { DictationKey = KeyCode.VcD }); + d.Handle(KeyCode.VcD, ModifierMask.LeftCtrl | ModifierMask.LeftShift, true); + + Assert.Equal(2, toggles); + } + + [Fact] + public void ClearShortcuts_DropsCancelGuard_ReboundCancelStillFiresAfterReRegister() + { + var d = new ShortcutDispatcher(); + d.UpdateShortcuts(Set(RecordingMode.Toggle, true)); + var cancels = 0; + d.CancelRequested += () => cancels++; + + d.Handle(KeyCode.VcEscape, ModifierMask.None, true); + Assert.Equal(1, cancels); + d.ClearShortcuts(); + + d.UpdateShortcuts(Set(RecordingMode.Toggle, true)); + d.Handle(KeyCode.VcEscape, ModifierMask.None, true); + + Assert.Equal(2, cancels); + } + + [Fact] + public void SelectionWorkflows_SharingOneKeyUnderDifferentModifiers_BothDispatch() + { + var d = new ShortcutDispatcher(); + // Ctrl+Alt+R runs "alpha"; the palette answers to Ctrl+R on the same physical key. + var set = SetWithPromptAction( + "alpha", + KeyCode.VcR, + ModifierMask.LeftCtrl | ModifierMask.LeftAlt + ); + d.UpdateShortcuts( + set with { PromptPaletteKey = KeyCode.VcR, PromptPaletteModifiers = ModifierMask.LeftCtrl } + ); + var observed = new List(); + d.PromptActionRequested += observed.Add; + d.PromptPaletteRequested += () => observed.Add("palette"); + + // Claim the prompt action, release only its Alt modifier, then claim the palette on + // the same key before every modifier is up. + d.Handle(KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, true); + d.Handle(KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, false); + d.Handle(KeyCode.VcLeftAlt, ModifierMask.LeftCtrl, false); + Assert.Empty(observed); + + d.Handle(KeyCode.VcR, ModifierMask.LeftCtrl, true); + d.Handle(KeyCode.VcR, ModifierMask.LeftCtrl, false); + d.Handle(KeyCode.VcLeftControl, ModifierMask.None, false); + + Assert.Equal(2, observed.Count); + Assert.Contains("alpha", observed); + Assert.Contains("palette", observed); + } + + [Fact] + public void SelectionWorkflows_AutoRepeatAfterDroppingAModifier_ClaimsOnlyTheFirstWorkflow() + { + var d = new ShortcutDispatcher(); + var set = SetWithPromptAction( + "alpha", + KeyCode.VcR, + ModifierMask.LeftCtrl | ModifierMask.LeftAlt + ); + d.UpdateShortcuts( + set with { PromptPaletteKey = KeyCode.VcR, PromptPaletteModifiers = ModifierMask.LeftCtrl } + ); + var observed = new List(); + d.PromptActionRequested += observed.Add; + d.PromptPaletteRequested += () => observed.Add("palette"); + + // R stays down throughout; releasing Alt makes the auto-repeat presses match the palette. + d.Handle(KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, true); + d.Handle(KeyCode.VcLeftAlt, ModifierMask.LeftCtrl, false); + d.Handle(KeyCode.VcR, ModifierMask.LeftCtrl, true); + d.Handle(KeyCode.VcR, ModifierMask.LeftCtrl, true); + + d.Handle(KeyCode.VcR, ModifierMask.LeftCtrl, false); + d.Handle(KeyCode.VcLeftControl, ModifierMask.None, false); + + Assert.Equal(["alpha"], observed); + } + [Fact] public void ProfileStartDictation_Toggle_FiresToggleWithId() { diff --git a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs index 623e979b7..289ce800b 100644 --- a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs @@ -293,7 +293,15 @@ public string PathFor(string fileName) public void Dispose() { - Directory.Delete(Path, recursive: true); + try + { + Directory.Delete(Path, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + or DirectoryNotFoundException) + { + // Best-effort: an already-removed temp directory must not fail a passing test. + } } } } diff --git a/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs index b384b9941..e03286bbb 100644 --- a/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs @@ -604,7 +604,10 @@ CancellationToken ct _calls.Enqueue(call); if (!_controlResponses) { - session = _sessions.Dequeue(); + Assert.True( + _sessions.TryDequeue(out session), + $"No playback session was queued for the SpeakAsync request '{request.Text}'." + ); } } diff --git a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs index 78c10bbdb..d2c1f6a12 100644 --- a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs @@ -655,6 +655,7 @@ public async Task InsertTextAsync_skips_restore_when_ownership_read_cannot_prove Assert.Equal(InsertionResult.Pasted, result); // No restore write happened: only the initial set. Assert.Equal(1, platform.SetClipboardCount); + Assert.Equal("new text", platform.Clipboard); } [Fact] diff --git a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs index a1f6f911b..07497adad 100644 --- a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs @@ -623,17 +623,7 @@ CancellationToken ct ) { ct.ThrowIfCancellationRequested(); - return Task.FromResult( - new WatchFolderTranscriptionResult( - $"Transcribed {Path.GetFileName(request.FilePath)}", - "en", - 1, - 0.1, - [], - "fake", - "test" - ) - ); + return Task.FromResult(CreateResult(request)); } private static WatchFolderOptions CreateOptions( From f0cddcbc74cea96ea3cbe799af54ac255dbb9d12 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 3 Aug 2026 13:24:53 -0400 Subject: [PATCH 208/226] Serialize the settings-test fakes and add the remaining 714 trailing commas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review passes over this slice: QA (CodeRabbit) — ISettingsService.Update documents that the read of Current and the write must happen under the same synchronization as Save, which is why the member is abstract rather than a mutate(Current)+Save default. All five FakeSettingsService doubles implemented exactly that fallback, so each now takes a Lock across both Save and Update, mirroring SettingsService. Three other findings were skipped: the CliInstallService launcher revalidation (the destructive write at :116 is already guarded by a fresh classification at :104, and the cached entry only gates copies into our own install dir), the IndexOfDelimiter "quadratic" claim (the search windows are disjoint, so it is O(n*m) with m capped at 70 by RFC 2046), and the closing-delimiter consolidation nitpick (a wash on complexity in a just-hardened parser). ReSharper — a fresh-cache whole-solution scan at HINT severity reported 714 ArrangeTrailingCommaInMultilineLists across 181 files that the previous scan had replayed from a poisoned cache. .editorconfig declares this style deliberate, so all 714 were applied at their exact SARIF coordinates. Re-scan with a fresh cache: 0. Build clean, 2461 tests pass. --- src/TypeWhisper.Cli/Models/CliOptions.cs | 2 +- src/TypeWhisper.Cli/Output/JsonFormatting.cs | 2 +- src/TypeWhisper.Cli/Program.cs | 2 +- src/TypeWhisper.Core/Models/CleanupLevel.cs | 2 +- .../Models/DictionaryEntrySource.cs | 2 +- .../Models/DictionaryEntryType.cs | 2 +- src/TypeWhisper.Core/Models/DiffSegment.cs | 2 +- src/TypeWhisper.Core/Models/ErrorLogEntry.cs | 2 +- .../Models/HistoryRetentionMode.cs | 2 +- src/TypeWhisper.Core/Models/IndustryPreset.cs | 2 +- .../LocalModelStorageUnavailableReason.cs | 2 +- src/TypeWhisper.Core/Models/MatchKind.cs | 2 +- src/TypeWhisper.Core/Models/ModelStatus.cs | 2 +- .../Models/ModelStatusType.cs | 2 +- .../Models/OverlayPosition.cs | 2 +- src/TypeWhisper.Core/Models/OverlayWidget.cs | 2 +- .../Models/ProfileHotkeyBehavior.cs | 2 +- .../Models/ProfileStylePreset.cs | 2 +- .../Models/RecentTranscriptionSource.cs | 2 +- src/TypeWhisper.Core/Models/RecordingMode.cs | 2 +- .../Models/SnippetTriggerMode.cs | 2 +- src/TypeWhisper.Core/Models/TermPack.cs | 32 ++-- .../Models/TextInsertionStatus.cs | 2 +- .../Models/TextInsertionStrategy.cs | 2 +- .../Models/TranscriptionTask.cs | 2 +- .../Models/TranslationModelInfo.cs | 8 +- .../Services/AppFormatterService.cs | 4 +- .../Services/CorrectionSuggestionService.cs | 4 +- .../Services/DetectionFailureTracker.cs | 2 +- .../Services/DeveloperFormattingService.cs | 4 +- .../Services/FirstRunDefaults.cs | 4 +- .../Services/HistoryInsightsService.cs | 2 +- .../Services/HistoryService.Export.cs | 2 +- .../Services/ProfileStylePresetService.cs | 4 +- .../Services/WhisperHallucinationFilter.cs | 2 +- .../Cli/CommandLineParser.cs | 2 +- .../Cli/Commands/RecordCommand.cs | 2 +- src/TypeWhisper.Linux/DiffKindConverters.cs | 4 +- .../AccessibilityBusActivationService.cs | 2 +- .../Services/ActiveWindow/AtSpiEventClient.cs | 8 +- .../ActiveWindow/AtSpiUrlExtractor.cs | 10 +- .../ActiveWindow/GnomeWindowCallsProvider.cs | 4 +- .../ActiveWindow/ProviderProcessRunner.cs | 4 +- src/TypeWhisper.Linux/Services/AppVersion.cs | 2 +- .../Services/AudioDuckingService.cs | 2 +- .../Services/AudioFileService.cs | 4 +- .../Services/AudioPlaybackService.cs | 2 +- .../Services/AudioRecordingService.cs | 2 +- .../BrowserAccessibilitySetupHelper.cs | 8 +- .../Services/DictationToggleGate.cs | 2 +- .../Services/FileTranscriptionProcessor.cs | 6 +- .../Services/GnomeWindowCallsSetupHelper.cs | 4 +- .../Services/HistoryRetentionCoordinator.cs | 2 +- .../Hotkey/DeSetup/DesktopDetector.cs | 4 +- .../DeSetup/DictationShortcutSpecFactory.cs | 2 +- .../Hotkey/DeSetup/GnomeShortcutWriter.cs | 4 +- .../Hotkey/Evdev/InputAccessSetupHelper.cs | 2 +- .../Services/Hotkey/Evdev/LinuxKeyMap.cs | 4 +- .../Evdev/LogindSessionActivityMonitor.cs | 6 +- .../Hotkey/SharpHookGlobalShortcutBackend.cs | 2 +- .../Services/Hotkey/ShortcutDispatcher.cs | 4 +- .../Services/Hotkey/ShortcutMatcher.cs | 2 +- .../Services/HotkeyService.cs | 26 +-- .../Services/Ipc/ControlSocketServer.cs | 4 +- .../Services/Ipc/JsonControlProtocol.cs | 2 +- .../LearnedCorrectionsNotificationService.cs | 4 +- .../LinuxDictationReadbackLanguagePolicy.cs | 4 +- .../LinuxDictationShortSpeechPolicy.cs | 2 +- .../LinuxLiveTranscriptionStartupPolicy.cs | 2 +- .../Services/LinuxSystemTtsProvider.cs | 2 +- .../Services/Localization/Loc.cs | 4 +- .../Services/MemoryService.cs | 2 +- .../Services/ModelManagerService.cs | 8 +- .../Plugins/PluginLocalityClassifier.cs | 2 +- .../Services/Plugins/PluginManager.cs | 8 +- .../Services/Plugins/PluginRegistryService.cs | 2 +- .../Services/Plugins/RegistryPlugin.cs | 2 +- .../Services/ProcessPriority.cs | 4 +- .../Services/ProcessRunner.cs | 2 +- .../Services/PromptProcessingService.cs | 2 +- .../Services/RecordingNotificationService.cs | 6 +- .../Services/SettingsBackupService.cs | 22 +-- .../Services/Setup/ISetupTask.cs | 4 +- .../SpokenCommand/SpokenCommandIntent.cs | 6 +- .../SpokenCommand/SpokenCommandKeyphrase.cs | 2 +- .../SpokenCommand/SpokenCommandText.cs | 2 +- .../StreamingTranscriptionCoordinator.cs | 2 +- .../SystemCommandAvailabilityService.cs | 10 +- .../Services/TranslationService.cs | 10 +- .../Services/TrayIconService.cs | 4 +- .../Services/UpdateCheckService.cs | 10 +- .../Services/WatchFolderExportBuilder.cs | 2 +- .../Services/WatchFolderModels.cs | 4 +- .../ViewModels/DictationOverlayViewModel.cs | 4 +- .../ViewModels/MainWindowViewModel.cs | 2 +- .../Sections/AboutSectionViewModel.cs | 2 +- .../Sections/AdvancedSectionViewModel.cs | 6 +- .../Sections/AppearanceSectionViewModel.cs | 8 +- .../Sections/DashboardSectionViewModel.cs | 4 +- .../Sections/DictationSectionViewModel.cs | 18 +- .../Sections/DictionarySectionViewModel.cs | 10 +- .../FileTranscriptionQueueItemStatus.cs | 2 +- .../FileTranscriptionSectionViewModel.cs | 4 +- .../Sections/HistorySectionViewModel.cs | 6 +- .../Sections/PluginCollectionViewModels.cs | 2 +- .../Sections/PluginsSectionViewModel.cs | 14 +- .../Sections/ProfilesSectionViewModel.cs | 16 +- .../Sections/PromptsSectionViewModel.cs | 6 +- .../Sections/ShortcutsSectionViewModel.cs | 16 +- .../Sections/SnippetsSectionViewModel.cs | 8 +- .../ViewModels/WelcomeWizardViewModel.cs | 8 +- .../Views/DictationOverlayWindow.axaml.cs | 2 +- .../Views/Sections/AboutSection.axaml.cs | 6 +- .../Views/Sections/DictationSection.axaml.cs | 2 +- .../Views/Sections/DictionarySection.axaml.cs | 4 +- .../FileTranscriptionSection.axaml.cs | 4 +- .../Helpers/OpenAiApiHelper.cs | 2 +- .../Helpers/OpenAiChatHelper.cs | 6 +- .../Helpers/OpenAiTranscriptionHelper.cs | 2 +- .../Models/PluginLogLevel.cs | 2 +- .../TranscriptionAccelerationBackend.cs | 2 +- .../TranscriptionAccelerationPreference.cs | 2 +- .../Models/TtsPurpose.cs | 2 +- .../Models/ErrorCategoryGuardTests.cs | 2 +- .../DictionaryServiceCorrectionsTests.cs | 12 +- .../Services/DictionaryServiceTests.cs | 68 ++++---- .../Services/HistoryInsightsServiceTests.cs | 4 +- .../Services/HistoryServiceTests.cs | 22 +-- .../Services/LocalModelStorageServiceTests.cs | 25 ++- .../Services/MatchProfileCascadeTests.cs | 2 +- .../Services/PostProcessingPipelineTests.cs | 58 +++---- .../Services/PromptActionServiceTests.cs | 34 ++-- .../Services/SettingsServiceTests.cs | 14 +- .../Services/SnippetServiceTests.cs | 50 +++--- .../Services/SubtitleExporterTests.cs | 8 +- .../VocabularyBoostingServiceTests.cs | 28 ++-- .../AppInsertionStrategyRowTests.cs | 4 +- .../AppearanceSectionViewModelTests.cs | 6 +- .../AudioDuckingServiceTests.cs | 2 +- .../AudioRecordingServiceTests.cs | 21 ++- .../ControlSocketOwnershipTests.cs | 2 +- .../DashboardSectionViewModelTests.cs | 2 +- ...ctationOrchestratorDiscardFeedbackTests.cs | 2 +- ...OrchestratorPromptActionResolutionTests.cs | 6 +- .../DictationShortcutSpecFactoryTests.cs | 4 +- .../DictionarySectionViewModelTests.cs | 10 +- .../EvdevDeviceReaderTests.cs | 4 +- .../EvdevGlobalShortcutBackendTests.cs | 8 +- .../FileTranscriptionSectionViewModelTests.cs | 4 +- .../GnomeShortcutWriterTests.cs | 2 +- .../HistorySectionViewModelTests.cs | 8 +- .../HotkeyServiceTests.cs | 90 +++++----- .../HttpApiAccelerationDtoTests.cs | 12 +- .../HttpApiCorrectionsDtoTests.cs | 2 +- .../HttpApiLocalFileDtoTests.cs | 2 +- .../InputAccessSetupHelperTests.cs | 4 +- ...earnedCorrectionsFeedbackPresenterTests.cs | 2 +- ...nuxDictationReadbackLanguagePolicyTests.cs | 2 +- ...inuxLiveTranscriptionStartupPolicyTests.cs | 32 ++-- .../LinuxSystemTtsProviderTests.cs | 6 +- .../LocalizationResourcesTests.cs | 4 +- .../MediaPauseServiceTests.cs | 2 +- .../PluginCollectionSettingsViewModelTests.cs | 2 +- .../PluginRegistryServiceTests.cs | 12 +- .../ProcessRunnerTests.cs | 4 +- .../ProfilesSectionViewModelTests.cs | 24 +-- .../PromptProcessingServiceTests.cs | 10 +- .../PromptsSectionViewModelTests.cs | 24 +-- .../RecentTranscriptionStoreTests.cs | 6 +- .../RecorderSectionViewModelTests.cs | 19 ++- .../RecordingNotificationServiceTests.cs | 30 ++-- .../SentinelBlockTests.cs | 2 +- .../SettingsBackupServiceTests.cs | 2 +- .../ShortcutDispatcherTests.cs | 4 +- .../ShortcutMatcherTests.cs | 2 +- .../ShortcutsSectionViewModelTests.cs | 32 ++-- .../SnippetsSectionViewModelTests.cs | 6 +- .../SoundFeedbackServiceTests.cs | 6 +- .../SpeechFeedbackServiceTests.cs | 6 +- .../SpokenCommandActionMatcherTests.cs | 8 +- .../StreamingTranscriptionCoordinatorTests.cs | 10 +- ...TargetAppCorrectionLearningServiceTests.cs | 19 ++- .../TestPluginManagerFactory.cs | 2 +- .../TextInsertionServiceTests.cs | 156 +++++++++--------- .../WatchFolderExportBuilderTests.cs | 2 +- .../HistoryRetentionCoordinatorTests.cs | 19 ++- 186 files changed, 788 insertions(+), 743 deletions(-) diff --git a/src/TypeWhisper.Cli/Models/CliOptions.cs b/src/TypeWhisper.Cli/Models/CliOptions.cs index 4a5b41fd4..6ba37306e 100644 --- a/src/TypeWhisper.Cli/Models/CliOptions.cs +++ b/src/TypeWhisper.Cli/Models/CliOptions.cs @@ -183,7 +183,7 @@ public static CliOptions Parse(string[] args) Prompt = prompt, Engine = engine, Model = model, - AwaitDownload = awaitDownload + AwaitDownload = awaitDownload, }; } diff --git a/src/TypeWhisper.Cli/Output/JsonFormatting.cs b/src/TypeWhisper.Cli/Output/JsonFormatting.cs index 23f99982d..362ae1d00 100644 --- a/src/TypeWhisper.Cli/Output/JsonFormatting.cs +++ b/src/TypeWhisper.Cli/Output/JsonFormatting.cs @@ -28,7 +28,7 @@ public static string Prop(JsonElement el, string name) JsonValueKind.Number => value.ToString(), JsonValueKind.True => "true", JsonValueKind.False => "false", - _ => "" + _ => "", }; } diff --git a/src/TypeWhisper.Cli/Program.cs b/src/TypeWhisper.Cli/Program.cs index 1627e1531..df35d5365 100644 --- a/src/TypeWhisper.Cli/Program.cs +++ b/src/TypeWhisper.Cli/Program.cs @@ -57,7 +57,7 @@ private static async Task Main(string[] args) "status" => await StatusCommand.RunAsync(api, options.Json), "models" => await ModelsCommand.RunAsync(api, options.Json), "transcribe" => await TranscribeCommand.RunAsync(api, options), - _ => ConsoleOutput.Error($"Unknown command: {options.Command}") + _ => ConsoleOutput.Error($"Unknown command: {options.Command}"), }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Models/CleanupLevel.cs b/src/TypeWhisper.Core/Models/CleanupLevel.cs index e03f8ff3a..ad7ee444b 100644 --- a/src/TypeWhisper.Core/Models/CleanupLevel.cs +++ b/src/TypeWhisper.Core/Models/CleanupLevel.cs @@ -6,5 +6,5 @@ public enum CleanupLevel None, Light, Medium, - High + High, } diff --git a/src/TypeWhisper.Core/Models/DictionaryEntrySource.cs b/src/TypeWhisper.Core/Models/DictionaryEntrySource.cs index 7b16bc8f3..0087300e8 100644 --- a/src/TypeWhisper.Core/Models/DictionaryEntrySource.cs +++ b/src/TypeWhisper.Core/Models/DictionaryEntrySource.cs @@ -7,5 +7,5 @@ public enum DictionaryEntrySource Manual, Import, CorrectionSuggestion, - AutoLearned + AutoLearned, } diff --git a/src/TypeWhisper.Core/Models/DictionaryEntryType.cs b/src/TypeWhisper.Core/Models/DictionaryEntryType.cs index cc2059db6..d62bcab95 100644 --- a/src/TypeWhisper.Core/Models/DictionaryEntryType.cs +++ b/src/TypeWhisper.Core/Models/DictionaryEntryType.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum DictionaryEntryType { Term, - Correction + Correction, } diff --git a/src/TypeWhisper.Core/Models/DiffSegment.cs b/src/TypeWhisper.Core/Models/DiffSegment.cs index 035a5ab3c..fad784ca5 100644 --- a/src/TypeWhisper.Core/Models/DiffSegment.cs +++ b/src/TypeWhisper.Core/Models/DiffSegment.cs @@ -10,7 +10,7 @@ public enum DiffKind Added, /// Present in the raw text but not the final text. - Removed + Removed, } /// diff --git a/src/TypeWhisper.Core/Models/ErrorLogEntry.cs b/src/TypeWhisper.Core/Models/ErrorLogEntry.cs index 973a66839..6e817a94b 100644 --- a/src/TypeWhisper.Core/Models/ErrorLogEntry.cs +++ b/src/TypeWhisper.Core/Models/ErrorLogEntry.cs @@ -17,7 +17,7 @@ public static ErrorLogEntry Create(string message, string category = ErrorCatego { return new ErrorLogEntry { - Id = Guid.NewGuid().ToString("N"), Timestamp = DateTime.UtcNow, Message = message, Category = category + Id = Guid.NewGuid().ToString("N"), Timestamp = DateTime.UtcNow, Message = message, Category = category, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Models/HistoryRetentionMode.cs b/src/TypeWhisper.Core/Models/HistoryRetentionMode.cs index e111282e2..e74d42585 100644 --- a/src/TypeWhisper.Core/Models/HistoryRetentionMode.cs +++ b/src/TypeWhisper.Core/Models/HistoryRetentionMode.cs @@ -5,5 +5,5 @@ public enum HistoryRetentionMode { Duration, Forever, - UntilAppCloses + UntilAppCloses, } diff --git a/src/TypeWhisper.Core/Models/IndustryPreset.cs b/src/TypeWhisper.Core/Models/IndustryPreset.cs index 3e1301582..5b8cc7bf2 100644 --- a/src/TypeWhisper.Core/Models/IndustryPreset.cs +++ b/src/TypeWhisper.Core/Models/IndustryPreset.cs @@ -33,7 +33,7 @@ public sealed record IndustryPreset(string Id, string Name, string Description, "Legal", "Contract, compliance, and litigation terms.", "legal" - ) + ), ]; public static string[] MergeIntoEnabledPackIds(string[] enabledPackIds, string presetId) diff --git a/src/TypeWhisper.Core/Models/LocalModelStorageUnavailableReason.cs b/src/TypeWhisper.Core/Models/LocalModelStorageUnavailableReason.cs index 80c5423a7..06a965bd1 100644 --- a/src/TypeWhisper.Core/Models/LocalModelStorageUnavailableReason.cs +++ b/src/TypeWhisper.Core/Models/LocalModelStorageUnavailableReason.cs @@ -14,5 +14,5 @@ public enum LocalModelStorageUnavailableReason NotWritable, /// The chosen target folder is nested inside the current storage folder. - NestedUnderCurrentFolder + NestedUnderCurrentFolder, } diff --git a/src/TypeWhisper.Core/Models/MatchKind.cs b/src/TypeWhisper.Core/Models/MatchKind.cs index 49c3ed857..593dd508b 100644 --- a/src/TypeWhisper.Core/Models/MatchKind.cs +++ b/src/TypeWhisper.Core/Models/MatchKind.cs @@ -8,5 +8,5 @@ public enum MatchKind App, Global, ManualOverride, - NoMatch + NoMatch, } diff --git a/src/TypeWhisper.Core/Models/ModelStatus.cs b/src/TypeWhisper.Core/Models/ModelStatus.cs index d7696fba7..11f2527a1 100644 --- a/src/TypeWhisper.Core/Models/ModelStatus.cs +++ b/src/TypeWhisper.Core/Models/ModelStatus.cs @@ -23,7 +23,7 @@ public static ModelStatus DownloadingModel(double progress, double? bytesPerSeco { return new ModelStatus { - Type = ModelStatusType.Downloading, Progress = progress, BytesPerSecond = bytesPerSecond + Type = ModelStatusType.Downloading, Progress = progress, BytesPerSecond = bytesPerSecond, }; } diff --git a/src/TypeWhisper.Core/Models/ModelStatusType.cs b/src/TypeWhisper.Core/Models/ModelStatusType.cs index 30df24e9c..341bc024e 100644 --- a/src/TypeWhisper.Core/Models/ModelStatusType.cs +++ b/src/TypeWhisper.Core/Models/ModelStatusType.cs @@ -7,5 +7,5 @@ public enum ModelStatusType Downloading, Loading, Ready, - Error + Error, } diff --git a/src/TypeWhisper.Core/Models/OverlayPosition.cs b/src/TypeWhisper.Core/Models/OverlayPosition.cs index f2fc0b27d..7a356e027 100644 --- a/src/TypeWhisper.Core/Models/OverlayPosition.cs +++ b/src/TypeWhisper.Core/Models/OverlayPosition.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum OverlayPosition { Top, - Bottom + Bottom, } diff --git a/src/TypeWhisper.Core/Models/OverlayWidget.cs b/src/TypeWhisper.Core/Models/OverlayWidget.cs index 5a1af6686..d00d97ac2 100644 --- a/src/TypeWhisper.Core/Models/OverlayWidget.cs +++ b/src/TypeWhisper.Core/Models/OverlayWidget.cs @@ -10,5 +10,5 @@ public enum OverlayWidget Clock, Profile, HotkeyMode, - AppName + AppName, } diff --git a/src/TypeWhisper.Core/Models/ProfileHotkeyBehavior.cs b/src/TypeWhisper.Core/Models/ProfileHotkeyBehavior.cs index 0028f3e69..8691d6087 100644 --- a/src/TypeWhisper.Core/Models/ProfileHotkeyBehavior.cs +++ b/src/TypeWhisper.Core/Models/ProfileHotkeyBehavior.cs @@ -14,5 +14,5 @@ namespace TypeWhisper.Core.Models; public enum ProfileHotkeyBehavior { StartDictation, - ProcessSelectedText + ProcessSelectedText, } diff --git a/src/TypeWhisper.Core/Models/ProfileStylePreset.cs b/src/TypeWhisper.Core/Models/ProfileStylePreset.cs index fd9cca40d..59d3c51d5 100644 --- a/src/TypeWhisper.Core/Models/ProfileStylePreset.cs +++ b/src/TypeWhisper.Core/Models/ProfileStylePreset.cs @@ -10,5 +10,5 @@ public enum ProfileStylePreset CasualMessage, Developer, TerminalSafe, - MeetingNotes + MeetingNotes, } diff --git a/src/TypeWhisper.Core/Models/RecentTranscriptionSource.cs b/src/TypeWhisper.Core/Models/RecentTranscriptionSource.cs index 53857ff9d..4d880b769 100644 --- a/src/TypeWhisper.Core/Models/RecentTranscriptionSource.cs +++ b/src/TypeWhisper.Core/Models/RecentTranscriptionSource.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum RecentTranscriptionSource { Session, - History + History, } diff --git a/src/TypeWhisper.Core/Models/RecordingMode.cs b/src/TypeWhisper.Core/Models/RecordingMode.cs index 9cff360b1..0a41d7a2d 100644 --- a/src/TypeWhisper.Core/Models/RecordingMode.cs +++ b/src/TypeWhisper.Core/Models/RecordingMode.cs @@ -5,5 +5,5 @@ public enum RecordingMode { Toggle, PushToTalk, - Hybrid + Hybrid, } diff --git a/src/TypeWhisper.Core/Models/SnippetTriggerMode.cs b/src/TypeWhisper.Core/Models/SnippetTriggerMode.cs index 92d0081e7..461fa4d28 100644 --- a/src/TypeWhisper.Core/Models/SnippetTriggerMode.cs +++ b/src/TypeWhisper.Core/Models/SnippetTriggerMode.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum SnippetTriggerMode { Anywhere, - ExactPhrase + ExactPhrase, } diff --git a/src/TypeWhisper.Core/Models/TermPack.cs b/src/TypeWhisper.Core/Models/TermPack.cs index feb394f56..678bb1de5 100644 --- a/src/TypeWhisper.Core/Models/TermPack.cs +++ b/src/TypeWhisper.Core/Models/TermPack.cs @@ -38,7 +38,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "SvelteKit", "Vercel", "Netlify", - "Supabase" + "Supabase", ] ), new( @@ -65,7 +65,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Moq", "CommunityToolkit", "Avalonia", - "Orleans" + "Orleans", ] ), new( @@ -87,7 +87,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "ArgoCD", "Pulumi", "Vault", - "Consul" + "Consul", ] ), new( @@ -109,7 +109,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Pandas", "NumPy", "Scikit-learn", - "RAG" + "RAG", ] ), new( @@ -131,7 +131,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Accessibility", "Responsive", "Breakpoint", - "Viewport" + "Viewport", ] ), new( @@ -153,7 +153,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Sprite", "Tilemap", "NavMesh", - "GameLoop" + "GameLoop", ] ), new( @@ -174,7 +174,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Room", "Firebase", "TestFlight", - "CocoaPods" + "CocoaPods", ] ), new( @@ -196,7 +196,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "SIEM", "SOC", "Ransomware", - "Phishing" + "Phishing", ] ), // These packs originated upstream with German display names and German @@ -221,7 +221,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Supabase", "PlanetScale", "Prisma", - "Drizzle" + "Drizzle", ] ), new( @@ -243,7 +243,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Orthopedics", "Neurology", "Pediatrics", - "Radiology" + "Radiology", ] ), new( @@ -265,7 +265,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Civil law", "Arbitration", "Data protection", - "Warranty" + "Warranty", ] ), new( @@ -287,7 +287,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Cryptocurrency", "Blockchain", "Fintech", - "Liquidity" + "Liquidity", ] ), new( @@ -309,7 +309,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Limiter", "Chorus", "Phaser", - "Arpeggiator" + "Arpeggiator", ] ), new( @@ -366,7 +366,7 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "ARM", "PITI", "Disclosure", - "Zoning" + "Zoning", ] ), new( @@ -423,9 +423,9 @@ public sealed record TermPack(string Id, string Name, string Icon, string[] Term "Grasshopper", "RFI", "Schematic design", - "Construction documents" + "Construction documents", ] - ) + ), ]; public static TermPack? FindById(string id) diff --git a/src/TypeWhisper.Core/Models/TextInsertionStatus.cs b/src/TypeWhisper.Core/Models/TextInsertionStatus.cs index d854f9d99..f0ef74dc6 100644 --- a/src/TypeWhisper.Core/Models/TextInsertionStatus.cs +++ b/src/TypeWhisper.Core/Models/TextInsertionStatus.cs @@ -17,5 +17,5 @@ public enum TextInsertionStatus // Appended after Failed to preserve the persisted numeric ordinals of the // members above: history.json serializes this enum by value (no string // converter), so inserting mid-enum would reinterpret existing records. - ActionUnavailable + ActionUnavailable, } diff --git a/src/TypeWhisper.Core/Models/TextInsertionStrategy.cs b/src/TypeWhisper.Core/Models/TextInsertionStrategy.cs index f4dadea3e..e30bd1cd4 100644 --- a/src/TypeWhisper.Core/Models/TextInsertionStrategy.cs +++ b/src/TypeWhisper.Core/Models/TextInsertionStrategy.cs @@ -6,5 +6,5 @@ public enum TextInsertionStrategy Auto, ClipboardPaste, DirectTyping, - CopyOnly + CopyOnly, } diff --git a/src/TypeWhisper.Core/Models/TranscriptionTask.cs b/src/TypeWhisper.Core/Models/TranscriptionTask.cs index 09a13e65e..9d7ef627c 100644 --- a/src/TypeWhisper.Core/Models/TranscriptionTask.cs +++ b/src/TypeWhisper.Core/Models/TranscriptionTask.cs @@ -4,5 +4,5 @@ namespace TypeWhisper.Core.Models; public enum TranscriptionTask { Transcribe, - Translate + Translate, } diff --git a/src/TypeWhisper.Core/Models/TranslationModelInfo.cs b/src/TypeWhisper.Core/Models/TranslationModelInfo.cs index e7311411c..863f40c0a 100644 --- a/src/TypeWhisper.Core/Models/TranslationModelInfo.cs +++ b/src/TypeWhisper.Core/Models/TranslationModelInfo.cs @@ -52,7 +52,7 @@ public sealed record TranslationModelInfo new("ar", "العربية"), new("hi", "हिन्दी"), new("vi", "Tiếng Việt"), - new("id", "Bahasa Indonesia") + new("id", "Bahasa Indonesia"), ]; // The OPUS-MT models that actually exist (confirmed Xenova ONNX exports). The @@ -102,7 +102,7 @@ public sealed record TranslationModelInfo Pair("en", "hu"), Pair("en", "id"), // Direct non-English pairs - Pair("de", "es") + Pair("de", "es"), ]; // Distinct target languages across every model pair — the targets we can @@ -196,8 +196,8 @@ private static TranslationModelInfo Pair(string src, string tgt, string? repoOve $"{Hf}/opus-mt-{repo}/resolve/main/onnx/decoder_model_quantized.onnx" ), new TranslationFileInfo("tokenizer.json", $"{Hf}/opus-mt-{repo}/resolve/main/tokenizer.json"), - new TranslationFileInfo("config.json", $"{Hf}/opus-mt-{repo}/resolve/main/config.json") - ] + new TranslationFileInfo("config.json", $"{Hf}/opus-mt-{repo}/resolve/main/config.json"), + ], }; } } diff --git a/src/TypeWhisper.Core/Services/AppFormatterService.cs b/src/TypeWhisper.Core/Services/AppFormatterService.cs index d08ceea1d..100068099 100644 --- a/src/TypeWhisper.Core/Services/AppFormatterService.cs +++ b/src/TypeWhisper.Core/Services/AppFormatterService.cs @@ -30,7 +30,7 @@ public static class AppFormatterService ["cmd"] = "code", ["powershell"] = "code", ["pwsh"] = "code", - ["cursor"] = "code" + ["cursor"] = "code", }; /// @@ -48,7 +48,7 @@ public static string Format(string text, string? processName) return format switch { "markdown" => FormatAsMarkdown(text), - _ => text // code + plaintext = passthrough + _ => text, // code + plaintext = passthrough }; } diff --git a/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs b/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs index 55ba14f95..065a69063 100644 --- a/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs +++ b/src/TypeWhisper.Core/Services/CorrectionSuggestionService.cs @@ -63,8 +63,8 @@ string correctedText [ new CorrectionSuggestion { - Original = original, Replacement = replacement, Confidence = Math.Round(confidence, 2) - } + Original = original, Replacement = replacement, Confidence = Math.Round(confidence, 2), + }, ]; } diff --git a/src/TypeWhisper.Core/Services/DetectionFailureTracker.cs b/src/TypeWhisper.Core/Services/DetectionFailureTracker.cs index b32a6335f..713e2f1a3 100644 --- a/src/TypeWhisper.Core/Services/DetectionFailureTracker.cs +++ b/src/TypeWhisper.Core/Services/DetectionFailureTracker.cs @@ -97,7 +97,7 @@ private static string AugmentReason(string compositor, string reason) "hyprland" or "sway" => $"{reason}. Compositor command failed unexpectedly.", "xdotool" => $"{reason}. xdotool only works on X11/XWayland — install a Wayland-native compositor for better detection.", - _ => reason + _ => reason, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Services/DeveloperFormattingService.cs b/src/TypeWhisper.Core/Services/DeveloperFormattingService.cs index 54c08a80d..fbfe8d860 100644 --- a/src/TypeWhisper.Core/Services/DeveloperFormattingService.cs +++ b/src/TypeWhisper.Core/Services/DeveloperFormattingService.cs @@ -32,7 +32,7 @@ private static readonly (Regex Pattern, string Replacement)[] s_symbolReplacemen (SemicolonRegex(), ";"), (CommaRegex(), ","), (UnderscoreRegex(), "_"), - (EqualsRegex(), "=") + (EqualsRegex(), "="), ]; public static string Format(string text) @@ -119,7 +119,7 @@ private static string ReplaceRepeated(string text, Regex regex, string replaceme "camel" => words[0] + string.Concat(words.Skip(1).Select(ToTitleInvariant)), "snake" => string.Join('_', words), "kebab" => string.Join('-', words), - _ => null + _ => null, }; } diff --git a/src/TypeWhisper.Core/Services/FirstRunDefaults.cs b/src/TypeWhisper.Core/Services/FirstRunDefaults.cs index 209a72aad..974531c15 100644 --- a/src/TypeWhisper.Core/Services/FirstRunDefaults.cs +++ b/src/TypeWhisper.Core/Services/FirstRunDefaults.cs @@ -66,7 +66,7 @@ public static PromptAction CreateAutoCleanupAction() IsPreset = false, IsEnabled = false, SortOrder = 0, - ProviderOverride = null + ProviderOverride = null, }; } @@ -85,7 +85,7 @@ public static Profile CreateAutoFormatProfile() PromptActionId = AutoCleanupActionId, HotkeyData = "Ctrl + Alt + E", HotkeyBehavior = ProfileHotkeyBehavior.StartDictation, - StylePreset = ProfileStylePreset.Raw + StylePreset = ProfileStylePreset.Raw, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Services/HistoryInsightsService.cs b/src/TypeWhisper.Core/Services/HistoryInsightsService.cs index c27e0fc24..d42de0587 100644 --- a/src/TypeWhisper.Core/Services/HistoryInsightsService.cs +++ b/src/TypeWhisper.Core/Services/HistoryInsightsService.cs @@ -79,7 +79,7 @@ or TextInsertionStatus.MissingPasteTool ), PromptActionAppliedCount = records.Count(record => record.PromptActionApplied), TranslationAppliedCount = records.Count(record => record.TranslationApplied), - TopApps = topApps + TopApps = topApps, }; } } diff --git a/src/TypeWhisper.Core/Services/HistoryService.Export.cs b/src/TypeWhisper.Core/Services/HistoryService.Export.cs index 7f173c1ea..5268969a5 100644 --- a/src/TypeWhisper.Core/Services/HistoryService.Export.cs +++ b/src/TypeWhisper.Core/Services/HistoryService.Export.cs @@ -129,7 +129,7 @@ public string ExportToJson(IReadOnlyList records) profile = r.ProfileName, insertion_status = r.InsertionStatus.ToString(), insertion_failure_reason = r.InsertionFailureReason, - words = r.WordCount + words = r.WordCount, }); return JsonSerializer.Serialize(data, s_jsonOptions); diff --git a/src/TypeWhisper.Core/Services/ProfileStylePresetService.cs b/src/TypeWhisper.Core/Services/ProfileStylePresetService.cs index 06997af6f..2f40ee498 100644 --- a/src/TypeWhisper.Core/Services/ProfileStylePresetService.cs +++ b/src/TypeWhisper.Core/Services/ProfileStylePresetService.cs @@ -45,7 +45,7 @@ public static ProfileStyleSettings Resolve(ProfileStylePreset preset) CleanupLevel.Medium, true ), - _ => Settings(ProfileStylePreset.Raw, CleanupLevel.None) + _ => Settings(ProfileStylePreset.Raw, CleanupLevel.None), }; } @@ -63,7 +63,7 @@ private static ProfileStyleSettings Settings( CleanupLevel = cleanupLevel, SmartFormattingEnabled = smartFormatting, DeveloperFormattingEnabled = developerFormatting, - TerminalSafe = terminalSafe + TerminalSafe = terminalSafe, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Core/Services/WhisperHallucinationFilter.cs b/src/TypeWhisper.Core/Services/WhisperHallucinationFilter.cs index 73ef1961c..289568d6c 100644 --- a/src/TypeWhisper.Core/Services/WhisperHallucinationFilter.cs +++ b/src/TypeWhisper.Core/Services/WhisperHallucinationFilter.cs @@ -35,7 +35,7 @@ public static class WhisperHallucinationFilter "bye", "bye bye", "goodbye", - "you" + "you", }; /// diff --git a/src/TypeWhisper.Linux/Cli/CommandLineParser.cs b/src/TypeWhisper.Linux/Cli/CommandLineParser.cs index a965f9fa4..26466c18d 100644 --- a/src/TypeWhisper.Linux/Cli/CommandLineParser.cs +++ b/src/TypeWhisper.Linux/Cli/CommandLineParser.cs @@ -19,7 +19,7 @@ internal enum CliActionKind Status, /// Args didn't parse; the driver should print usage and exit non-zero. - Invalid + Invalid, } /// Result of parsing the command line. diff --git a/src/TypeWhisper.Linux/Cli/Commands/RecordCommand.cs b/src/TypeWhisper.Linux/Cli/Commands/RecordCommand.cs index ae3178d93..4b6101e56 100644 --- a/src/TypeWhisper.Linux/Cli/Commands/RecordCommand.cs +++ b/src/TypeWhisper.Linux/Cli/Commands/RecordCommand.cs @@ -19,7 +19,7 @@ public static int Run(string verb) "stop" => JsonControlProtocol.CmdRecordStop, "toggle" => JsonControlProtocol.CmdRecordToggle, "cancel" => JsonControlProtocol.CmdRecordCancel, - _ => null + _ => null, }; if (cmd is null) { diff --git a/src/TypeWhisper.Linux/DiffKindConverters.cs b/src/TypeWhisper.Linux/DiffKindConverters.cs index e1f94fdd5..a9cc38bd3 100644 --- a/src/TypeWhisper.Linux/DiffKindConverters.cs +++ b/src/TypeWhisper.Linux/DiffKindConverters.cs @@ -23,7 +23,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture { DiffKind.Added => s_added, DiffKind.Removed => s_removed, - _ => s_unchanged + _ => s_unchanged, }; public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => @@ -69,7 +69,7 @@ public object Convert(object? value, Type targetType, object? parameter, Culture { "Background" => local ? s_localBackground : s_networkBackground, "Border" => local ? s_localBorder : s_networkBorder, - _ => local ? s_localForeground : s_networkForeground + _ => local ? s_localForeground : s_networkForeground, }; } diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs index 2274e1d62..9e5e4c992 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AccessibilityBusActivationService.cs @@ -117,7 +117,7 @@ private async Task SetPropertyAsync(string property, bool value, Cancellat StatusInterface, property, "b", - value ? "true" : "false" + value ? "true" : "false", ], timeout: s_timeout, ct: ct diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs index c5f5764bd..a5ea5e92f 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiEventClient.cs @@ -1245,7 +1245,7 @@ private async Task TryStartAsync() // body arg, so Arg0="focused" lets the bus daemon filter to focus changes // for us instead of waking us for every state change session-wide. The // in-handler detail/detail1 checks below stay as defense in depth. - Arg0 = FocusedStateName + Arg0 = FocusedStateName, }, s_readSignal, HandleStateChanged, @@ -1258,7 +1258,7 @@ private async Task TryStartAsync() { Type = MessageType.Signal, Interface = EventObjectInterface, - Member = "TextChanged" + Member = "TextChanged", }, s_readSignal, HandleTextChanged, @@ -1278,7 +1278,7 @@ private async Task TryStartAsync() Sender = "org.freedesktop.DBus", Interface = "org.freedesktop.DBus", Member = "NameOwnerChanged", - Arg0 = RegistryBusName + Arg0 = RegistryBusName, }, s_readNameOwnerChanged, HandleRegistryOwnerChanged, @@ -1688,7 +1688,7 @@ int end "org.freedesktop.DBus.Error.UnknownMethod", "org.freedesktop.DBus.Error.ServiceUnknown", // app's a11y bridge went away "org.freedesktop.DBus.Error.NoReply", // app busy / not responding - "org.freedesktop.DBus.Error.Disconnected" + "org.freedesktop.DBus.Error.Disconnected", ]; // at-spi2-core 2.52 (Ubuntu/Mint) answers a property Get for an interface the element does not diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs index 455d7e012..de8f43899 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs @@ -52,7 +52,7 @@ public sealed partial class AtSpiUrlExtractor "opera", "zen", "zen-browser", - "zen-bin" + "zen-bin", }; private static readonly TimeSpan s_cacheTtl = TimeSpan.FromSeconds(10); @@ -685,7 +685,7 @@ params string[] signatureAndArgs destination, path, @interface, - method + method, }; args.AddRange(signatureAndArgs); @@ -716,7 +716,7 @@ private static bool CheckCommandAvailable(string command, string args) using var p = Process.Start( new ProcessStartInfo(command, args) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, } ); p?.WaitForExit(1000); @@ -737,7 +737,7 @@ private static int RunProcess(string fileName, string args, out string? output) using var p = Process.Start( new ProcessStartInfo(fileName, args) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, } ); if (p is null) @@ -779,7 +779,7 @@ private static int RunProcess(string fileName, IReadOnlyList args, out s { var startInfo = new ProcessStartInfo(fileName) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; foreach (var arg in args) { diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/GnomeWindowCallsProvider.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/GnomeWindowCallsProvider.cs index 52ab2b494..31995219c 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/GnomeWindowCallsProvider.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/GnomeWindowCallsProvider.cs @@ -25,7 +25,7 @@ public sealed class GnomeWindowCallsProvider : IActiveWindowProvider private static readonly (string Path, string Interface)[] s_endpoints = [ ("/org/gnome/Shell/Extensions/Windows", "org.gnome.Shell.Extensions.Windows"), - ("/org/gnome/Shell/Extensions/WindowsExt", "org.gnome.Shell.Extensions.WindowsExt") + ("/org/gnome/Shell/Extensions/WindowsExt", "org.gnome.Shell.Extensions.WindowsExt"), ]; public string Name => "gnome-window-calls"; @@ -162,7 +162,7 @@ public bool IsApplicable() { JsonValueKind.Number => idProp.GetInt64().ToString(), JsonValueKind.String => idProp.GetString(), - _ => null + _ => null, }; } diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/ProviderProcessRunner.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/ProviderProcessRunner.cs index 102d547a3..f853a3a36 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/ProviderProcessRunner.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/ProviderProcessRunner.cs @@ -18,7 +18,7 @@ CancellationToken ct { var psi = new ProcessStartInfo(fileName, args) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; return RunAsync(psi, ct); } @@ -36,7 +36,7 @@ CancellationToken ct { var psi = new ProcessStartInfo(fileName) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; foreach (var a in args) { diff --git a/src/TypeWhisper.Linux/Services/AppVersion.cs b/src/TypeWhisper.Linux/Services/AppVersion.cs index a0830b614..c80a0ef84 100644 --- a/src/TypeWhisper.Linux/Services/AppVersion.cs +++ b/src/TypeWhisper.Linux/Services/AppVersion.cs @@ -107,7 +107,7 @@ private static int CompareIdentifier(string a, string b) // Numeric identifiers rank below alphanumeric (SemVer §11.4). (true, _) => -1, (_, true) => 1, - _ => string.CompareOrdinal(a, b) + _ => string.CompareOrdinal(a, b), }; } diff --git a/src/TypeWhisper.Linux/Services/AudioDuckingService.cs b/src/TypeWhisper.Linux/Services/AudioDuckingService.cs index ba9ace70d..34176ffe4 100644 --- a/src/TypeWhisper.Linux/Services/AudioDuckingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioDuckingService.cs @@ -162,7 +162,7 @@ private ProcessRunResult SetSinkInputVolume(string inputId, string[] volumes) var arguments = new List(2 + volumes.Length) { "set-sink-input-volume", - inputId + inputId, }; arguments.AddRange(volumes); return RunPactl(arguments); diff --git a/src/TypeWhisper.Linux/Services/AudioFileService.cs b/src/TypeWhisper.Linux/Services/AudioFileService.cs index 53c3eb3df..346c96d09 100644 --- a/src/TypeWhisper.Linux/Services/AudioFileService.cs +++ b/src/TypeWhisper.Linux/Services/AudioFileService.cs @@ -18,7 +18,7 @@ public sealed class AudioFileService ".mkv", ".avi", ".mov", - ".webm" + ".webm", }; private readonly SystemCommandAvailabilityService _commands; @@ -65,7 +65,7 @@ public async Task LoadAudioAsWavAsync( $"-v error -i \"{filePath}\" -vn -ac 1 -ar 16000 -f wav pipe:1" ) { - RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true + RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; process.Start(); diff --git a/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs b/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs index 902ff1b13..5790711ce 100644 --- a/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs +++ b/src/TypeWhisper.Linux/Services/AudioPlaybackService.cs @@ -92,7 +92,7 @@ public void Play(string audioFileName) channelCount = Channels, sampleFormat = SampleFormat.Float32, suggestedLatency = outputInfo.defaultLowOutputLatency, - hostApiSpecificStreamInfo = IntPtr.Zero + hostApiSpecificStreamInfo = IntPtr.Zero, }; _stream = new PaStream( diff --git a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs index 30dec1d2b..1be5f896e 100644 --- a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs @@ -938,7 +938,7 @@ PaStream.Callback callback channelCount = Channels, sampleFormat = SampleFormat.Float32, suggestedLatency = inputInfo.defaultLowInputLatency, - hostApiSpecificStreamInfo = IntPtr.Zero + hostApiSpecificStreamInfo = IntPtr.Zero, }; return new PaStream( diff --git a/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs b/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs index d57556476..9aaf96693 100644 --- a/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs @@ -49,7 +49,7 @@ public sealed partial class BrowserAccessibilitySetupHelper "microsoft-edge.desktop", "brave-browser.desktop", "vivaldi-stable.desktop", - "opera.desktop" + "opera.desktop", ]; private static readonly string[] s_firefoxLauncherNames = @@ -61,13 +61,13 @@ public sealed partial class BrowserAccessibilitySetupHelper "io.gitlab.librewolf-community.desktop", "zen.desktop", "app.zen_browser.zen.desktop", - "io.github.zen_browser.zen.desktop" + "io.github.zen_browser.zen.desktop", ]; private static readonly string[] s_systemLauncherDirectories = [ "/usr/share/applications", - "/var/lib/flatpak/exports/share/applications" + "/var/lib/flatpak/exports/share/applications", ]; /// @@ -561,7 +561,7 @@ private static IEnumerable EnumerateFirefoxProfileDirs() Path.Join(home, ".var", "app", "app.zen_browser.zen", ".zen"), Path.Join(home, ".var", "app", "io.github.zen_browser.zen", ".zen"), Path.Join(home, ".zen"), Path.Join(home, ".var", "app", "io.gitlab.librewolf-community", ".librewolf"), - Path.Join(home, ".librewolf") + Path.Join(home, ".librewolf"), }; foreach (var root in roots) { diff --git a/src/TypeWhisper.Linux/Services/DictationToggleGate.cs b/src/TypeWhisper.Linux/Services/DictationToggleGate.cs index 68ba4474e..72723c54b 100644 --- a/src/TypeWhisper.Linux/Services/DictationToggleGate.cs +++ b/src/TypeWhisper.Linux/Services/DictationToggleGate.cs @@ -4,7 +4,7 @@ internal enum DictationStopGateResult { Acquired, PendingStartupCompletion, - Busy + Busy, } /// diff --git a/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs b/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs index 8e94938b9..6d55c8ba4 100644 --- a/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs +++ b/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs @@ -119,7 +119,7 @@ CancellationToken cancellationToken segment.Start, segment.End )) - .ToArray() + .ToArray(), }; var pipelineResult = await pipeline.ProcessAsync( @@ -129,7 +129,7 @@ CancellationToken cancellationToken VocabularyBooster = currentSettings.VocabularyBoostingEnabled ? vocabularyBoosting.Apply : null, - DictionaryCorrector = dictionary.ApplyCorrections + DictionaryCorrector = dictionary.ApplyCorrections, }, cancellationToken ); @@ -206,7 +206,7 @@ CancellationToken cancellationToken $"Ambiguous transcription model '{options.ModelId}': provided by multiple engines. " + "Specify the engine explicitly or use the full plugin-qualified model id." ), - _ => ModelManagerService.GetPluginModelId(matches[0].GetTranscriptionSelectionId(), options.ModelId) + _ => ModelManagerService.GetPluginModelId(matches[0].GetTranscriptionSelectionId(), options.ModelId), }; } } diff --git a/src/TypeWhisper.Linux/Services/GnomeWindowCallsSetupHelper.cs b/src/TypeWhisper.Linux/Services/GnomeWindowCallsSetupHelper.cs index 7d336178b..71db5b47e 100644 --- a/src/TypeWhisper.Linux/Services/GnomeWindowCallsSetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/GnomeWindowCallsSetupHelper.cs @@ -20,7 +20,7 @@ public sealed class GnomeWindowCallsSetupHelper private static readonly (string Path, string Interface)[] s_endpoints = [ ("/org/gnome/Shell/Extensions/Windows", "org.gnome.Shell.Extensions.Windows"), - ("/org/gnome/Shell/Extensions/WindowsExt", "org.gnome.Shell.Extensions.WindowsExt") + ("/org/gnome/Shell/Extensions/WindowsExt", "org.gnome.Shell.Extensions.WindowsExt"), ]; // kept instance: injected as a DI/test seam by callers @@ -104,7 +104,7 @@ public bool TryOpenInstallPage() using var p = Process.Start( new ProcessStartInfo("xdg-open", ExtensionInstallUrl) { - UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true + UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, } ); return p is not null; diff --git a/src/TypeWhisper.Linux/Services/HistoryRetentionCoordinator.cs b/src/TypeWhisper.Linux/Services/HistoryRetentionCoordinator.cs index 57110ef2f..b1baee6f3 100644 --- a/src/TypeWhisper.Linux/Services/HistoryRetentionCoordinator.cs +++ b/src/TypeWhisper.Linux/Services/HistoryRetentionCoordinator.cs @@ -117,6 +117,6 @@ private enum HistoryRetentionTrigger Startup, SettingsChanged, HistoryChanged, - Shutdown + Shutdown, } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DesktopDetector.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DesktopDetector.cs index e3b101739..686406ad8 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DesktopDetector.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DesktopDetector.cs @@ -98,7 +98,7 @@ public static string DisplayName(string? id = null) "kde" => "KDE Plasma", "hyprland" => "Hyprland", "sway" => "Sway", - _ => RawXdgFallback() + _ => RawXdgFallback(), }; } @@ -176,7 +176,7 @@ private static string RawXdgFallback() "Pantheon" => "Pantheon", "Budgie" => "Budgie", "Deepin" => "Deepin", - _ => tokens[^1] + _ => tokens[^1], }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs index 9bebaa44d..b50541fb0 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs @@ -47,7 +47,7 @@ public static class DictationShortcutSpecFactory cancelTrigger, cancelTrigger is null ? null : $"{gui} record cancel" ), - _ => null + _ => null, }; } diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs index c53057f57..e44ae11a0 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs @@ -113,7 +113,7 @@ public async Task WriteAsync(DeShortcutSpec spec, Cancell var (key, value) in new[] { ("name", spec.DisplayName), ("command", spec.OnPressCommand), - ("binding", FormatGnomeAccel(spec.Trigger)) + ("binding", FormatGnomeAccel(spec.Trigger)), } ) { @@ -475,7 +475,7 @@ public static string FormatGnomeAccel(string trigger) "shift" => "Shift", "alt" => "Alt", "super" or "win" or "windows" or "cmd" or "meta" => "Super", - _ => null + _ => null, }; if (modifier is null) { diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs index 774d3d393..52910b8ed 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs @@ -35,7 +35,7 @@ public sealed class InputAccessSetupHelper private static readonly string[] s_seatManagerDirectoryPaths = [ "/run/systemd/seats", - "/run/elogind/seats" + "/run/elogind/seats", ]; // System config dir holding the udev rule. Always /etc in production. Tests diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LinuxKeyMap.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LinuxKeyMap.cs index bcd179aef..5fcae929f 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LinuxKeyMap.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LinuxKeyMap.cs @@ -33,7 +33,7 @@ public static ModifierMask ToModifier(int linuxCode) KeyRightalt => ModifierMask.RightAlt, KeyLeftmeta => ModifierMask.LeftMeta, KeyRightmeta => ModifierMask.RightMeta, - _ => ModifierMask.None + _ => ModifierMask.None, }; } @@ -141,7 +141,7 @@ public static bool IsModifier(int linuxCode) KeyLeftmeta => KeyCode.VcLeftMeta, KeyRightmeta => KeyCode.VcRightMeta, - _ => null + _ => null, }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LogindSessionActivityMonitor.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LogindSessionActivityMonitor.cs index 09bd8c60b..2749c4230 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LogindSessionActivityMonitor.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/LogindSessionActivityMonitor.cs @@ -195,7 +195,7 @@ private static bool IndicatesLogindAbsent(Exception ex) dbus.ErrorName is "org.freedesktop.DBus.Error.ServiceUnknown" or "org.freedesktop.DBus.Error.NameHasNoOwner" or "org.freedesktop.DBus.Error.FileNotFound", - _ => false + _ => false, }; } @@ -332,7 +332,7 @@ string sessionPath Interface = PropertiesInterface, Path = sessionPath, Member = "PropertiesChanged", - Arg0 = SessionInterface + Arg0 = SessionInterface, }, s_readPropertiesChanged, HandlePropertiesChanged, @@ -355,7 +355,7 @@ bool locked Sender = LoginService, Interface = SessionInterface, Path = sessionPath, - Member = member + Member = member, }, locked ? s_readLockSignal : s_readUnlockSignal, locked ? HandleLockSignal : HandleUnlockSignal, diff --git a/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs b/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs index 32675a727..2eadcdd83 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs @@ -203,7 +203,7 @@ private static ModifierMask NormalizeMask(KeyCode key, ModifierMask mask) KeyCode.VcRightAlt => ModifierMask.RightAlt, KeyCode.VcLeftMeta => ModifierMask.LeftMeta, KeyCode.VcRightMeta => ModifierMask.RightMeta, - _ => ModifierMask.None + _ => ModifierMask.None, }; return modBit == ModifierMask.None ? mask : mask & ~modBit; } diff --git a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs index 3a2891957..ce90208ad 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs @@ -306,7 +306,7 @@ private void HandleRelease(KeyCode key, ModifierMask mods, GlobalShortcutSet set { _pendingSelectionWorkflows[key] = releasedWorkflow with { - TriggerReleased = true + TriggerReleased = true, }; } @@ -543,7 +543,7 @@ private enum SelectionWorkflowKind PromptPalette, PromptAction, ProfileTextProcessing, - TransformSelection + TransformSelection, } private readonly record struct PendingSelectionWorkflow( diff --git a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutMatcher.cs b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutMatcher.cs index c0305a54f..c5dcaa3c2 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutMatcher.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutMatcher.cs @@ -16,7 +16,7 @@ internal enum ShortcutMatchKind TransformSelection, Cancel, PromptAction, - Profile + Profile, } /// diff --git a/src/TypeWhisper.Linux/Services/HotkeyService.cs b/src/TypeWhisper.Linux/Services/HotkeyService.cs index 28f091564..07c3f3175 100644 --- a/src/TypeWhisper.Linux/Services/HotkeyService.cs +++ b/src/TypeWhisper.Linux/Services/HotkeyService.cs @@ -12,7 +12,7 @@ public enum HotkeyCandidateValidationStatus CollidesWithFixedBinding, CollidesWithPromptAction, CollidesWithProfile, - MissingEnabledPromptAction + MissingEnabledPromptAction, } public sealed record HotkeyCandidateValidationResult( @@ -552,7 +552,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithFixedBinding, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -567,7 +567,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithPromptAction, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -581,7 +581,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithProfile, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -623,7 +623,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.MissingEnabledPromptAction, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -637,7 +637,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithFixedBinding, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -651,7 +651,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithPromptAction, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -666,7 +666,7 @@ IEnumerable profiles return parsed with { Status = HotkeyCandidateValidationStatus.CollidesWithProfile, - NormalizedHotkey = null + NormalizedHotkey = null, }; } @@ -1213,7 +1213,7 @@ KeyCode.VcLeftAlt or KeyCode.VcRightAlt => ModifierMask.LeftAlt | ModifierMask.RightAlt, KeyCode.VcLeftMeta or KeyCode.VcRightMeta => ModifierMask.LeftMeta | ModifierMask.RightMeta, - _ => ModifierMask.None + _ => ModifierMask.None, }; } @@ -1296,7 +1296,7 @@ private static string FormatHotkey(KeyCode key, ModifierMask mods) KeyCode.VcRightAlt => "Right Alt", KeyCode.VcLeftMeta => "Left Meta", KeyCode.VcRightMeta => "Right Meta", - _ => null + _ => null, }; if (sideSpecific is not null) { @@ -1419,7 +1419,7 @@ private static bool TryParseHotkey(string text, out KeyCode? key, out ModifierMa "right" => KeyCode.VcRight, "up" => KeyCode.VcUp, "down" => KeyCode.VcDown, - _ => (KeyCode?)null + _ => (KeyCode?)null, }; if (named is not null) { @@ -1467,7 +1467,7 @@ private static bool TryParseSideSpecificSingleModifier(string token, out KeyCode "right alt" => KeyCode.VcRightAlt, "left meta" or "left super" or "left win" => KeyCode.VcLeftMeta, "right meta" or "right super" or "right win" => KeyCode.VcRightMeta, - _ => KeyCode.VcUndefined + _ => KeyCode.VcUndefined, }; return key != KeyCode.VcUndefined; } @@ -1478,6 +1478,6 @@ private enum HotkeyBinding PromptPalette, RecentTranscriptions, CopyLastTranscription, - TransformSelection + TransformSelection, } } diff --git a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs index e3bac0300..2626900d5 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs @@ -576,7 +576,7 @@ await writer JsonControlProtocol.CmdRecordCancel => await HandleCancelAsync() .ConfigureAwait(false), JsonControlProtocol.CmdStatus => HandleStatus(), - _ => JsonControlProtocol.SerializeError(JsonControlProtocol.ErrUnknownCommand) + _ => JsonControlProtocol.SerializeError(JsonControlProtocol.ErrUnknownCommand), }; await writer.WriteLineAsync(response).ConfigureAwait(false); @@ -684,7 +684,7 @@ private string HandleStatus() Backend = _hotkey?.ActiveBackendId, SupportsPressRelease = _hotkey?.ActiveBackendSupportsPressRelease ?? false, ActiveBinding = _hotkey?.CurrentHotkeyString, - Mode = _settings?.Current.Mode.ToString() + Mode = _settings?.Current.Mode.ToString(), }; return JsonControlProtocol.SerializeStatus(response); } diff --git a/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs b/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs index eb6e272a0..1aa1f471c 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs @@ -54,7 +54,7 @@ internal static class JsonControlProtocol // the documented response shape (camelCase would not match the spec). PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false + WriteIndented = false, }; public static string SerializeError(string code) diff --git a/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs b/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs index 5809463d0..2e3081511 100644 --- a/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs +++ b/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs @@ -534,7 +534,7 @@ private async Task EnsureConnectedAsync() Sender = NotificationsService, Interface = NotificationsInterface, Path = NotificationsPath, - Member = "ActionInvoked" + Member = "ActionInvoked", }, s_readActionInvoked, HandleActionInvoked, @@ -549,7 +549,7 @@ private async Task EnsureConnectedAsync() Sender = NotificationsService, Interface = NotificationsInterface, Path = NotificationsPath, - Member = "NotificationClosed" + Member = "NotificationClosed", }, s_readClosed, HandleClosed, diff --git a/src/TypeWhisper.Linux/Services/LinuxDictationReadbackLanguagePolicy.cs b/src/TypeWhisper.Linux/Services/LinuxDictationReadbackLanguagePolicy.cs index 2cd797ad1..3fa4b3689 100644 --- a/src/TypeWhisper.Linux/Services/LinuxDictationReadbackLanguagePolicy.cs +++ b/src/TypeWhisper.Linux/Services/LinuxDictationReadbackLanguagePolicy.cs @@ -61,7 +61,7 @@ target is not null { FinalLanguage.TranslatedToTarget => target, FinalLanguage.Rewritten => null, - _ => engineTranslatedToEnglish ? "en" : sourceLanguage + _ => engineTranslatedToEnglish ? "en" : sourceLanguage, }; } @@ -103,6 +103,6 @@ private enum FinalLanguage { Unchanged, // No post-processing step changed the language. TranslatedToTarget, // Translation step ran and changed the language. - Rewritten // Prompt/plugin rewrote into an unknown language. + Rewritten, // Prompt/plugin rewrote into an unknown language. } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/LinuxDictationShortSpeechPolicy.cs b/src/TypeWhisper.Linux/Services/LinuxDictationShortSpeechPolicy.cs index 207523e7c..97a145c21 100644 --- a/src/TypeWhisper.Linux/Services/LinuxDictationShortSpeechPolicy.cs +++ b/src/TypeWhisper.Linux/Services/LinuxDictationShortSpeechPolicy.cs @@ -4,7 +4,7 @@ internal enum LinuxShortSpeechDecision { DiscardTooShort, DiscardNoSpeech, - Transcribe + Transcribe, } internal static class LinuxDictationShortSpeechPolicy diff --git a/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs b/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs index e3e1df619..9a7810966 100644 --- a/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs +++ b/src/TypeWhisper.Linux/Services/LinuxLiveTranscriptionStartupPolicy.cs @@ -7,7 +7,7 @@ internal enum LiveTranscriptionMode { None, Polling, - Streaming + Streaming, } // Selects the live-transcription mode for the recording loop. Ported from diff --git a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs index 901c7e8ed..fca292fa4 100644 --- a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs +++ b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs @@ -152,7 +152,7 @@ private static IReadOnlyList BuildArguments( { "espeak" or "espeak-ng" => ["-v", language, text], "spd-say" => ["--wait", "-l", language, text], - _ => BuildDefaultArguments(command, text) + _ => BuildDefaultArguments(command, text), }; } diff --git a/src/TypeWhisper.Linux/Services/Localization/Loc.cs b/src/TypeWhisper.Linux/Services/Localization/Loc.cs index 91d0d503e..cb1a4089f 100644 --- a/src/TypeWhisper.Linux/Services/Localization/Loc.cs +++ b/src/TypeWhisper.Linux/Services/Localization/Loc.cs @@ -25,7 +25,7 @@ public sealed class Loc : INotifyPropertyChanged private static readonly JsonSerializerOptions s_jsonOptions = new() { - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; private readonly Dictionary> _strings = []; @@ -193,7 +193,7 @@ private static List BuildUiLanguageOptions(List codes) ["ru"] = "Русский", ["ja"] = "日本語", ["zh"] = "中文", - ["ko"] = "한국어" + ["ko"] = "한국어", }; var options = new List { new(null, "Auto (System)") }; diff --git a/src/TypeWhisper.Linux/Services/MemoryService.cs b/src/TypeWhisper.Linux/Services/MemoryService.cs index c90db2876..a26d6f980 100644 --- a/src/TypeWhisper.Linux/Services/MemoryService.cs +++ b/src/TypeWhisper.Linux/Services/MemoryService.cs @@ -138,7 +138,7 @@ string userPrompt ProviderId = providerId, ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = null + InjectedMemoryContext = null, }; capture.Add(provenance); return provenance; diff --git a/src/TypeWhisper.Linux/Services/ModelManagerService.cs b/src/TypeWhisper.Linux/Services/ModelManagerService.cs index c339732b2..aa655bdd7 100644 --- a/src/TypeWhisper.Linux/Services/ModelManagerService.cs +++ b/src/TypeWhisper.Linux/Services/ModelManagerService.cs @@ -581,7 +581,7 @@ public void MigrateSettings() ), "plugin:com.typewhisper.voxtral:mistral-whisper" => GetPluginModelId("com.typewhisper.voxtral", "voxtral-mini-latest"), - _ => modelId + _ => modelId, }; } @@ -596,7 +596,7 @@ public void MigrateSettings() { "plugin:com.typewhisper.voxtral:mistral-whisper" => GetPluginModelId("com.typewhisper.voxtral", "voxtral-mini-latest"), - _ => modelId + _ => modelId, }; } @@ -608,7 +608,7 @@ private static TranscriptionAccelerationPreference GetAccelerationPreference(str AppSettings.LocalModelAccelerationNvidiaCuda => TranscriptionAccelerationPreference.NvidiaCuda, AppSettings.LocalModelAccelerationCpu => TranscriptionAccelerationPreference.Cpu, - _ => TranscriptionAccelerationPreference.Auto + _ => TranscriptionAccelerationPreference.Auto, }; } @@ -1111,7 +1111,7 @@ public async Task TranscribeAsync( Text = result.Text, DetectedLanguage = result.DetectedLanguage, Duration = result.DurationSeconds, - NoSpeechProbability = result.NoSpeechProbability + NoSpeechProbability = result.NoSpeechProbability, }; } } diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs index 1e7972e5a..b4b916d6e 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginLocalityClassifier.cs @@ -24,7 +24,7 @@ public static class PluginLocalityClassifier "com.typewhisper.file-memory", "com.typewhisper.obsidian", "com.typewhisper.script", - "com.typewhisper.webhook" + "com.typewhisper.webhook", ]; public static bool IsLocal(PluginManifest manifest) => diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs index f061063cf..32581c1ae 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginManager.cs @@ -19,7 +19,7 @@ public sealed class PluginManager : IDisposable private static readonly HashSet s_defaultEnabledPluginIds = new(StringComparer.Ordinal) { "com.typewhisper.whisper-cpp", // offline transcription (recommended default) - "com.typewhisper.sherpa-onnx" // offline transcription + "com.typewhisper.sherpa-onnx", // offline transcription }; private readonly HashSet _activatedPlugins = []; @@ -579,8 +579,8 @@ private static string ResolveErrorCategory(LoadedPlugin plugin) { ITranscriptionEnginePlugin => ErrorCategory.Transcription, ILlmProviderPlugin => ErrorCategory.Prompt, - _ => ErrorCategory.Plugin - } + _ => ErrorCategory.Plugin, + }, }; } @@ -757,7 +757,7 @@ private async Task MigrateApiKeysAsync() current with { GroqApiKey = migratedGroq ? "" : current.GroqApiKey, - OpenAiApiKey = migratedOpenAi ? "" : current.OpenAiApiKey + OpenAiApiKey = migratedOpenAi ? "" : current.OpenAiApiKey, } ); } diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs index 2828d1fb7..c04749141 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs @@ -48,7 +48,7 @@ public sealed class PluginRegistryService "com.typewhisper.qwen3-stt", "com.typewhisper.obsidian", "com.typewhisper.linear", - "com.typewhisper.openai-compatible" + "com.typewhisper.openai-compatible", }; private readonly HttpClient _httpClient; diff --git a/src/TypeWhisper.Linux/Services/Plugins/RegistryPlugin.cs b/src/TypeWhisper.Linux/Services/Plugins/RegistryPlugin.cs index 60a3146f3..4e0b384eb 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/RegistryPlugin.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/RegistryPlugin.cs @@ -35,5 +35,5 @@ public enum PluginInstallState UpdateAvailable, // ReSharper disable once UnusedMember.Global member of the JsonStringEnumConverter-serialized install-state vocabulary (PluginInstallState); kept for completeness, not currently produced in-tree - Bundled + Bundled, } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/ProcessPriority.cs b/src/TypeWhisper.Linux/Services/ProcessPriority.cs index 9e5e2cfaa..135ebd611 100644 --- a/src/TypeWhisper.Linux/Services/ProcessPriority.cs +++ b/src/TypeWhisper.Linux/Services/ProcessPriority.cs @@ -21,7 +21,7 @@ public static string ResetToDefaults() var results = new List { Run("renice", $"-n 0 -p {pid}"), - Run("ionice", $"-c 2 -n 4 -p {pid}") + Run("ionice", $"-c 2 -n 4 -p {pid}"), }; return string.Join("; ", results); @@ -39,7 +39,7 @@ private static string Run(string file, string args) RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); if (p is null) diff --git a/src/TypeWhisper.Linux/Services/ProcessRunner.cs b/src/TypeWhisper.Linux/Services/ProcessRunner.cs index af913a477..b91a12f83 100644 --- a/src/TypeWhisper.Linux/Services/ProcessRunner.cs +++ b/src/TypeWhisper.Linux/Services/ProcessRunner.cs @@ -94,7 +94,7 @@ public async Task RunAsync( RedirectStandardError = true, RedirectStandardInput = standardInput is not null, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, }; foreach (var arg in args) { diff --git a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs index 30c971c98..9594a36a5 100644 --- a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs +++ b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs @@ -228,7 +228,7 @@ public async Task ProcessSystemPromptAsync( ProviderId = providerId, ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = injectedMemoryContext + InjectedMemoryContext = injectedMemoryContext, }; capture.Add(provenance); return provenance; diff --git a/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs b/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs index 60273b89f..ce01f7e42 100644 --- a/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs +++ b/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs @@ -103,7 +103,7 @@ public static string BodyFor(RecordingMode mode) { RecordingMode.Toggle => Loc.Instance["Notify.BodyToggle"], RecordingMode.PushToTalk => Loc.Instance["Notify.BodyPushToTalk"], - _ => Loc.Instance["Notify.BodyHybrid"] + _ => Loc.Instance["Notify.BodyHybrid"], }; } @@ -281,7 +281,7 @@ uint replaceId "TypeWhisper", replaceId.ToString(), ResolveIconPath(), presentation.Summary, presentation.Body, "[]", // actions "{}", // hints - presentation.ExpireTimeout.ToString() + presentation.ExpireTimeout.ToString(), ], timeout: s_callTimeout ) @@ -317,7 +317,7 @@ await _runner [ "call", "--session", "--dest", "org.freedesktop.Notifications", "--object-path", "/org/freedesktop/Notifications", "--method", - "org.freedesktop.Notifications.CloseNotification", id.ToString() + "org.freedesktop.Notifications.CloseNotification", id.ToString(), ], timeout: s_callTimeout ) diff --git a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs index 5a28bb6db..e982803cb 100644 --- a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs +++ b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs @@ -15,7 +15,7 @@ internal enum StartupRestoreStatus Applied, PriorGenerationRestored, LockUnavailable, - UnresolvedFailure + UnresolvedFailure, } internal sealed record StartupRestoreResult( @@ -60,7 +60,7 @@ public sealed class SettingsBackupService [ "settings.json", "settings.json.bak", - "linux-preferences.json" + "linux-preferences.json", ]; private static readonly string[] s_backupDirectoryRoots = ["Data", "PluginData"]; @@ -78,7 +78,7 @@ public sealed class SettingsBackupService private static readonly JsonSerializerOptions s_transactionJsonOptions = new() { WriteIndented = true, - Converters = { new JsonStringEnumConverter() } + Converters = { new JsonStringEnumConverter() }, }; private readonly string _basePath; @@ -133,7 +133,7 @@ public SettingsBackupResult CreateBackup(string destinationZipPath) kind = ManifestKind, createdUtc = DateTimeOffset.UtcNow, includes = s_manifestIncludes, - excludes = s_manifestExcludes + excludes = s_manifestExcludes, }; var manifestEntry = archive.CreateEntry(ManifestEntryName, CompressionLevel.Optimal); using (var writer = new StreamWriter(manifestEntry.Open())) @@ -254,7 +254,7 @@ public SettingsBackupResult StageRestore(string sourceZipPath) { Version = PendingStateVersion, FileCount = fileCount, - UncompressedBytes = bytes + UncompressedBytes = bytes, } ); @@ -369,7 +369,7 @@ private StartupRestoreResult ApplyPendingRestoreUnderLock() ), RestoreJournalPhase.Committed => FinishCommittedTransaction(), RestoreJournalPhase.RolledBack => FinishRolledBackTransaction(), - _ => throw new InvalidDataException("The settings restore journal phase is invalid.") + _ => throw new InvalidDataException("The settings restore journal phase is invalid."), }; } @@ -379,7 +379,7 @@ private StartupRestoreResult ApplyPendingRestoreUnderLock() .Select(relativePath => new RestoreJournalItem { RelativePath = relativePath, - OriginallyExisted = File.Exists(GetLiveTargetPath(relativePath)) + OriginallyExisted = File.Exists(GetLiveTargetPath(relativePath)), }) .ToArray(); @@ -397,7 +397,7 @@ private StartupRestoreResult ApplyPendingRestoreUnderLock() { Version = JournalVersion, Phase = RestoreJournalPhase.Prepared, - Items = items + Items = items, }; try @@ -563,7 +563,7 @@ private void MarkUncommittedRequestRolledBackBestEffort(RestoreJournalItem[] ite { Version = JournalVersion, Phase = RestoreJournalPhase.RolledBack, - Items = items + Items = items, } ); TryCleanupPendingDirectory(); @@ -683,7 +683,7 @@ RestoreJournalPhase phase { Version = journal.Version, Phase = phase, - Items = journal.Items + Items = journal.Items, }; } @@ -1005,7 +1005,7 @@ private enum RestoreJournalPhase { Prepared, Committed, - RolledBack + RolledBack, } private sealed class PendingState diff --git a/src/TypeWhisper.Linux/Services/Setup/ISetupTask.cs b/src/TypeWhisper.Linux/Services/Setup/ISetupTask.cs index 59f564cbb..2303f4389 100644 --- a/src/TypeWhisper.Linux/Services/Setup/ISetupTask.cs +++ b/src/TypeWhisper.Linux/Services/Setup/ISetupTask.cs @@ -7,7 +7,7 @@ namespace TypeWhisper.Linux.Services.Setup; public enum SetupTaskSeverity { Required, - Recommended + Recommended, } /// @@ -25,7 +25,7 @@ public enum SetupTaskStatusKind Working, /// The last action failed; the user can retry or fall back to the manual command. - Failed + Failed, } /// diff --git a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandIntent.cs b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandIntent.cs index c05ec9342..0b178e1a4 100644 --- a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandIntent.cs +++ b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandIntent.cs @@ -17,7 +17,7 @@ public static class SpokenCommandIntent private static readonly HashSet s_selectionReferents = new(StringComparer.OrdinalIgnoreCase) { - "this", "that", "it", "these", "those", "them", "selection", "highlighted", "selected" + "this", "that", "it", "these", "those", "them", "selection", "highlighted", "selected", }; private static readonly string[] s_selectionPhrases = @@ -32,7 +32,7 @@ public static class SpokenCommandIntent "translate", "shorten", "lengthen", "summarize", "summarise", "rewrite", "rephrase", "reword", "reformat", "format", "fix", "correct", "proofread", "simplify", "condense", "expand", "capitalize", "capitalise", "uppercase", "lowercase", "bold", "italicize", "italicise", - "punctuate" + "punctuate", }; // A command that opens with one of these asks for new text from scratch ("write an email", @@ -42,7 +42,7 @@ public static class SpokenCommandIntent // demoting those to create would hijack a legitimate invocation of that saved action. private static readonly HashSet s_leadingCreationVerbs = new(StringComparer.OrdinalIgnoreCase) { - "write", "draft", "compose", "create", "generate" + "write", "draft", "compose", "create", "generate", }; public static bool RefersToSelection(string command) diff --git a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandKeyphrase.cs b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandKeyphrase.cs index 909f9886c..ece602578 100644 --- a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandKeyphrase.cs +++ b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandKeyphrase.cs @@ -45,7 +45,7 @@ public static bool TryStrip(string rawText, string keyphrase, out string command { <= 3 => 0, <= 6 => 1, - _ => 2 + _ => 2, }; var tokens = Tokenize(rawText); diff --git a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandText.cs b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandText.cs index 0fa240ab6..bea7290bc 100644 --- a/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandText.cs +++ b/src/TypeWhisper.Linux/Services/SpokenCommand/SpokenCommandText.cs @@ -15,7 +15,7 @@ internal static class SpokenCommandText public static readonly IReadOnlySet LeadingFillers = new HashSet(StringComparer.OrdinalIgnoreCase) { - "please", "pls", "kindly", "just", "can", "could", "would", "you" + "please", "pls", "kindly", "just", "can", "could", "would", "you", }; // Splits on whitespace and keeps only alphanumerics per token, dropping empties. Casing is diff --git a/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs b/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs index 4fa46061a..1425b153f 100644 --- a/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs +++ b/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs @@ -195,7 +195,7 @@ public async Task StartAsync(CancellationToken ct) var channel = Channel.CreateBounded(new BoundedChannelOptions(ChannelCapacity) { - FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false + FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false, }); var handler = OnTranscriptReceived; diff --git a/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs index 73794a4a2..292cb0303 100644 --- a/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs +++ b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs @@ -37,12 +37,12 @@ public sealed partial class SystemCommandAvailabilityService "/usr/local/cuda-12.1/lib64", "/usr/local/cuda-12.1/targets/x86_64-linux/lib", "/usr/local/cuda-12.0/lib64", - "/usr/local/cuda-12.0/targets/x86_64-linux/lib" + "/usr/local/cuda-12.0/targets/x86_64-linux/lib", ]; private static readonly string[] s_requiredCuda12RuntimeLibraries = [ "libcudart.so.12", - "libcublas.so.12" + "libcublas.so.12", ]; private static readonly Lock s_cudaPreloadLock = new(); @@ -349,7 +349,7 @@ public async Task RunCudaBenchmarkAsync( RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); @@ -598,7 +598,7 @@ private static LinuxCapabilitySnapshot BuildSnapshot() RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); if (p is null) @@ -721,7 +721,7 @@ private static bool FindInLdCache(string libraryName) RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); diff --git a/src/TypeWhisper.Linux/Services/TranslationService.cs b/src/TypeWhisper.Linux/Services/TranslationService.cs index c534cdf66..191731652 100644 --- a/src/TypeWhisper.Linux/Services/TranslationService.cs +++ b/src/TypeWhisper.Linux/Services/TranslationService.cs @@ -113,7 +113,7 @@ string userPrompt ProviderId = providerId, ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = null + InjectedMemoryContext = null, }; capture.Add(provenance); return provenance; @@ -263,7 +263,7 @@ private static LoadedTranslationModel LoadModel(string modelDir) { GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL, InterOpNumThreads = 1, - IntraOpNumThreads = Environment.ProcessorCount + IntraOpNumThreads = Environment.ProcessorCount, }; var encoder = new InferenceSession( @@ -299,7 +299,7 @@ private static string RunInference(LoadedTranslationModel model, string text) using var encoderResults = model.Encoder.Run([ NamedOnnxValue.CreateFromTensor("input_ids", inputIdsTensor), - NamedOnnxValue.CreateFromTensor("attention_mask", attentionMask) + NamedOnnxValue.CreateFromTensor("attention_mask", attentionMask), ]); var encoderHidden = @@ -321,7 +321,7 @@ encoderResults[0].Value as DenseTensor { NamedOnnxValue.CreateFromTensor("input_ids", decoderInputIds), NamedOnnxValue.CreateFromTensor("encoder_attention_mask", attentionMask), - NamedOnnxValue.CreateFromTensor("encoder_hidden_states", encoderHidden) + NamedOnnxValue.CreateFromTensor("encoder_hidden_states", encoderHidden), }; using var decoderResults = model.Decoder.Run(decoderInputs); @@ -383,7 +383,7 @@ private static void RegisterOnnxRuntimeResolver() var rid = RuntimeInformation.ProcessArchitecture switch { Architecture.Arm64 => "linux-arm64", - _ => "linux-x64" + _ => "linux-x64", }; var candidate = Path.Join( diff --git a/src/TypeWhisper.Linux/Services/TrayIconService.cs b/src/TypeWhisper.Linux/Services/TrayIconService.cs index 5acab0070..a04e53725 100644 --- a/src/TypeWhisper.Linux/Services/TrayIconService.cs +++ b/src/TypeWhisper.Linux/Services/TrayIconService.cs @@ -58,7 +58,7 @@ public void Initialize() { _trayIcon = new TrayIcon { - ToolTipText = "TypeWhisper", IsVisible = true, Menu = BuildMenu(), Icon = LoadIcon() + ToolTipText = "TypeWhisper", IsVisible = true, Menu = BuildMenu(), Icon = LoadIcon(), }; _trayIcon.Clicked += (_, _) => ShowSettingsRequested?.Invoke(this, EventArgs.Empty); @@ -110,7 +110,7 @@ internal bool ProbeTrayAvailable() "--method", "org.freedesktop.DBus.Properties.Get", "org.kde.StatusNotifierWatcher", - "IsStatusNotifierHostRegistered" + "IsStatusNotifierHostRegistered", ], timeout: TimeSpan.FromSeconds(2) ) diff --git a/src/TypeWhisper.Linux/Services/UpdateCheckService.cs b/src/TypeWhisper.Linux/Services/UpdateCheckService.cs index 06e932f76..d030c73dc 100644 --- a/src/TypeWhisper.Linux/Services/UpdateCheckService.cs +++ b/src/TypeWhisper.Linux/Services/UpdateCheckService.cs @@ -101,7 +101,7 @@ public async Task CheckOnStartupAsync(CancellationToken cancellationToken = defa LatestVersion = known, ReleaseUrl = string.IsNullOrWhiteSpace(_prefs.Current.LastKnownLatestUrl) ? ReleasesPage - : _prefs.Current.LastKnownLatestUrl + : _prefs.Current.LastKnownLatestUrl, } ); } @@ -140,7 +140,7 @@ public async Task CheckAsync(CancellationToken cancellationTo Checked = true, Faulted = true, CurrentVersion = current, - Error = "No published release was found." + Error = "No published release was found.", }; } else @@ -151,7 +151,7 @@ public async Task CheckAsync(CancellationToken cancellationTo UpdateAvailable = AppVersion.Compare(current, latest) < 0, CurrentVersion = current, LatestVersion = latest, - ReleaseUrl = string.IsNullOrWhiteSpace(latestUrl) ? ReleasesPage : latestUrl + ReleaseUrl = string.IsNullOrWhiteSpace(latestUrl) ? ReleasesPage : latestUrl, }; } } @@ -166,7 +166,7 @@ public async Task CheckAsync(CancellationToken cancellationTo Debug.WriteLine($"[UpdateCheckService] Check failed: {ex.Message}"); result = new UpdateCheckResult { - Checked = true, Faulted = true, CurrentVersion = current, Error = ex.Message + Checked = true, Faulted = true, CurrentVersion = current, Error = ex.Message, }; } @@ -180,7 +180,7 @@ preferences with { LastUpdateCheckUtc = DateTime.UtcNow, LastKnownLatestVersion = result.LatestVersion, - LastKnownLatestUrl = result.ReleaseUrl + LastKnownLatestUrl = result.ReleaseUrl, } ); } diff --git a/src/TypeWhisper.Linux/Services/WatchFolderExportBuilder.cs b/src/TypeWhisper.Linux/Services/WatchFolderExportBuilder.cs index 142277770..91392728a 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderExportBuilder.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderExportBuilder.cs @@ -17,7 +17,7 @@ DateTime date WatchFolderOutputFormat.PlainText => new WatchFolderExportArtifact("txt", result.Text), WatchFolderOutputFormat.Srt => BuildSubtitle("srt", result), WatchFolderOutputFormat.Vtt => BuildSubtitle("vtt", result), - _ => BuildMarkdown(result, fileName, engineName, date) + _ => BuildMarkdown(result, fileName, engineName, date), }; } diff --git a/src/TypeWhisper.Linux/Services/WatchFolderModels.cs b/src/TypeWhisper.Linux/Services/WatchFolderModels.cs index 17aaf4860..7011784a1 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderModels.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderModels.cs @@ -7,7 +7,7 @@ public enum WatchFolderOutputFormat Markdown, PlainText, Srt, - Vtt + Vtt, } public sealed record WatchFolderOptions( @@ -68,7 +68,7 @@ public static string ToStoredValue(WatchFolderOutputFormat format) WatchFolderOutputFormat.PlainText => "txt", WatchFolderOutputFormat.Srt => "srt", WatchFolderOutputFormat.Vtt => "vtt", - _ => "md" + _ => "md", }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs b/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs index 7acd1db95..5ba2a38e6 100644 --- a/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs @@ -323,11 +323,11 @@ private string ResolveText(OverlayWidget widget) RecordingMode.Toggle => Loc.Instance["Common.ModeToggle"], RecordingMode.PushToTalk => Loc.Instance["Common.ModePushToTalk"], RecordingMode.Hybrid => Loc.Instance["Common.ModeHybrid"], - _ => "" + _ => "", }, OverlayWidget.AppName => ActiveAppName ?? "", // Indicator, Waveform and None render no text; handled by the default arm. - _ => "" + _ => "", }; } } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/ViewModels/MainWindowViewModel.cs b/src/TypeWhisper.Linux/ViewModels/MainWindowViewModel.cs index 78d408ed4..1fb471b76 100644 --- a/src/TypeWhisper.Linux/ViewModels/MainWindowViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/MainWindowViewModel.cs @@ -95,7 +95,7 @@ AboutSectionViewModel about new NavItem("Nav.General", Symbol.Settings, General, false), new NavItem("Nav.Appearance", Symbol.Color, Appearance, false), new NavItem("Nav.Advanced", Symbol.AppsSettings, Advanced, false), - new NavItem("Nav.About", Symbol.Info, About, false) + new NavItem("Nav.About", Symbol.Info, About, false), ]; SelectedItem = NavItems.First(i => i.Content is DashboardSectionViewModel); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs index 7f7b486a4..79380c216 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs @@ -282,7 +282,7 @@ private void RebuildCategoryFilters() var desired = new List { - new(null, Loc.Instance["About.ErrorFilterAll"]) + new(null, Loc.Instance["About.ErrorFilterAll"]), }; desired.AddRange(present.Select(c => new CategoryFilterOption(c, FormatCategory(c)))); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs index 88ade66c4..67f40a117 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs @@ -127,7 +127,7 @@ value is null new(30, Loc.Instance["Advanced.AutoUnload30Seconds"]), new(60, Loc.Instance["Advanced.AutoUnload1Minute"]), new(300, Loc.Instance["Advanced.AutoUnload5Minutes"]), - new(900, Loc.Instance["Advanced.AutoUnload15Minutes"]) + new(900, Loc.Instance["Advanced.AutoUnload15Minutes"]), ]; public IReadOnlyList HistoryRetentionOptions { get; } = @@ -137,7 +137,7 @@ value is null new(HistoryRetentionMode.Duration, 30 * 24 * 60, Loc.Instance["Advanced.Retention30Days"]), new(HistoryRetentionMode.Duration, 90 * 24 * 60, Loc.Instance["Advanced.Retention90Days"]), new(HistoryRetentionMode.Forever, null, Loc.Instance["Advanced.RetentionForever"]), - new(HistoryRetentionMode.UntilAppCloses, null, Loc.Instance["Advanced.RetentionUntilAppCloses"]) + new(HistoryRetentionMode.UntilAppCloses, null, Loc.Instance["Advanced.RetentionUntilAppCloses"]), ]; public bool CanUseSpokenFeedback => _speechFeedback.IsAvailable; @@ -314,7 +314,7 @@ _settings.Current with { HistoryRetentionMode = value.Mode, HistoryRetentionMinutes = - value.Minutes ?? _settings.Current.HistoryRetentionMinutes + value.Minutes ?? _settings.Current.HistoryRetentionMinutes, } ); } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AppearanceSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AppearanceSectionViewModel.cs index c9d406c1c..8d6fd5367 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AppearanceSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AppearanceSectionViewModel.cs @@ -61,7 +61,7 @@ public AppearanceSectionViewModel(ISettingsService settings) public IReadOnlyList OverlayPositions { get; } = [ new(OverlayPosition.Top, "Appearance.PositionTop"), - new(OverlayPosition.Bottom, "Appearance.PositionBottom") + new(OverlayPosition.Bottom, "Appearance.PositionBottom"), ]; public IReadOnlyList OverlayWidgets { get; } = @@ -73,7 +73,7 @@ public AppearanceSectionViewModel(ISettingsService settings) new(OverlayWidget.Clock, "Appearance.WidgetClock"), new(OverlayWidget.Profile, "Appearance.WidgetProfile"), new(OverlayWidget.HotkeyMode, "Appearance.WidgetHotkeyMode"), - new(OverlayWidget.AppName, "Appearance.WidgetAppName") + new(OverlayWidget.AppName, "Appearance.WidgetAppName"), ]; public string PreviewBubbleAutoHideSecondsText => @@ -181,10 +181,10 @@ private string SampleText(OverlayWidget? widget) RecordingMode.Toggle => Loc.Instance["Common.ModeToggle"], RecordingMode.PushToTalk => Loc.Instance["Common.ModePushToTalk"], RecordingMode.Hybrid => Loc.Instance["Common.ModeHybrid"], - _ => "" + _ => "", }, OverlayWidget.AppName => Loc.Instance["Appearance.SampleAppName"], - _ => "" + _ => "", }; } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs index 9060fdc29..7c282ac1a 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs @@ -14,7 +14,7 @@ public enum TimeRange { Weekly, Month, - AllTime + AllTime, } private const double ManualTypingWordsPerMinute = 40.0; @@ -168,7 +168,7 @@ private void Refresh() { TimeRange.Weekly => now.AddDays(-7), TimeRange.Month => now.AddDays(-30), - _ => DateTime.MinValue + _ => DateTime.MinValue, }; var records = _history.Records.Where(r => r.Timestamp >= cutoff).ToList(); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs index 4c6608186..99c1c1868 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs @@ -277,7 +277,7 @@ IAccessibilityBusActivation a11yBus [ new(AppSettings.LocalModelAccelerationAuto, Loc.Instance["Dictation.AccelerationAuto"]), new(AppSettings.LocalModelAccelerationCpu, Loc.Instance["Dictation.AccelerationCpu"]), - new(AppSettings.LocalModelAccelerationNvidiaCuda, Loc.Instance["Dictation.AccelerationNvidiaCuda"]) + new(AppSettings.LocalModelAccelerationNvidiaCuda, Loc.Instance["Dictation.AccelerationNvidiaCuda"]), ]; public ObservableCollection LanguageChoices { get; } = @@ -294,7 +294,7 @@ IAccessibilityBusActivation a11yBus new("cs", "Čeština"), new("sv", "Svenska"), new("da", "Dansk"), - new("fi", "Suomi") + new("fi", "Suomi"), ]; public ObservableCollection TranslationTargetOptions { get; } = []; @@ -304,7 +304,7 @@ IAccessibilityBusActivation a11yBus new(CleanupLevel.None, Loc.Instance["Dictation.CleanupNone"]), new(CleanupLevel.Light, Loc.Instance["Dictation.CleanupLight"]), new(CleanupLevel.Medium, Loc.Instance["Dictation.CleanupMedium"]), - new(CleanupLevel.High, Loc.Instance["Dictation.CleanupHigh"]) + new(CleanupLevel.High, Loc.Instance["Dictation.CleanupHigh"]), ]; public ObservableCollection InsertionStrategyOptions { get; } = @@ -312,7 +312,7 @@ IAccessibilityBusActivation a11yBus new(TextInsertionStrategy.Auto, Loc.Instance["Dictation.AccelerationAuto"]), new(TextInsertionStrategy.ClipboardPaste, Loc.Instance["Dictation.StrategyClipboardPaste"]), new(TextInsertionStrategy.DirectTyping, Loc.Instance["Dictation.StrategyDirectTyping"]), - new(TextInsertionStrategy.CopyOnly, Loc.Instance["Dictation.StrategyCopyOnly"]) + new(TextInsertionStrategy.CopyOnly, Loc.Instance["Dictation.StrategyCopyOnly"]), ]; public ObservableCollection AppInsertionStrategies { get; } = []; @@ -454,7 +454,7 @@ public string AccelerationStatusText : Loc.Instance["Dictation.AccelCudaNotVisible"], AppSettings.LocalModelAccelerationNvidiaCuda => Loc.Instance["Dictation.AccelCudaReady"], - _ => Loc.Instance["Dictation.AccelAutoStatus"] + _ => Loc.Instance["Dictation.AccelAutoStatus"], }; } @@ -818,7 +818,7 @@ private void RefreshModelState() status.Progress.ToString("P0") ), ModelStatusType.Error => FormatModelStatusError(status.ErrorMessage), - _ => Loc.Instance["Dictation.StatusNotReady"] + _ => Loc.Instance["Dictation.StatusNotReady"], }; OnPropertyChanged(nameof(CanDeleteSelectedModel)); OnPropertyChanged(nameof(CanUseCuda)); @@ -938,7 +938,7 @@ public async Task ChangeModelStorageAsync(string? folderPath) LocalModelStorageUnavailableReason.NestedUnderCurrentFolder => Loc.Instance.GetString( "Dictation.ModelStorageNestedUnderCurrent", ex.Path, ex.CurrentPath ?? string.Empty), - _ => Loc.Instance.GetString("Dictation.ModelStorageChangeFailed", ex.Message) + _ => Loc.Instance.GetString("Dictation.ModelStorageChangeFailed", ex.Message), }; } catch (Exception ex) @@ -1325,7 +1325,7 @@ partial void OnSelectedDeviceChanged(AudioInputDevice? value) _settings.Save( _settings.Current with { - SelectedMicrophoneDevice = value.Index, SelectedMicrophoneDeviceId = value.PersistentId + SelectedMicrophoneDevice = value.Index, SelectedMicrophoneDeviceId = value.PersistentId, } ); } @@ -1636,7 +1636,7 @@ partial void OnAudioDuckingLevelChanged(double value) _settings.Save( _settings.Current with { - AudioDuckingLevel = (float)Math.Clamp(value, MinDuckingLevel, MaxDuckingLevel) + AudioDuckingLevel = (float)Math.Clamp(value, MinDuckingLevel, MaxDuckingLevel), } ); OnPropertyChanged(nameof(AudioDuckingReductionPercent)); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictionarySectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictionarySectionViewModel.cs index cd5424da5..32e14e3da 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictionarySectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictionarySectionViewModel.cs @@ -92,7 +92,7 @@ public DictionarySectionViewModel(IDictionaryService dict, ISettingsService sett { 1 => Loc.Instance["Dictionary.EmptyTitleTerms"], 2 => Loc.Instance["Dictionary.EmptyTitleCorrections"], - _ => Loc.Instance["Dictionary.EmptyTitleAll"] + _ => Loc.Instance["Dictionary.EmptyTitleAll"], }; public string EmptyStateSubtitle => @@ -100,7 +100,7 @@ public DictionarySectionViewModel(IDictionaryService dict, ISettingsService sett { 1 => Loc.Instance["Dictionary.EmptySubtitleTerms"], 2 => Loc.Instance["Dictionary.EmptySubtitleCorrections"], - _ => Loc.Instance["Dictionary.EmptySubtitleAll"] + _ => Loc.Instance["Dictionary.EmptySubtitleAll"], }; public bool IsNewTypeCorrection @@ -210,7 +210,7 @@ private void SetTab(object? tab) string stringValue when int.TryParse(stringValue, out var parsed) => parsed, // Leave the current tab unchanged for any other value; the // [ObservableProperty] setter's equality guard makes this a no-op. - _ => SelectedTab + _ => SelectedTab, }; } @@ -239,7 +239,7 @@ private void AddEntry() : NewReplacement.Trim(), CaseSensitive = CaseSensitive, IsEnabled = true, - Priority = Math.Clamp(NewPriority, 0, 999) + Priority = Math.Clamp(NewPriority, 0, 999), } ); @@ -326,7 +326,7 @@ private void Refresh() 1 => entries.Where(entry => entry.EntryType == DictionaryEntryType.Term), 2 => entries.Where(entry => entry.EntryType == DictionaryEntryType.Correction), 3 => [], - _ => entries + _ => entries, }; if (!string.IsNullOrWhiteSpace(SearchText)) diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionQueueItemStatus.cs b/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionQueueItemStatus.cs index b6d7c8297..cc6f05700 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionQueueItemStatus.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionQueueItemStatus.cs @@ -8,5 +8,5 @@ public enum FileTranscriptionQueueItemStatus Completed, Cancelled, Error, - Unsupported + Unsupported, } \ No newline at end of file diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionSectionViewModel.cs index 98788e61d..24fd9ab72 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/FileTranscriptionSectionViewModel.cs @@ -723,7 +723,7 @@ _settings.Current with FileTranscriptionEngineOverride = CleanSettingValue( FileTranscriptionEngineOverride ), - FileTranscriptionModelOverride = CleanSettingValue(FileTranscriptionModelOverride) + FileTranscriptionModelOverride = CleanSettingValue(FileTranscriptionModelOverride), } ); } @@ -747,7 +747,7 @@ _settings.Current with WatchFolderDeleteSource = WatchFolderDeleteSource, WatchFolderLanguage = string.IsNullOrWhiteSpace(WatchFolderLanguage) ? "auto" - : WatchFolderLanguage + : WatchFolderLanguage, } ); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs index 413898cf3..831a286c2 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs @@ -93,7 +93,7 @@ public string BuildExportContent(string extension) ".csv" => _history.ExportToCsv(visibleRecords), ".md" => _history.ExportToMarkdown(visibleRecords), ".json" => _history.ExportToJson(visibleRecords), - _ => _history.ExportToText(visibleRecords) + _ => _history.ExportToText(visibleRecords), }; } @@ -193,7 +193,7 @@ internal void AddTermFromHistory(HistoryRecordRow record) Id = Guid.NewGuid().ToString(), EntryType = DictionaryEntryType.Term, Original = term, - Source = DictionaryEntrySource.Manual + Source = DictionaryEntrySource.Manual, } ); } @@ -718,7 +718,7 @@ public LlmCallDisplay(LlmCallProvenance call) "Cleanup" => Loc.Instance["History.Inspect.StageCleanup"], "Translation" => Loc.Instance["History.Inspect.StageTranslation"], "Memory" => Loc.Instance["History.Inspect.StageMemory"], - _ => Loc.Instance["History.Inspect.StagePromptAction"] + _ => Loc.Instance["History.Inspect.StagePromptAction"], }; public string ProviderModelLabel => $"{_call.ProviderName} · {_call.ModelId}"; diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/PluginCollectionViewModels.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PluginCollectionViewModels.cs index c3c54e2d2..e4a8bafd6 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PluginCollectionViewModels.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PluginCollectionViewModels.cs @@ -65,7 +65,7 @@ private void AddItem() { PluginSettingKind.Boolean => "true", PluginSettingKind.Dropdown when field.Options is { Count: > 0 } => field.Options[0].Value, - _ => string.Empty + _ => string.Empty, }; } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs index 11382ab6c..60d69024c 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs @@ -29,7 +29,7 @@ public partial class PluginsSectionViewModel : ObservableObject "com.typewhisper.soniox", "com.typewhisper.speechmatics", "com.typewhisper.voxtral", - "com.typewhisper.whisper-cpp" + "com.typewhisper.whisper-cpp", ]; private static readonly HashSet s_llmPluginIds = @@ -42,7 +42,7 @@ public partial class PluginsSectionViewModel : ObservableObject "com.typewhisper.gemma-local", "com.typewhisper.groq", "com.typewhisper.openai-compatible", - "com.typewhisper.openrouter" + "com.typewhisper.openrouter", ]; private static readonly HashSet s_actionPluginIds = @@ -50,18 +50,18 @@ public partial class PluginsSectionViewModel : ObservableObject "com.typewhisper.linear", "com.typewhisper.obsidian", "com.typewhisper.script", - "com.typewhisper.webhook" + "com.typewhisper.webhook", ]; private static readonly HashSet s_memoryPluginIds = [ "com.typewhisper.file-memory", - "com.typewhisper.openai-vector-memory" + "com.typewhisper.openai-vector-memory", ]; private static readonly HashSet s_utilityPluginIds = [ - "com.typewhisper.openai-compatible" + "com.typewhisper.openai-compatible", ]; private readonly IErrorLogService? _errorLog; @@ -718,7 +718,7 @@ public static PluginCategoryInfo Resolve(string? rawCategory) ), "action" => new PluginCategoryInfo("action", Loc.Instance["Plugins.CategoryAction"], 3), "memory" => new PluginCategoryInfo("memory", Loc.Instance["Plugins.CategoryMemory"], 4), - _ => new PluginCategoryInfo("utility", Loc.Instance["Plugins.CategoryUtility"], 5) + _ => new PluginCategoryInfo("utility", Loc.Instance["Plugins.CategoryUtility"], 5), }; } @@ -732,7 +732,7 @@ private static string Normalize(string? rawCategory) "post-processing", "action" => "action", "memory" => "memory", - _ => "utility" + _ => "utility", }; } } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs index c4ef77b23..c2c1187e5 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs @@ -206,13 +206,13 @@ BrowserAccessibilitySetupHelper browserSetup new(ProfileStylePreset.CasualMessage, Loc.Instance["Profiles.StylePresetCasualMessage"]), new(ProfileStylePreset.Developer, Loc.Instance["Profiles.StylePresetDeveloper"]), new(ProfileStylePreset.TerminalSafe, Loc.Instance["Profiles.StylePresetTerminalSafe"]), - new(ProfileStylePreset.MeetingNotes, Loc.Instance["Profiles.StylePresetMeetingNotes"]) + new(ProfileStylePreset.MeetingNotes, Loc.Instance["Profiles.StylePresetMeetingNotes"]), ]; public ObservableCollection HotkeyBehaviorOptions { get; } = [ new(ProfileHotkeyBehavior.StartDictation, Loc.Instance["Profiles.HotkeyBehaviorStartDictation"]), - new(ProfileHotkeyBehavior.ProcessSelectedText, Loc.Instance["Profiles.HotkeyBehaviorProcessSelectedText"]) + new(ProfileHotkeyBehavior.ProcessSelectedText, Loc.Instance["Profiles.HotkeyBehaviorProcessSelectedText"]), ]; public ObservableCollection CleanupOverrideOptions { get; } = @@ -221,7 +221,7 @@ BrowserAccessibilitySetupHelper browserSetup new(CleanupLevel.None, Loc.Instance["Profiles.CleanupNone"]), new(CleanupLevel.Light, Loc.Instance["Profiles.CleanupLight"]), new(CleanupLevel.Medium, Loc.Instance["Profiles.CleanupMedium"]), - new(CleanupLevel.High, Loc.Instance["Profiles.CleanupHigh"]) + new(CleanupLevel.High, Loc.Instance["Profiles.CleanupHigh"]), ]; public ObservableCollection ProcessNameChips { get; } = []; @@ -327,7 +327,7 @@ SelectedProfile is null [ new(null, Loc.Instance["Profiles.UseGlobalDefault"]), new(true, Loc.Instance["Common.Enabled"]), - new(false, Loc.Instance["Common.Disabled"]) + new(false, Loc.Instance["Common.Disabled"]), ]; public TranslationTargetOption? SelectedTranslationTargetOption @@ -600,7 +600,7 @@ private void AddProfile() IsEnabled = true, Priority = 0, ProcessNames = [], - UrlPatterns = [] + UrlPatterns = [], }; _profiles.AddProfile(profile); @@ -635,7 +635,7 @@ private void SaveProfile() Loc.Instance["Profiles.HotkeyMalformed"], HotkeyCandidateValidationStatus.MissingEnabledPromptAction => Loc.Instance["Profiles.HotkeyPromptActionRequired"], - _ => Loc.Instance["Profiles.HotkeyCollision"] + _ => Loc.Instance["Profiles.HotkeyCollision"], }; return; } @@ -662,7 +662,7 @@ private void SaveProfile() CleanupLevelOverride = EditCleanupLevelOverride, DeveloperFormattingOverride = EditDeveloperFormattingOverride, Priority = EditPriority, - IsEnabled = EditIsEnabled + IsEnabled = EditIsEnabled, }; var selectedId = SelectedProfile.Id; @@ -687,7 +687,7 @@ private void DuplicateProfile() // so a copied hotkey would be silently dead. HotkeyData = null, CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow, }; _profiles.AddProfile(duplicate); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs index 36ca42cec..ee7c1537a 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs @@ -327,7 +327,7 @@ private void SaveAction() { HotkeyCandidateValidationStatus.Malformed => Loc.Instance["Prompts.HotkeyMalformed"], - _ => Loc.Instance["Prompts.HotkeyCollision"] + _ => Loc.Instance["Prompts.HotkeyCollision"], }; return; } @@ -348,7 +348,7 @@ private void SaveAction() HotkeyKey = hotkeyValidation.NormalizedHotkey, IsManualOnly = EditIsManualOnly, IsEnabled = true, - SortOrder = _prompts.Actions.Count + SortOrder = _prompts.Actions.Count, }; if (!TryMutate(() => _prompts.AddAction(action), "add a prompt action")) @@ -384,7 +384,7 @@ existing with ProviderOverride = EditProviderOverride, TargetActionPluginId = EditTargetActionPluginId, HotkeyKey = hotkeyValidation.NormalizedHotkey, - IsManualOnly = EditIsManualOnly + IsManualOnly = EditIsManualOnly, } ), "update a prompt action" diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs index 8ba308583..ae56930dd 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs @@ -14,7 +14,7 @@ internal enum ManagedDesktopIntegrationState Unknown, Absent, Current, - Stale + Stale, } // MVVM Toolkit [ObservableProperty] generates the OnChanged(value) partial hooks; the @@ -296,7 +296,7 @@ private async Task RefreshKeyboardAccessAsync() // times per second. The orchestrator is idempotent so it's safe, // just noisy. "Sway" => "bindsym --no-repeat $mod+space exec typewhisper record start", - _ => "" + _ => "", }; // ReSharper disable once MemberCanBeMadeStatic.Global @@ -306,7 +306,7 @@ private async Task RefreshKeyboardAccessAsync() { "Hyprland" => "bindr = CTRL SHIFT, SPACE, exec, typewhisper record stop", "Sway" => "bindsym --release $mod+space exec typewhisper record stop", - _ => "" + _ => "", }; // ReSharper disable once MemberCanBeMadeStatic.Global @@ -316,7 +316,7 @@ private async Task RefreshKeyboardAccessAsync() { "Hyprland" => Loc.Instance["Shortcuts.PushToTalkSnippetHintHyprland"], "Sway" => Loc.Instance["Shortcuts.PushToTalkSnippetHintSway"], - _ => "" + _ => "", }; // DesktopDetector normalizes edge cases like "ubuntu:GNOME". @@ -344,7 +344,7 @@ public string DesktopName "XFCE" => Loc.Instance["Shortcuts.DesktopInstructionsXfce"], "Cinnamon" => Loc.Instance["Shortcuts.DesktopInstructionsCinnamon"], "MATE" => Loc.Instance["Shortcuts.DesktopInstructionsMate"], - _ => Loc.Instance["Shortcuts.DesktopInstructionsGeneric"] + _ => Loc.Instance["Shortcuts.DesktopInstructionsGeneric"], }; private IDeShortcutWriter? ActiveWriter @@ -616,7 +616,7 @@ private string GetModeDisplayName() RecordingMode.Toggle => Loc.Instance["Common.ModeToggle"], RecordingMode.PushToTalk => Loc.Instance["Common.ModePushToTalk"], RecordingMode.Hybrid => Loc.Instance["Common.ModeHybrid"], - _ => "" + _ => "", }; } @@ -662,7 +662,7 @@ private void ApplyCopyLastTranscriptionHotkey() _settings.Save( _settings.Current with { - CopyLastTranscriptionHotkey = _hotkey.CurrentCopyLastTranscriptionHotkeyString + CopyLastTranscriptionHotkey = _hotkey.CurrentCopyLastTranscriptionHotkeyString, } ); StatusMessage = string.IsNullOrWhiteSpace( @@ -944,7 +944,7 @@ partial void OnModeChanged(RecordingMode value) RecordingMode.Toggle => Loc.Instance["Shortcuts.ModeToggleStatus"], RecordingMode.PushToTalk => Loc.Instance["Shortcuts.ModePushToTalkStatus"], RecordingMode.Hybrid => Loc.Instance["Shortcuts.ModeHybridStatus"], - _ => "" + _ => "", }; OnPropertyChanged(nameof(ShowCapabilityMismatch)); OnPropertyChanged(nameof(IntegrationPreview)); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs index bb08f4d79..ba68d4226 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs @@ -85,7 +85,7 @@ public SnippetsSectionViewModel(ISnippetService snippets, IDictionaryService dic public IReadOnlyList TriggerModeOptions { get; } = [ new(SnippetTriggerMode.Anywhere, Loc.Instance["Snippets.TriggerModeAnywhere"]), - new(SnippetTriggerMode.ExactPhrase, Loc.Instance["Snippets.TriggerModeExactPhrase"]) + new(SnippetTriggerMode.ExactPhrase, Loc.Instance["Snippets.TriggerModeExactPhrase"]), ]; public void Dispose() @@ -173,7 +173,7 @@ private void SaveSnippet() IsEnabled = existing?.IsEnabled ?? true, UsageCount = existing?.UsageCount ?? 0, LastUsedAt = existing?.LastUsedAt, - CreatedAt = existing?.CreatedAt ?? DateTime.UtcNow + CreatedAt = existing?.CreatedAt ?? DateTime.UtcNow, }; if (existing is null) @@ -327,7 +327,7 @@ private string BuildConflictWarning(string trigger) ), { EntryType: DictionaryEntryType.Correction, - Replacement: { Length: > 0 } replacement + Replacement: { Length: > 0 } replacement, } => Loc.Instance.GetString( "Snippets.ConflictCorrectionReplacement", conflict.Original, @@ -337,7 +337,7 @@ private string BuildConflictWarning(string trigger) "Snippets.ConflictCorrection", conflict.Original ), - _ => "" + _ => "", }; } diff --git a/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs b/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs index b2b35e8e1..52079ac06 100644 --- a/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/WelcomeWizardViewModel.cs @@ -557,7 +557,7 @@ private async Task NextAsync() _settings.Current with { SelectedMicrophoneDevice = SelectedMic.Index, - SelectedMicrophoneDeviceId = SelectedMic.PersistentId + SelectedMicrophoneDeviceId = SelectedMic.PersistentId, } ); } @@ -598,7 +598,7 @@ _settings.Current with EnabledPackIds = IndustryPreset.MergeIntoEnabledPackIds( _settings.Current.EnabledPackIds, SelectedIndustryPresetId - ) + ), } ); } @@ -1018,7 +1018,7 @@ public SetupTaskRow(ISetupTask source) SetupTaskStatusKind.Satisfied => "ok", SetupTaskStatusKind.Failed => "error", SetupTaskStatusKind.Working => "busy", - _ => "missing" + _ => "missing", }; public string StatusGlyph => Kind switch @@ -1026,7 +1026,7 @@ public SetupTaskRow(ISetupTask source) SetupTaskStatusKind.Satisfied => "✓", SetupTaskStatusKind.Failed => "!", SetupTaskStatusKind.Working => "…", - _ => "•" + _ => "•", }; public void Apply(SetupTaskState state) diff --git a/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs b/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs index 41e3a61fd..76fa2204d 100644 --- a/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs +++ b/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs @@ -340,7 +340,7 @@ private void OnDragSaveTimerTick(object? sender, EventArgs e) _settings.Save(_settings.Current with { OverlayCustomLeft = (double)pos.X, - OverlayCustomTop = (double)pos.Y + OverlayCustomTop = (double)pos.Y, }); } } diff --git a/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs index 433c2fb49..de8ef0841 100644 --- a/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs @@ -35,7 +35,7 @@ private async void OnExportDiagnostics(object? sender, RoutedEventArgs e) Title = Loc.Instance["Dialog.ExportDiagnostics"], SuggestedFileName = "typewhisper-diagnostics.json", DefaultExtension = "json", - FileTypeChoices = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }] + FileTypeChoices = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }], } ); @@ -75,7 +75,7 @@ private async void OnBackupSettings(object? sender, RoutedEventArgs e) $"typewhisper-settings-backup-{DateTime.Now:yyyyMMdd-HHmmss}.zip", DefaultExtension = "zip", FileTypeChoices = - [new FilePickerFileType("Zip archive") { Patterns = ["*.zip"] }] + [new FilePickerFileType("Zip archive") { Patterns = ["*.zip"] }], } ); @@ -119,7 +119,7 @@ private async void OnRestoreSettings(object? sender, RoutedEventArgs e) Title = Loc.Instance["Dialog.RestoreSettings"], AllowMultiple = false, FileTypeFilter = - [new FilePickerFileType("Zip archive") { Patterns = ["*.zip"] }] + [new FilePickerFileType("Zip archive") { Patterns = ["*.zip"] }], } ); diff --git a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs index be4cbcef3..7db8612c4 100644 --- a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs @@ -73,7 +73,7 @@ private async void OnChangeModelStorage(object? sender, RoutedEventArgs e) new FolderPickerOpenOptions { Title = "Choose model storage folder", - AllowMultiple = false + AllowMultiple = false, } ); diff --git a/src/TypeWhisper.Linux/Views/Sections/DictionarySection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/DictionarySection.axaml.cs index 770a29600..8b69ff5d3 100644 --- a/src/TypeWhisper.Linux/Views/Sections/DictionarySection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/DictionarySection.axaml.cs @@ -36,7 +36,7 @@ private async void OnExport(object? sender, RoutedEventArgs e) Title = Loc.Instance["Dialog.ExportDictionary"], SuggestedFileName = "typewhisper-dictionary.csv", DefaultExtension = "csv", - FileTypeChoices = [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }] + FileTypeChoices = [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }], } ); @@ -77,7 +77,7 @@ private async void OnImport(object? sender, RoutedEventArgs e) { Title = Loc.Instance["Dialog.ImportDictionary"], AllowMultiple = false, - FileTypeFilter = [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }] + FileTypeFilter = [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }], } ); diff --git a/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs index ca6462b9d..c5b81a662 100644 --- a/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs +++ b/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs @@ -137,7 +137,7 @@ private async Task ExportTextAsync( Title = Loc.Instance["Dialog.ExportText"], SuggestedFileName = $"{baseName}.txt", DefaultExtension = "txt", - FileTypeChoices = [new FilePickerFileType("Text") { Patterns = ["*.txt"] }] + FileTypeChoices = [new FilePickerFileType("Text") { Patterns = ["*.txt"] }], } ); @@ -189,7 +189,7 @@ DataContext is not FileTranscriptionSectionViewModel viewModel Title = $"Export {label}", SuggestedFileName = $"{baseName}.{extension}", DefaultExtension = extension, - FileTypeChoices = [new FilePickerFileType(label) { Patterns = [$"*.{extension}"] }] + FileTypeChoices = [new FilePickerFileType(label) { Patterns = [$"*.{extension}"] }], } ); diff --git a/src/TypeWhisper.PluginSDK/Helpers/OpenAiApiHelper.cs b/src/TypeWhisper.PluginSDK/Helpers/OpenAiApiHelper.cs index 071c5fdf2..b77b64546 100644 --- a/src/TypeWhisper.PluginSDK/Helpers/OpenAiApiHelper.cs +++ b/src/TypeWhisper.PluginSDK/Helpers/OpenAiApiHelper.cs @@ -51,7 +51,7 @@ CancellationToken ct 401 => "Invalid API key", 413 => "Audio too large (max 25 MB)", 429 => "Rate limit reached, please wait", - _ => $"API error {(int)response.StatusCode}: {ExtractErrorMessage(errorBody)}" + _ => $"API error {(int)response.StatusCode}: {ExtractErrorMessage(errorBody)}", }; throw new InvalidOperationException(message); } diff --git a/src/TypeWhisper.PluginSDK/Helpers/OpenAiChatHelper.cs b/src/TypeWhisper.PluginSDK/Helpers/OpenAiChatHelper.cs index 97b70a9ae..130bda555 100644 --- a/src/TypeWhisper.PluginSDK/Helpers/OpenAiChatHelper.cs +++ b/src/TypeWhisper.PluginSDK/Helpers/OpenAiChatHelper.cs @@ -168,7 +168,7 @@ public static async IAsyncEnumerable SendChatCompletionStreamingAsync( { 401 => "Invalid API key", 429 => "Rate limit reached, please wait", - _ => $"API error {(int)response.StatusCode}: {OpenAiApiHelper.ExtractErrorMessage(errorBody)}" + _ => $"API error {(int)response.StatusCode}: {OpenAiApiHelper.ExtractErrorMessage(errorBody)}", }; throw new InvalidOperationException(message); } @@ -382,8 +382,8 @@ bool stream ["model"] = model, ["messages"] = new object[] { - new { role = "system", content = systemPrompt }, new { role = "user", content = userText } - } + new { role = "system", content = systemPrompt }, new { role = "user", content = userText }, + }, }; if (temperature is not null) diff --git a/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs b/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs index c0cfc7bfd..9a084d7fe 100644 --- a/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs +++ b/src/TypeWhisper.PluginSDK/Helpers/OpenAiTranscriptionHelper.cs @@ -96,7 +96,7 @@ internal static PluginTranscriptionResult ParseTranscriptionResponse(string json { return new PluginTranscriptionResult(text.Trim(), language, duration, minNoSpeechProb) { - Segments = segments + Segments = segments, }; } diff --git a/src/TypeWhisper.PluginSDK/Models/PluginLogLevel.cs b/src/TypeWhisper.PluginSDK/Models/PluginLogLevel.cs index bd04d40b8..0771c776e 100644 --- a/src/TypeWhisper.PluginSDK/Models/PluginLogLevel.cs +++ b/src/TypeWhisper.PluginSDK/Models/PluginLogLevel.cs @@ -14,5 +14,5 @@ public enum PluginLogLevel // ReSharper disable once UnusedMember.Global Warning, // ReSharper disable once UnusedMember.Global - Error + Error, } diff --git a/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationBackend.cs b/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationBackend.cs index 4eef1b9f2..03b25fbc7 100644 --- a/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationBackend.cs +++ b/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationBackend.cs @@ -12,5 +12,5 @@ public enum TranscriptionAccelerationBackend // ReSharper disable once UnusedMember.Global Cpu, // ReSharper disable once UnusedMember.Global - NvidiaCuda + NvidiaCuda, } diff --git a/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationPreference.cs b/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationPreference.cs index 0b12b94c0..d21e00a84 100644 --- a/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationPreference.cs +++ b/src/TypeWhisper.PluginSDK/Models/TranscriptionAccelerationPreference.cs @@ -14,5 +14,5 @@ public enum TranscriptionAccelerationPreference // ReSharper disable once UnusedMember.Global Cpu, // ReSharper disable once UnusedMember.Global - NvidiaCuda + NvidiaCuda, } diff --git a/src/TypeWhisper.PluginSDK/Models/TtsPurpose.cs b/src/TypeWhisper.PluginSDK/Models/TtsPurpose.cs index aeae8cafa..6fb303cbc 100644 --- a/src/TypeWhisper.PluginSDK/Models/TtsPurpose.cs +++ b/src/TypeWhisper.PluginSDK/Models/TtsPurpose.cs @@ -20,5 +20,5 @@ public enum TtsPurpose /// User explicitly requested the text to be read aloud. // ReSharper disable once UnusedMember.Global - ManualReadback + ManualReadback, } diff --git a/tests/TypeWhisper.Core.Tests/Models/ErrorCategoryGuardTests.cs b/tests/TypeWhisper.Core.Tests/Models/ErrorCategoryGuardTests.cs index bac9ad083..f48b1d1b5 100644 --- a/tests/TypeWhisper.Core.Tests/Models/ErrorCategoryGuardTests.cs +++ b/tests/TypeWhisper.Core.Tests/Models/ErrorCategoryGuardTests.cs @@ -37,7 +37,7 @@ public void Category_constants_are_distinct_and_lowercase() ErrorCategory.Prompt, ErrorCategory.Plugin, ErrorCategory.Insertion, - ErrorCategory.Detection + ErrorCategory.Detection, ]; Assert.Equal(all.Length, all.Distinct().Count()); diff --git a/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceCorrectionsTests.cs b/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceCorrectionsTests.cs index 01c1e5010..616643cf6 100644 --- a/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceCorrectionsTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceCorrectionsTests.cs @@ -32,7 +32,7 @@ public void GetCorrections_ReturnsEnabledOnly() EntryType = DictionaryEntryType.Correction, Original = "teh", Replacement = "the", - IsEnabled = true + IsEnabled = true, }); _sut.AddEntry(new DictionaryEntry { @@ -40,13 +40,13 @@ public void GetCorrections_ReturnsEnabledOnly() EntryType = DictionaryEntryType.Correction, Original = "recieve", Replacement = "receive", - IsEnabled = false + IsEnabled = false, }); _sut.AddEntry(new DictionaryEntry { Id = "3", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", }); var corrections = _sut.GetCorrections(); @@ -107,7 +107,7 @@ public void DeleteTerm_Match() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "FooCorp" + Original = "FooCorp", }); var deleted = _sut.DeleteTerm("foocorp"); @@ -123,7 +123,7 @@ public void DeleteTerm_NoMatch_ReturnsFalse() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "FooCorp" + Original = "FooCorp", }); var deleted = _sut.DeleteTerm("BarCorp"); @@ -140,7 +140,7 @@ public void DeleteTerm_LeavesCorrectionsAlone() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "teh" + Original = "teh", }); var deleted = _sut.DeleteTerm("teh"); diff --git a/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceTests.cs index bde2778d3..b93b4adb5 100644 --- a/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/DictionaryServiceTests.cs @@ -36,7 +36,7 @@ public void AddEntry_AppearsInEntries() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); @@ -52,7 +52,7 @@ public void DeleteEntry_RemovesFromEntries() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); @@ -69,7 +69,7 @@ public void DeleteEntries_BatchRemove() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "A" + Original = "A", } ); _sut.AddEntry( @@ -77,7 +77,7 @@ public void DeleteEntries_BatchRemove() { Id = "2", EntryType = DictionaryEntryType.Term, - Original = "B" + Original = "B", } ); _sut.AddEntry( @@ -85,7 +85,7 @@ public void DeleteEntries_BatchRemove() { Id = "3", EntryType = DictionaryEntryType.Term, - Original = "C" + Original = "C", } ); @@ -115,7 +115,7 @@ public void ActivatePack_AllowsSameTermInDifferentSources() { Id = "existing", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); @@ -175,7 +175,7 @@ public void DeactivatePack_RemovesPackTerms() { Id = "manual", EntryType = DictionaryEntryType.Term, - Original = "TypeScript" + Original = "TypeScript", } ); @@ -194,7 +194,7 @@ public void ApplyCorrections_ReplacesText() Id = "1", EntryType = DictionaryEntryType.Correction, Original = "kubernets", - Replacement = "Kubernetes" + Replacement = "Kubernetes", } ); @@ -211,7 +211,7 @@ public void PreviewCorrections_ReplacesText() Id = "1", EntryType = DictionaryEntryType.Correction, Original = "kubernets", - Replacement = "Kubernetes" + Replacement = "Kubernetes", } ); @@ -228,7 +228,7 @@ public void PreviewCorrections_DoesNotUpdateUsageMetadata() Id = "1", EntryType = DictionaryEntryType.Correction, Original = "kubernets", - Replacement = "Kubernetes" + Replacement = "Kubernetes", } ); @@ -251,7 +251,7 @@ public void PreviewCorrections_DoesNotPersistAcrossInstances() Id = "1", EntryType = DictionaryEntryType.Correction, Original = "kubernets", - Replacement = "Kubernetes" + Replacement = "Kubernetes", } ); @@ -270,7 +270,7 @@ public void ApplyCorrections_UpdatesUsageMetadata() Id = "1", EntryType = DictionaryEntryType.Correction, Original = "kubernets", - Replacement = "Kubernetes" + Replacement = "Kubernetes", } ); @@ -291,7 +291,7 @@ public void ApplyCorrections_DoesNotUpdateUsageMetadata_WhenWordBoundaryDoesNotM Id = "1", EntryType = DictionaryEntryType.Correction, Original = "test", - Replacement = "exam" + Replacement = "exam", } ); @@ -311,7 +311,7 @@ public void ApplyCorrections_PrefersHigherPriorityCorrection() Id = "low", EntryType = DictionaryEntryType.Correction, Original = "type whisper", - Replacement = "Type Whisper" + Replacement = "Type Whisper", } ); _sut.AddEntry( @@ -321,7 +321,7 @@ public void ApplyCorrections_PrefersHigherPriorityCorrection() EntryType = DictionaryEntryType.Correction, Original = "type whisper", Replacement = "TypeWhisper", - Priority = 10 + Priority = 10, } ); @@ -338,7 +338,7 @@ public void GetTermsForPrompt_ReturnsCommaSeparated() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); _sut.AddEntry( @@ -346,7 +346,7 @@ public void GetTermsForPrompt_ReturnsCommaSeparated() { Id = "2", EntryType = DictionaryEntryType.Term, - Original = "Vue" + Original = "Vue", } ); @@ -362,7 +362,7 @@ public void SetTerms_AppendsNormalizedTerms_WhenReplaceExistingFalse() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); @@ -379,7 +379,7 @@ public void SetTerms_ReplacesExistingTerms_WhenReplaceExistingTrue() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); _sut.AddEntry( @@ -388,7 +388,7 @@ public void SetTerms_ReplacesExistingTerms_WhenReplaceExistingTrue() Id = "2", EntryType = DictionaryEntryType.Correction, Original = "teh", - Replacement = "the" + Replacement = "the", } ); @@ -406,7 +406,7 @@ public void RemoveAllTerms_KeepsCorrections() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); _sut.AddEntry( @@ -415,7 +415,7 @@ public void RemoveAllTerms_KeepsCorrections() Id = "2", EntryType = DictionaryEntryType.Correction, Original = "teh", - Replacement = "the" + Replacement = "the", } ); @@ -473,7 +473,7 @@ public void LearnCorrection_DoesNotOverwriteUserAuthoredEntry(DictionaryEntrySou EntryType = DictionaryEntryType.Correction, Original = "kubernets", Replacement = "Kubernetes", - Source = source + Source = source, } ); @@ -489,7 +489,7 @@ public void LearnCorrections_AddsNewCorrectionsAsAutoLearnedAndReturnsIds() { var learned = _sut.LearnCorrections([ new CorrectionSuggestion("teh", "the"), - new CorrectionSuggestion("recieve", "receive") + new CorrectionSuggestion("recieve", "receive"), ]); Assert.Equal(2, learned.Count); @@ -527,7 +527,7 @@ DictionaryEntrySource source EntryType = DictionaryEntryType.Correction, Original = "teh", Replacement = "the", - Source = source + Source = source, } ); @@ -590,7 +590,7 @@ public void LearnCorrections_WithinBatchDuplicateOriginals_FirstWins() { var learned = _sut.LearnCorrections([ new CorrectionSuggestion("teh", "the"), - new CorrectionSuggestion("TEH", "thee") + new CorrectionSuggestion("TEH", "thee"), ]); Assert.Single(learned); @@ -604,7 +604,7 @@ public void UndoLearnedCorrections_RemovesOnlyListedIdsAndLeavesTheRest() { var learned = _sut.LearnCorrections([ new CorrectionSuggestion("teh", "the"), - new CorrectionSuggestion("recieve", "receive") + new CorrectionSuggestion("recieve", "receive"), ]); _sut.AddEntry( new DictionaryEntry @@ -613,7 +613,7 @@ public void UndoLearnedCorrections_RemovesOnlyListedIdsAndLeavesTheRest() EntryType = DictionaryEntryType.Correction, Original = "seperate", Replacement = "separate", - Source = DictionaryEntrySource.Manual + Source = DictionaryEntrySource.Manual, } ); @@ -668,7 +668,7 @@ public void ExportToCsv_IncludesMetadataAndEscapesFields() CaseSensitive = true, IsStarred = true, Priority = 7, - Source = DictionaryEntrySource.CorrectionSuggestion + Source = DictionaryEntrySource.CorrectionSuggestion, } ); @@ -726,7 +726,7 @@ public void ImportFromCsv_UpdatesExistingCorrectionByOriginalIgnoringCase() Original = "wispr", Replacement = "Wispr", Priority = 5, - Source = DictionaryEntrySource.Manual + Source = DictionaryEntrySource.Manual, } ); @@ -762,7 +762,7 @@ public void ImportFromCsv_ExactDuplicateCorrectionIsNoOp() IsStarred = true, UsageCount = 12, Priority = 5, - Source = DictionaryEntrySource.Manual + Source = DictionaryEntrySource.Manual, } ); @@ -809,7 +809,7 @@ public void ImportFromCsv_SkipsDuplicatesAndInvalidCorrections() { Id = "existing", EntryType = DictionaryEntryType.Term, - Original = "TypeWhisper" + Original = "TypeWhisper", } ); @@ -844,7 +844,7 @@ public void UpdateEntry_ModifiesEntry() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); @@ -865,7 +865,7 @@ public void EntriesChanged_FiresOnModification() { Id = "1", EntryType = DictionaryEntryType.Term, - Original = "React" + Original = "React", } ); _sut.DeleteEntry("1"); diff --git a/tests/TypeWhisper.Core.Tests/Services/HistoryInsightsServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/HistoryInsightsServiceTests.cs index bf558f448..65e834339 100644 --- a/tests/TypeWhisper.Core.Tests/Services/HistoryInsightsServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/HistoryInsightsServiceTests.cs @@ -40,7 +40,7 @@ public void Build_ComputesAveragesAndTopApps() true, promptApplied: true, translationApplied: true - ) + ), }; var result = _sut.Build(records); @@ -108,7 +108,7 @@ private static TranscriptionRecord Record( SnippetApplied = snippetApplied, DictionaryCorrectionApplied = dictionaryApplied, PromptActionApplied = promptApplied, - TranslationApplied = translationApplied + TranslationApplied = translationApplied, }; } } diff --git a/tests/TypeWhisper.Core.Tests/Services/HistoryServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/HistoryServiceTests.cs index 9270ae3a2..1ce97cfc3 100644 --- a/tests/TypeWhisper.Core.Tests/Services/HistoryServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/HistoryServiceTests.cs @@ -43,7 +43,7 @@ public void ModelUsed_PersistsCorrectly() RawText = "hello", FinalText = "hello", EngineUsed = "plugin:com.test:model-1", - ModelUsed = "plugin:com.test:model-1" + ModelUsed = "plugin:com.test:model-1", }; _sut.AddRecord(record); @@ -61,7 +61,7 @@ public void ModelUsed_NullByDefault() Id = Guid.NewGuid().ToString(), Timestamp = DateTime.UtcNow, RawText = "test", - FinalText = "test" + FinalText = "test", }; _sut.AddRecord(record); @@ -81,7 +81,7 @@ public void InsertionMetadata_PersistsCorrectly() RawText = "hello", FinalText = "hello", InsertionStatus = TextInsertionStatus.MissingPasteTool, - InsertionFailureReason = "Automatic paste tool is unavailable." + InsertionFailureReason = "Automatic paste tool is unavailable.", }; _sut.AddRecord(record); @@ -100,7 +100,7 @@ public void PendingCorrectionSuggestions_PersistCorrectly() Id = Guid.NewGuid().ToString(), Timestamp = DateTime.UtcNow, RawText = "hello", - FinalText = "hello" + FinalText = "hello", }; _sut.AddRecord(record); @@ -130,8 +130,8 @@ public void ExportToMarkdown_FormatsCorrectly() FinalText = "Hello, world!", AppProcessName = "notepad", DurationSeconds = 2.5, - Language = "en" - } + Language = "en", + }, }; var result = _sut.ExportToMarkdown(records); @@ -155,8 +155,8 @@ public void ExportToCsv_EscapesLabelsAppTextAndLanguage() FinalText = "Hello, \"world\"", AppProcessName = "browser, tab", DurationSeconds = 1.5, - Language = "en,us" - } + Language = "en,us", + }, }; var result = _sut.ExportToCsv(records); @@ -180,8 +180,8 @@ public void ExportToJson_ProducesValidJson() AppProcessName = "code", DurationSeconds = 1.0, Language = "en", - InsertionStatus = TextInsertionStatus.Pasted - } + InsertionStatus = TextInsertionStatus.Pasted, + }, }; var result = _sut.ExportToJson(records); @@ -259,7 +259,7 @@ private static TranscriptionRecord CreateRecord( CreatedAt = createdAt, RawText = "test", FinalText = "test", - AudioFileName = audioFileName + AudioFileName = audioFileName, }; } } \ No newline at end of file diff --git a/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs index 27bf8997a..131ab4fdb 100644 --- a/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/LocalModelStorageServiceTests.cs @@ -426,6 +426,9 @@ public void ResetToDefault_ClearsCustomPath() private sealed class FakeSettingsService : ISettingsService { + // ISettingsService.Update must read and persist under the same gate as Save. + private readonly Lock _gate = new(); + public FakeSettingsService(AppSettings initial) => Current = initial; public AppSettings Current { get; private set; } @@ -436,18 +439,24 @@ private sealed class FakeSettingsService : ISettingsService public void Save(AppSettings settings) { - if (ThrowOnSave is not null) - throw ThrowOnSave; - - Current = settings; - SettingsChanged?.Invoke(settings); + lock (_gate) + { + if (ThrowOnSave is not null) + throw ThrowOnSave; + + Current = settings; + SettingsChanged?.Invoke(settings); + } } public AppSettings Update(Func mutate) { - var updated = mutate(Current); - Save(updated); - return updated; + lock (_gate) + { + var updated = mutate(Current); + Save(updated); + return updated; + } } public event Action? SettingsChanged; diff --git a/tests/TypeWhisper.Core.Tests/Services/MatchProfileCascadeTests.cs b/tests/TypeWhisper.Core.Tests/Services/MatchProfileCascadeTests.cs index 76d90ae26..974a7a576 100644 --- a/tests/TypeWhisper.Core.Tests/Services/MatchProfileCascadeTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/MatchProfileCascadeTests.cs @@ -161,7 +161,7 @@ int priority IsEnabled = true, Priority = priority, ProcessNames = processNames, - UrlPatterns = urlPatterns + UrlPatterns = urlPatterns, }; } } \ No newline at end of file diff --git a/tests/TypeWhisper.Core.Tests/Services/PostProcessingPipelineTests.cs b/tests/TypeWhisper.Core.Tests/Services/PostProcessingPipelineTests.cs index 2798886a4..bbe90eef9 100644 --- a/tests/TypeWhisper.Core.Tests/Services/PostProcessingPipelineTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/PostProcessingPipelineTests.cs @@ -143,7 +143,7 @@ public async Task ProcessAsync_DictionaryCorrections_Applied() { var options = new PipelineOptions { - DictionaryCorrector = text => text.Replace("teh", "the") + DictionaryCorrector = text => text.Replace("teh", "the"), }; var result = await _sut.ProcessAsync("teh quick fox", options); @@ -155,7 +155,7 @@ public async Task ProcessAsync_SnippetExpansion_Applied() { var options = new PipelineOptions { - SnippetExpander = text => text.Replace("brb", "be right back") + SnippetExpander = text => text.Replace("brb", "be right back"), }; var result = await _sut.ProcessAsync("brb", options); @@ -169,7 +169,7 @@ public async Task ProcessAsync_ReturnsStepChangeMetadata() { CleanupHandler = (text, _) => Task.FromResult(text.Trim()), SnippetExpander = text => text.Replace("brb", "be right back"), - DictionaryCorrector = text => text + DictionaryCorrector = text => text, }; var result = await _sut.ProcessAsync(" brb ", options); @@ -185,7 +185,7 @@ public async Task ProcessAsync_LlmHandler_Applied() { var options = new PipelineOptions { - LlmHandler = (text, _) => Task.FromResult(text.ToUpperInvariant()) + LlmHandler = (text, _) => Task.FromResult(text.ToUpperInvariant()), }; var result = await _sut.ProcessAsync("hello", options); @@ -198,7 +198,7 @@ public async Task ProcessAsync_RequiredLlmHandlerFailure_Throws() var options = new PipelineOptions { LlmHandler = (_, _) => throw new InvalidOperationException("LLM failed"), - RequireLlmSuccess = true + RequireLlmSuccess = true, }; var ex = await Assert.ThrowsAsync(() => @@ -224,7 +224,7 @@ public async Task ProcessAsync_Translation_Applied() { TranslationHandler = (text, _, tgt, _) => Task.FromResult($"[{tgt}] {text}"), TranslationTarget = "fr", - DetectedLanguage = "en" + DetectedLanguage = "en", }; var result = await _sut.ProcessAsync("hello", options); @@ -244,7 +244,7 @@ public async Task ProcessAsync_Translation_UsesDetectedLanguageWhenEffectiveLang }, TranslationTarget = "it", EffectiveSourceLanguage = "it", - DetectedLanguage = "en" + DetectedLanguage = "en", }; var result = await _sut.ProcessAsync("ciao mondo", options); @@ -260,7 +260,7 @@ public async Task ProcessAsync_Translation_SkippedWhenSameLanguage() { TranslationHandler = (text, _, tgt, _) => Task.FromResult($"[{tgt}] {text}"), TranslationTarget = "en", - DetectedLanguage = "en" + DetectedLanguage = "en", }; var result = await _sut.ProcessAsync("hello", options); @@ -283,7 +283,7 @@ public async Task ProcessAsync_PriorityOrdering_PluginsBeforeLlm() executionOrder.Add("Plugin100"); return Task.FromResult(text + "+P100"); } - ) + ), ], LlmHandler = (text, _) => { @@ -304,7 +304,7 @@ public async Task ProcessAsync_PriorityOrdering_PluginsBeforeLlm() { executionOrder.Add("Dictionary"); return text + "+DICT"; - } + }, }; var result = await _sut.ProcessAsync("start", options); @@ -330,7 +330,7 @@ public async Task ProcessAsync_Cleanup_RunsBeforeLlmAndSnippets() executionOrder.Add("Plugin100"); return Task.FromResult(text + "+P100"); } - ) + ), ], CleanupHandler = (text, _) => { @@ -346,7 +346,7 @@ public async Task ProcessAsync_Cleanup_RunsBeforeLlmAndSnippets() { executionOrder.Add("Snippets"); return text + "+SNP"; - } + }, }; var result = await _sut.ProcessAsync("start", options); @@ -387,8 +387,8 @@ public async Task ProcessAsync_MultiplePlugins_SortedByPriority() executionOrder.Add("Plugin400"); return Task.FromResult(text + "+P400"); } - ) - ] + ), + ], }; var result = await _sut.ProcessAsync("start", options); @@ -414,7 +414,7 @@ public async Task ProcessAsync_PluginBetweenLlmAndSnippets() executionOrder.Add("Plugin400"); return Task.FromResult(text); } - ) + ), ], LlmHandler = (text, _) => { @@ -425,7 +425,7 @@ public async Task ProcessAsync_PluginBetweenLlmAndSnippets() { executionOrder.Add("Snippets"); return text; - } + }, }; await _sut.ProcessAsync("test", options); @@ -444,9 +444,9 @@ public async Task ProcessAsync_ErrorResilience_ContinuesAfterFailure() new PluginPostProcessor( 100, (_, _) => throw new InvalidOperationException("Plugin failed") - ) + ), ], - DictionaryCorrector = text => text + "+DICT" + DictionaryCorrector = text => text + "+DICT", }; var result = await _sut.ProcessAsync("hello", options); @@ -473,7 +473,7 @@ public async Task ProcessAsync_InternalCleanupCancellation_ContinuesAfterFailure await Task.Delay(Timeout.Infinite, privateCts.Token); return text; }, - SnippetExpander = text => text + "+SNIPPET" + SnippetExpander = text => text + "+SNIPPET", }; var result = await _sut.ProcessAsync("hello", options, CancellationToken.None); @@ -503,9 +503,9 @@ public async Task ProcessAsync_InternalPluginTaskCancellation_ContinuesAfterFail null, unrelatedCts.Token ) - ) + ), ], - DictionaryCorrector = text => text + "+DICT" + DictionaryCorrector = text => text + "+DICT", }; var result = await _sut.ProcessAsync("hello", options, CancellationToken.None); @@ -517,7 +517,7 @@ public async Task ProcessAsync_InternalPluginTaskCancellation_ContinuesAfterFail { Name: "Plugin(100)", Succeeded: false, - ErrorMessage: "Simulated internal HTTP timeout" + ErrorMessage: "Simulated internal HTTP timeout", } ); } @@ -533,7 +533,7 @@ public async Task ProcessAsync_Translation_UsesAutoWhenSourceUnknown() sourceLanguage = src; return Task.FromResult(text); }, - TranslationTarget = "fr" + TranslationTarget = "fr", }; await _sut.ProcessAsync("bonjour", options); @@ -572,8 +572,8 @@ public async Task ProcessAsync_StepCancelsCallerTokenThenThrows_Propagates() throw new OperationCanceledException(cts.Token); // ReSharper restore AccessToDisposedClosure } - ) - ] + ), + ], }; await Assert.ThrowsAsync(() => @@ -596,7 +596,7 @@ public async Task ProcessAsync_StatusCallback_CalledForLlmAndTranslation() { statusCalls.Add(status); return Task.CompletedTask; - } + }, }; await _sut.ProcessAsync("test", options); @@ -623,7 +623,7 @@ public async Task ProcessAsync_TranslationAlwaysLast() return Task.FromResult(text); }, TranslationTarget = "fr", - DetectedLanguage = "en" + DetectedLanguage = "en", }; await _sut.ProcessAsync("test", options); @@ -648,7 +648,7 @@ public async Task ProcessAsync_VocabularyBoosting_RunsBeforeDictionary() { executionOrder.Add("Dictionary"); return text.Replace("TypeWhisper", "TYPEWHISPER"); - } + }, }; var result = await _sut.ProcessAsync("type whisper", options); @@ -673,7 +673,7 @@ public async Task ProcessAsync_OutlookFormatting_DoesNotEmitHtmlTags() var options = new PipelineOptions { AppFormatter = AppFormatterService.Format, - TargetProcessName = "OUTLOOK" + TargetProcessName = "OUTLOOK", }; var result = await _sut.ProcessAsync("- one\n- two", options); diff --git a/tests/TypeWhisper.Core.Tests/Services/PromptActionServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/PromptActionServiceTests.cs index 88b95232c..29191a359 100644 --- a/tests/TypeWhisper.Core.Tests/Services/PromptActionServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/PromptActionServiceTests.cs @@ -32,7 +32,7 @@ public void AddAction_PersistsAndLoads() Id = "1", Name = "Test Prompt", SystemPrompt = "Do something", - Icon = "\U0001F680" + Icon = "\U0001F680", } ); @@ -51,7 +51,7 @@ public void UpdateAction_PersistsChanges() { Id = "1", Name = "Original", - SystemPrompt = "Original prompt" + SystemPrompt = "Original prompt", } ); @@ -60,7 +60,7 @@ public void UpdateAction_PersistsChanges() { Id = "1", Name = "Updated", - SystemPrompt = "Updated prompt" + SystemPrompt = "Updated prompt", } ); @@ -78,7 +78,7 @@ public void DeleteAction_RemovesFromStorage() { Id = "1", Name = "A", - SystemPrompt = "a" + SystemPrompt = "a", } ); _sut.AddAction( @@ -86,7 +86,7 @@ public void DeleteAction_RemovesFromStorage() { Id = "2", Name = "B", - SystemPrompt = "b" + SystemPrompt = "b", } ); @@ -130,7 +130,7 @@ public void EnabledActions_FiltersAndSorts() Name = "C", SystemPrompt = "c", SortOrder = 2, - IsEnabled = true + IsEnabled = true, } ); _sut.AddAction( @@ -140,7 +140,7 @@ public void EnabledActions_FiltersAndSorts() Name = "A", SystemPrompt = "a", SortOrder = 0, - IsEnabled = true + IsEnabled = true, } ); _sut.AddAction( @@ -150,7 +150,7 @@ public void EnabledActions_FiltersAndSorts() Name = "B", SystemPrompt = "b", SortOrder = 1, - IsEnabled = false + IsEnabled = false, } ); @@ -169,7 +169,7 @@ public void Reorder_UpdatesSortOrder() Id = "1", Name = "First", SystemPrompt = "a", - SortOrder = 0 + SortOrder = 0, } ); _sut.AddAction( @@ -178,7 +178,7 @@ public void Reorder_UpdatesSortOrder() Id = "2", Name = "Second", SystemPrompt = "b", - SortOrder = 1 + SortOrder = 1, } ); _sut.AddAction( @@ -187,7 +187,7 @@ public void Reorder_UpdatesSortOrder() Id = "3", Name = "Third", SystemPrompt = "c", - SortOrder = 2 + SortOrder = 2, } ); @@ -211,7 +211,7 @@ public void ActionsChanged_FiresOnAdd() { Id = "1", Name = "Test", - SystemPrompt = "test" + SystemPrompt = "test", } ); @@ -228,7 +228,7 @@ public void ProviderOverride_PersistsCorrectly() Name = "With Provider", SystemPrompt = "test", ProviderOverride = "plugin:com.test:model-1", - ModelOverride = "model-1" + ModelOverride = "model-1", } ); @@ -248,7 +248,7 @@ public void TargetActionPluginId_PersistsCorrectly() Name = "With Target", SystemPrompt = "test", TargetActionPluginId = "com.test.linear", - HotkeyKey = "Ctrl+Shift+L" + HotkeyKey = "Ctrl+Shift+L", } ); @@ -266,7 +266,7 @@ public void TargetActionPluginId_NullByDefault() { Id = "1", Name = "Normal", - SystemPrompt = "test" + SystemPrompt = "test", } ); @@ -285,7 +285,7 @@ public void IsManualOnly_PersistsCorrectly() Id = "1", Name = "Manual", SystemPrompt = "test", - IsManualOnly = true + IsManualOnly = true, } ); @@ -326,7 +326,7 @@ public void AddAction_WhenSaveFails_ThrowsWithoutChangingCacheFileOrEvent() { Id = "new", Name = "New", - SystemPrompt = "Do not persist" + SystemPrompt = "Do not persist", } ) ); diff --git a/tests/TypeWhisper.Core.Tests/Services/SettingsServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/SettingsServiceTests.cs index 3ca823e7b..ef62802d8 100644 --- a/tests/TypeWhisper.Core.Tests/Services/SettingsServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/SettingsServiceTests.cs @@ -10,7 +10,7 @@ public sealed class SettingsServiceTests : IDisposable private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; private readonly string _filePath; @@ -64,8 +64,8 @@ public void SaveAndLoad_RoundTrips() AppInsertionStrategies = new Dictionary { ["kitty"] = TextInsertionStrategy.DirectTyping, - ["firefox"] = TextInsertionStrategy.ClipboardPaste - } + ["firefox"] = TextInsertionStrategy.ClipboardPaste, + }, }; sut.Save(settings); @@ -227,8 +227,8 @@ public async Task Update_ConcurrentDisjointMutations_AllSurvive() current.AppInsertionStrategies, StringComparer.OrdinalIgnoreCase) { - [$"app{idx}"] = TextInsertionStrategy.DirectTyping - } + [$"app{idx}"] = TextInsertionStrategy.DirectTyping, + }, }); }); } @@ -289,7 +289,7 @@ public void SaveAndLoad_RoundTripsMinuteBasedRetention() AppSettings.Default with { HistoryRetentionMode = HistoryRetentionMode.Duration, - HistoryRetentionMinutes = 60 + HistoryRetentionMinutes = 60, } ); @@ -306,7 +306,7 @@ public void SaveAndLoad_RoundTripsUntilAppClosesMode() sut.Save( AppSettings.Default with { - HistoryRetentionMode = HistoryRetentionMode.UntilAppCloses + HistoryRetentionMode = HistoryRetentionMode.UntilAppCloses, } ); diff --git a/tests/TypeWhisper.Core.Tests/Services/SnippetServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/SnippetServiceTests.cs index f272475d0..760bd36e3 100644 --- a/tests/TypeWhisper.Core.Tests/Services/SnippetServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/SnippetServiceTests.cs @@ -32,7 +32,7 @@ public void AddSnippet_WithTags_PersistsAndLoads() Id = "1", Trigger = "mfg", Replacement = "Mit freundlichen Grüßen", - Tags = "E-Mail,Gruß" + Tags = "E-Mail,Gruß", } ); @@ -49,7 +49,7 @@ public void ApplySnippets_ClipboardPlaceholder_ExpandsFromProvider() { Id = "1", Trigger = "link", - Replacement = "Siehe: {clipboard}" + Replacement = "Siehe: {clipboard}", } ); @@ -65,7 +65,7 @@ public void ApplySnippets_ClipboardPlaceholder_EmptyWhenNoProvider() { Id = "1", Trigger = "link", - Replacement = "Siehe: {clipboard}" + Replacement = "Siehe: {clipboard}", } ); @@ -81,7 +81,7 @@ public void ApplySnippets_CustomDateFormat_ExpandsCorrectly() { Id = "1", Trigger = "heute", - Replacement = "{date:dd.MM.yyyy}" + Replacement = "{date:dd.MM.yyyy}", } ); @@ -97,7 +97,7 @@ public void ApplySnippets_CustomTimeFormat_ExpandsCorrectly() { Id = "1", Trigger = "uhr", - Replacement = "{time:HH:mm:ss}" + Replacement = "{time:HH:mm:ss}", } ); @@ -114,7 +114,7 @@ public void ApplySnippets_StandardPlaceholders_StillWork() { Id = "1", Trigger = "datum", - Replacement = "{date}" + Replacement = "{date}", } ); _sut.AddSnippet( @@ -122,7 +122,7 @@ public void ApplySnippets_StandardPlaceholders_StillWork() { Id = "2", Trigger = "zeit", - Replacement = "{time}" + Replacement = "{time}", } ); _sut.AddSnippet( @@ -130,7 +130,7 @@ public void ApplySnippets_StandardPlaceholders_StillWork() { Id = "3", Trigger = "tag", - Replacement = "{day}" + Replacement = "{day}", } ); _sut.AddSnippet( @@ -138,7 +138,7 @@ public void ApplySnippets_StandardPlaceholders_StillWork() { Id = "4", Trigger = "jahr", - Replacement = "{year}" + Replacement = "{year}", } ); @@ -171,7 +171,7 @@ public void AllTags_ReturnsDistinctSortedTags() Id = "1", Trigger = "a", Replacement = "A", - Tags = "Code,E-Mail" + Tags = "Code,E-Mail", } ); _sut.AddSnippet( @@ -180,7 +180,7 @@ public void AllTags_ReturnsDistinctSortedTags() Id = "2", Trigger = "b", Replacement = "B", - Tags = "E-Mail,Datum" + Tags = "E-Mail,Datum", } ); _sut.AddSnippet( @@ -189,7 +189,7 @@ public void AllTags_ReturnsDistinctSortedTags() Id = "3", Trigger = "c", Replacement = "C", - Tags = "" + Tags = "", } ); @@ -209,7 +209,7 @@ public void ExportToJson_ReturnsValidJson() Id = "1", Trigger = "mfg", Replacement = "Grüße", - Tags = "E-Mail" + Tags = "E-Mail", } ); _sut.AddSnippet( @@ -217,7 +217,7 @@ public void ExportToJson_ReturnsValidJson() { Id = "2", Trigger = "sig", - Replacement = "Signatur\nZeile 2" + Replacement = "Signatur\nZeile 2", } ); @@ -236,7 +236,7 @@ public void ImportFromJson_AddsSnippets() { Id = "1", Trigger = "existing", - Replacement = "Existing" + Replacement = "Existing", } ); @@ -260,7 +260,7 @@ public void ImportFromJson_SkipsDuplicateTriggers() { Id = "1", Trigger = "mfg", - Replacement = "Grüße" + Replacement = "Grüße", } ); @@ -284,7 +284,7 @@ public void ApplySnippets_MultilineReplacement_Works() { Id = "1", Trigger = "sig", - Replacement = "Mit freundlichen Grüßen\nMarco Mustermann\nTypeWhisper GmbH" + Replacement = "Mit freundlichen Grüßen\nMarco Mustermann\nTypeWhisper GmbH", } ); @@ -305,7 +305,7 @@ public void ApplySnippets_ConsumesTrailingPunctuation(string input, string expec { Id = "1", Trigger = "mfg", - Replacement = "Mit freundlichen Grüßen" + Replacement = "Mit freundlichen Grüßen", } ); @@ -322,7 +322,7 @@ public void ApplySnippets_ExactPhraseTrigger_ReplacesWholeUtteranceOnly() Id = "1", Trigger = "sig", Replacement = "Signature", - TriggerMode = SnippetTriggerMode.ExactPhrase + TriggerMode = SnippetTriggerMode.ExactPhrase, } ); @@ -339,7 +339,7 @@ public void ApplySnippets_ProfileScopedSnippet_OnlyAppliesToMatchingProfile() Id = "1", Trigger = "sig", Replacement = "Profile signature", - ProfileIds = ["profile-1"] + ProfileIds = ["profile-1"], } ); @@ -356,7 +356,7 @@ public void ApplySnippets_GlobalSnippet_AppliesWhenProfileIsActive() { Id = "1", Trigger = "sig", - Replacement = "Global signature" + Replacement = "Global signature", } ); @@ -371,7 +371,7 @@ public void ApplySnippets_UpdatesLastUsedAt() { Id = "1", Trigger = "sig", - Replacement = "Signature" + Replacement = "Signature", } ); @@ -414,7 +414,7 @@ public void UpdateSnippet_WithTags_PersistsChanges() Id = "1", Trigger = "mfg", Replacement = "Grüße", - Tags = "Alt" + Tags = "Alt", } ); _sut.UpdateSnippet( @@ -423,7 +423,7 @@ public void UpdateSnippet_WithTags_PersistsChanges() Id = "1", Trigger = "mfg", Replacement = "Grüße", - Tags = "Neu" + Tags = "Neu", } ); @@ -449,7 +449,7 @@ public void AddSnippet_WhenSaveFails_ThrowsWithoutChangingCacheFileOrEvent() { Id = "new", Trigger = "new", - Replacement = "Do not persist" + Replacement = "Do not persist", } ) ); diff --git a/tests/TypeWhisper.Core.Tests/Services/SubtitleExporterTests.cs b/tests/TypeWhisper.Core.Tests/Services/SubtitleExporterTests.cs index 1d9c4c25f..213dc2dd3 100644 --- a/tests/TypeWhisper.Core.Tests/Services/SubtitleExporterTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/SubtitleExporterTests.cs @@ -10,7 +10,7 @@ public void ToSrt_SegmentPast24Hours_UsesTotalElapsedHours() { var segments = new List { - new("late cue", 90001.5, 90002.5) + new("late cue", 90001.5, 90002.5), }; var srt = SubtitleExporter.ToSrt(segments); @@ -23,7 +23,7 @@ public void ToWebVtt_SegmentPast24Hours_UsesTotalElapsedHours() { var segments = new List { - new("late cue", 90001.5, 90002.5) + new("late cue", 90001.5, 90002.5), }; var vtt = SubtitleExporter.ToWebVtt(segments); @@ -36,7 +36,7 @@ public void ToSrt_SegmentUnder24Hours_FormatsNormally() { var segments = new List { - new("normal cue", 3661.25, 3662.25) + new("normal cue", 3661.25, 3662.25), }; var srt = SubtitleExporter.ToSrt(segments); @@ -49,7 +49,7 @@ public void ToWebVtt_SegmentUnder24Hours_FormatsNormally() { var segments = new List { - new("normal cue", 3661.25, 3662.25) + new("normal cue", 3661.25, 3662.25), }; var vtt = SubtitleExporter.ToWebVtt(segments); diff --git a/tests/TypeWhisper.Core.Tests/Services/VocabularyBoostingServiceTests.cs b/tests/TypeWhisper.Core.Tests/Services/VocabularyBoostingServiceTests.cs index 942b3d1c6..2f56c91b2 100644 --- a/tests/TypeWhisper.Core.Tests/Services/VocabularyBoostingServiceTests.cs +++ b/tests/TypeWhisper.Core.Tests/Services/VocabularyBoostingServiceTests.cs @@ -15,7 +15,7 @@ public void Apply_ExactTermAlreadyPresent_LeavesTextUnchanged() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "TypeWhisper" + Original = "TypeWhisper", } ); @@ -32,7 +32,7 @@ public void Apply_SingleWordTerm_RewritesSimilarRecognition() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "Parakeet" + Original = "Parakeet", } ); @@ -49,7 +49,7 @@ public void Apply_MultiWordWindow_RewritesToStoredTerm() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "TypeWhisper" + Original = "TypeWhisper", } ); @@ -66,7 +66,7 @@ public void Apply_LowSimilarity_DoesNotRewrite() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "Parakeet" + Original = "Parakeet", } ); @@ -83,13 +83,13 @@ public void Apply_AmbiguousMatchWithinMargin_DoesNotRewrite() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "Parakeet" + Original = "Parakeet", }, new DictionaryEntry { Id = "manual-2", EntryType = DictionaryEntryType.Term, - Original = "Parakeat" + Original = "Parakeat", } ); @@ -106,13 +106,13 @@ public void Apply_LongerTerm_WinsOverShorterOverlap() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "Visual Studio" + Original = "Visual Studio", }, new DictionaryEntry { Id = "manual-2", EntryType = DictionaryEntryType.Term, - Original = "Studio" + Original = "Studio", } ); @@ -129,13 +129,13 @@ public void Apply_ManualTerm_WinsOverPackVariant() { Id = "pack:dotnet:typewhisper", EntryType = DictionaryEntryType.Term, - Original = "typewhisper" + Original = "typewhisper", }, new DictionaryEntry { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "TypeWhisper" + Original = "TypeWhisper", } ); @@ -153,7 +153,7 @@ public void Apply_DisabledTerms_AreIgnored() Id = "manual-1", EntryType = DictionaryEntryType.Term, Original = "TypeWhisper", - IsEnabled = false + IsEnabled = false, } ); @@ -171,7 +171,7 @@ public void Apply_CorrectionEntries_AreIgnoredAsBoostSource() Id = "manual-1", EntryType = DictionaryEntryType.Correction, Original = "type whisper", - Replacement = "TypeWhisper" + Replacement = "TypeWhisper", } ); @@ -188,7 +188,7 @@ public void Apply_HyphenAndWhitespaceNormalization_RewritesToStoredForm() { Id = "manual-1", EntryType = DictionaryEntryType.Term, - Original = "Type-Whisper" + Original = "Type-Whisper", } ); @@ -206,7 +206,7 @@ public void Apply_TermWithReplacement_UsesCanonicalReplacementAsOutput() Id = "manual-1", EntryType = DictionaryEntryType.Term, Original = "Type visped.", - Replacement = "TypeWhisper" + Replacement = "TypeWhisper", } ); diff --git a/tests/TypeWhisper.Linux.Tests/AppInsertionStrategyRowTests.cs b/tests/TypeWhisper.Linux.Tests/AppInsertionStrategyRowTests.cs index 857b45cc7..bf7c97853 100644 --- a/tests/TypeWhisper.Linux.Tests/AppInsertionStrategyRowTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AppInsertionStrategyRowTests.cs @@ -11,7 +11,7 @@ public sealed class AppInsertionStrategyRowTests new(TextInsertionStrategy.Auto, "Auto"), new(TextInsertionStrategy.ClipboardPaste, "Clipboard paste"), new(TextInsertionStrategy.DirectTyping, "Direct typing"), - new(TextInsertionStrategy.CopyOnly, "Copy only") + new(TextInsertionStrategy.CopyOnly, "Copy only"), ]; [Fact] @@ -25,7 +25,7 @@ public void SelectedStrategyOption_UpdatesStrategyAndNotifiesChange() () => changeCount++ ) { SelectedStrategyOption = s_options.First(option => option.Value == TextInsertionStrategy.DirectTyping - ) + ), }; Assert.Equal(TextInsertionStrategy.DirectTyping, sut.Strategy); diff --git a/tests/TypeWhisper.Linux.Tests/AppearanceSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/AppearanceSectionViewModelTests.cs index 82f26a652..9ad34c9e1 100644 --- a/tests/TypeWhisper.Linux.Tests/AppearanceSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AppearanceSectionViewModelTests.cs @@ -54,7 +54,7 @@ public void IsOverlayPositionCustomized_RequiresBothFields( AppSettings.Default with { OverlayCustomLeft = left, - OverlayCustomTop = top + OverlayCustomTop = top, }); var sut = new AppearanceSectionViewModel(settings.Object); @@ -69,7 +69,7 @@ public void ResetOverlayPositionCommand_ClearsBothFields() AppSettings.Default with { OverlayCustomLeft = 120.0, - OverlayCustomTop = 80.0 + OverlayCustomTop = 80.0, }); var sut = new AppearanceSectionViewModel(settings.Object); @@ -95,7 +95,7 @@ public void Refresh_PropagatesIsOverlayPositionCustomized() var updated = AppSettings.Default with { OverlayCustomLeft = 250.0, - OverlayCustomTop = 150.0 + OverlayCustomTop = 150.0, }; settings.SetupGet(s => s.Current).Returns(updated); settings.Raise(s => s.SettingsChanged += null, updated); diff --git a/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs index d8ab62aa7..14ee464a0 100644 --- a/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AudioDuckingServiceTests.cs @@ -178,7 +178,7 @@ Sink Input #7 0, string.Empty, "forced timeout" - ) + ), }; runner.RespondWith( (fileName, args) => fileName == "pactl" && args.SequenceEqual(list), diff --git a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs index e1e6f11b0..24fcedab4 100644 --- a/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AudioRecordingServiceTests.cs @@ -881,6 +881,9 @@ private static short[] AssertPcm16Wav(byte[] wav, short[] expectedSamples) private sealed class FakeSettingsService(AppSettings current) : ISettingsService { + // ISettingsService.Update must read and persist under the same gate as Save. + private readonly Lock _gate = new(); + public int SaveCount { get; private set; } public AppSettings Current { get; private set; } = current; @@ -891,16 +894,22 @@ public AppSettings Load() public void Save(AppSettings settings) { - SaveCount++; - Current = settings; - SettingsChanged?.Invoke(settings); + lock (_gate) + { + SaveCount++; + Current = settings; + SettingsChanged?.Invoke(settings); + } } public AppSettings Update(Func mutate) { - var updated = mutate(Current); - Save(updated); - return updated; + lock (_gate) + { + var updated = mutate(Current); + Save(updated); + return updated; + } } public event Action? SettingsChanged; diff --git a/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs b/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs index 581aa8496..3ea23e8d1 100644 --- a/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ControlSocketOwnershipTests.cs @@ -109,7 +109,7 @@ public async Task RefusedClientCleanup_CannotUnlinkBoundOwnerBeforeListen() var request = new JsonControlProtocol.Request { Version = JsonControlProtocol.CurrentVersion, - Command = JsonControlProtocol.CmdStatus + Command = JsonControlProtocol.CmdStatus, }; Assert.False( ControlSocketClient.TrySendJson( diff --git a/tests/TypeWhisper.Linux.Tests/DashboardSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/DashboardSectionViewModelTests.cs index 7177c6b86..992aa52ad 100644 --- a/tests/TypeWhisper.Linux.Tests/DashboardSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DashboardSectionViewModelTests.cs @@ -148,7 +148,7 @@ private static TranscriptionRecord CreateRecord( SnippetApplied = snippetApplied, DictionaryCorrectionApplied = dictionaryApplied, PromptActionApplied = promptApplied, - TranslationApplied = translationApplied + TranslationApplied = translationApplied, }; } } \ No newline at end of file diff --git a/tests/TypeWhisper.Linux.Tests/DictationOrchestratorDiscardFeedbackTests.cs b/tests/TypeWhisper.Linux.Tests/DictationOrchestratorDiscardFeedbackTests.cs index 14cffb41f..1878c8a23 100644 --- a/tests/TypeWhisper.Linux.Tests/DictationOrchestratorDiscardFeedbackTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DictationOrchestratorDiscardFeedbackTests.cs @@ -66,7 +66,7 @@ public void Discard_reasons_resolve_their_distinct_localized_messages() var discardReason in new[] { LinuxShortSpeechDecision.DiscardTooShort, - LinuxShortSpeechDecision.DiscardNoSpeech + LinuxShortSpeechDecision.DiscardNoSpeech, } ) { diff --git a/tests/TypeWhisper.Linux.Tests/DictationOrchestratorPromptActionResolutionTests.cs b/tests/TypeWhisper.Linux.Tests/DictationOrchestratorPromptActionResolutionTests.cs index 4dad27f21..0b63e64fb 100644 --- a/tests/TypeWhisper.Linux.Tests/DictationOrchestratorPromptActionResolutionTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DictationOrchestratorPromptActionResolutionTests.cs @@ -19,7 +19,7 @@ public void ResolveAutoPromptAction_ReturnsActionWhenNotManualOnly() { Id = "auto", Name = "Auto", - SystemPrompt = "x" + SystemPrompt = "x", }; var resolved = DictationOrchestrator.ResolveAutoPromptAction("auto", [action]); @@ -35,7 +35,7 @@ public void ResolveAutoPromptAction_ReturnsNullWhenManualOnly() Id = "manual", Name = "Manual", SystemPrompt = "x", - IsManualOnly = true + IsManualOnly = true, }; var resolved = DictationOrchestrator.ResolveAutoPromptAction("manual", [action]); @@ -58,7 +58,7 @@ public void ResolveAutoPromptAction_ReturnsNullWhenIdDoesNotMatch() { Id = "other", Name = "Other", - SystemPrompt = "x" + SystemPrompt = "x", }; var resolved = DictationOrchestrator.ResolveAutoPromptAction("missing", [action]); diff --git a/tests/TypeWhisper.Linux.Tests/DictationShortcutSpecFactoryTests.cs b/tests/TypeWhisper.Linux.Tests/DictationShortcutSpecFactoryTests.cs index 7b45421b8..7d7ac8ce5 100644 --- a/tests/TypeWhisper.Linux.Tests/DictationShortcutSpecFactoryTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DictationShortcutSpecFactoryTests.cs @@ -117,7 +117,7 @@ private SettingsService CreateSettings(RecordingMode mode, string trigger = "Ctr settings.Current with { Mode = mode, - ToggleHotkey = trigger + ToggleHotkey = trigger, } ); return settings; @@ -131,7 +131,7 @@ private static IDeShortcutWriter CreateWriter(string writerId) "sway" => new SwayShortcutWriter(), "gnome" => new GnomeShortcutWriter(), "kde" => new KdeShortcutWriter(), - _ => throw new ArgumentOutOfRangeException(nameof(writerId), writerId, null) + _ => throw new ArgumentOutOfRangeException(nameof(writerId), writerId, null), }; } } diff --git a/tests/TypeWhisper.Linux.Tests/DictionarySectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/DictionarySectionViewModelTests.cs index 162d02a9c..b302a4781 100644 --- a/tests/TypeWhisper.Linux.Tests/DictionarySectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DictionarySectionViewModelTests.cs @@ -59,7 +59,7 @@ public void EntryControls_UpdateStarredAndPriority() EntryType = DictionaryEntryType.Correction, Original = "wispr", Replacement = "Wispr", - Priority = 1 + Priority = 1, }; dictionary.AddEntry(entry); var sut = CreateViewModel(dictionary); @@ -86,22 +86,22 @@ public void Refresh_SortsStarredAndHighPriorityFirst() { Id = "low", EntryType = DictionaryEntryType.Term, - Original = "alpha" + Original = "alpha", }, new DictionaryEntry { Id = "priority", EntryType = DictionaryEntryType.Term, Original = "beta", - Priority = 5 + Priority = 5, }, new DictionaryEntry { Id = "starred", EntryType = DictionaryEntryType.Term, Original = "gamma", - IsStarred = true - } + IsStarred = true, + }, ]); var sut = CreateViewModel(dictionary); diff --git a/tests/TypeWhisper.Linux.Tests/EvdevDeviceReaderTests.cs b/tests/TypeWhisper.Linux.Tests/EvdevDeviceReaderTests.cs index 8c45048cb..074bddd07 100644 --- a/tests/TypeWhisper.Linux.Tests/EvdevDeviceReaderTests.cs +++ b/tests/TypeWhisper.Linux.Tests/EvdevDeviceReaderTests.cs @@ -38,7 +38,7 @@ public async Task SynDropped_ReconcilesInDeterministicOrderAndResumesStream() { var device = new FakeInputDevice { - Snapshot = Bitmap(LinuxKeyMap.KeyLeftctrl, 30) // LeftCtrl + KEY_A. + Snapshot = Bitmap(LinuxKeyMap.KeyLeftctrl, 30), // LeftCtrl + KEY_A. }; var events = new EventLog(); var failure = NewFailureSignal(); @@ -68,7 +68,7 @@ public async Task SynDropped_ReconcilesInDeterministicOrderAndResumesStream() new KeyEdge(LinuxKeyMap.KeyLeftshift, false), new KeyEdge(LinuxKeyMap.KeyLeftctrl, true), // Presses: modifier before terminal. new KeyEdge(30, true), - new KeyEdge(30, false) + new KeyEdge(30, false), ], events.Snapshot() ); diff --git a/tests/TypeWhisper.Linux.Tests/EvdevGlobalShortcutBackendTests.cs b/tests/TypeWhisper.Linux.Tests/EvdevGlobalShortcutBackendTests.cs index f954db289..e1c4b157c 100644 --- a/tests/TypeWhisper.Linux.Tests/EvdevGlobalShortcutBackendTests.cs +++ b/tests/TypeWhisper.Linux.Tests/EvdevGlobalShortcutBackendTests.cs @@ -20,7 +20,7 @@ public async Task SameModifierHeldByTwoReaders_OneReleaseKeepsRemainingChordUsab var shortcuts = DefaultShortcuts() with { - DictationModifiers = ModifierMask.LeftCtrl + DictationModifiers = ModifierMask.LeftCtrl, }; Assert.True((await backend.RegisterAsync(shortcuts, CancellationToken.None)).Success); var readers = factory.Readers; @@ -46,7 +46,7 @@ public async Task ReaderFailure_SubtractsOnlyFailedReaderState() var shortcuts = DefaultShortcuts() with { - DictationModifiers = ModifierMask.LeftCtrl + DictationModifiers = ModifierMask.LeftCtrl, }; Assert.True((await backend.RegisterAsync(shortcuts, CancellationToken.None)).Success); var readers = factory.Readers; @@ -77,7 +77,7 @@ public async Task ReaderFailure_ReleasesSoleModifierAndPreventsFalseLaterChord() { DictationModifiers = ModifierMask.LeftCtrl, PromptPaletteKey = KeyCode.VcLeftControl, - PromptPaletteModifiers = ModifierMask.None + PromptPaletteModifiers = ModifierMask.None, }; Assert.True((await backend.RegisterAsync(shortcuts, CancellationToken.None)).Success); var readers = factory.Readers; @@ -110,7 +110,7 @@ public async Task ReaderFailure_ReleasesHeldDictationKeyUsingPressTimeMode() var pushToTalk = DefaultShortcuts() with { DictationModifiers = ModifierMask.None, - Mode = RecordingMode.PushToTalk + Mode = RecordingMode.PushToTalk, }; Assert.True((await backend.RegisterAsync(pushToTalk, CancellationToken.None)).Success); var readers = factory.Readers; diff --git a/tests/TypeWhisper.Linux.Tests/FileTranscriptionSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/FileTranscriptionSectionViewModelTests.cs index 8c9583026..969d9512e 100644 --- a/tests/TypeWhisper.Linux.Tests/FileTranscriptionSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/FileTranscriptionSectionViewModelTests.cs @@ -45,7 +45,7 @@ public void ClearQueue_RemovesTerminalItems_KeepsActiveAndQueued() new[] { FileTranscriptionQueueItemStatus.Queued, - FileTranscriptionQueueItemStatus.Transcribing + FileTranscriptionQueueItemStatus.Transcribing, }, remaining); Assert.False(vm.HasClearableItems); @@ -139,7 +139,7 @@ private SettingsService CreateSettingsWithPoisonedWatchFolder(bool autoStart) settings.Current with { WatchFolderPath = Path.Join(poisonedParent, "watch-folder"), - WatchFolderAutoStart = autoStart + WatchFolderAutoStart = autoStart, } ); return settings; diff --git a/tests/TypeWhisper.Linux.Tests/GnomeShortcutWriterTests.cs b/tests/TypeWhisper.Linux.Tests/GnomeShortcutWriterTests.cs index d9f3f5cfc..98819d20b 100644 --- a/tests/TypeWhisper.Linux.Tests/GnomeShortcutWriterTests.cs +++ b/tests/TypeWhisper.Linux.Tests/GnomeShortcutWriterTests.cs @@ -547,7 +547,7 @@ private enum MutationMode { None, AfterFirstGet, - AfterEveryGet + AfterEveryGet, } private sealed class StatefulGSettingsRunner : IProcessRunner diff --git a/tests/TypeWhisper.Linux.Tests/HistorySectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/HistorySectionViewModelTests.cs index 6e6362d85..a345d162f 100644 --- a/tests/TypeWhisper.Linux.Tests/HistorySectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HistorySectionViewModelTests.cs @@ -126,7 +126,7 @@ private SettingsService CreateSettingsService( AppSettings.Default with { AutoAddDictionaryCorrections = autoAddCorrections, - CaptureLlmProvenance = captureProvenance + CaptureLlmProvenance = captureProvenance, } ); return settings; @@ -165,7 +165,7 @@ private static TranscriptionRecord CreateRecord( FinalText = finalText, DurationSeconds = 2.4, AppProcessName = "test", - LlmCalls = llmCalls ?? [] + LlmCalls = llmCalls ?? [], }; } @@ -186,7 +186,7 @@ private static LlmCallProvenance CreateCall( ProviderId = "com.test.provider", ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = injectedContext + InjectedMemoryContext = injectedContext, }; } @@ -203,7 +203,7 @@ public void InspectorCalls_ProjectsProvenanceWithLabels() CreateCall( "Cleanup", injectedContext: "remembered fact" - ) + ), ] ); history.AddRecord(record); diff --git a/tests/TypeWhisper.Linux.Tests/HotkeyServiceTests.cs b/tests/TypeWhisper.Linux.Tests/HotkeyServiceTests.cs index 8a82e3800..b7fd76e87 100644 --- a/tests/TypeWhisper.Linux.Tests/HotkeyServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HotkeyServiceTests.cs @@ -85,8 +85,8 @@ public void ValidatePromptActionHotkeyCandidate_RejectsEnabledPromptActionCollis Id = "other", Name = "Other", SystemPrompt = "x", - HotkeyKey = "Alt+F8" - } + HotkeyKey = "Alt+F8", + }, }; var result = hotkey.ValidatePromptActionHotkeyCandidate( @@ -112,8 +112,8 @@ public void ValidatePromptActionHotkeyCandidate_UsesCrossSideModifierPrefixForPr { Id = "other", Name = "Other", - HotkeyData = "Right Ctrl" - } + HotkeyData = "Right Ctrl", + }, }; var result = hotkey.ValidatePromptActionHotkeyCandidate( @@ -140,7 +140,7 @@ public void ValidateCandidates_AllowOwnUnchangedBindingAndIgnoreDisabledOthers() Id = "edited-action", Name = "Edited", SystemPrompt = "x", - HotkeyKey = "alt+f8" + HotkeyKey = "alt+f8", }, new PromptAction { @@ -148,8 +148,8 @@ public void ValidateCandidates_AllowOwnUnchangedBindingAndIgnoreDisabledOthers() Name = "Disabled", SystemPrompt = "x", HotkeyKey = "Alt+F8", - IsEnabled = false - } + IsEnabled = false, + }, }; var profiles = new[] { @@ -157,15 +157,15 @@ public void ValidateCandidates_AllowOwnUnchangedBindingAndIgnoreDisabledOthers() { Id = "edited-profile", Name = "Edited", - HotkeyData = "Meta+F9" + HotkeyData = "Meta+F9", }, new Profile { Id = "disabled-profile", Name = "Disabled", HotkeyData = "Meta+F9", - IsEnabled = false - } + IsEnabled = false, + }, }; var actionResult = hotkey.ValidatePromptActionHotkeyCandidate( @@ -223,7 +223,7 @@ public void ValidateProfileHotkeyCandidate_RequiresUsableSelectedTextDestination Id = "disabled", Name = "Disabled", SystemPrompt = "x", - IsEnabled = false + IsEnabled = false, }; var enabled = disabled with { Id = "enabled", Name = "Enabled", IsEnabled = true }; @@ -297,7 +297,7 @@ public async Task Initialize_RecordsRequiresToggleModeFromBackend() null, true, null - ) + ), }; using var hotkey = new HotkeyService(new BackendSelector(() => backend)); @@ -319,7 +319,7 @@ public async Task PushShortcuts_FailedRegistration_RaisesHookFailed() "boom", false, null - ) + ), }; using var hotkey = new HotkeyService(new BackendSelector(() => backend)); string? observed = null; @@ -396,7 +396,7 @@ public async Task SetPromptActionHotkeys_DropsEntryCollidingWithDictation() "keeper", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt - ) + ), ] ); @@ -425,7 +425,7 @@ public async Task SetPromptActionHotkeys_KeepsFirstDuplicateAndDropsSecond() "second", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt - ) + ), ] ); @@ -461,7 +461,7 @@ public async Task SetPromptActionHotkeys_DropsIntraBatchPrefixCollision() "ctrl-chord", KeyCode.VcF12, ModifierMask.LeftCtrl - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -512,7 +512,7 @@ public async Task TrySetHotkeyFromString_RejectsChordAlreadyBoundToPromptAction( "alpha", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -536,7 +536,7 @@ public async Task SetPromptActionHotkeys_AcceptsUnchangedListWithoutSelfConflict var entries = new[] { new PromptActionHotkey("alpha", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt), - new PromptActionHotkey("beta", KeyCode.VcT, ModifierMask.LeftCtrl | ModifierMask.LeftAlt) + new PromptActionHotkey("beta", KeyCode.VcT, ModifierMask.LeftCtrl | ModifierMask.LeftAlt), }; hotkey.SetPromptActionHotkeys(entries); @@ -559,7 +559,7 @@ public void ParsePromptActionHotkeys_SkipsDisabledOrUnparseableActions() Id = "enabled", Name = "E", SystemPrompt = "x", - HotkeyKey = "Ctrl+Alt+R" + HotkeyKey = "Ctrl+Alt+R", }, new PromptAction { @@ -567,21 +567,21 @@ public void ParsePromptActionHotkeys_SkipsDisabledOrUnparseableActions() Name = "D", SystemPrompt = "x", IsEnabled = false, - HotkeyKey = "Ctrl+Alt+T" + HotkeyKey = "Ctrl+Alt+T", }, new PromptAction { Id = "no-hotkey", Name = "N", - SystemPrompt = "x" + SystemPrompt = "x", }, new PromptAction { Id = "bad", Name = "B", SystemPrompt = "x", - HotkeyKey = "Not+a+real+combo" - } + HotkeyKey = "Not+a+real+combo", + }, ] ); @@ -605,7 +605,7 @@ public async Task SetProfileHotkeys_RaisesProfileDictationToggleRequestedWithId( KeyCode.VcE, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -633,7 +633,7 @@ public async Task SetProfileHotkeys_RaisesProfileTextProcessingRequestedWithId() KeyCode.VcS, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.ProcessSelectedText - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -666,7 +666,7 @@ public async Task SetProfileHotkeys_DropsEntryCollidingWithDictation() KeyCode.VcE, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -702,7 +702,7 @@ [new PromptActionHotkey("action", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierM KeyCode.VcE, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -732,7 +732,7 @@ public async Task SetProfileHotkeys_KeepsFirstDuplicateAndDropsSecond() KeyCode.VcE, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.ProcessSelectedText - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -780,7 +780,7 @@ await Task.WhenAll( foreach (var snapshot in new[] { actionFirstBackend.LastSet, - profileFirstBackend.LastSet + profileFirstBackend.LastSet, }) { Assert.NotNull(snapshot); @@ -808,7 +808,7 @@ [new PromptActionHotkey("action", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierM KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -829,7 +829,7 @@ public async Task DynamicHotkeys_IncrementalResultMatchesFreshCombinedReconcilia PromptActionHotkey[] actions = [ new("action-winner", KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt), - new("action-only", KeyCode.VcT, ModifierMask.LeftCtrl | ModifierMask.LeftAlt) + new("action-only", KeyCode.VcT, ModifierMask.LeftCtrl | ModifierMask.LeftAlt), ]; ProfileHotkey[] profiles = [ @@ -844,7 +844,7 @@ public async Task DynamicHotkeys_IncrementalResultMatchesFreshCombinedReconcilia KeyCode.VcE, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.ProcessSelectedText - ) + ), ]; var incrementalBackend = new TestShortcutBackend(); var freshBackend = new TestShortcutBackend(); @@ -905,7 +905,7 @@ public async Task SetDynamicHotkeys_ReturnsIdentifyingMessageForEveryRejection() "", KeyCode.VcT, ModifierMask.LeftCtrl | ModifierMask.LeftAlt - ) + ), ], [ new ProfileHotkey( @@ -919,7 +919,7 @@ public async Task SetDynamicHotkeys_ReturnsIdentifyingMessageForEveryRejection() KeyCode.VcE, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.ProcessSelectedText - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -980,7 +980,7 @@ public async Task DynamicHotkeys_DefensivelySnapshotsRetainedCandidates() KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.StartDictation - ) + ), }; hotkey.SetProfileHotkeys(profiles); @@ -1009,33 +1009,33 @@ public void ParseProfileHotkeys_SkipsDisabledBlankUnparseable_AndCarriesBehavior Id = "dictate", Name = "Dictate", HotkeyData = "Ctrl+Alt+E", - HotkeyBehavior = ProfileHotkeyBehavior.StartDictation + HotkeyBehavior = ProfileHotkeyBehavior.StartDictation, }, new Profile { Id = "selection", Name = "Selection", HotkeyData = "Ctrl+Alt+S", - HotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText + HotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText, }, new Profile { Id = "disabled", Name = "Disabled", IsEnabled = false, - HotkeyData = "Ctrl+Alt+T" + HotkeyData = "Ctrl+Alt+T", }, new Profile { Id = "no-hotkey", - Name = "None" + Name = "None", }, new Profile { Id = "bad", Name = "Bad", - HotkeyData = "Not+a+real+combo" - } + HotkeyData = "Not+a+real+combo", + }, ] ); @@ -1064,7 +1064,7 @@ public async Task TrySetHotkeyFromString_RejectsChordAlreadyBoundToProfile() KeyCode.VcR, ModifierMask.LeftCtrl | ModifierMask.LeftAlt, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); await backend.WaitUntilSettledAsync(); @@ -1308,7 +1308,7 @@ public async Task SetPromptActionHotkeys_DropsEntryThatPrefixesExistingChord() "keeper", KeyCode.VcR, ModifierMask.LeftAlt | ModifierMask.LeftMeta - ) + ), ] ); @@ -1338,7 +1338,7 @@ [new PromptActionHotkey("action", KeyCode.VcF10, ModifierMask.LeftMeta)], KeyCode.VcF11, ModifierMask.LeftMeta, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); hotkey.IsCancelShortcutEnabled = true; @@ -1435,7 +1435,7 @@ [new PromptActionHotkey("action", KeyCode.VcF10, ModifierMask.LeftMeta)], KeyCode.VcF11, ModifierMask.LeftMeta, ProfileHotkeyBehavior.StartDictation - ) + ), ] ); hotkey.Initialize(); diff --git a/tests/TypeWhisper.Linux.Tests/HttpApiAccelerationDtoTests.cs b/tests/TypeWhisper.Linux.Tests/HttpApiAccelerationDtoTests.cs index 2c5a9a77d..6232bb5e8 100644 --- a/tests/TypeWhisper.Linux.Tests/HttpApiAccelerationDtoTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HttpApiAccelerationDtoTests.cs @@ -25,11 +25,11 @@ public void BuildAccelerationDto_WhisperCppCpuPreference_ReflectsCpuStatus() AccelerationStatus = new TranscriptionAccelerationStatus( TranscriptionAccelerationBackend.Cpu, "Using CPU" - ) + ), }; var settings = AppSettings.Default with { - LocalModelAcceleration = AppSettings.LocalModelAccelerationCpu + LocalModelAcceleration = AppSettings.LocalModelAccelerationCpu, }; var dto = HttpApiService.BuildAccelerationDto(plugin, settings); @@ -51,11 +51,11 @@ public void BuildAccelerationDto_AutoPreferenceWithCpuLoaded_IncludesDetail() TranscriptionAccelerationBackend.Cpu, "Using CPU", "CUDA not available; falling back to CPU." - ) + ), }; var settings = AppSettings.Default with { - LocalModelAcceleration = AppSettings.LocalModelAccelerationAuto + LocalModelAcceleration = AppSettings.LocalModelAccelerationAuto, }; var dto = HttpApiService.BuildAccelerationDto(plugin, settings); @@ -78,11 +78,11 @@ public void BuildAccelerationDto_RequiresRestart_PropagatesFlag() "Using CPU", "Process is pinned to CPU. Restart to switch to NVIDIA CUDA.", RequiresRestart: true - ) + ), }; var settings = AppSettings.Default with { - LocalModelAcceleration = AppSettings.LocalModelAccelerationNvidiaCuda + LocalModelAcceleration = AppSettings.LocalModelAccelerationNvidiaCuda, }; var dto = HttpApiService.BuildAccelerationDto(plugin, settings); diff --git a/tests/TypeWhisper.Linux.Tests/HttpApiCorrectionsDtoTests.cs b/tests/TypeWhisper.Linux.Tests/HttpApiCorrectionsDtoTests.cs index b0a3167a0..2575a330b 100644 --- a/tests/TypeWhisper.Linux.Tests/HttpApiCorrectionsDtoTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HttpApiCorrectionsDtoTests.cs @@ -9,7 +9,7 @@ public class HttpApiCorrectionsDtoTests private static readonly JsonSerializerOptions s_options = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; [Fact] diff --git a/tests/TypeWhisper.Linux.Tests/HttpApiLocalFileDtoTests.cs b/tests/TypeWhisper.Linux.Tests/HttpApiLocalFileDtoTests.cs index be442b81f..8f50e3b5a 100644 --- a/tests/TypeWhisper.Linux.Tests/HttpApiLocalFileDtoTests.cs +++ b/tests/TypeWhisper.Linux.Tests/HttpApiLocalFileDtoTests.cs @@ -9,7 +9,7 @@ public class HttpApiLocalFileDtoTests private static readonly JsonSerializerOptions s_options = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - PropertyNameCaseInsensitive = true + PropertyNameCaseInsensitive = true, }; [Fact] diff --git a/tests/TypeWhisper.Linux.Tests/InputAccessSetupHelperTests.cs b/tests/TypeWhisper.Linux.Tests/InputAccessSetupHelperTests.cs index 16593057a..92ace588b 100644 --- a/tests/TypeWhisper.Linux.Tests/InputAccessSetupHelperTests.cs +++ b/tests/TypeWhisper.Linux.Tests/InputAccessSetupHelperTests.cs @@ -159,7 +159,7 @@ private static int RunManualWriteBlock() RedirectStandardInput = true, RedirectStandardError = true, RedirectStandardOutput = true, - UseShellExecute = false + UseShellExecute = false, }; // Shim dir first so sudo/udevadm/usermod resolve to our stubs, then real // coreutils (head/cat) from the standard bin dirs. @@ -354,7 +354,7 @@ public async Task InstallAsync_passes_a_bounded_timeout_and_recovers_when_it_fir var runner = new FakeProcessRunner { // Model a stalled polkit prompt that outlives the timeout window. - Default = new ProcessRunResult(true, true, -1, string.Empty, string.Empty) + Default = new ProcessRunResult(true, true, -1, string.Empty, string.Empty), }; var helper = new InputAccessSetupHelper(runner); diff --git a/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs b/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs index c3feb04e8..53999c6b8 100644 --- a/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LearnedCorrectionsFeedbackPresenterTests.cs @@ -52,7 +52,7 @@ public void ShowLearned_MultipleCorrections_UsesCountFormat() presenter.ShowLearned( [ Correction("1", "a", "A"), - Correction("2", "b", "B") + Correction("2", "b", "B"), ]); var feedback = Assert.Single(emitted); diff --git a/tests/TypeWhisper.Linux.Tests/LinuxDictationReadbackLanguagePolicyTests.cs b/tests/TypeWhisper.Linux.Tests/LinuxDictationReadbackLanguagePolicyTests.cs index 7ba78f11b..0b731da9b 100644 --- a/tests/TypeWhisper.Linux.Tests/LinuxDictationReadbackLanguagePolicyTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LinuxDictationReadbackLanguagePolicyTests.cs @@ -361,7 +361,7 @@ public void Resolve_IgnoresLanguagePreservingSteps() new(PostProcessingStepNames.Formatting, Changed: true), new(PostProcessingStepNames.Cleanup, Changed: true), new(PostProcessingStepNames.Dictionary, Changed: true), - new(PostProcessingStepNames.Snippets, Changed: true) + new(PostProcessingStepNames.Snippets, Changed: true), ]; var language = LinuxDictationReadbackLanguagePolicy.Resolve( diff --git a/tests/TypeWhisper.Linux.Tests/LinuxLiveTranscriptionStartupPolicyTests.cs b/tests/TypeWhisper.Linux.Tests/LinuxLiveTranscriptionStartupPolicyTests.cs index dd7dc1fd0..cf8e79fbd 100644 --- a/tests/TypeWhisper.Linux.Tests/LinuxLiveTranscriptionStartupPolicyTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LinuxLiveTranscriptionStartupPolicyTests.cs @@ -48,7 +48,7 @@ public void Select_CloudPluginWithoutOptIn_ReturnsNone() var settings = AppSettings.Default with { LiveTranscriptionEnabled = true, - OnlineAsrBatchLiveTranscriptionEnabled = false + OnlineAsrBatchLiveTranscriptionEnabled = false, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -56,7 +56,7 @@ public void Select_CloudPluginWithoutOptIn_ReturnsNone() new FakeTranscriptionEnginePlugin { SupportsModelDownload = false, - SupportsStreaming = false + SupportsStreaming = false, }); Assert.Equal(LiveTranscriptionMode.None, mode); @@ -68,7 +68,7 @@ public void Select_CloudPluginWithOptIn_ReturnsPolling() var settings = AppSettings.Default with { LiveTranscriptionEnabled = true, - OnlineAsrBatchLiveTranscriptionEnabled = true + OnlineAsrBatchLiveTranscriptionEnabled = true, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -76,7 +76,7 @@ public void Select_CloudPluginWithOptIn_ReturnsPolling() new FakeTranscriptionEnginePlugin { SupportsModelDownload = false, - SupportsStreaming = false + SupportsStreaming = false, }); Assert.Equal(LiveTranscriptionMode.Polling, mode); @@ -91,7 +91,7 @@ public void Select_StreamingCapableCloudPluginWithoutOptIn_ReturnsNone() { LiveTranscriptionEnabled = true, LiveTranscriptionStreamingEnabled = false, - OnlineAsrBatchLiveTranscriptionEnabled = false + OnlineAsrBatchLiveTranscriptionEnabled = false, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -99,7 +99,7 @@ public void Select_StreamingCapableCloudPluginWithoutOptIn_ReturnsNone() new FakeTranscriptionEnginePlugin { SupportsModelDownload = false, - SupportsStreaming = true + SupportsStreaming = true, }); Assert.Equal(LiveTranscriptionMode.None, mode); @@ -111,7 +111,7 @@ public void Select_WhenStreamingCapableAndOptedIn_ReturnsStreaming() var settings = AppSettings.Default with { LiveTranscriptionEnabled = true, - LiveTranscriptionStreamingEnabled = true + LiveTranscriptionStreamingEnabled = true, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -119,7 +119,7 @@ public void Select_WhenStreamingCapableAndOptedIn_ReturnsStreaming() new FakeTranscriptionEnginePlugin { SupportsModelDownload = false, - SupportsStreaming = true + SupportsStreaming = true, }); Assert.Equal(LiveTranscriptionMode.Streaming, mode); @@ -131,7 +131,7 @@ public void Select_WhenStreamingCapableButOptedOut_FallsThroughToPolling() var settings = AppSettings.Default with { LiveTranscriptionEnabled = true, - LiveTranscriptionStreamingEnabled = false + LiveTranscriptionStreamingEnabled = false, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -139,7 +139,7 @@ public void Select_WhenStreamingCapableButOptedOut_FallsThroughToPolling() new FakeTranscriptionEnginePlugin { SupportsModelDownload = true, - SupportsStreaming = true + SupportsStreaming = true, }); Assert.Equal(LiveTranscriptionMode.Polling, mode); @@ -152,7 +152,7 @@ public void Select_WhenStreamingNotCapableButOptedIn_FallsThroughToPolling() { LiveTranscriptionEnabled = true, LiveTranscriptionStreamingEnabled = true, - OnlineAsrBatchLiveTranscriptionEnabled = true + OnlineAsrBatchLiveTranscriptionEnabled = true, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -160,7 +160,7 @@ public void Select_WhenStreamingNotCapableButOptedIn_FallsThroughToPolling() new FakeTranscriptionEnginePlugin { SupportsModelDownload = false, - SupportsStreaming = false + SupportsStreaming = false, }); Assert.Equal(LiveTranscriptionMode.Polling, mode); @@ -175,7 +175,7 @@ public void Select_WhenStreamingWinsOverLocalModel_ReturnsStreaming() var settings = AppSettings.Default with { LiveTranscriptionEnabled = true, - LiveTranscriptionStreamingEnabled = true + LiveTranscriptionStreamingEnabled = true, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -183,7 +183,7 @@ public void Select_WhenStreamingWinsOverLocalModel_ReturnsStreaming() new FakeTranscriptionEnginePlugin { SupportsModelDownload = true, - SupportsStreaming = true + SupportsStreaming = true, }); Assert.Equal(LiveTranscriptionMode.Streaming, mode); @@ -196,7 +196,7 @@ public void Select_WhenLiveTranscriptionDisabled_BeatsEverything() { LiveTranscriptionEnabled = false, LiveTranscriptionStreamingEnabled = true, - OnlineAsrBatchLiveTranscriptionEnabled = true + OnlineAsrBatchLiveTranscriptionEnabled = true, }; var mode = LinuxLiveTranscriptionStartupPolicy.Select( @@ -204,7 +204,7 @@ public void Select_WhenLiveTranscriptionDisabled_BeatsEverything() new FakeTranscriptionEnginePlugin { SupportsModelDownload = true, - SupportsStreaming = true + SupportsStreaming = true, }); Assert.Equal(LiveTranscriptionMode.None, mode); diff --git a/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs b/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs index 2f3d3de03..97c08a11b 100644 --- a/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LinuxSystemTtsProviderTests.cs @@ -254,7 +254,7 @@ public async Task Localized_invocation_does_not_retry_without_voice_rejection(st { "success" => Success(), "not-started" => new ProcessRunResult(false, false, -1, "", "launch failed"), - _ => throw new ArgumentOutOfRangeException(nameof(outcome)) + _ => throw new ArgumentOutOfRangeException(nameof(outcome)), }; var runner = ControlledProcessRunner.WithImmediateResult(result); using var provider = CreateProvider("spd-say", runner); @@ -504,7 +504,7 @@ string outcome "failed" => new ProcessRunResult(false, false, -1, "", "launch failed"), "timed-out" => new ProcessRunResult(true, true, -1, "", ""), "throwing" => null, - _ => throw new ArgumentOutOfRangeException(nameof(outcome)) + _ => throw new ArgumentOutOfRangeException(nameof(outcome)), }; var runner = ControlledProcessRunner.WithPendingResults(2); using var provider = CreateProvider("spd-say", runner); @@ -592,7 +592,7 @@ public async Task Failed_runner_results_end_session_and_complete_once(string fai "not-started" => new ProcessRunResult(false, false, -1, "", "launch failed"), "non-zero" => new ProcessRunResult(true, false, 23, "", "failed"), "timed-out" => new ProcessRunResult(true, true, -1, "", ""), - _ => throw new ArgumentOutOfRangeException(nameof(failure)) + _ => throw new ArgumentOutOfRangeException(nameof(failure)), }; var runner = ControlledProcessRunner.WithImmediateResult(result); using var provider = CreateProvider("espeak", runner); diff --git a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs index 1bc52b64e..81f54d9fe 100644 --- a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs @@ -35,7 +35,7 @@ public void CanonicalCatalog_HasNativeDictationDisclosuresWithoutObsoleteEvdevCl "Shortcuts.NativeDictationOwnershipActive", "Shortcuts.NativeDictationInstallDeferred", "Shortcuts.NativeDictationRemovalActive", - "Shortcuts.NativeDictationRemovalDeferred" + "Shortcuts.NativeDictationRemovalDeferred", }; foreach (var key in disclosureKeys) @@ -57,7 +57,7 @@ public void CanonicalCatalog_HasDesktopIntegrationStaleAndRefreshMessages() "Shortcuts.DesktopIntegrationStale", "Shortcuts.DesktopIntegrationStaleHint", "Shortcuts.DesktopIntegrationStaleUnsupported", - "Shortcuts.RefreshDesktopIntegrationOn" + "Shortcuts.RefreshDesktopIntegrationOn", }; foreach (var key in keys) diff --git a/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs b/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs index b15b85eb0..8be00125e 100644 --- a/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/MediaPauseServiceTests.cs @@ -117,7 +117,7 @@ public void ResumeMedia_retains_timed_out_player_even_with_zero_exit_code() 0, string.Empty, "forced timeout" - ) + ), }; runner.RespondWith( (fileName, args) => fileName == "playerctl" && args.SequenceEqual(status), diff --git a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs index 1cc3825e6..7b220d502 100644 --- a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs @@ -368,7 +368,7 @@ private static PluginCollectionDefinition ThingsDefinition() [ new PluginSettingDefinition("name", "Name", Kind: PluginSettingKind.Text), new PluginSettingDefinition("enabled", "Enabled", Kind: PluginSettingKind.Boolean), - new PluginSettingDefinition("__id", "__id", Kind: PluginSettingKind.Text) + new PluginSettingDefinition("__id", "__id", Kind: PluginSettingKind.Text), ], "name", "Add thing" diff --git a/tests/TypeWhisper.Linux.Tests/PluginRegistryServiceTests.cs b/tests/TypeWhisper.Linux.Tests/PluginRegistryServiceTests.cs index b72cd9f48..5bf1c8d62 100644 --- a/tests/TypeWhisper.Linux.Tests/PluginRegistryServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PluginRegistryServiceTests.cs @@ -50,7 +50,7 @@ public async Task FetchRegistryAsync_DeserializesAndFiltersLinuxCompatiblePlugin Description = "A Linux-compatible plugin", Size = 1024L, DownloadUrl = "https://example.com/plugin.zip", - RequiresApiKey = false + RequiresApiKey = false, }, new { @@ -61,8 +61,8 @@ public async Task FetchRegistryAsync_DeserializesAndFiltersLinuxCompatiblePlugin Description = "A Windows-only plugin entry for this test", Size = 1024L, DownloadUrl = "https://example.com/live-transcript.zip", - RequiresApiKey = false - } + RequiresApiKey = false, + }, }; var json = JsonSerializer.Serialize(plugins); @@ -90,8 +90,8 @@ public async Task FetchRegistryAsync_CachesResults() Description = "D", Size = 100L, DownloadUrl = "u", - RequiresApiKey = false - } + RequiresApiKey = false, + }, }; var json = JsonSerializer.Serialize(plugins); @@ -110,7 +110,7 @@ public async Task FetchRegistryAsync_CachesResults() callCount++; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(json) + Content = new StringContent(json), }; }); diff --git a/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs b/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs index 59d63267b..6300a4d86 100644 --- a/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ProcessRunnerTests.cs @@ -22,7 +22,7 @@ public async Task RunAsync_returns_success_when_descendant_holds_stdout_open() "-c", "sleep 30 & child=$!; printf '%s' \"$child\" > \"$1\"; exit 0", "process-runner-test", - pidFile + pidFile, ], timeout: TimeSpan.FromSeconds(2) ); @@ -314,7 +314,7 @@ public async Task RunAsync_caller_cancellation_wins_during_post_exit_output_drai "-c", "sleep 30 & child=$!; printf '%s %s' \"$$\" \"$child\" > \"$1\"; exit 0", "process-runner-test", - pidFile + pidFile, ], timeout: TimeSpan.FromSeconds(30), ct: cts.Token diff --git a/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs index b23498cb0..be50ee766 100644 --- a/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs @@ -185,7 +185,7 @@ public void SaveProfile_MalformedBindingDoesNotUpdateAndShowsFeedback() new BrowserAccessibilitySetupHelper() ) { - EditHotkeyData = "Ctrl+NoSuchKey" + EditHotkeyData = "Ctrl+NoSuchKey", }; sut.SaveProfileCommand.Execute(null); @@ -210,7 +210,7 @@ public void SaveProfile_CrossDynamicPrefixCollisionDoesNotUpdate() Id = "action", Name = "Action", SystemPrompt = "x", - HotkeyKey = "Right Ctrl" + HotkeyKey = "Right Ctrl", } ); var sut = new ProfilesSectionViewModel( @@ -224,7 +224,7 @@ public void SaveProfile_CrossDynamicPrefixCollisionDoesNotUpdate() new BrowserAccessibilitySetupHelper() ) { - EditHotkeyData = "Ctrl+Alt+E" + EditHotkeyData = "Ctrl+Alt+E", }; sut.SaveProfileCommand.Execute(null); @@ -256,7 +256,7 @@ bool addDisabledAction Id = "disabled", Name = "Disabled", SystemPrompt = "x", - IsEnabled = false + IsEnabled = false, } ); } @@ -274,7 +274,7 @@ bool addDisabledAction { EditHotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText, EditPromptActionId = promptActionId, - EditHotkeyData = "Meta+F9" + EditHotkeyData = "Meta+F9", }; sut.SaveProfileCommand.Execute(null); @@ -300,7 +300,7 @@ public void SaveProfile_SelectedTextBindingWithEnabledActionPersistsCanonicalCho { Id = "enabled", Name = "Enabled", - SystemPrompt = "x" + SystemPrompt = "x", } ); var sut = new ProfilesSectionViewModel( @@ -316,7 +316,7 @@ public void SaveProfile_SelectedTextBindingWithEnabledActionPersistsCanonicalCho { EditHotkeyBehavior = ProfileHotkeyBehavior.ProcessSelectedText, EditPromptActionId = "enabled", - EditHotkeyData = " super + f9 " + EditHotkeyData = " super + f9 ", }; sut.SaveProfileCommand.Execute(null); @@ -357,7 +357,7 @@ public void SaveProfile_StartDictationAcceptsValidOrBlankBinding( ) { EditHotkeyBehavior = ProfileHotkeyBehavior.StartDictation, - EditHotkeyData = draft + EditHotkeyData = draft, }; sut.SaveProfileCommand.Execute(null); @@ -380,7 +380,7 @@ public async Task ActivateLiveContext_AppliesOneSnapshotAndTracksMatchedProfile( IsEnabled = true, Priority = 10, ProcessNames = ["firefox"], - UrlPatterns = [] + UrlPatterns = [], } ); @@ -580,7 +580,7 @@ public void RefreshPromptActionOptions_ExcludesManualOnlyActions() { Id = "auto", Name = "Auto", - SystemPrompt = "a" + SystemPrompt = "a", } ); promptActions.AddAction( @@ -589,7 +589,7 @@ public void RefreshPromptActionOptions_ExcludesManualOnlyActions() Id = "manual", Name = "Manual", SystemPrompt = "m", - IsManualOnly = true + IsManualOnly = true, } ); @@ -660,7 +660,7 @@ private static Profile CreateEditableProfile(string? hotkeyData = null) { Id = "profile", Name = "Profile", - HotkeyData = hotkeyData + HotkeyData = hotkeyData, }; } diff --git a/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs b/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs index 1857c20a2..02d8f6dcc 100644 --- a/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PromptProcessingServiceTests.cs @@ -53,7 +53,7 @@ public async Task ProcessAsync_UsesDefaultProvider_WhenNoOverrideIsSet() { Id = "prompt", Name = "Rewrite", - SystemPrompt = "Rewrite this" + SystemPrompt = "Rewrite this", }, "hello", ct: CancellationToken.None @@ -82,7 +82,7 @@ public async Task ProcessAsync_UsesPromptOverride_WhenProvided() [defaultProvider, overrideProvider], [ CreateLoadedPlugin(defaultProvider.PluginId, defaultProvider), - CreateLoadedPlugin(overrideProvider.PluginId, overrideProvider) + CreateLoadedPlugin(overrideProvider.PluginId, overrideProvider), ] ); var settings = CreateSettings( @@ -101,7 +101,7 @@ public async Task ProcessAsync_UsesPromptOverride_WhenProvided() Id = "prompt", Name = "Rewrite", SystemPrompt = "Rewrite this", - ProviderOverride = "plugin:com.test.override:model-b" + ProviderOverride = "plugin:com.test.override:model-b", }, "hello", ct: CancellationToken.None @@ -134,7 +134,7 @@ public async Task ProcessAsync_FallsBackToFirstAvailableProvider_WhenNoDefaultIs { Id = "prompt", Name = "Rewrite", - SystemPrompt = "Rewrite this" + SystemPrompt = "Rewrite this", }, "hello", ct: CancellationToken.None @@ -462,7 +462,7 @@ private LoadedPlugin CreateLoadedPlugin( Version = plugin.PluginVersion, AssemblyName = "fake.dll", PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name, - IsLocal = isLocal + IsLocal = isLocal, }, plugin, new PluginAssemblyLoadContext(pluginDir), diff --git a/tests/TypeWhisper.Linux.Tests/PromptsSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/PromptsSectionViewModelTests.cs index 56fb3b2c8..961cff343 100644 --- a/tests/TypeWhisper.Linux.Tests/PromptsSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PromptsSectionViewModelTests.cs @@ -93,7 +93,7 @@ public void SaveAction_PersistsHotkeyAndManualOnlyForExistingAction() { Id = "existing", Name = "Existing", - SystemPrompt = "x" + SystemPrompt = "x", } ); using var pluginManager = TestPluginManagerFactory.Create(); @@ -121,7 +121,7 @@ public void OnSelectedActionChanged_PopulatesHotkeyAndManualOnlyFromAction() Name = "Existing", SystemPrompt = "x", HotkeyKey = "Ctrl+Alt+R", - IsManualOnly = true + IsManualOnly = true, } ); using var pluginManager = TestPluginManagerFactory.Create(); @@ -189,7 +189,7 @@ public void SaveAction_MalformedExistingDraftDoesNotUpdate() Id = "existing", Name = "Existing", SystemPrompt = "x", - HotkeyKey = "Alt+F8" + HotkeyKey = "Alt+F8", }; var prompts = new Mock(); prompts.SetupGet(service => service.Actions).Returns([existing]); @@ -221,7 +221,7 @@ public void SaveAction_CrossDynamicPrefixCollisionDoesNotPersist() { Id = "profile", Name = "Profile", - HotkeyData = "Right Ctrl" + HotkeyData = "Right Ctrl", } ); var prompts = new PromptActionService(Path.Join(_tempDir, "prompt-actions.json")); @@ -258,7 +258,7 @@ public void SelectedEditProvider_UpdatesProviderOverride() [provider], loadedPlugins: [ - TestPluginManagerFactory.CreateLoadedPlugin(_tempDir, provider.PluginId, provider) + TestPluginManagerFactory.CreateLoadedPlugin(_tempDir, provider.PluginId, provider), ] ); var settings = TestPluginManagerFactory.CreateSettings(new AppSettings()); @@ -288,7 +288,7 @@ public void SelectedSpokenCommandProvider_PersistsToSettings() [provider], loadedPlugins: [ - TestPluginManagerFactory.CreateLoadedPlugin(_tempDir, provider.PluginId, provider) + TestPluginManagerFactory.CreateLoadedPlugin(_tempDir, provider.PluginId, provider), ] ); var settings = TestPluginManagerFactory.CreateSettings(new AppSettings()); @@ -327,13 +327,13 @@ public void SelectedEditProvider_IgnoresTransientSelectionChangesDuringProviderR [provider], loadedPlugins: [ - TestPluginManagerFactory.CreateLoadedPlugin(_tempDir, provider.PluginId, provider) + TestPluginManagerFactory.CreateLoadedPlugin(_tempDir, provider.PluginId, provider), ] ); var settings = TestPluginManagerFactory.CreateSettings(new AppSettings()); var sut = new PromptsSectionViewModel(prompts, _profiles, _hotkeys, pluginManager, settings.Object) { - EditProviderOverride = "plugin:com.typewhisper.openai:gpt-4.1-mini" + EditProviderOverride = "plugin:com.typewhisper.openai:gpt-4.1-mini", }; // Simulate the guard flag that the view-model sets while it rebuilds // the provider list — a null selection during that window must not @@ -385,7 +385,7 @@ public void CommandModeEnabled_TogglePersistsToSettings() var sut = new PromptsSectionViewModel(prompts, _profiles, _hotkeys, pluginManager, settings.Object) { - CommandModeEnabled = true + CommandModeEnabled = true, }; Assert.True(sut.CommandModeEnabled); @@ -405,7 +405,7 @@ public void CommandKeyphrase_TrimmedValuePersistsNormalizedOnce() var sut = new PromptsSectionViewModel(prompts, _profiles, _hotkeys, pluginManager, settings.Object) { - CommandKeyphrase = " Jarvis " + CommandKeyphrase = " Jarvis ", }; // The re-entrant normalization must land the trimmed value and persist it exactly once. @@ -428,7 +428,7 @@ public void CommandKeyphrase_BlankValueFallsBackToDefaultAndPersists() var sut = new PromptsSectionViewModel(prompts, _profiles, _hotkeys, pluginManager, settings.Object) { - CommandKeyphrase = " " + CommandKeyphrase = " ", }; Assert.Equal(AppSettings.DefaultCommandKeyphrase, sut.CommandKeyphrase); @@ -447,7 +447,7 @@ public void CommandKeyphrase_UnchangedNormalizedValueHitsNoOpGuard() var sut = new PromptsSectionViewModel(prompts, _profiles, _hotkeys, pluginManager, settings.Object) { // Whitespace that normalizes back to the already-saved value: no persist. - CommandKeyphrase = " Jarvis " + CommandKeyphrase = " Jarvis ", }; Assert.Equal("Jarvis", sut.CommandKeyphrase); diff --git a/tests/TypeWhisper.Linux.Tests/RecentTranscriptionStoreTests.cs b/tests/TypeWhisper.Linux.Tests/RecentTranscriptionStoreTests.cs index d7ea25440..9adf51305 100644 --- a/tests/TypeWhisper.Linux.Tests/RecentTranscriptionStoreTests.cs +++ b/tests/TypeWhisper.Linux.Tests/RecentTranscriptionStoreTests.cs @@ -20,8 +20,8 @@ public void MergedEntries_prefers_session_entry_over_history_duplicate() Id = "same", Timestamp = timestamp.AddSeconds(-1), RawText = "raw", - FinalText = "history text" - } + FinalText = "history text", + }, }; var entries = store.MergedEntries(history, 10); @@ -72,7 +72,7 @@ public void PaletteViewModel_filters_text_and_subtitle() "Browser", "firefox", RecentTranscriptionSource.Session - ) + ), }; var sut = new RecentTranscriptionsPaletteViewModel(entries, _ => { }) { SearchQuery = "firefox" }; diff --git a/tests/TypeWhisper.Linux.Tests/RecorderSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/RecorderSectionViewModelTests.cs index 2c8946727..f47aad177 100644 --- a/tests/TypeWhisper.Linux.Tests/RecorderSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/RecorderSectionViewModelTests.cs @@ -237,6 +237,9 @@ AudioRecordingService audio private sealed class FakeSettingsService(AppSettings current) : ISettingsService { + // ISettingsService.Update must read and persist under the same gate as Save. + private readonly Lock _gate = new(); + public AppSettings Current { get; private set; } = current; public AppSettings Load() @@ -246,15 +249,21 @@ public AppSettings Load() public void Save(AppSettings settings) { - Current = settings; - SettingsChanged?.Invoke(settings); + lock (_gate) + { + Current = settings; + SettingsChanged?.Invoke(settings); + } } public AppSettings Update(Func mutate) { - var updated = mutate(Current); - Save(updated); - return updated; + lock (_gate) + { + var updated = mutate(Current); + Save(updated); + return updated; + } } public event Action? SettingsChanged; diff --git a/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs b/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs index abb6a9515..7cff7b27e 100644 --- a/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/RecordingNotificationServiceTests.cs @@ -24,7 +24,7 @@ public async Task Recording_processing_and_success_replace_one_notification_in_p AppSettings.Default with { Mode = RecordingMode.PushToTalk, - PreviewBubbleAutoHideMilliseconds = terminalExpiry + PreviewBubbleAutoHideMilliseconds = terminalExpiry, } ); service.Initialize(); @@ -34,7 +34,7 @@ AppSettings.Default with { IsOverlayVisible = true, IsRecording = true, - StatusText = Loc.Instance["Dictation.StatusRecording"] + StatusText = Loc.Instance["Dictation.StatusRecording"], } ); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -44,7 +44,7 @@ AppSettings.Default with new DictationOverlayState { IsOverlayVisible = true, - StatusText = processing + StatusText = processing, } ); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -54,7 +54,7 @@ AppSettings.Default with new DictationOverlayState { ShowFeedback = true, - FeedbackText = success + FeedbackText = success, } ); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -90,7 +90,7 @@ bool isError var settings = AppSettings.Default with { PreviewBubbleAutoHideMilliseconds = - AppSettings.MaxPreviewBubbleAutoHideMilliseconds + 500 + AppSettings.MaxPreviewBubbleAutoHideMilliseconds + 500, }; var (source, runner, service) = CreateSut(settings); service.Initialize(); @@ -104,7 +104,7 @@ bool isError ShowFeedback = true, FeedbackIsError = isError, FeedbackText = feedbackText, - IsRecording = false + IsRecording = false, } ); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -130,7 +130,7 @@ public async Task Non_presentation_changes_are_deduplicated_while_recording_and_ IsRecording = true, PartialText = "one", ActiveProfileName = "Profile A", - ActiveAppName = "Editor" + ActiveAppName = "Editor", }; source.Raise(recording); @@ -141,7 +141,7 @@ recording with PartialText = "one two", ActiveProfileName = "Profile B", ActiveAppName = "Terminal", - SessionStartedAtUtc = DateTime.UtcNow + SessionStartedAtUtc = DateTime.UtcNow, } ); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -149,7 +149,7 @@ recording with var processing = new DictationOverlayState { IsOverlayVisible = true, - StatusText = Loc.Instance["Overlay.Processing"] + StatusText = Loc.Instance["Overlay.Processing"], }; source.Raise(processing); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -158,7 +158,7 @@ processing with { PartialText = "ignored preview", ActiveProfileName = "Profile C", - ActiveAppName = "Browser" + ActiveAppName = "Browser", } ); await service.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -185,7 +185,7 @@ public async Task Hidden_and_zero_duration_terminal_feedback_close_the_owned_not var zeroSettings = AppSettings.Default with { - PreviewBubbleAutoHideMilliseconds = -100 + PreviewBubbleAutoHideMilliseconds = -100, }; var (zeroSource, zeroRunner, zeroService) = CreateSut(zeroSettings); zeroService.Initialize(); @@ -196,7 +196,7 @@ public async Task Hidden_and_zero_duration_terminal_feedback_close_the_owned_not new DictationOverlayState { ShowFeedback = true, - FeedbackText = "Finished" + FeedbackText = "Finished", } ); await zeroService.WaitForIdleAsync().WaitAsync(s_testGuard); @@ -224,7 +224,7 @@ public async Task Slow_initial_notify_coalesces_pending_states_to_latest_termina new DictationOverlayState { IsOverlayVisible = true, - StatusText = Loc.Instance["Overlay.Processing"] + StatusText = Loc.Instance["Overlay.Processing"], } ); const string terminal = "Dictation inserted"; @@ -232,7 +232,7 @@ public async Task Slow_initial_notify_coalesces_pending_states_to_latest_termina new DictationOverlayState { ShowFeedback = true, - FeedbackText = terminal + FeedbackText = terminal, } ); Assert.Single(runner.Invocations); @@ -284,7 +284,7 @@ public async Task Enabled_dispose_closes_owned_notification_and_ignores_later_st new DictationOverlayState { IsOverlayVisible = true, - StatusText = Loc.Instance["Overlay.Processing"] + StatusText = Loc.Instance["Overlay.Processing"], } ); diff --git a/tests/TypeWhisper.Linux.Tests/SentinelBlockTests.cs b/tests/TypeWhisper.Linux.Tests/SentinelBlockTests.cs index 8265a53bd..f6d2102dd 100644 --- a/tests/TypeWhisper.Linux.Tests/SentinelBlockTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SentinelBlockTests.cs @@ -187,7 +187,7 @@ public void ExtractBlockLines_WellFormedBlock_ReturnsInnerLines() var managed = new[] { "bind = CTRL SHIFT, SPACE, exec, typewhisper record start", - "bindr = CTRL SHIFT, SPACE, exec, typewhisper record stop" + "bindr = CTRL SHIFT, SPACE, exec, typewhisper record stop", }; var input = "bind = SUPER, q, killactive\n" diff --git a/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs index 4775eff69..6fb9022e3 100644 --- a/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SettingsBackupServiceTests.cs @@ -849,7 +849,7 @@ private static Profile CreateProfile(string id, string name) Id = id, Name = name, CreatedAt = DateTime.UnixEpoch, - UpdatedAt = DateTime.UnixEpoch + UpdatedAt = DateTime.UnixEpoch, }; } diff --git a/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs b/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs index 0eefa7a2b..80a6351dd 100644 --- a/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ShortcutDispatcherTests.cs @@ -310,7 +310,7 @@ public void TransformSelection_WaitsForTriggerAndCtrlShiftAltMetaReleased() Set(RecordingMode.Toggle) with { TransformSelectionKey = KeyCode.VcT, - TransformSelectionModifiers = allShortcutModifiers + TransformSelectionModifiers = allShortcutModifiers, } ); var transform = 0; @@ -346,7 +346,7 @@ public void ResetState_DropsPendingTransformSelection() Set(RecordingMode.Toggle) with { TransformSelectionKey = KeyCode.VcT, - TransformSelectionModifiers = ModifierMask.LeftAlt + TransformSelectionModifiers = ModifierMask.LeftAlt, } ); var transform = 0; diff --git a/tests/TypeWhisper.Linux.Tests/ShortcutMatcherTests.cs b/tests/TypeWhisper.Linux.Tests/ShortcutMatcherTests.cs index f08470b68..6a09ed899 100644 --- a/tests/TypeWhisper.Linux.Tests/ShortcutMatcherTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ShortcutMatcherTests.cs @@ -61,7 +61,7 @@ public void Match_PromptActionTakesPriorityOverDictation() "alpha", KeyCode.VcSpace, ModifierMask.LeftCtrl | ModifierMask.LeftShift - ) + ), ], [] ); diff --git a/tests/TypeWhisper.Linux.Tests/ShortcutsSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/ShortcutsSectionViewModelTests.cs index fc88eb033..a7412ea28 100644 --- a/tests/TypeWhisper.Linux.Tests/ShortcutsSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ShortcutsSectionViewModelTests.cs @@ -84,7 +84,7 @@ public void ApplyTransformSelectionHotkey_BlankInputClearsBinding() var sut = new ShortcutsSectionViewModel(hotkey, settings) { - TransformSelectionHotkeyText = "" + TransformSelectionHotkeyText = "", }; sut.ApplyTransformSelectionHotkeyCommand.Execute(null); @@ -104,7 +104,7 @@ public void ApplyTransformSelectionHotkey_RejectsCollisionWithPromptPalette() var sut = new ShortcutsSectionViewModel(hotkey, settings) { - TransformSelectionHotkeyText = "Ctrl+Shift+P" + TransformSelectionHotkeyText = "Ctrl+Shift+P", }; sut.ApplyTransformSelectionHotkeyCommand.Execute(null); @@ -160,7 +160,7 @@ public async Task RefreshSectionState_AfterStartupMismatchShowsStaleBannerWithou using var hotkey = TestShortcutBackend.CreateHotkeyService(); var writer = new FakeDeShortcutWriter { - InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space") + InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space"), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); hotkey.SetNativeDictationBindingActive(true); @@ -298,7 +298,7 @@ public async Task ImmediateRefreshFromRestartStaleStateReestablishesSuppressionA Assert.True(hotkey.TrySetPromptPaletteHotkeyFromString("Ctrl+Alt+P")); var writer = new FakeDeShortcutWriter { - InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space") + InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space"), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); await sut.RefreshDesktopIntegrationStateAsync(CancellationToken.None); @@ -343,7 +343,7 @@ bool warning "Shortcut refreshed.", [], warning ? "Live apply failed." : null - ) + ), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); await sut.RefreshDesktopIntegrationStateAsync(CancellationToken.None); @@ -369,7 +369,7 @@ public async Task RefreshFailurePreservesPriorSuppressionAndStaleBanner(bool thr { InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space"), WriteResult = new DeShortcutWriteResult(false, "Write failed.", []), - WriteException = throws ? new InvalidOperationException("boom") : null + WriteException = throws ? new InvalidOperationException("boom") : null, }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); await sut.RefreshDesktopIntegrationStateAsync(CancellationToken.None); @@ -395,7 +395,7 @@ public async Task LateOldHotkeyProbeCannotOverwriteNewerStaleResult() IsInstalledHandler = (spec, _) => spec.Trigger == "Ctrl+Shift+Space" ? oldProbeGate.Task - : Task.FromResult(false) + : Task.FromResult(false), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); var oldProbe = sut.RefreshDesktopIntegrationStateAsync(CancellationToken.None); @@ -425,7 +425,7 @@ public async Task LatePreRefreshProbeCannotRestoreStaleAfterSuccessfulRefresh() InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space"), // ReSharper disable once AccessToModifiedClosure -- the test deliberately flips blockProbe after setup so the next probe blocks on probeGate. IsInstalledHandler = (_, _) => - blockProbe ? probeGate.Task : Task.FromResult(false) + blockProbe ? probeGate.Task : Task.FromResult(false), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); await sut.RefreshDesktopIntegrationStateAsync(CancellationToken.None); @@ -454,7 +454,7 @@ public async Task LatePreRemovalProbeCannotRestoreCurrentAfterSuccessfulRemoval( var writer = new FakeDeShortcutWriter { InstalledSpec = CreateToggleSpec("Ctrl+Shift+Space"), - IsInstalledHandler = (_, _) => probeGate.Task + IsInstalledHandler = (_, _) => probeGate.Task, }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); var oldProbe = sut.RefreshDesktopIntegrationStateAsync(CancellationToken.None); @@ -558,7 +558,7 @@ bool hasWarning "Shortcut installed.", [], hasWarning ? "Live apply failed." : null - ) + ), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); @@ -584,7 +584,7 @@ public async Task SetupAutomatically_FailureOrExceptionKeepsPreexistingSuppressi var writer = new FakeDeShortcutWriter { WriteResult = new DeShortcutWriteResult(false, "Write failed.", []), - WriteException = throws ? new InvalidOperationException("boom") : null + WriteException = throws ? new InvalidOperationException("boom") : null, }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); @@ -640,7 +640,7 @@ bool hasWarning "Shortcut removed.", [], hasWarning ? "Reload required." : null - ) + ), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); @@ -666,7 +666,7 @@ public async Task RemoveIntegration_FailureOrExceptionKeepsPreexistingSuppressio var writer = new FakeDeShortcutWriter { RemoveResult = new DeShortcutWriteResult(false, "Remove failed.", []), - RemoveException = throws ? new InvalidOperationException("boom") : null + RemoveException = throws ? new InvalidOperationException("boom") : null, }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); @@ -746,7 +746,7 @@ public async Task RefreshNativeDictationBindingState_ProbeErrorFailsOpen() hotkey.SetNativeDictationBindingActive(true); var writer = new FakeDeShortcutWriter { - IsInstalledException = new InvalidOperationException("boom") + IsInstalledException = new InvalidOperationException("boom"), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); @@ -764,7 +764,7 @@ public async Task RefreshNativeDictationBindingState_CancellationFailsOpenAndPro hotkey.SetNativeDictationBindingActive(true); var writer = new FakeDeShortcutWriter { - IsInstalledException = new OperationCanceledException() + IsInstalledException = new OperationCanceledException(), }; var sut = new ShortcutsSectionViewModel(hotkey, settings, [writer]); @@ -802,7 +802,7 @@ settings.Current with { Mode = RecordingMode.Toggle, ToggleHotkey = toggleHotkey, - WaylandEvdevHotkeysEnabled = true + WaylandEvdevHotkeysEnabled = true, } ); return settings; diff --git a/tests/TypeWhisper.Linux.Tests/SnippetsSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/SnippetsSectionViewModelTests.cs index 1f7571fe6..cfb6a12b2 100644 --- a/tests/TypeWhisper.Linux.Tests/SnippetsSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SnippetsSectionViewModelTests.cs @@ -62,7 +62,7 @@ public void SaveSnippet_WhenEditing_PreservesUsageMetadata() TriggerMode = SnippetTriggerMode.Anywhere, UsageCount = 4, LastUsedAt = lastUsedAt, - CreatedAt = DateTime.UtcNow.AddDays(-10) + CreatedAt = DateTime.UtcNow.AddDays(-10), }; service.AddSnippet(existing); var sut = CreateViewModel(service); @@ -105,7 +105,7 @@ public void ConflictWarning_ShowsDictionaryTermMatch() { Id = "term-1", EntryType = DictionaryEntryType.Term, - Original = "Kubernetes" + Original = "Kubernetes", } ); var sut = CreateViewModel(CreateSnippetService(), dictionary); @@ -129,7 +129,7 @@ public void ConflictWarning_ShowsDictionaryCorrectionMatch() Id = "correction-1", EntryType = DictionaryEntryType.Correction, Original = "wispr", - Replacement = "Wispr" + Replacement = "Wispr", } ); var sut = CreateViewModel(CreateSnippetService(), dictionary); diff --git a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs index 623e979b7..54a7e9f3e 100644 --- a/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SoundFeedbackServiceTests.cs @@ -118,7 +118,7 @@ RunnerOutcome outcome RunnerOutcome.Exception => ControlledProcessRunner.WithException( new InvalidOperationException("fake runner failure") ), - _ => throw new ArgumentOutOfRangeException(nameof(outcome), outcome, null) + _ => throw new ArgumentOutOfRangeException(nameof(outcome), outcome, null), }; var sut = new SoundFeedbackService(runner, "fake-player", sounds.Path); @@ -138,7 +138,7 @@ public void Source_has_no_direct_process_path_and_observes_every_fire_and_forget @"\bProcess\s*\.\s*Start\b", @"\bProcessStartInfo\b", @"\bWaitForExit(?:Async)?\b", - @"\bnew\s+Process\s*\(" + @"\bnew\s+Process\s*\(", ]; foreach (var pattern in directProcessPatterns) @@ -206,7 +206,7 @@ public enum RunnerOutcome { NotStarted, TimedOut, - Exception + Exception, } private sealed class ControlledProcessRunner : IProcessRunner diff --git a/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs b/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs index b384b9941..0344877f0 100644 --- a/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SpeechFeedbackServiceTests.cs @@ -66,7 +66,7 @@ public async Task SpeakAutomaticTranscription_substitutes_configured_language_wh { Language = "de", SpokenFeedbackEnabled = true, - SpokenFeedbackProviderId = "cloud" + SpokenFeedbackProviderId = "cloud", } ); var plugin = new FakeTtsProvider("cloud", "Cloud Voice", true); @@ -94,7 +94,7 @@ public async Task SpeakAutomaticTranscription_keeps_explicit_request_language() { Language = "de", SpokenFeedbackEnabled = true, - SpokenFeedbackProviderId = "cloud" + SpokenFeedbackProviderId = "cloud", } ); var plugin = new FakeTtsProvider("cloud", "Cloud Voice", true); @@ -123,7 +123,7 @@ public async Task SpeakAutomaticTranscription_skips_configured_language_fallback { Language = "de", SpokenFeedbackEnabled = true, - SpokenFeedbackProviderId = "cloud" + SpokenFeedbackProviderId = "cloud", } ); var plugin = new FakeTtsProvider("cloud", "Cloud Voice", true); diff --git a/tests/TypeWhisper.Linux.Tests/SpokenCommandActionMatcherTests.cs b/tests/TypeWhisper.Linux.Tests/SpokenCommandActionMatcherTests.cs index 8973adafb..97aeccd0f 100644 --- a/tests/TypeWhisper.Linux.Tests/SpokenCommandActionMatcherTests.cs +++ b/tests/TypeWhisper.Linux.Tests/SpokenCommandActionMatcherTests.cs @@ -10,7 +10,7 @@ public sealed class SpokenCommandActionMatcherTests [ new() { Id = "clean", Name = "Clean up email", SystemPrompt = "..." }, new() { Id = "auto", Name = "Auto Clean Up Text", SystemPrompt = "..." }, - new() { Id = "formal", Name = "Make Formal", SystemPrompt = "..." } + new() { Id = "formal", Name = "Make Formal", SystemPrompt = "..." }, ]; [Theory] @@ -59,7 +59,7 @@ public void Match_DoesNotMatchSingleWordNameMerelyMentioned() // A create command that only mentions the word must not hijack a single-word "Email" action. var actions = new PromptAction[] { - new() { Id = "email", Name = "Email", SystemPrompt = "..." } + new() { Id = "email", Name = "Email", SystemPrompt = "..." }, }; Assert.Null(SpokenCommandActionMatcher.Match("draft an email to Bob", actions)); @@ -70,7 +70,7 @@ public void Match_MatchesSingleWordNameWhenItLeadsTheCommand() { var actions = new PromptAction[] { - new() { Id = "email", Name = "Email", SystemPrompt = "..." } + new() { Id = "email", Name = "Email", SystemPrompt = "..." }, }; var matched = SpokenCommandActionMatcher.Match("email this to the team", actions); @@ -84,7 +84,7 @@ public void Match_MatchesSingleWordNameAfterLeadingFiller() // A leading politeness filler ("please") must not hide an explicit single-word invocation. var actions = new PromptAction[] { - new() { Id = "email", Name = "Email", SystemPrompt = "..." } + new() { Id = "email", Name = "Email", SystemPrompt = "..." }, }; var matched = SpokenCommandActionMatcher.Match("please email this to the team", actions); diff --git a/tests/TypeWhisper.Linux.Tests/StreamingTranscriptionCoordinatorTests.cs b/tests/TypeWhisper.Linux.Tests/StreamingTranscriptionCoordinatorTests.cs index e5bb9a935..99dafdcf2 100644 --- a/tests/TypeWhisper.Linux.Tests/StreamingTranscriptionCoordinatorTests.cs +++ b/tests/TypeWhisper.Linux.Tests/StreamingTranscriptionCoordinatorTests.cs @@ -17,7 +17,7 @@ public async Task AcceptAudioFrame_BeforeStartAsync_QueuesInPendingBuffer() TaskCreationOptions.RunContinuationsAsynchronously); var plugin = new FakePlugin { - OnStartStreaming = _ => connectTcs.Task + OnStartStreaming = _ => connectTcs.Task, }; await using var coord = new StreamingTranscriptionCoordinator( @@ -261,7 +261,7 @@ public async Task Fault_OnConnectException_PropagatesViaOnFault() var plugin = new FakePlugin { OnStartStreaming = _ => Task.FromException( - new HttpRequestException("auth failed (simulated)")) + new HttpRequestException("auth failed (simulated)")), }; await using var coord = new StreamingTranscriptionCoordinator( @@ -795,7 +795,7 @@ public async Task Dispose_AfterFault_DoesNotThrow() var plugin = new FakePlugin { OnStartStreaming = _ => Task.FromException( - new HttpRequestException("simulated")) + new HttpRequestException("simulated")), }; var coord = new StreamingTranscriptionCoordinator( plugin, null, 1, (_, _) => { }, _ => { }); @@ -857,7 +857,7 @@ public async Task Dispose_WhileConnectPending_PluginHonorsCancellation_DoesNotFa { await Task.Delay(Timeout.Infinite, ct); throw new InvalidOperationException("unreachable"); - }, ct) + }, ct), }; var coord = new StreamingTranscriptionCoordinator( @@ -890,7 +890,7 @@ public async Task Dispose_WhileConnectPending_DisposesLateArrivingSession() { // Deliberately ignore cancellation — simulate a misbehaving plugin // or a native WebSocket that resolves just before honoring cancel. - OnStartStreaming = _ => connectTcs.Task + OnStartStreaming = _ => connectTcs.Task, }; var coord = new StreamingTranscriptionCoordinator( diff --git a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs index c9f863cb6..f806a0d0b 100644 --- a/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TargetAppCorrectionLearningServiceTests.cs @@ -1580,6 +1580,9 @@ public void Dispose() private sealed class FakeSettingsService(AppSettings current) : ISettingsService { + // ISettingsService.Update must read and persist under the same gate as Save. + private readonly Lock _gate = new(); + public AppSettings Current { get; private set; } = current; public AppSettings Load() @@ -1589,15 +1592,21 @@ public AppSettings Load() public void Save(AppSettings settings) { - Current = settings; - SettingsChanged?.Invoke(settings); + lock (_gate) + { + Current = settings; + SettingsChanged?.Invoke(settings); + } } public AppSettings Update(Func mutate) { - var updated = mutate(Current); - Save(updated); - return updated; + lock (_gate) + { + var updated = mutate(Current); + Save(updated); + return updated; + } } public event Action? SettingsChanged; diff --git a/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs b/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs index ae0be20e9..4b752bdf7 100644 --- a/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs +++ b/tests/TypeWhisper.Linux.Tests/TestPluginManagerFactory.cs @@ -80,7 +80,7 @@ ITypeWhisperPlugin plugin Name = plugin.PluginName, Version = plugin.PluginVersion, AssemblyName = "fake.dll", - PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name + PluginClass = plugin.GetType().FullName ?? plugin.GetType().Name, }, plugin, new PluginAssemblyLoadContext(pluginDir), diff --git a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs index 78c10bbdb..f3eb8819b 100644 --- a/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/TextInsertionServiceTests.cs @@ -15,7 +15,7 @@ public async Task InsertTextAsync_successful_auto_paste_restores_previous_clipbo var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService(platform); @@ -33,7 +33,7 @@ public async Task InsertTextAsync_retries_failed_paste_before_fallback() var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = false + PasteSucceeds = false, }; var confirmation = new FakePasteConfirmationSource { Result = true }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -56,7 +56,7 @@ public async Task InsertTextAsync_successful_retry_restores_previous_clipboard() var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteResults = new Queue([false, false, true]) + PasteResults = new Queue([false, false, true]), }; var sut = new TextInsertionService(platform); @@ -81,9 +81,9 @@ public async Task InsertTextAsync_verifies_clipboard_serves_before_paste_and_ret [ "previous", // snapshot of the user's clipboard "previous", // verify attempt 1 — wl-copy not serving yet - "new text" // verify attempt 2 — serving + "new text", // verify attempt 2 — serving ] - ) + ), }; var sut = new TextInsertionService(platform); @@ -111,9 +111,9 @@ public async Task InsertTextAsync_verify_failure_resets_clipboard_once_then_proc [ "previous", // snapshot "previous", "previous", "previous", "previous", // verify pass 1 — all stale - "new text" // verify pass 2 after the re-set — serving + "new text", // verify pass 2 after the re-set — serving ] - ) + ), }; var sut = new TextInsertionService(platform); @@ -140,9 +140,9 @@ public async Task InsertTextAsync_verify_never_serves_skips_paste_and_falls_back [ "previous", // snapshot "previous", "previous", "previous", "previous", // verify pass 1 - "previous", "previous", "previous", "previous" // verify pass 2 after re-set + "previous", "previous", "previous", "previous", // verify pass 2 after re-set ] - ) + ), }; var sut = new TextInsertionService(platform); @@ -160,7 +160,7 @@ public async Task InsertTextAsync_confirmed_paste_restores_immediately_without_f var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var confirmation = new FakePasteConfirmationSource { Result = true }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -188,12 +188,12 @@ public async Task InsertTextAsync_arms_paste_watch_before_sending_ctrl_v() { Clipboard = "previous", PasteSucceeds = true, - OnPasteSent = () => order.Add("ctrl-v") + OnPasteSent = () => order.Add("ctrl-v"), }; var confirmation = new FakePasteConfirmationSource { Result = true, - OnBeginWatch = () => order.Add("begin-watch") + OnBeginWatch = () => order.Add("begin-watch"), }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -215,13 +215,13 @@ public async Task InsertTextAsync_text_changed_during_paste_is_latched_and_confi var client = new FakeAtSpiEventClient { CurrentFocusedElement = targetElement, - TextByElement = { [targetElement] = "Prefix new text suffix" } + TextByElement = { [targetElement] = "Prefix new text suffix" }, }; var platform = new FakeTextInsertionPlatform { Clipboard = "previous", PasteSucceeds = true, - OnPasteSent = () => client.RaiseTextChanged(targetElement) + OnPasteSent = () => client.RaiseTextChanged(targetElement), }; var sut = new TextInsertionService( platform, @@ -255,13 +255,13 @@ public async Task InsertTextAsync_unrelated_same_bus_text_change_does_not_confir var client = new FakeAtSpiEventClient { CurrentFocusedElement = focusedElement, - TextByElement = { [unrelatedElement] = "Background log count: 17" } + TextByElement = { [unrelatedElement] = "Background log count: 17" }, }; var platform = new FakeTextInsertionPlatform { Clipboard = "previous", PasteSucceeds = true, - OnPasteSent = () => client.RaiseTextChanged(unrelatedElement) + OnPasteSent = () => client.RaiseTextChanged(unrelatedElement), }; var sut = new TextInsertionService( platform, @@ -289,13 +289,13 @@ public async Task InsertTextAsync_unknown_bus_unverified_text_change_does_not_co var client = new FakeAtSpiEventClient { CurrentFocusedElement = null, - TextByElement = { [changedElement] = "Background log count: 17" } + TextByElement = { [changedElement] = "Background log count: 17" }, }; var platform = new FakeTextInsertionPlatform { Clipboard = "previous", PasteSucceeds = true, - OnPasteSent = () => client.RaiseTextChanged(changedElement) + OnPasteSent = () => client.RaiseTextChanged(changedElement), }; var sut = new TextInsertionService( platform, @@ -319,7 +319,7 @@ public async Task InsertTextAsync_unreadable_same_bus_text_change_remains_indete { Clipboard = "previous", PasteSucceeds = true, - OnPasteSent = () => client.RaiseTextChanged(targetElement) + OnPasteSent = () => client.RaiseTextChanged(targetElement), }; var sut = new TextInsertionService( platform, @@ -345,13 +345,13 @@ public async Task InsertTextAsync_password_element_text_change_is_never_read() { CurrentFocusedElement = targetElement, TextByElement = { [targetElement] = "Prefix new text suffix" }, - PasswordRoleByElement = { [targetElement] = true } + PasswordRoleByElement = { [targetElement] = true }, }; var platform = new FakeTextInsertionPlatform { Clipboard = "previous", PasteSucceeds = true, - OnPasteSent = () => client.RaiseTextChanged(targetElement) + OnPasteSent = () => client.RaiseTextChanged(targetElement), }; var sut = new TextInsertionService( platform, @@ -380,8 +380,8 @@ public async Task InsertTextAsync_watch_keeps_listening_after_unverified_text_ch TextByElement = { [unrelatedElement] = "Background log count: 17", - [targetElement] = "Prefix new text suffix" - } + [targetElement] = "Prefix new text suffix", + }, }; var platform = new FakeTextInsertionPlatform { @@ -391,7 +391,7 @@ public async Task InsertTextAsync_watch_keeps_listening_after_unverified_text_ch { client.RaiseTextChanged(unrelatedElement); client.RaiseTextChanged(targetElement); - } + }, }; var sut = new TextInsertionService( platform, @@ -420,7 +420,7 @@ public async Task InsertTextAsync_auto_enter_waits_for_confirmed_paste_without_f Clipboard = "previous", PasteSucceeds = true, OnPasteSent = () => order.Add("paste"), - OnEnterSent = () => order.Add("enter") + OnEnterSent = () => order.Add("enter"), }; var confirmation = new FakePasteConfirmationSource { @@ -429,7 +429,7 @@ public async Task InsertTextAsync_auto_enter_waits_for_confirmed_paste_without_f { order.Add("gate"); Assert.False(platform.EnterSent); - } + }, }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -460,7 +460,7 @@ public async Task InsertTextAsync_auto_enter_indeterminate_gate_delays_once_befo order.Add("floor"); } }, - OnEnterSent = () => order.Add("enter") + OnEnterSent = () => order.Add("enter"), }; var confirmation = new FakePasteConfirmationSource { Result = null }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -499,7 +499,7 @@ public async Task InsertTextAsync_auto_enter_without_confirmer_delays_once_even_ order.Add("floor"); } }, - OnEnterSent = () => order.Add("enter") + OnEnterSent = () => order.Add("enter"), }; var sut = new TextInsertionService(platform); @@ -525,7 +525,7 @@ public async Task InsertTextAsync_paste_watch_acquires_and_releases_text_changed var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService( platform, @@ -545,7 +545,7 @@ public async Task InsertTextAsync_without_confirmer_uses_floor_delay_then_restor var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService(platform); @@ -564,7 +564,7 @@ public async Task InsertTextAsync_indeterminate_confirmation_uses_floor_delay_th var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var confirmation = new FakePasteConfirmationSource { SourceNotRunning = true }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -586,7 +586,7 @@ public async Task InsertTextAsync_watch_timeout_uses_floor_delay_then_restores() var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var confirmation = new FakePasteConfirmationSource { Result = null }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -615,9 +615,9 @@ public async Task InsertTextAsync_skips_restore_when_clipboard_no_longer_holds_o [ "previous", // snapshot "new text", // verify — serving - "user copied meanwhile" // ownership check before restore + "user copied meanwhile", // ownership check before restore ] - ) + ), }; var confirmation = new FakePasteConfirmationSource { Result = true }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -643,9 +643,9 @@ public async Task InsertTextAsync_skips_restore_when_ownership_read_cannot_prove [ "previous", // snapshot "new text", // verify — serving - null // ownership check — clipboard no longer reads back as text + null, // ownership check — clipboard no longer reads back as text ] - ) + ), }; var confirmation = new FakePasteConfirmationSource { Result = true }; var sut = new TextInsertionService(platform, pasteConfirmation: confirmation); @@ -667,7 +667,7 @@ public async Task InsertTextAsync_null_previous_clipboard_skips_wait_and_restore { Clipboard = null, ClipboardHasNonTextFormats = false, - PasteSucceeds = true + PasteSucceeds = true, }; var confirmation = new FakePasteConfirmationSource { Result = true }; var errorLog = new RecordingErrorLogService(); @@ -691,7 +691,7 @@ public async Task InsertTextAsync_nontext_previous_clipboard_logs_unrestorable_d { Clipboard = null, ClipboardHasNonTextFormats = true, - PasteSucceeds = true + PasteSucceeds = true, }; var errorLog = new RecordingErrorLogService(); var sut = new TextInsertionService(platform, errorLog); @@ -712,7 +712,7 @@ public async Task InsertTextAsync_richer_previous_clipboard_skips_lossy_restore_ { Clipboard = "previous", ClipboardHasNonTextFormats = true, - PasteSucceeds = true + PasteSucceeds = true, }; var confirmation = new FakePasteConfirmationSource { Result = true }; var errorLog = new RecordingErrorLogService(); @@ -748,7 +748,7 @@ public async Task InsertTextAsync_focus_failure_falls_back_to_clipboard() { Clipboard = "previous", ActiveWindowId = "other", - ActivateSucceeds = false + ActivateSucceeds = false, }; var sut = new TextInsertionService(platform); @@ -775,7 +775,7 @@ public async Task InsertTextAsync_partial_typing_failure_does_not_retry_via_clip Clipboard = "previous", TypeSucceeds = false, TypeFailureReason = InsertionFailureReason.PartialTypingFailure, - LastTypingDeliveredPartialText = true + LastTypingDeliveredPartialText = true, }; var sut = new TextInsertionService(platform); @@ -801,7 +801,7 @@ public async Task InsertTextAsync_partial_delivery_with_structural_reason_still_ Clipboard = "previous", TypeSucceeds = false, TypeFailureReason = InsertionFailureReason.YdotoolSocketUnreachable, - LastTypingDeliveredPartialText = true + LastTypingDeliveredPartialText = true, }; var sut = new TextInsertionService(platform); @@ -827,7 +827,7 @@ public async Task InsertTextAsync_direct_typing_failure_reason_survives_paste_fa TypeSucceeds = false, TypeFailureReason = InsertionFailureReason.YdotoolSocketUnreachable, PasteSucceeds = false, - PasteFailureReason = InsertionFailureReason.NoWaylandTypingTool + PasteFailureReason = InsertionFailureReason.NoWaylandTypingTool, }; var sut = new TextInsertionService(platform); @@ -846,7 +846,7 @@ public async Task InsertTextAsync_terminal_multiline_focus_failure_fails_closed_ { Clipboard = "previous", ActiveWindowId = "other", - ActivateSucceeds = false + ActivateSucceeds = false, }; var sut = new TextInsertionService(platform); @@ -876,9 +876,9 @@ public async Task InsertTextAsync_terminal_multiline_verify_failure_fails_closed [ "previous", // snapshot "previous", "previous", "previous", "previous", // verify pass 1 - "previous", "previous", "previous", "previous" // verify pass 2 after re-set + "previous", "previous", "previous", "previous", // verify pass 2 after re-set ] - ) + ), }; var sut = new TextInsertionService(platform); @@ -903,7 +903,7 @@ public async Task InsertTextAsync_terminal_multiline_fail_closed_keeps_staged_te { Clipboard = null, ActiveWindowId = "other", - ActivateSucceeds = false + ActivateSucceeds = false, }; var sut = new TextInsertionService(platform); @@ -924,7 +924,7 @@ public async Task InsertTextAsync_terminal_multiline_nontext_clipboard_keeps_sta Clipboard = null, ClipboardHasNonTextFormats = true, ActiveWindowId = "other", - ActivateSucceeds = false + ActivateSucceeds = false, }; var errorLog = new RecordingErrorLogService(); var sut = new TextInsertionService(platform, errorLog); @@ -956,9 +956,9 @@ public async Task InsertTextAsync_terminal_multiline_fail_closed_keeps_newer_cli ClipboardReadResults = new Queue( [ "previous", // snapshot before staging - "user-copied-this-later" // ownership check during fail-closed restore + "user-copied-this-later", // ownership check during fail-closed restore ] - ) + ), }; var sut = new TextInsertionService(platform); @@ -977,7 +977,7 @@ public async Task InsertTextAsync_missing_clipboard_tool_returns_specific_result var platform = new FakeTextInsertionPlatform { ClipboardSetAvailable = false, - PasteAvailable = true + PasteAvailable = true, }; var sut = new TextInsertionService(platform); @@ -993,7 +993,7 @@ public async Task InsertTextAsync_missing_paste_tool_returns_specific_result_whe var platform = new FakeTextInsertionPlatform { ClipboardSetAvailable = true, - PasteAvailable = false + PasteAvailable = false, }; var sut = new TextInsertionService(platform); @@ -1010,7 +1010,7 @@ public async Task InsertTextAsync_missing_paste_tool_allows_copy_only() { Clipboard = "previous", ClipboardSetAvailable = true, - PasteAvailable = false + PasteAvailable = false, }; var sut = new TextInsertionService(platform); @@ -1027,7 +1027,7 @@ public async Task InsertTextAsync_codex_window_uses_direct_typing() var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService(platform); @@ -1113,7 +1113,7 @@ string text var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService(platform); @@ -1154,7 +1154,7 @@ public async Task InsertTextAsync_terminal_multiline_without_clipboard_tool_fail var platform = new FakeTextInsertionPlatform { ClipboardSetAvailable = false, - PasteAvailable = true + PasteAvailable = true, }; var sut = new TextInsertionService(platform); @@ -1175,7 +1175,7 @@ public async Task InsertTextAsync_terminal_multiline_when_paste_chord_fails_fail var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = false + PasteSucceeds = false, }; var sut = new TextInsertionService(platform); @@ -1237,7 +1237,7 @@ string windowTitle var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService(platform); @@ -1260,7 +1260,7 @@ public async Task InsertTextAsync_clipboard_paste_strategy_overrides_terminal_di var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteSucceeds = true + PasteSucceeds = true, }; var sut = new TextInsertionService(platform); @@ -1307,7 +1307,7 @@ public async Task InsertTextAsync_unknown_target_with_ascii_text_types_directly( { Clipboard = "previous", PasteSucceeds = true, - PrefersDirectTypingForUnknownTarget = true + PrefersDirectTypingForUnknownTarget = true, }; var sut = new TextInsertionService(platform); @@ -1340,7 +1340,7 @@ string text { Clipboard = "previous", PasteSucceeds = true, - PrefersDirectTypingForUnknownTarget = true + PrefersDirectTypingForUnknownTarget = true, }; var sut = new TextInsertionService(platform); @@ -1365,7 +1365,7 @@ public async Task InsertTextAsync_unknown_target_ascii_safe_check_allows_tab_and var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PrefersDirectTypingForUnknownTarget = true + PrefersDirectTypingForUnknownTarget = true, }; var sut = new TextInsertionService(platform); @@ -1424,7 +1424,7 @@ public async Task InsertTextAsync_empty_text_with_auto_enter_requires_paste_tool var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - PasteAvailable = false + PasteAvailable = false, }; var sut = new TextInsertionService(platform); @@ -1442,7 +1442,7 @@ public async Task CaptureSelectedTextAsync_returns_selection_and_restores_clipbo var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - SelectionText = "the selected text" + SelectionText = "the selected text", }; var sut = new TextInsertionService(platform); @@ -1461,7 +1461,7 @@ public async Task CaptureSelectedTextAsync_uses_terminal_copy_shortcut_for_termi var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - SelectionText = "the selected text" + SelectionText = "the selected text", }; var sut = new TextInsertionService(platform); @@ -1498,7 +1498,7 @@ public async Task CaptureSelectedTextAsync_returns_empty_when_copy_leaves_clipbo var platform = new FakeTextInsertionPlatform { Clipboard = "stale clipboard content", - SelectionText = null + SelectionText = null, }; var sut = new TextInsertionService(platform); @@ -1517,7 +1517,7 @@ public async Task CaptureSelectedTextAsync_retries_copy_until_selection_lands() { Clipboard = "previous", SelectionText = "the selected text", - CopyLandsOnAttempt = 3 + CopyLandsOnAttempt = 3, }; var sut = new TextInsertionService(platform); @@ -1537,7 +1537,7 @@ public async Task CaptureSelectedTextAsync_returns_empty_when_copy_never_lands() var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - SelectionText = null + SelectionText = null, }; var sut = new TextInsertionService(platform); @@ -1553,7 +1553,7 @@ public async Task CaptureSelectedTextAsync_returns_empty_when_copy_fails() var platform = new FakeTextInsertionPlatform { Clipboard = "previous", - CopySucceeds = false + CopySucceeds = false, }; var sut = new TextInsertionService(platform); @@ -1572,7 +1572,7 @@ public async Task CaptureSelectedTextAsync_preserves_nontext_clipboard_when_no_s { Clipboard = null, ClipboardHasNonTextFormats = true, - SelectionText = null + SelectionText = null, }; var errorLog = new RecordingErrorLogService(); var sut = new TextInsertionService(platform, errorLog); @@ -1593,7 +1593,7 @@ public async Task CaptureSelectedTextAsync_richer_clipboard_restores_plain_text_ { Clipboard = "previous", ClipboardHasNonTextFormats = true, - SelectionText = "the selected text" + SelectionText = "the selected text", }; var errorLog = new RecordingErrorLogService(); var sut = new TextInsertionService(platform, errorLog); @@ -1855,7 +1855,7 @@ public async Task LinuxTextInsertionPlatform_Xdotool_TerminalPasteUsesCtrlShiftV ["keydown", "--clearmodifiers", "Shift_L"], ["key", "v"], ["keyup", "Shift_L"], - ["keyup", "Control_L"] + ["keyup", "Control_L"], ], runner.Calls.Select(call => call.Arguments).ToArray() ); @@ -2361,7 +2361,7 @@ public async Task LinuxTextInsertionPlatform_Wtype_TypesNewlineAsShiftEnter() [ ["--", "line one"], ["-M", "shift", "-k", "Return", "-m", "shift"], - ["--", "line two"] + ["--", "line two"], ], runner.Calls.Select(c => c.Arguments).ToArray() ); @@ -2490,7 +2490,7 @@ public async Task LinuxTextInsertionPlatform_Ydotool_TypesNewlineAsShiftEnter() // LEFTSHIFT(42)+ENTER(28) press/release pairs, with an inter-event delay so the // Shift modifier reliably registers before Enter. ["key", "--key-delay", "25", "42:1", "28:1", "28:0", "42:0"], - ["type", "--key-delay", "2", "--key-hold", "2", "--", "line two"] + ["type", "--key-delay", "2", "--key-hold", "2", "--", "line two"], ], runner.Calls.Select(c => c.Arguments).ToArray() ); @@ -2513,7 +2513,7 @@ public async Task LinuxTextInsertionPlatform_Xdotool_TypesNewlineAsShiftEnter() [ ["type", "--clearmodifiers", "--delay", "8", "--", "line one"], ["key", "--clearmodifiers", "shift+Return"], - ["type", "--clearmodifiers", "--delay", "8", "--", "line two"] + ["type", "--clearmodifiers", "--delay", "8", "--", "line two"], ], runner.Calls.Select(c => c.Arguments).ToArray() ); @@ -2538,7 +2538,7 @@ public async Task LinuxTextInsertionPlatform_ParagraphBreak_EmitsTwoShiftEntersA ["--", "first"], ["-M", "shift", "-k", "Return", "-m", "shift"], ["-M", "shift", "-k", "Return", "-m", "shift"], - ["--", "second"] + ["--", "second"], ], runner.Calls.Select(c => c.Arguments).ToArray() ); @@ -2560,7 +2560,7 @@ public async Task LinuxTextInsertionPlatform_CrlfNewline_NormalizedToSingleShift [ ["--", "a"], ["-M", "shift", "-k", "Return", "-m", "shift"], - ["--", "b"] + ["--", "b"], ], runner.Calls.Select(c => c.Arguments).ToArray() ); @@ -2645,7 +2645,7 @@ private static FakeProcessRunner CreateTimedOutProcessRunner() -1, string.Empty, string.Empty - ) + ), }; } diff --git a/tests/TypeWhisper.Linux.Tests/WatchFolderExportBuilderTests.cs b/tests/TypeWhisper.Linux.Tests/WatchFolderExportBuilderTests.cs index b19757e63..8c27b572d 100644 --- a/tests/TypeWhisper.Linux.Tests/WatchFolderExportBuilderTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WatchFolderExportBuilderTests.cs @@ -43,7 +43,7 @@ public void Build_creates_subtitle_exports() { var result = Result("caption") with { - Segments = [new TranscriptionSegment("caption", 1.2, 3.4)] + Segments = [new TranscriptionSegment("caption", 1.2, 3.4)], }; var srt = WatchFolderExportBuilder.Build( diff --git a/tests/TypeWhisper.PluginSystem.Tests/HistoryRetentionCoordinatorTests.cs b/tests/TypeWhisper.PluginSystem.Tests/HistoryRetentionCoordinatorTests.cs index 227308a8f..8943172ea 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/HistoryRetentionCoordinatorTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/HistoryRetentionCoordinatorTests.cs @@ -171,6 +171,9 @@ AppSettings.Default with private sealed class FakeSettingsService(AppSettings initialSettings) : ISettingsService { + // ISettingsService.Update must read and persist under the same gate as Save. + private readonly Lock _gate = new(); + public AppSettings Current { get; private set; } = initialSettings; public event Action? SettingsChanged; @@ -181,15 +184,21 @@ public AppSettings Load() public void Save(AppSettings settings) { - Current = settings; - SettingsChanged?.Invoke(settings); + lock (_gate) + { + Current = settings; + SettingsChanged?.Invoke(settings); + } } public AppSettings Update(Func mutate) { - var updated = mutate(Current); - Save(updated); - return updated; + lock (_gate) + { + var updated = mutate(Current); + Save(updated); + return updated; + } } } From 6e3e392556b91d8a0ad0e84b346982a3aa6ebda6 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 3 Aug 2026 13:32:12 -0400 Subject: [PATCH 209/226] Discover per-user Flatpak browser launchers via XDG_DATA_DIRS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FindSystemLauncher only scanned /usr/share/applications and the system-wide Flatpak export dir, so a browser installed with `flatpak install --user` — what GNOME Software defaults to — was never found. Setup then patched nothing, and because HasInstalledLauncher also missed it, IsFullyConfigured succeeded vacuously: the UI reported success while AT-SPI URL detection kept failing. Zen on this box was exactly that case. Discovery now walks XDG_DATA_DIRS in its declared order, led by the per-user Flatpak export dir, since that copy is the one the application menu launches. Spec defaults apply only when the variable is unset; a root the session omitted is one whose launchers the desktop does not read, so it is not reintroduced. Flatpak's own export roots are the exception — they are placed in the variable by hooks that miss some session types. Also resolve XDG_DATA_HOME for the shadow-launcher and backup dirs: a shadow written under a data home the session ignores never reaches the menu. A relative value is treated as unset in both places, including the sibling KdeShortcutWriter this resolution was modelled on. The Chromium name list gained the Flatpak app IDs, mirroring the Firefox list, since a Flatpak-only install ships no native launcher. --- .../BrowserAccessibilitySetupHelper.cs | 79 +++++++- .../Hotkey/DeSetup/KdeShortcutWriter.cs | 6 +- ...wserAccessibilityLauncherDiscoveryTests.cs | 190 ++++++++++++++++++ 3 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/BrowserAccessibilityLauncherDiscoveryTests.cs diff --git a/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs b/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs index 9aaf96693..daafef935 100644 --- a/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs +++ b/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs @@ -41,15 +41,23 @@ public sealed partial class BrowserAccessibilitySetupHelper private const string UserJsOwnedSeparatorSuffix = "; separator newline owned"; + // Native package names and Flatpak app IDs both: a Flatpak-only install ships no + // native launcher, so omitting an app ID makes that browser invisible entirely. private static readonly string[] s_chromiumLauncherNames = [ "google-chrome.desktop", + "com.google.Chrome.desktop", "chromium.desktop", "chromium-browser.desktop", + "org.chromium.Chromium.desktop", "microsoft-edge.desktop", + "com.microsoft.Edge.desktop", "brave-browser.desktop", + "com.brave.Browser.desktop", "vivaldi-stable.desktop", + "com.vivaldi.Vivaldi.desktop", "opera.desktop", + "com.opera.Opera.desktop", ]; private static readonly string[] s_firefoxLauncherNames = @@ -64,10 +72,13 @@ public sealed partial class BrowserAccessibilitySetupHelper "io.github.zen_browser.zen.desktop", ]; - private static readonly string[] s_systemLauncherDirectories = + // Appended even when XDG_DATA_DIRS omits them: Flatpak's profile.d snippet and + // systemd generator do not reach every session type, so an absent export root means + // a propagation gap. System roots get no such treatment — one the session left out + // is one whose launchers the desktop does not read at all. + private static readonly string[] s_flatpakExportRoots = [ - "/usr/share/applications", - "/var/lib/flatpak/exports/share/applications", + "/var/lib/flatpak/exports/share", ]; /// @@ -895,9 +906,45 @@ private static int FindFieldCodeOrFlatpakEscape(string line, int searchStart) return -1; } - private static string? FindSystemLauncher(string name) + /// + /// Launcher source directories in XDG_DATA_DIRS precedence order; the spec + /// defaults apply only when that variable is unset. The per-user Flatpak export + /// dir leads because flatpak install --user writes there and that copy is + /// the one the application menu launches — sourcing a lower-precedence duplicate + /// would shadow the launcher with a different browser's Exec line. + /// + internal static IEnumerable LauncherSourceDirectories() + { + var dataDirs = Environment.GetEnvironmentVariable("XDG_DATA_DIRS"); + var roots = new List { Path.Join(DataHome(), "flatpak", "exports", "share") }; + roots.AddRange( + string.IsNullOrEmpty(dataDirs) + ? ["/usr/local/share", "/usr/share"] + : dataDirs.Split(':', StringSplitOptions.RemoveEmptyEntries) + ); + roots.AddRange(s_flatpakExportRoots); + + var seen = new HashSet(StringComparer.Ordinal); + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- only the guard is convertible; the body still mutates `seen` and yields. + foreach (var root in roots) + { + // The XDG spec says relative entries are invalid and must be ignored. + if (!Path.IsPathRooted(root)) + { + continue; + } + + var dir = Path.Join(root, "applications"); + if (seen.Add(dir.TrimEnd('/'))) + { + yield return dir; + } + } + } + + internal static string? FindSystemLauncher(string name) { - return s_systemLauncherDirectories + return LauncherSourceDirectories() .Select(dir => Path.Join(dir, name)) .FirstOrDefault(File.Exists); } @@ -1088,16 +1135,30 @@ private static string EnvFilePath() return Path.Join(home, ".config", "environment.d", EnvFileName); } - private static string UserApplicationsDir() + /// + /// The user's XDG data home, resolved as + /// does. A shadow launcher + /// written under a data home the session does not read never reaches the + /// application menu, so setup would report success while changing nothing. + /// + private static string DataHome() { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return Path.Join(home, ".local", "share", "applications"); + var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + // A relative value is invalid per the spec, and would resolve against the CWD. + return string.IsNullOrEmpty(xdg) || !Path.IsPathRooted(xdg) + ? Path.Join(home, ".local", "share") + : xdg; + } + + private static string UserApplicationsDir() + { + return Path.Join(DataHome(), "applications"); } private static string LauncherBackupDir() { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return Path.Join(home, ".local", "share", "typewhisper", "launcher-backups"); + return Path.Join(DataHome(), "typewhisper", "launcher-backups"); } public sealed record Status( diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/KdeShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/KdeShortcutWriter.cs index ed7aac4f4..2a15df737 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/KdeShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/KdeShortcutWriter.cs @@ -160,7 +160,11 @@ private static (string dir, string file) ResolveTargetPath(string shortcutId) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - var dataHome = string.IsNullOrEmpty(xdg) ? Path.Join(home, ".local", "share") : xdg; + // A relative value is invalid per the spec, and would write the shortcut under + // the CWD where KGlobalAccel never looks. + var dataHome = string.IsNullOrEmpty(xdg) || !Path.IsPathRooted(xdg) + ? Path.Join(home, ".local", "share") + : xdg; var dir = Path.Join(dataHome, "kglobalaccel"); return (dir, Path.Join(dir, FileName(shortcutId))); } diff --git a/tests/TypeWhisper.Linux.Tests/BrowserAccessibilityLauncherDiscoveryTests.cs b/tests/TypeWhisper.Linux.Tests/BrowserAccessibilityLauncherDiscoveryTests.cs new file mode 100644 index 000000000..06e160367 --- /dev/null +++ b/tests/TypeWhisper.Linux.Tests/BrowserAccessibilityLauncherDiscoveryTests.cs @@ -0,0 +1,190 @@ +using TypeWhisper.Linux.Services; +using Xunit; + +namespace TypeWhisper.Linux.Tests; + +/// +/// Launcher discovery tests. The failure they guard against is silent: an +/// undiscovered browser is patched by nothing, yet +/// reports +/// success vacuously, since "not installed" and "installed and patched" reach the +/// same verdict. Env vars are mutated in place, safe only because the assembly +/// disables test parallelization. +/// +public sealed class BrowserAccessibilityLauncherDiscoveryTests : IDisposable +{ + private readonly string _tempDir = Path.Join( + Path.GetTempPath(), + "tw-launcher-discovery-" + Guid.NewGuid().ToString("N") + ); + + private readonly string? _originalHome = Environment.GetEnvironmentVariable("HOME"); + private readonly string? _originalDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + private readonly string? _originalDataDirs = Environment.GetEnvironmentVariable("XDG_DATA_DIRS"); + + public BrowserAccessibilityLauncherDiscoveryTests() + { + Directory.CreateDirectory(_tempDir); + // An isolated HOME keeps the developer's real browsers out of the assertions. + Environment.SetEnvironmentVariable("HOME", _tempDir); + Environment.SetEnvironmentVariable("XDG_DATA_HOME", Path.Join(_tempDir, "data-home")); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable("HOME", _originalHome); + Environment.SetEnvironmentVariable("XDG_DATA_HOME", _originalDataHome); + Environment.SetEnvironmentVariable("XDG_DATA_DIRS", _originalDataDirs); + try + { + Directory.Delete(_tempDir, true); + } + catch + { + /* best effort */ + } + } + + [Fact] + public void PerUserFlatpakExportDirectory_LeadsPrecedence() + { + Environment.SetEnvironmentVariable("XDG_DATA_DIRS", "/usr/share"); + + var dirs = BrowserAccessibilitySetupHelper.LauncherSourceDirectories().ToList(); + + Assert.Equal( + Path.Join(_tempDir, "data-home", "flatpak", "exports", "share", "applications"), + dirs[0] + ); + } + + [Fact] + public void DataDirsAreHonouredInDeclaredOrder() + { + Environment.SetEnvironmentVariable("XDG_DATA_DIRS", "/opt/first/share:/opt/second/share"); + + var dirs = BrowserAccessibilitySetupHelper.LauncherSourceDirectories().ToList(); + + Assert.Equal("/opt/first/share/applications", dirs[1]); + Assert.Equal("/opt/second/share/applications", dirs[2]); + } + + [Fact] + public void FlatpakExportRootsSurviveAnIncompleteDataDirs() + { + Environment.SetEnvironmentVariable("XDG_DATA_DIRS", "/opt/only/share"); + + var dirs = BrowserAccessibilitySetupHelper.LauncherSourceDirectories().ToList(); + + Assert.Contains("/var/lib/flatpak/exports/share/applications", dirs); + } + + [Fact] + public void SystemRootsOmittedByTheSessionAreNotReintroduced() + { + // A root the session left out is one whose launchers the desktop never reads, + // so patching a copy from it would shadow the menu with an unreachable build. + Environment.SetEnvironmentVariable("XDG_DATA_DIRS", "/opt/only/share"); + + var dirs = BrowserAccessibilitySetupHelper.LauncherSourceDirectories().ToList(); + + Assert.DoesNotContain("/usr/share/applications", dirs); + Assert.DoesNotContain("/usr/local/share/applications", dirs); + } + + [Fact] + public void DuplicateAndRelativeEntriesAreDropped() + { + // A duplicate must never outrank a higher-precedence source. + Environment.SetEnvironmentVariable( + "XDG_DATA_DIRS", + "/usr/share/:/usr/share:relative/share:/usr/local/share" + ); + + var dirs = BrowserAccessibilitySetupHelper.LauncherSourceDirectories().ToList(); + + Assert.Single(dirs, d => d.TrimEnd('/') == "/usr/share/applications"); + Assert.DoesNotContain(dirs, d => d.Contains("relative", StringComparison.Ordinal)); + } + + [Fact] + public void UnsetDataDirsFallsBackToSpecDefaults() + { + Environment.SetEnvironmentVariable("XDG_DATA_DIRS", null); + + var dirs = BrowserAccessibilitySetupHelper.LauncherSourceDirectories().ToList(); + + Assert.Contains("/usr/local/share/applications", dirs); + Assert.Contains("/usr/share/applications", dirs); + } + + [Theory] + [InlineData("org.chromium.Chromium.desktop")] + [InlineData("com.google.Chrome.desktop")] + [InlineData("com.brave.Browser.desktop")] + [InlineData("com.microsoft.Edge.desktop")] + [InlineData("org.mozilla.firefox.desktop")] + [InlineData("app.zen_browser.zen.desktop")] + public void UserFlatpakExport_IsFoundAndOutranksSystemCopy(string launcherName) + { + // Asserted on the resolved path, not a status flag: a machine with the same + // launcher in /usr/share would satisfy the flag from the wrong copy. + var exportDir = WriteLauncher( + Path.Join(_tempDir, "data-home", "flatpak", "exports", "share", "applications"), + launcherName + ); + + var found = BrowserAccessibilitySetupHelper.FindSystemLauncher(launcherName); + + Assert.Equal(Path.Join(exportDir, launcherName), found); + } + + [Fact] + public void UserFlatpakExport_DrivesInstalledStatus() + { + // Brave ships no launcher in the system dirs here or on a stock CI image, so an + // installed verdict can only have come from the export directory. + WriteLauncher( + Path.Join(_tempDir, "data-home", "flatpak", "exports", "share", "applications"), + "com.brave.Browser.desktop" + ); + + var status = BrowserAccessibilitySetupHelper.IsCurrentlyConfigured(); + + Assert.True(status.ChromiumInstalled); + // Discovered but unpatched: the status must not claim the work is done. + Assert.False(status.ChromiumLauncherPresent); + Assert.False(status.IsFullyConfigured); + } + + [Fact] + public void RelativeDataHome_FallsBackToTheDefaultRoot() + { + Environment.SetEnvironmentVariable("XDG_DATA_HOME", "relative/data"); + + var dirs = BrowserAccessibilitySetupHelper.LauncherSourceDirectories().ToList(); + + Assert.Equal( + Path.Join(_tempDir, ".local", "share", "flatpak", "exports", "share", "applications"), + dirs[0] + ); + } + + [Fact] + public void UnknownLauncherName_IsNotFound() + { + Assert.Null( + BrowserAccessibilitySetupHelper.FindSystemLauncher("com.example.NotAThing.desktop") + ); + } + + private static string WriteLauncher(string dir, string name) + { + Directory.CreateDirectory(dir); + File.WriteAllText( + Path.Join(dir, name), + "[Desktop Entry]\nType=Application\nExec=/usr/bin/flatpak run org.example.App %U\n" + ); + return dir; + } +} From f713da91f9ad3d0e5a7ffa393f13e806f6d6bf8a Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 3 Aug 2026 13:58:40 -0400 Subject: [PATCH 210/226] Harden persistence, publication, and process cancellation (QA + review) Addresses the QA findings on this branch and the issues surfaced by the follow-up adversarial/standard review loop. Data safety: - LocalModelStorageService: never delete a migrated source based on resemblance. Track the targets this run actually wrote, stamped with size and mtime, and delete only against an unchanged stamp. Previously any pre-existing file at the target counted as "already migrated" and the source was removed. - ProfileService: refuse to save while profiles.json failed to load, and retry the load in EnsureCacheLoaded so a transient failure or a repaired file recovers without a restart. - PluginHostServices: do not cache an empty store after an unreadable file, and clear the load-failed flag on every recovery path. - FileMemoryPlugin: write memories owner-only on every write, including the first, and commit with a single atomic overwriting rename. - AtomicFileWrite: fsync the temp file before it becomes the destination, and close the check-then-move race on the replace path. SettingsService: - Load under the write lock; add Reload so restore-from-backup is atomic. - Publish SettingsChanged outside the lock but in commit order, via a single elected drainer, with per-subscriber exception isolation. ProcessRunner: - Caller cancellation now kills the process tree and propagates instead of being reported as NotStarted; disposal of a blocked stdin writer can no longer displace the cancellation or timeout result. UI and misc: - Surface save failures in the Snippets, Prompts, and Profiles sections (en/de/es/ru), and show watch-folder warnings on succeeded runs. - Marshal HttpApiService/settings notifications to the UI thread, guarded against persisting a stale snapshot. - Cache correction regexes per pattern with CultureInvariant matching. - Order-gate and orchestrator: wait for the insertion turn before the status/focus handoff, and never queue an unreserved session. Tests: 2131 passing. ReSharper: 0 issues at HINT severity. --- .../FileMemoryPlugin.cs | 29 ++- .../Interfaces/ISettingsService.cs | 17 ++ .../Services/AtomicFileWrite.cs | 35 ++- .../Services/DictionaryService.cs | 65 ++++-- .../Services/LocalModelStorageService.cs | 154 +++++++++--- .../Services/ProfileService.cs | 54 ++++- .../Services/SettingsService.cs | 113 ++++++++- .../Services/SnippetService.cs | 3 + src/TypeWhisper.Linux/App.axaml.cs | 17 +- .../Resources/Localization/de.json | 3 + .../Resources/Localization/en.json | 3 + .../Resources/Localization/es.json | 3 + .../Resources/Localization/ru.json | 3 + src/TypeWhisper.Linux/ServiceRegistrations.cs | 7 +- .../ActiveWindow/AtSpiUrlExtractor.cs | 54 +++-- .../Services/DictationInsertionOrderGate.cs | 5 +- .../Services/DictationOrchestrator.cs | 15 +- .../Services/Plugins/PluginHostServices.cs | 22 +- .../Services/ProcessRunner.cs | 178 +++++++++----- .../Services/TextInsertionService.cs | 10 +- .../Services/WatchFolderModels.cs | 12 + .../Sections/AboutSectionViewModel.cs | 2 +- .../Sections/GeneralSectionViewModel.cs | 50 +++- .../Sections/ProfilesSectionViewModel.cs | 68 +++++- .../Sections/PromptsSectionViewModel.cs | 18 +- .../Sections/SnippetsSectionViewModel.cs | 21 +- .../Sections/FileTranscriptionSection.axaml | 10 +- .../Views/Sections/ProfilesSection.axaml | 13 ++ .../Views/Sections/PromptsSection.axaml | 13 ++ .../Views/Sections/SnippetsSection.axaml | 13 ++ .../Services/LocalModelStorageServiceTests.cs | 68 ++++++ .../Services/ProfileServiceTests.cs | 44 ++++ .../Services/SettingsServiceTests.cs | 219 +++++++++++++++--- .../AtSpiUrlExtractorTests.cs | 130 +++++++++++ .../DictationInsertionOrderGateTests.cs | 17 +- ...OrchestratorPostProcessingLanguageTests.cs | 4 +- ...earnedCorrectionsFeedbackPresenterTests.cs | 16 +- ...rnedCorrectionsNotificationServiceTests.cs | 4 +- .../LlmCleanupServiceTests.cs | 12 + .../ProcessRunnerTests.cs | 57 ++++- .../RecorderFileNamerTests.cs | 9 +- .../StreamingTranscriptStateTests.cs | 13 +- .../TextInsertionServiceTests.cs | 16 +- .../WatchFolderServiceTests.cs | 2 +- .../ModelManagerServiceTests.cs | 3 +- 45 files changed, 1391 insertions(+), 233 deletions(-) create mode 100644 tests/TypeWhisper.Linux.Tests/AtSpiUrlExtractorTests.cs diff --git a/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs b/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs index b12b4d3b2..d008ce3c3 100644 --- a/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs +++ b/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs @@ -249,11 +249,32 @@ private async Task SaveEntriesAsync(List entries, CancellationToken Directory.CreateDirectory(dir); var json = JsonSerializer.Serialize(entries, JsonOptions); + + if (!OperatingSystem.IsWindows()) + { + // Owner-only on *every* write, including the first: neither the umask (0644 under + // a typical 022) nor an existing permissive mode may widen dictated memories. + // Set before the content is written so it is never briefly readable. + using ( + new FileStream( + tempPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 1 + ) + ) + { + File.SetUnixFileMode(tempPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + await File.WriteAllTextAsync(tempPath, json, ct); - if (File.Exists(_filePath)) - File.Replace(tempPath, _filePath, destinationBackupFileName: null); - else - File.Move(tempPath, _filePath); + + // One atomic rename for both the create and the replace case; sampling whether the + // destination existed first would only add races in each direction. Moves the temp + // file's inode and its 0600 into place, repairing a world-readable legacy file. + File.Move(tempPath, _filePath, overwrite: true); } catch (Exception ex) { diff --git a/src/TypeWhisper.Core/Interfaces/ISettingsService.cs b/src/TypeWhisper.Core/Interfaces/ISettingsService.cs index c000bb51a..c8b456354 100644 --- a/src/TypeWhisper.Core/Interfaces/ISettingsService.cs +++ b/src/TypeWhisper.Core/Interfaces/ISettingsService.cs @@ -16,6 +16,23 @@ public interface ISettingsService /// Persists , updates , and raises . void Save(AppSettings settings); + /// + /// Re-reads settings from disk and writes them straight back, so and + /// reflect files replaced underneath the app (e.g. a restored + /// backup). Implementations that add real locking to must perform the + /// read and the write under that same lock, so a concurrent writer cannot be clobbered by a + /// stale snapshot; the default body below does not synchronize and is only a fallback for + /// implementers without locking (test doubles). + /// + // ReSharper disable once UnusedMemberInSuper.Global -- default interface method is a fallback for other implementers; the sole in-tree implementer overrides it. + // ReSharper disable once UnusedMethodReturnValue.Global -- returns the reloaded settings for caller convenience; part of the public API contract. + AppSettings Reload() + { + var loaded = Load(); + Save(loaded); + return loaded; + } + /// /// Atomically applies to the latest and persists the /// result. Unlike Save(Current with { ... }), the read of the latest settings and the write happen diff --git a/src/TypeWhisper.Core/Services/AtomicFileWrite.cs b/src/TypeWhisper.Core/Services/AtomicFileWrite.cs index bf3aa83a4..8607786a2 100644 --- a/src/TypeWhisper.Core/Services/AtomicFileWrite.cs +++ b/src/TypeWhisper.Core/Services/AtomicFileWrite.cs @@ -74,16 +74,27 @@ Action writeTemporaryFile writeTemporaryFile(tempPath); } - if (replaceExisting && File.Exists(path)) + FlushToDisk(tempPath); + + if (replaceExisting) { - // File.Replace brings the temp file's inode (and mode) into the destination, so - // copy the destination's mode over first to preserve its permissions. - if (!OperatingSystem.IsWindows()) + if (File.Exists(path)) { - File.SetUnixFileMode(tempPath, File.GetUnixFileMode(path)); - } + // File.Replace brings the temp file's inode (and mode) into the destination, so + // copy the destination's mode over first to preserve its permissions. + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(tempPath, File.GetUnixFileMode(path)); + } - File.Replace(tempPath, path, null); + File.Replace(tempPath, path, null); + } + else + { + // Overwriting move: a destination created between the check above and the move + // must still be replaced rather than fail the write. + File.Move(tempPath, path, overwrite: true); + } } else { @@ -109,4 +120,14 @@ Action writeTemporaryFile throw; } } + + /// + /// Forces the finished temporary file out of the page cache before it becomes the + /// destination, so a crash cannot leave a renamed-but-empty file behind. + /// + private static void FlushToDisk(string tempPath) + { + using var handle = File.OpenHandle(tempPath, FileMode.Open, FileAccess.Write); + RandomAccess.FlushToDisk(handle); + } } diff --git a/src/TypeWhisper.Core/Services/DictionaryService.cs b/src/TypeWhisper.Core/Services/DictionaryService.cs index 1d088877d..3b8731e68 100644 --- a/src/TypeWhisper.Core/Services/DictionaryService.cs +++ b/src/TypeWhisper.Core/Services/DictionaryService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json; using System.Text.RegularExpressions; @@ -15,10 +16,19 @@ public sealed partial class DictionaryService : IDictionaryService { private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + private const int MaxCachedCorrectionPatterns = 512; + private readonly string _filePath; private readonly Lock _gate = new(); private List _cache = []; + // A correction's pattern is a pure function of its original text and case sensitivity, so + // this needs no invalidation when entries change — an edited original just maps to a new key. + // Reusing the instances keeps the dictation path off Regex's static cache, which holds only + // 15 patterns and thrashes once a user has more corrections than that. + private readonly ConcurrentDictionary<(string Original, bool CaseSensitive), Regex> + _correctionPatterns = new(); + private bool _cacheLoaded; // Set when the cache file exists but couldn't be read (IO / permission error). @@ -166,31 +176,17 @@ private string ApplyCorrectionsCore(string text, bool recordUsage) continue; } - var pattern = Regex.Escape(entry.Original); - var options = entry.CaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase; - // \b silently fails for originals like "C#" or ".NET" whose ends are non-word chars. - // Anchor each side based on what the original starts/ends with: \b on word-chars, - // lookaround on symbol-chars. - var prefix = char.IsLetterOrDigit(entry.Original[0]) || entry.Original[0] == '_' - ? @"\b" - : @"(?<=\W|^)"; - var lastChar = entry.Original[^1]; - var suffix = char.IsLetterOrDigit(lastChar) || lastChar == '_' - ? @"\b" - : @"(?=\W|$)"; // MatchEvaluator overload: prevents "$1"/"$&" in user replacements from being // interpreted as regex substitution tokens; also counts each match individually. var replacement = entry.Replacement!; var matchCount = 0; - var replaced = Regex.Replace( + var replaced = GetCorrectionRegex(entry.Original, entry.CaseSensitive).Replace( text, - prefix + pattern + suffix, _ => { matchCount++; return replacement; - }, - options + } ); if (matchCount == 0 || string.Equals(replaced, text, StringComparison.Ordinal)) { @@ -211,6 +207,43 @@ private string ApplyCorrectionsCore(string text, bool recordUsage) return text; } + private Regex GetCorrectionRegex(string original, bool caseSensitive) + { + // Bounded only against a pathological session that edits thousands of distinct originals; + // a clear costs nothing but a rebuild on next use. + if (_correctionPatterns.Count > MaxCachedCorrectionPatterns) + { + _correctionPatterns.Clear(); + } + + return _correctionPatterns.GetOrAdd( + (original, caseSensitive), + static key => + { + var (text, isCaseSensitive) = key; + // \b silently fails for originals like "C#" or ".NET" whose ends are non-word + // chars. Anchor each side based on what the original starts/ends with: \b on + // word-chars, lookaround on symbol-chars. + var prefix = char.IsLetterOrDigit(text[0]) || text[0] == '_' + ? @"\b" + : @"(?<=\W|^)"; + var lastChar = text[^1]; + var suffix = char.IsLetterOrDigit(lastChar) || lastChar == '_' + ? @"\b" + : @"(?=\W|$)"; + // CultureInvariant to match the culture-free OrdinalIgnoreCase pre-filter, and + // because a cached instance would otherwise pin the culture current when it was + // built (Turkish dotless-i being the classic divergence). + return new Regex( + prefix + Regex.Escape(text) + suffix, + isCaseSensitive + ? RegexOptions.None + : RegexOptions.IgnoreCase | RegexOptions.CultureInvariant + ); + } + ); + } + public string? GetTermsForPrompt() { EnsureCacheLoaded(); diff --git a/src/TypeWhisper.Core/Services/LocalModelStorageService.cs b/src/TypeWhisper.Core/Services/LocalModelStorageService.cs index d82aa0a9b..e95a893a0 100644 --- a/src/TypeWhisper.Core/Services/LocalModelStorageService.cs +++ b/src/TypeWhisper.Core/Services/LocalModelStorageService.cs @@ -94,6 +94,7 @@ public static string ResolveAvailablePluginAssetDirectory( public async Task MoveDownloadsAndUsePathAsync(string targetPath, CancellationToken ct = default) { var targetRoot = PrepareWritableTarget(targetPath); + var migrated = new MigratedTargets(); var sourceRoot = ResolvedModelStoragePath; var currentIsDefault = AppSettings.NormalizeLocalModelStoragePath(_settings.Current.LocalModelStoragePath) is null; @@ -112,7 +113,7 @@ public async Task MoveDownloadsAndUsePathAsync(string targetPath, CancellationTo await Task.Run(() => { ct.ThrowIfCancellationRequested(); - CopyPluginAssets(pluginAssetSourceRoot, targetRoot, ct); + CopyPluginAssets(pluginAssetSourceRoot, targetRoot, migrated, ct); }, ct); } @@ -123,7 +124,7 @@ await Task.Run(() => // Settings already point at targetRoot, so this cleanup is best-effort: a failure or // interruption wastes disk space, never data — hence CancellationToken.None after the commit. await Task.Run( - () => DeletePluginAssetSourceContents(pluginAssetSourceRoot, targetRoot), + () => DeletePluginAssetSourceContents(pluginAssetSourceRoot, targetRoot, migrated), CancellationToken.None); } @@ -167,8 +168,8 @@ await Task.Run( await Task.Run(() => { ct.ThrowIfCancellationRequested(); - CopyModelRootContents(sourceRoot, targetRoot, ct); - CopyPluginAssets(pluginAssetSourceRoot, targetRoot, ct); + CopyModelRootContents(sourceRoot, targetRoot, migrated, ct); + CopyPluginAssets(pluginAssetSourceRoot, targetRoot, migrated, ct); }, ct); _settings.Save(_settings.Current with { LocalModelStoragePath = targetRoot }); @@ -176,8 +177,8 @@ await Task.Run(() => // Best-effort cleanup after the commit above — see comment in the currentIsDefault branch. await Task.Run(() => { - DeleteModelRootSourceContents(sourceRoot, targetRoot); - DeletePluginAssetSourceContents(pluginAssetSourceRoot, targetRoot); + DeleteModelRootSourceContents(sourceRoot, targetRoot, migrated); + DeletePluginAssetSourceContents(pluginAssetSourceRoot, targetRoot, migrated); }, CancellationToken.None); } @@ -237,7 +238,11 @@ private static void EnsureWritable(string fullPath) } } - private static void CopyModelRootContents(string sourceRoot, string targetRoot, CancellationToken ct) + private static void CopyModelRootContents( + string sourceRoot, + string targetRoot, + MigratedTargets migrated, + CancellationToken ct) { if (!Directory.Exists(sourceRoot)) return; @@ -251,11 +256,14 @@ private static void CopyModelRootContents(string sourceRoot, string targetRoot, if (string.Equals(name, LocalModelStoragePaths.PluginDataFolderName, StringComparison.OrdinalIgnoreCase)) continue; - CopyEntry(entry, Path.Join(targetRoot, SafeLeafName(name, nameof(entry))), ct); + CopyEntry(entry, Path.Join(targetRoot, SafeLeafName(name, nameof(entry))), migrated, ct); } } - private static void DeleteModelRootSourceContents(string sourceRoot, string targetRoot) + private static void DeleteModelRootSourceContents( + string sourceRoot, + string targetRoot, + MigratedTargets migrated) { if (!Directory.Exists(sourceRoot)) return; @@ -266,11 +274,15 @@ private static void DeleteModelRootSourceContents(string sourceRoot, string targ if (string.Equals(name, LocalModelStoragePaths.PluginDataFolderName, StringComparison.OrdinalIgnoreCase)) continue; - DeleteMigratedEntry(entry, Path.Join(targetRoot, SafeLeafName(name, nameof(entry)))); + DeleteMigratedEntry(entry, Path.Join(targetRoot, SafeLeafName(name, nameof(entry))), migrated); } } - private static void CopyPluginAssets(string assetSourceRoot, string targetRoot, CancellationToken ct) + private static void CopyPluginAssets( + string assetSourceRoot, + string targetRoot, + MigratedTargets migrated, + CancellationToken ct) { var pluginDataFolderName = SafeRelativeName(LocalModelStoragePaths.PluginDataFolderName, nameof(LocalModelStoragePaths.PluginDataFolderName)); @@ -290,12 +302,16 @@ private static void CopyPluginAssets(string assetSourceRoot, string targetRoot, CopyEntry( Path.Join(sourcePluginDir, safeEntryName), Path.Join(targetPluginDir, safeEntryName), + migrated, ct); } } } - private static void DeletePluginAssetSourceContents(string assetSourceRoot, string targetRoot) + private static void DeletePluginAssetSourceContents( + string assetSourceRoot, + string targetRoot, + MigratedTargets migrated) { var pluginDataFolderName = SafeRelativeName(LocalModelStoragePaths.PluginDataFolderName, nameof(LocalModelStoragePaths.PluginDataFolderName)); @@ -312,15 +328,66 @@ private static void DeletePluginAssetSourceContents(string assetSourceRoot, stri var safeEntryName = SafeRelativeName(entryName, nameof(entryName)); DeleteMigratedEntry( Path.Join(sourcePluginDir, safeEntryName), - Path.Join(targetPluginDir, safeEntryName)); + Path.Join(targetPluginDir, safeEntryName), + migrated); + } + } + } + + /// + /// The targets this run copied itself, stamped with the size and modification time they had + /// immediately after the copy. Deletion is gated on this rather than on any inference from + /// the target's contents — equal size is not proof of equal bytes, and a wrong guess costs + /// the user the only remaining copy of a multi-gigabyte model. Hashing instead would roughly + /// double migration I/O and still be a time-of-check race; a replacement moves the timestamp. + /// + private sealed class MigratedTargets + { + private readonly Dictionary _written = + new(StringComparer.Ordinal); + + public void Record(string target) + { + try + { + var info = new FileInfo(target); + _written[target] = (info.Length, info.LastWriteTimeUtc); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Unstattable right after writing it: leave it unrecorded so cleanup keeps + // the source rather than deleting against an unverifiable copy. + } + } + + public bool IsUnchangedSinceThisRunWroteIt(string target) + { + if (!_written.TryGetValue(target, out var written)) + { + return false; + } + + try + { + var info = new FileInfo(target); + return info.Exists + && info.Length == written.Length + && info.LastWriteTimeUtc == written.LastWriteUtc; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; } } } // Files land at target only via an atomic same-directory rename, so a crash or I/O error - // mid-copy never leaves a partial file visible there — a later resume can trust - // File.Exists(target) to mean fully copied. - private static void CopyEntry(string source, string target, CancellationToken ct) + // mid-copy never leaves a partial file visible there. + private static void CopyEntry( + string source, + string target, + MigratedTargets migrated, + CancellationToken ct) { ct.ThrowIfCancellationRequested(); @@ -332,14 +399,33 @@ private static void CopyEntry(string source, string target, CancellationToken ct CopyEntry( child, Path.Join(target, SafeLeafName(Path.GetFileName(child), nameof(child))), + migrated, ct); } return; } - if (!File.Exists(source) || File.Exists(target)) + if (!File.Exists(source)) + return; + + if (File.Exists(target)) + { + // Something already occupies the name. A size mismatch is definitely not our copy, so + // fail rather than migrate onto an unrelated file. A size match may be a resumed + // migration's own output but is not proven to be, so the target is deliberately NOT + // recorded — costing disk rather than risking the source. + if (!FileLengthsMatch(source, target)) + { + throw new IOException(string.Format( + CultureInfo.InvariantCulture, + "Cannot migrate '{0}': a different file already exists at '{1}'.", + source, + target)); + } + return; + } var targetDir = Path.GetDirectoryName(target)!; Directory.CreateDirectory(targetDir); @@ -350,6 +436,7 @@ private static void CopyEntry(string source, string target, CancellationToken ct { File.Copy(source, stagingTarget); File.Move(stagingTarget, target); + migrated.Record(target); } catch { @@ -368,7 +455,7 @@ private static void CopyEntry(string source, string target, CancellationToken ct // Deletes source only once its copy is confirmed at target. Runs only after the settings // commit, so a failure wastes disk space but cannot make the active model root incomplete. - private static void DeleteMigratedEntry(string source, string target) + private static void DeleteMigratedEntry(string source, string target, MigratedTargets migrated) { if (Directory.Exists(source)) { @@ -376,33 +463,44 @@ private static void DeleteMigratedEntry(string source, string target) { DeleteMigratedEntry( child, - Path.Join(target, SafeLeafName(Path.GetFileName(child), nameof(child)))); + Path.Join(target, SafeLeafName(Path.GetFileName(child), nameof(child))), + migrated); } TryDeleteDirectoryIfEmpty(source); return; } - if (!File.Exists(source) || !File.Exists(target)) + // Provenance, not resemblance: only a target this run wrote is known to be the source's + // copy, and it must still carry the size and timestamp it had when written — a target + // swapped out between the copy and this cleanup pass spares the source. + if (!File.Exists(source) || !migrated.IsUnchangedSinceThisRunWroteIt(target)) + { return; + } TryDeleteFile(source); } - private static void TryDeleteFile(string path) + private static bool FileLengthsMatch(string source, string target) { try { - File.Delete(path); + return new FileInfo(source).Length == new FileInfo(target).Length; } - catch (IOException ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - System.Diagnostics.Trace.TraceWarning( - "Could not delete migrated source file '{0}': {1}", - path, - ex.Message); + return false; } - catch (UnauthorizedAccessException ex) + } + + private static void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { System.Diagnostics.Trace.TraceWarning( "Could not delete migrated source file '{0}': {1}", diff --git a/src/TypeWhisper.Core/Services/ProfileService.cs b/src/TypeWhisper.Core/Services/ProfileService.cs index fb6b8cc9e..62760ef9c 100644 --- a/src/TypeWhisper.Core/Services/ProfileService.cs +++ b/src/TypeWhisper.Core/Services/ProfileService.cs @@ -13,12 +13,16 @@ public sealed class ProfileService : IProfileService private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; private readonly string _filePath; + private readonly IErrorLogService? _errorLog; private List _cache = []; private bool _cacheLoaded; + private bool _loadFailed; + private bool _loadFailureReported; - public ProfileService(string filePath) + public ProfileService(string filePath, IErrorLogService? errorLog = null) { _filePath = filePath; + _errorLog = errorLog; } public IReadOnlyList Profiles @@ -250,22 +254,52 @@ private static void SortList(List profiles) private void EnsureCacheLoaded() { - if (_cacheLoaded) + // Retry while a previous load failed rather than staying poisoned for the process + // lifetime; the cause may be transient or since repaired. It has to retry *here*: callers + // build their next list from _cache, so recovering later would still write the stale set. + if (_cacheLoaded && !_loadFailed) { return; } try { + _cache = []; if (File.Exists(_filePath)) { var json = File.ReadAllText(_filePath); - _cache = JsonSerializer.Deserialize>(json) ?? []; + // A blank file is a benign "no profiles yet" state, not corruption — leave the + // cache empty so normal saves still happen. Only non-empty content that fails to + // parse is treated as a load failure below. + if (!string.IsNullOrWhiteSpace(json)) + { + _cache = JsonSerializer.Deserialize>(json) ?? []; + } } + + _loadFailed = false; } - catch + catch (Exception ex) { + // Report once per failure streak: this runs on the dictation path via MatchProfile, + // so logging every retry would flood the error log. + if (!_loadFailureReported) + { + _errorLog?.AddEntry( + $"Could not load saved profiles from {_filePath}: {ex.Message}" + ); + _loadFailureReported = true; + } + _cache = []; + // The file exists but couldn't be read or parsed. Treat the cache as untrustworthy so + // a later add/update doesn't overwrite the (possibly recoverable) file. + _loadFailed = true; + } + + if (!_loadFailed) + { + _loadFailureReported = false; } SortList(_cache); @@ -274,6 +308,18 @@ private void EnsureCacheLoaded() private void SaveToDisk(IReadOnlyList profiles) { + if (_loadFailed) + { + // The cache is a partial set (the file didn't load), so refuse until it loads cleanly + // rather than clobber the user's saved profiles. + const string reason = + "the existing file could not be loaded, so writing now would overwrite saved profiles"; + _errorLog?.AddEntry($"Not saving profiles at {_filePath}: {reason}."); + throw new InvalidOperationException( + $"Cannot save profiles at '{_filePath}': {reason}." + ); + } + var dir = Path.GetDirectoryName(_filePath); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) { diff --git a/src/TypeWhisper.Core/Services/SettingsService.cs b/src/TypeWhisper.Core/Services/SettingsService.cs index ed8d1f865..4afe62308 100644 --- a/src/TypeWhisper.Core/Services/SettingsService.cs +++ b/src/TypeWhisper.Core/Services/SettingsService.cs @@ -18,6 +18,12 @@ public sealed class SettingsService : ISettingsService }; private readonly Lock _gate = new(); + + // Publication happens outside _gate (handlers must not run under the write lock) but still in + // commit order, or a preempted publisher could deliver its older snapshot after a newer + // commit. Both fields are guarded by _gate; see PublishPendingChanges. + private readonly Queue _pendingNotifications = new(); + private bool _publishing; private readonly string _filePath; public SettingsService(string filePath) @@ -34,6 +40,28 @@ public SettingsService(string filePath) public event Action? SettingsChanged; public AppSettings Load() + { + // Under _gate for the whole read: Load both writes Current and (on the backup path) copies + // over the primary file, so an unsynchronized read could clobber a concurrent Save. + lock (_gate) + { + return LoadLocked(); + } + } + + public AppSettings Reload() + { + AppSettings committed; + lock (_gate) + { + committed = SaveLocked(LoadLocked()); + } + + PublishPendingChanges(); + return committed; + } + + private AppSettings LoadLocked() { var result = TryLoadFrom(_filePath); if (result is not null) @@ -66,20 +94,94 @@ public void Save(AppSettings settings) { SaveLocked(settings); } + + PublishPendingChanges(); } public AppSettings Update(Func mutate) { ArgumentNullException.ThrowIfNull(mutate); + AppSettings committed; + lock (_gate) + { + committed = SaveLocked(mutate(Current)); + } + + PublishPendingChanges(); + return committed; + } + + /// + /// Drains committed snapshots to in commit order. No lock is + /// held while a subscriber runs — a handler that saves, or waits on a thread that saves, + /// must not deadlock — so a single active drainer is elected instead. A writer that finds a + /// drain already running (another thread, or this thread re-entering from a handler) leaves + /// its snapshot queued and returns, keeping delivery ordered and non-recursive. + /// + private void PublishPendingChanges() + { lock (_gate) { - var updated = mutate(Current); - SaveLocked(updated); - return updated; + if (_publishing) + { + return; + } + + _publishing = true; + } + + try + { + while (true) + { + AppSettings next; + lock (_gate) + { + if (_pendingNotifications.Count == 0) + { + // Resign in the same acquisition that observes the empty queue. Clearing + // later leaves a window where a writer enqueues, sees _publishing still + // true, declines to drain, and strands its notification. + _publishing = false; + return; + } + + next = _pendingNotifications.Dequeue(); + } + + // Per subscriber, not per multicast invoke: one throwing handler would otherwise + // starve every handler after it. The drainer may also be carrying another writer's + // snapshot, so a failure must not escape and fail that already-succeeded Save. + foreach (var subscriber in + SettingsChanged?.GetInvocationList() ?? []) + { + try + { + ((Action)subscriber)(next); + } + catch (Exception ex) + { + LogWarning($"A SettingsChanged subscriber threw: {ex}"); + } + } + } + } + catch + { + // Backstop for an abnormal exit (the subscriber chain is already guarded above): + // never leave the flag set, or publication stops for the process lifetime. + lock (_gate) + { + _publishing = false; + } + + throw; } } - private void SaveLocked(AppSettings settings) + // Queues the committed snapshot instead of raising SettingsChanged here: handlers must never + // run under _gate. Callers publish via PublishPendingChanges once the lock is released. + private AppSettings SaveLocked(AppSettings settings) { var directory = Path.GetDirectoryName(_filePath); if (!string.IsNullOrEmpty(directory)) @@ -99,7 +201,8 @@ private void SaveLocked(AppSettings settings) // Advance in-memory state only after disk success so Current never leads what a reload sees. Current = settings; - SettingsChanged?.Invoke(settings); + _pendingNotifications.Enqueue(settings); + return settings; } private static AppSettings? TryLoadFrom(string path) diff --git a/src/TypeWhisper.Core/Services/SnippetService.cs b/src/TypeWhisper.Core/Services/SnippetService.cs index 69f067800..7a25c89ef 100644 --- a/src/TypeWhisper.Core/Services/SnippetService.cs +++ b/src/TypeWhisper.Core/Services/SnippetService.cs @@ -327,6 +327,9 @@ private void IncrementUsageCounts(Dictionary increments) return; } + // Deliberately the reverse of the mutating APIs, which persist before swapping the + // cache: usage counts are best-effort telemetry on the dictation path, so a failed + // write must not cost the in-memory increment too. _cache = next; try { diff --git a/src/TypeWhisper.Linux/App.axaml.cs b/src/TypeWhisper.Linux/App.axaml.cs index 25339683a..ee230144e 100644 --- a/src/TypeWhisper.Linux/App.axaml.cs +++ b/src/TypeWhisper.Linux/App.axaml.cs @@ -207,7 +207,9 @@ main.DataContext as MainWindowViewModel catch (Exception ex) { Trace.WriteLine($"[App] Failed to seed first-run prompt actions: {ex}"); - services.GetRequiredService().AddEntry( + // Optional resolution: a GetRequiredService throw here would escape the catch and + // abort startup over a failed best-effort seed. + services.GetService()?.AddEntry( $"Could not seed first-run prompt actions: {ex.Message}", ErrorCategory.Prompt ); @@ -221,7 +223,18 @@ main.DataContext as MainWindowViewModel HotkeyService.ParsePromptActionHotkeys(promptActions.Actions) ); var profileService = services.GetRequiredService(); - profileService.SeedFirstRunDefaultsIfMissing(); + try + { + profileService.SeedFirstRunDefaultsIfMissing(); + } + catch (Exception ex) + { + Trace.WriteLine($"[App] Failed to seed first-run profiles: {ex}"); + services.GetService()?.AddEntry( + $"Could not seed first-run profiles: {ex.Message}" + ); + } + hotkey.SetProfileHotkeys( HotkeyService.ParseProfileHotkeys(profileService.Profiles) ); diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index 1c928b085..3accff86b 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -543,6 +543,7 @@ "Profiles.RulesSummary": "{0} App-Regel(n), {1} URL-Regel(n)", "Profiles.SaveHint": "Einmal speichern, nachdem du Regeln, Overrides oder Aktivierung geändert hast.", "Profiles.SelectProfile": "Profil auswählen", + "Profiles.SaveFailed": "Profile konnten nicht gespeichert werden: {0}", "Profiles.SelectProfileHint": "Wähle ein Profil aus der Liste oder erstelle ein neues", "Profiles.StylePreset": "Stil-Vorlage", "Profiles.StylePresetCasualMessage": "Lockere Nachricht", @@ -599,6 +600,7 @@ "Prompts.NoProvider": "Kein LLM-Anbieter konfiguriert", "Prompts.Provider": "Anbieter", "Prompts.ProviderWarning": "OpenAI oder Groq in den Erweiterungen aktivieren.", + "Prompts.SaveFailed": "Prompt-Aktionen konnten nicht gespeichert werden: {0}", "Prompts.Summary": "{0} Prompts, {1} aktiv", "Prompts.SystemPrompt": "System-Prompt", "Prompts.Title": "Prompts", @@ -789,6 +791,7 @@ "Snippets.ProfileIdsPlaceholder": "Profil-IDs", "Snippets.ReplacementPlaceholder": "Ersetzungstext", "Snippets.SaveChanges": "Änderungen speichern", + "Snippets.SaveFailed": "Snippets konnten nicht gespeichert werden: {0}", "Snippets.SummaryText": "{0} Textbausteine, {1} aktiv", "Snippets.TagFilter": "Tag-Filter:", "Snippets.TagsPlaceholder": "Tags", diff --git a/src/TypeWhisper.Linux/Resources/Localization/en.json b/src/TypeWhisper.Linux/Resources/Localization/en.json index 092cfafba..eac064ccf 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -543,6 +543,7 @@ "Profiles.RulesSummary": "{0} app rule(s), {1} URL rule(s)", "Profiles.SaveHint": "Save once after changing rules, overrides, or activation.", "Profiles.SelectProfile": "Select Profile", + "Profiles.SaveFailed": "Could not save your profiles: {0}", "Profiles.SelectProfileHint": "Select a profile from the list or create a new one", "Profiles.StylePreset": "Style Preset", "Profiles.StylePresetCasualMessage": "Casual message", @@ -599,6 +600,7 @@ "Prompts.NoProvider": "No LLM provider configured", "Prompts.Provider": "Provider", "Prompts.ProviderWarning": "Enable OpenAI or Groq in Extensions.", + "Prompts.SaveFailed": "Could not save your prompt actions: {0}", "Prompts.Summary": "{0} prompts, {1} enabled", "Prompts.SystemPrompt": "System Prompt", "Prompts.Title": "Prompts", @@ -789,6 +791,7 @@ "Snippets.ProfileIdsPlaceholder": "Profile IDs", "Snippets.ReplacementPlaceholder": "Replacement text", "Snippets.SaveChanges": "Save changes", + "Snippets.SaveFailed": "Could not save your snippets: {0}", "Snippets.SummaryText": "{0} snippets, {1} enabled", "Snippets.TagFilter": "Tag filter:", "Snippets.TagsPlaceholder": "Tags", diff --git a/src/TypeWhisper.Linux/Resources/Localization/es.json b/src/TypeWhisper.Linux/Resources/Localization/es.json index ba90609f8..39f255d92 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -543,6 +543,7 @@ "Profiles.RulesSummary": "{0} regla(s) de app, {1} regla(s) de URL", "Profiles.SaveHint": "Guarda una vez después de cambiar reglas, anulaciones o activación.", "Profiles.SelectProfile": "Seleccionar perfil", + "Profiles.SaveFailed": "No se pudieron guardar tus perfiles: {0}", "Profiles.SelectProfileHint": "Selecciona un perfil de la lista o crea uno nuevo", "Profiles.StylePreset": "Preset de estilo", "Profiles.StylePresetCasualMessage": "Mensaje informal", @@ -599,6 +600,7 @@ "Prompts.NoProvider": "Ningún proveedor de LLM configurado", "Prompts.Provider": "Proveedor", "Prompts.ProviderWarning": "Activa OpenAI o Groq en Extensiones.", + "Prompts.SaveFailed": "No se pudieron guardar tus acciones de prompt: {0}", "Prompts.Summary": "{0} prompts, {1} activados", "Prompts.SystemPrompt": "Prompt del sistema", "Prompts.Title": "Prompts", @@ -789,6 +791,7 @@ "Snippets.ProfileIdsPlaceholder": "IDs de perfil", "Snippets.ReplacementPlaceholder": "Texto de reemplazo", "Snippets.SaveChanges": "Guardar cambios", + "Snippets.SaveFailed": "No se pudieron guardar tus fragmentos: {0}", "Snippets.SummaryText": "{0} fragmentos, {1} activados", "Snippets.TagFilter": "Filtro de etiquetas:", "Snippets.TagsPlaceholder": "Etiquetas", diff --git a/src/TypeWhisper.Linux/Resources/Localization/ru.json b/src/TypeWhisper.Linux/Resources/Localization/ru.json index cfa7a7397..b15dd23eb 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -543,6 +543,7 @@ "Profiles.RulesSummary": "Правил приложений: {0}, правил URL: {1}", "Profiles.SaveHint": "Сохраните один раз после изменения правил, переопределений или активации.", "Profiles.SelectProfile": "Выбрать профиль", + "Profiles.SaveFailed": "Не удалось сохранить профили: {0}", "Profiles.SelectProfileHint": "Выберите профиль из списка или создайте новый", "Profiles.StylePreset": "Стилевой пресет", "Profiles.StylePresetCasualMessage": "Неформальное сообщение", @@ -599,6 +600,7 @@ "Prompts.NoProvider": "LLM-провайдер не настроен", "Prompts.Provider": "Провайдер", "Prompts.ProviderWarning": "Включите OpenAI или Groq в разделе «Расширения».", + "Prompts.SaveFailed": "Не удалось сохранить промпт-действия: {0}", "Prompts.Summary": "Промптов: {0}, включено: {1}", "Prompts.SystemPrompt": "Системный промпт", "Prompts.Title": "Промпты", @@ -789,6 +791,7 @@ "Snippets.ProfileIdsPlaceholder": "ID профилей", "Snippets.ReplacementPlaceholder": "Текст замены", "Snippets.SaveChanges": "Сохранить изменения", + "Snippets.SaveFailed": "Не удалось сохранить сниппеты: {0}", "Snippets.SummaryText": "Сниппетов: {0}, включено: {1}", "Snippets.TagFilter": "Фильтр тегов:", "Snippets.TagsPlaceholder": "Теги", diff --git a/src/TypeWhisper.Linux/ServiceRegistrations.cs b/src/TypeWhisper.Linux/ServiceRegistrations.cs index 89200217b..40bce2a93 100644 --- a/src/TypeWhisper.Linux/ServiceRegistrations.cs +++ b/src/TypeWhisper.Linux/ServiceRegistrations.cs @@ -46,8 +46,11 @@ public static void Register(IServiceCollection services) services.AddSingleton( new SnippetService(Path.Join(dataPath, "snippets.json")) ); - services.AddSingleton( - new ProfileService(Path.Join(dataPath, "profiles.json")) + services.AddSingleton(sp => + new ProfileService( + Path.Join(dataPath, "profiles.json"), + sp.GetRequiredService() + ) ); services.AddSingleton(sp => new PromptActionService( diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs index 455d7e012..713ae9e33 100644 --- a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs +++ b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs @@ -75,14 +75,24 @@ public sealed partial class AtSpiUrlExtractor private string? _missProcessName; private string? _missTitle; + // Test seam standing in for the AT-SPI tree walk, so the cache/miss-backoff state machine + // can be exercised without busctl, gdbus, or a live a11y bus. Always null in production. + private readonly Func? _walkOverride; + public AtSpiUrlExtractor() : this(null) { } public AtSpiUrlExtractor(IErrorLogService? errorLog) + : this(errorLog, walkOverride: null) + { + } + + internal AtSpiUrlExtractor(IErrorLogService? errorLog, Func? walkOverride) { _errorLog = errorLog; + _walkOverride = walkOverride; } public string? TryGetBrowserUrl( @@ -137,22 +147,39 @@ _cachedUrl is not null } } - if (!s_isBusctlAvailable || !s_isGdbusAvailable) + string? url; + if (_walkOverride is not null) { - LogOnce("AT-SPI URL walk skipped: busctl/gdbus not on PATH."); - return null; + url = _walkOverride(processHint); } - - var address = GetAtSpiBusAddress(); - if (string.IsNullOrWhiteSpace(address)) + else { - LogOnce("AT-SPI URL walk skipped: a11y bus address not resolvable via gdbus."); - return null; - } + if (!s_isBusctlAvailable || !s_isGdbusAvailable) + { + LogOnce("AT-SPI URL walk skipped: busctl/gdbus not on PATH."); + return null; + } + + var address = GetAtSpiBusAddress(); + if (string.IsNullOrWhiteSpace(address)) + { + LogOnce("AT-SPI URL walk skipped: a11y bus address not resolvable via gdbus."); + return null; + } - using var cts = new CancellationTokenSource(s_walkBudget); - var stats = new WalkStats(); - var url = WalkForUrl(address, processHint, stats, cts.Token); + using var cts = new CancellationTokenSource(s_walkBudget); + var stats = new WalkStats(); + url = WalkForUrl(address, processHint, stats, cts.Token); + LogOnce( + BuildDiagnosticLine( + processHint, + focusedTitle, + stats, + url, + cts.IsCancellationRequested + ) + ); + } lock (_cacheLock) { @@ -177,9 +204,6 @@ _cachedUrl is not null } } - LogOnce( - BuildDiagnosticLine(processHint, focusedTitle, stats, url, cts.IsCancellationRequested) - ); return url; } diff --git a/src/TypeWhisper.Linux/Services/DictationInsertionOrderGate.cs b/src/TypeWhisper.Linux/Services/DictationInsertionOrderGate.cs index 8db9386b1..9ac980b49 100644 --- a/src/TypeWhisper.Linux/Services/DictationInsertionOrderGate.cs +++ b/src/TypeWhisper.Linux/Services/DictationInsertionOrderGate.cs @@ -65,7 +65,10 @@ internal async Task WaitForTurnAsync(int sessionId, CancellationToken cancellati TaskCompletionSource tcs; lock (_lock) { - if (_pending.Count == 0 || _pending.Min == sessionId) + // An unreserved (or already-released) session has nothing to wait behind: no + // predecessor's Release would ever target its waiter, so registering one would + // stall it until the backstop instead of returning now. + if (!_pending.Contains(sessionId) || _pending.Min == sessionId) { return; } diff --git a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs index c59f393e8..8130441b4 100644 --- a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs +++ b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs @@ -2020,6 +2020,15 @@ actionPlugin is not null ); } + // Wait for every earlier-started session to finish inserting first + // (audit §2 H3) — but only around the delivery call itself, not the + // transcription/post-processing above, which stays concurrent. Ahead of the + // status/focus handoff below so a queued session doesn't announce "Inserting…" and + // take focus minutes before it can deliver. Cancellation here flows to the pipeline's + // own handler, and the terminal safety net still releases this session's slot. + await _insertionOrder.WaitForTurnAsync(context.SessionId, cancelToken) + .ConfigureAwait(false); + // Yield focus before any synthesized keystroke: on Wayland a // visible overlay can still hold keyboard focus, and ydotool's // virtual keyboard fires Ctrl+V to whatever has focus. wtype on @@ -2047,12 +2056,6 @@ actionPlugin is null var insertionThrew = false; try { - // Wait for every earlier-started session to finish inserting first - // (audit §2 H3) — but only around the delivery call itself, not the - // transcription/post-processing above, which stays concurrent. - await _insertionOrder.WaitForTurnAsync(context.SessionId, cancelToken) - .ConfigureAwait(false); - // Final lock check before synthesizing any keystroke, re-evaluated // AFTER the insertion-order wait above. A normal stop nulls // _activeDictationCts and releases the gate before transcription diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginHostServices.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginHostServices.cs index 41af8b527..8a6c9a129 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginHostServices.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginHostServices.cs @@ -252,7 +252,10 @@ private Dictionary LoadSettings() } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { - // A genuinely absent file is an empty store, not a load failure. + // A genuinely absent file is an empty store, not a load failure. Clear the flag + // too: an earlier unreadable-file failure has nothing left to protect once the + // file is gone, and leaving it set would reject every save from here on. + _loadFailed = false; _settingsCache = []; return _settingsCache; } @@ -265,8 +268,9 @@ private Dictionary LoadSettings() $"Plugin '{_pluginDisplayName}' ({_pluginId}) settings could not be read; saves are disabled to protect the existing file: {ex.Message}" ); _loadFailed = true; - _settingsCache = []; - return _settingsCache; + // Deliberately not cached: the failure may be transient (a lock, a brief + // permissions blip), so the next call re-reads instead of being stuck empty. + return []; } try @@ -276,17 +280,17 @@ private Dictionary LoadSettings() json, s_jsonOptions ) ?? throw new JsonException("The settings file contained null JSON."); + _loadFailed = false; } catch (JsonException ex) { Trace.WriteLine($"[Plugin:{_pluginId}] Failed to parse settings: {ex.Message}"); var brokenPath = PreserveBrokenFile(_settingsFilePath); - if (brokenPath is null && File.Exists(_settingsFilePath)) - { - // The corrupt original is still on disk; overwriting it would lose the only - // copy, so disable saves until it is dealt with. - _loadFailed = true; - } + // Saves stay disabled only while the corrupt original is still the sole copy on + // disk; once it has been preserved elsewhere (or has vanished) there is nothing + // left to overwrite. Assigned rather than only set, so a stale flag from an + // earlier unreadable-file failure clears on this recovery. + _loadFailed = brokenPath is null && File.Exists(_settingsFilePath); AddSettingsError( brokenPath is null diff --git a/src/TypeWhisper.Linux/Services/ProcessRunner.cs b/src/TypeWhisper.Linux/Services/ProcessRunner.cs index f6342b2be..04e463821 100644 --- a/src/TypeWhisper.Linux/Services/ProcessRunner.cs +++ b/src/TypeWhisper.Linux/Services/ProcessRunner.cs @@ -50,7 +50,11 @@ public interface IProcessRunner /// When set, the process tree is killed if it outlives the window and the result is flagged /// . /// - /// Cancels the run; the process tree is killed on cancellation. + /// + /// Cancels the run: the process tree is killed and an + /// is thrown. Cancellation is never reported as a + /// result, so stays a statement about the launch. + /// Task RunAsync( string fileName, IReadOnlyList args, @@ -117,92 +121,127 @@ public async Task RunAsync( } var lifecycleToken = timeoutCts?.Token ?? ct; - await using var standardInputWriter = standardInput is not null - ? process.StandardInput - : null; - if (standardInput is not null) + + using var standardOutputReader = process.StandardOutput; + using var standardErrorReader = process.StandardError; + // Drain both pipes from the start so a chatty process cannot fill its output + // buffer and deadlock against our stdin write below. + var stdoutTask = standardOutputReader.ReadToEndAsync(ct); + var stderrTask = standardErrorReader.ReadToEndAsync(ct); + + // Deliberately not `await using`: disposing a writer whose content was never drained + // flushes into the pipe, and once the process is killed that flush throws. That + // exception would replace the in-flight cancellation (or a TimedOut return) and end + // up misreported as NotStarted by the outer catch. + var standardInputWriter = standardInput is not null ? process.StandardInput : null; + try { + if (standardInput is not null) + { + try + { + await standardInputWriter! + .WriteAsync(standardInput.AsMemory(), lifecycleToken) + .ConfigureAwait(false); + standardInputWriter.Close(); + } + catch (OperationCanceledException) when ( + timeoutCts?.IsCancellationRequested == true && !ct.IsCancellationRequested + ) + { + KillProcessTree(process); + AbandonRead(standardOutputReader, stdoutTask); + AbandonRead(standardErrorReader, stderrTask); + return TimedOutResult(); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Kill before unwinding so the blocked write cannot hold the pipe open. + KillProcessTree(process); + throw; + } + } + try { - await standardInputWriter! - .WriteAsync(standardInput.AsMemory(), lifecycleToken) - .ConfigureAwait(false); - standardInputWriter.Close(); + await process.WaitForExitAsync(lifecycleToken).ConfigureAwait(false); } catch (OperationCanceledException) when ( timeoutCts?.IsCancellationRequested == true && !ct.IsCancellationRequested ) { + // Inner timeout fired (not the caller's ct) — kill and return TimedOut + // so the caller can distinguish a timeout from a hard cancellation. KillProcessTree(process); + AbandonRead(standardOutputReader, stdoutTask); + AbandonRead(standardErrorReader, stderrTask); return TimedOutResult(); } - } - using var standardOutputReader = process.StandardOutput; - using var standardErrorReader = process.StandardError; - var stdoutTask = standardOutputReader.ReadToEndAsync(ct); - var stderrTask = standardErrorReader.ReadToEndAsync(ct); + var exitCode = process.ExitCode; + if (timeout is not { } timeoutLimit) + { + return new ProcessRunResult( + true, + false, + exitCode, + await stdoutTask.ConfigureAwait(false), + await stderrTask.ConfigureAwait(false) + ); + } - try - { - await process.WaitForExitAsync(lifecycleToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when ( - timeoutCts?.IsCancellationRequested == true && !ct.IsCancellationRequested - ) - { - // Inner timeout fired (not the caller's ct) — kill and return TimedOut - // so the caller can distinguish a timeout from a hard cancellation. - KillProcessTree(process); - AbandonRead(standardOutputReader, stdoutTask); - AbandonRead(standardErrorReader, stderrTask); - return TimedOutResult(); - } + var remaining = timeoutLimit - timeoutStopwatch!.Elapsed; + // Preserve the lifecycle deadline when time remains, but allow a small + // post-exit grace so a process exiting at the deadline can flush normal + // redirected output. The total run may therefore exceed the limit by at + // most 250 ms when the process exits at deadline-minus-epsilon. + var drainLimit = remaining > s_minimumDrainGrace ? remaining : s_minimumDrainGrace; + using var drainCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + drainCts.CancelAfter(drainLimit); + try + { + await Task.WhenAll(stdoutTask, stderrTask) + .WaitAsync(drainCts.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // The process itself exited, so its exit code is authoritative. A + // descendant may still hold the pipe writers (wl-copy/xclip do this). + // Close our redirected stream handles and observe any resulting + // background read faults rather than surfacing a false process timeout + // or waiting for the descendant. + AbandonRead(standardOutputReader, stdoutTask); + AbandonRead(standardErrorReader, stderrTask); + } - var exitCode = process.ExitCode; - if (timeout is not { } timeoutLimit) - { return new ProcessRunResult( true, false, exitCode, - await stdoutTask.ConfigureAwait(false), - await stderrTask.ConfigureAwait(false) + CompletedOutput(stdoutTask), + CompletedOutput(stderrTask) ); } - - var remaining = timeoutLimit - timeoutStopwatch!.Elapsed; - // Preserve the lifecycle deadline when time remains, but allow a small - // post-exit grace so a process exiting at the deadline can flush normal - // redirected output. The total run may therefore exceed the limit by at - // most 250 ms when the process exits at deadline-minus-epsilon. - var drainLimit = remaining > s_minimumDrainGrace ? remaining : s_minimumDrainGrace; - using var drainCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - drainCts.CancelAfter(drainLimit); - try + catch (OperationCanceledException) when (ct.IsCancellationRequested) { - await Task.WhenAll(stdoutTask, stderrTask) - .WaitAsync(drainCts.Token) - .ConfigureAwait(false); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - // The process itself exited, so its exit code is authoritative. A - // descendant may still hold the pipe writers (wl-copy/xclip do this). - // Close our redirected stream handles and observe any resulting - // background read faults rather than surfacing a false process timeout - // or waiting for the descendant. + // Caller cancellation: kill the tree per the documented contract and let the + // cancellation surface, rather than reporting a started process as NotStarted. + KillProcessTree(process); AbandonRead(standardOutputReader, stdoutTask); AbandonRead(standardErrorReader, stderrTask); + throw; } - - return new ProcessRunResult( - true, - false, - exitCode, - CompletedOutput(stdoutTask), - CompletedOutput(stderrTask) - ); + finally + { + AbandonStandardInput(standardInputWriter); + } + } + catch (OperationCanceledException) + { + // Must precede the generic catch, which would otherwise flatten cancellation + // into a NotStarted result. + throw; } catch (Exception ex) { @@ -217,6 +256,19 @@ private static string CompletedOutput(Task readTask) : string.Empty; } + private static void AbandonStandardInput(StreamWriter? writer) + { + try + { + writer?.Dispose(); + } + catch + { + // A broken pipe (the process was killed with our write still buffered) must not + // displace the cancellation or timeout result this call is already returning. + } + } + private static void KillProcessTree(Process process) { try diff --git a/src/TypeWhisper.Linux/Services/TextInsertionService.cs b/src/TypeWhisper.Linux/Services/TextInsertionService.cs index 35bdafa8b..6856a1ce0 100644 --- a/src/TypeWhisper.Linux/Services/TextInsertionService.cs +++ b/src/TypeWhisper.Linux/Services/TextInsertionService.cs @@ -59,8 +59,10 @@ public sealed class TextInsertionService private const int PasteAttemptCount = 3; private const string ClipboardNonTextCouldNotRestoreMessage = "Clipboard preservation skipped: the previous clipboard offered a non-text format (e.g. an image or file list) that cannot be captured as plain text, so it was replaced and could not be restored."; + // Both call sites reach here after the dictated text is already on the clipboard, so the + // message must not imply the previous content survived. private const string ClipboardRichRestoreSkippedMessage = - "Clipboard preservation skipped: the previous clipboard also offered a richer, non-text format (e.g. HTML) that would be lost if restored as plain text, so it was left as-is instead of a lossy restore."; + "Clipboard preservation skipped: the previous clipboard also offered a richer, non-text format (e.g. HTML) that a plain-text restore would have lost, so it was not restored. The clipboard now holds the dictated text — copy the original content again if you still need it."; private const string ClipboardRichRestoreLossyMessage = "Clipboard preservation was lossy: the previous clipboard also offered a richer, non-text format (e.g. HTML) that could not be restored; only its plain-text content was restored."; private static readonly TimeSpan s_focusDelay = TimeSpan.FromMilliseconds(100); @@ -999,6 +1001,12 @@ internal sealed class LinuxTextInsertionPlatform : ITextInsertionPlatform "MULTIPLE", "SAVE_TARGETS", "TIMESTAMP", + // ICCCM metadata and side-effect targets that Xt/Motif-based owners routinely + // advertise. Treating them as content would strand the clipboard on plain-text copies. + "LENGTH", + "DELETE", + "INSERT_SELECTION", + "INSERT_PROPERTY", "STRING", "UTF8_STRING", "TEXT", diff --git a/src/TypeWhisper.Linux/Services/WatchFolderModels.cs b/src/TypeWhisper.Linux/Services/WatchFolderModels.cs index 17aaf4860..460309689 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderModels.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderModels.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using TypeWhisper.Core.Models; namespace TypeWhisper.Linux.Services; @@ -45,6 +46,17 @@ public sealed record WatchFolderHistoryItem public required string OutputPath { get; init; } public required bool Success { get; init; } public string? ErrorMessage { get; init; } + + /// + /// A completed transcription that still carries a message — e.g. the transcript was written + /// but the source file could not be deleted. Distinct from so the + /// UI can show it without demoting the run to a failure. + /// + [JsonIgnore] + public bool ShowsWarning => Success && !string.IsNullOrEmpty(ErrorMessage); + + [JsonIgnore] + public bool ShowsFailure => !Success && !string.IsNullOrEmpty(ErrorMessage); } public static class WatchFolderOutputFormats diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs index 50f5bd9dc..3aa45e4b7 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs @@ -159,7 +159,7 @@ public async Task RestoreSettingsBackupAsync(string path) var result = await Task.Run(() => _settingsBackup.RestoreBackup(path)); // Re-load and re-save each settings file so in-memory state // reflects the just-restored files and SettingsChanged is fired. - _settings.Save(_settings.Load()); + _settings.Reload(); _linuxPreferences.Save(_linuxPreferences.Load()); BackupStatusText = Loc.Instance.GetString("About.BackupRestored", result.FileCount); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs index f737d538e..fc535b89a 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs @@ -1,3 +1,4 @@ +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using System.Collections.ObjectModel; @@ -16,6 +17,11 @@ public partial class GeneralSectionViewModel : ObservableObject private readonly ISettingsService _settings; private readonly TrayIconService _tray; + // Set while Refresh hydrates from persisted settings so the generated OnChanged + // hooks don't write the value straight back. Delivery is deferred to the UI thread, so + // without this a refresh could persist its snapshot over a newer commit. + private bool _hydratingFromSettings; + [ObservableProperty] private string _apiBearerToken = ""; @@ -65,8 +71,14 @@ TrayIconService tray Refresh(settings.Current); StartWithSystem = StartupService.IsEnabled; CloseToTray = _linuxPrefs.Current.CloseToTray; - _settings.SettingsChanged += Refresh; - _api.StateChanged += () => ApiStatusText = _api.StatusText; + // Both fire on whichever thread wrote — a background Save, or the teardown continuation + // left on the pool by the awaited model unload — so hop to the UI thread rather than + // mutating bound properties off it. Re-read Current when the post runs instead of + // capturing the payload, so queued refreshes coalesce onto the newest commit. + _settings.SettingsChanged += _ => + Dispatcher.UIThread.Post(() => Refresh(_settings.Current)); + _api.StateChanged += () => + Dispatcher.UIThread.Post(() => ApiStatusText = _api.StatusText); ApiStatusText = _api.StatusText; RefreshCliState(); } @@ -112,12 +124,20 @@ public UiLanguageOption? SelectedUiLanguageOption private void Refresh(AppSettings s) { - UiLanguage = s.UiLanguage; - ApiServerEnabled = s.ApiServerEnabled; - ApiServerPort = s.ApiServerPort; - ApiBearerToken = HttpApiService.ReadBearerToken(s); - RefreshExamples(s.ApiServerPort); - OnPropertyChanged(nameof(SelectedUiLanguageOption)); + _hydratingFromSettings = true; + try + { + UiLanguage = s.UiLanguage; + ApiServerEnabled = s.ApiServerEnabled; + ApiServerPort = s.ApiServerPort; + ApiBearerToken = HttpApiService.ReadBearerToken(s); + RefreshExamples(s.ApiServerPort); + OnPropertyChanged(nameof(SelectedUiLanguageOption)); + } + finally + { + _hydratingFromSettings = false; + } } [RelayCommand] @@ -171,7 +191,12 @@ private void RefreshExamples(int port) partial void OnUiLanguageChanged(string? value) { - _settings.Save(_settings.Current with { UiLanguage = value }); + // Applying the language still runs while hydrating; only the write-back is suppressed. + if (!_hydratingFromSettings) + { + _settings.Save(_settings.Current with { UiLanguage = value }); + } + Loc.Instance.CurrentLanguage = Loc.Instance.ResolveLanguage(value); OnPropertyChanged(nameof(SelectedUiLanguageOption)); } @@ -195,7 +220,7 @@ partial void OnStartWithSystemChanged(bool value) partial void OnApiServerEnabledChanged(bool value) { - if (_settings.Current.ApiServerEnabled == value) + if (_hydratingFromSettings || _settings.Current.ApiServerEnabled == value) { return; } @@ -205,7 +230,10 @@ partial void OnApiServerEnabledChanged(bool value) partial void OnApiServerPortChanged(int value) { - if (value <= 0 || value > 65535 || _settings.Current.ApiServerPort == value) + if (_hydratingFromSettings + || value <= 0 + || value > 65535 + || _settings.Current.ApiServerPort == value) { return; } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs index 63f30f4c0..091564adc 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs @@ -23,6 +23,7 @@ public partial class ProfilesSectionViewModel : ObservableObject { private readonly IActiveWindowService _activeWindow; private readonly BrowserAccessibilitySetupHelper _browserSetup; + private readonly IErrorLogService? _errorLog; private readonly IDetectionFailureTracker _failureTracker; private readonly GnomeWindowCallsSetupHelper _gnomeSetup; private readonly string _hostProcessName = Process.GetCurrentProcess().ProcessName; @@ -52,6 +53,9 @@ public partial class ProfilesSectionViewModel : ObservableObject [ObservableProperty] private string _currentProcessName = "-"; + [ObservableProperty] + private string _errorText = ""; + [ObservableProperty] private string _currentUrl = "-"; @@ -132,9 +136,11 @@ public ProfilesSectionViewModel( IPromptActionService promptActions, IDetectionFailureTracker failureTracker, GnomeWindowCallsSetupHelper gnomeSetup, - BrowserAccessibilitySetupHelper browserSetup + BrowserAccessibilitySetupHelper browserSetup, + IErrorLogService? errorLog = null ) { + _errorLog = errorLog; _profiles = profiles; _activeWindow = activeWindow; _pluginManager = pluginManager; @@ -302,6 +308,8 @@ SelectedProfile is null ? Loc.Instance.GetString("Profiles.Matches", MatchedProfileName) : Loc.Instance["Profiles.NoActiveMatch"]; + public bool HasError => !string.IsNullOrEmpty(ErrorText); + public bool ShowLiveContextProfileHint => !HasSelectedProfile; public bool HasCurrentProcess => @@ -593,7 +601,11 @@ private void AddProfile() UrlPatterns = [] }; - _profiles.AddProfile(profile); + if (!TryMutate(() => _profiles.AddProfile(profile), "add a profile")) + { + return; + } + RefreshProfiles(); SelectById(profile.Id); } @@ -631,7 +643,11 @@ private void SaveProfile() }; var selectedId = SelectedProfile.Id; - _profiles.UpdateProfile(updated); + if (!TryMutate(() => _profiles.UpdateProfile(updated), "save a profile")) + { + return; + } + RefreshProfiles(); SelectById(selectedId); } @@ -655,7 +671,11 @@ private void DuplicateProfile() UpdatedAt = DateTime.UtcNow }; - _profiles.AddProfile(duplicate); + if (!TryMutate(() => _profiles.AddProfile(duplicate), "duplicate a profile")) + { + return; + } + RefreshProfiles(); SelectById(duplicate.Id); } @@ -668,7 +688,11 @@ private void DeleteSelectedProfile() return; } - _profiles.DeleteProfile(SelectedProfile.Id); + if (!TryMutate(() => _profiles.DeleteProfile(SelectedProfile.Id), "delete a profile")) + { + return; + } + RefreshProfiles(); SelectedProfile = null; } @@ -681,10 +705,42 @@ private void ToggleProfileEnabled(Profile? profile) return; } - _profiles.UpdateProfile(profile with { IsEnabled = !profile.IsEnabled }); + if ( + !TryMutate( + () => _profiles.UpdateProfile(profile with { IsEnabled = !profile.IsEnabled }), + "toggle a profile" + ) + ) + { + return; + } + RefreshProfiles(); } + private bool TryMutate(Action mutation, string operation) + { + try + { + mutation(); + ErrorText = ""; + return true; + } + catch (Exception ex) + { + Trace.WriteLine($"[ProfilesSectionViewModel] Failed to {operation}: {ex}"); + _errorLog?.AddEntry($"Could not {operation}: {ex.Message}"); + ErrorText = Loc.Instance.GetString("Profiles.SaveFailed", ex.Message); + RefreshProfiles(); + return false; + } + } + + partial void OnErrorTextChanged(string value) + { + OnPropertyChanged(nameof(HasError)); + } + [RelayCommand] private void AddProcessNameChip() { diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs index 9c09168dd..7afb30763 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs @@ -16,10 +16,14 @@ namespace TypeWhisper.Linux.ViewModels.Sections; // ReSharper disable UnusedParameterInPartialMethod public partial class PromptsSectionViewModel : ObservableObject { + private readonly IErrorLogService? _errorLog; private readonly PluginManager _pluginManager; private readonly IPromptActionService _prompts; private readonly ISettingsService _settings; + [ObservableProperty] + private string _errorText = ""; + // Set while hydrating the spoken-command properties from saved settings so the // generated OnChanged hooks don't persist the value straight back. private bool _hydratingCommandSettings; @@ -70,12 +74,14 @@ public partial class PromptsSectionViewModel : ObservableObject public PromptsSectionViewModel( IPromptActionService prompts, PluginManager pluginManager, - ISettingsService settings + ISettingsService settings, + IErrorLogService? errorLog = null ) { _prompts = prompts; _pluginManager = pluginManager; _settings = settings; + _errorLog = errorLog; _prompts.ActionsChanged += () => Dispatcher.UIThread.Post(RefreshActions); _pluginManager.PluginStateChanged += (_, _) => @@ -107,6 +113,8 @@ ISettingsService settings [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "XAML binding surface; ViewModel properties must be instance members for compiled bindings")] public string PromptsHint => Loc.Instance["Prompts.Hint"]; + public bool HasError => !string.IsNullOrEmpty(ErrorText); + public bool ShowProviderWarning => AvailableProviders.Count <= 1; // ReSharper disable once MemberCanBeMadeStatic.Global [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "XAML binding surface; ViewModel properties must be instance members for compiled bindings")] @@ -509,16 +517,24 @@ private bool TryMutate(Action mutation, string operation) try { mutation(); + ErrorText = ""; return true; } catch (Exception ex) { Trace.WriteLine($"[PromptsSectionViewModel] Failed to {operation}: {ex}"); + _errorLog?.AddEntry($"Could not {operation}: {ex.Message}", ErrorCategory.Prompt); + ErrorText = Loc.Instance.GetString("Prompts.SaveFailed", ex.Message); RefreshActions(); return false; } } + partial void OnErrorTextChanged(string value) + { + OnPropertyChanged(nameof(HasError)); + } + private void RefreshPluginOptions() { var selectedProvider = EditProviderOverride; diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs index bb08f4d79..0eb9ec951 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs @@ -16,12 +16,16 @@ public partial class SnippetsSectionViewModel : ObservableObject, IDisposable { private readonly IDictionaryService _dictionary; private readonly Action _entriesChangedHandler; + private readonly IErrorLogService? _errorLog; private readonly ISnippetService _snippets; private readonly Action _snippetsChangedHandler; [ObservableProperty] private bool _caseSensitive; + [ObservableProperty] + private string _errorText = ""; + [ObservableProperty] private string? _editingSnippetId; @@ -46,10 +50,15 @@ public partial class SnippetsSectionViewModel : ObservableObject, IDisposable [ObservableProperty] private bool _showEditor; - public SnippetsSectionViewModel(ISnippetService snippets, IDictionaryService dictionary) + public SnippetsSectionViewModel( + ISnippetService snippets, + IDictionaryService dictionary, + IErrorLogService? errorLog = null + ) { _snippets = snippets; _dictionary = dictionary; + _errorLog = errorLog; _snippetsChangedHandler = () => Dispatcher.UIThread.Post(Refresh); _entriesChangedHandler = () => Dispatcher.UIThread.Post(NotifyConflictWarningChanged); _snippets.SnippetsChanged += _snippetsChangedHandler; @@ -67,6 +76,8 @@ public SnippetsSectionViewModel(ISnippetService snippets, IDictionaryService dic public bool ShowEmptyState => FilteredSnippets.Count == 0; public bool ShowSnippetList => FilteredSnippets.Count > 0; + public bool HasError => !string.IsNullOrEmpty(ErrorText); + public bool HasSelectedTagFilter => !string.Equals(SelectedTagFilter, Loc.Instance["Snippets.AllTags"], StringComparison.Ordinal); @@ -136,6 +147,11 @@ partial void OnShowEditorChanged(bool value) OnPropertyChanged(nameof(EditorSaveText)); } + partial void OnErrorTextChanged(string value) + { + OnPropertyChanged(nameof(HasError)); + } + partial void OnEditingSnippetIdChanged(string? value) { OnPropertyChanged(nameof(IsEditingExisting)); @@ -290,11 +306,14 @@ private bool TryMutate(Action mutation, string operation) try { mutation(); + ErrorText = ""; return true; } catch (Exception ex) { Trace.WriteLine($"[SnippetsSectionViewModel] Failed to {operation}: {ex}"); + _errorLog?.AddEntry($"Could not {operation}: {ex.Message}"); + ErrorText = Loc.Instance.GetString("Snippets.SaveFailed", ex.Message); Refresh(); return false; } diff --git a/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml b/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml index 70e9b97ba..19f6859c9 100644 --- a/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml @@ -396,7 +396,15 @@ FontSize="12" Foreground="#FF8A8A" TextWrapping="Wrap" - IsVisible="{Binding !Success}" /> + IsVisible="{Binding ShowsFailure}" /> + + diff --git a/src/TypeWhisper.Linux/Views/Sections/ProfilesSection.axaml b/src/TypeWhisper.Linux/Views/Sections/ProfilesSection.axaml index e880f8e5f..618a75350 100644 --- a/src/TypeWhisper.Linux/Views/Sections/ProfilesSection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/ProfilesSection.axaml @@ -85,6 +85,19 @@ Classes="panel-title" /> + + + + + + + + internal static class InterProcessFileLock diff --git a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs index 9e47f5bd5..d3f5b16d7 100644 --- a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs @@ -417,6 +417,7 @@ out var isCommittedTranscript $"ElevenLabs streaming provider error: {error}" ); + // ReSharper disable once InvertIf -- inverting would add a `continue` for the loop's normal path; this is the terminal check. if ( isCommittedTranscript && Volatile.Read(ref _finalCommitPending) != 0 diff --git a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs index 68e5b5cb5..375ef566c 100644 --- a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs @@ -128,10 +128,12 @@ CancellationToken ct var audioUrl = await UploadAudioAsync(wavAudio, apiKey, ct); var job = await InitiateTranscriptionAsync(audioUrl, language, apiKey, ct); - var terminalJson = await PollUntilTerminalAsync(job, apiKey, ct); try { + // Polling is inside the try so a poll failure, timeout or cancellation + // still deletes the server-side job instead of leaking the audio. + var terminalJson = await PollUntilTerminalAsync(job, apiKey, ct); using var terminalDocument = ParseProtocolJson( terminalJson, "polling" @@ -154,6 +156,7 @@ CancellationToken ct } } + // Owns the supplied HttpClient, including an injected one, and disposes it. public void Dispose() { _httpClient.Dispose(); @@ -304,7 +307,9 @@ CancellationToken ct if (!response.IsSuccessStatusCode) { throw new HttpRequestException( - $"{operation} error {(int)response.StatusCode}: {ExtractProviderDetails(json)}" + $"{operation} error {(int)response.StatusCode}: {ExtractProviderDetails(json)}", + null, + response.StatusCode ); } @@ -390,6 +395,7 @@ private static PluginTranscriptionResult ParseCompletedResult( return null; } + // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator -- LINQ would box JsonElement's struct ArrayEnumerator. foreach (var language in languages.EnumerateArray()) { if (language.ValueKind == JsonValueKind.String diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs index 56fb47845..b0c1f65b8 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs @@ -228,6 +228,7 @@ private static int FindQuietBoundary(byte[] wavAudio, int chunkOffset) // Prefer the later window when scores tie so uniformly quiet audio stays // as close as possible to the nominal 55-second boundary. + // ReSharper disable once InvertIf -- inverting flips the tie-break rule the comment above documents. if (score <= quietestScore) { quietestScore = score; @@ -350,6 +351,7 @@ out var secs ) { var first = resultsForLang[0]; + // ReSharper disable once InvertIf -- optional-property read; there is no early exit to invert toward. if (first.TryGetProperty("languageCode", out var lc)) { if (lc.ValueKind != JsonValueKind.String) diff --git a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs index 1e87d343a..837fd27ab 100644 --- a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs @@ -196,13 +196,13 @@ private static Task WriteIndividualNoteAsync( string content, CancellationToken ct ) => - WriteIndividualNoteAsync(filePath, content, ct, WriteUtf8TextAsync); + WriteIndividualNoteAsync(filePath, content, WriteUtf8TextAsync, ct); internal static async Task WriteIndividualNoteAsync( string filePath, string content, - CancellationToken ct, - Func writeAsync + Func writeAsync, + CancellationToken ct ) { var dir = Path.GetDirectoryName(filePath)!; diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs index 89f106421..308019f87 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs @@ -85,10 +85,9 @@ internal static Dictionary CreateRequestBody( internal static string? ParseResponseText(string body) { - if (TryParseJsonResponseText(body, out var responseText)) - return responseText; - - return ParseEventStreamResponseText(body); + return TryParseJsonResponseText(body, out var responseText) + ? responseText + : ParseEventStreamResponseText(body); } private static string? ParseEventStreamResponseText(string body) @@ -173,10 +172,13 @@ internal static Dictionary CreateRequestBody( if (type == "response.completed") { - if (!string.Equals(status, "completed", StringComparison.OrdinalIgnoreCase)) + // The event type is itself the success signal, so only an explicitly + // contradictory status is a failure — a missing one must not be. + if (status is not null + && !string.Equals(status, "completed", StringComparison.OrdinalIgnoreCase)) { return $"ChatGPT SSE event 'response.completed' had non-completed status " - + $"'{status ?? "missing"}'."; + + $"'{status}'."; } return null; diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs index 1926dfb29..340727f2a 100644 --- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs @@ -77,6 +77,7 @@ public static async Task ConnectAsync( internal static OpenAiRealtimeStreamingSession CreateConnectedSessionForTests(WebSocket ws) { + // ReSharper disable once ConvertIfStatementToReturnStatement -- the guard throws; folding it into a ternary would need a throw expression. if (ws.State != WebSocketState.Open) throw new InvalidOperationException("The test WebSocket must already be open."); @@ -502,7 +503,17 @@ private async Task ReceiveLoopAsync(CancellationToken ct) // don't hang until the caller's token. CaptureReceiveLoopClosure(ct); } - catch (OperationCanceledException) { } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Deliberate disposal owns this token; not a fault. + } + catch (OperationCanceledException ex) + { + // Cancellation from anywhere else (an aborted socket, a subscriber) + // still strands finalize's waiters, so publish it as a terminal fault. + Debug.WriteLine($"OpenAI realtime receive canceled: {ex.Message}"); + CaptureReceiveLoopException(ex); + } catch (WebSocketException ex) { Debug.WriteLine($"OpenAI realtime WebSocket error: {ex.Message}"); diff --git a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs index 788bd82cc..c23f0b254 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs @@ -517,14 +517,22 @@ public async Task RefreshModelCatalogAsync(CancellationToken ct = default) // catalogs don't go stale when a server adds or removes models after the // profile was first saved. var changedProfileIds = new HashSet(StringComparer.Ordinal); - foreach (var profile in _additionalProfiles.Where(p => !string.IsNullOrEmpty(p.BaseUrl))) + foreach (var snapshot in SnapshotProfiles().Where(p => !string.IsNullOrEmpty(p.BaseUrl))) { - var models = await FetchModelsForAsync(profile.BaseUrl, GetProfileApiKey(profile.Id), ct); - if (models is null || !ProfileCatalogStateChanged(profile, models)) + var apiKey = GetProfileApiKey(snapshot.Id); + var models = await FetchModelsForAsync(snapshot.BaseUrl, apiKey, ct); + if (models is null) continue; - ApplyProfileCatalog(profile, models); - changedProfileIds.Add(profile.Id); + if (TryApplyFetchedCatalog( + snapshot.Id, + snapshot.BaseUrl, + apiKey, + models, + ProfileCatalogStateChanged)) + { + changedProfileIds.Add(snapshot.Id); + } } if (changedProfileIds.Count > 0) @@ -621,13 +629,13 @@ List models // provider via OpenAiCompatibleProfileRole and the selection-identity scheme. public IReadOnlyList AdditionalTranscriptionEngines => - _additionalProfiles - .Select(ITranscriptionEngineRole (profile) => GetProfileRole(profile.Id)) + SnapshotProfileIds() + .Select(ITranscriptionEngineRole (id) => GetProfileRole(id)) .ToList(); public IReadOnlyList AdditionalLlmProviders => - _additionalProfiles - .Select(ILlmProviderRole (profile) => GetProfileRole(profile.Id)) + SnapshotProfileIds() + .Select(ILlmProviderRole (id) => GetProfileRole(id)) .ToList(); public IReadOnlyList GetCollectionDefinitions() => @@ -672,7 +680,7 @@ public Task> GetItemsAsync( if (collectionKey != ProfilesCollectionKey) return Task.FromResult>([]); - IReadOnlyList items = _additionalProfiles + IReadOnlyList items = SnapshotProfiles() .Select(p => new PluginCollectionItem( new Dictionary { @@ -699,7 +707,7 @@ public async Task SetItemsAsync( if (collectionKey != ProfilesCollectionKey) return new PluginSettingsValidationResult(false, Loc.L("Settings.UnknownCollection")); - var previousById = _additionalProfiles.ToDictionary(p => p.Id, StringComparer.Ordinal); + var previousById = SnapshotProfiles().ToDictionary(p => p.Id, StringComparer.Ordinal); var newProfiles = new List(items.Count); var keyUpdates = new Dictionary(StringComparer.Ordinal); var seenIds = new HashSet(StringComparer.Ordinal); @@ -774,8 +782,7 @@ public async Task SetItemsAsync( } } - _additionalProfiles.Clear(); - _additionalProfiles.AddRange(newProfiles); + ReplaceProfiles(newProfiles); // State is now persisted; the best-effort model fetch below may fail or be // cancelled, but that must not revert the saved profiles. @@ -786,11 +793,20 @@ public async Task SetItemsAsync( // profile's models. New profiles and profiles whose endpoint changed have an // empty catalog here and get (re)fetched; an unchanged endpoint keeps its // existing catalog and is skipped. - foreach (var profile in _additionalProfiles.Where(p => p.FetchedModels.Count == 0)) + foreach (var snapshot in SnapshotProfiles().Where(p => p.FetchedModels.Count == 0)) { - var models = await FetchModelsForAsync(profile.BaseUrl, GetProfileApiKey(profile.Id), ct); + var apiKey = GetProfileApiKey(snapshot.Id); + var models = await FetchModelsForAsync(snapshot.BaseUrl, apiKey, ct); if (models is not null) - ApplyProfileCatalog(profile, models); + { + TryApplyFetchedCatalog( + snapshot.Id, + snapshot.BaseUrl, + apiKey, + models, + static (_, _) => true + ); + } } PersistAdditionalProfiles(notify: false); @@ -951,13 +967,15 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct private async Task LoadAdditionalProfilesAsync(IPluginHostServices host) { - var previousById = _additionalProfiles.ToDictionary(p => p.Id, StringComparer.Ordinal); + var previousById = SnapshotProfiles().ToDictionary(p => p.Id, StringComparer.Ordinal); var previousApiKeys = new Dictionary(_additionalApiKeys, StringComparer.Ordinal); - _additionalProfiles.Clear(); _additionalApiKeys.Clear(); var stored = host.GetSetting>(AdditionalProfilesSettingKey) ?? []; var seen = new HashSet(StringComparer.Ordinal); + // Built locally and swapped in once, so the awaited secret loads below + // never expose a half-populated profile set to the host. + var loadedProfiles = new List(); foreach (var profile in stored) { @@ -971,13 +989,15 @@ private async Task LoadAdditionalProfilesAsync(IPluginHostServices host) .Where(m => !string.IsNullOrWhiteSpace(m.Id)) .ToList(); - _additionalProfiles.Add(profile); + loadedProfiles.Add(profile); var key = await host.LoadSecretAsync(SecretKeyFor(profile.Id)); if (!string.IsNullOrEmpty(key)) _additionalApiKeys[profile.Id] = key; } + ReplaceProfiles(loadedProfiles); + var changedApiKeyIds = previousApiKeys .Keys.Union(_additionalApiKeys.Keys, StringComparer.Ordinal) .Where(id => @@ -992,7 +1012,7 @@ private async Task LoadAdditionalProfilesAsync(IPluginHostServices host) private void PersistAdditionalProfiles(bool notify) { - _host?.SetSetting(AdditionalProfilesSettingKey, _additionalProfiles); + _host?.SetSetting(AdditionalProfilesSettingKey, SnapshotProfiles()); if (notify) _host?.NotifyCapabilitiesChanged(); } @@ -1049,10 +1069,69 @@ CancellationToken ct private string? GetProfileApiKey(string id) => _additionalApiKeys.GetValueOrDefault(id); + // The settings-save and activation paths replace _additionalProfiles wholesale + // while the catalog refresh and the host's capability queries enumerate it across + // awaits, so hand out copies under the lock that already guards _profileRoles. + private List SnapshotProfileIds() + { + lock (_profileRolesLock) + { + return _additionalProfiles.Select(p => p.Id).ToList(); + } + } + + private List SnapshotProfiles() + { + lock (_profileRolesLock) + { + return [.. _additionalProfiles]; + } + } + + private void ReplaceProfiles(IEnumerable profiles) + { + lock (_profileRolesLock) + { + _additionalProfiles.Clear(); + _additionalProfiles.AddRange(profiles); + } + } + + // A settings save during the awaited fetch swaps in new profile objects, so + // mutating the snapshot's orphan would drop the catalog while reporting success. + // Re-resolve by ID under the lock, and discard a catalog whose endpoint or key + // the profile no longer uses — that one describes a different account. + private bool TryApplyFetchedCatalog( + string id, + string fetchedFromBaseUrl, + string? fetchedWithApiKey, + List models, + Func, bool> shouldApply + ) + { + lock (_profileRolesLock) + { + var current = _additionalProfiles.FirstOrDefault( + p => string.Equals(p.Id, id, StringComparison.Ordinal) + ); + if (current is null + || !string.Equals(current.BaseUrl, fetchedFromBaseUrl, StringComparison.Ordinal) + || !string.Equals(GetProfileApiKey(id), fetchedWithApiKey, StringComparison.Ordinal) + || !shouldApply(current, models)) + { + return false; + } + + ApplyProfileCatalog(current, models); + return true; + } + } + private OpenAiCompatibleProfileRole GetProfileRole(string id) { lock (_profileRolesLock) { + // ReSharper disable once InvertIf -- idiomatic get-or-add; inverting would duplicate the return. if (!_profileRoles.TryGetValue(id, out var role)) { role = new OpenAiCompatibleProfileRole(this, id); @@ -1069,7 +1148,7 @@ IEnumerable changedSecretIds ) { var changedSecrets = changedSecretIds.ToHashSet(StringComparer.Ordinal); - var currentById = _additionalProfiles.ToDictionary(p => p.Id, StringComparer.Ordinal); + var currentById = SnapshotProfiles().ToDictionary(p => p.Id, StringComparer.Ordinal); lock (_profileRolesLock) { @@ -1105,7 +1184,7 @@ OpenAiCompatibleProfile right } private OpenAiCompatibleProfile? FindAdditional(string id) => - _additionalProfiles.FirstOrDefault(p => string.Equals(p.Id, id, StringComparison.Ordinal)); + SnapshotProfiles().FirstOrDefault(p => string.Equals(p.Id, id, StringComparison.Ordinal)); private OpenAiCompatibleProfile RequireAdditional(string id) => FindAdditional(id) ?? throw new ArgumentException($"Unknown OpenAI-compatible profile: {id}", nameof(id)); @@ -1140,13 +1219,13 @@ private string NormalizeProfileId(string? rawId, HashSet taken) private string CreateProfileId(HashSet taken) { + var existingIds = SnapshotProfileIds(); string id; do { id = $"{ProfileIdPrefix}{Guid.NewGuid():N}"; } - while (taken.Contains(id) - || _additionalProfiles.Any(p => string.Equals(p.Id, id, StringComparison.Ordinal))); + while (taken.Contains(id) || existingIds.Contains(id)); return id; } diff --git a/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs b/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs index dc1606e7f..e2fb89f59 100644 --- a/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Reson8/Reson8StreamingSession.cs @@ -50,6 +50,7 @@ public static async Task ConnectAsync( internal static Reson8StreamingSession CreateConnectedSessionForTests(WebSocket ws) { + // ReSharper disable once ConvertIfStatementToReturnStatement -- the guard throws; folding it into a ternary would need a throw expression. if (ws.State != WebSocketState.Open) throw new InvalidOperationException("The test WebSocket must already be open."); diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaDecodeCoordinator.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaDecodeCoordinator.cs index 798d568fa..1fbaadeeb 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaDecodeCoordinator.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaDecodeCoordinator.cs @@ -100,7 +100,7 @@ private static int FindLowEnergyCut(float[] audioSamples, int start, int hardEnd start + OverlapSampleCount + EnergyWindowSampleCount, hardEnd - BoundarySearchSampleCount ); - var halfWindow = EnergyWindowSampleCount / 2; + const int halfWindow = EnergyWindowSampleCount / 2; var bestCut = hardEnd; var bestEnergy = double.MaxValue; @@ -117,7 +117,10 @@ private static int FindLowEnergyCut(float[] audioSamples, int start, int hardEnd energy += audioSamples[i] * audioSamples[i]; energy /= Math.Max(1, windowEnd - windowStart); - if (energy < bestEnergy) + // <= so a tie (digital silence across the search window) keeps the + // latest candidate, producing the longest chunk instead of the shortest. + // ReSharper disable once InvertIf -- inverting would add a `continue` to a two-line accumulator body. + if (energy <= bestEnergy) { bestEnergy = energy; bestCut = candidate; @@ -144,6 +147,7 @@ private static string StitchTokenOverlap(string accumulated, string next) var matches = true; for (var i = 0; i < length; i++) { + // ReSharper disable once InvertIf -- already the negated mismatch guard; inverting would re-nest the loop body. if ( !string.Equals( accumulatedTokens[accumulatedTokens.Length - length + i], @@ -157,6 +161,7 @@ private static string StitchTokenOverlap(string accumulated, string next) } } + // ReSharper disable once InvertIf -- inverting would add a `continue` before the loop's only exit. if (matches) { overlap = length; @@ -181,12 +186,19 @@ private static SherpaDecodeResult ParseCanaryResult(string rawText) if (json.RootElement.ValueKind != JsonValueKind.Object) return new SherpaDecodeResult(rawText.Trim(), null); + // GetString() throws InvalidOperationException on a number or boolean, + // which the JsonException handler below would not catch. var text = rawText.Trim(); - if (json.RootElement.TryGetProperty("text", out var textNode)) + if (json.RootElement.TryGetProperty("text", out var textNode) + && textNode.ValueKind is JsonValueKind.String or JsonValueKind.Null) + { text = textNode.GetString()?.Trim() ?? string.Empty; + } string? language = null; - if (json.RootElement.TryGetProperty("lang", out var languageNode)) + // ReSharper disable once InvertIf -- optional-property read inside the parse block; nothing to return early to. + if (json.RootElement.TryGetProperty("lang", out var languageNode) + && languageNode.ValueKind == JsonValueKind.String) { var parsed = languageNode.GetString(); if (!string.IsNullOrWhiteSpace(parsed)) diff --git a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs index 3e7f8c8ea..36b354fed 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs @@ -654,7 +654,8 @@ CancellationToken ct { ct.ThrowIfCancellationRequested(); var audioSamples = DecodeWav(wavAudio); - var audioDuration = audioSamples.Length / 16000.0; + var audioDuration = + audioSamples.Length / (double)SherpaDecodeCoordinator.SampleRate; ct.ThrowIfCancellationRequested(); lock (_sync) @@ -783,7 +784,7 @@ internal void SetHostForTests(IPluginHostServices host) // Test seam: run the structural preflight without the native loader, so per-model // token/ONNX acceptance (e.g. Canary carries no blank token) is testable in isolation. - internal void RunArtifactPreflightForTests(string modelId, string modelDir) => + internal static void RunArtifactPreflightForTests(string modelId, string modelDir) => VerifyModelArtifacts(GetModelDefinition(modelId), modelDir); internal string ComputeBackendForTests @@ -859,6 +860,7 @@ private static void VerifyModelArtifact(string path, string fileName, bool requi return; } + // ReSharper disable once InvertIf -- last arm of a match-then-return dispatch chain; inverting would break its shape. if (string.Equals(fileName, "tokens.txt", StringComparison.OrdinalIgnoreCase)) { VerifyTokensFile(path, fileName, requireBlankToken); @@ -980,7 +982,7 @@ private static void VerifyTokensFile(string path, string fileName, bool requireB while (reader.ReadLine() is { } line) { - if (line.IndexOf('\0') >= 0) + if (line.Contains('\0')) throw new InvalidDataException( $"Model artifact '{fileName}' contains a null character." ); @@ -1032,8 +1034,10 @@ out var id private static bool IsArtifactInvalidLoadFailure(Exception exception) { + // ReSharper disable once SuggestVarOrType_SimpleTypes -- `var` would infer non-nullable Exception and warn on the InnerException assignment. for (Exception? current = exception; current is not null; current = current.InnerException) { + // ReSharper disable once ConvertIfStatementToSwitchStatement -- type-pattern guards; the second arm's multi-line Any() would make an unwieldy `when` clause. if (current is InvalidDataException) return true; diff --git a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs index 15fc53a1e..920cccf33 100644 --- a/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiStreamingSession.cs @@ -44,6 +44,7 @@ public static async Task ConnectAsync( internal static SmallestAiStreamingSession CreateConnectedSessionForTests(WebSocket ws) { + // ReSharper disable once ConvertIfStatementToReturnStatement -- the guard throws; folding it into a ternary would need a throw expression. if (ws.State != WebSocketState.Open) throw new InvalidOperationException("The test WebSocket must already be open."); diff --git a/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs b/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs index 04bd7fe5d..cf21c2b34 100644 --- a/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs @@ -24,6 +24,7 @@ public VoxtralPlugin() { } + // The plugin takes ownership of the supplied client and disposes it. internal VoxtralPlugin(HttpClient httpClient) { _httpClient = httpClient; diff --git a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs index 52db07ed0..79a10d958 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs @@ -845,6 +845,7 @@ public async Task SetItemsAsync( } await _settingsSaveLock.WaitAsync(ct); + // ReSharper disable once TryStatementsCanBeMerged -- the outer try/finally owns the lock release and the inner try/catch owns logging; merging blends the two concerns. try { try @@ -1014,6 +1015,7 @@ out string? reference var normalizedName = NormalizeHeaderName(headerName); foreach (var candidate in references) { + // ReSharper disable once InvertIf -- find-and-return loop; inverting only adds a `continue`. if (NormalizeHeaderName(candidate.Key) == normalizedName) { reference = candidate.Value; diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs b/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs index 1e9cfeb2c..84e26ab5b 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiResponsesClient.cs @@ -254,6 +254,7 @@ private static bool IsFailureStatus(string status) => if (ExtractErrorMessage(root) is { } rootError) return rootError; + // ReSharper disable once InvertIf -- inverting would duplicate the trailing ExtractIncompleteReason return. if (TryGetResponse(root, out var response)) { if (ExtractErrorMessage(response) is { } responseError) diff --git a/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs b/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs index 9d3e03fa1..70940f4bd 100644 --- a/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Xai/XaiStreamingSession.cs @@ -65,6 +65,7 @@ public static async Task ConnectAsync( internal static XaiStreamingSession CreateConnectedSessionForTests(WebSocket ws) { + // ReSharper disable once ConvertIfStatementToReturnStatement -- the guard throws; folding it into a ternary would need a throw expression. if (ws.State != WebSocketState.Open) throw new InvalidOperationException("The test WebSocket must already be open."); @@ -76,6 +77,7 @@ internal static Task CreateConnectedSessionForTests( TimeSpan readinessTimeout, CancellationToken ct) { + // ReSharper disable once ConvertIfStatementToReturnStatement -- the guard throws; folding it into a ternary would need a throw expression. if (ws.State != WebSocketState.Open) throw new InvalidOperationException("The test WebSocket must already be open."); diff --git a/src/TypeWhisper.Linux/Services/AppVersion.cs b/src/TypeWhisper.Linux/Services/AppVersion.cs index bcf5ea5eb..3ae5c5309 100644 --- a/src/TypeWhisper.Linux/Services/AppVersion.cs +++ b/src/TypeWhisper.Linux/Services/AppVersion.cs @@ -211,6 +211,7 @@ private static int CompareStrict(StrictSemanticVersion a, StrictSemanticVersion return core; } + // ReSharper disable once ConvertIfStatementToSwitchStatement -- guards over two different operands (a and b), not one switchable expression. if (a.PreRelease.Count == 0 && b.PreRelease.Count == 0) { return 0; @@ -268,6 +269,7 @@ private static bool AreValidIdentifiers(string value, bool rejectNumericLeadingZ return false; } + // ReSharper disable once LoopCanBeConvertedToQuery -- the ten-line multi-clause predicate reads worse inlined into an All() lambda. foreach (var identifier in value.Split('.')) { if ( diff --git a/src/TypeWhisper.Linux/Services/BundledPluginDeployer.cs b/src/TypeWhisper.Linux/Services/BundledPluginDeployer.cs index dc7302bd4..f6427ba31 100644 --- a/src/TypeWhisper.Linux/Services/BundledPluginDeployer.cs +++ b/src/TypeWhisper.Linux/Services/BundledPluginDeployer.cs @@ -22,6 +22,7 @@ public sealed class BundledPluginDeployer public static int DeployIfMissing() { var source = FindBundledPluginsDir(); + // ReSharper disable once InvertIf -- already the early-return guard; inverting would nest the happy path. if (source is null) { Trace.WriteLine( @@ -234,6 +235,7 @@ var abandonedStage in Directory Directory.Delete(abandonedStage, recursive: true); } + // ReSharper disable once InvertIf -- trailing block of a void method; inverting would only add a bare `return`. if (Directory.Exists(backup)) { if (Directory.Exists(dest)) diff --git a/src/TypeWhisper.Linux/Services/MemoryService.cs b/src/TypeWhisper.Linux/Services/MemoryService.cs index c8ad099f6..2a8a25619 100644 --- a/src/TypeWhisper.Linux/Services/MemoryService.cs +++ b/src/TypeWhisper.Linux/Services/MemoryService.cs @@ -126,7 +126,9 @@ string userPrompt } var providerId = provider.GetLlmSelectionId(); - var plugin = _pluginManager.GetPlugin(providerId); + // Look the plugin up by its owning plugin ID: a profile-backed role's + // selection ID is the profile's, which matches no manifest ID. + var plugin = _pluginManager.GetPlugin(provider.PluginId); var ranLocally = plugin?.Metadata.RanLocally ?? false; var provenance = new LlmCallProvenance diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginEventBus.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginEventBus.cs index 8e9e7f1d3..b7f01cffc 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginEventBus.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginEventBus.cs @@ -70,6 +70,7 @@ public IDisposable Subscribe(Func handler) where T : PluginEvent { var eventType = typeof(T); + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- declared immediately above its only use, which is the next line. Task WrappedHandler(object obj) => handler((T)obj); var subscription = new Subscription(this, eventType, WrappedHandler); diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs index bca635790..a027fbef0 100644 --- a/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs +++ b/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs @@ -319,6 +319,7 @@ internal static PluginMetadataDescriptor ResolveMetadata(PluginManifest manifest ); } + // ReSharper disable once InvertIf -- optional-section validation; more checks follow, so there is no early exit. if (categories is not null) { if ( @@ -364,11 +365,6 @@ private static PluginCategory InferLegacyCategory(PluginManifest manifest) return PluginCategory.Memory; } - if (s_legacyUtilityPluginIds.Contains(id)) - { - return PluginCategory.Utility; - } - var combined = $"{manifest.Name} {manifest.Description}".ToLowerInvariant(); if ( combined.Contains("transcription") @@ -463,10 +459,4 @@ private static PluginCategory InferLegacyCategory(PluginManifest manifest) "com.typewhisper.file-memory", "com.typewhisper.openai-vector-memory", }.ToFrozenSet(StringComparer.Ordinal); - - private static readonly FrozenSet s_legacyUtilityPluginIds = - new[] - { - "com.typewhisper.openai-compatible", - }.ToFrozenSet(StringComparer.Ordinal); } diff --git a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs index c076df643..3596cc62f 100644 --- a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs +++ b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs @@ -216,7 +216,9 @@ public async Task ProcessSystemPromptAsync( } var providerId = provider.GetLlmSelectionId(); - var plugin = _pluginManager.GetPlugin(providerId); + // Look the plugin up by its owning plugin ID: a profile-backed role's + // selection ID is the profile's, which matches no manifest ID. + var plugin = _pluginManager.GetPlugin(provider.PluginId); var ranLocally = plugin?.Metadata.RanLocally ?? false; var provenance = new LlmCallProvenance diff --git a/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs b/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs index ed00ccd02..03d8c41d3 100644 --- a/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs +++ b/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs @@ -354,6 +354,7 @@ private static bool MatchesTargetIdentity( } } + // ReSharper disable once InvertIf -- the block also sets hasAppIdentity, so inverting would skip that side effect. if (!string.IsNullOrWhiteSpace(target.ProcessName)) { hasAppIdentity = true; diff --git a/src/TypeWhisper.Linux/Services/TranslationService.cs b/src/TypeWhisper.Linux/Services/TranslationService.cs index b8c9b4ece..d97a3a0a5 100644 --- a/src/TypeWhisper.Linux/Services/TranslationService.cs +++ b/src/TypeWhisper.Linux/Services/TranslationService.cs @@ -101,7 +101,9 @@ string userPrompt } var providerId = provider.GetLlmSelectionId(); - var plugin = _pluginManager.GetPlugin(providerId); + // Look the plugin up by its owning plugin ID: a profile-backed role's + // selection ID is the profile's, which matches no manifest ID. + var plugin = _pluginManager.GetPlugin(provider.PluginId); var ranLocally = plugin?.Metadata.RanLocally ?? false; var provenance = new LlmCallProvenance diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs index f1387718d..37517907e 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs @@ -1,3 +1,5 @@ +// ReSharper disable ArrangeObjectCreationWhenTypeNotEvident -- target-typed `new(...)` inside collection +// expressions and record construction is the prevailing style across this codebase. using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using System.Collections.ObjectModel; diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs index d46e1309f..35266a032 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs @@ -1,3 +1,5 @@ +// ReSharper disable ArrangeObjectCreationWhenTypeNotEvident -- target-typed `new(...)` inside collection +// expressions and record construction is the prevailing style across this codebase. using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs index 698e0b093..2fdd3803a 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PluginsSectionViewModel.cs @@ -416,6 +416,7 @@ await provider.SetSettingValueAsync(field.Key, field.Value, ct) return true; } ); + // ReSharper disable once InvertIf -- already the failure guard; inverting would nest the success path. if (!setResult.IsSuccess) { row.Status = Loc.Instance["Plugins.SettingsSaveFailed"]; @@ -652,7 +653,7 @@ SettingsReloadKind reloadKind } } - private void MarkSettingsLoadFailed(PluginRow row, bool preserveStatus = false) + private static void MarkSettingsLoadFailed(PluginRow row, bool preserveStatus = false) { row.SettingFields.Clear(); row.Collections.Clear(); @@ -940,6 +941,7 @@ internal void CaptureSettingsBaseline() private PluginSettingsDraftEntry[] CaptureSettingsDraft() { var entries = new List(); + // ReSharper disable once LoopCanBeConvertedToQuery -- index-carrying accumulation into a list this method fills from several loops. for (var fieldIndex = 0; fieldIndex < SettingFields.Count; fieldIndex++) { var field = SettingFields[fieldIndex]; @@ -974,6 +976,7 @@ private PluginSettingsDraftEntry[] CaptureSettingsDraft() for (var itemIndex = 0; itemIndex < collection.Items.Count; itemIndex++) { var item = collection.Items[itemIndex]; + // ReSharper disable once LoopCanBeConvertedToQuery -- index-carrying accumulation into a list this method fills from several loops. for (var fieldIndex = 0; fieldIndex < item.Fields.Count; fieldIndex++) { var field = item.Fields[fieldIndex]; diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs index 9d940047e..e12d1f79b 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs @@ -1,3 +1,5 @@ +// ReSharper disable ArrangeObjectCreationWhenTypeNotEvident -- target-typed `new(...)` inside collection +// expressions and record construction is the prevailing style across this codebase. using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; diff --git a/src/TypeWhisper.PluginSDK/PluginSelectionExtensions.cs b/src/TypeWhisper.PluginSDK/PluginSelectionExtensions.cs index d819ee7e3..f1a1a7fd5 100644 --- a/src/TypeWhisper.PluginSDK/PluginSelectionExtensions.cs +++ b/src/TypeWhisper.PluginSDK/PluginSelectionExtensions.cs @@ -71,6 +71,7 @@ public static bool IsValidSelectionId(string? selectionId) return false; } + // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator -- LINQ would box the string's struct enumerator on this validation path. foreach (var character in selectionId) { if ( diff --git a/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs index d7b7ffbb5..6ddea76db 100644 --- a/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AdvancedSectionViewModelTests.cs @@ -408,6 +408,7 @@ public async Task LanguageChange_RebuildsLocalizedOptions_PreservesSelectionWith var selectedVoiceIdBefore = harness.ViewModel.SelectedSpokenFeedbackVoiceId; harness.ViewModel.PropertyChanged += (_, args) => { + // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here. if (args.PropertyName == nameof(AdvancedSectionViewModel.AutoUnloadOptions)) { // ReSharper disable once AccessToDisposedClosure -- handler runs synchronously while setting Loc.Instance.CurrentLanguage below, before the using disposes harness at scope end. diff --git a/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs b/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs index 38ea18b7e..eb75bbc6a 100644 --- a/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs +++ b/tests/TypeWhisper.Linux.Tests/AppBootstrapTests.cs @@ -220,6 +220,7 @@ public async Task RunAsync_DependencyChainFailure_SkipsTransitiveDependentsWithR Assert.Equal(App.BootstrapStageStatus.Skipped, Outcome("C").Status); Assert.Equal("B", Outcome("C").SkippedDueTo); + // ReSharper disable once SeparateLocalFunctionsWithJumpStatement -- a bare trailing `return;` before the local function would be noise. App.BootstrapStageOutcome Outcome(string name) { return Assert.Single(report.Outcomes, outcome => outcome.Name == name); diff --git a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs index fced110c8..9807dbbc7 100644 --- a/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs +++ b/tests/TypeWhisper.Linux.Tests/LocalizationResourcesTests.cs @@ -255,13 +255,24 @@ string unknown { var catalog = Load(language); - Assert.Equal(local, catalog["Plugins.BadgeLocal"]); - Assert.Equal(network, catalog["Plugins.BadgeCloud"]); - Assert.Equal(mixed, catalog["Plugins.BadgeMixed"]); - Assert.Equal(userControlled, catalog["Plugins.BadgeUserControlled"]); - Assert.Equal(tts, catalog["Plugins.CategoryTts"]); - Assert.Equal(integration, catalog["Plugins.CategoryIntegration"]); - Assert.Equal(unknown, catalog["Plugins.CategoryUnknown"]); + AssertCatalogValue(catalog, language, "Plugins.BadgeLocal", local); + AssertCatalogValue(catalog, language, "Plugins.BadgeCloud", network); + AssertCatalogValue(catalog, language, "Plugins.BadgeMixed", mixed); + AssertCatalogValue(catalog, language, "Plugins.BadgeUserControlled", userControlled); + AssertCatalogValue(catalog, language, "Plugins.CategoryTts", tts); + AssertCatalogValue(catalog, language, "Plugins.CategoryIntegration", integration); + AssertCatalogValue(catalog, language, "Plugins.CategoryUnknown", unknown); + } + + private static void AssertCatalogValue( + Dictionary catalog, + string language, + string key, + string expected + ) + { + Assert.True(catalog.TryGetValue(key, out var actual), $"Missing {language} key: {key}"); + Assert.Equal(expected, actual); } [Theory] diff --git a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs index 832f4526d..00ecfcfdb 100644 --- a/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/PluginCollectionSettingsViewModelTests.cs @@ -1099,6 +1099,7 @@ public IReadOnlyList GetSettingDefinitions() CancellationToken ct = default ) { + // ReSharper disable once ConvertIfStatementToReturnStatement -- the guard throws; folding it into a ternary would need a throw expression. if (ThrowOnGetValue) { throw new InvalidOperationException("setting getter exploded"); @@ -1113,6 +1114,7 @@ public Task SetSettingValueAsync( CancellationToken ct = default ) { + // ReSharper disable once ConvertIfStatementToReturnStatement -- the guard throws; folding it into a ternary would need a throw expression. if (ThrowOnSetValue) { throw new InvalidOperationException("setting setter exploded"); diff --git a/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs b/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs index 4b9353f3e..e7e4736c6 100644 --- a/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs +++ b/tests/TypeWhisper.Linux.Tests/ProfilesSectionViewModelTests.cs @@ -132,6 +132,7 @@ public void LanguageChange_RebuildsLocalizedOptions_PreservesSelectionsWithoutPe sut.SelectedCleanupOverrideOption = null; sut.PropertyChanged += (_, args) => { + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (args.PropertyName == nameof(ProfilesSectionViewModel.WhisperModeOptions)) { sut.SelectedWhisperModeOption = null; diff --git a/tests/TypeWhisper.Linux.Tests/StartupServiceTests.cs b/tests/TypeWhisper.Linux.Tests/StartupServiceTests.cs index bf3a5d049..d4e91b188 100644 --- a/tests/TypeWhisper.Linux.Tests/StartupServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/StartupServiceTests.cs @@ -135,7 +135,7 @@ public void Customized_legacy_entry_is_unowned_and_preserved_by_both_operations( "name=typewhisper", StringComparison.Ordinal ), - legacyContent + "\n" + legacyContent + "\n", }; foreach (var customizedContent in customizedContents) @@ -245,7 +245,7 @@ public void IsEnabled_requires_exact_marker_line_or_exact_legacy_content() $"[Desktop Entry]\n#{ManagedLine}", $"[Desktop Entry]\nPrefix-{ManagedLine}", $"[Desktop Entry]\n{ManagedLine}-extra", - "[Desktop Entry]\nX-TypeWhisper-Managed=false" + "[Desktop Entry]\nX-TypeWhisper-Managed=false", }; foreach (var markerLookalike in markerLookalikes) { diff --git a/tests/TypeWhisper.Linux.Tests/UiOperationGuardTests.cs b/tests/TypeWhisper.Linux.Tests/UiOperationGuardTests.cs index b9db94fca..d7ed932f7 100644 --- a/tests/TypeWhisper.Linux.Tests/UiOperationGuardTests.cs +++ b/tests/TypeWhisper.Linux.Tests/UiOperationGuardTests.cs @@ -64,12 +64,16 @@ public static TheoryData ExpectedFailures() }; } + // Exception is not IXunitSerializable, so Test Explorer cannot pre-enumerate the + // cases. They still all run, and the concrete instances are the point of the data. +#pragma warning disable xUnit1045 [Theory] [MemberData(nameof(ExpectedFailures))] public async Task RunAsync_ExpectedFailure_RollsBackThenPresentsAndLogs( UiFailureKind failureKind, Exception failure ) +#pragma warning restore xUnit1045 { var events = new List(); var errorLog = new Mock(); diff --git a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs index c2318fa97..32c1d1c31 100644 --- a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs @@ -242,16 +242,18 @@ public async Task ProcessFile_WhenFingerprintCommitFails_RecordsFailureAndRollsB } failures.Enqueue(item); - if (failures.Count == 1) + switch (failures.Count) { - firstFailure.TrySetResult(item); - } - else if (failures.Count == 2) - { - secondFailure.TrySetResult(item); + case 1: + firstFailure.TrySetResult(item); + break; + case 2: + secondFailure.TrySetResult(item); + break; } }; + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- kept beside the queue it captures, ahead of the Start call that uses it. Task TranscribeAndCountAsync( WatchFolderTranscriptionRequest request, CancellationToken ct @@ -763,6 +765,7 @@ public async Task FailedFingerprint_ForCaseDistinctPath_DoesNotSuppressOtherFile WatchFolderService.WatchFolderRun? run = null; service.FileProcessed += (_, item) => { + // ReSharper disable once ConvertIfStatementToSwitchStatement -- compound conditions over several members, not one switchable expression. if ( !item.Success && string.Equals(item.FileName, "Meeting.wav", StringComparison.Ordinal) @@ -786,6 +789,7 @@ public async Task FailedFingerprint_ForCaseDistinctPath_DoesNotSuppressOtherFile (request, ct) => { ct.ThrowIfCancellationRequested(); + // ReSharper disable once ConvertIfStatementToReturnStatement -- the guard throws; folding it into a ternary would need a throw expression. if ( string.Equals( Path.GetFileName(request.FilePath), @@ -958,6 +962,7 @@ public async Task ProviderCancellation_WithLiveRun_RecordsFailureAndContinuesQue { var fileName = Path.GetFileName(request.FilePath); calls.Enqueue(fileName); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (fileName == "a-timeout.wav") { timeoutEntered.TrySetResult(ct); @@ -1121,6 +1126,7 @@ public async Task QueueWorkerFault_IsObservedAndMarksCurrentRunUnhealthy() service.StateChanged += (_, _) => { // ReSharper disable once AccessToModifiedClosure -- the handler deliberately reads the current `run` (assigned after Start) to correlate the transition with the active run. + // ReSharper disable once InlineTemporaryVariable -- the local is a deliberate snapshot of the mutable `run` capture; inlining would defeat it. var observedRun = run; if ( observedRun is not null @@ -1147,7 +1153,7 @@ observedRun is not null Assert.Null(service.WatchPath); Assert.Null(service.CurrentlyProcessing); Assert.True(run.CancellationSource.IsCancellationRequested); - Assert.IsAssignableFrom(run.WorkerFailure); + Assert.IsType(run.WorkerFailure, exactMatch: false); Assert.True(run.WorkerCompletion.IsCompletedSuccessfully); Assert.Empty(service.History); Assert.Empty(processed); diff --git a/tests/TypeWhisper.PluginSystem.Tests/CloudflareAsrPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/CloudflareAsrPluginTests.cs index 380fc9846..3198ca423 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/CloudflareAsrPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/CloudflareAsrPluginTests.cs @@ -112,6 +112,7 @@ public async Task TranscribeAsync_AcceptsExplicitEmptyResultText() } [Theory] + // ReSharper disable once RawStringCanBeSimplified -- kept raw so every InlineData in this theory has the same form. [InlineData("""{}""")] [InlineData("""{ "result": null }""")] [InlineData("""{ "result": {} }""")] diff --git a/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs b/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs index b299c2f7b..b6711f689 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/CudaRuntimeProvisionerTests.cs @@ -804,6 +804,7 @@ CancellationToken cancellationToken return new HttpResponseMessage(HttpStatusCode.NotFound); var wheelRequest = Interlocked.Increment(ref _wheel); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (_pauseFirstWheelResponse && wheelRequest == 1) { _firstWheelRequestStarted.TrySetResult(true); diff --git a/tests/TypeWhisper.PluginSystem.Tests/GladiaPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/GladiaPluginTests.cs index d1ab9815f..9b8566440 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/GladiaPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/GladiaPluginTests.cs @@ -573,6 +573,14 @@ public async Task TranscribeAsync_HttpErrorAtEachStageFails(string failingStage) ); } + // A failed poll leaves the job on Gladia's servers, so cleanup runs + // for it too — the DELETE is part of the expected request sequence. + if (request.Method == HttpMethod.Delete) + { + Assert.Equal("poll", failingStage); + return JsonResponse("{}", HttpStatusCode.Accepted); + } + throw new InvalidOperationException( $"Unexpected request: {request.Method} {request.RequestUri}" ); @@ -605,7 +613,7 @@ public async Task TranscribeAsync_HttpErrorAtEachStageFails(string failingStage) { "upload" => 1, "initiate" => 2, - _ => 3, + _ => 4, }, requestCount ); @@ -642,6 +650,7 @@ public async Task TranscribeAsync_NonObjectJsonErrorBodyStillReportsHttpError(st } [Theory] + // ReSharper disable once RawStringCanBeSimplified -- kept raw so every InlineData in this theory has the same form. [InlineData("""{}""")] [InlineData("""{ "status": 17 }""")] [InlineData("""{ "status": "paused" }""")] @@ -668,7 +677,12 @@ public async Task TranscribeAsync_MalformedOrUnknownPollStatusFails(string pollR ); }); - using var sut = await CreateConfiguredPluginAsync(handler); + // Without a bound, a regression that treated these statuses as non-terminal + // would spin against the zero poll delay for the 30-minute default. + using var sut = await CreateConfiguredPluginAsync( + handler, + pollWindow: TimeSpan.FromSeconds(2) + ); var exception = await Assert.ThrowsAsync( () => sut.TranscribeAsync( @@ -821,7 +835,9 @@ public async Task TranscribeAsync_CancellationDuringPollingIsObserved() cts.Cancel(); await Assert.ThrowsAnyAsync(() => transcription); - Assert.Equal(0, deleteCount); + // The job exists on Gladia's servers holding the user's audio, so a + // cancelled dictation still deletes it. + Assert.Equal(1, deleteCount); } [Fact] diff --git a/tests/TypeWhisper.PluginSystem.Tests/GoogleCloudSttPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/GoogleCloudSttPluginTests.cs index 26d47644d..0e7ee8fae 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/GoogleCloudSttPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/GoogleCloudSttPluginTests.cs @@ -65,7 +65,7 @@ await sut.TranscribeAsync( ); Assert.Equal(2, handler.Requests.Count); - var expectedFirstChunkBytes = quietWindowStart + QuietWindowBytes / 2; + const int expectedFirstChunkBytes = quietWindowStart + QuietWindowBytes / 2; Assert.Equal(expectedFirstChunkBytes, handler.Requests[0].Audio.Length); Assert.Equal(audioBytes - expectedFirstChunkBytes, handler.Requests[1].Audio.Length); Assert.All(handler.Requests, request => @@ -378,6 +378,7 @@ private static byte[] BuildFfmpegStyleWav(int dataBytes, short amplitude = 0) BinaryPrimitives.WriteUInt32LittleEndian(span[(offset + 4)..], 0xFFFFFFFF); offset += 8; + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (amplitude != 0) { for (var sampleOffset = offset; sampleOffset < buffer.Length; sampleOffset += 2) @@ -418,7 +419,7 @@ CancellationToken cancellationToken ) { var body = await Assert - .IsAssignableFrom(request.Content) + .IsType(request.Content, exactMatch: false) .ReadAsStringAsync(cancellationToken); using var json = JsonDocument.Parse(body); var root = json.RootElement; diff --git a/tests/TypeWhisper.PluginSystem.Tests/ObsidianPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/ObsidianPluginTests.cs index c3b893988..799e52327 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/ObsidianPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/ObsidianPluginTests.cs @@ -121,12 +121,12 @@ public async Task IndividualSave_FailedNewlyOwnedWrite_DeletesPartialFile() ObsidianPlugin.WriteIndividualNoteAsync( notePath, "complete content", - CancellationToken.None, async (stream, _, ct) => { await stream.WriteAsync("partial"u8.ToArray(), ct); throw new IOException("Injected write failure."); - } + }, + CancellationToken.None ) ); diff --git a/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs index b3673a478..876d06346 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/OpenAiPluginTests.cs @@ -557,6 +557,7 @@ public async Task ConcurrentChatGptRequests_RefreshOnceAndUseOneCoherentCredenti { // ReSharper disable once AccessToModifiedClosure -- intentional shared counter across handler invocations; Interlocked.Increment coordinates the concurrent-refresh dedup this test asserts. var refreshNumber = Interlocked.Increment(ref tokenPostCount); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (refreshNumber == 1) { firstRefreshStarted.TrySetResult(true); @@ -633,6 +634,7 @@ public async Task ChatGptRefresh_FailureReleasesCredentialGateForWaitingRequest( // ReSharper disable once AccessToModifiedClosure -- intentional shared counter across handler invocations; Interlocked.Increment coordinates the concurrent-refresh dedup this test asserts. var refreshNumber = Interlocked.Increment(ref tokenPostCount); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (refreshNumber == 1) { firstRefreshStarted.TrySetResult(true); @@ -704,6 +706,7 @@ public async Task ChatGptRefresh_CancellationReleasesCredentialGateForWaitingReq // ReSharper disable once AccessToModifiedClosure -- intentional shared counter across handler invocations; Interlocked.Increment coordinates the concurrent-refresh dedup this test asserts. var refreshNumber = Interlocked.Increment(ref tokenPostCount); + // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is. if (refreshNumber == 1) { firstRefreshStarted.TrySetResult(true); @@ -1626,6 +1629,7 @@ private static async Task WaitForTranscriptAsync( var received = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); + // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- kept beside the completion source it captures, ahead of the subscribe below. void OnTranscript(StreamingTranscriptEvent transcriptEvent) { if (transcriptEvent == expected) @@ -1769,7 +1773,9 @@ private sealed class FakeRealtimeWebSocket : WebSocket private WebSocketCloseStatus? _closeStatus; private string? _closeStatusDescription; + // ReSharper disable once ConvertToAutoPropertyWithPrivateSetter -- field-backed to match the Volatile-read siblings in this fake; an auto-property would hide the cross-thread access. public override WebSocketCloseStatus? CloseStatus => _closeStatus; + // ReSharper disable once ConvertToAutoPropertyWithPrivateSetter -- field-backed to match the Volatile-read siblings in this fake; an auto-property would hide the cross-thread access. public override string? CloseStatusDescription => _closeStatusDescription; public override WebSocketState State => (WebSocketState)Volatile.Read(ref _state); public override string? SubProtocol => null; diff --git a/tests/TypeWhisper.PluginSystem.Tests/PluginEventBusTests.cs b/tests/TypeWhisper.PluginSystem.Tests/PluginEventBusTests.cs index 743dd9d29..b83b887d2 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/PluginEventBusTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/PluginEventBusTests.cs @@ -353,6 +353,7 @@ public async Task Publish_CoalescesLatestByType_WithoutDroppingDurableEvent() received.Add(description); } + // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here. if ( pluginEvent is PartialTranscriptionUpdateEvent @@ -664,6 +665,7 @@ public async Task Publish_QueuedTerminalFrame_SurvivesBurstOfLaterNonTerminalSam received.Add(pluginEvent.AccumulatedText); } + // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here. if (pluginEvent.AccumulatedText == "gate") { firstEntered.TrySetResult(true); diff --git a/tests/TypeWhisper.PluginSystem.Tests/Qwen3SttPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/Qwen3SttPluginTests.cs index 8e2cc95c1..2678e5a43 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/Qwen3SttPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/Qwen3SttPluginTests.cs @@ -133,6 +133,7 @@ public async Task TranscribeAsync_PostsOpenAiMultipartRequestAndParsesVerboseJso } [Theory] + // ReSharper disable once RawStringCanBeSimplified -- kept raw so every InlineData in this theory has the same form. [InlineData("""{}""")] [InlineData("""{ "text": null }""")] [InlineData("""{ "text": 42 }""")] diff --git a/tests/TypeWhisper.PluginSystem.Tests/SherpaOnnxCancellationTests.cs b/tests/TypeWhisper.PluginSystem.Tests/SherpaOnnxCancellationTests.cs index f49676794..1e1c4c5f7 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/SherpaOnnxCancellationTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/SherpaOnnxCancellationTests.cs @@ -166,6 +166,31 @@ public void Decode_CanaryChunkOverlap_StitchesWithoutLossOrDuplication() Assert.Empty(payloads); } + // A non-string "text"/"lang" must fall back to the raw payload rather than + // throwing InvalidOperationException out of JsonElement.GetString(). + [Theory] + [InlineData("""{"text":42,"lang":"en"}""", """{"text":42,"lang":"en"}""", "en")] + [InlineData("""{"text":true,"lang":"en"}""", """{"text":true,"lang":"en"}""", "en")] + [InlineData("""{"text":"hello","lang":42}""", "hello", null)] + [InlineData("""{"text":"hello","lang":false}""", "hello", null)] + public void Decode_CanaryPayloadWithNonStringFields_FallsBackWithoutThrowing( + string payload, + string expectedText, + string? expectedLanguage + ) + { + var coordinator = new SherpaDecodeCoordinator(_ => payload); + + var result = coordinator.Decode( + new float[16], + parseCanaryPayload: true, + CancellationToken.None + ); + + Assert.Equal(expectedText, result.Text); + Assert.Equal(expectedLanguage, result.DetectedLanguage); + } + private static Mock CreateHost(string assetDirectory) { var host = new Mock(); diff --git a/tests/TypeWhisper.PluginSystem.Tests/StreamingProviderFailurePropagationTests.cs b/tests/TypeWhisper.PluginSystem.Tests/StreamingProviderFailurePropagationTests.cs index 272391912..d52f67ccf 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/StreamingProviderFailurePropagationTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/StreamingProviderFailurePropagationTests.cs @@ -27,7 +27,17 @@ public async Task AssemblyAi_OneFinalThenTransportFault_SendAndFinalizeRethrow() ); await finalReceived.Task.WaitAsync(s_testTimeout); socket.EnqueueFault(new WebSocketException("AssemblyAI transport failed.")); - await socket.LastReceiveConsumed.WaitAsync(s_testTimeout); + + // FinalizeAsync cannot return until the fault is captured, but its own + // terminal send may lose the race to the abort and surface the raw transport + // error. Drive it once to land the fault, then assert the session-visible type. + var driveFault = await Record.ExceptionAsync( + () => session.FinalizeAsync(CancellationToken.None).WaitAsync(s_testTimeout) + ); + Assert.NotNull(driveFault); + // A regression that never completes the terminal waiter must fail here, + // not hang, and its timeout must not pass for the provider fault. + Assert.IsNotType(driveFault); await Assert.ThrowsAsync( () => session.SendAudioAsync(new byte[1600], CancellationToken.None) @@ -160,7 +170,17 @@ public async Task Deepgram_OneFinalThenTransportFault_SendAndFinalizeRethrow() socket.EnqueueText(DeepgramResult("A complete prefix.", isFinal: true)); await finalReceived.Task.WaitAsync(s_testTimeout); socket.EnqueueFault(new WebSocketException("Deepgram transport failed.")); - await socket.LastReceiveConsumed.WaitAsync(s_testTimeout); + + // FinalizeAsync cannot return until the fault is captured, but its own + // terminal send may lose the race to the abort and surface the raw transport + // error. Drive it once to land the fault, then assert the session-visible type. + var driveFault = await Record.ExceptionAsync( + () => session.FinalizeAsync(CancellationToken.None).WaitAsync(s_testTimeout) + ); + Assert.NotNull(driveFault); + // A regression that never completes the terminal waiter must fail here, + // not hang, and its timeout must not pass for the provider fault. + Assert.IsNotType(driveFault); await Assert.ThrowsAsync( () => session.SendAudioAsync(new byte[] { 1, 2 }, CancellationToken.None) @@ -285,7 +305,17 @@ public async Task ElevenLabs_OneFinalThenTransportFault_SendAndFinalizeRethrow() ); await finalReceived.Task.WaitAsync(s_testTimeout); socket.EnqueueFault(new WebSocketException("ElevenLabs transport failed.")); - await socket.LastReceiveConsumed.WaitAsync(s_testTimeout); + + // FinalizeAsync cannot return until the fault is captured, but its own + // terminal send may lose the race to the abort and surface the raw transport + // error. Drive it once to land the fault, then assert the session-visible type. + var driveFault = await Record.ExceptionAsync( + () => session.FinalizeAsync(CancellationToken.None).WaitAsync(s_testTimeout) + ); + Assert.NotNull(driveFault); + // A regression that never completes the terminal waiter must fail here, + // not hang, and its timeout must not pass for the provider fault. + Assert.IsNotType(driveFault); await Assert.ThrowsAsync( () => session.SendAudioAsync(new byte[3200], CancellationToken.None) @@ -512,16 +542,28 @@ private sealed class FakeWebSocket : WebSocket Channel.CreateUnbounded(); private readonly Channel _sends = Channel.CreateUnbounded(); - private TaskCompletionSource _lastReceiveConsumed = - new(TaskCreationOptions.RunContinuationsAsynchronously); + // Guards the mutable state below: it is written on the session's receive + // and teardown paths and read from the test thread. Volatile.Read cannot + // be used here — the fields are enums and structs, not reference types. + private readonly Lock _stateLock = new(); private WebSocketState _state = WebSocketState.Open; private WebSocketCloseStatus? _closeStatus; private string? _closeDescription; - public Task LastReceiveConsumed => _lastReceiveConsumed.Task; - public override WebSocketCloseStatus? CloseStatus => _closeStatus; - public override string? CloseStatusDescription => _closeDescription; - public override WebSocketState State => _state; + public override WebSocketCloseStatus? CloseStatus + { + get { lock (_stateLock) { return _closeStatus; } } + } + + public override string? CloseStatusDescription + { + get { lock (_stateLock) { return _closeDescription; } } + } + + public override WebSocketState State + { + get { lock (_stateLock) { return _state; } } + } public override string? SubProtocol => null; public void EnqueueText(string json) => @@ -551,17 +593,15 @@ public void EnqueueFault(Exception exception) => public async Task NextSentAsync() => await _sends.Reader.ReadAsync().AsTask().WaitAsync(s_testTimeout); - private void Enqueue(ReceiveItem item) - { - _lastReceiveConsumed = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously - ); + private void Enqueue(ReceiveItem item) => Assert.True(_receives.Writer.TryWrite(item)); - } public override void Abort() { - _state = WebSocketState.Aborted; + lock (_stateLock) + { + _state = WebSocketState.Aborted; + } } public override Task CloseAsync( @@ -571,9 +611,7 @@ CancellationToken cancellationToken ) { cancellationToken.ThrowIfCancellationRequested(); - _closeStatus = closeStatus; - _closeDescription = statusDescription; - _state = WebSocketState.Closed; + SetClosed(closeStatus, statusDescription, WebSocketState.Closed); return Task.CompletedTask; } @@ -584,15 +622,31 @@ CancellationToken cancellationToken ) { cancellationToken.ThrowIfCancellationRequested(); - _closeStatus = closeStatus; - _closeDescription = statusDescription; - _state = WebSocketState.CloseSent; + SetClosed(closeStatus, statusDescription, WebSocketState.CloseSent); return Task.CompletedTask; } + private void SetClosed( + WebSocketCloseStatus? closeStatus, + string? closeDescription, + WebSocketState state + ) + { + lock (_stateLock) + { + _closeStatus = closeStatus; + _closeDescription = closeDescription; + _state = state; + } + } + public override void Dispose() { - _state = WebSocketState.Closed; + lock (_stateLock) + { + _state = WebSocketState.Closed; + } + _receives.Writer.TryComplete(); _sends.Writer.TryComplete(); } @@ -603,20 +657,21 @@ CancellationToken cancellationToken ) { var item = await _receives.Reader.ReadAsync(cancellationToken); - _lastReceiveConsumed.TrySetResult(); if (item is ReceiveItem.Fault fault) { - _state = WebSocketState.Aborted; + Abort(); ExceptionDispatchInfo.Capture(fault.Exception).Throw(); } var frame = Assert.IsType(item); if (frame.MessageType == WebSocketMessageType.Close) { - _closeStatus = frame.CloseStatus; - _closeDescription = frame.CloseDescription; - _state = WebSocketState.CloseReceived; + SetClosed( + frame.CloseStatus, + frame.CloseDescription, + WebSocketState.CloseReceived + ); return new WebSocketReceiveResult( 0, WebSocketMessageType.Close, @@ -646,7 +701,7 @@ CancellationToken cancellationToken ) { cancellationToken.ThrowIfCancellationRequested(); - if (_state != WebSocketState.Open) + if (State != WebSocketState.Open) throw new WebSocketException("The fake WebSocket is not open."); Assert.True(endOfMessage); diff --git a/tests/TypeWhisper.PluginSystem.Tests/VoxtralPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/VoxtralPluginTests.cs index 151b636a0..2d230109e 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/VoxtralPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/VoxtralPluginTests.cs @@ -221,6 +221,7 @@ public async Task TranscribeAsync_AcceptsExplicitEmptyText() } [Theory] + // ReSharper disable once RawStringCanBeSimplified -- kept raw so every InlineData in this theory has the same form. [InlineData("""{}""")] [InlineData("""{ "text": null }""")] [InlineData("""{ "text": 42 }""")] diff --git a/tests/TypeWhisper.PluginSystem.Tests/WebhookCollectionSettingsTests.cs b/tests/TypeWhisper.PluginSystem.Tests/WebhookCollectionSettingsTests.cs index 8c4006317..71530cbac 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/WebhookCollectionSettingsTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/WebhookCollectionSettingsTests.cs @@ -576,7 +576,7 @@ public async Task ActivateAsync_MigratesLegacyPlaintextHeadersAndSecuresConfigFi } ); await File.WriteAllTextAsync(ConfigPath, legacyJson); - var expectedMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + const UnixFileMode expectedMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; if (!OperatingSystem.IsWindows()) { File.SetUnixFileMode( diff --git a/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs index 560035670..3b35b7cd0 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/WhisperCppPluginTests.cs @@ -794,12 +794,12 @@ public void ArtifactPreflight_CanaryTokensWithoutBlank_AcceptedButStillStructura // Canary (attention encoder-decoder) has no blank token; preflight must accept // it, or a blank requirement would fail every Canary download and delete caches. - plugin.RunArtifactPreflightForTests("canary-180m-flash", dir); + SherpaOnnxPlugin.RunArtifactPreflightForTests("canary-180m-flash", dir); // The blank exemption must not switch off the remaining token checks. File.WriteAllText(Path.Join(dir, "tokens.txt"), " 0\n not-an-id\n"); Assert.Throws( - () => plugin.RunArtifactPreflightForTests("canary-180m-flash", dir) + () => SherpaOnnxPlugin.RunArtifactPreflightForTests("canary-180m-flash", dir) ); } diff --git a/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs b/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs index 6d040bbf0..d3d82d841 100644 --- a/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs +++ b/tests/TypeWhisper.PluginSystem.Tests/XaiPluginTests.cs @@ -1078,8 +1078,11 @@ public IReadOnlyList SentFrames public Task ReceiveExited => _receiveExited.Task; public bool AbortCalled { get; private set; } public bool DisposeCalled { get; private set; } + // ReSharper disable once ConvertToAutoPropertyWithPrivateSetter -- field-backed to match the other mutable state in this fake socket. public override WebSocketCloseStatus? CloseStatus => _closeStatus; + // ReSharper disable once ConvertToAutoPropertyWithPrivateSetter -- field-backed to match the other mutable state in this fake socket. public override string? CloseStatusDescription => _closeDescription; + // ReSharper disable once ConvertToAutoPropertyWithPrivateSetter -- field-backed to match the other mutable state in this fake socket. public override WebSocketState State => _state; public override string? SubProtocol => null; From 2524c9d34b36e2ef9180cfe6d22716b9db8b0ea0 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 3 Aug 2026 17:47:36 -0400 Subject: [PATCH 223/226] Reconnect the default-device watcher when its subscription ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pactl exits whenever the sound server does, and AudioRecordingService latches _watcherStarted, so a PipeWire/PulseAudio restart silently ended live default-device following for the rest of the session — the read loop already cleared its state for a restart nobody ever triggered. The watcher now reconnects itself. A run that survived reconnects at once; one that died young backs off (1s doubling to 16s) because the first attempt after a restart routinely lands before the server is listening again, bounded by MaxAutoRestarts so a permanently broken server cannot respawn pactl forever. One task owns the backoff sequence, so a failed launch — which starts no read loop — cannot silently end recovery. Each reconnect signals the dispatcher once: pactl does not replay events, so a default that changed while the subscription was down would otherwise go unnoticed. Lifecycle fixes this needed: the self-teardown path now disposes the CancellationTokenSource (only Stop() did, so every EOF leaked one) and kills the subscription before disposing it (Process.Dispose does not terminate the child, so a read error with pactl still alive orphaned it). Reconnects publish their replacement without releasing the lock, and Stop() clears the intent first and bumps a session counter, so neither a reconnect racing Stop nor a retry worker sleeping across a Stop/Start cycle can resurrect a stopped watcher or start a second retry chain. Start() no longer overwrites the callback while a run is live, keeping its documented idempotence. Four tests cover recovery, the bounded give-up, self-reconnect and the Stop-versus-reconnect race. --- .../Services/DefaultDeviceWatcher.cs | 264 +++++++++++++++--- .../DefaultDeviceWatcherTests.cs | 151 ++++++++++ 2 files changed, 376 insertions(+), 39 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/DefaultDeviceWatcher.cs b/src/TypeWhisper.Linux/Services/DefaultDeviceWatcher.cs index 9f3f3665c..4ca096295 100644 --- a/src/TypeWhisper.Linux/Services/DefaultDeviceWatcher.cs +++ b/src/TypeWhisper.Linux/Services/DefaultDeviceWatcher.cs @@ -234,6 +234,28 @@ public sealed class PactlDefaultDeviceWatcher : IDefaultDeviceChangeWatcher private object? _runToken; private int _disposed; + // The callback Start() was given, kept so a reconnect after the sound server restarts + // can re-arm the same pipeline, and the caller's "watching requested" intent. Stop() + // clears the intent so a subscription ending concurrently is not reconnected. + private Action? _callback; + private bool _wantRunning; + private int _autoRestarts; + + // Bumped by Stop(). A delayed retry worker captures it and exits if it changed, so a + // Stop()/Start() cycle during a backoff window cannot leave workers from the previous + // session running alongside the new one's — repeated toggles would otherwise accumulate + // them, collapsing the backoff and overrunning MaxAutoRestarts between them. + private long _session; + + // pactl exits whenever the sound server does (a PipeWire/PulseAudio restart), and the + // caller latches "started", so without reconnecting here the watcher would stay dead for + // the rest of the session. A run that survived _minRunBeforeRestart reconnects at once; + // one that died young (server not back yet, or none at all) backs off, bounded by + // MaxAutoRestarts so a permanently broken server cannot respawn pactl forever. + private const int MaxAutoRestarts = 10; + private readonly TimeSpan _minRunBeforeRestart; + private readonly TimeSpan _retryBackoff; + public PactlDefaultDeviceWatcher(SystemCommandAvailabilityService commands) : this(() => commands.HasPactl) { @@ -252,12 +274,16 @@ internal PactlDefaultDeviceWatcher(Func isPactlAvailable) // after the loop exits on EOF/error — is verifiable without spawning 'pactl subscribe'. internal PactlDefaultDeviceWatcher( Func isPactlAvailable, - Func subscriptionFactory + Func subscriptionFactory, + TimeSpan? minRunBeforeRestart = null, + TimeSpan? retryBackoff = null ) { _isPactlAvailable = isPactlAvailable; _subscriptionFactory = subscriptionFactory; _debounce = TimeSpan.FromMilliseconds(350); + _minRunBeforeRestart = minRunBeforeRestart ?? TimeSpan.FromSeconds(5); + _retryBackoff = retryBackoff ?? TimeSpan.FromSeconds(1); } // True while a subscription run is active. Test-only: lets a test wait for the read @@ -352,46 +378,73 @@ public void Start(Action onDefaultDeviceChanged) if (_subscription is not null) { - // Already running — idempotent. + // Already running — idempotent. Returning BEFORE touching _callback matters: + // the live dispatcher keeps invoking the original one, so overwriting it here + // would swap the callback at the next reconnect. return; } - _dispatcher = new DefaultDeviceChangeDispatcher(onDefaultDeviceChanged, _debounce); + _callback = onDefaultDeviceChanged; + _wantRunning = true; + _autoRestarts = 0; + // A new session, so any retry worker still sleeping from the previous one retires. + _session++; + SpawnLocked(); + } + } - PactlSubscription subscription; - try - { - subscription = _subscriptionFactory(); - } - catch (Exception ex) - { - // Launch failure is non-fatal: log, drop the dispatcher, stay stopped. - Trace.WriteLine( - $"[PactlDefaultDeviceWatcher] Failed to start 'pactl subscribe': {ex.Message}" - ); - _dispatcher.Dispose(); - _dispatcher = null; - return; - } + // Launch one subscription run; returns its dispatcher, or null if nothing was launched. + // The caller MUST hold _gate and must have already validated the intent, so that a + // reconnect checks _wantRunning and publishes its replacement without releasing the lock + // in between — otherwise a Stop() landing in that window could be undone afterwards. + private DefaultDeviceChangeDispatcher? SpawnLocked() + { + if (_subscription is not null || _callback is null) + { + // Already running — idempotent. + return null; + } - var runToken = new object(); - var dispatcher = _dispatcher; - var cts = new CancellationTokenSource(); - var token = cts.Token; - _subscription = subscription; - _runToken = runToken; - _cts = cts; - // Capture locals (not fields) into the loop so a concurrent Stop() nulling the - // fields can't turn a field read on the task thread into a NullReferenceException. - // ReSharper disable once MethodSupportsCancellation - // Deliberately do NOT pass `token` to Task.Run: if it were already cancelled the - // delegate would never run, so ReadLoopAsync's finally (ClearRunState) would not - // execute and _subscription would stay set. The token is honored INSIDE the loop - // instead, which still lets teardown run. - _readerTask = Task.Run( - () => ReadLoopAsync(subscription, dispatcher, runToken, token) + _dispatcher = new DefaultDeviceChangeDispatcher(_callback, _debounce); + + PactlSubscription subscription; + try + { + subscription = _subscriptionFactory(); + } + catch (Exception ex) + { + // Launch failure is non-fatal: log, drop the dispatcher, stay stopped. + Trace.WriteLine( + $"[PactlDefaultDeviceWatcher] Failed to start 'pactl subscribe': {ex.Message}" ); + _dispatcher.Dispose(); + _dispatcher = null; + return null; } + + var runToken = new object(); + var dispatcher = _dispatcher; + var cts = new CancellationTokenSource(); + var token = cts.Token; + // Carried through so this run's eventual reconnect can be rejected if the session + // was replaced while it was ending. + var session = _session; + _subscription = subscription; + _runToken = runToken; + _cts = cts; + // Capture locals (not fields) into the loop so a concurrent Stop() nulling the + // fields can't turn a field read on the task thread into a NullReferenceException. + // ReSharper disable once MethodSupportsCancellation + // Deliberately do NOT pass `token` to Task.Run: if it were already cancelled the + // delegate would never run, so ReadLoopAsync's finally (ClearRunState) would not + // execute and _subscription would stay set. The token is honored INSIDE the loop + // instead, which still lets teardown run. + _readerTask = Task.Run( + () => ReadLoopAsync(subscription, dispatcher, runToken, session, token) + ); + + return dispatcher; } private static PactlSubscription LaunchPactlSubscribe() @@ -419,14 +472,16 @@ private static PactlSubscription LaunchPactlSubscribe() // Thin, untested shell: read subscribe stdout line by line and forward relevant // lines to the (tested) dispatcher. Any failure just ends the loop — the watcher - // then clears its own state (see ClearRunState) so a later Start() can restart it. + // then clears its own state (ClearRunState) and reconnects (TryReconnect). private async Task ReadLoopAsync( PactlSubscription subscription, DefaultDeviceChangeDispatcher dispatcher, object runToken, + long session, CancellationToken ct ) { + var startedAt = Stopwatch.GetTimestamp(); try { var reader = subscription.Output; @@ -460,31 +515,142 @@ CancellationToken ct // Self-teardown: the subscription ended (EOF, read error, or cancellation). // Clear the watcher's run state so a subsequent Start() is not rejected as // "already running" and can spawn a fresh subscription. Identity-guarded so a - // concurrent Stop()/Start() that already replaced this run is never clobbered. - ClearRunState(runToken, subscription, dispatcher); + // concurrent Stop()/Start() that already replaced this run is never clobbered; + // only the run that actually owned the state reconnects. + if (ClearRunState(runToken, subscription, dispatcher)) + { + TryReconnect(Stopwatch.GetElapsedTime(startedAt), session); + } } } + // Reconnect after the subscription ended on its own. The caller (AudioRecordingService) + // latches "watcher started" and never calls Start() again, so without this a sound-server + // restart would silently end live default-device following for the rest of the session — + // leaving only the lazy re-resolve at the next recording start. + private void TryReconnect(TimeSpan runDuration, long session) + { + int attempt; + lock (_gate) + { + // Disposing, Stop() cleared the intent, something already reconnected, or this + // run belongs to a session that has since been replaced. + if (Volatile.Read(ref _disposed) == 1 + || !_wantRunning + || _session != session + || _subscription is not null) + { + return; + } + + if (runDuration >= _minRunBeforeRestart) + { + // The subscription was healthy and then ended — the sound server restarted + // under a working watcher. Reconnect at once and refresh the budget; if even + // the launch failed, fall through and retry it on the backoff schedule. + _autoRestarts = 0; + if (Reconnect()) + { + return; + } + } + + // It died young: the server is probably still coming back up. Back off rather + // than either respawning in a tight loop or abandoning recovery, since the first + // reconnect after a restart routinely lands before the server is listening again. + if (_autoRestarts >= MaxAutoRestarts) + { + Trace.WriteLine( + $"[PactlDefaultDeviceWatcher] giving up after {MaxAutoRestarts} attempts; " + + "falling back to lazy re-resolve at the next recording start." + ); + return; + } + + attempt = ++_autoRestarts; + } + + // One task owns the whole backoff sequence: it keeps trying until a subscription is + // actually launched (from then on that run's own exit drives any further recovery) or + // the budget runs out. Driving it from the read loop instead would end recovery + // silently whenever the launch itself failed, since no loop would exist to retry. + _ = Task.Run(async () => + { + while (true) + { + await Task.Delay(BackoffFor(attempt)).ConfigureAwait(false); + lock (_gate) + { + // Re-validate: Stop()/Dispose() or another run may have intervened. + if (Volatile.Read(ref _disposed) == 1 + || !_wantRunning + || _session != session + || _subscription is not null) + { + return; + } + + if (Reconnect()) + { + return; + } + + if (_autoRestarts >= MaxAutoRestarts) + { + Trace.WriteLine( + $"[PactlDefaultDeviceWatcher] giving up after {MaxAutoRestarts} " + + "attempts; falling back to lazy re-resolve at the next recording start." + ); + return; + } + + attempt = ++_autoRestarts; + } + } + }); + } + + // Caller holds _gate. Relaunches and asks the fresh run to reconcile once: pactl does not + // replay events, so a default that changed while the subscription was down would otherwise + // go unnoticed until the next event or recording start. Signal() is debounced and the + // callback no-ops when the device is unchanged, so a spurious one costs nothing. + private bool Reconnect() + { + Trace.WriteLine("[PactlDefaultDeviceWatcher] subscription ended; reconnecting."); + var dispatcher = SpawnLocked(); + dispatcher?.Signal(); + return dispatcher is not null; + } + + // 1s, 2s, 4s, 8s, then 16s for every further attempt. + private TimeSpan BackoffFor(int attempt) => + _retryBackoff * (1 << Math.Min(attempt - 1, 4)); + // Clear the state for a specific run (identified by runToken) and release its // subscription + dispatcher. Called from the read loop's finally block when the // subscription ends. A no-op if runToken is no longer the current run — i.e. // Stop()/Dispose() or a newer Start() already took over — so it never disposes a // subscription (or dispatcher) that a newer run owns, and never double-disposes one // Stop() is already tearing down. - private void ClearRunState( + // Returns true when this run was still the current one and its state was cleared here. + private bool ClearRunState( object runToken, PactlSubscription subscription, DefaultDeviceChangeDispatcher? dispatcher ) { + CancellationTokenSource? cts; lock (_gate) { if (!ReferenceEquals(_runToken, runToken)) { // A newer run (or an explicit Stop) already owns/cleared the state. - return; + return false; } + // Captured, not just nulled: Stop() disposes the source it tears down, so this + // path has to as well or every self-teardown leaks one. + cts = _cts; _subscription = null; _runToken = null; _cts = null; @@ -494,6 +660,18 @@ private void ClearRunState( // Dispose outside the lock (best effort): killing the process / disposing the // reader must not run under _gate. + try + { + // Kill first, as Stop() does: on a read error pactl can still be alive, and the + // production Dispose is Process.Dispose, which releases the handle without + // terminating the child — reconnecting on that would orphan one process per failure. + subscription.Kill(); + } + catch + { + /* best effort: process may already be gone */ + } + try { subscription.Dispose(); @@ -503,7 +681,9 @@ private void ClearRunState( /* best effort: process may already be gone */ } + cts?.Dispose(); dispatcher?.Dispose(); + return true; } public void Stop() @@ -515,6 +695,12 @@ public void Stop() lock (_gate) { + // Clear the intent FIRST and unconditionally: a read loop that is ending right + // now may reach TryReconnect after this method has already returned, and must + // not resurrect a watcher the caller just stopped. + _wantRunning = false; + _autoRestarts = 0; + _session++; subscription = _subscription; cts = _cts; readerTask = _readerTask; diff --git a/tests/TypeWhisper.Linux.Tests/DefaultDeviceWatcherTests.cs b/tests/TypeWhisper.Linux.Tests/DefaultDeviceWatcherTests.cs index f2bd829dd..8bd3f244f 100644 --- a/tests/TypeWhisper.Linux.Tests/DefaultDeviceWatcherTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DefaultDeviceWatcherTests.cs @@ -308,6 +308,135 @@ PactlSubscription Factory() Assert.Equal(2, starts); } + [Fact] + public void PactlWatcher_Reconnects_WhenASurvivingSubscriptionEnds() + { + // Regression: pactl exits when the sound server restarts, and AudioRecordingService + // latches "watcher started" and never calls Start() again — so the watcher had to + // reconnect itself or live default-device following was over for the session. + // minRunBeforeRestart: Zero makes every instant-EOF fake count as a surviving run, so + // each reconnects immediately; the factory ends the chain on the 3rd call. + var starts = 0; + // ReSharper disable once MoveLocalFunctionAfterJumpStatement + PactlSubscription Factory() + { + // ReSharper disable once AccessToModifiedClosure -- counting the reconnects IS the + // assertion; the reader threads write it and the waits below read it, both interlocked. + return Interlocked.Increment(ref starts) >= 3 + ? throw new InvalidOperationException("no more subscriptions") + : PactlSubscription.ForTest(new StringReader("Event 'change' on server #0\n")); + } + + using var sut = new PactlDefaultDeviceWatcher( + isPactlAvailable: () => true, + subscriptionFactory: Factory, + minRunBeforeRestart: TimeSpan.Zero, + retryBackoff: TimeSpan.FromMilliseconds(1)); + + sut.Start(() => { }); + + WaitUntil(() => Volatile.Read(ref starts) >= 3); + Assert.True(Volatile.Read(ref starts) >= 3, "the watcher never reconnected on its own."); + } + + [Fact] + public void PactlWatcher_RecoversFromATemporarilyUnavailableServer() + { + // The first reconnect after a server restart routinely lands before the server is + // listening again, so pactl dies young. That must back off and keep trying, not + // abandon recovery — otherwise the most common real outage kills the watcher. + var starts = 0; + var live = new BlockingSubscriptionReader(); + // ReSharper disable once MoveLocalFunctionAfterJumpStatement + PactlSubscription Factory() + { + // ReSharper disable once AccessToModifiedClosure -- see above; interlocked throughout. + var attempt = Interlocked.Increment(ref starts); + // Attempts 1-3 die instantly (server still down); the 4th connects and stays up. + // Kill/Dispose release the blocked reader so teardown does not strand a thread. + return attempt >= 4 + ? PactlSubscription.ForTest(live, onKill: live.Release, onDispose: live.Release) + : PactlSubscription.ForTest(new StringReader("")); + } + + using var sut = new PactlDefaultDeviceWatcher( + isPactlAvailable: () => true, + // Every instant-EOF run counts as dying young, so all of them take the backoff path. + minRunBeforeRestart: TimeSpan.FromHours(1), + subscriptionFactory: Factory, + retryBackoff: TimeSpan.FromMilliseconds(1)); + + sut.Start(() => { }); + + // Wait on the attempt count first: IsRunningForTest also flickers true for each of + // the three short-lived runs, so it alone would not identify the recovered one. + WaitUntil(() => Volatile.Read(ref starts) >= 4); + // ReSharper disable once AccessToDisposedClosure -- WaitUntil spin-waits synchronously. + WaitUntil(() => sut.IsRunningForTest); + Assert.Equal(4, Volatile.Read(ref starts)); + } + + [Fact] + public void PactlWatcher_GivesUp_WhenTheServerNeverComesBack() + { + // The bounded budget is what stops a permanently broken server from respawning pactl + // forever: the initial start plus MaxAutoRestarts attempts, then it stays down. + var starts = 0; + // ReSharper disable once MoveLocalFunctionAfterJumpStatement + PactlSubscription Factory() + { + // ReSharper disable once AccessToModifiedClosure -- see above; interlocked throughout. + Interlocked.Increment(ref starts); + return PactlSubscription.ForTest(new StringReader("")); + } + + using var sut = new PactlDefaultDeviceWatcher( + isPactlAvailable: () => true, + minRunBeforeRestart: TimeSpan.FromHours(1), + subscriptionFactory: Factory, + retryBackoff: TimeSpan.FromMilliseconds(1)); + + sut.Start(() => { }); + + WaitUntil(() => Volatile.Read(ref starts) == 11); + Thread.Sleep(150); + Assert.Equal(11, Volatile.Read(ref starts)); + } + + [Fact] + public void PactlWatcher_Stop_IsNotUndoneByAConcurrentReconnect() + { + // Stop() and the read loop's reconnect both take the watcher's lock; the reconnect + // publishes its replacement without releasing it in between, so a Stop landing in + // that window can never be overtaken by a watcher that restarts itself afterwards. + var starts = 0; + // ReSharper disable once MoveLocalFunctionAfterJumpStatement + PactlSubscription Factory() + { + // ReSharper disable once AccessToModifiedClosure -- see above; interlocked throughout. + Interlocked.Increment(ref starts); + return PactlSubscription.ForTest(new StringReader("Event 'change' on server #0\n")); + } + + for (var i = 0; i < 50; i++) + { + using var sut = new PactlDefaultDeviceWatcher( + isPactlAvailable: () => true, + subscriptionFactory: Factory, + minRunBeforeRestart: TimeSpan.Zero, + retryBackoff: TimeSpan.FromMilliseconds(1)); + + sut.Start(() => { }); + sut.Stop(); + + // Whatever the interleaving, a stopped watcher must settle with nothing running. + // ReSharper disable once AccessToDisposedClosure -- WaitUntil spin-waits synchronously. + WaitUntil(() => !sut.IsRunningForTest); + Thread.Sleep(5); + Assert.False(sut.IsRunningForTest, "Stop() was undone by a concurrent reconnect."); + } + } + private static void WaitUntil(Func condition) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); @@ -518,3 +647,25 @@ public ValueTask DisposeAsync() } } } + +// A subscription reader that stays open until released, modeling a pactl that connected +// successfully and is waiting for events. Release() (wired to the fake subscription's +// Kill/Dispose) unblocks it and reports EOF so teardown never strands a pool thread. +internal sealed class BlockingSubscriptionReader : TextReader +{ + private readonly ManualResetEventSlim _released = new(false); + + public override string? ReadLine() + { + _released.Wait(); + return null; + } + + public void Release() => _released.Set(); + + protected override void Dispose(bool disposing) + { + _released.Set(); + base.Dispose(disposing); + } +} From 629166e1b2b0683fb67818836090943b0c03ec00 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 3 Aug 2026 18:28:28 -0400 Subject: [PATCH 224/226] Retry a failed initial pactl launch, and pin the reconnect knobs in tests A launch failure during Start() logged and stayed down forever, while the same failure at runtime retried on the backoff schedule. Extracted that schedule into ScheduleRetryLocked and called it from both, so the startup path can no longer leave the watcher silently dead. Start() bumps the session first, which retires any sleeping worker, so it cannot produce a duplicate one. PactlWatcher_Restarts_AfterReadLoopExitsOnEof predates the auto-reconnect and had started relying on it not firing: its instant-EOF fakes now schedule a one-second retry that could bump the count under the assertions. Both reconnect knobs are pinned out of reach so it tests the explicit restart again, and the cross-thread counter is interlocked on both sides. PactlWatcher_Reconnects_WhenASurvivingSubscriptionEnds claimed the factory throw ended the chain; SpawnLocked catches it and falls into the retry budget, so the count kept climbing and the assertion had been weakened to match. The third subscription now stays open, ending the chain at exactly three. --- .../Services/DefaultDeviceWatcher.cs | 41 +++++++++++-------- .../DefaultDeviceWatcherTests.cs | 28 +++++++++---- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/TypeWhisper.Linux/Services/DefaultDeviceWatcher.cs b/src/TypeWhisper.Linux/Services/DefaultDeviceWatcher.cs index 4ca096295..c3f96bb95 100644 --- a/src/TypeWhisper.Linux/Services/DefaultDeviceWatcher.cs +++ b/src/TypeWhisper.Linux/Services/DefaultDeviceWatcher.cs @@ -387,9 +387,15 @@ public void Start(Action onDefaultDeviceChanged) _callback = onDefaultDeviceChanged; _wantRunning = true; _autoRestarts = 0; - // A new session, so any retry worker still sleeping from the previous one retires. + // A new session retires any retry worker still sleeping from the previous one, so + // the one scheduled below is the only one that can exist for it. _session++; - SpawnLocked(); + if (SpawnLocked() is null) + { + // pactl was reported available but would not launch. Retry on the same bounded + // schedule as a runtime failure instead of silently staying down forever. + ScheduleRetryLocked(_session); + } } } @@ -530,7 +536,6 @@ CancellationToken ct // leaving only the lazy re-resolve at the next recording start. private void TryReconnect(TimeSpan runDuration, long session) { - int attempt; lock (_gate) { // Disposing, Stop() cleared the intent, something already reconnected, or this @@ -558,22 +563,26 @@ private void TryReconnect(TimeSpan runDuration, long session) // It died young: the server is probably still coming back up. Back off rather // than either respawning in a tight loop or abandoning recovery, since the first // reconnect after a restart routinely lands before the server is listening again. - if (_autoRestarts >= MaxAutoRestarts) - { - Trace.WriteLine( - $"[PactlDefaultDeviceWatcher] giving up after {MaxAutoRestarts} attempts; " - + "falling back to lazy re-resolve at the next recording start." - ); - return; - } + ScheduleRetryLocked(session); + } + } - attempt = ++_autoRestarts; + // Caller holds _gate. One task owns the whole backoff sequence for this session: it keeps + // trying until a subscription is launched (from then on that run's own exit drives any + // further recovery) or the budget runs out. Driving it from the read loop instead would end + // recovery silently whenever the launch itself failed, since no loop would exist to retry. + private void ScheduleRetryLocked(long session) + { + if (_autoRestarts >= MaxAutoRestarts) + { + Trace.WriteLine( + $"[PactlDefaultDeviceWatcher] giving up after {MaxAutoRestarts} attempts; " + + "falling back to lazy re-resolve at the next recording start." + ); + return; } - // One task owns the whole backoff sequence: it keeps trying until a subscription is - // actually launched (from then on that run's own exit drives any further recovery) or - // the budget runs out. Driving it from the read loop instead would end recovery - // silently whenever the launch itself failed, since no loop would exist to retry. + var attempt = ++_autoRestarts; _ = Task.Run(async () => { while (true) diff --git a/tests/TypeWhisper.Linux.Tests/DefaultDeviceWatcherTests.cs b/tests/TypeWhisper.Linux.Tests/DefaultDeviceWatcherTests.cs index 8bd3f244f..fc652b8a2 100644 --- a/tests/TypeWhisper.Linux.Tests/DefaultDeviceWatcherTests.cs +++ b/tests/TypeWhisper.Linux.Tests/DefaultDeviceWatcherTests.cs @@ -283,21 +283,28 @@ public void PactlWatcher_Restarts_AfterReadLoopExitsOnEof() // ReSharper disable once MoveLocalFunctionAfterJumpStatement PactlSubscription Factory() { - starts++; + // ReSharper disable once AccessToModifiedClosure -- the reader threads write the + // counter and the asserts read it; interlocked on both sides. + Interlocked.Increment(ref starts); // One relevant line then EOF (StringReader returns null after its content). return PactlSubscription.ForTest(new StringReader("Event 'change' on server #0\n")); } + // This test is about the EXPLICIT restart, so both reconnect knobs are pinned out of + // reach: every instant-EOF run counts as dying young and its backoff never elapses here. + // Otherwise an automatic reconnect could bump `starts` under the assertions below. using var sut = new PactlDefaultDeviceWatcher( isPactlAvailable: () => true, - subscriptionFactory: Factory); + subscriptionFactory: Factory, + minRunBeforeRestart: TimeSpan.FromHours(1), + retryBackoff: TimeSpan.FromHours(1)); sut.Start(() => { }); // The first run's read loop hits EOF and self-tears-down: wait until it clears. // WaitUntil runs the closure synchronously (spin-wait) before `sut` is disposed. // ReSharper disable once AccessToDisposedClosure WaitUntil(() => !sut.IsRunningForTest); - Assert.Equal(1, starts); + Assert.Equal(1, Volatile.Read(ref starts)); // A second Start() must NOT be rejected as "already running" — it spawns a fresh // subscription because the first run cleared its state on exit. @@ -305,7 +312,7 @@ PactlSubscription Factory() // Synchronous spin-wait again; captured `sut` is not accessed after disposal. // ReSharper disable once AccessToDisposedClosure WaitUntil(() => !sut.IsRunningForTest); - Assert.Equal(2, starts); + Assert.Equal(2, Volatile.Read(ref starts)); } [Fact] @@ -315,15 +322,17 @@ public void PactlWatcher_Reconnects_WhenASurvivingSubscriptionEnds() // latches "watcher started" and never calls Start() again — so the watcher had to // reconnect itself or live default-device following was over for the session. // minRunBeforeRestart: Zero makes every instant-EOF fake count as a surviving run, so - // each reconnects immediately; the factory ends the chain on the 3rd call. + // each one reconnects immediately and at full budget. The 3rd subscription stays open, + // which ends the chain at an exact count instead of leaving it to the retry budget. var starts = 0; + var live = new BlockingSubscriptionReader(); // ReSharper disable once MoveLocalFunctionAfterJumpStatement PactlSubscription Factory() { // ReSharper disable once AccessToModifiedClosure -- counting the reconnects IS the // assertion; the reader threads write it and the waits below read it, both interlocked. return Interlocked.Increment(ref starts) >= 3 - ? throw new InvalidOperationException("no more subscriptions") + ? PactlSubscription.ForTest(live, onKill: live.Release, onDispose: live.Release) : PactlSubscription.ForTest(new StringReader("Event 'change' on server #0\n")); } @@ -335,8 +344,11 @@ PactlSubscription Factory() sut.Start(() => { }); - WaitUntil(() => Volatile.Read(ref starts) >= 3); - Assert.True(Volatile.Read(ref starts) >= 3, "the watcher never reconnected on its own."); + // Two self-reconnects (after runs 1 and 2), then the third stays up. + WaitUntil(() => Volatile.Read(ref starts) == 3); + // ReSharper disable once AccessToDisposedClosure -- WaitUntil spin-waits synchronously. + WaitUntil(() => sut.IsRunningForTest); + Assert.Equal(3, Volatile.Read(ref starts)); } [Fact] From e1dc94594fd3362905426c9d762d5e136d84c5f7 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Mon, 3 Aug 2026 18:48:46 -0400 Subject: [PATCH 225/226] Close job-leak, profile/credential and activation races from review QA findings, each checked against current code first: - Gladia created the job before validating result_url, so a missing, non-string or non-HTTPS value leaked the uploaded audio server-side. Delete the job once its id is known and any later initiation step fails. - SelectProfileModel mutated the profile FindAdditional returned. That is a snapshot, so a concurrent save could orphan it and drop the user's model choice on the next persist. Re-resolve by id under the lock. - _additionalApiKeys was read by the transcription/LLM paths while the settings-save and activation paths rewrote it. A Dictionary read racing a resize can loop or return the wrong entry, so it now shares the profile lock. - Failed webhook activation left Service pointing at a disposed instance when the event-bus subscribe threw. Codex review on top of those: - SetItemsAsync applied each secret to the live cache as it was written but only swapped the profiles in afterwards, so a later secret failure left a new credential paired with the old BaseUrl. Stage the keys and publish both together. - Publishing and reading them as two lock acquisitions had the same window, so profiles and credentials are now written and resolved in one critical section. Tightened the watch-folder worker-failure assertion to an exact ArgumentException match, verified against the actual exception. Skipped: rejecting an orphaned "" header placeholder. SetItems_NewHeaderPlaceholderStoresLiteralValue pins the opposite contract by name, so flipping it is a decision, not a fix. Worth revisiting - duplicating a webhook row can persist the placeholder as a real header value. ReSharper: 0 at HINT severity, verified against a fresh cache. The refactor orphaned five now-unreachable helpers; deleted them. --- .../TypeWhisper.Plugin.Gladia/GladiaPlugin.cs | 35 ++-- .../OpenAiCompatiblePlugin.cs | 158 ++++++++++++------ .../WebhookPlugin.cs | 1 + .../WatchFolderServiceTests.cs | 2 +- 4 files changed, 135 insertions(+), 61 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs index 375ef566c..1a8353b66 100644 --- a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs @@ -216,22 +216,33 @@ CancellationToken ct var json = await SendJsonAsync(request, "Gladia transcription initiation", ct); using var document = ParseProtocolJson(json, "transcription initiation"); var id = RequireString(document.RootElement, "id", "transcription initiation"); - var resultUrl = RequireString( - document.RootElement, - "result_url", - "transcription initiation" - ); - // Require HTTPS: polling sends x-gladia-key to this URL; non-HTTPS would leak it in plaintext. - if (!Uri.TryCreate(resultUrl, UriKind.Absolute, out var resultUri) - || resultUri.Scheme != Uri.UriSchemeHttps) + // The job now exists server-side, so anything that fails below has to + // delete it before propagating or the audio leaks. + try { - throw new InvalidOperationException( - "Gladia transcription initiation response contained an invalid result_url." + var resultUrl = RequireString( + document.RootElement, + "result_url", + "transcription initiation" ); - } - return new InitiatedJob(id, resultUri); + // Require HTTPS: polling sends x-gladia-key to this URL; non-HTTPS would leak it in plaintext. + if (!Uri.TryCreate(resultUrl, UriKind.Absolute, out var resultUri) + || resultUri.Scheme != Uri.UriSchemeHttps) + { + throw new InvalidOperationException( + "Gladia transcription initiation response contained an invalid result_url." + ); + } + + return new InitiatedJob(id, resultUri); + } + catch + { + await DeleteJobBestEffortAsync(id, apiKey); + throw; + } } private async Task PollUntilTerminalAsync( diff --git a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs index c23f0b254..15f093500 100644 --- a/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs +++ b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs @@ -763,26 +763,27 @@ public async Task SetItemsAsync( }); } - // Do the fallible host secret-store writes before swapping the shared - // profile set, so a failure here can't leave _additionalProfiles half - // updated. Each _additionalApiKeys mutation is paired with its host op, - // so the cache stays consistent with the store even on a mid-loop throw. + // Do the fallible secret-store writes before swapping any shared state, and + // stage the key changes locally rather than applying them as they land: + // publishing a new key while the profile set still holds the old BaseUrl + // would send that credential to the previous endpoint. + var newApiKeys = SnapshotProfileApiKeys(); if (_host is not null) { foreach (var removedId in previousById.Keys.Where(k => !seenIds.Contains(k))) { await _host.DeleteSecretAsync(SecretKeyFor(removedId)); - _additionalApiKeys.Remove(removedId); + newApiKeys.Remove(removedId); } foreach (var (id, key) in keyUpdates) { await _host.StoreSecretAsync(SecretKeyFor(id), key!); - _additionalApiKeys[id] = key; + newApiKeys[id] = key; } } - ReplaceProfiles(newProfiles); + ReplaceProfilesAndApiKeys(newProfiles, newApiKeys); // State is now persisted; the best-effort model fetch below may fail or be // cancelled, but that must not revert the saved profiles. @@ -852,17 +853,23 @@ internal IReadOnlyList ProfileLlmModels(string id) internal void SelectProfileModel(string id, string modelId) { - var profile = FindAdditional(id); - if (profile is null) - return; - var selectedModelId = string.IsNullOrWhiteSpace(modelId) ? null : modelId.Trim(); - if (string.Equals(profile.SelectedModelId, selectedModelId, StringComparison.Ordinal)) - return; - profile.SelectedModelId = selectedModelId; + // Re-resolve inside the lock: a snapshot taken outside it can be orphaned by + // a concurrent save, and the selection would then be written to a discarded + // instance and silently lost on the next persist. lock (_profileRolesLock) { + var profile = _additionalProfiles.FirstOrDefault(p => + string.Equals(p.Id, id, StringComparison.Ordinal) + ); + if (profile is null + || string.Equals(profile.SelectedModelId, selectedModelId, StringComparison.Ordinal)) + { + return; + } + + profile.SelectedModelId = selectedModelId; _profileRoles.Remove(id); } @@ -878,17 +885,17 @@ internal async Task TranscribeForProfileAsync( CancellationToken ct ) { - var profile = RequireAdditional(id); - if (string.IsNullOrEmpty(profile.BaseUrl)) + var endpoint = ResolveProfileEndpoint(id); + if (string.IsNullOrEmpty(endpoint.BaseUrl)) throw new InvalidOperationException(Loc.L("Settings.ServerUrlNotConfigured")); - if (string.IsNullOrEmpty(profile.SelectedModelId)) + if (string.IsNullOrEmpty(endpoint.SelectedModelId)) throw new InvalidOperationException(Loc.L("Settings.NoTranscriptionModelSelected")); return await OpenAiTranscriptionHelper.TranscribeAsync( _httpClient, - profile.BaseUrl, - GetProfileApiKey(id) ?? "", - profile.SelectedModelId!, + endpoint.BaseUrl, + endpoint.ApiKey ?? "", + endpoint.SelectedModelId!, wavAudio, language, translate, @@ -906,18 +913,18 @@ internal async Task ProcessForProfileAsync( CancellationToken ct ) { - var profile = RequireAdditional(id); - if (string.IsNullOrEmpty(profile.BaseUrl)) + var endpoint = ResolveProfileEndpoint(id); + if (string.IsNullOrEmpty(endpoint.BaseUrl)) throw new InvalidOperationException(Loc.L("Settings.ServerUrlNotConfigured")); - var modelId = !string.IsNullOrEmpty(model) ? model : profile.SelectedLlmModelId ?? ""; + var modelId = !string.IsNullOrEmpty(model) ? model : endpoint.SelectedLlmModelId ?? ""; if (string.IsNullOrEmpty(modelId)) throw new InvalidOperationException(Loc.L("Settings.NoLlmModelSelected")); return await OpenAiChatHelper.SendChatCompletionAsync( _httpClient, - profile.BaseUrl, - GetProfileApiKey(id) ?? "", + endpoint.BaseUrl, + endpoint.ApiKey ?? "", modelId, systemPrompt, userText, @@ -943,18 +950,18 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct yield break; } - var profile = RequireAdditional(id); - if (string.IsNullOrEmpty(profile.BaseUrl)) + var endpoint = ResolveProfileEndpoint(id); + if (string.IsNullOrEmpty(endpoint.BaseUrl)) throw new InvalidOperationException(Loc.L("Settings.ServerUrlNotConfigured")); - var modelId = !string.IsNullOrEmpty(model) ? model : profile.SelectedLlmModelId ?? ""; + var modelId = !string.IsNullOrEmpty(model) ? model : endpoint.SelectedLlmModelId ?? ""; if (string.IsNullOrEmpty(modelId)) throw new InvalidOperationException(Loc.L("Settings.NoLlmModelSelected")); var source = OpenAiChatHelper.SendChatCompletionStreamingAsync( _httpClient, - profile.BaseUrl, - GetProfileApiKey(id) ?? "", + endpoint.BaseUrl, + endpoint.ApiKey ?? "", modelId, systemPrompt, userText, @@ -968,14 +975,14 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct private async Task LoadAdditionalProfilesAsync(IPluginHostServices host) { var previousById = SnapshotProfiles().ToDictionary(p => p.Id, StringComparer.Ordinal); - var previousApiKeys = new Dictionary(_additionalApiKeys, StringComparer.Ordinal); - _additionalApiKeys.Clear(); + var previousApiKeys = SnapshotProfileApiKeys(); var stored = host.GetSetting>(AdditionalProfilesSettingKey) ?? []; var seen = new HashSet(StringComparer.Ordinal); // Built locally and swapped in once, so the awaited secret loads below // never expose a half-populated profile set to the host. var loadedProfiles = new List(); + var loadedApiKeys = new Dictionary(StringComparer.Ordinal); foreach (var profile in stored) { @@ -993,17 +1000,17 @@ private async Task LoadAdditionalProfilesAsync(IPluginHostServices host) var key = await host.LoadSecretAsync(SecretKeyFor(profile.Id)); if (!string.IsNullOrEmpty(key)) - _additionalApiKeys[profile.Id] = key; + loadedApiKeys[profile.Id] = key; } - ReplaceProfiles(loadedProfiles); + ReplaceProfilesAndApiKeys(loadedProfiles, loadedApiKeys); var changedApiKeyIds = previousApiKeys - .Keys.Union(_additionalApiKeys.Keys, StringComparer.Ordinal) + .Keys.Union(loadedApiKeys.Keys, StringComparer.Ordinal) .Where(id => !string.Equals( previousApiKeys.GetValueOrDefault(id), - _additionalApiKeys.GetValueOrDefault(id), + loadedApiKeys.GetValueOrDefault(id), StringComparison.Ordinal ) ); @@ -1066,8 +1073,73 @@ CancellationToken ct private static string SecretKeyFor(string profileId) => $"api-key.{profileId}"; - private string? GetProfileApiKey(string id) => - _additionalApiKeys.GetValueOrDefault(id); + // _additionalApiKeys is rewritten by the settings-save and activation paths + // while the transcription/LLM paths read it, so it shares the profile lock. + // A Dictionary read racing a resize can loop or return the wrong entry. + private string? GetProfileApiKey(string id) + { + lock (_profileRolesLock) + { + return _additionalApiKeys.GetValueOrDefault(id); + } + } + + private Dictionary SnapshotProfileApiKeys() + { + lock (_profileRolesLock) + { + return new Dictionary(_additionalApiKeys, StringComparer.Ordinal); + } + } + + + private void ReplaceProfileApiKeysLocked(IReadOnlyDictionary keys) + { + _additionalApiKeys.Clear(); + foreach (var (id, key) in keys) + _additionalApiKeys[id] = key; + } + + // Publish together: between two separate lock acquisitions a request could + // pair the new endpoint with the old credential and send it to the wrong host. + private void ReplaceProfilesAndApiKeys( + IEnumerable profiles, + IReadOnlyDictionary keys + ) + { + lock (_profileRolesLock) + { + _additionalProfiles.Clear(); + _additionalProfiles.AddRange(profiles); + ReplaceProfileApiKeysLocked(keys); + } + } + + // Read together, for the same reason. + private (string BaseUrl, string? SelectedModelId, string? SelectedLlmModelId, string? ApiKey) + ResolveProfileEndpoint(string id) + { + lock (_profileRolesLock) + { + var profile = _additionalProfiles.FirstOrDefault(p => + string.Equals(p.Id, id, StringComparison.Ordinal) + ); + if (profile is null) + { + throw new ArgumentException( + $"Unknown OpenAI-compatible profile: {id}", + nameof(id) + ); + } + + return ( + profile.BaseUrl, + profile.SelectedModelId, + profile.SelectedLlmModelId, + _additionalApiKeys.GetValueOrDefault(id) + ); + } + } // The settings-save and activation paths replace _additionalProfiles wholesale // while the catalog refresh and the host's capability queries enumerate it across @@ -1088,14 +1160,6 @@ private List SnapshotProfiles() } } - private void ReplaceProfiles(IEnumerable profiles) - { - lock (_profileRolesLock) - { - _additionalProfiles.Clear(); - _additionalProfiles.AddRange(profiles); - } - } // A settings save during the awaited fetch swaps in new profile objects, so // mutating the snapshot's orphan would drop the catalog while reporting success. @@ -1186,8 +1250,6 @@ OpenAiCompatibleProfile right private OpenAiCompatibleProfile? FindAdditional(string id) => SnapshotProfiles().FirstOrDefault(p => string.Equals(p.Id, id, StringComparison.Ordinal)); - private OpenAiCompatibleProfile RequireAdditional(string id) => - FindAdditional(id) ?? throw new ArgumentException($"Unknown OpenAI-compatible profile: {id}", nameof(id)); private static string NormalizeBaseUrl(string url) { diff --git a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs index 79a10d958..99381d19d 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Webhook/WebhookPlugin.cs @@ -641,6 +641,7 @@ public async Task ActivateAsync(IPluginHostServices host) catch { service.Dispose(); + Service = null; Host = null; throw; } diff --git a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs index 32c1d1c31..89d607e9f 100644 --- a/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs +++ b/tests/TypeWhisper.Linux.Tests/WatchFolderServiceTests.cs @@ -1153,7 +1153,7 @@ observedRun is not null Assert.Null(service.WatchPath); Assert.Null(service.CurrentlyProcessing); Assert.True(run.CancellationSource.IsCancellationRequested); - Assert.IsType(run.WorkerFailure, exactMatch: false); + Assert.IsType(run.WorkerFailure, exactMatch: true); Assert.True(run.WorkerCompletion.IsCompletedSuccessfully); Assert.Empty(service.History); Assert.Empty(processed); From 82d774b161d64bdcf31634e49f037380736b18c4 Mon Sep 17 00:00:00 2001 From: Chris Smashe Date: Tue, 4 Aug 2026 14:03:05 -0400 Subject: [PATCH 226/226] Stop the package smoke gate racing a slow distro mirror Each of the three Ubuntu smoke containers installed the same ~95 MB of GUI and audio dependencies, and that download ran inside the 10-minute per-format timeout. When archive.ubuntu.com dropped to ~140 kB/s the apt work alone outlasted the budget, so the run was killed before a single assertion executed - twice, at a different container each time. Bake the dependencies into one prepared image per distribution up front, outside the timeout, so the per-format containers touch no package manager and the budget covers only the install and execute checks. Add apt and dnf retries so a dropped connection retries instead of failing the run, and raise the workflow step budget to cover the remaining one-time download. The release workflow gated on the same script and would have failed a real release identically. Also stage the rpm payload through a file instead of a pipe: cpio stops at the archive trailer and closes the pipe while rpm2cpio is still writing padding, so pipefail failed the run despite a complete extraction. --- .github/workflows/package-dry-run-linux.yml | 5 +- .github/workflows/release-linux.yml | 5 +- scripts/smoke-test-linux-packages.sh | 100 ++++++++++++++++++-- 3 files changed, 102 insertions(+), 8 deletions(-) diff --git a/.github/workflows/package-dry-run-linux.yml b/.github/workflows/package-dry-run-linux.yml index 311a725d9..b17725880 100644 --- a/.github/workflows/package-dry-run-linux.yml +++ b/.github/workflows/package-dry-run-linux.yml @@ -50,7 +50,10 @@ jobs: run: bash scripts/build-linux-packages.sh "${{ steps.meta.outputs.version }}" dist - name: Smoke test all Linux packages - timeout-minutes: 30 + # Covers building the two prepared images as well as the smoke runs + # themselves; a throttled distro mirror has pushed the dependency + # download alone past twenty minutes. + timeout-minutes: 45 run: bash scripts/smoke-test-linux-packages.sh "${{ steps.meta.outputs.version }}" dist - uses: actions/upload-artifact@v7 diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 907be5324..8fa18a502 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -133,7 +133,10 @@ jobs: run: bash scripts/build-linux-packages.sh "$VERSION" dist - name: Smoke test all Linux packages - timeout-minutes: 30 + # Covers building the two prepared images as well as the smoke runs + # themselves; a throttled distro mirror has pushed the dependency + # download alone past twenty minutes. + timeout-minutes: 45 run: bash scripts/smoke-test-linux-packages.sh "$VERSION" dist - uses: actions/upload-artifact@v7 diff --git a/scripts/smoke-test-linux-packages.sh b/scripts/smoke-test-linux-packages.sh index 9ee43b0b2..4328df43c 100755 --- a/scripts/smoke-test-linux-packages.sh +++ b/scripts/smoke-test-linux-packages.sh @@ -187,8 +187,29 @@ run_gui_probe() { echo " GUI remained alive for the full 20-second health window." } +# Written by --install-runtime once the dependencies are baked into the smoke +# image, so the per-format containers can skip the package manager entirely. +RUNTIME_READY_MARKER=/var/lib/typewhisper-smoke/runtime-ready + +runtime_already_installed() { + if [ -f "$RUNTIME_READY_MARKER" ]; then + echo "==> Runtime dependencies preinstalled in the smoke image." + return 0 + fi + return 1 +} + +mark_runtime_installed() { + mkdir -p "$(dirname "$RUNTIME_READY_MARKER")" + printf 'typewhisper smoke runtime dependencies installed\n' >"$RUNTIME_READY_MARKER" +} + install_ubuntu_runtime() { + runtime_already_installed && return 0 export DEBIAN_FRONTEND=noninteractive + # A flaky or throttled mirror otherwise fails the whole smoke run on a single + # dropped connection. + printf 'Acquire::Retries "3";\n' >/etc/apt/apt.conf.d/99-typewhisper-smoke-retries apt-get update # libjack/libasound back the bundled libportaudio.so. A desktop gets them via # pipewire-jack; a bare container does not, and without them PortAudio fails to @@ -222,8 +243,9 @@ install_ubuntu_runtime() { } install_fedora_runtime() { + runtime_already_installed && return 0 # See install_ubuntu_runtime: alsa-lib/jack back the bundled libportaudio.so. - dnf install -y \ + dnf install -y --setopt=retries=3 \ alsa-lib \ dbus-daemon \ fontconfig \ @@ -435,6 +457,20 @@ container_smoke_rpm() { assert_removed /opt/typewhisper } +# Runs inside `docker build`, so the dependency download happens once per smoke +# run instead of once per package format. +if [ "${1:-}" = "--install-runtime" ]; then + [ "$#" -eq 2 ] || fail "internal runtime mode requires a distribution." + case "$2" in + ubuntu) install_ubuntu_runtime ;; + fedora) install_fedora_runtime ;; + *) fail "unknown internal runtime distribution '$2'." ;; + esac + mark_runtime_installed + echo "==> Smoke runtime dependencies installed for $2." + exit 0 +fi + if [ "${1:-}" = "--container" ]; then [ "$#" -eq 4 ] \ || fail "internal container mode requires a format, package path, and CLI version." @@ -536,6 +572,11 @@ rpm -qip "$EXPECTED_RPM" EXTRACT_ROOT="$(mktemp -d)" cleanup_host() { rm -rf "$EXTRACT_ROOT" + # Defined further down, once the container stage is reached; a --validate-only + # run exits before then. + if declare -F cleanup_images >/dev/null; then + cleanup_images + fi } trap cleanup_host EXIT @@ -550,7 +591,12 @@ tar -xzf "$EXPECTED_TARBALL" --no-same-owner -C "$TARBALL_EXTRACT" dpkg-deb --extract "$EXPECTED_DEB" "$DEB_EXTRACT" ( cd "$RPM_EXTRACT" - rpm2cpio "$EXPECTED_RPM" | cpio --quiet -idmu --no-absolute-filenames + # Staged through a file rather than piped: cpio stops at the archive trailer + # and closes the pipe while rpm2cpio is still writing padding, so rpm2cpio + # takes SIGPIPE and pipefail fails the run even though extraction succeeded. + rpm2cpio "$EXPECTED_RPM" >"$EXTRACT_ROOT/rpm-payload.cpio" + cpio --quiet -idmu --no-absolute-filenames <"$EXTRACT_ROOT/rpm-payload.cpio" + rm -f "$EXTRACT_ROOT/rpm-payload.cpio" ) if ! ( cd "$APPIMAGE_EXTRACT" @@ -690,8 +736,45 @@ if ! docker info >/dev/null 2>&1; then fail "Docker is installed but its daemon is unavailable; container package smoke tests cannot run." fi -UBUNTU_IMAGE="ubuntu:24.04" -FEDORA_IMAGE="fedora:43" +UBUNTU_BASE_IMAGE="ubuntu:24.04" +FEDORA_BASE_IMAGE="fedora:43" +UBUNTU_IMAGE="typewhisper-smoke-ubuntu:$$" +FEDORA_IMAGE="typewhisper-smoke-fedora:$$" +BUILT_IMAGES=() + +cleanup_images() { + local image + for image in "${BUILT_IMAGES[@]+"${BUILT_IMAGES[@]}"}"; do + docker image rm --force "$image" >/dev/null 2>&1 || true + done +} + +# Bake the runtime dependencies into one image per distribution up front. Three +# Ubuntu formats otherwise download the same ~95 MB three times, and because that +# used to happen inside the per-format timeout a slow mirror killed the run +# before a single assertion executed. +build_smoke_image() { + local distribution="$1" + local base_image="$2" + local image="$3" + local context + + echo "==> Building $distribution smoke image from $base_image" + context="$(mktemp -d)" + cp "$SCRIPT_PATH" "$context/smoke-test-linux-packages.sh" + { + printf 'FROM %s\n' "$base_image" + printf 'COPY smoke-test-linux-packages.sh /smoke-test-linux-packages.sh\n' + printf 'RUN bash /smoke-test-linux-packages.sh --install-runtime %s\n' "$distribution" + } >"$context/Dockerfile" + + if ! docker build --pull --tag "$image" "$context"; then + rm -rf "$context" + fail "failed to build the $distribution smoke image." + fi + rm -rf "$context" + BUILT_IMAGES+=("$image") +} run_container_smoke() { local format="$1" @@ -700,10 +783,12 @@ run_container_smoke() { local container_name="typewhisper-package-smoke-${format}-$$" local status - echo "==> Running $format smoke test in pinned image $image" + echo "==> Running $format smoke test in prepared image $image" + # The dependencies are already baked in, so this budget now covers only the + # install/execute assertions, which take well under a minute. set +e timeout --signal=INT --kill-after=30s 10m \ - docker run --name "$container_name" --rm --pull=always \ + docker run --name "$container_name" --rm \ --mount "type=bind,src=$SCRIPT_PATH,dst=/smoke-test-linux-packages.sh,readonly" \ --mount "type=bind,src=$PACKAGE_DIR,dst=/packages,readonly" \ "$image" \ @@ -718,6 +803,9 @@ run_container_smoke() { fi } +build_smoke_image ubuntu "$UBUNTU_BASE_IMAGE" "$UBUNTU_IMAGE" +build_smoke_image fedora "$FEDORA_BASE_IMAGE" "$FEDORA_IMAGE" + # Keep these sequential so logs identify the failing format and package-manager # operations never overlap on the runner. run_container_smoke "tarball" "$UBUNTU_IMAGE" "$EXPECTED_TARBALL"