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/.github/workflows/package-dry-run-linux.yml b/.github/workflows/package-dry-run-linux.yml index 186a7e948..b17725880 100644 --- a/.github/workflows/package-dry-run-linux.yml +++ b/.github/workflows/package-dry-run-linux.yml @@ -37,7 +37,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - dpkg-dev rpm wget desktop-file-utils + cpio dpkg-dev file rpm wget desktop-file-utils - name: Resolve dry-run version id: meta @@ -49,7 +49,16 @@ jobs: - name: Build all Linux packages run: bash scripts/build-linux-packages.sh "${{ steps.meta.outputs.version }}" dist + - name: Smoke test all Linux packages + # 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 + # Keep the built packages available even when the smoke gate fails so a + # failed dry run can still be diagnosed; the job still reports failure. if: always() with: name: package-dry-run-linux-${{ steps.meta.outputs.version }} 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/pr-ci-linux.yml b/.github/workflows/pr-ci-linux.yml index 036fada05..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,13 +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 + 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() @@ -64,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/publish-plugins.yml b/.github/workflows/publish-plugins.yml index 8c1bbda5e..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: | @@ -161,98 +165,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/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 3cf5bef0b..8fa18a502 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -95,6 +95,8 @@ jobs: build: needs: prepare runs-on: ubuntu-latest + # 30m unit-test cap + 30m package-smoke cap + 30m setup/build/upload allowance. + timeout-minutes: 90 env: VERSION: ${{ needs.prepare.outputs.version }} @@ -115,19 +117,28 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - dpkg-dev rpm wget desktop-file-utils + cpio dpkg-dev file 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.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 + - name: Smoke test all Linux packages + # 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 with: name: release-linux-${{ env.VERSION }} diff --git a/README.md b/README.md index 20fec10ad..ac3e44703 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Press a key, talk, and have clean, punctuated text land in whatever app you're i - **Personalization** — searchable history, a dictionary with term packs, snippets, and app/URL-matched profiles. History also has an opt-in **Inspect** panel that shows exactly what was sent to the LLM for each entry — the raw→final diff, the exact prompt, injected memory context, and the reply, with local-vs-cloud labelling. See [Profiles](https://github.com/csmashe/typewhisper-linux/wiki/Profiles) and [History](https://github.com/csmashe/typewhisper-linux/wiki/History). - **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. A brief toast shows what was learned and offers **Undo**. Off by default (it reads the focused field); enable it under [Dictation](https://github.com/csmashe/typewhisper-linux/wiki/Dictation) settings, and review or remove learned entries in the [Dictionary](https://github.com/csmashe/typewhisper-linux/wiki/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 a `typewhisper` [CLI](https://github.com/csmashe/typewhisper-linux/wiki/CLI) client (currently built from source; release packages don't yet bundle the CLI binary). +- **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). Everything here is Linux-specific work adapted from the upstream macOS/Windows project: Wayland/X11 global hotkeys, compositor-native window and URL detection, session audio handling, and Linux packaging. The deep how-and-why for each lives in the wiki — start with [Wayland Notes](https://github.com/csmashe/typewhisper-linux/wiki/Wayland-Notes) if you're on Wayland. 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/Shared/Cuda/CudaRuntimeProvisioner.cs b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs index c3c15965a..d3c0bae41 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, } /// @@ -33,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 @@ -49,40 +53,43 @@ 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 // 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 +109,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 +131,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,19 +145,59 @@ 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); + 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); + _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. public string CacheDirectory { get; } + // Test seams: pin the lock paths and timeout so tests avoid a real per-user cache. + // ReSharper disable once ConvertToAutoPropertyWhenPossible -- the backing field is the real member, read throughout this class; these are read-only test seams over it. + internal string MaintenanceLockPathForTests => _maintenanceLockPath; + // ReSharper disable once ConvertToAutoPropertyWhenPossible -- the backing field is the real member, read throughout this class; these are read-only test seams over it. + 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 @@ -164,8 +211,28 @@ 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 ? WhisperWheels : OnnxRuntimeWheels; + profile == CudaRuntimeProfile.WhisperCublas ? s_whisperWheels : s_onnxRuntimeWheels; /// /// True when every CUDA library the needs is @@ -236,8 +303,8 @@ CancellationToken ct await _gate.WaitAsync(ct).ConfigureAwait(false); try { - Directory.CreateDirectory(CacheDirectory); - PruneStaleBundles(); + await TryMigrateLegacyCacheAsync(ct).ConfigureAwait(false); + EnsureExternalLockDirectory(); // A wheel is fetched unless EVERY library it provides is already // resolvable (on-system or in our cache). Checking only the primary @@ -246,7 +313,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) { @@ -261,6 +337,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 { @@ -268,6 +348,72 @@ CancellationToken ct } } + 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, @@ -285,30 +431,226 @@ 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) + { + 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); + } - foreach (var (wheel, url, size, sha256) in jobs) + 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 Task AcquireMaintenanceLocksAsync( + string operation, + CancellationToken ct + ) => + AcquireMaintenanceLocksAsync( + _maintenanceLockPath, + _wheelLockDirectory, + operation, + ct + ); + + private async Task AcquireMaintenanceLocksAsync( + string maintenanceLockPath, + string wheelLockDirectory, + string operation, + CancellationToken ct + ) + { + EnsureExternalLockDirectory(wheelLockDirectory); + 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(wheel => WheelLockPath(wheel, wheelLockDirectory)) + .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) => + WheelLockPath(wheel, _wheelLockDirectory); + + private static string WheelLockPath(CudaWheel wheel, string wheelLockDirectory) => + Path.Join(wheelLockDirectory, wheel.Package + ".lock"); + + private void EnsureExternalLockDirectory() => + 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) + { + 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( @@ -329,6 +671,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) @@ -382,14 +725,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)) @@ -485,6 +824,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) @@ -601,10 +941,11 @@ 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) { + // ReSharper disable once InlineTemporaryVariable -- the pattern binding carries the non-null narrowing; inlining it reads worse. if (SystemLibraryProbeForTests is { } probe) return probe(soname); @@ -651,7 +992,7 @@ private static bool LdConfigContains(string soname) RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); @@ -680,36 +1021,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 + await using ( + await AcquireMaintenanceLocksAsync( + "clearing the CUDA runtime cache", + ct + ) + .ConfigureAwait(false) + ) { - Directory.Delete(root, recursive: true); - _log?.Invoke($"CUDA runtime: cleared cache at {root}."); - } - 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}"); - throw; + 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 {_cacheRoot}: {ex.Message}" + ); + throw; + } finally { _gate.Release(); @@ -720,24 +1069,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}" + ); } } @@ -790,6 +1163,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); @@ -798,10 +1175,22 @@ internal static bool TryInitializeCudaDriver(out string? error) [DllImport("libdl.so.2")] 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, string[] RequiredSonames ); + + private sealed record CachePaths( + string CacheRoot, + string MaintenanceLockPath, + string WheelLockDirectory + ); } diff --git a/plugins/Shared/Net/InterProcessFileLock.cs b/plugins/Shared/Net/InterProcessFileLock.cs index 9aab05ce8..f8b3cc340 100644 --- a/plugins/Shared/Net/InterProcessFileLock.cs +++ b/plugins/Shared/Net/InterProcessFileLock.cs @@ -9,7 +9,7 @@ namespace TypeWhisper.Plugins.Shared.Net; /// A cross-process advisory lock built on an exclusively-opened sentinel file. /// /// The on-demand GPU artifacts stage into stable paths in a SHARED -/// cache so a dropped download can resume (see ). +/// cache so a dropped download can resume (see ResilientDownloader). /// A stable path means two writers can pick the same file, and the per-engine /// SemaphoreSlim gates only serialize within one provisioner instance — /// not the two file-linked copies of the CUDA provisioner in different plugin @@ -20,7 +20,7 @@ namespace TypeWhisper.Plugins.Shared.Net; /// /// /// Compiled into each plugin assembly via file-linking, so the type is -/// internal (like ). +/// internal (like ResilientDownloader). /// /// internal static class InterProcessFileLock 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.AssemblyAi/AssemblyAiPlugin.cs b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs index 050419bd7..d53f66229 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); @@ -170,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) @@ -178,13 +182,14 @@ 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() ?? ""; 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( @@ -199,7 +204,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 +218,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 +266,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 +274,8 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "api-key" => ApiKey, + "selectedModel" => SelectedModelId, _ => null, } ); @@ -294,10 +300,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..54ac6e32b 100644 --- a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs @@ -1,5 +1,6 @@ -using System.IO; +using System.Diagnostics; using System.Net.WebSockets; +using System.Runtime.ExceptionServices; using System.Text; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -8,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( @@ -24,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 @@ -32,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 = Encoding.UTF8.GetBytes("""{"terminate_session":true}"""); - 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) @@ -71,7 +149,7 @@ private async Task ReceiveLoopAsync(CancellationToken ct) try { - while (!ct.IsCancellationRequested && _ws.State == WebSocketState.Open) + while (true) { messageBuffer.SetLength(0); WebSocketReceiveResult result; @@ -79,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); @@ -91,42 +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.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/CerebrasPlugin.cs b/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs index 3c7ceb762..b46ed458f 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,18 +92,19 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct var source = OpenAiChatHelper.SendChatCompletionStreamingAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, model, systemPrompt, userText, ct ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) 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.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/ClaudePlugin.cs b/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs index 2f2cc5a1a..a4f0a540c 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"; @@ -15,9 +19,11 @@ public sealed partial class ClaudePlugin : ILlmProviderPlugin, IPluginSettingsPr // 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 string? _apiKey; private bool _streamResponses = true; public ClaudePlugin() @@ -37,7 +43,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 +59,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( @@ -75,14 +81,11 @@ 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"); - 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); @@ -133,14 +136,11 @@ [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"); - 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")); @@ -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; + } } /// @@ -270,8 +296,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) => @@ -287,8 +314,8 @@ 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(); - _apiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed; + var trimmed = apiKey.Trim(); + ApiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed; if (_host is not null) { if (string.IsNullOrEmpty(trimmed)) @@ -300,7 +327,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-"); } @@ -331,7 +358,7 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, + "api-key" => ApiKey, LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false", _ => null, @@ -368,12 +395,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.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/CloudflareAsrPlugin.cs b/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs index e241c72f5..e0386f93b 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,18 +10,27 @@ namespace TypeWhisper.Plugin.CloudflareAsr; -public sealed partial class CloudflareAsrPlugin +public sealed class CloudflareAsrPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware { - private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(120) }; + private readonly HttpClient _httpClient; private IPluginHostServices? _host; private string? _apiToken; private string? _accountId; - private string? _selectedModelId; - private static readonly IReadOnlyList Models = + 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)"), ]; @@ -36,7 +49,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 +64,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); } @@ -73,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"; @@ -107,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; @@ -180,7 +201,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) { @@ -213,7 +234,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 +244,7 @@ public IReadOnlyList GetSettingDefinitions() => { "account-id" => _accountId, "api-token" => _apiToken, - "selectedModel" => _selectedModelId, + "selectedModel" => SelectedModelId, _ => 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.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/CoherePlugin.cs b/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs index d38d6cdd9..e2ab14d43 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, 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, @@ -105,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.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/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..ccdbdcd3b 100644 --- a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs @@ -1,5 +1,6 @@ -using System.IO; +using System.Diagnostics; using System.Net.WebSockets; +using System.Runtime.ExceptionServices; using System.Text; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -8,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; @@ -21,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 @@ -31,32 +43,92 @@ 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}"; - 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 = Encoding.UTF8.GetBytes("""{"type":"CloseStream"}"""); - 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) @@ -66,7 +138,7 @@ private async Task ReceiveLoopAsync(CancellationToken ct) try { - while (!ct.IsCancellationRequested && _ws.State == WebSocketState.Open) + while (true) { messageBuffer.SetLength(0); WebSocketReceiveResult result; @@ -74,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); @@ -86,45 +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.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/ElevenLabsPlugin.cs b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs index 7914fde47..910c0fca9 100644 --- a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs +++ b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs @@ -1,3 +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.Buffers; using System.Net.Http.Headers; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -12,14 +18,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 SearchValues s_invalidKeytermCharacters = SearchValues.Create("<>{}[]\\"); - 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 +132,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 +148,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 +161,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 +189,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 +225,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 +255,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 +303,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 +370,7 @@ var part in prompt.Split( if ( term.Length == 0 || term.Length >= 50 - || term.IndexOfAny(InvalidKeytermCharacters) >= 0 + || term.AsSpan().IndexOfAny(s_invalidKeytermCharacters) >= 0 || term.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length > 5 || !seen.Add(term) ) @@ -393,7 +399,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 +409,8 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "api-key" => ApiKey, + "selectedModel" => SelectedModelId, _ => null, } ); @@ -429,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")); @@ -450,10 +456,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..10908a517 100644 --- a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs +++ b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs @@ -1,5 +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.Diagnostics; using System.Net.WebSockets; +using System.Runtime.ExceptionServices; using System.Text; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -10,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( @@ -26,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 @@ -46,10 +80,18 @@ 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) @@ -57,7 +99,24 @@ public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationTo 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 { @@ -71,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 - { - await _sendLock.WaitAsync(ct); - } - catch (ObjectDisposedException) + ThrowIfFaulted(); + if (Volatile.Read(ref _finalCommitSent) == 0) { - 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 ? Array.Empty() : _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) @@ -138,32 +239,65 @@ 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; } + // 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; @@ -173,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; @@ -183,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)); @@ -202,7 +376,7 @@ private async Task ReceiveLoopAsync(CancellationToken ct) try { - while (!ct.IsCancellationRequested && _ws.State == WebSocketState.Open) + while (true) { messageBuffer.SetLength(0); WebSocketReceiveResult result; @@ -210,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); @@ -222,35 +400,107 @@ 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}" + ); + + // ReSharper disable once InvertIf -- the positive form states the terminal-commit case that ends the receive loop. + 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) { @@ -269,18 +519,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 { - _receiveCts.Cancel(); - if (_ws.State == WebSocketState.Open) { // Bound the handshake: an unresponsive peer with CancellationToken.None @@ -312,6 +605,7 @@ await _ws.CloseAsync( } } + // ReSharper disable once MethodHasAsyncOverload -- MemoryStream has no async disposal work; DisposeAsync would only add overhead here. _audioBuffer.Dispose(); } finally @@ -321,5 +615,7 @@ await _ws.CloseAsync( _receiveCts.Dispose(); _ws.Dispose(); } + + _ = _terminalCompletion.Task.Exception; } } 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/FileMemoryPlugin.cs b/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs index b12b4d3b2..ec6367656 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,12 +247,33 @@ 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); + + 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/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/FireworksPlugin.cs b/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs index fdefe2e39..112cc4674 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,9 +10,8 @@ namespace TypeWhisper.Plugin.Fireworks; -public sealed partial class FireworksPlugin +public sealed class FireworksPlugin : ILlmProviderPlugin, - IDisposable, IPluginSettingsProvider, IPluginLocalizationAware { @@ -61,7 +64,7 @@ public void SetLocalization(IPluginLocalization localization) => public IReadOnlyList SupportedModels { get; } = [ - new PluginModelInfo( + new( "accounts/fireworks/models/llama4-scout-instruct-basic", "Llama 4 Scout" ) @@ -117,7 +120,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/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/GeminiPlugin.cs b/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs index 615895004..fead8ddec 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,18 +105,19 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct var source = OpenAiChatHelper.SendChatCompletionStreamingAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, model, systemPrompt, userText, ct ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -126,17 +130,18 @@ public void SetLocalization(IPluginLocalization localization) => internal async Task SetApiKeyAsync(string apiKey) { - var trimmed = apiKey?.Trim(); - _apiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed; + var trimmed = apiKey.Trim(); + // 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) @@ -180,7 +185,7 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, + "api-key" => ApiKey, LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false", _ => null, @@ -215,10 +220,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.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/GemmaLocalPlugin.cs b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs index 07a7d67e0..ad8db0cc9 100644 --- a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs @@ -1,6 +1,11 @@ +// 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.Collections.Immutable; using System.Diagnostics; -using System.IO; -using System.Net.Http; using LLama; using LLama.Common; using LLama.Sampling; @@ -11,7 +16,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", @@ -44,11 +49,14 @@ public sealed class GemmaLocalPlugin : ILlmProviderPlugin, IPluginSettingsProvid private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromHours(2) }; private readonly SemaphoreSlim _inferenceLock = new(1, 1); + private readonly Action _modelRoutingGuard; + + // 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 string? _selectedModelId; private LLamaWeights? _weights; private LLamaContext? _context; - private string? _loadedModelId; private bool _streamResponses = true; private CancellationTokenSource? _startupCts; private Task? _startupTask; @@ -57,34 +65,48 @@ public sealed class GemmaLocalPlugin : ILlmProviderPlugin, IPluginSettingsProvid public string PluginName => "Gemma 4 (Local)"; public string PluginVersion => "1.0.0"; + public GemmaLocalPlugin() + : this(null, EnsureRequestedModelIsActive) { } + + internal GemmaLocalPlugin( + string? loadedModelId, + Action modelRoutingGuard + ) + { + LoadedModelId = loadedModelId; + _modelRoutingGuard = + modelRoutingGuard ?? throw new ArgumentNullException(nameof(modelRoutingGuard)); + } + 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 () => @@ -121,6 +143,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) { } @@ -143,6 +166,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 { @@ -161,7 +185,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 +204,7 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "selectedModel" => _selectedModelId, + "selectedModel" => SelectedModelId, LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false", _ => null, } @@ -211,7 +235,11 @@ public async Task SetSettingValueAsync( await _inferenceLock.WaitAsync(ct).ConfigureAwait(false); try { - _selectedModelId = null; + lock (_selectionLock) + { + SelectedModelId = null; + } + _host?.SetSetting("selectedModel", string.Empty); UnloadModel(); } @@ -229,13 +257,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 +281,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,17 +296,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 - .Select(m => new PluginModelInfo(m.Id, m.DisplayName) - { - SizeDescription = m.SizeDescription, - EstimatedSizeMB = m.EstimatedSizeMB, - IsRecommended = m.IsRecommended, - }) - .ToList(); + public IReadOnlyList SupportedModels => + GetSupportedModels(LoadedModelId); public async Task ProcessAsync( string systemPrompt, @@ -289,6 +311,8 @@ CancellationToken ct await _inferenceLock.WaitAsync(ct); try { + _modelRoutingGuard(model, LoadedModelId); + if (_context is null || _weights is null) throw new InvalidOperationException( "No model loaded. Download and load a model first." @@ -334,6 +358,8 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct await _inferenceLock.WaitAsync(ct); try { + _modelRoutingGuard(model, LoadedModelId); + if (_context is null || _weights is null) throw new InvalidOperationException( "No model loaded. Download and load a model first." @@ -364,8 +390,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 +403,68 @@ 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; + // ReSharper disable once ConvertToAutoPropertyWhenPossible -- expression-bodied accessor returning the shared static list; not an auto-property candidate. + internal static IReadOnlyList ModelDefinitions => s_models; + + internal static IReadOnlyList GetSupportedModels(string? loadedModelId) + { + if (loadedModelId is null) + return ImmutableArray.Empty; + + var model = GetModelDefinition(loadedModelId); + // ReSharper disable once UseCollectionExpression -- a collection expression targets IReadOnlyList and lowers to a ReadOnlySingleElementList; ImmutableArray.Create keeps the concrete ImmutableArray return type both branches (and the tests) rely on. + return ImmutableArray.Create( + new PluginModelInfo(model.Id, model.DisplayName) + { + SizeDescription = model.SizeDescription, + EstimatedSizeMB = model.EstimatedSizeMB, + IsRecommended = model.IsRecommended, + } + ); + } + + internal static void EnsureRequestedModelIsActive( + string requestedModelId, + string? activeModelId + ) + { + if ( + s_models.All(m => + !string.Equals(m.Id, requestedModelId, StringComparison.Ordinal) + ) + ) + { + throw new InvalidOperationException( + $"Requested Gemma model '{requestedModelId}' is unknown; " + + $"the active Gemma model is '{activeModelId ?? "(none)"}'." + ); + } + + if (activeModelId is null) + { + throw new InvalidOperationException( + $"Requested Gemma model '{requestedModelId}' cannot run because " + + "the active Gemma model is '(none)'." + ); + } + + if (!string.Equals(requestedModelId, activeModelId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Requested Gemma model '{requestedModelId}' does not match " + + $"the active Gemma model '{activeModelId}'." + ); + } + } internal void SelectModel(string modelId) { _ = GetModelDefinition(modelId); - _selectedModelId = modelId; + lock (_selectionLock) + { + SelectedModelId = modelId; + } + _host?.SetSetting("selectedModel", modelId); _host?.NotifyCapabilitiesChanged(); } @@ -456,6 +540,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); @@ -504,13 +589,13 @@ 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 // 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,16 +629,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 { @@ -576,12 +663,12 @@ internal void UnloadModel() _context = null; _weights?.Dispose(); _weights = null; - _loadedModelId = null; + LoadedModelId = null; } // 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(); @@ -591,7 +678,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"); } @@ -610,7 +697,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) @@ -644,6 +731,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 { @@ -663,6 +751,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.GemmaLocal/TypeWhisper.Plugin.GemmaLocal.csproj b/plugins/TypeWhisper.Plugin.GemmaLocal/TypeWhisper.Plugin.GemmaLocal.csproj index 295056e15..be4e8e44a 100644 --- a/plugins/TypeWhisper.Plugin.GemmaLocal/TypeWhisper.Plugin.GemmaLocal.csproj +++ b/plugins/TypeWhisper.Plugin.GemmaLocal/TypeWhisper.Plugin.GemmaLocal.csproj @@ -8,6 +8,9 @@ true + + + 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/GladiaPlugin.cs b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs index 7bfee8268..71ba111dd 100644 --- a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs @@ -1,21 +1,62 @@ -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.Diagnostics; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; 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 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; - private string? _selectedModelId; - private static readonly IReadOnlyList Models = + private static readonly IReadOnlyList s_models = [ 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"; @@ -28,7 +69,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 +83,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,14 +101,13 @@ 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); } - // 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, @@ -75,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() @@ -86,6 +159,453 @@ 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; + } + + // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator -- LINQ would box JsonElement's struct enumerator. + 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) => @@ -101,7 +621,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) { @@ -127,7 +647,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 +656,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..360f160cc 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; @@ -83,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) @@ -260,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.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/GoogleCloudSttPlugin.cs b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs index 907fd047a..6e968c270 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.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.Buffers.Binary; -using System.Net.Http; using System.Text; using System.Text.Json; using TypeWhisper.PluginSDK; @@ -16,26 +20,37 @@ 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 { 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; private string? _apiKey; - private string? _selectedModelId; public GoogleCloudSttPlugin() : this(new HttpClientHandler()) { } + // 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. - // Timeout exceeds MaxSyncSeconds because it also covers upload and recognition time. internal GoogleCloudSttPlugin(HttpMessageHandler handler) => - _httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(120) }; + _httpClient = new HttpClient(handler) { Timeout = s_requestTimeout }; public string PluginId => "com.typewhisper.google-cloud-stt"; public string PluginName => "Google Cloud STT"; @@ -45,7 +60,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})"); } @@ -60,16 +75,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); } @@ -81,6 +97,17 @@ public async Task TranscribeAsync( CancellationToken ct ) { + var langCode = language?.Trim(); + if ( + string.IsNullOrWhiteSpace(langCode) + || string.Equals(langCode, "auto", StringComparison.OrdinalIgnoreCase) + ) + { + throw new NotSupportedException( + "Google Cloud STT requires an explicit language; automatic language detection is not supported." + ); + } + if (!IsConfigured) throw new InvalidOperationException(Loc.L("Settings.NotConfiguredApiKeyRequired")); @@ -89,31 +116,69 @@ 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"), // so expand 2-letter codes to a regional variant before sending. 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", }, @@ -121,14 +186,58 @@ 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. + // ReSharper disable once InvertIf -- the positive form states the tie-breaking rule described in the comment above. + 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 @@ -154,7 +263,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; @@ -166,34 +275,42 @@ 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 - ) + 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()) { - foreach (var alt in alternatives.EnumerateArray()) - { - if (alt.TryGetProperty("transcript", out var transcript)) - { - if (sb.Length > 0) - sb.Append(' '); - sb.Append(transcript.GetString()); - } - } + 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(' '); + sb.Append(transcript.GetString()); } } } @@ -203,9 +320,12 @@ 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") + billedStr.EndsWith('s') && double.TryParse( billedStr[..^1], System.Globalization.NumberStyles.Float, @@ -216,9 +336,14 @@ out var secs { duration = secs; } + else + { + throw InvalidResponse("'totalBilledTime' must be a duration string"); + } } 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 @@ -226,17 +351,27 @@ out var secs ) { var first = resultsForLang[0]; + // ReSharper disable once InvertIf -- the positive TryGetProperty form reads better than an inverted skip. 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 { @@ -312,7 +447,7 @@ public IReadOnlyList GetSettingDefinitions() => key switch { "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "selectedModel" => SelectedModelId, _ => null, } ); 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 191ed356a..744a1b6be 100644 --- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json +++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json @@ -3,7 +3,9 @@ "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", "pluginClass": "TypeWhisper.Plugin.GoogleCloudStt.GoogleCloudSttPlugin" } diff --git a/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs b/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs index d77a134f7..e459b6c3a 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,18 +192,19 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct var source = OpenAiChatHelper.SendChatCompletionStreamingAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, modelId, systemPrompt, userText, ct ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) 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.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/LinearPlugin.cs b/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs index fef980866..4bd69b674 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() { @@ -17,22 +22,30 @@ public sealed partial class LinearPlugin : IActionPlugin, IPluginSettingsProvide DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; - private readonly HttpClient _httpClient = new(); - private IPluginHostServices? _host; - private string? _apiKey; - private string? _defaultTeamId; - private string? _defaultProjectId; + 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"; 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 => _host; + public IPluginHostServices? Host { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -41,17 +54,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 +96,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,24 +106,23 @@ 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") ); 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( @@ -129,41 +143,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 = """ @@ -187,6 +201,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( @@ -202,7 +217,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 +228,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 +243,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 +278,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 +289,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 +320,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 +339,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 +348,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})" ); @@ -357,17 +372,18 @@ 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()); - _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})" ); @@ -383,10 +399,11 @@ 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()); - _host?.Log( + Host?.Log( PluginLogLevel.Error, $"Linear API response read failed: {ex.Message} (sha256:{fingerprint})" ); @@ -397,6 +414,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 +439,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 +461,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 +525,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 +554,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.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.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/ObsidianPlugin.cs b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs index 76a52519c..837fd27ab 100644 --- a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs @@ -1,25 +1,48 @@ -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.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 partial class ObsidianPlugin : IActionPlugin, IPluginSettingsProvider, IPluginLocalizationAware +public sealed class ObsidianPlugin : IActionPlugin, IPluginSettingsProvider, IPluginLocalizationAware { - private IPluginHostServices? _host; + 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"; 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 => _host; + internal IPluginHostServices? Host { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -28,12 +51,12 @@ 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; - _detectedVaults = DetectVaults(); + Host = host; + _detectedVaults = _detectVaults(); return Task.CompletedTask; } @@ -45,10 +68,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 +81,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"; @@ -70,7 +93,6 @@ CancellationToken ct string filePath; string filename; - string content; if (dailyNoteMode) { @@ -78,29 +100,21 @@ 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}"); + Host.Log(PluginLogLevel.Info, $"Saved transcription to {filePath}"); return new ActionResult(true, Loc.L("Settings.SavedTo", filename)); } @@ -164,6 +178,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 @@ -176,26 +191,205 @@ private static string SanitizeFilename(string filename) return string.IsNullOrWhiteSpace(result) ? "Transcription" : result; } - private static string EnsureUniqueFilePath(string filePath) - { - if (!File.Exists(filePath)) - return filePath; + private static Task WriteIndividualNoteAsync( + string filePath, + string content, + CancellationToken ct + ) => + WriteIndividualNoteAsync(filePath, content, WriteUtf8TextAsync, ct); + internal static async Task WriteIndividualNoteAsync( + string filePath, + string content, + Func writeAsync, + CancellationToken ct + ) + { 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++) { - candidate = Path.Join(dir, $"{nameWithoutExt} {counter}{ext}"); - counter++; - } while (File.Exists(candidate)); + var candidate = attempt == 0 + ? filePath + : Path.Join(dir, $"{nameWithoutExt} {attempt + 1}{ext}"); + FileStream claimedStream; - return candidate; + 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)) + { + 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 + ); + + 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. private static string EscapeYaml(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); @@ -223,13 +417,21 @@ 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); - vaults.Add(new ObsidianVaultInfo(name ?? vault.Name, 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)); } } } @@ -251,6 +453,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 +501,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 +519,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 +554,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.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/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/OpenAiChatGptClient.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs index 5ebef51e2..308019f87 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), @@ -80,13 +83,18 @@ internal static Dictionary CreateRequestBody( return body; } - internal static string? ParseResponseText(string body) => - ParseJsonResponseText(body) ?? ParseEventStreamResponseText(body); + internal static string? ParseResponseText(string body) + { + return TryParseJsonResponseText(body, out var responseText) + ? responseText + : 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)) { @@ -96,7 +104,10 @@ internal static Dictionary CreateRequestBody( var payload = line[6..]; if (payload == "[DONE]") - continue; + { + receivedDone = true; + break; + } JsonDocument doc; try @@ -112,10 +123,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) @@ -136,6 +150,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(); @@ -143,7 +163,42 @@ 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") + { + // 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}'."; + } + + 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 { @@ -151,7 +206,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 @@ -159,7 +217,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) @@ -181,15 +240,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.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..170ed4f2b 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 @@ -50,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); @@ -85,17 +90,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); } @@ -105,15 +108,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); } @@ -147,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."); } @@ -347,9 +346,11 @@ private async Task AcceptOnAnyListenerAsync(CancellationToken ct) private void StopListeners() { try { _v4Listener?.Stop(); } - catch { } + catch { /* Listener may already be stopped or disposed. */ } + try { _v6Listener?.Stop(); } - catch { } + catch { /* Listener may already be stopped or disposed. */ } + _v4Listener = null; _v6Listener = null; } @@ -409,14 +410,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 495e60694..2c564a2f6 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; @@ -40,32 +43,23 @@ 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; 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 readonly SemaphoreSlim _oauthCredentialGate = new(1, 1); + private OAuthCredentialSnapshot _oauthCredentials = OAuthCredentialSnapshot.Empty; 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,39 @@ internal OpenAiPlugin( 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)); - _oauthIdToken = NormalizeApiKey(await host.LoadSecretAsync(OAuthIdTokenSecretName)); - _authMode = OpenAiAuthModeExtensions.Parse(host.GetSetting(AuthModeSettingName)); - _selectedLlmModelId = host.GetSetting(SelectedLlmModelSettingName); + ApiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); + + 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)); + TtsInstructions = host.GetSetting(TtsInstructionsSettingName) ?? ""; + ReasoningEffort = NormalizeReasoningEffort(host.GetSetting(ReasoningEffortSettingName)); _fetchedLlmModels = host.GetSetting>(FetchedLlmModelsSettingName) ?? []; - _oauthAccountId = host.GetSetting(OAuthAccountIdSettingName); - _oauthPlanType = 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 +186,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 +202,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 +220,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 +229,7 @@ CancellationToken ct ); return await OpenAiRealtimeStreamingSession.TranscribeWavAsync( - _apiKey!, + ApiKey!, wavAudio, NormalizeLanguage(language), prompt, @@ -231,7 +240,7 @@ CancellationToken ct return await OpenAiTranscriptionHelper.TranscribeAsync( _httpClient, BaseUrl, - _apiKey!, + ApiKey!, _selectedApiModelName, wavAudio, NormalizeLanguage(language), @@ -244,20 +253,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 +278,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,46 +299,51 @@ 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); + var credentials = await ValidOAuthCredentialsAsync(ct); + var client = new OpenAiChatGptClient( + _httpClient, + credentials.AccessToken!, + credentials.AccountId + ); return await client.ProcessAsync( 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 +356,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 +364,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,18 +377,18 @@ [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) ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } @@ -382,8 +396,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 @@ -428,18 +444,29 @@ 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 + { + get + { + var credentials = Volatile.Read(ref _oauthCredentials); + return !string.IsNullOrWhiteSpace(credentials.RefreshToken) + || !string.IsNullOrWhiteSpace(credentials.AccessToken); + } + } + + internal string? ChatGptPlanType => Volatile.Read(ref _oauthCredentials).PlanType; + + internal string? SelectedLlmModelId { get; private set; } + + internal string ReasoningEffort { get; private set; } = "medium"; + + internal string TtsInstructions { get; private set; } = ""; - internal bool HasChatGptCredentials => - !string.IsNullOrWhiteSpace(_oauthRefreshToken) - || !string.IsNullOrWhiteSpace(_oauthAccessToken); + internal string TemperatureMode { get; private set; } = TemperatureModeProviderDefault; - 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 double TemperatureValue { get; private set; } = 0.3; internal static bool UsesResponsesApi(string modelId) { @@ -536,10 +563,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 +595,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 { @@ -579,7 +606,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)) @@ -623,7 +650,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 +658,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 +672,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,12 +707,12 @@ internal async Task LoginWithChatGptInBrowserAsync(CancellationToken ct = defaul Process.Start(new ProcessStartInfo { FileName = authUri.ToString(), - UseShellExecute = true + UseShellExecute = true, }); 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); } @@ -702,7 +729,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( @@ -714,30 +741,23 @@ 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; - _oauthIdToken = null; - _oauthAccountId = null; - _oauthPlanType = 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(); } } // API key / settings management - internal string? ApiKey => _apiKey; + internal string? ApiKey { get; private set; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -752,9 +772,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 +789,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 +818,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,74 +835,136 @@ 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; } - 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; - _oauthIdToken = tokens.IdToken; - _oauthAccountId = metadata.AccountId; - _oauthPlanType = 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(OAuthIdTokenSecretName, tokens.IdToken); - _host.SetSetting(OAuthAccountIdSettingName, _oauthAccountId); - _host.SetSetting(OAuthPlanTypeSettingName, _oauthPlanType); - _host.SetSetting(OAuthExpiresAtSettingName, _oauthExpiresAt); + await host.StoreSecretAsync(OAuthAccessTokenSecretName, credentials.AccessToken); + if (string.IsNullOrWhiteSpace(credentials.RefreshToken)) + await host.DeleteSecretAsync(OAuthRefreshTokenSecretName); + else + 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 // 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 +974,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) @@ -910,7 +992,7 @@ private void NormalizeSelectedLlmModel(bool persist) try { var value = host.GetSetting(OAuthExpiresAtSettingName); - return value == default ? null : value; + return value; } catch { @@ -974,7 +1056,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 +1064,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 +1143,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 +1223,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")); @@ -1167,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")); } @@ -1175,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) @@ -1239,13 +1321,25 @@ 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); + 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/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiRealtimeStreamingSession.cs index d30ec3f63..502262e67 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; @@ -15,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( @@ -66,9 +70,26 @@ 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) + { + // ReSharper disable once ConvertIfStatementToReturnStatement -- precondition guard; the suggested ternary-throw buries the throw. + 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; } @@ -103,7 +124,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 +139,7 @@ internal static string CreateSessionUpdatePayload(string? language, string? prom { var transcription = new Dictionary { - ["model"] = ModelId + ["model"] = ModelId, }; if (!string.IsNullOrWhiteSpace(language)) @@ -161,9 +182,9 @@ internal static string CreateSessionUpdatePayload(string? language, string? prom }, ["transcription"] = transcription, ["turn_detection"] = turnDetection, - } - } - } + }, + }, + }, }; return JsonSerializer.Serialize(payload); @@ -171,7 +192,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", @@ -198,11 +219,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 { @@ -214,40 +234,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) + { + 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) { - await SendTextAsync("""{"type":"input_audio_buffer.commit"}""", ct); - Volatile.Write(ref _audioPendingCommit, 0); + 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() @@ -264,6 +341,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]; @@ -279,7 +456,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); @@ -287,42 +467,52 @@ 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 // 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( - 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); } } @@ -344,7 +534,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) @@ -362,7 +552,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); @@ -413,7 +603,7 @@ public async ValueTask DisposeAsync() return; _disposed = true; - _receiveCts.Cancel(); + await _receiveCts.CancelAsync(); if (_ws.State == WebSocketState.Open) { @@ -445,7 +635,10 @@ public async ValueTask DisposeAsync() if (_receiveTask is not null) { try { await _receiveTask; } - catch { } + catch + { + //nada + } } _sendLock.Dispose(); @@ -557,8 +750,10 @@ 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)) { + // 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/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..2e74ec25e 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); @@ -200,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.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/OpenAiCompatiblePlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiCompatible/OpenAiCompatiblePlugin.cs index a14f532d1..9f2c06aba 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,14 +33,17 @@ 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 = []; 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) }) @@ -54,10 +62,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 +100,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 +108,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 +133,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 +154,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 +162,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 +176,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,29 +207,31 @@ [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, ct ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) 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 +241,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) @@ -240,31 +251,40 @@ internal void SetBaseUrl(string url) // Helpers append "/v1/..." themselves; pasted URLs often already // include "/v1", so strip a trailing "/v1" segment to avoid building // "/v1/v1/models" and similar paths. - var normalized = url.Trim().TrimEnd('/'); - if (normalized.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) - normalized = normalized[..^3]; - _baseUrl = normalized; + var normalized = NormalizeBaseUrl(url); + var changed = !string.Equals(BaseUrl, normalized, StringComparison.Ordinal); + BaseUrl = normalized; _host?.SetSetting("baseUrl", normalized); - _host?.NotifyCapabilitiesChanged(); + + if (changed) + SetFetchedModels([]); + else + _host?.NotifyCapabilitiesChanged(); } internal async Task SetApiKeyAsync(string key) { - _apiKey = string.IsNullOrWhiteSpace(key) ? null : key; + var apiKey = string.IsNullOrWhiteSpace(key) ? null : key; + var changed = !string.Equals(ApiKey, apiKey, StringComparison.Ordinal); + if (_host is not null) { - if (string.IsNullOrWhiteSpace(key)) + if (apiKey is null) await _host.DeleteSecretAsync("api-key"); else - await _host.StoreSecretAsync("api-key", key); - - _host.NotifyCapabilitiesChanged(); + await _host.StoreSecretAsync("api-key", apiKey); } + + ApiKey = apiKey; + if (changed) + SetFetchedModels([]); + else + _host?.NotifyCapabilitiesChanged(); } internal void SelectLlmModel(string modelId) { - _selectedLlmModelId = modelId; + SelectedLlmModelId = modelId; _host?.SetSetting("selectedLlmModel", modelId); } @@ -279,7 +299,13 @@ private static bool ParseBool(string? value) => internal void SetFetchedModels(List models, bool notifyCapabilitiesChanged = true) { + var selectedModelId = NormalizeModelSelection(SelectedModelId, models); + var selectedLlmModelId = NormalizeModelSelection(SelectedLlmModelId, models); + _fetchedModels = models; + SelectedModelId = selectedModelId; + SelectedLlmModelId = selectedLlmModelId; + try { var json = JsonSerializer.Serialize(models); @@ -288,6 +314,10 @@ internal void SetFetchedModels(List models, bool notifyCapabilitie catch { /* best effort */ } + + _host?.SetSetting("selectedModel", selectedModelId); + _host?.SetSetting("selectedLlmModel", selectedLlmModelId); + if (notifyCapabilitiesChanged) _host?.NotifyCapabilitiesChanged(); } @@ -298,14 +328,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 +368,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 +436,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, @@ -430,13 +460,18 @@ public async Task SetSettingValueAsync( case "api-key": await SetApiKeyAsync(value ?? string.Empty); break; + // Only honor a selection that exists in the authoritative catalog. On the + // host's full-form save, an earlier baseUrl/api-key change clears the catalog + // and both selections, but the form still carries the previous selection in + // these later fields; restoring it unconditionally would re-pair the new + // endpoint with a stale model. A genuine dropdown pick is always in-catalog. case "selectedModel": - if (!string.IsNullOrWhiteSpace(value)) - SelectModel(value); + if (IsKnownModel(value)) + SelectModel(value!); break; case "selectedLlmModel": - if (!string.IsNullOrWhiteSpace(value)) - SelectLlmModel(value); + if (IsKnownModel(value)) + SelectLlmModel(value!); break; case LlmStreamingSettings.StreamResponsesSettingKey: SetStreamResponses(ParseBool(value)); @@ -444,23 +479,20 @@ public async Task SetSettingValueAsync( } } + private bool IsKnownModel(string? modelId) => + !string.IsNullOrWhiteSpace(modelId) + && _fetchedModels.Any(m => string.Equals(m.Id, modelId, StringComparison.Ordinal)); + 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); - if (!valid) + var models = await FetchModelsAsync(ct); + if (models is null) return new PluginSettingsValidationResult(false, Loc.L("Settings.CouldNotConnect")); - var models = await FetchModelsAsync(ct) ?? []; SetFetchedModels(models, notifyCapabilitiesChanged: false); - - if (string.IsNullOrWhiteSpace(_selectedModelId) && models.Count > 0) - SelectModel(models[0].Id); - if (string.IsNullOrWhiteSpace(_selectedLlmModelId) && models.Count > 0) - SelectLlmModel(models[0].Id); - _host?.NotifyCapabilitiesChanged(); return new PluginSettingsValidationResult( @@ -469,57 +501,115 @@ public async Task SetSettingValueAsync( ); } - // IModelCatalogProvider: read-only model-list refresh for dropdown-open. - // Only the model catalog is touched — no connection-validation message, no - // asset downloads, no auto-selecting a model. Keeps the cached list on a - // transient failure (FetchModelsAsync returns null) so an unreachable - // endpoint doesn't empty the dropdown, but honors a successful empty - // response (empty list) so a server that legitimately dropped all its - // models clears the cache. + // IModelCatalogProvider: model-list refresh for dropdown-open. A successful + // response is authoritative for both the catalog and selections; a transient + // failure leaves all three untouched. 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)) + if (models is not null && DefaultCatalogStateChanged(models)) SetFetchedModels(models); } // Refresh additional profiles on the same dropdown-open path so their // catalogs don't go stale when a server adds or removes models after the // profile was first saved. - var anyProfileChanged = false; + var changedProfileIds = new HashSet(StringComparer.Ordinal); foreach (var profile in _additionalProfiles.Where(p => !string.IsNullOrEmpty(p.BaseUrl))) { var models = await FetchModelsForAsync(profile.BaseUrl, GetProfileApiKey(profile.Id), ct); - if (models is null || !CatalogChanged(models, profile.FetchedModels)) + if (models is null || !ProfileCatalogStateChanged(profile, models)) continue; - profile.FetchedModels = models; - anyProfileChanged = true; + ApplyProfileCatalog(profile, models); + changedProfileIds.Add(profile.Id); } - if (anyProfileChanged) - PersistAdditionalProfiles(notify: true); + if (changedProfileIds.Count > 0) + { + PersistAdditionalProfiles(notify: false); + lock (_profileRolesLock) + { + foreach (var id in changedProfileIds) + _profileRoles.Remove(id); + } + + _host?.NotifyCapabilitiesChanged(); + } } private static bool CatalogChanged(List fetched, List current) => - fetched.Count != current.Count - || !fetched.Select(m => m.Id).SequenceEqual(current.Select(m => m.Id)); + !fetched.SequenceEqual(current); + + private bool DefaultCatalogStateChanged(List models) => + CatalogChanged(models, _fetchedModels) + || !string.Equals( + SelectedModelId, + NormalizeModelSelection(SelectedModelId, models), + StringComparison.Ordinal + ) + || !string.Equals( + SelectedLlmModelId, + NormalizeModelSelection(SelectedLlmModelId, models), + StringComparison.Ordinal + ); + + private static bool ProfileCatalogStateChanged( + OpenAiCompatibleProfile profile, + List models + ) => + CatalogChanged(models, profile.FetchedModels) + || !string.Equals( + profile.SelectedModelId, + NormalizeModelSelection(profile.SelectedModelId, models), + StringComparison.Ordinal + ) + || !string.Equals( + profile.SelectedLlmModelId, + NormalizeModelSelection(profile.SelectedLlmModelId, models), + StringComparison.Ordinal + ); + + private static void ApplyProfileCatalog( + OpenAiCompatibleProfile profile, + List models + ) + { + profile.SelectedModelId = NormalizeModelSelection(profile.SelectedModelId, models); + profile.SelectedLlmModelId = NormalizeModelSelection(profile.SelectedLlmModelId, models); + profile.FetchedModels = models; + } + + private static string? NormalizeModelSelection( + string? selectedModelId, + List models + ) + { + if (models.Count == 0) + return null; + + return !string.IsNullOrWhiteSpace(selectedModelId) + && models.Any(m => string.Equals(m.Id, selectedModelId, StringComparison.Ordinal)) + ? selectedModelId + : models[0].Id; + } private List? BuildModelOptions() { var models = _fetchedModels.Select(m => 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; @@ -530,19 +620,19 @@ private static bool CatalogChanged(List fetched, List AdditionalTranscriptionEngines => + public IReadOnlyList AdditionalTranscriptionEngines => _additionalProfiles - .Select(p => (ITranscriptionEnginePlugin)new OpenAiCompatibleProfileRole(this, p.Id)) + .Select(ITranscriptionEngineRole (profile) => GetProfileRole(profile.Id)) .ToList(); - public IReadOnlyList AdditionalLlmProviders => + public IReadOnlyList AdditionalLlmProviders => _additionalProfiles - .Select(p => (ILlmProviderPlugin)new OpenAiCompatibleProfileRole(this, p.Id)) + .Select(ILlmProviderRole (profile) => GetProfileRole(profile.Id)) .ToList(); public IReadOnlyList GetCollectionDefinitions() => [ - new PluginCollectionDefinition( + new( Key: ProfilesCollectionKey, Label: Loc.L("Settings.ProfilesLabel"), Description: Loc.L("Settings.ProfilesDescription"), @@ -633,29 +723,35 @@ public async Task SetItemsAsync( var id = NormalizeProfileId(Get(item, "__id"), seenIds); - var key = Get(item, "api-key"); - var keyChanged = !string.IsNullOrWhiteSpace(key); + var key = NullIfWhiteSpace(Get(item, "api-key")); + var keyChanged = key is not null + && !string.Equals(GetProfileApiKey(id), key, StringComparison.Ordinal); if (keyChanged) - keyUpdates[id] = key!.Trim(); + keyUpdates[id] = key; - // Preserve the fetched model catalog only when the endpoint is unchanged. - // A changed base URL (or updated credentials) can point at a different - // server, so drop the stale catalog and let the refetch below repopulate - // it — otherwise the profile would keep advertising the previous server's - // model IDs to dictation and prompt selection. + // A changed endpoint (base URL or credentials) may point at a different + // server, so drop the stale catalog and this save's submitted selections + // rather than pair the new endpoint with the previous server's model IDs. var hadProfile = previousById.TryGetValue(id, out var prev); - var endpointUnchanged = hadProfile - && !keyChanged - && string.Equals(prev!.BaseUrl, baseUrl, StringComparison.Ordinal); + var endpointChanged = hadProfile + && (keyChanged + || !string.Equals(prev!.BaseUrl, baseUrl, StringComparison.Ordinal)); + var preserveCatalog = hadProfile && !endpointChanged; + var selectedModelId = endpointChanged + ? null + : NullIfWhiteSpace(Get(item, "selectedModel")); + var selectedLlmModelId = endpointChanged + ? null + : NullIfWhiteSpace(Get(item, "selectedLlmModel")); newProfiles.Add(new OpenAiCompatibleProfile { Id = id, Name = name.Length == 0 ? "Custom Server" : name, BaseUrl = baseUrl, - SelectedModelId = NullIfWhiteSpace(Get(item, "selectedModel")), - SelectedLlmModelId = NullIfWhiteSpace(Get(item, "selectedLlmModel")), - FetchedModels = endpointUnchanged ? prev!.FetchedModels : [], + SelectedModelId = selectedModelId, + SelectedLlmModelId = selectedLlmModelId, + FetchedModels = preserveCatalog ? prev!.FetchedModels : [], }); } @@ -684,6 +780,7 @@ public async Task SetItemsAsync( // State is now persisted; the best-effort model fetch below may fail or be // cancelled, but that must not revert the saved profiles. PersistAdditionalProfiles(notify: false); + InvalidateChangedProfileRoles(previousById, keyUpdates.Keys); // Best-effort: populate model catalogs so prompts/dictation can list each // profile's models. New profiles and profiles whose endpoint changed have an @@ -693,10 +790,12 @@ public async Task SetItemsAsync( { var models = await FetchModelsForAsync(profile.BaseUrl, GetProfileApiKey(profile.Id), ct); if (models is not null) - profile.FetchedModels = models; + ApplyProfileCatalog(profile, models); } - PersistAdditionalProfiles(notify: true); + PersistAdditionalProfiles(notify: false); + InvalidateChangedProfileRoles(previousById, keyUpdates.Keys); + _host?.NotifyCapabilitiesChanged(); return new PluginSettingsValidationResult(true, $"Saved {_additionalProfiles.Count} profile(s)."); } @@ -741,7 +840,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); } @@ -837,28 +945,42 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct ct ); - await foreach (var delta in source.WithCancellation(ct)) + await foreach (var delta in source) yield return delta; } 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(); - 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.Where(p => p is not null)) + 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(); + // 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); @@ -867,6 +989,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) @@ -921,12 +1054,68 @@ 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 OpenAiCompatibleProfileRole GetProfileRole(string id) + { + lock (_profileRolesLock) + { + // ReSharper disable once InvertIf -- standard get-or-add shape; inverting would duplicate the return. + 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)); @@ -978,19 +1167,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; @@ -1003,10 +1190,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( @@ -1031,7 +1214,6 @@ public IAsyncEnumerable ProcessStreamingAsync( CancellationToken ct ) => owner.ProcessStreamingForProfileAsync(profileId, systemPrompt, userText, model, ct); - public void Dispose() { } } } 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/OpenAiVectorMemoryPlugin.cs b/plugins/TypeWhisper.Plugin.OpenAiVectorMemory/OpenAiVectorMemoryPlugin.cs index b13a0846e..c02a1c3a8 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,15 +16,26 @@ 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 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"; @@ -115,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 @@ -313,7 +338,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 +366,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 +381,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); } @@ -381,5 +407,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.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/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/OpenRouterPlugin.cs b/plugins/TypeWhisper.Plugin.OpenRouter/OpenRouterPlugin.cs index 37b3b4e2f..3c94601f4 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,21 +199,22 @@ 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)) + await foreach (var delta in source) yield return delta; } // 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")); @@ -909,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.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/Qwen3SttPlugin.cs b/plugins/TypeWhisper.Plugin.Qwen3Stt/Qwen3SttPlugin.cs index edf5242d2..259f459af 100644 --- a/plugins/TypeWhisper.Plugin.Qwen3Stt/Qwen3SttPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Qwen3Stt/Qwen3SttPlugin.cs @@ -1,21 +1,33 @@ -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"; - private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; + private readonly HttpClient _httpClient; private IPluginHostServices? _host; private string? _apiKey; private string? _baseUrl; - private string? _selectedModelId; + + 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"; @@ -28,7 +40,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 +55,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 +87,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 +172,7 @@ public IReadOnlyList GetSettingDefinitions() => { "baseUrl" => _baseUrl, "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "selectedModel" => SelectedModelId, _ => null, } ); 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/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/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..33ba48a06 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,23 @@ 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))]; + // 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; - 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 +122,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"); @@ -161,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(); }; @@ -194,9 +200,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 +220,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 +245,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 +276,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 +305,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 +323,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 +344,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 +358,6 @@ internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken c { return false; } - catch (OperationCanceledException) { throw; } catch (HttpRequestException) { return false; @@ -368,8 +373,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 +383,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 +397,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 +411,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 +427,7 @@ internal static Uri BuildPrerecordedUri(string baseUrl, string? modelId, string? { "encoding=pcm_s16le", "sample_rate=16000", - "channels=1" + "channels=1", }; if (!string.IsNullOrWhiteSpace(language)) @@ -519,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: @@ -562,38 +569,56 @@ public static byte[] ExtractPcm16(byte[] wavAudio) var offset = 12; short audioFormat = 0; short channels = 0; - int sampleRate = 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) + var remaining = wavAudio.Length - offset; + + if (chunkId == "data") + { + // 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; - if (chunkId == "fmt " && chunkSize >= 16) + 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); } - else if (chunkId == "data") - { - data = wavAudio.Skip(offset).Take(chunkSize).ToArray(); - } - offset += chunkSize + (chunkSize % 2); + offset += size + size % 2; } if (data is null) return wavAudio; - if (audioFormat == 1 && channels == 1 && sampleRate == 16000 && bitsPerSample == 16) - return data; + // 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; } @@ -603,6 +628,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 907f4722e..a7f275548 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; @@ -9,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; @@ -39,6 +45,20 @@ public static async Task ConnectAsync( await ws.ConnectAsync(BuildRealtimeUri(baseUrl, modelId, language), ct); + return CreateStartedSession(ws); + } + + internal static Reson8StreamingSession CreateConnectedSessionForTests(WebSocket ws) + { + // ReSharper disable once ConvertIfStatementToReturnStatement -- precondition guard; the suggested ternary-throw buries the throw. + 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; @@ -57,7 +77,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 +85,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,47 +108,67 @@ 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) { - 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) @@ -186,34 +226,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; - _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 +330,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.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/ScriptPlugin.cs b/plugins/TypeWhisper.Plugin.Script/ScriptPlugin.cs index 53b49fa52..a8e8ae159 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( @@ -166,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) @@ -213,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, @@ -231,7 +247,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.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/SherpaCudaRuntimeInstaller.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaCudaRuntimeInstaller.cs index 19677491e..a1defb939 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; @@ -194,9 +197,11 @@ 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; + // 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/SherpaDecodeCoordinator.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaDecodeCoordinator.cs new file mode 100644 index 000000000..9f1b97d18 --- /dev/null +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaDecodeCoordinator.cs @@ -0,0 +1,217 @@ +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 + ); + const int 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); + // <= 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; + } + } + + 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++) + { + // ReSharper disable once InvertIf -- already the inverted mismatch guard; inverting again would re-nest the loop body. + if ( + !string.Equals( + accumulatedTokens[accumulatedTokens.Length - length + i], + nextTokens[i], + StringComparison.Ordinal + ) + ) + { + matches = false; + break; + } + } + + // ReSharper disable once InvertIf -- the positive form states the "overlap found" case that ends the search. + 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); + + // 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) + && textNode.ValueKind is JsonValueKind.String or JsonValueKind.Null) + { + text = textNode.GetString()?.Trim() ?? string.Empty; + } + + string? language = null; + // ReSharper disable once InvertIf -- the positive TryGetProperty form reads better than an inverted skip. + // ValueKind guard: GetString() throws on a non-string element, so a canary payload + // with a numeric or boolean "lang" must fall back rather than fault the decode. + if (json.RootElement.TryGetProperty("lang", out var languageNode) + && languageNode.ValueKind == JsonValueKind.String) + { + 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/SherpaOnnxNativeRuntime.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxNativeRuntime.cs index 407106918..ee8a8345c 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,16 +41,17 @@ 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 bool _resolverRegistered; - private static string? _cudaRuntimeDirectory; + private static readonly Lock s_sync = new(); + private static bool s_resolverRegistered; + private static string? s_cudaRuntimeDirectory; /// /// Registers the import resolver once. Safe (and cheap) to call even on the @@ -61,16 +61,16 @@ internal static class SherpaOnnxNativeRuntime /// public static void RegisterResolver() { - lock (Sync) + lock (s_sync) { - if (_resolverRegistered) + if (s_resolverRegistered) return; NativeLibrary.SetDllImportResolver( typeof(OfflineRecognizer).Assembly, ResolveNativeLibrary ); - _resolverRegistered = true; + s_resolverRegistered = true; } } @@ -84,15 +84,15 @@ 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) + if (!s_resolverRegistered) { NativeLibrary.SetDllImportResolver( typeof(OfflineRecognizer).Assembly, ResolveNativeLibrary ); - _resolverRegistered = true; + s_resolverRegistered = true; } foreach (var soname in PreloadOrder) @@ -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()); @@ -115,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; } } @@ -125,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. @@ -147,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.SherpaOnnx/SherpaOnnxPlugin.cs b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs index cacfcafa2..ff509c933 100644 --- a/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.SherpaOnnx/SherpaOnnxPlugin.cs @@ -1,8 +1,7 @@ using System.Diagnostics; -using System.IO; -using System.Net.Http; +using System.Globalization; using System.Runtime.InteropServices; -using System.Text.Json; +using System.Text; using SherpaOnnx; using TypeWhisper.Plugins.Shared.Cuda; using TypeWhisper.Plugins.Shared.Net; @@ -11,14 +10,14 @@ 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"; 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 +25,27 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP "es", ]; - private static readonly IReadOnlyList Models = + // 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( "parakeet-tdt-0.6b", @@ -36,11 +55,12 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP 25, true, false, + true, [ - 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( @@ -51,15 +71,16 @@ public sealed class SherpaOnnxPlugin : ITypeWhisperPlugin, ITranscriptionEngineP 4, false, true, + false, [ - 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,15 +91,16 @@ 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; + private Func _parakeetRecognizerFactory = + CreateParakeetRecognizer; private SherpaCudaRuntimeInstaller? _cudaRuntimeInstaller; 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 +115,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 +126,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 +146,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,30 +162,37 @@ 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) { _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() @@ -178,7 +204,7 @@ public Task DeactivateAsync() public void SelectModel(string modelId) { _ = GetModelDefinition(modelId); - _selectedModelId = modelId; + SelectedModelId = modelId; } public Task ConfigureComputeBackendAsync(string backend) @@ -218,7 +244,7 @@ public Task ConfigureComputeBackendAsync(string backend) public void SetAccelerationPreference(TranscriptionAccelerationPreference preference) { - _accelerationPreference = preference; + AccelerationPreference = preference; var desired = preference == TranscriptionAccelerationPreference.NvidiaCuda ? "cuda" : "cpu"; @@ -229,7 +255,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 +279,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 +330,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 @@ -314,7 +341,8 @@ await ResilientDownloader.DownloadToFileAsync( lastReport = now; } }, - verifyComplete: null, + verifyComplete: path => + VerifyModelArtifact(path, file.FileName, model.RequiresBlankToken), ct ); @@ -354,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( @@ -370,80 +402,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 - { - _recognizer = model.SupportsTranslation - ? CreateCanaryRecognizer(dir, "en", "en", activeProvider) - : CreateParakeetRecognizer(dir, activeProvider); - } - catch (Exception ex) - when (string.Equals(activeProvider, "cuda", StringComparison.Ordinal)) + lock (_sync) { - // 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) @@ -606,8 +652,10 @@ CancellationToken ct return Task.Run( () => { + ct.ThrowIfCancellationRequested(); var audioSamples = DecodeWav(wavAudio); var audioDuration = audioSamples.Length / 16000.0; + ct.ThrowIfCancellationRequested(); lock (_sync) { @@ -621,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 ); @@ -700,8 +754,66 @@ 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( + 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 static 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) => - Models.FirstOrDefault(m => m.Id == modelId) + s_models.FirstOrDefault(m => m.Id == modelId) ?? throw new ArgumentException($"Unknown model: {modelId}"); private void UnloadRecognizer() @@ -720,6 +832,262 @@ 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; + } + + // ReSharper disable once InvertIf -- matches the shape of the preceding per-artifact dispatch block. + 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.Contains('\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) + { + // ReSharper disable once SuggestVarOrType_SimpleTypes -- var would infer non-nullable Exception, so assigning InnerException in the iterator would warn. + for (Exception? current = exception; current is not null; current = current.InnerException) + { + // ReSharper disable once ConvertIfStatementToSwitchStatement -- two independent type guards in a walk-the-chain loop; a switch would hide that both just return true. + 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(); @@ -794,12 +1162,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." @@ -848,43 +1216,28 @@ private void EnsureCanaryLanguage(string? language, bool translate) _canaryTgtLang = tgtLang; } - 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"; - } - - private static (string Text, string? DetectedLanguage) ParseCanaryResult(string rawText) + internal static string NormalizeCanaryLanguage(string? language) { - if (string.IsNullOrWhiteSpace(rawText)) - return (string.Empty, null); - - try + var normalized = language?.Trim(); + if ( + string.IsNullOrWhiteSpace(normalized) + || string.Equals(normalized, "auto", StringComparison.OrdinalIgnoreCase) + ) { - 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; - if (json.RootElement.TryGetProperty("lang", out var langNode)) - { - var parsed = langNode.GetString(); - if (!string.IsNullOrWhiteSpace(parsed)) - lang = parsed; - } - - return (text, lang); + throw new NotSupportedException( + "Sherpa ONNX Canary requires an explicit source language from the supported set: en, de, fr, es." + ); } - catch (JsonException) + + normalized = normalized.ToLowerInvariant(); + if (!s_canarySupportedLanguages.Contains(normalized)) { - return (rawText.Trim(), null); + throw new NotSupportedException( + "Sherpa ONNX Canary requires an explicit source language from the supported set: en, de, fr, es." + ); } + + return normalized; } private static float[] DecodeWav(byte[] wavData) @@ -895,7 +1248,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 @@ -949,7 +1302,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 +1322,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 @@ -1001,16 +1355,22 @@ 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, 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 ); 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.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/SmallestAiPlugin.cs b/plugins/TypeWhisper.Plugin.SmallestAi/SmallestAiPlugin.cs index e54f6e458..ea2c74b39 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,17 @@ 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; + // 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; - 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 +97,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 +117,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,19 +143,21 @@ 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; + // 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 { @@ -184,7 +190,6 @@ internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken c { return false; } - catch (OperationCanceledException) { throw; } catch (HttpRequestException) { return false; @@ -217,7 +222,7 @@ internal static PluginTranscriptionResult ParseTranscriptionResponse(string json return new PluginTranscriptionResult(text, language, duration, NoSpeechProbability: null) { - Segments = segments + Segments = segments, }; } @@ -310,6 +315,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) { @@ -334,11 +340,14 @@ 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)) { + // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here. 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 +419,7 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - ApiKeySecretName => _apiKey, + ApiKeySecretName => ApiKey, _ => null, } ); @@ -431,10 +440,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..9720d36d1 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; @@ -9,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; @@ -33,6 +39,20 @@ 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) + { + // ReSharper disable once ConvertIfStatementToReturnStatement -- precondition guard; the suggested ternary-throw buries the throw. + 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; @@ -43,7 +63,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 +79,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) @@ -72,42 +92,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) @@ -173,64 +213,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; - _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}" + ); + } } } @@ -269,6 +452,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.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/SonioxPlugin.cs b/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs index 0e28779ae..647301384 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,23 +23,24 @@ 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 TimeSpan s_defaultCleanupBudget = TimeSpan.FromSeconds(5); - private static readonly IReadOnlyList Models = + private static readonly IReadOnlyList s_models = [ new(DefaultModelId, "Soniox Async") { - IsRecommended = true + IsRecommended = true, }, ]; 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; - private string? _apiKey; private string _selectedModelId = DefaultModelId; public SonioxPlugin() @@ -47,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 ?? DefaultPollDelay; + _pollDelay = pollDelay ?? s_defaultPollDelay; _maxPollAttempts = maxPollAttempts; + _cleanupBudget = resolvedCleanupBudget; } // ITypeWhisperPlugin @@ -66,7 +76,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,10 +91,11 @@ 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; + // ReSharper disable once ReturnTypeCanBeNotNullable -- matches the interface contract, which declares this member nullable. public string? SelectedModelId => _selectedModelId; public bool SupportsTranslation => false; @@ -96,7 +107,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 +130,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 +166,7 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, + "api-key" => ApiKey, _ => null, }); @@ -171,10 +182,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 +193,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 +214,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 +231,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; @@ -360,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( @@ -410,6 +462,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 +498,7 @@ internal static PluginTranscriptionResult ParseTranscript( return new PluginTranscriptionResult(text, detectedLanguage ?? fallbackLanguage, duration, NoSpeechProbability: null) { - Segments = BuildSubtitleSegments(segmentTokens) + Segments = BuildSubtitleSegments(segmentTokens), }; } @@ -509,7 +562,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 +586,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 +690,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..21501212e 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,9 +156,11 @@ 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) { + // 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) @@ -295,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.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/SpeechmaticsPlugin.cs b/plugins/TypeWhisper.Plugin.Speechmatics/SpeechmaticsPlugin.cs index 8623d9fb6..eb9d94c34 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); } @@ -102,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" }, } ); @@ -185,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( @@ -199,6 +200,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( @@ -213,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}"); } @@ -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..f504f34c1 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; @@ -192,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") @@ -315,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) @@ -374,7 +375,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.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/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..e36791a71 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; @@ -37,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) @@ -110,8 +110,11 @@ 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 })), + // 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.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..0a60c8ba0 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; @@ -57,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); @@ -114,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 3deea50b4..f53bbe988 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); @@ -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 906d1267f..f264bb53a 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; @@ -57,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) { } @@ -74,13 +75,13 @@ 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; _assetManager = assetManager; _synthesizerFactory = synthesizerFactory; - _playbackFactory = playbackFactory - ?? ((samples, sampleRate) => SupertonicTtsPlaybackSession.Create(samples, sampleRate)); + _playbackFactory = playbackFactory ?? SupertonicTtsPlaybackSession.Create; } public string PluginId => "com.typewhisper.supertonic-tts"; @@ -89,11 +90,13 @@ 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; + // 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; - internal bool HasAcceptedModelLicense => _licenseAccepted; + internal bool HasAcceptedModelLicense { get; private set; } + internal bool AreAssetsReady => IsConfigured; private IPluginLocalization? _injectedLocalization; @@ -105,6 +108,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 @@ -125,7 +129,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 +141,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 +215,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() ), @@ -239,10 +244,10 @@ public IReadOnlyList GetSettingDefinitions() => ]; public Task GetSettingValueAsync(string key, CancellationToken ct = default) => - Task.FromResult( + 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 +284,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 +320,7 @@ or InvalidOperationException internal void SetLicenseAccepted(bool accepted) { - _licenseAccepted = accepted; + HasAcceptedModelLicense = accepted; _host?.SetSetting(LicenseAcceptedSettingName, accepted); } @@ -333,10 +338,9 @@ 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 (!_licenseAccepted) + if (!HasAcceptedModelLicense) throw new InvalidOperationException("The Supertonic 3 OpenRAIL-M license must be accepted before downloading model assets."); if (_assetManager is null) @@ -349,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; @@ -448,8 +457,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 +470,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 +483,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.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/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 c288312f7..cf21c2b34 100644 --- a/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Voxtral/VoxtralPlugin.cs @@ -1,21 +1,34 @@ -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; -using TypeWhisper.PluginSDK.Helpers; using TypeWhisper.PluginSDK.Models; 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"; private const string LegacyModelId = "mistral-whisper"; - private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(60) }; + private readonly HttpClient _httpClient; private IPluginHostServices? _host; - private string? _apiKey; - private string? _selectedModelId; + + public VoxtralPlugin() + : this(new HttpClient { Timeout = TimeSpan.FromSeconds(60) }) + { + } + + // The plugin takes ownership of the supplied client and disposes it. + internal VoxtralPlugin(HttpClient httpClient) + { + _httpClient = httpClient; + } public string PluginId => "com.typewhisper.voxtral"; public string PluginName => "Voxtral"; @@ -24,9 +37,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 +63,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 +80,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); } @@ -78,24 +92,153 @@ 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 string? ApiKey => _apiKey; + 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; } + private IPluginLocalization? _injectedLocalization; public void SetLocalization(IPluginLocalization localization) => @@ -112,7 +255,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 @@ -123,7 +266,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 +301,8 @@ public IReadOnlyList GetSettingDefinitions() => Task.FromResult( key switch { - "api-key" => _apiKey, - "selectedModel" => _selectedModelId, + "api-key" => ApiKey, + "selectedModel" => SelectedModelId, _ => null, } ); @@ -184,10 +327,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.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/Localization/de.json b/plugins/TypeWhisper.Plugin.Webhook/Localization/de.json index b06cd54da..ad1cd97be 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/Localization/de.json +++ b/plugins/TypeWhisper.Plugin.Webhook/Localization/de.json @@ -18,6 +18,7 @@ "Settings.MethodInvalid": "Methode muss POST oder PUT sein.", "Settings.HeaderMissingSeparator": "Header-Zeile '{0}' fehlt ein ':'-Trennzeichen.", "Settings.HeaderEmptyName": "Header-Zeile '{0}' hat einen leeren Namen.", + "Settings.HeaderPlaceholderWithoutStoredValue": "für Header '{0}' gibt es keinen gespeicherten Wert zum Beibehalten; geben Sie den tatsächlichen Wert ein.", "Settings.FailedToSaveSettings": "Einstellungen konnten nicht gespeichert werden: {0}", "Settings.Saved": "Gespeichert.", "Manifest.Name": "Webhook", diff --git a/plugins/TypeWhisper.Plugin.Webhook/Localization/en.json b/plugins/TypeWhisper.Plugin.Webhook/Localization/en.json index 5f3265d25..c32e63ed6 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/Localization/en.json +++ b/plugins/TypeWhisper.Plugin.Webhook/Localization/en.json @@ -18,6 +18,7 @@ "Settings.MethodInvalid": "method must be POST or PUT.", "Settings.HeaderMissingSeparator": "header line '{0}' is missing a ':' separator.", "Settings.HeaderEmptyName": "header line '{0}' has an empty name.", + "Settings.HeaderPlaceholderWithoutStoredValue": "header '{0}' has no stored value to keep; enter its real value.", "Settings.FailedToSaveSettings": "Failed to save settings: {0}", "Settings.Saved": "Saved.", "Manifest.Name": "Webhook", diff --git a/plugins/TypeWhisper.Plugin.Webhook/Localization/es.json b/plugins/TypeWhisper.Plugin.Webhook/Localization/es.json index d5a64093b..0e30b1451 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/Localization/es.json +++ b/plugins/TypeWhisper.Plugin.Webhook/Localization/es.json @@ -18,6 +18,7 @@ "Settings.MethodInvalid": "el método debe ser POST o PUT.", "Settings.HeaderMissingSeparator": "a la línea de cabecera '{0}' le falta un separador ':'.", "Settings.HeaderEmptyName": "la línea de cabecera '{0}' tiene un nombre vacío.", + "Settings.HeaderPlaceholderWithoutStoredValue": "la cabecera '{0}' no tiene ningún valor almacenado que conservar; introduzca su valor real.", "Settings.FailedToSaveSettings": "No se pudieron guardar los ajustes: {0}", "Settings.Saved": "Guardado.", "Manifest.Name": "Webhook", diff --git a/plugins/TypeWhisper.Plugin.Webhook/Localization/ru.json b/plugins/TypeWhisper.Plugin.Webhook/Localization/ru.json index 6c26bc76e..f46d0c6a0 100644 --- a/plugins/TypeWhisper.Plugin.Webhook/Localization/ru.json +++ b/plugins/TypeWhisper.Plugin.Webhook/Localization/ru.json @@ -18,6 +18,7 @@ "Settings.MethodInvalid": "метод должен быть POST или PUT.", "Settings.HeaderMissingSeparator": "в строке заголовка '{0}' отсутствует разделитель ':'.", "Settings.HeaderEmptyName": "в строке заголовка '{0}' пустое имя.", + "Settings.HeaderPlaceholderWithoutStoredValue": "для заголовка '{0}' нет сохранённого значения, которое можно оставить; введите его настоящее значение.", "Settings.FailedToSaveSettings": "Не удалось сохранить настройки: {0}", "Settings.Saved": "Сохранено.", "Manifest.Name": "Webhook", 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 36140ca09..78efaa734 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; @@ -15,8 +19,11 @@ 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; } = []; } @@ -37,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, @@ -55,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) { @@ -80,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)) { @@ -95,7 +167,7 @@ public void Save(IEnumerable configs) } finally { - if (tempPath is not null && File.Exists(tempPath)) + if (File.Exists(tempPath)) { try { @@ -120,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 @@ -128,23 +200,38 @@ 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; } = []; 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) @@ -194,6 +281,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; @@ -260,6 +348,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) @@ -295,13 +384,25 @@ 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; + + 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); @@ -366,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) { @@ -429,9 +607,11 @@ public sealed class WebhookPlugin IPluginDataLocationAware, IPluginLocalizationAware { + internal const string StoredHeaderPlaceholder = ""; + private IDisposable? _subscription; - private IPluginHostServices? _host; private string? _dataDirectory; + private readonly SemaphoreSlim _settingsSaveLock = new(1, 1); public string PluginId => "com.typewhisper.webhook"; public string PluginName => "Webhook"; @@ -439,9 +619,9 @@ public sealed class WebhookPlugin public WebhookService? Service { get; private set; } - public Task ActivateAsync(IPluginHostServices host) + public async 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 @@ -449,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() @@ -467,7 +657,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 +667,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 +687,7 @@ private string ResolveDataDir() => public IReadOnlyList GetCollectionDefinitions() => [ - new PluginCollectionDefinition( + new( Key: "webhooks", Label: Loc.L("Settings.Webhooks"), Description: Loc.L("Settings.WebhooksDescription"), @@ -522,6 +713,7 @@ public IReadOnlyList GetCollectionDefinitions() => new PluginSettingDefinition( "headers", Loc.L("Settings.Headers"), + IsSecret: true, Description: Loc.L("Settings.HeadersDescription"), Kind: PluginSettingKind.Multiline ), @@ -563,11 +755,11 @@ public Task> GetItemsAsync( { try { - source = new WebhookStore(ResolveDataDir()).Load(); + source = new WebhookStore(ResolveDataDir()).Load(protectExistingFile: true); } catch (Exception ex) { - _host?.Log(PluginLogLevel.Warning, $"Failed to load webhooks: {ex.Message}"); + Host?.Log(PluginLogLevel.Warning, $"Failed to load webhooks: {ex.Message}"); source = []; } } @@ -579,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"), @@ -590,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) { @@ -635,47 +828,172 @@ 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 { + 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) + { + // GetItems renders stored values as this placeholder, so untouched + // headers return it unchanged. With nothing stored behind it — a + // duplicated row, a renamed header — it would be sent as the value. + if (!hasExistingReference) + { + return Fail( + parsedItem.Config.Name, + Loc.L("Settings.HeaderPlaceholderWithoutStoredValue", header.Key) + ); + } + + 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 Task.FromResult( - new PluginSettingsValidationResult( - false, - Loc.L("Settings.FailedToSaveSettings", ex.Message) - ) + 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.TryGetValue(key, out var value) ? value : null; + item.Values.GetValueOrDefault(key); private static bool TryGetBool(PluginCollectionItem item, string key, out bool value) { @@ -686,9 +1004,43 @@ 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) + { + // ReSharper disable once InvertIf -- the positive form states the header match that ends the search. + 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 @@ -701,7 +1053,7 @@ internal static bool TryParseHeaders( IPluginLocalization? loc = null ) { - headers = []; + headers = new Dictionary(StringComparer.OrdinalIgnoreCase); error = ""; if (string.IsNullOrWhiteSpace(text)) @@ -740,6 +1092,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.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/WhisperCppPlugin.cs b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCppPlugin.cs index 8f06d31ef..464f0e496 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; @@ -20,14 +22,13 @@ float NoSpeechProbability ); public sealed class WhisperCppPlugin - : ITypeWhisperPlugin, - ITranscriptionEnginePlugin, + : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware { 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 +221,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 +246,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 +266,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 +286,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,15 +304,25 @@ 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 // 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) ); @@ -323,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) @@ -357,7 +362,7 @@ public async Task DeactivateAsync() public void SelectModel(string modelId) { _ = GetModel(modelId); - _selectedModelId = modelId; + SelectedModelId = modelId; _host?.SetSetting("selectedModel", modelId); } @@ -399,6 +404,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(); @@ -417,15 +423,14 @@ public void SetAccelerationPreference(TranscriptionAccelerationPreference prefer var backend = preference switch { TranscriptionAccelerationPreference.NvidiaCuda => "cuda", - TranscriptionAccelerationPreference.Cpu => "cpu", _ => "cpu", }; // 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 +692,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 +737,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" @@ -785,8 +790,12 @@ 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() { + // 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( @@ -796,6 +805,7 @@ async IAsyncEnumerable GetSegmentsAsync() segment.NoSpeechProbability ); } + // ReSharper restore AccessToDisposedClosure } return await AccumulateSegmentsAsync(GetSegmentsAsync(), threshold); @@ -838,6 +848,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 +873,7 @@ public async Task UnloadModelAsync() { DisposeFactoryUnsafe(); _loadedModelId = null; - _selectedModelId = null; + SelectedModelId = null; } finally { @@ -882,9 +893,9 @@ public async Task DeleteModelAsync(string modelId, CancellationToken ct) _loadedModelId = null; } - if (_selectedModelId == modelId) + if (SelectedModelId == modelId) { - _selectedModelId = null; + SelectedModelId = null; _host?.SetSetting("selectedModel", ""); } @@ -921,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) ); @@ -1054,7 +1067,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( @@ -1134,8 +1147,8 @@ out var parsed ); } - private ModelDefinition GetModel(string modelId) => - Models.FirstOrDefault(model => model.Id == modelId) + private static ModelDefinition GetModel(string modelId) => + s_models.FirstOrDefault(model => model.Id == modelId) ?? throw new ArgumentException($"Unknown model: {modelId}"); private string GetModelPath(string modelId) @@ -1264,23 +1277,33 @@ 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 ) { 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." @@ -1350,7 +1373,10 @@ private static void TryDeleteFile(string path) if (File.Exists(path)) File.Delete(path); } - catch { } + catch + { + //nada + } } private sealed record ModelDefinition( @@ -1360,6 +1386,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 diff --git a/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCudaRuntimeInstaller.cs b/plugins/TypeWhisper.Plugin.WhisperCpp/WhisperCudaRuntimeInstaller.cs index fb63e297f..a50069002 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,8 @@ 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 = + // 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"; @@ -52,7 +55,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 +97,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 +154,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( @@ -218,9 +221,11 @@ 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; + // 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,13 +235,13 @@ void OnBytesOnDisk(long onDisk) return ResilientDownloader.DownloadToFileAsync( _httpClient, - DownloadUrl, + s_downloadUrl, destination, approxTotalBytes: ApproxDownloadBytes, idleTimeout: TimeSpan.FromSeconds(60), allowResume: true, onBytesOnDisk: OnBytesOnDisk, - verifyComplete: path => VerifySha256(path), + verifyComplete: VerifySha256, ct ); } @@ -260,7 +265,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.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/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..76aa3a79c 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,11 +210,11 @@ 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)) + await foreach (var delta in source) yield return delta; } @@ -225,19 +225,18 @@ public async IAsyncEnumerable ProcessStreamingAsync( ? _fetchedVoices.Select(v => new PluginVoiceInfo(v.VoiceId, v.DisplayName, v.Language)).ToList() : XaiTtsConfiguration.FallbackVoices; - public string? SelectedVoiceId => - !string.IsNullOrWhiteSpace(_customVoiceId) - ? _customVoiceId + 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; - var latency = _ttsLowLatency ? "low latency" : "quality"; + ?? SelectedVoiceId; + var latency = TtsLowLatency ? "low latency" : "quality"; return $"Voice: {voice}; {latency}"; } } @@ -273,11 +272,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 +286,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 +297,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 +331,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 +350,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 +378,6 @@ internal async Task> FetchLlmModelsAsync(CancellationToken { return []; } - catch (OperationCanceledException) { throw; } catch (HttpRequestException) { return []; @@ -404,7 +406,6 @@ internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken c { return false; } - catch (OperationCanceledException) { throw; } catch (HttpRequestException) { return false; @@ -433,7 +434,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 +463,6 @@ internal async Task> FetchVoicesAsync(CancellationToken ct { return []; } - catch (OperationCanceledException) { throw; } catch (HttpRequestException) { return []; @@ -479,21 +479,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 +514,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 +535,7 @@ internal static PluginTranscriptionResult ParseSttResponse(string json, string? return new PluginTranscriptionResult(text, language ?? fallbackLanguage ?? "", duration) { - Segments = segments + Segments = segments, }; } @@ -554,12 +555,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 +571,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,9 +585,9 @@ 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) => + private static string NormalizeVoiceId(string? voiceId) => string.IsNullOrWhiteSpace(voiceId) ? XaiTtsConfiguration.DefaultVoiceId : voiceId.Trim(); private static string? NormalizeLanguage(string? language) => @@ -656,7 +659,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 +714,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 +763,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..6d64fbab6 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); } @@ -90,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(); @@ -98,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."); + } } /// @@ -149,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 . /// @@ -175,23 +191,115 @@ public async IAsyncEnumerable ProcessStreamingAsync( return null; } - switch (typeEl.GetString()) + 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 (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; + + // ReSharper disable once InvertIf -- inverting would duplicate the trailing ExtractIncompleteReason(root) return. + 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. if (element.TryGetProperty("error", out var error)) { if (error.ValueKind == JsonValueKind.Object @@ -219,6 +327,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(); @@ -230,6 +339,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 +369,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 d3c282dd4..51110e122 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; @@ -10,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 @@ -30,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; @@ -44,14 +47,68 @@ 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) + { + // ReSharper disable once ConvertIfStatementToReturnStatement -- precondition guard; the suggested ternary-throw buries the throw. + 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) + { + // ReSharper disable once ConvertIfStatementToReturnStatement -- precondition guard; the suggested ternary-throw buries the throw. + 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 @@ -73,7 +130,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) @@ -86,13 +143,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); @@ -167,6 +233,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]; @@ -182,7 +323,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); @@ -191,36 +335,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. @@ -234,7 +400,9 @@ 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(); + _readinessSignal.TrySetCanceled(_receiveCts.Token); await _sendLock.WaitAsync(CancellationToken.None); try @@ -298,7 +466,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; @@ -311,6 +478,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); @@ -324,14 +496,20 @@ 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"), - _ => null + _ => null, }; } + private StreamingTranscriptEvent? ApplyCreatedEvent() + { + IsReady = true; + return null; + } + public PluginTranscriptionResult FinalResult(string? fallbackLanguage) { var text = !string.IsNullOrWhiteSpace(_doneText) @@ -356,9 +534,9 @@ 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) { - _interim = ""; if (string.IsNullOrWhiteSpace(text)) return null; @@ -385,7 +563,6 @@ public PluginTranscriptionResult FinalResult(string? fallbackLanguage) return new StreamingTranscriptEvent(text, IsFinal: true); } - _interim = text; return new StreamingTranscriptEvent(text, IsFinal: false); } @@ -393,7 +570,6 @@ public PluginTranscriptionResult FinalResult(string? fallbackLanguage) { var text = GetString(root, "text")?.Trim() ?? ""; RememberMetadata(root); - _interim = ""; IsTerminal = true; if (string.IsNullOrWhiteSpace(text)) @@ -415,6 +591,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,8 +634,10 @@ 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)) { + // 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 ffbad4ef6..e96b802b0 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); @@ -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"; 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/scripts/build-linux-packages.sh b/scripts/build-linux-packages.sh index 0b1ff1d83..f5e0b4a80 100755 --- a/scripts/build-linux-packages.sh +++ b/scripts/build-linux-packages.sh @@ -38,6 +38,8 @@ APP_ID="typewhisper" APP_NAME="TypeWhisper" PROJECT="$ROOT/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj" PUBLISH_DIR="$ROOT/src/TypeWhisper.Linux/bin/$CONFIG/net10.0/$RID/publish" +CLI_PROJECT="$ROOT/src/TypeWhisper.Cli/TypeWhisper.Cli.csproj" +CLI_PUBLISH_DIR="$ROOT/src/TypeWhisper.Cli/bin/$CONFIG/net10.0/$RID/publish" ICON_SRC="$ROOT/src/TypeWhisper.Linux/Resources/typewhisper-128.png" mkdir -p "$OUTPUT_DIR" @@ -59,6 +61,23 @@ dotnet publish "$PROJECT" \ -p:DebugSymbols=false \ --nologo +echo "==> Publishing TypeWhisper.Cli ($CONFIG, $RID, version $VERSION)" +dotnet publish "$CLI_PROJECT" \ + -c "$CONFIG" \ + -r "$RID" \ + --self-contained true \ + -p:Version="$VERSION" \ + -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:DebugType=None \ + -p:DebugSymbols=false \ + --nologo + +rm -rf "$PUBLISH_DIR/Cli" +mkdir -p "$PUBLISH_DIR/Cli" +cp "$CLI_PUBLISH_DIR/typewhisper-cli" "$PUBLISH_DIR/Cli/typewhisper-cli" +chmod 0755 "$PUBLISH_DIR/Cli/typewhisper-cli" + echo "==> Bundling Linux plugins" # Pass VERSION so PluginSDK and plugins build with the same AssemblyVersion as # the host. Otherwise plugins reference PluginSDK at the Directory.Build.props @@ -142,11 +161,28 @@ cat > "$TARBALL_STAGE/install.sh" <<'EOF' set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -INSTALL_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}/TypeWhisper" +INSTALL_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}/typewhisper-app" APPS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" ICONS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/128x128/apps" BIN_DIR="$HOME/.local/bin" +# Both the uninstall and the reinstall path delete entries under INSTALL_ROOT, so the +# sanity check on it covers both. +case "$INSTALL_ROOT" in + ""|"/"|"$HOME"|"$HOME/") + echo "Refusing to continue: INSTALL_ROOT ('$INSTALL_ROOT') is unsafe to delete from." >&2 + exit 1 + ;; +esac + +# Everything the running app creates inside INSTALL_ROOT. Defined once and shared by both +# cleanup loops below: two copies of this list could drift and silently delete user data. +# NOTE: Plugins/ is on the list. In this tarball layout BasePath == INSTALL_ROOT, so +# PluginsPath == $INSTALL_ROOT/Plugins holds BOTH the shipped bundled plugins AND any +# marketplace/custom plugins the user installed at runtime. Deleting it would destroy +# user-installed plugins (and their keys) — only --purge may remove it. +KEEP=" Audio Data PluginData Plugins Logs Models Runtimes backups training settings.json settings.json.bak linux-preferences.json " + if [ "${1:-}" = "--uninstall" ]; then PURGE=0 case "${2:-}" in @@ -157,13 +193,6 @@ if [ "${1:-}" = "--uninstall" ]; then rm -f "$APPS_DIR/typewhisper.desktop" "$ICONS_DIR/typewhisper.png" "$BIN_DIR/typewhisper" - case "$INSTALL_ROOT" in - ""|"/"|"$HOME"|"$HOME/") - echo "Refusing to uninstall: INSTALL_ROOT ('$INSTALL_ROOT') is unsafe to remove." >&2 - exit 1 - ;; - esac - if [ "$PURGE" -eq 1 ]; then rm -rf "$INSTALL_ROOT" echo "TypeWhisper and all its user data have been uninstalled." @@ -171,16 +200,10 @@ if [ "${1:-}" = "--uninstall" ]; then fi # Preserve user data, remove only the program payload. Everything the running - # app creates is on this KEEP list; anything else at the top level is payload - # shipped in the tarball (binary, *.dll, *.so, runtime configs, + # app creates is on the KEEP list defined above; anything else at the top level + # is payload shipped in the tarball (binary, *.dll, *.so, runtime configs, # icon/desktop/installer) and is safe to delete. - # NOTE: Plugins/ is on the KEEP list. In this tarball layout BasePath == - # INSTALL_ROOT, so PluginsPath == $INSTALL_ROOT/Plugins holds BOTH the shipped - # bundled plugins AND any marketplace/custom plugins the user installed at - # runtime. Deleting it here would destroy user-installed plugins (and their - # keys) on an ordinary uninstall — only --purge (rm -rf above) may remove it. if [ -d "$INSTALL_ROOT" ]; then - KEEP=" Audio Data PluginData Plugins Logs Models Runtimes backups training settings.json settings.json.bak linux-preferences.json " for entry in "$INSTALL_ROOT"/* "$INSTALL_ROOT"/.[!.]*; do [ -e "$entry" ] || continue name="$(basename "$entry")" @@ -216,7 +239,6 @@ mkdir -p "$INSTALL_ROOT" "$APPS_DIR" "$ICONS_DIR" "$BIN_DIR" # Plugins/ is KEPT: it holds user-installed marketplace/custom plugins alongside # the bundled ones. The `cp -R "$HERE"/*` below merges the fresh bundled plugins # on top, refreshing them while leaving the user's installed plugins intact. -KEEP=" Audio Data PluginData Plugins Logs Models Runtimes backups training settings.json settings.json.bak linux-preferences.json " for entry in "$INSTALL_ROOT"/* "$INSTALL_ROOT"/.[!.]*; do [ -e "$entry" ] || continue name="$(basename "$entry")" @@ -336,6 +358,12 @@ exec /opt/typewhisper/typewhisper "$@" EOF chmod 0755 "$DEB_STAGE/usr/bin/typewhisper" + cat > "$DEB_STAGE/usr/bin/typewhisper-cli" <<'EOF' +#!/usr/bin/env bash +exec /opt/typewhisper/Cli/typewhisper-cli "$@" +EOF + chmod 0755 "$DEB_STAGE/usr/bin/typewhisper-cli" + cat > "$DEB_STAGE/usr/share/applications/typewhisper.desktop" < Installed-Size: $INSTALLED_SIZE +Depends: libasound2t64 | libasound2, libjack-jackd2-0 | libjack0 | pipewire-jack Recommends: libpulse0, pulseaudio-utils, playerctl, xdotool Description: Speech-to-text dictation for Linux desktop TypeWhisper provides global dictation, file transcription, recorder, @@ -410,6 +439,12 @@ exec /opt/typewhisper/typewhisper "$@" EOF chmod 0755 "$RPM_SRC/usr/bin/typewhisper" + cat > "$RPM_SRC/usr/bin/typewhisper-cli" <<'EOF' +#!/usr/bin/env bash +exec /opt/typewhisper/Cli/typewhisper-cli "$@" +EOF + chmod 0755 "$RPM_SRC/usr/bin/typewhisper-cli" + cat > "$RPM_SRC/usr/share/applications/typewhisper.desktop" <&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/scripts/smoke-test-linux-packages.sh b/scripts/smoke-test-linux-packages.sh new file mode 100755 index 000000000..4328df43c --- /dev/null +++ b/scripts/smoke-test-linux-packages.sh @@ -0,0 +1,816 @@ +#!/usr/bin/env bash +# Validate, extract, install, and execute every Linux package produced by +# build-linux-packages.sh. Package-manager mutations happen only in disposable +# containers; host-side work is limited to metadata inspection and extraction. +# +# Usage: +# scripts/smoke-test-linux-packages.sh [package-dir] +# scripts/smoke-test-linux-packages.sh [package-dir] --validate-only +# +# --validate-only runs filename, metadata, and extraction checks without Docker. +# It is intended for constrained local development environments, never CI. + +set -euo pipefail + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "required command '$1' is not available." +} + +require_file() { + [ -s "$1" ] || fail "required file is missing or empty: $1" +} + +require_executable() { + [ -x "$1" ] || fail "required executable is missing or not executable: $1" +} + +assert_removed() { + local path="$1" + if [ -e "$path" ] || [ -L "$path" ]; then + fail "package-owned path remains after removal: $path" + fi +} + +prepare_isolated_profile() { + SMOKE_HOME_ROOT=/tmp/typewhisper-smoke-home + SMOKE_DATA_ROOT=/tmp/typewhisper-smoke-data + SMOKE_CONFIG_ROOT=/tmp/typewhisper-smoke-config + SMOKE_CACHE_ROOT=/tmp/typewhisper-smoke-cache + SMOKE_STATE_ROOT=/tmp/typewhisper-smoke-state + SMOKE_RUNTIME_ROOT=/tmp/typewhisper-smoke-runtime + SMOKE_PROFILE_ENV=( + "HOME=$SMOKE_HOME_ROOT" + "XDG_DATA_HOME=$SMOKE_DATA_ROOT" + "XDG_CONFIG_HOME=$SMOKE_CONFIG_ROOT" + "XDG_CACHE_HOME=$SMOKE_CACHE_ROOT" + "XDG_STATE_HOME=$SMOKE_STATE_ROOT" + "XDG_RUNTIME_DIR=$SMOKE_RUNTIME_ROOT" + "TYPEWHISPER_DISABLE_IME=1" + "LIBGL_ALWAYS_SOFTWARE=1" + ) + + rm -rf \ + "$SMOKE_HOME_ROOT" \ + "$SMOKE_DATA_ROOT" \ + "$SMOKE_CONFIG_ROOT" \ + "$SMOKE_CACHE_ROOT" \ + "$SMOKE_STATE_ROOT" \ + "$SMOKE_RUNTIME_ROOT" + mkdir -p \ + "$SMOKE_HOME_ROOT" \ + "$SMOKE_DATA_ROOT" \ + "$SMOKE_CONFIG_ROOT" \ + "$SMOKE_CACHE_ROOT" \ + "$SMOKE_STATE_ROOT" \ + "$SMOKE_RUNTIME_ROOT" + chmod 0700 "$SMOKE_RUNTIME_ROOT" +} + +run_help_probe() { + local executable="$1" + local output status + + echo "==> Executing --help: $executable" + set +e + output=$( + timeout --signal=TERM --kill-after=5s 30s \ + env "${SMOKE_PROFILE_ENV[@]}" "$executable" --help 2>&1 + ) + status=$? + set -e + printf '%s\n' "$output" + + [ "$status" -eq 0 ] || fail "'$executable --help' exited with status $status." + grep -Fq "Usage:" <<<"$output" \ + || fail "'$executable --help' did not print the expected usage marker." +} + +run_cli_probe() { + local executable="$1" + local stdout_file=/tmp/typewhisper-cli-stdout + local stderr_file=/tmp/typewhisper-cli-stderr + local status actual expected + + echo "==> Executing CLI version probe: $executable" + set +e + timeout --signal=TERM --kill-after=5s 30s \ + env "${SMOKE_PROFILE_ENV[@]}" "$executable" --version \ + >"$stdout_file" 2>"$stderr_file" + status=$? + set -e + cat "$stdout_file" + cat "$stderr_file" >&2 + + [ "$status" -eq 0 ] || fail "'$executable --version' exited with status $status." + # Byte-exact, including the trailing newline. Command substitution strips + # trailing newlines, so both sides carry an 'x' sentinel to preserve them; + # a bare comparison would accept a missing or duplicated final newline. + # Done in-shell rather than with cmp: diffutils is not installed in the + # Fedora smoke container. + actual="$(cat "$stdout_file"; printf 'x')" + expected="$(printf 'typewhisper-cli %s\nx' "$EXPECTED_CLI_VERSION")" + [ "$actual" = "$expected" ] \ + || fail "'$executable --version' did not print the exact expected version." + [ ! -s "$stderr_file" ] \ + || fail "'$executable --version' unexpectedly wrote to stderr." + + echo "==> Executing controlled CLI status failure: $executable" + set +e + timeout --signal=TERM --kill-after=5s 30s \ + env "${SMOKE_PROFILE_ENV[@]}" "$executable" status \ + >"$stdout_file" 2>"$stderr_file" + status=$? + set -e + cat "$stdout_file" + cat "$stderr_file" >&2 + + # Exact code: a bare "not zero" also accepts timeout kills (124, or 137 once + # --kill-after has to SIGKILL a CLI that hung after printing the error). + [ "$status" -eq 1 ] \ + || fail "'$executable status' exited with status $status; expected 1." + grep -Fq "TypeWhisper API socket not found" "$stderr_file" \ + || fail "'$executable status' did not report the expected missing API socket." +} + +run_gui_probe() { + local executable="$1" + local display_number=99 + local gui_status xvfb_pid + + echo "==> Starting bounded headless GUI probe: $executable" + rm -f "/tmp/.X${display_number}-lock" + rm -rf "/tmp/.X11-unix/X${display_number}" + Xvfb ":${display_number}" -screen 0 1280x800x24 -nolisten tcp \ + >/tmp/typewhisper-xvfb.log 2>&1 & + xvfb_pid=$! + + for _ in {1..50}; do + if [ -S "/tmp/.X11-unix/X${display_number}" ]; then + break + fi + if ! kill -0 "$xvfb_pid" 2>/dev/null; then + cat /tmp/typewhisper-xvfb.log >&2 + fail "Xvfb exited before the GUI probe started." + fi + sleep 0.1 + done + + if [ ! -S "/tmp/.X11-unix/X${display_number}" ]; then + cat /tmp/typewhisper-xvfb.log >&2 + kill "$xvfb_pid" 2>/dev/null || true + wait "$xvfb_pid" 2>/dev/null || true + fail "Xvfb did not become ready." + fi + + set +e + timeout --signal=TERM --kill-after=5s 20s \ + env "${SMOKE_PROFILE_ENV[@]}" DISPLAY=":${display_number}" \ + dbus-run-session -- "$executable" --minimized 2>&1 \ + | tee /tmp/typewhisper-gui.log + gui_status=${PIPESTATUS[0]} + set -e + + kill "$xvfb_pid" 2>/dev/null || true + wait "$xvfb_pid" 2>/dev/null || true + + if [ "$gui_status" -ne 124 ]; then + echo "Xvfb diagnostics:" >&2 + cat /tmp/typewhisper-xvfb.log >&2 + fail "GUI probe exited before its 20-second health window (status $gui_status)." + fi + + 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 + # load and the GUI probe can only ever prove the no-audio path. + apt-get install -y --no-install-recommends \ + dbus-x11 \ + gzip \ + libdbus-1-3 \ + libegl1 \ + libfontconfig1 \ + libfreetype6 \ + libgl1 \ + libice6 \ + libicu74 \ + libjack-jackd2-0 \ + libasound2t64 \ + libsm6 \ + libx11-6 \ + libx11-xcb1 \ + libxcb1 \ + libxcursor1 \ + libxext6 \ + libxfixes3 \ + libxi6 \ + libxkbcommon-x11-0 \ + libxkbcommon0 \ + libxrandr2 \ + libxrender1 \ + tar \ + xvfb +} + +install_fedora_runtime() { + runtime_already_installed && return 0 + # See install_ubuntu_runtime: alsa-lib/jack back the bundled libportaudio.so. + dnf install -y --setopt=retries=3 \ + alsa-lib \ + dbus-daemon \ + fontconfig \ + freetype \ + gzip \ + jack-audio-connection-kit \ + libICE \ + libSM \ + libX11 \ + libX11-xcb \ + libXcursor \ + libXext \ + libXfixes \ + libXi \ + libXrandr \ + libXrender \ + libglvnd-egl \ + libglvnd-glx \ + libicu \ + libxcb \ + libxkbcommon \ + libxkbcommon-x11 \ + tar \ + xorg-x11-server-Xvfb +} + +assert_no_system_dotnet() { + if command -v dotnet >/dev/null 2>&1; then + fail "container unexpectedly provides system dotnet at $(command -v dotnet)." + fi +} + +container_smoke_tarball() { + local package="$1" + local app_root extracted install_script + + install_ubuntu_runtime + assert_no_system_dotnet + prepare_isolated_profile + + extracted=/tmp/typewhisper-tarball + mkdir -p "$extracted" + tar -xzf "$package" -C "$extracted" + install_script=$(find "$extracted" -mindepth 2 -maxdepth 2 -type f -name install.sh -print) + [ "$(printf '%s\n' "$install_script" | sed '/^$/d' | wc -l)" -eq 1 ] \ + || fail "tarball container extraction did not produce exactly one install.sh." + + mkdir -p "$SMOKE_DATA_ROOT/TypeWhisper" + printf 'preserve application data\n' >"$SMOKE_DATA_ROOT/TypeWhisper/smoke-sentinel" + + echo "==> Installing tarball into isolated HOME/XDG roots" + env "${SMOKE_PROFILE_ENV[@]}" bash "$install_script" + app_root="$SMOKE_DATA_ROOT/typewhisper-app" + + # The app writes its user data INTO the install root, so the installer's KEEP list is + # all that stands between a reinstall/uninstall and permanent data loss. + mkdir -p "$app_root/Data" + printf 'preserve user data\n' >"$app_root/Data/smoke-user-data" + + env "${SMOKE_PROFILE_ENV[@]}" bash "$install_script" + require_file "$app_root/Data/smoke-user-data" + require_executable "$SMOKE_HOME_ROOT/.local/bin/typewhisper" + require_executable "$app_root/Cli/typewhisper-cli" + require_file "$SMOKE_DATA_ROOT/applications/typewhisper.desktop" + require_file "$SMOKE_DATA_ROOT/icons/hicolor/128x128/apps/typewhisper.png" + [ -d "$app_root" ] \ + || fail "tarball application directory was not installed." + require_file "$SMOKE_DATA_ROOT/TypeWhisper/smoke-sentinel" + [ -L "$SMOKE_HOME_ROOT/.local/bin/typewhisper" ] \ + || fail "tarball launcher is not a symlink." + [ "$(readlink "$SMOKE_HOME_ROOT/.local/bin/typewhisper")" = \ + "$app_root/typewhisper" ] \ + || fail "tarball launcher does not target the installed application." + grep -Fxq "Exec=$app_root/typewhisper" \ + "$SMOKE_DATA_ROOT/applications/typewhisper.desktop" \ + || fail "installed tarball desktop entry does not target the installed application." + + run_cli_probe "$app_root/Cli/typewhisper-cli" + run_help_probe "$SMOKE_HOME_ROOT/.local/bin/typewhisper" + run_gui_probe "$SMOKE_HOME_ROOT/.local/bin/typewhisper" + + echo "==> Uninstalling tarball from isolated HOME/XDG roots" + env "${SMOKE_PROFILE_ENV[@]}" bash "$install_script" --uninstall + 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" + # 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 "$app_root/Data/smoke-user-data" + 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() { + local package="$1" + local app_run cli_executable + + install_ubuntu_runtime + assert_no_system_dotnet + prepare_isolated_profile + + mkdir -p /tmp/typewhisper-appimage + echo "==> Extracting AppImage without FUSE in container" + ( + cd /tmp/typewhisper-appimage + "$package" --appimage-extract + ) + app_run=/tmp/typewhisper-appimage/squashfs-root/AppRun + cli_executable=/tmp/typewhisper-appimage/squashfs-root/usr/bin/Cli/typewhisper-cli + require_executable "$app_run" + require_executable "$cli_executable" + + run_cli_probe "$cli_executable" + run_help_probe "$app_run" + run_gui_probe "$app_run" +} + +container_smoke_deb() { + local package="$1" + local owned_path package_files + + install_ubuntu_runtime + assert_no_system_dotnet + prepare_isolated_profile + + echo "==> Installing deb in disposable Ubuntu container" + apt-get install -y --no-install-recommends "$package" + require_executable /usr/bin/typewhisper + require_executable /usr/bin/typewhisper-cli + require_executable /opt/typewhisper/Cli/typewhisper-cli + require_file /usr/share/applications/typewhisper.desktop + require_file /usr/share/icons/hicolor/128x128/apps/typewhisper.png + [ -d /opt/typewhisper ] || fail "deb application directory was not installed." + package_files=$(dpkg-query --listfiles typewhisper) + for owned_path in \ + /usr/bin/typewhisper \ + /usr/bin/typewhisper-cli \ + /usr/share/applications/typewhisper.desktop \ + /usr/share/icons/hicolor/128x128/apps/typewhisper.png \ + /opt/typewhisper/Cli/typewhisper-cli \ + /opt/typewhisper; do + grep -Fxq "$owned_path" <<<"$package_files" \ + || fail "deb database does not own expected path: $owned_path" + done + + run_cli_probe /opt/typewhisper/Cli/typewhisper-cli + run_cli_probe /usr/bin/typewhisper-cli + run_help_probe /usr/bin/typewhisper + run_gui_probe /usr/bin/typewhisper + + echo "==> Removing deb" + apt-get remove -y typewhisper + assert_removed /usr/bin/typewhisper + assert_removed /usr/bin/typewhisper-cli + assert_removed /usr/share/applications/typewhisper.desktop + assert_removed /usr/share/icons/hicolor/128x128/apps/typewhisper.png + assert_removed /opt/typewhisper/Cli/typewhisper-cli + assert_removed /opt/typewhisper +} + +container_smoke_rpm() { + local package="$1" + local owned_path package_files + + install_fedora_runtime + assert_no_system_dotnet + prepare_isolated_profile + + echo "==> Installing rpm in disposable Fedora container" + dnf install -y "$package" + require_executable /usr/bin/typewhisper + require_executable /usr/bin/typewhisper-cli + require_executable /opt/typewhisper/Cli/typewhisper-cli + require_file /usr/share/applications/typewhisper.desktop + require_file /usr/share/icons/hicolor/128x128/apps/typewhisper.png + [ -d /opt/typewhisper ] || fail "rpm application directory was not installed." + package_files=$(rpm -ql typewhisper) + for owned_path in \ + /usr/bin/typewhisper \ + /usr/bin/typewhisper-cli \ + /usr/share/applications/typewhisper.desktop \ + /usr/share/icons/hicolor/128x128/apps/typewhisper.png \ + /opt/typewhisper/Cli/typewhisper-cli \ + /opt/typewhisper; do + grep -Fxq "$owned_path" <<<"$package_files" \ + || fail "rpm database does not own expected path: $owned_path" + done + + run_cli_probe /opt/typewhisper/Cli/typewhisper-cli + run_cli_probe /usr/bin/typewhisper-cli + run_help_probe /usr/bin/typewhisper + run_gui_probe /usr/bin/typewhisper + + echo "==> Removing rpm" + dnf remove -y typewhisper + assert_removed /usr/bin/typewhisper + assert_removed /usr/bin/typewhisper-cli + assert_removed /usr/share/applications/typewhisper.desktop + assert_removed /usr/share/icons/hicolor/128x128/apps/typewhisper.png + assert_removed /opt/typewhisper/Cli/typewhisper-cli + 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." + EXPECTED_CLI_VERSION="$4" + case "$2" in + tarball) container_smoke_tarball "$3" ;; + appimage) container_smoke_appimage "$3" ;; + deb) container_smoke_deb "$3" ;; + rpm) container_smoke_rpm "$3" ;; + *) fail "unknown internal container format '$2'." ;; + esac + echo "==> Container smoke passed: $2" + exit 0 +fi + +VERSION="${1:-}" +PACKAGE_DIR="${2:-dist}" +MODE="${3:-}" + +if [ -z "$VERSION" ]; then + echo "Usage: $0 [package-dir] [--validate-only]" >&2 + exit 2 +fi +if [[ ! "$VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + fail "expected version '$VERSION' is not a supported SemVer value." +fi +if [ -n "$MODE" ] && [ "$MODE" != "--validate-only" ]; then + fail "unknown mode '$MODE'; expected --validate-only." +fi +if [ "$MODE" = "--validate-only" ] && [ "${CI:-}" = "true" ]; then + fail "--validate-only is disabled in CI; container smoke tests are mandatory." +fi +if [ "$#" -gt 3 ]; then + fail "too many arguments." +fi + +for command in cpio dpkg-deb file find grep rpm rpm2cpio sed tar timeout; do + require_command "$command" +done + +[ -d "$PACKAGE_DIR" ] || fail "package directory does not exist: $PACKAGE_DIR" +PACKAGE_DIR="$(cd "$PACKAGE_DIR" && pwd)" +SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")" +EXPECTED_VERSION="${VERSION#v}" +EXPECTED_CLI_VERSION="${EXPECTED_VERSION%%+*}" +RPM_VERSION="${EXPECTED_VERSION//-/\~}" + +EXPECTED_TARBALL="$PACKAGE_DIR/typewhisper-linux-x64-${VERSION}.tar.gz" +EXPECTED_APPIMAGE="$PACKAGE_DIR/TypeWhisper-${VERSION}-x86_64.AppImage" +EXPECTED_DEB="$PACKAGE_DIR/typewhisper_${EXPECTED_VERSION}_amd64.deb" +EXPECTED_RPM="$PACKAGE_DIR/typewhisper-${RPM_VERSION}-1.x86_64.rpm" + +shopt -s nullglob +tarballs=("$PACKAGE_DIR"/*.tar.gz) +appimages=("$PACKAGE_DIR"/*.AppImage) +debs=("$PACKAGE_DIR"/*.deb) +rpms=("$PACKAGE_DIR"/*.rpm) +shopt -u nullglob + +check_exact_artifact() { + local format="$1" + local expected="$2" + shift 2 + local matches=("$@") + + [ "${#matches[@]}" -eq 1 ] \ + || fail "expected exactly one $format in '$PACKAGE_DIR', found ${#matches[@]}." + [ "${matches[0]}" = "$expected" ] \ + || fail "$format filename mismatch: expected '$(basename "$expected")', found '$(basename "${matches[0]}")'." + require_file "$expected" +} + +check_exact_artifact "tarball" "$EXPECTED_TARBALL" "${tarballs[@]}" +check_exact_artifact "AppImage" "$EXPECTED_APPIMAGE" "${appimages[@]}" +check_exact_artifact "deb" "$EXPECTED_DEB" "${debs[@]}" +check_exact_artifact "rpm" "$EXPECTED_RPM" "${rpms[@]}" +require_executable "$EXPECTED_APPIMAGE" + +echo "==> Debian metadata" +dpkg-deb --info "$EXPECTED_DEB" +[ "$(dpkg-deb -f "$EXPECTED_DEB" Package)" = "typewhisper" ] \ + || fail "deb Package metadata is not 'typewhisper'." +[ "$(dpkg-deb -f "$EXPECTED_DEB" Version)" = "$EXPECTED_VERSION" ] \ + || fail "deb Version metadata does not match '$EXPECTED_VERSION'." +[ "$(dpkg-deb -f "$EXPECTED_DEB" Architecture)" = "amd64" ] \ + || fail "deb Architecture metadata is not 'amd64'." + +echo "==> RPM metadata" +rpm -qip "$EXPECTED_RPM" +[ "$(rpm -qp --queryformat '%{NAME}' "$EXPECTED_RPM")" = "typewhisper" ] \ + || fail "rpm Name metadata is not 'typewhisper'." +[ "$(rpm -qp --queryformat '%{VERSION}' "$EXPECTED_RPM")" = "$RPM_VERSION" ] \ + || fail "rpm Version metadata does not match '$RPM_VERSION'." +[ "$(rpm -qp --queryformat '%{RELEASE}' "$EXPECTED_RPM")" = "1" ] \ + || fail "rpm Release metadata is not '1'." +[ "$(rpm -qp --queryformat '%{ARCH}' "$EXPECTED_RPM")" = "x86_64" ] \ + || fail "rpm Architecture metadata is not 'x86_64'." + +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 + +TARBALL_EXTRACT="$EXTRACT_ROOT/tarball" +APPIMAGE_EXTRACT="$EXTRACT_ROOT/appimage" +DEB_EXTRACT="$EXTRACT_ROOT/deb" +RPM_EXTRACT="$EXTRACT_ROOT/rpm" +mkdir -p "$TARBALL_EXTRACT" "$APPIMAGE_EXTRACT" "$DEB_EXTRACT" "$RPM_EXTRACT" + +echo "==> Extracting every package format on the runner" +tar -xzf "$EXPECTED_TARBALL" --no-same-owner -C "$TARBALL_EXTRACT" +dpkg-deb --extract "$EXPECTED_DEB" "$DEB_EXTRACT" +( + cd "$RPM_EXTRACT" + # 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" + "$EXPECTED_APPIMAGE" --appimage-extract >appimage-extract.log 2>&1 +); then + cat "$APPIMAGE_EXTRACT/appimage-extract.log" >&2 + fail "AppImage extraction without FUSE failed." +fi + +validate_desktop_entry() { + local desktop_file="$1" + + require_file "$desktop_file" + grep -Fxq "Type=Application" "$desktop_file" \ + || fail "desktop entry has no Type=Application: $desktop_file" + grep -Fxq "Exec=typewhisper" "$desktop_file" \ + || fail "desktop entry does not launch the shipped app: $desktop_file" + + if command -v desktop-file-validate >/dev/null 2>&1; then + desktop-file-validate "$desktop_file" + else + echo "WARN: desktop-file-validate is unavailable; basic desktop-entry checks passed." >&2 + fi +} + +validate_payload() { + local format="$1" + local app_dir="$2" + local executable="$3" + local desktop_file="$4" + local icon_file="$5" + local assembly_name cli_executable native_file plugin_id + local plugin_dir + local plugin_dirs=() + + echo "==> Validating extracted $format payload" + require_executable "$executable" + file "$executable" | grep -Eq 'ELF 64-bit.*x86-64' \ + || fail "$format app executable is not an x86-64 ELF binary." + cli_executable="$app_dir/Cli/typewhisper-cli" + require_executable "$cli_executable" + file "$cli_executable" | grep -Eq 'ELF 64-bit.*x86-64' \ + || fail "$format CLI executable is not an x86-64 ELF binary." + validate_desktop_entry "$desktop_file" + require_file "$icon_file" + file "$icon_file" | grep -Fq "PNG image data" \ + || fail "$format icon is not a PNG image: $icon_file" + + require_file "$app_dir/typewhisper.dll" + require_file "$app_dir/typewhisper.deps.json" + require_file "$app_dir/typewhisper.runtimeconfig.json" + require_file "$app_dir/TypeWhisper.PluginSDK.dll" + require_file "$app_dir/libhostfxr.so" + require_file "$app_dir/libhostpolicy.so" + require_file "$app_dir/libcoreclr.so" + require_file "$app_dir/libSkiaSharp.so" + grep -Fq "\"typewhisper/$EXPECTED_VERSION\":" "$app_dir/typewhisper.deps.json" \ + || fail "$format deployed payload is not stamped with version '$EXPECTED_VERSION'." + + [ -d "$app_dir/Plugins" ] || fail "$format payload has no bundled Plugins directory." + shopt -s nullglob + plugin_dirs=("$app_dir"/Plugins/*) + shopt -u nullglob + [ "${#plugin_dirs[@]}" -gt 0 ] || fail "$format payload has no bundled plugins." + + for plugin_dir in "${plugin_dirs[@]}"; do + [ -d "$plugin_dir" ] || fail "unexpected non-directory in bundled Plugins: $plugin_dir" + require_file "$plugin_dir/manifest.json" + plugin_id=$( + sed -nE 's/^[[:space:]]*"id"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' \ + "$plugin_dir/manifest.json" + ) + [ "$plugin_id" = "$(basename "$plugin_dir")" ] \ + || fail "plugin manifest id does not match its directory: $plugin_dir" + assembly_name=$( + sed -nE 's/^[[:space:]]*"assemblyName"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' \ + "$plugin_dir/manifest.json" + ) + [ -n "$assembly_name" ] \ + || fail "plugin manifest has no assemblyName: $plugin_dir/manifest.json" + require_file "$plugin_dir/$assembly_name" + done + + native_file=$(find "$app_dir" -type f \( -name '*.so' -o -name '*.so.*' \) -print -quit) + [ -n "$native_file" ] || fail "$format payload has no native runtime libraries." +} + +TARBALL_APP_DIR="$TARBALL_EXTRACT/typewhisper-linux-x64-${VERSION}" +[ -d "$TARBALL_APP_DIR" ] || fail "tarball did not contain its expected top-level directory." +require_executable "$TARBALL_APP_DIR/install.sh" +validate_payload \ + "tarball" \ + "$TARBALL_APP_DIR" \ + "$TARBALL_APP_DIR/typewhisper" \ + "$TARBALL_APP_DIR/typewhisper.desktop" \ + "$TARBALL_APP_DIR/typewhisper.png" + +APPIMAGE_ROOT="$APPIMAGE_EXTRACT/squashfs-root" +require_executable "$APPIMAGE_ROOT/AppRun" +validate_desktop_entry "$APPIMAGE_ROOT/typewhisper.desktop" +require_file "$APPIMAGE_ROOT/typewhisper.png" +file "$APPIMAGE_ROOT/typewhisper.png" | grep -Fq "PNG image data" \ + || fail "AppImage root icon is not a PNG image." +validate_payload \ + "AppImage" \ + "$APPIMAGE_ROOT/usr/bin" \ + "$APPIMAGE_ROOT/usr/bin/typewhisper" \ + "$APPIMAGE_ROOT/usr/share/applications/typewhisper.desktop" \ + "$APPIMAGE_ROOT/usr/share/icons/hicolor/128x128/apps/typewhisper.png" + +require_executable "$DEB_EXTRACT/usr/bin/typewhisper" +require_executable "$DEB_EXTRACT/usr/bin/typewhisper-cli" +validate_payload \ + "deb" \ + "$DEB_EXTRACT/opt/typewhisper" \ + "$DEB_EXTRACT/opt/typewhisper/typewhisper" \ + "$DEB_EXTRACT/usr/share/applications/typewhisper.desktop" \ + "$DEB_EXTRACT/usr/share/icons/hicolor/128x128/apps/typewhisper.png" + +require_executable "$RPM_EXTRACT/usr/bin/typewhisper" +require_executable "$RPM_EXTRACT/usr/bin/typewhisper-cli" +validate_payload \ + "rpm" \ + "$RPM_EXTRACT/opt/typewhisper" \ + "$RPM_EXTRACT/opt/typewhisper/typewhisper" \ + "$RPM_EXTRACT/usr/share/applications/typewhisper.desktop" \ + "$RPM_EXTRACT/usr/share/icons/hicolor/128x128/apps/typewhisper.png" + +echo "==> All host-side metadata and extraction checks passed." +if [ "$MODE" = "--validate-only" ]; then + echo "==> Container install/execution checks explicitly skipped by --validate-only." + exit 0 +fi + +require_command docker +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_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" + local image="$2" + local package="$3" + local container_name="typewhisper-package-smoke-${format}-$$" + local status + + 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 \ + --mount "type=bind,src=$SCRIPT_PATH,dst=/smoke-test-linux-packages.sh,readonly" \ + --mount "type=bind,src=$PACKAGE_DIR,dst=/packages,readonly" \ + "$image" \ + bash /smoke-test-linux-packages.sh \ + --container "$format" "/packages/$(basename "$package")" "$EXPECTED_CLI_VERSION" + status=$? + set -e + + if [ "$status" -ne 0 ]; then + docker rm --force "$container_name" >/dev/null 2>&1 || true + fail "$format container smoke test failed or timed out (status $status)." + 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" +run_container_smoke "appimage" "$UBUNTU_IMAGE" "$EXPECTED_APPIMAGE" +run_container_smoke "deb" "$UBUNTU_IMAGE" "$EXPECTED_DEB" +run_container_smoke "rpm" "$FEDORA_IMAGE" "$EXPECTED_RPM" + +echo "==> All Linux package smoke tests passed." diff --git a/src/TypeWhisper.Cli/Commands/ModelsCommand.cs b/src/TypeWhisper.Cli/Commands/ModelsCommand.cs index 459b5ed6f..a4b84f974 100644 --- a/src/TypeWhisper.Cli/Commands/ModelsCommand.cs +++ b/src/TypeWhisper.Cli/Commands/ModelsCommand.cs @@ -7,12 +7,26 @@ namespace TypeWhisper.Cli.Commands; /// Implements typewhisper models: lists available models as a table or JSON. internal static class ModelsCommand { - public static async Task RunAsync(ApiClient api, bool json) + private static readonly TimeSpan s_defaultBudget = TimeSpan.FromSeconds(10); + + public static async Task RunAsync( + ApiClient api, + bool json, + CancellationToken ct, + TimeSpan? budget = null + ) { + var requestBudget = budget ?? s_defaultBudget; + using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + requestCts.CancelAfter(requestBudget); + try { - var response = await api.Http.GetAsync($"{api.BaseUrl}/v1/models"); - var body = await response.Content.ReadAsStringAsync(); + using var response = await api.Http.GetAsync( + $"{api.BaseUrl}/v1/models", + requestCts.Token + ); + var body = await response.Content.ReadAsStringAsync(requestCts.Token); if (!response.IsSuccessStatusCode) { return ConsoleOutput.Error( @@ -20,31 +34,28 @@ public static async Task RunAsync(ApiClient api, bool json) ); } - if (json) + var validation = ApiResponseValidator.ValidateModels(body); + if (validation.Error is not null) { - Console.WriteLine(JsonFormatting.PrettyJson(body)); - return 0; + return ApiResponseValidator.ProtocolError(validation.Error); } - using var doc = JsonDocument.Parse(body); - if (!doc.RootElement.TryGetProperty("models", out var models)) + if (json) { - await Console.Error.WriteLineAsync( - "Warning: response is missing the 'models' field; the API contract may have changed." - ); + Console.WriteLine(JsonFormatting.PrettyJson(body)); return 0; } - var rows = models.EnumerateArray().ToList(); + var rows = validation.Value!.Models; if (rows.Count == 0) { Console.WriteLine("No models available."); return 0; } - var idWidth = Math.Max(2, rows.Max(m => JsonFormatting.Prop(m, "id").Length)); - var engineWidth = Math.Max(6, rows.Max(m => JsonFormatting.Prop(m, "engine").Length)); - var nameWidth = Math.Max(4, rows.Max(m => JsonFormatting.Prop(m, "name").Length)); + var idWidth = Math.Max(2, rows.Max(m => m.Id.Length)); + var engineWidth = Math.Max(6, rows.Max(m => m.Engine.Length)); + var nameWidth = Math.Max(4, rows.Max(m => m.Name.Length)); Console.WriteLine( $"{ConsoleOutput.Pad("ID", idWidth)} {ConsoleOutput.Pad("ENGINE", engineWidth)} {ConsoleOutput.Pad("NAME", nameWidth)} STATUS" @@ -53,10 +64,9 @@ await Console.Error.WriteLineAsync( foreach (var m in rows) { - var selected = - m.TryGetProperty("selected", out var sel) && sel.GetBoolean() ? " *" : ""; + var selected = m.Selected ? " *" : ""; Console.WriteLine( - $"{ConsoleOutput.Pad(JsonFormatting.Prop(m, "id"), idWidth)} {ConsoleOutput.Pad(JsonFormatting.Prop(m, "engine"), engineWidth)} {ConsoleOutput.Pad(JsonFormatting.Prop(m, "name"), nameWidth)} {JsonFormatting.Prop(m, "status")}{selected}" + $"{ConsoleOutput.Pad(m.Id, idWidth)} {ConsoleOutput.Pad(m.Engine, engineWidth)} {ConsoleOutput.Pad(m.Name, nameWidth)} {m.Status}{selected}" ); } @@ -66,9 +76,19 @@ await Console.Error.WriteLineAsync( { return ConsoleOutput.Error("TypeWhisper is not running or API server is disabled."); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return ConsoleOutput.Error("Cancelled."); + } + catch (OperationCanceledException) + { + return ConsoleOutput.Error( + $"The API did not respond within {ConsoleOutput.FormatBudget(requestBudget)}." + ); + } catch (JsonException) { return ConsoleOutput.Error("Received malformed JSON from the API."); } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Cli/Commands/StatusCommand.cs b/src/TypeWhisper.Cli/Commands/StatusCommand.cs index f21575ab2..034872be5 100644 --- a/src/TypeWhisper.Cli/Commands/StatusCommand.cs +++ b/src/TypeWhisper.Cli/Commands/StatusCommand.cs @@ -7,12 +7,26 @@ namespace TypeWhisper.Cli.Commands; /// Implements typewhisper status: reports engine/model readiness. internal static class StatusCommand { - public static async Task RunAsync(ApiClient api, bool json) + private static readonly TimeSpan s_defaultBudget = TimeSpan.FromSeconds(10); + + public static async Task RunAsync( + ApiClient api, + bool json, + CancellationToken ct, + TimeSpan? budget = null + ) { + var requestBudget = budget ?? s_defaultBudget; + using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + requestCts.CancelAfter(requestBudget); + try { - var response = await api.Http.GetAsync($"{api.BaseUrl}/v1/status"); - var body = await response.Content.ReadAsStringAsync(); + using var response = await api.Http.GetAsync( + $"{api.BaseUrl}/v1/status", + requestCts.Token + ); + var body = await response.Content.ReadAsStringAsync(requestCts.Token); if (!response.IsSuccessStatusCode) { return ConsoleOutput.Error( @@ -20,21 +34,24 @@ public static async Task RunAsync(ApiClient api, bool json) ); } + var validation = ApiResponseValidator.ValidateStatus(body); + if (validation.Error is not null) + { + return ApiResponseValidator.ProtocolError(validation.Error); + } + if (json) { Console.WriteLine(JsonFormatting.PrettyJson(body)); return 0; } - using var doc = JsonDocument.Parse(body); - var root = doc.RootElement; - var status = JsonFormatting.Prop(root, "status") == "ready" ? "Ready" : "No model loaded"; - var engine = JsonFormatting.Prop(root, "engine"); - var model = JsonFormatting.Prop(root, "model"); + var result = validation.Value!; + var status = result.Status == "ready" ? "Ready" : "No model loaded"; Console.WriteLine( - string.IsNullOrEmpty(model) - ? $"{status} - {engine}" - : $"{status} - {engine} ({model})" + string.IsNullOrEmpty(result.Model) + ? $"{status} - {result.Engine}" + : $"{status} - {result.Engine} ({result.Model})" ); return 0; } @@ -42,9 +59,19 @@ public static async Task RunAsync(ApiClient api, bool json) { return ConsoleOutput.Error("TypeWhisper is not running or API server is disabled."); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return ConsoleOutput.Error("Cancelled."); + } + catch (OperationCanceledException) + { + return ConsoleOutput.Error( + $"The API did not respond within {ConsoleOutput.FormatBudget(requestBudget)}." + ); + } catch (JsonException) { return ConsoleOutput.Error("Received malformed JSON from the API."); } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Cli/Commands/TranscribeCommand.cs b/src/TypeWhisper.Cli/Commands/TranscribeCommand.cs index d1fa89bff..001623380 100644 --- a/src/TypeWhisper.Cli/Commands/TranscribeCommand.cs +++ b/src/TypeWhisper.Cli/Commands/TranscribeCommand.cs @@ -1,5 +1,6 @@ -using System.Net.Http.Headers; +using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using TypeWhisper.Cli.Models; using TypeWhisper.Cli.Output; using TypeWhisper.Cli.Services; @@ -7,16 +8,28 @@ namespace TypeWhisper.Cli.Commands; /// -/// Implements typewhisper transcribe <file|->: uploads an audio -/// file (or stdin) to the API and prints the transcript or JSON response. +/// Implements typewhisper transcribe <file|->: passes a local audio +/// path (spooling stdin to a private file) to the API and prints the transcript +/// or JSON response. /// -internal static class TranscribeCommand +internal static partial class TranscribeCommand { - // Mirrors the server's MaxTranscribeRequestBytes (HttpApiService): the API - // rejects larger uploads, so there is no point buffering past this. - private const long MaxStdinBytes = 100L * 1024 * 1024; + // Longest magic-byte window StdinAudioSniffer.Detect inspects (RIFF/WAVE). + private const int SniffHeadBytes = 12; - public static async Task RunAsync(ApiClient api, CliOptions options) + private const UnixFileMode PrivateFileMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite; + + public static Task RunAsync(ApiClient api, CliOptions options) + { + return RunAsync(api, options, Console.OpenStandardInput()); + } + + internal static async Task RunAsync( + ApiClient api, + CliOptions options, + Stream stdin + ) { if (!string.IsNullOrEmpty(options.Language) && options.LanguageHints.Count > 0) { @@ -26,136 +39,241 @@ public static async Task RunAsync(ApiClient api, CliOptions options) var file = options.Positionals.FirstOrDefault(); if (string.IsNullOrWhiteSpace(file)) { - return ConsoleOutput.Error("Usage: typewhisper transcribe "); + return ConsoleOutput.Error("Usage: typewhisper-cli transcribe "); } - Stream audioStream; - string fileName; - - if (file == "-") + string? spoolPath = null; + try { - // Buffer stdin to enable magic-byte sniffing before forwarding to - // the API; cap at MaxTranscribeRequestBytes so an unbounded pipe can't OOM. - var buffer = new MemoryStream(); - var stdin = Console.OpenStandardInput(); - var chunk = new byte[81920]; - int read; - while ((read = await stdin.ReadAsync(chunk)) > 0) + string localPath; + if (file == "-") { - if (buffer.Length + read > MaxStdinBytes) + try { - return ConsoleOutput.Error( - $"stdin audio exceeds the {MaxStdinBytes / (1024 * 1024)} MB limit." - ); - } + spoolPath = await SpoolStdinAsync(stdin); + if (spoolPath is null) + { + // Multipart already 400s empty bodies; local-file has no such + // check and would spawn ffmpeg on nothing, returning a bare 500. + return ConsoleOutput.Error("Empty audio data on stdin."); + } - buffer.Write(chunk, 0, read); + localPath = spoolPath; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return ConsoleOutput.Error($"Could not spool stdin: {ex.Message}"); + } } - - buffer.Position = 0; - audioStream = buffer; - fileName = $"stdin.{StdinAudioSniffer.Detect(buffer.GetBuffer().AsSpan(0, (int)buffer.Length))}"; - } - else - { - if (!File.Exists(file)) + else { - return ConsoleOutput.Error($"File not found: {file}"); + if (!File.Exists(file)) + { + return ConsoleOutput.Error($"File not found: {file}"); + } + + localPath = Path.GetFullPath(file); } + // Multipart trimmed fields and dropped blanks server-side; local-file + // forwards the body verbatim, so trim here to keep --engine " whisper " working. + var request = new LocalFileTranscribeRequest( + localPath, + Clean(options.Language), + [.. options.LanguageHints.Select(Clean).OfType()], + Clean(options.Task), + Clean(options.TranslateTo), + Clean(options.ResponseFormat), + Clean(options.Prompt), + Clean(options.Engine), + Clean(options.Model), + options.AwaitDownload + ); + using var content = new StringContent( + JsonSerializer.Serialize( + request, + TranscribeJsonContext.Default.LocalFileTranscribeRequest + ), + Encoding.UTF8, + "application/json" + ); + var requestBudget = options.AwaitDownload + ? TimeSpan.FromMinutes(15) + : TimeSpan.FromMinutes(5); + using var requestCts = new CancellationTokenSource(requestBudget); + + HttpResponseMessage response; + string body; try { - audioStream = File.OpenRead(file); + response = await api.TranscribeHttp.PostAsync( + $"{api.BaseUrl}/v1/transcribe/local-file", + content, + requestCts.Token + ); + body = await response.Content.ReadAsStringAsync(requestCts.Token); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (OperationCanceledException) { - return ConsoleOutput.Error($"Could not open file: {ex.Message}"); + return ConsoleOutput.Error( + options.AwaitDownload + ? "Transcription timed out while waiting for model download." + : "Transcription timed out." + ); } - fileName = Path.GetFileName(file); - } - - try - { - await using (audioStream) + if (!response.IsSuccessStatusCode) { - using var content = new MultipartFormDataContent(); - var fileContent = new StreamContent(audioStream); - fileContent.Headers.ContentType = new MediaTypeHeaderValue( - "application/octet-stream" + return ConsoleOutput.Error( + $"Transcription failed ({(int)response.StatusCode}): {JsonFormatting.ExtractErrorMessage(body)}" ); - content.Add(fileContent, "file", fileName); - - AddString(content, "language", options.Language); - foreach (var hint in options.LanguageHints) - { - AddString(content, "language_hint", hint); - } - - AddString(content, "task", options.Task); - AddString(content, "target_language", options.TranslateTo); - AddString(content, "response_format", options.ResponseFormat); - AddString(content, "prompt", options.Prompt); - AddString(content, "engine", options.Engine); - AddString(content, "model", options.Model); - - var path = options.AwaitDownload - ? "/v1/transcribe?await_download=1" - : "/v1/transcribe"; - var requestBudget = options.AwaitDownload - ? TimeSpan.FromMinutes(15) - : TimeSpan.FromMinutes(5); - using var requestCts = new CancellationTokenSource(requestBudget); - - HttpResponseMessage response; - string body; - try - { - response = await api.TranscribeHttp.PostAsync( - $"{api.BaseUrl}{path}", - content, - requestCts.Token - ); - body = await response.Content.ReadAsStringAsync(requestCts.Token); - } - catch (OperationCanceledException) - { - return ConsoleOutput.Error( - options.AwaitDownload - ? "Transcription timed out while waiting for model download." - : "Transcription timed out." - ); - } - - if (!response.IsSuccessStatusCode) - { - return ConsoleOutput.Error( - $"Transcription failed ({(int)response.StatusCode}): {JsonFormatting.ExtractErrorMessage(body)}" - ); - } + } - if (options.Json) - { - Console.WriteLine(JsonFormatting.PrettyJson(body)); - return 0; - } + var validation = ApiResponseValidator.ValidateTranscribe(body); + if (validation.Error is not null) + { + return ApiResponseValidator.ProtocolError(validation.Error); + } - using var doc = JsonDocument.Parse(body); - Console.WriteLine(JsonFormatting.Prop(doc.RootElement, "text")); + if (options.Json) + { + Console.WriteLine(JsonFormatting.PrettyJson(body)); return 0; } + + Console.WriteLine(validation.Value!.Text); + return 0; } catch (HttpRequestException) { return ConsoleOutput.Error("TypeWhisper is not running or API server is disabled."); } + finally + { + if (spoolPath is not null) + { + TryDeleteSpoolFile(spoolPath); + } + } } - private static void AddString(MultipartFormDataContent content, string name, string? value) + private static void TryDeleteSpoolFile(string path) { - if (!string.IsNullOrWhiteSpace(value)) + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - content.Add(new StringContent(value), name); + // Cleanup must never replace the command's result or the spool failure that + // got us here — but the file holds the whole recording, so say where it is. + Console.Error.WriteLine( + $"Warning: could not remove the temporary audio file {path}: {ex.Message}" + ); } } -} \ No newline at end of file + + private static string? Clean(string? value) + { + var trimmed = value?.Trim(); + return string.IsNullOrEmpty(trimmed) ? null : trimmed; + } + + /// + /// Spools stdin to a private temp file, returning null when stdin was + /// empty so the caller can report it without creating the file. + /// + private static async Task SpoolStdinAsync(Stream stdin) + { + // A pipe may satisfy a read with fewer bytes than were asked for, so fill + // the whole sniff window before detecting; otherwise a short first read + // mis-detects the container as the "wav" default. + var head = new byte[SniffHeadBytes]; + var headLength = 0; + while (headLength < head.Length) + { + var headRead = await stdin.ReadAsync(head.AsMemory(headLength)); + if (headRead == 0) + { + break; + } + + headLength += headRead; + } + + // The head loop only stops short of the window at EOF, so no bytes here means + // stdin was empty. + if (headLength == 0) + { + return null; + } + + var extension = StdinAudioSniffer.Detect(head.AsSpan(0, headLength)); + var spoolPath = Path.GetFullPath( + Path.Join( + Path.GetTempPath(), + $"typewhisper-stdin-{Guid.NewGuid():N}.{extension}" + ) + ); + + try + { + var spoolOptions = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + }; + // Apply 0600 at open time so there is no chmod-after-create window. + if (!OperatingSystem.IsWindows()) + { + spoolOptions.UnixCreateMode = PrivateFileMode; + } + + await using var spool = new FileStream( + spoolPath, + spoolOptions + ); + await spool.WriteAsync(head.AsMemory(0, headLength)); + + // The local-file route has no audio-body limit, so stream until EOF and + // let available temporary storage be the natural bound. + var chunk = new byte[81920]; + int read; + while ((read = await stdin.ReadAsync(chunk)) > 0) + { + await spool.WriteAsync(chunk.AsMemory(0, read)); + } + + return spoolPath; + } + catch + { + TryDeleteSpoolFile(spoolPath); + throw; + } + } + + // Every property is read reflectively by JsonSerializer when the request body is + // written, which ReSharper cannot see. + // ReSharper disable NotAccessedPositionalProperty.Local + private sealed record LocalFileTranscribeRequest( + string Path, + string? Language, + IReadOnlyList LanguageHints, + string? Task, + string? TargetLanguage, + string? ResponseFormat, + string? Prompt, + string? Engine, + string? Model, + bool AwaitDownload + ); + // ReSharper restore NotAccessedPositionalProperty.Local + + // Source-generated so the published CLI can be trimmed: the reflection-based + // serializer roots types the linker can't see and warns (IL2026). + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] + [JsonSerializable(typeof(LocalFileTranscribeRequest))] + private partial class TranscribeJsonContext : JsonSerializerContext; +} diff --git a/src/TypeWhisper.Cli/Models/CliOptions.cs b/src/TypeWhisper.Cli/Models/CliOptions.cs index 4a5b41fd4..0c16ac3f6 100644 --- a/src/TypeWhisper.Cli/Models/CliOptions.cs +++ b/src/TypeWhisper.Cli/Models/CliOptions.cs @@ -10,12 +10,8 @@ namespace TypeWhisper.Cli.Models; /// internal sealed record CliOptions { - private const int DefaultPort = 9876; - public string? Command { get; init; } public List Positionals { get; init; } = []; - public int Port { get; init; } = DefaultPort; - public bool PortWasExplicit { get; init; } public string? Token { get; init; } public bool TokenWasExplicit { get; init; } public bool Json { get; init; } @@ -37,6 +33,7 @@ public static CliOptions Parse(string[] args) var options = new CliOptions(); var positionals = new List(); var languageHints = new List(); + var transcribeOptions = new List(); string? command = null; string? language = null; var task = "transcribe"; @@ -47,14 +44,34 @@ public static CliOptions Parse(string[] args) string? model = null; var token = Environment.GetEnvironmentVariable("TYPEWHISPER_API_TOKEN"); var tokenWasExplicit = false; - var port = DefaultPort; - var portWasExplicit = false; var json = false; var awaitDownload = false; + var parseOptions = true; for (var i = 0; i < args.Length; i++) { var arg = args[i]; + // ReSharper disable once ConvertIfStatementToSwitchStatement -- these are guard clauses on different subjects (the "--" separator vs. the post-separator operand mode); a switch on parseOptions would sit right above the switch on arg below and read worse. + if (parseOptions && arg == "--") + { + parseOptions = false; + continue; + } + + if (!parseOptions) + { + if (command is null) + { + command = arg; + } + else + { + positionals.Add(arg); + } + + continue; + } + switch (arg) { case "--help": @@ -67,19 +84,7 @@ public static CliOptions Parse(string[] args) break; case "--await-download": awaitDownload = true; - break; - case "--port": - if ( - !TryReadValue(args, ref i, out var portValue) - || !int.TryParse(portValue, out port) - || port < 1 - || port > 65535 - ) - { - return options with { ErrorMessage = "--port requires a number between 1 and 65535." }; - } - - portWasExplicit = true; + transcribeOptions.Add(arg); break; case "--token": case "--api-token": @@ -96,6 +101,7 @@ public static CliOptions Parse(string[] args) return options with { ErrorMessage = "--language requires a value." }; } + transcribeOptions.Add(arg); break; case "--language-hint": if (!TryReadValue(args, ref i, out var hint)) @@ -104,13 +110,26 @@ public static CliOptions Parse(string[] args) } languageHints.Add(hint); + transcribeOptions.Add(arg); break; case "--task": - if (!TryReadValue(args, ref i, out task)) + if (!TryReadValue(args, ref i, out var taskValue)) { return options with { ErrorMessage = "--task requires a value." }; } + var normalizedTask = NormalizeTask(taskValue); + if (normalizedTask is null) + { + return options with + { + ErrorMessage = + $"Invalid value '{taskValue}' for --task. Allowed values: transcribe, translate.", + }; + } + + task = normalizedTask; + transcribeOptions.Add(arg); break; case "--translate-to": if (!TryReadValue(args, ref i, out translateTo)) @@ -118,13 +137,26 @@ public static CliOptions Parse(string[] args) return options with { ErrorMessage = "--translate-to requires a value." }; } + transcribeOptions.Add(arg); break; case "--response-format": - if (!TryReadValue(args, ref i, out responseFormat)) + if (!TryReadValue(args, ref i, out var responseFormatValue)) { return options with { ErrorMessage = "--response-format requires a value." }; } + var normalizedResponseFormat = NormalizeResponseFormat(responseFormatValue); + if (normalizedResponseFormat is null) + { + return options with + { + ErrorMessage = + $"Invalid value '{responseFormatValue}' for --response-format. Allowed values: json, verbose_json.", + }; + } + + responseFormat = normalizedResponseFormat; + transcribeOptions.Add(arg); break; case "--prompt": if (!TryReadValue(args, ref i, out prompt)) @@ -132,6 +164,7 @@ public static CliOptions Parse(string[] args) return options with { ErrorMessage = "--prompt requires a value." }; } + transcribeOptions.Add(arg); break; case "--engine": if (!TryReadValue(args, ref i, out engine)) @@ -139,6 +172,7 @@ public static CliOptions Parse(string[] args) return options with { ErrorMessage = "--engine requires a value." }; } + transcribeOptions.Add(arg); break; case "--model": if (!TryReadValue(args, ref i, out model)) @@ -146,6 +180,7 @@ public static CliOptions Parse(string[] args) return options with { ErrorMessage = "--model requires a value." }; } + transcribeOptions.Add(arg); break; default: if (arg.StartsWith('-') && arg != "-") @@ -166,12 +201,10 @@ public static CliOptions Parse(string[] args) } } - return options with + var parsed = options with { Command = command, Positionals = positionals, - Port = port, - PortWasExplicit = portWasExplicit, Token = token, TokenWasExplicit = tokenWasExplicit, Json = json, @@ -183,8 +216,35 @@ public static CliOptions Parse(string[] args) Prompt = prompt, Engine = engine, Model = model, - AwaitDownload = awaitDownload + AwaitDownload = awaitDownload, + }; + + var grammarError = command switch + { + "status" or "models" when transcribeOptions.Count > 0 => + $"Option '{transcribeOptions[0]}' is not valid for '{command}'.", + "status" or "models" when positionals.Count > 0 => + $"Unexpected operand '{positionals[0]}' for '{command}'.", + "transcribe" when positionals.Count == 0 => + "Command 'transcribe' requires exactly one file operand.", + "transcribe" when positionals.Count > 1 => + $"Unexpected operand '{positionals[1]}' for 'transcribe'.", + _ => null, }; + + return grammarError is null ? parsed : parsed with { ErrorMessage = grammarError }; + } + + private static string? NormalizeTask(string value) + { + var normalized = value.Trim().ToLowerInvariant(); + return normalized is "transcribe" or "translate" ? normalized : null; + } + + private static string? NormalizeResponseFormat(string value) + { + var normalized = value.Trim().ToLowerInvariant(); + return normalized is "json" or "verbose_json" ? normalized : null; } private static bool TryReadValue(string[] args, ref int index, out string value) @@ -195,7 +255,7 @@ private static bool TryReadValue(string[] args, ref int index, out string value) return false; } - // Reject flag-looking tokens (e.g. "--json" after "--port") so a missing + // Reject flag-looking tokens (e.g. "--json" after "--token") so a missing // value fails fast. A bare "-" is allowed for stdin-style positionals. var candidate = args[index + 1]; if (candidate.Length > 1 && candidate.StartsWith('-')) @@ -207,4 +267,4 @@ private static bool TryReadValue(string[] args, ref int index, out string value) value = args[++index]; return true; } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Cli/Models/DiscoveryFile.cs b/src/TypeWhisper.Cli/Models/DiscoveryFile.cs index f3a733668..da651e959 100644 --- a/src/TypeWhisper.Cli/Models/DiscoveryFile.cs +++ b/src/TypeWhisper.Cli/Models/DiscoveryFile.cs @@ -1,4 +1,9 @@ namespace TypeWhisper.Cli.Models; -/// Port and optional token read from the app's api-discovery.json. -internal sealed record DiscoveryFile(int Port, string? Token); \ No newline at end of file +/// TCP port, optional token, Unix socket, and protocol version read from api-discovery.json. +internal sealed record DiscoveryFile( + int Port, + string? Token, + string? SocketPath, + int? Version = null +); diff --git a/src/TypeWhisper.Cli/Output/ConsoleOutput.cs b/src/TypeWhisper.Cli/Output/ConsoleOutput.cs index d3ad37adb..7be8027f8 100644 --- a/src/TypeWhisper.Cli/Output/ConsoleOutput.cs +++ b/src/TypeWhisper.Cli/Output/ConsoleOutput.cs @@ -2,8 +2,8 @@ namespace TypeWhisper.Cli.Output; /// /// Small console helpers shared by the commands: error reporting (writes to -/// stderr and returns the process exit code) and fixed-width padding for -/// the tabular models listing. +/// stderr and returns the process exit code), fixed-width padding for the +/// tabular models listing, and timeout wording. /// internal static class ConsoleOutput { @@ -17,4 +17,13 @@ public static string Pad(string value, int width) { return value.PadRight(width); } + + public static string FormatBudget(TimeSpan budget) + { + var seconds = budget.TotalSeconds.ToString( + "0.###", + System.Globalization.CultureInfo.InvariantCulture + ); + return seconds == "1" ? "1 second" : $"{seconds} seconds"; + } } \ No newline at end of file diff --git a/src/TypeWhisper.Cli/Output/JsonFormatting.cs b/src/TypeWhisper.Cli/Output/JsonFormatting.cs index 23f99982d..9ca2c715e 100644 --- a/src/TypeWhisper.Cli/Output/JsonFormatting.cs +++ b/src/TypeWhisper.Cli/Output/JsonFormatting.cs @@ -1,36 +1,16 @@ +using System.Buffers; +using System.Text; using System.Text.Json; namespace TypeWhisper.Cli.Output; /// -/// JSON helpers for rendering API responses: scalar property extraction for -/// the human-readable tables, pretty-printing for --json output, and -/// error-message extraction from the API's error envelope. +/// JSON helpers for rendering API responses: pretty-printing for --json +/// output, and error-message extraction from the API's error envelope. /// internal static class JsonFormatting { - private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; - - /// - /// Returns the scalar value of property as a string, - /// or "" when the property is absent or not a string/number/bool. - /// - public static string Prop(JsonElement el, string name) - { - if (!el.TryGetProperty(name, out var value)) - { - return ""; - } - - return value.ValueKind switch - { - JsonValueKind.String => value.GetString() ?? "", - JsonValueKind.Number => value.ToString(), - JsonValueKind.True => "true", - JsonValueKind.False => "false", - _ => "" - }; - } + private static readonly JsonWriterOptions s_writerOptions = new() { Indented = true }; /// Re-serializes indented, returning the input unchanged if it isn't valid JSON. public static string PrettyJson(string json) @@ -38,7 +18,15 @@ public static string PrettyJson(string json) try { using var doc = JsonDocument.Parse(json); - return JsonSerializer.Serialize(doc.RootElement, s_jsonOptions); + // WriteTo rather than JsonSerializer.Serialize: writing an already-parsed + // document needs no reflection, so this survives trimming (IL2026). + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer, s_writerOptions)) + { + doc.RootElement.WriteTo(writer); + } + + return Encoding.UTF8.GetString(buffer.WrittenSpan); } catch { diff --git a/src/TypeWhisper.Cli/Output/UsageText.cs b/src/TypeWhisper.Cli/Output/UsageText.cs index 1cb9d3e63..2c3cc0856 100644 --- a/src/TypeWhisper.Cli/Output/UsageText.cs +++ b/src/TypeWhisper.Cli/Output/UsageText.cs @@ -9,7 +9,7 @@ public static void Print() """ TypeWhisper CLI - Speech-to-Text from the command line - Usage: typewhisper [options] + Usage: typewhisper-cli [options] Commands: status Show TypeWhisper status @@ -17,12 +17,12 @@ models List available models transcribe Transcribe an audio file, or - for stdin Global options: - --port API server port (default: 9876, or auto-discovered) --token API bearer token, or TYPEWHISPER_API_TOKEN --api-token Alias of --token (Mac CLI parity) --json Output as JSON --version Show version --help, -h Show this help + -- Treat remaining arguments as file operands Transcribe options: --language Source language (e.g. en, de) @@ -36,13 +36,13 @@ models List available models --await-download Wait for local model restore/download Examples: - typewhisper status --token "$TYPEWHISPER_API_TOKEN" - typewhisper transcribe recording.wav - typewhisper transcribe recording.wav --language de --json - typewhisper transcribe recording.wav --language-hint de --language-hint en - typewhisper transcribe recording.wav --engine groq --model whisper-large-v3-turbo - typewhisper transcribe - < audio.wav + typewhisper-cli status --token "$TYPEWHISPER_API_TOKEN" + typewhisper-cli transcribe recording.wav + typewhisper-cli transcribe recording.wav --language de --json + typewhisper-cli transcribe recording.wav --language-hint de --language-hint en + typewhisper-cli transcribe recording.wav --engine groq --model whisper-large-v3-turbo + typewhisper-cli transcribe - < audio.wav """ ); } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Cli/Program.cs b/src/TypeWhisper.Cli/Program.cs index 1627e1531..e5f3b98e5 100644 --- a/src/TypeWhisper.Cli/Program.cs +++ b/src/TypeWhisper.Cli/Program.cs @@ -7,14 +7,19 @@ namespace TypeWhisper.Cli; /// /// TypeWhisper CLI entry point. Parses arguments, resolves the API -/// port/token (explicit flags win over the auto-discovery file), then +/// Unix socket/token (explicit token flags win over auto-discovery), then /// dispatches to the matching command. All real work lives in the /// , , and /// namespaces; this file only wires them together. /// public static class Program { - private static async Task Main(string[] args) + private static Task Main(string[] args) + { + return RunAsync(args); + } + + internal static async Task RunAsync(string[] args) { var options = CliOptions.Parse(args); if (options.ShowHelp) @@ -34,30 +39,79 @@ private static async Task Main(string[] args) return ConsoleOutput.Error(options.ErrorMessage); } + // ReSharper disable once InvertIf -- guard clause, matching the three checks above. if (options.Command is null) { UsageText.Print(); return 1; } - // Auto-discovery: pick up port + token from ~/.config/typewhisper/api-discovery.json - // when neither was explicitly passed. Explicit --port/--token always wins. - var discovered = DiscoveryFileReader.TryRead(); - var port = options.PortWasExplicit - ? options.Port - : discovered?.Port ?? options.Port; - var token = options.TokenWasExplicit - ? options.Token - : options.Token ?? discovered?.Token; - - var api = new ApiClient($"http://127.0.0.1:{port}", token); - return options.Command switch { - "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}") + "status" => await WithQuickApiAsync( + (api, ct) => StatusCommand.RunAsync(api, options.Json, ct) + ), + "models" => await WithQuickApiAsync( + (api, ct) => ModelsCommand.RunAsync(api, options.Json, ct) + ), + "transcribe" => await WithApiAsync(api => TranscribeCommand.RunAsync(api, options)), + _ => ConsoleOutput.Error($"Unknown command: {options.Command}"), }; + + // Ctrl+C is intercepted only once the API call is in flight. Installing the + // handler around discovery would suppress the default terminate while + // DiscoveryFileReader.TryRead is blocked, and that read is synchronous and + // uncancellable, so the CLI would stop responding to Ctrl+C entirely. + Task WithQuickApiAsync(Func> run) + { + return WithApiAsync(async api => + { + using var cts = new CancellationTokenSource(); + ConsoleCancelEventHandler handler = (_, e) => + { + e.Cancel = true; + // ReSharper disable once AccessToDisposedClosure -- the finally below unsubscribes the handler before the using disposes cts. + cts.Cancel(); + }; + Console.CancelKeyPress += handler; + try + { + return await run(api, cts.Token); + } + finally + { + Console.CancelKeyPress -= handler; + } + }); + } + + // Resolved per command so an unknown command still reports itself when the + // app is stopped. The CLI never falls back to TCP: the socket path + // authenticates the transport before bearer credentials or private audio + // leave this process. + async Task WithApiAsync(Func> run) + { + var discovered = DiscoveryFileReader.TryRead(); + if (discovered?.Version is { } version && version != 2) + { + return ConsoleOutput.Error( + $"The TypeWhisper app wrote discovery protocol version {version}, but this CLI speaks version 2 — app and CLI versions are out of sync." + ); + } + + var socketPath = discovered?.SocketPath; + if (string.IsNullOrWhiteSpace(socketPath)) + { + return ConsoleOutput.Error( + "TypeWhisper API socket not found — is the TypeWhisper app running with the local API enabled?" + ); + } + + var token = options.TokenWasExplicit + ? options.Token + : options.Token ?? discovered?.Token; + + return await run(new ApiClient(socketPath, token)); + } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Cli/Services/ApiClient.cs b/src/TypeWhisper.Cli/Services/ApiClient.cs index 02ce6adbb..b6b1778e1 100644 --- a/src/TypeWhisper.Cli/Services/ApiClient.cs +++ b/src/TypeWhisper.Cli/Services/ApiClient.cs @@ -1,3 +1,4 @@ +using System.Net.Sockets; using System.Net.Http.Headers; namespace TypeWhisper.Cli.Services; @@ -12,9 +13,22 @@ namespace TypeWhisper.Cli.Services; /// internal sealed class ApiClient { - public ApiClient(string baseUrl, string? token) + private readonly Func _validateServer; + + public ApiClient( + string socketPath, + string? token, + Func? validateServer = null + ) { - BaseUrl = baseUrl; + ArgumentException.ThrowIfNullOrWhiteSpace(socketPath); + _validateServer = validateServer ?? UnixPeerCredentials.IsOwnedByEffectiveUser; + BaseUrl = "http://localhost"; + Http = new HttpClient(CreateHandler(socketPath)) { Timeout = Timeout.InfiniteTimeSpan }; + TranscribeHttp = new HttpClient(CreateHandler(socketPath)) + { + Timeout = Timeout.InfiniteTimeSpan, + }; if (string.IsNullOrWhiteSpace(token)) { @@ -27,7 +41,47 @@ public ApiClient(string baseUrl, string? token) } public string BaseUrl { get; } - public HttpClient Http { get; } = new() { Timeout = TimeSpan.FromMinutes(5) }; + public HttpClient Http { get; } - public HttpClient TranscribeHttp { get; } = new() { Timeout = Timeout.InfiniteTimeSpan }; -} \ No newline at end of file + public HttpClient TranscribeHttp { get; } + + private SocketsHttpHandler CreateHandler(string socketPath) + { + return new SocketsHttpHandler + { + AllowAutoRedirect = false, + // ConnectCallback always dials the Unix socket directly, so an ambient + // HTTP_PROXY/ALL_PROXY would just make the client speak proxy/SOCKS + // negotiation at Kestrel — never useful for a local socket. + UseProxy = false, + ConnectCallback = async (_, ct) => + { + var socket = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified + ); + try + { + await socket.ConnectAsync( + new UnixDomainSocketEndPoint(socketPath), + ct + ); + if (!_validateServer(socket)) + { + throw new UnauthorizedAccessException( + "TypeWhisper API socket is owned by a different user." + ); + } + + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + }, + }; + } +} diff --git a/src/TypeWhisper.Cli/Services/ApiResponseValidator.cs b/src/TypeWhisper.Cli/Services/ApiResponseValidator.cs new file mode 100644 index 000000000..68df2ce32 --- /dev/null +++ b/src/TypeWhisper.Cli/Services/ApiResponseValidator.cs @@ -0,0 +1,272 @@ +using System.Text.Json; +using TypeWhisper.Cli.Output; + +namespace TypeWhisper.Cli.Services; + +/// Validates successful API response bodies before commands render them. +internal static class ApiResponseValidator +{ + private const string SupportedApiVersion = "1.0"; + + internal sealed record StatusResponse(string Status, string Engine, string Model); + + internal sealed record ModelResponse( + string Id, + string Engine, + string Name, + string Status, + bool Selected + ); + + internal sealed record ModelsResponse(IReadOnlyList Models); + + internal sealed record TranscribeResponse(string Text); + + internal readonly record struct ValidationResult(T? Value, string? Error) + where T : class; + + public static ValidationResult ValidateStatus(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return Failure("status response must be a JSON object"); + } + + if (!root.TryGetProperty("status", out var status)) + { + return Failure( + "status response is missing required field 'status'" + ); + } + + if (status.ValueKind != JsonValueKind.String) + { + return Failure("field 'status' must be a string"); + } + + var statusValue = status.GetString()!; + if (statusValue is not ("ready" or "no_model")) + { + return Failure( + $"unknown status value '{statusValue}'" + ); + } + + if (root.TryGetProperty("api_version", out var apiVersion)) + { + if (apiVersion.ValueKind != JsonValueKind.String) + { + return Failure( + "field 'api_version' must be a string when present" + ); + } + + var apiVersionValue = apiVersion.GetString()!; + if (apiVersionValue != SupportedApiVersion) + { + return Failure( + $"API version '{apiVersionValue}' is not supported by this CLI, which speaks version {SupportedApiVersion}" + ); + } + } + + var engineResult = ReadOptionalString(root, "engine", "status"); + if (engineResult.Error is not null) + { + return Failure(engineResult.Error); + } + + var modelResult = ReadOptionalString(root, "model", "status"); + if (modelResult.Error is not null) + { + return Failure(modelResult.Error); + } + + return Success( + new StatusResponse( + statusValue, + engineResult.Value!, + modelResult.Value! + ) + ); + } + catch (JsonException) + { + return Failure("status response body is not valid JSON"); + } + } + + public static ValidationResult ValidateModels(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return Failure("models response must be a JSON object"); + } + + if (!root.TryGetProperty("models", out var models)) + { + return Failure( + "models response is missing required field 'models'" + ); + } + + if (models.ValueKind != JsonValueKind.Array) + { + return Failure("field 'models' must be an array"); + } + + var values = new List(); + var index = 0; + foreach (var model in models.EnumerateArray()) + { + if (model.ValueKind != JsonValueKind.Object) + { + return Failure( + $"models[{index}] must be a JSON object" + ); + } + + var idResult = ReadOptionalString(model, "id", $"models[{index}]"); + if (idResult.Error is not null) + { + return Failure(idResult.Error); + } + + var nameResult = ReadOptionalString(model, "name", $"models[{index}]"); + if (nameResult.Error is not null) + { + return Failure(nameResult.Error); + } + + var engineResult = ReadOptionalString(model, "engine", $"models[{index}]"); + if (engineResult.Error is not null) + { + return Failure(engineResult.Error); + } + + var statusResult = ReadOptionalString(model, "status", $"models[{index}]"); + if (statusResult.Error is not null) + { + return Failure(statusResult.Error); + } + + var selected = false; + if (model.TryGetProperty("selected", out var selectedElement)) + { + if ( + selectedElement.ValueKind + is not (JsonValueKind.True or JsonValueKind.False) + ) + { + return Failure( + $"field 'models[{index}].selected' must be a boolean when present" + ); + } + + selected = selectedElement.GetBoolean(); + } + + values.Add( + new ModelResponse( + idResult.Value!, + engineResult.Value!, + nameResult.Value!, + statusResult.Value!, + selected + ) + ); + index++; + } + + return Success(new ModelsResponse(values)); + } + catch (JsonException) + { + return Failure("models response body is not valid JSON"); + } + } + + public static ValidationResult ValidateTranscribe(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return Failure( + "transcription response must be a JSON object" + ); + } + + if (!root.TryGetProperty("text", out var text)) + { + return Failure( + "transcription response is missing required field 'text'" + ); + } + + // ReSharper disable once ConvertIfStatementToReturnStatement -- keeps the + // guard-clause shape the sibling validators use; the ternary would bury the + // happy path in the else-branch of a negated check. + if (text.ValueKind != JsonValueKind.String) + { + return Failure("field 'text' must be a string"); + } + + return Success(new TranscribeResponse(text.GetString()!)); + } + catch (JsonException) + { + return Failure( + "transcription response body is not valid JSON" + ); + } + } + + public static int ProtocolError(string detail) + { + return ConsoleOutput.Error( + $"Protocol error: {detail}. The TypeWhisper app and typewhisper-cli may be out of sync." + ); + } + + private static ValidationResult ReadOptionalString( + JsonElement parent, + string property, + string context + ) + { + // The app serializes absent optional strings as JSON null, so null reads as "". + if (!parent.TryGetProperty(property, out var value) || value.ValueKind == JsonValueKind.Null) + { + return Success(""); + } + + return value.ValueKind == JsonValueKind.String + ? Success(value.GetString()!) + : Failure( + $"field '{context}.{property}' must be a string when present" + ); + } + + private static ValidationResult Success(T value) + where T : class + { + return new ValidationResult(value, null); + } + + private static ValidationResult Failure(string error) + where T : class + { + return new ValidationResult(null, error); + } +} diff --git a/src/TypeWhisper.Cli/Services/DiscoveryFileReader.cs b/src/TypeWhisper.Cli/Services/DiscoveryFileReader.cs index 8bd85f43c..98feec267 100644 --- a/src/TypeWhisper.Cli/Services/DiscoveryFileReader.cs +++ b/src/TypeWhisper.Cli/Services/DiscoveryFileReader.cs @@ -6,8 +6,8 @@ namespace TypeWhisper.Cli.Services; /// /// Reads the running app's discovery file /// ($XDG_CONFIG_HOME/typewhisper/api-discovery.json, falling back to -/// ~/.config) so the CLI can auto-pick up the port and token when -/// neither was passed explicitly. Any read/parse failure is treated as +/// ~/.config) so the CLI can auto-pick up the Unix socket and token. +/// Any read/parse failure is treated as /// "no discovery file" and returns null. /// internal static class DiscoveryFileReader @@ -34,7 +34,18 @@ internal static class DiscoveryFileReader using var doc = JsonDocument.Parse(File.ReadAllText(path)); var root = doc.RootElement; int? port = null; + int? version = null; string? token = null; + string? socketPath = null; + if ( + root.TryGetProperty("version", out var versionEl) + && versionEl.ValueKind == JsonValueKind.Number + && versionEl.TryGetInt32(out var versionValue) + ) + { + version = versionValue; + } + if (root.TryGetProperty("port", out var portEl) && portEl.ValueKind == JsonValueKind.Number && portEl.TryGetInt32(out var portValue) @@ -48,11 +59,21 @@ internal static class DiscoveryFileReader token = tokenEl.GetString(); } - return port is null ? null : new DiscoveryFile(port.Value, token); + if ( + root.TryGetProperty("socket_path", out var socketPathEl) + && socketPathEl.ValueKind == JsonValueKind.String + ) + { + socketPath = socketPathEl.GetString(); + } + + return port is null + ? null + : new DiscoveryFile(port.Value, token, socketPath, version); } catch { return null; } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Cli/Services/UnixPeerCredentials.cs b/src/TypeWhisper.Cli/Services/UnixPeerCredentials.cs new file mode 100644 index 000000000..73187a538 --- /dev/null +++ b/src/TypeWhisper.Cli/Services/UnixPeerCredentials.cs @@ -0,0 +1,67 @@ +using System.ComponentModel; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +namespace TypeWhisper.Cli.Services; + +/// Validates the server identity before HTTP headers or bodies are sent. +internal static class UnixPeerCredentials +{ + private const int SolSocket = 1; + private const int SoPeerCred = 17; + + internal static bool IsOwnedByEffectiveUser(Socket socket) + { + ArgumentNullException.ThrowIfNull(socket); + + var credentials = new PeerCredentials(); + var length = (uint)Marshal.SizeOf(); + if ( + getsockopt( + socket.Handle, + SolSocket, + SoPeerCred, + ref credentials, + ref length + ) != 0 + ) + { + throw new IOException( + "Could not read TypeWhisper API peer credentials.", + new Win32Exception(Marshal.GetLastPInvokeError()) + ); + } + + if (length != Marshal.SizeOf()) + { + throw new IOException("TypeWhisper API peer credentials had an unexpected size."); + } + + return credentials.Uid == geteuid(); + } + + [StructLayout(LayoutKind.Sequential)] + private struct PeerCredentials + { + internal int Pid; + internal uint Uid; + internal uint Gid; + } + + // Classic DllImport keeps the CLI project free of generated unsafe marshalling code. +#pragma warning disable SYSLIB1054 + // ReSharper disable once InconsistentNaming -- native libc function name. + [DllImport("libc", SetLastError = true)] + private static extern int getsockopt( + IntPtr socket, + int level, + int optionName, + ref PeerCredentials optionValue, + ref uint optionLength + ); + + // ReSharper disable once InconsistentNaming -- native libc function name. + [DllImport("libc", SetLastError = true)] + private static extern uint geteuid(); +#pragma warning restore SYSLIB1054 +} diff --git a/src/TypeWhisper.Cli/TypeWhisper.Cli.csproj b/src/TypeWhisper.Cli/TypeWhisper.Cli.csproj index f581a1646..32a58507a 100644 --- a/src/TypeWhisper.Cli/TypeWhisper.Cli.csproj +++ b/src/TypeWhisper.Cli/TypeWhisper.Cli.csproj @@ -6,7 +6,17 @@ enable latest TypeWhisper.Cli - typewhisper + typewhisper-cli + + true + full + false diff --git a/src/TypeWhisper.Core/Interfaces/IDictionaryService.cs b/src/TypeWhisper.Core/Interfaces/IDictionaryService.cs index 36017ce06..0491c7ecc 100644 --- a/src/TypeWhisper.Core/Interfaces/IDictionaryService.cs +++ b/src/TypeWhisper.Core/Interfaces/IDictionaryService.cs @@ -23,6 +23,13 @@ public interface IDictionaryService /// Applies the enabled corrections to and returns the rewritten text. string ApplyCorrections(string text); + /// + /// Like but never records usage counts or persists + /// to disk. For the live dictation preview, which can run many times per second on + /// text that may still change or never be inserted (audit §2 M3). + /// + string PreviewCorrections(string text); + /// Comma-separated enabled terms for seeding an STT/LLM prompt, or null when there are none. string? GetTermsForPrompt(); 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/Interfaces/ISettingsService.cs b/src/TypeWhisper.Core/Interfaces/ISettingsService.cs index d0fde5865..89bc8464b 100644 --- a/src/TypeWhisper.Core/Interfaces/ISettingsService.cs +++ b/src/TypeWhisper.Core/Interfaces/ISettingsService.cs @@ -16,5 +16,33 @@ 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. 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 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/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 f89835724..f0ef74dc6 100644 --- a/src/TypeWhisper.Core/Models/TextInsertionStatus.cs +++ b/src/TypeWhisper.Core/Models/TextInsertionStatus.cs @@ -12,5 +12,10 @@ public enum TextInsertionStatus ActionFailed, MissingClipboardTool, MissingPasteTool, - Failed + Failed, + + // 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, } 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/AtomicFileWrite.cs b/src/TypeWhisper.Core/Services/AtomicFileWrite.cs index bf3aa83a4..77f800e36 100644 --- a/src/TypeWhisper.Core/Services/AtomicFileWrite.cs +++ b/src/TypeWhisper.Core/Services/AtomicFileWrite.cs @@ -1,11 +1,101 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + 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; + + // 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 + /// 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 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 atomically here. + File.Move(tempPath, path); + return; + } + + if (Link(tempPath, path) == 0) + { + // 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); + } + catch + { + // Best-effort and deliberately unfiltered: an extra hard link is harmless. + } + + 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); + } + + /// + /// 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)); @@ -34,6 +124,61 @@ public static void WriteAllBytesCreateNew(string path, byte[] bytes) WriteCore(path, replaceExisting: false, tempPath => File.WriteAllBytes(tempPath, bytes)); } + /// + /// Atomically creates with complete byte content and the + /// requested Unix mode already set when the destination becomes visible. + /// + public static void WriteAllBytesCreateNew( + string path, + byte[] bytes, + UnixFileMode unixCreateMode + ) + { + if (OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException( + "An explicit Unix create mode is not supported on Windows." + ); + } + + WriteAllBytesCreateNewUnix(path, bytes, unixCreateMode); + } + + [UnsupportedOSPlatform("windows")] + private static void WriteAllBytesCreateNewUnix( + string path, + byte[] bytes, + UnixFileMode unixCreateMode + ) + { + WriteCore( + path, + replaceExisting: false, + tempPath => + { + using var stream = new FileStream( + tempPath, + new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + UnixCreateMode = unixCreateMode, + } + ); + File.SetUnixFileMode(tempPath, unixCreateMode); + if (File.GetUnixFileMode(tempPath) != unixCreateMode) + { + throw new IOException( + $"Could not apply Unix mode '{unixCreateMode}' to '{tempPath}'." + ); + } + + stream.Write(bytes); + } + ); + } + private static void WriteCore( string path, bool replaceExisting, @@ -74,20 +219,15 @@ Action writeTemporaryFile writeTemporaryFile(tempPath); } - if (replaceExisting && File.Exists(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)); - } + FlushToDisk(tempPath); - File.Replace(tempPath, path, null); + if (replaceExisting) + { + PublishReplace(tempPath, path); } else { - File.Move(tempPath, path); + PublishCreateNew(tempPath, path); } } catch @@ -109,4 +249,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/CleanupService.cs b/src/TypeWhisper.Core/Services/CleanupService.cs index 6c1628632..faa4c55bc 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; @@ -31,11 +32,11 @@ public sealed partial class CleanupService "things", "to", "we", - "with" + "with", }; // 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) { @@ -50,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, }; } @@ -65,7 +66,7 @@ public static string GetLlmSystemPrompt(CleanupLevel level) nameof(level), level, "Only Medium and High cleanup use LLM prompts." - ) + ), }; } @@ -131,7 +132,7 @@ private static string ApplySpokenPunctuation(string text) "exclamation mark" or "exclamation point" => "!", "colon" => ":", "semicolon" => ";", - _ => match.Value + _ => match.Value, }; } ); @@ -148,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, }; } @@ -286,7 +287,7 @@ private static int SpokenNumberToInt(string number) "seven" => 7, "eight" => 8, "nine" => 9, - _ => 0 + _ => 0, }; } @@ -315,7 +316,9 @@ private static string ApplyBasicSentenceCasing(string text) return text; } - [GeneratedRegex(@"(?i)(^|[\s,.;:!?-])(?:um+|uh+|er+|ah+|you know)(?=$|[\s,.;:!?-])")] + // Doubled-letter minimums (umm/err, not um/er) — bare "er"/"um" are real words in + // German/Dutch/Swedish/Danish/Portuguese and must survive this language-agnostic pass. + [GeneratedRegex(@"(?i)(^|[\s,.;:!?-])(?:umm+|uh+|err+|erm+|ah+|you know)(?=$|[\s,.;:!?-])")] private static partial Regex StandaloneFillerRegex(); [GeneratedRegex(@"[ \t]{2,}")] @@ -371,4 +374,4 @@ private static string ApplyBasicSentenceCasing(string text) [GeneratedRegex("[A-Za-z][A-Za-z'-]*")] private static partial Regex WordRegex(); -} \ No newline at end of file +} 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/DictionaryService.Csv.cs b/src/TypeWhisper.Core/Services/DictionaryService.Csv.cs index 69a5f260a..6d426500f 100644 --- a/src/TypeWhisper.Core/Services/DictionaryService.Csv.cs +++ b/src/TypeWhisper.Core/Services/DictionaryService.Csv.cs @@ -69,8 +69,24 @@ public int ImportFromCsv(string csv) var newCache = new List(_cache); var startIndex = LooksLikeHeader(rows[0]) ? 1 : 0; var existingKeys = newCache + .Where(entry => entry.EntryType != DictionaryEntryType.Correction) .Select(DictionaryEntryKey) .ToHashSet(StringComparer.OrdinalIgnoreCase); + // Corrections merge by case-insensitive Original — the same identity + // UpsertCorrection uses — so an import can never create a conflicting + // duplicate; later rows (in-file or vs. cache) win. + var correctionIndexes = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + + for (var i = 0; i < newCache.Count; i++) + { + var existing = newCache[i]; + if (existing.EntryType == DictionaryEntryType.Correction) + { + correctionIndexes.TryAdd(existing.Original, i); + } + } for (var i = startIndex; i < rows.Count; i++) { @@ -121,9 +137,38 @@ 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) + { + if (correctionIndexes.TryGetValue(original, out var existingIndex)) + { + var existing = newCache[existingIndex]; + if (HasSameCsvFields(existing, entry)) + { + continue; + } + + newCache[existingIndex] = existing with + { + Replacement = entry.Replacement, + CaseSensitive = entry.CaseSensitive, + IsEnabled = entry.IsEnabled, + IsStarred = entry.IsStarred, + Priority = entry.Priority, + Source = entry.Source, + }; + imported++; + continue; + } + + correctionIndexes.Add(original, newCache.Count); + newCache.Add(entry); + imported++; + continue; + } + if (!existingKeys.Add(DictionaryEntryKey(entry))) { continue; @@ -148,6 +193,16 @@ public int ImportFromCsv(string csv) return imported; } + private static bool HasSameCsvFields(DictionaryEntry existing, DictionaryEntry incoming) + { + return existing.Replacement == incoming.Replacement + && existing.CaseSensitive == incoming.CaseSensitive + && existing.IsEnabled == incoming.IsEnabled + && existing.IsStarred == incoming.IsStarred + && existing.Priority == incoming.Priority + && existing.Source == incoming.Source; + } + private static string DictionaryEntryKey(DictionaryEntry entry) { return $"{entry.EntryType}|{entry.Original.Trim()}|{entry.Replacement?.Trim()}"; diff --git a/src/TypeWhisper.Core/Services/DictionaryService.cs b/src/TypeWhisper.Core/Services/DictionaryService.cs index 3221bde25..aee7f7ef9 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). @@ -121,6 +131,20 @@ public void DeleteEntries(IEnumerable ids) } public string ApplyCorrections(string text) + { + return ApplyCorrectionsCore(text, recordUsage: true); + } + + /// + /// Side-effect-free variant of : never records usage + /// counts or writes the dictionary file (audit §2 M3). + /// + public string PreviewCorrections(string text) + { + return ApplyCorrectionsCore(text, recordUsage: false); + } + + private string ApplyCorrectionsCore(string text, bool recordUsage) { EnsureCacheLoaded(); List corrections; @@ -152,31 +176,17 @@ public string ApplyCorrections(string text) 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)) { @@ -189,7 +199,7 @@ public string ApplyCorrections(string text) : matchCount; } - if (usedCounts.Count > 0) + if (recordUsage && usedCounts.Count > 0) { IncrementUsageCounts(usedCounts); } @@ -197,6 +207,43 @@ public string ApplyCorrections(string text) 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(); @@ -255,7 +302,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, } ); } @@ -356,7 +403,7 @@ bool caseSensitive { newCache[idx] = existing with { - Replacement = replacement, CaseSensitive = caseSensitive, IsEnabled = true + Replacement = replacement, CaseSensitive = caseSensitive, IsEnabled = true, }; } } @@ -370,7 +417,7 @@ bool caseSensitive Original = original, Replacement = replacement, CaseSensitive = caseSensitive, - Source = DictionaryEntrySource.Manual + Source = DictionaryEntrySource.Manual, } ); } @@ -445,7 +492,7 @@ public void LearnCorrection(string original, string replacement) Replacement = replacement, UsageCount = existing.UsageCount + 1, TimesCorrected = existing.TimesCorrected + 1, - LastCorrectedAt = DateTime.UtcNow + LastCorrectedAt = DateTime.UtcNow, }; } } @@ -460,7 +507,7 @@ public void LearnCorrection(string original, string replacement) Replacement = replacement, TimesCorrected = 1, LastCorrectedAt = DateTime.UtcNow, - Source = DictionaryEntrySource.CorrectionSuggestion + Source = DictionaryEntrySource.CorrectionSuggestion, } ); } @@ -529,7 +576,7 @@ public IReadOnlyList LearnCorrections( { Replacement = replacement, TimesCorrected = existing.TimesCorrected + 1, - LastCorrectedAt = DateTime.UtcNow + LastCorrectedAt = DateTime.UtcNow, }; newCache[idx] = updated; learned.Add( @@ -547,7 +594,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)); @@ -635,7 +682,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(); @@ -723,7 +770,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..d7d9b0eac 100644 --- a/src/TypeWhisper.Core/Services/ErrorLogService.cs +++ b/src/TypeWhisper.Core/Services/ErrorLogService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Globalization; using System.Reflection; using System.Runtime.InteropServices; @@ -58,7 +59,7 @@ public void AddEntry(string message, string category = ErrorCategory.General) SaveToDisk(); } - EntriesChanged?.Invoke(); + RaiseEntriesChanged(); } public void ClearAll() @@ -69,7 +70,31 @@ public void ClearAll() SaveToDisk(); } - EntriesChanged?.Invoke(); + RaiseEntriesChanged(); + } + + // Callers report errors from inside their own catch blocks; a throwing subscriber must not + // escape from there and take down the operation that was trying to report a failure. + private void RaiseEntriesChanged() + { + foreach (var subscriber in EntriesChanged?.GetInvocationList() ?? []) + { + try + { + ((Action)subscriber)(); + } + catch (Exception ex) + { + try + { + Trace.WriteLine($"[ErrorLogService] An EntriesChanged subscriber threw: {ex}"); + } + catch + { + /* logging must never throw: ToString is virtual and listeners can fail */ + } + } + } } public string ExportDiagnostics() @@ -90,13 +115,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/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 70e1e16a0..d42de0587 100644 --- a/src/TypeWhisper.Core/Services/HistoryInsightsService.cs +++ b/src/TypeWhisper.Core/Services/HistoryInsightsService.cs @@ -38,6 +38,9 @@ record.InsertionStatus is TextInsertionStatus.Pasted var typedCount = records.Count(record => record.InsertionStatus is TextInsertionStatus.Typed ); + var actionHandledCount = records.Count(record => + record.InsertionStatus is TextInsertionStatus.ActionHandled + ); var copiedToClipboardCount = records.Count(record => record.InsertionStatus is TextInsertionStatus.CopiedToClipboard ); @@ -45,10 +48,11 @@ record.InsertionStatus is TextInsertionStatus.CopiedToClipboard record.InsertionStatus is TextInsertionStatus.Failed or TextInsertionStatus.ActionFailed + or TextInsertionStatus.ActionUnavailable or TextInsertionStatus.MissingClipboardTool or TextInsertionStatus.MissingPasteTool ); - var successfulInsertionCount = pastedCount + typedCount; + var successfulInsertionCount = pastedCount + typedCount + actionHandledCount; var insertionAttemptCount = successfulInsertionCount + copiedToClipboardCount + failedInsertionCount; @@ -75,7 +79,7 @@ or TextInsertionStatus.MissingPasteTool ), PromptActionAppliedCount = records.Count(record => record.PromptActionApplied), TranslationAppliedCount = records.Count(record => record.TranslationApplied), - TopApps = topApps + TopApps = topApps, }; } -} \ No newline at end of file +} 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/IdeFileReferenceService.cs b/src/TypeWhisper.Core/Services/IdeFileReferenceService.cs index a344d5652..b52db1607 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; @@ -14,7 +15,7 @@ public sealed partial class IdeFileReferenceService "tag ", "file tag ", "file reference ", - "reference " + "reference ", ]; private static readonly string[] s_plainReferencePrefixes = ["file ", "open file "]; @@ -38,7 +39,7 @@ public sealed partial class IdeFileReferenceService ["json"] = "json", ["yaml"] = "yaml", ["yml"] = "yml", - ["env"] = "env" + ["env"] = "env", }; public static string ToFileReference(string spokenText) @@ -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 4eb9a9a91..22d125dba 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; @@ -25,8 +26,8 @@ public sealed class LocalModelStorageService "hf-cache", ".setup-complete", "python-embed.zip", - "get-pip.py" - ] + "get-pip.py", + ], }; private readonly ISettingsService _settings; @@ -94,6 +95,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,11 +114,22 @@ public async Task MoveDownloadsAndUsePathAsync(string targetPath, CancellationTo await Task.Run(() => { ct.ThrowIfCancellationRequested(); - MigratePluginAssets(pluginAssetSourceRoot, targetRoot, ct); + CopyPluginAssets(pluginAssetSourceRoot, targetRoot, migrated, ct); }, ct); } _settings.Save(_settings.Current with { LocalModelStoragePath = targetRoot }); + + if (currentIsDefault) + { + // 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( + () => TryCleanUp(() => + DeletePluginAssetSourceContents(pluginAssetSourceRoot, targetRoot, migrated)), + CancellationToken.None); + } + return; } @@ -157,11 +170,19 @@ await Task.Run(() => await Task.Run(() => { ct.ThrowIfCancellationRequested(); - MigrateModelRootContents(sourceRoot, targetRoot, ct); - MigratePluginAssets(pluginAssetSourceRoot, targetRoot, ct); + CopyModelRootContents(sourceRoot, targetRoot, migrated, ct); + CopyPluginAssets(pluginAssetSourceRoot, targetRoot, migrated, ct); }, ct); _settings.Save(_settings.Current with { LocalModelStoragePath = targetRoot }); + + // Best-effort cleanup after the commit above — see comment in the currentIsDefault branch. + await Task.Run(() => + { + TryCleanUp(() => DeleteModelRootSourceContents(sourceRoot, targetRoot, migrated)); + TryCleanUp(() => + DeletePluginAssetSourceContents(pluginAssetSourceRoot, targetRoot, migrated)); + }, CancellationToken.None); } /// @@ -220,7 +241,11 @@ private static void EnsureWritable(string fullPath) } } - private static void MigrateModelRootContents(string sourceRoot, string targetRoot, CancellationToken ct) + private static void CopyModelRootContents( + string sourceRoot, + string targetRoot, + MigratedTargets migrated, + CancellationToken ct) { if (!Directory.Exists(sourceRoot)) return; @@ -234,11 +259,33 @@ private static void MigrateModelRootContents(string sourceRoot, string targetRoo if (string.Equals(name, LocalModelStoragePaths.PluginDataFolderName, StringComparison.OrdinalIgnoreCase)) continue; - MoveEntry(entry, Path.Join(targetRoot, SafeLeafName(name, nameof(entry)))); + CopyEntry(entry, Path.Join(targetRoot, SafeLeafName(name, nameof(entry))), migrated, ct); } } - private static void MigratePluginAssets(string assetSourceRoot, string targetRoot, CancellationToken ct) + private static void DeleteModelRootSourceContents( + string sourceRoot, + string targetRoot, + MigratedTargets migrated) + { + if (!Directory.Exists(sourceRoot)) + return; + + foreach (var entry in Directory.EnumerateFileSystemEntries(sourceRoot)) + { + var name = Path.GetFileName(entry); + if (string.Equals(name, LocalModelStoragePaths.PluginDataFolderName, StringComparison.OrdinalIgnoreCase)) + continue; + + DeleteMigratedEntry(entry, Path.Join(targetRoot, SafeLeafName(name, nameof(entry))), migrated); + } + } + + private static void CopyPluginAssets( + string assetSourceRoot, + string targetRoot, + MigratedTargets migrated, + CancellationToken ct) { var pluginDataFolderName = SafeRelativeName(LocalModelStoragePaths.PluginDataFolderName, nameof(LocalModelStoragePaths.PluginDataFolderName)); @@ -255,31 +302,232 @@ private static void MigratePluginAssets(string assetSourceRoot, string targetRoo { ct.ThrowIfCancellationRequested(); var safeEntryName = SafeRelativeName(entryName, nameof(entryName)); - var sourceEntry = Path.Join(sourcePluginDir, safeEntryName); - var targetEntry = Path.Join(targetPluginDir, safeEntryName); - MoveEntry(sourceEntry, targetEntry); + CopyEntry( + Path.Join(sourcePluginDir, safeEntryName), + Path.Join(targetPluginDir, safeEntryName), + migrated, + ct); } } } - private static void MoveEntry(string source, string target) + private static void DeletePluginAssetSourceContents( + string assetSourceRoot, + string targetRoot, + MigratedTargets migrated) { + var pluginDataFolderName = SafeRelativeName(LocalModelStoragePaths.PluginDataFolderName, nameof(LocalModelStoragePaths.PluginDataFolderName)); + + foreach (var (pluginId, entries) in s_pluginAssetEntries) + { + var pluginFolderName = SafeLeafName(pluginId, nameof(pluginId)); + var sourcePluginDir = Path.Join(assetSourceRoot, pluginFolderName); + if (!Directory.Exists(sourcePluginDir)) + continue; + + var targetPluginDir = Path.Join(targetRoot, pluginDataFolderName, pluginFolderName); + foreach (var entryName in entries) + { + var safeEntryName = SafeRelativeName(entryName, nameof(entryName)); + DeleteMigratedEntry( + Path.Join(sourcePluginDir, 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. + private static void CopyEntry( + string source, + string target, + MigratedTargets migrated, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (Directory.Exists(source)) { Directory.CreateDirectory(target); foreach (var child in Directory.EnumerateFileSystemEntries(source)) - MoveEntry(child, Path.Join(target, SafeLeafName(Path.GetFileName(child), nameof(child)))); + { + CopyEntry( + child, + Path.Join(target, SafeLeafName(Path.GetFileName(child), nameof(child))), + migrated, + ct); + } + + return; + } + + 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); + var stagingTarget = Path.Join( + targetDir, + $".{Path.GetFileName(target)}.tw-migrate-{Guid.NewGuid():N}.tmp"); + try + { + File.Copy(source, stagingTarget); + File.Move(stagingTarget, target); + migrated.Record(target); + } + catch + { + try + { + File.Delete(stagingTarget); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // best-effort staging cleanup + } + + throw; + } + } + + + // 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, MigratedTargets migrated) + { + if (Directory.Exists(source)) + { + foreach (var child in Directory.EnumerateFileSystemEntries(source)) + { + DeleteMigratedEntry( + 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); + } + + // 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) + { + Trace.TraceWarning( + "Model storage migration cleanup failed: {0}", + ex.Message); + } + } + + private static bool FileLengthsMatch(string source, string target) + { + try + { + return new FileInfo(source).Length == new FileInfo(target).Length; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } - Directory.CreateDirectory(Path.GetDirectoryName(target)!); - File.Copy(source, target); - File.Delete(source); + private static void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Trace.TraceWarning( + "Could not delete migrated source file '{0}': {1}", + path, + ex.Message); + } } private static string SafeLeafName(string value, string parameterName) @@ -314,14 +562,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.Core/Services/NumberNormalization/EnglishNumberWordParser.cs b/src/TypeWhisper.Core/Services/NumberNormalization/EnglishNumberWordParser.cs index 6e973e54e..0b4673792 100644 --- a/src/TypeWhisper.Core/Services/NumberNormalization/EnglishNumberWordParser.cs +++ b/src/TypeWhisper.Core/Services/NumberNormalization/EnglishNumberWordParser.cs @@ -13,25 +13,25 @@ internal static class EnglishNumberWordParser private static readonly Dictionary s_unitValues = new(StringComparer.Ordinal) { ["zero"] = 0, ["one"] = 1, ["two"] = 2, ["three"] = 3, ["four"] = 4, - ["five"] = 5, ["six"] = 6, ["seven"] = 7, ["eight"] = 8, ["nine"] = 9 + ["five"] = 5, ["six"] = 6, ["seven"] = 7, ["eight"] = 8, ["nine"] = 9, }; private static readonly Dictionary s_teenValues = new(StringComparer.Ordinal) { ["ten"] = 10, ["eleven"] = 11, ["twelve"] = 12, ["thirteen"] = 13, ["fourteen"] = 14, - ["fifteen"] = 15, ["sixteen"] = 16, ["seventeen"] = 17, ["eighteen"] = 18, ["nineteen"] = 19 + ["fifteen"] = 15, ["sixteen"] = 16, ["seventeen"] = 17, ["eighteen"] = 18, ["nineteen"] = 19, }; private static readonly Dictionary s_tensValues = new(StringComparer.Ordinal) { ["twenty"] = 20, ["thirty"] = 30, ["forty"] = 40, ["fifty"] = 50, - ["sixty"] = 60, ["seventy"] = 70, ["eighty"] = 80, ["ninety"] = 90 + ["sixty"] = 60, ["seventy"] = 70, ["eighty"] = 80, ["ninety"] = 90, }; private static readonly Dictionary s_scaleValues = new(StringComparer.Ordinal) { ["thousand"] = 1_000, - ["million"] = 1_000_000 + ["million"] = 1_000_000, }; public static NumberWordNormalizer.ParsedWords? Parse(IReadOnlyList words) @@ -99,6 +99,8 @@ private static (int Value, int NextIndex)? ParseInteger(IReadOnlyList wo index++; var nextGroup = ParseGroup(words, index); + // ReSharper disable once InvertIf -- last statement in the loop body; inverting + // only buys a trailing `continue`. if (nextGroup is not null) { group = nextGroup; @@ -137,9 +139,8 @@ private static (int Value, int NextIndex)? ParseGroup(IReadOnlyList word index++; consumed = true; - if (index < words.Count && - s_unitValues.TryGetValue(words[index], out var unit) && - unit > 0) + // ReSharper disable once InvertIf -- no early exit in the tens branch to invert toward. + if (index < words.Count && s_unitValues.TryGetValue(words[index], out var unit) && unit > 0) { value += unit; index++; diff --git a/src/TypeWhisper.Core/Services/NumberNormalization/GermanNumberWordParser.cs b/src/TypeWhisper.Core/Services/NumberNormalization/GermanNumberWordParser.cs index 47ba6d36e..42630395b 100644 --- a/src/TypeWhisper.Core/Services/NumberNormalization/GermanNumberWordParser.cs +++ b/src/TypeWhisper.Core/Services/NumberNormalization/GermanNumberWordParser.cs @@ -14,21 +14,21 @@ internal static class GermanNumberWordParser { ["null"] = 0, ["eins"] = 1, ["ein"] = 1, ["eine"] = 1, ["einen"] = 1, ["einem"] = 1, ["einer"] = 1, ["zwei"] = 2, ["drei"] = 3, ["vier"] = 4, ["funf"] = 5, ["fuenf"] = 5, - ["sechs"] = 6, ["sieben"] = 7, ["acht"] = 8, ["neun"] = 9 + ["sechs"] = 6, ["sieben"] = 7, ["acht"] = 8, ["neun"] = 9, }; private static readonly Dictionary s_teens = new(StringComparer.Ordinal) { ["zehn"] = 10, ["elf"] = 11, ["zwolf"] = 12, ["zwoelf"] = 12, ["dreizehn"] = 13, ["vierzehn"] = 14, ["funfzehn"] = 15, ["fuenfzehn"] = 15, ["sechzehn"] = 16, ["siebzehn"] = 17, - ["achtzehn"] = 18, ["neunzehn"] = 19 + ["achtzehn"] = 18, ["neunzehn"] = 19, }; private static readonly Dictionary s_tens = new(StringComparer.Ordinal) { ["zwanzig"] = 20, ["dreissig"] = 30, ["dreizig"] = 30, ["vierzig"] = 40, ["funfzig"] = 50, ["fuenfzig"] = 50, ["sechzig"] = 60, ["siebzig"] = 70, - ["achtzig"] = 80, ["neunzig"] = 90 + ["achtzig"] = 80, ["neunzig"] = 90, }; public static NumberWordNormalizer.ParsedWords? Parse(IReadOnlyList words) @@ -86,9 +86,11 @@ private static (int Value, int NextIndex)? ParseInteger(IReadOnlyList wo { var word = words[index]; + // ReSharper disable once ConvertIfStatementToSwitchStatement -- the branches test + // different subjects (connector plus parser state, then bare scale words), so a + // switch on `word` could not carry them. if (word == "und" && - current > 0 && - current < 10 && + current is > 0 and < 10 && index + 1 < words.Count && s_tens.TryGetValue(words[index + 1], out var tenValue)) { @@ -110,9 +112,10 @@ private static (int Value, int NextIndex)? ParseInteger(IReadOnlyList wo if (word is "tausend" or "million" or "millionen") { - // A plural scale word ("millionen") is a count noun without a leading number - // ("Millionen von Menschen"); only treat it as a number when a count precedes it. - if (word == "millionen" && current == 0) + // Without a leading count these are nouns, not numbers ("Millionen von + // Menschen", "eine halbe Million"). "eine Million" still converts: + // AllowsArticleOne consumes the article as 1 before this branch is reached. + if (word is "million" or "millionen" && current == 0) break; var scale = word == "tausend" ? 1_000 : 1_000_000; @@ -173,6 +176,8 @@ private static (string Digits, int NextIndex) ParseDecimalDigits(IReadOnlyList= 0) { var prefix = word[..hundredIndex]; @@ -193,30 +198,24 @@ private static (string Digits, int NextIndex) ParseDecimalDigits(IReadOnlyList= 0) - { - var prefix = word[..undIndex]; - var suffix = word[(undIndex + "und".Length)..]; - if (DirectUnitValue(prefix, true) is { } unit && - unit > 0 && - unit < 10 && - s_tens.TryGetValue(suffix, out var tenValue)) - { - return unit + tenValue; - } - } + if (undIndex < 0) + return null; - return null; + var prefix = word[..undIndex]; + var suffix = word[(undIndex + "und".Length)..]; + return DirectUnitValue(prefix, true) is { } unit and > 0 and < 10 && + s_tens.TryGetValue(suffix, out var tenValue) + ? unit + tenValue + : null; } private static int? DirectValue(string word, bool allowArticleOne) => - DirectUnitValue(word, allowArticleOne) is { } unit - ? unit - : s_teens.TryGetValue(word, out var teen) - ? teen - : s_tens.TryGetValue(word, out var ten) - ? ten - : null; + DirectUnitValue(word, allowArticleOne) + ?? (s_teens.TryGetValue(word, out var teen) + ? teen + : s_tens.TryGetValue(word, out var ten) + ? ten + : null); private static int? DirectUnitValue(string word, bool allowArticleOne) { diff --git a/src/TypeWhisper.Core/Services/NumberNormalization/NumberWordNormalizer.cs b/src/TypeWhisper.Core/Services/NumberNormalization/NumberWordNormalizer.cs index 59806e98b..47cd8e8cc 100644 --- a/src/TypeWhisper.Core/Services/NumberNormalization/NumberWordNormalizer.cs +++ b/src/TypeWhisper.Core/Services/NumberNormalization/NumberWordNormalizer.cs @@ -5,8 +5,8 @@ namespace TypeWhisper.Core.Services.NumberNormalization; public static class NumberWordNormalizer { - // fr/zh/ja parsers from upstream are intentionally not ported on Linux (locales are en/de/es/ru), - // so those language codes are omitted from the supported set. + // Only languages with a parser in this folder. fr/zh/ja were deliberately not ported from + // upstream; "ru" is a UI locale but has no parser, so Russian number words are left alone. private static readonly HashSet s_supportedLanguageCodes = ["en", "de", "es"]; public static string Normalize(string text, string? language) @@ -61,6 +61,9 @@ internal static string NormalizeWord(string word) { var normalized = word.Normalize(NormalizationForm.FormD); var builder = new StringBuilder(normalized.Length); + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- + // the LINQ form swaps string's struct enumerator for the boxed one, allocating on a + // method that runs for every word of every transcript. foreach (var c in normalized) { if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) @@ -82,7 +85,7 @@ internal static string NormalizeWord(string word) "en" => EnglishNumberWordParser.Parse(wordTexts), "de" => GermanNumberWordParser.Parse(wordTexts), "es" => SpanishNumberWordParser.Parse(wordTexts), - _ => null + _ => null, }; if (parsed is null || parsed.ConsumedWords <= 0 || parsed.ConsumedWords > words.Count) @@ -176,7 +179,7 @@ private static bool IsWordConnector(string text) => private enum TokenKind { Word, - Other + Other, } private sealed record Token(string Text, TokenKind Kind) diff --git a/src/TypeWhisper.Core/Services/NumberNormalization/SpanishNumberWordParser.cs b/src/TypeWhisper.Core/Services/NumberNormalization/SpanishNumberWordParser.cs index ed8aea6e0..7d7328a15 100644 --- a/src/TypeWhisper.Core/Services/NumberNormalization/SpanishNumberWordParser.cs +++ b/src/TypeWhisper.Core/Services/NumberNormalization/SpanishNumberWordParser.cs @@ -13,26 +13,26 @@ internal static class SpanishNumberWordParser private static readonly Dictionary s_unitValues = new(StringComparer.Ordinal) { ["cero"] = 0, ["uno"] = 1, ["un"] = 1, ["una"] = 1, ["dos"] = 2, ["tres"] = 3, - ["cuatro"] = 4, ["cinco"] = 5, ["seis"] = 6, ["siete"] = 7, ["ocho"] = 8, ["nueve"] = 9 + ["cuatro"] = 4, ["cinco"] = 5, ["seis"] = 6, ["siete"] = 7, ["ocho"] = 8, ["nueve"] = 9, }; private static readonly Dictionary s_teenValues = new(StringComparer.Ordinal) { ["diez"] = 10, ["once"] = 11, ["doce"] = 12, ["trece"] = 13, ["catorce"] = 14, - ["quince"] = 15, ["dieciseis"] = 16, ["diecisiete"] = 17, ["dieciocho"] = 18, ["diecinueve"] = 19 + ["quince"] = 15, ["dieciseis"] = 16, ["diecisiete"] = 17, ["dieciocho"] = 18, ["diecinueve"] = 19, }; private static readonly Dictionary s_twentyValues = new(StringComparer.Ordinal) { ["veinte"] = 20, ["veintiuno"] = 21, ["veintiun"] = 21, ["veintiuna"] = 21, ["veintidos"] = 22, ["veintitres"] = 23, ["veinticuatro"] = 24, ["veinticinco"] = 25, - ["veintiseis"] = 26, ["veintisiete"] = 27, ["veintiocho"] = 28, ["veintinueve"] = 29 + ["veintiseis"] = 26, ["veintisiete"] = 27, ["veintiocho"] = 28, ["veintinueve"] = 29, }; private static readonly Dictionary s_tensValues = new(StringComparer.Ordinal) { ["treinta"] = 30, ["cuarenta"] = 40, ["cincuenta"] = 50, - ["sesenta"] = 60, ["setenta"] = 70, ["ochenta"] = 80, ["noventa"] = 90 + ["sesenta"] = 60, ["setenta"] = 70, ["ochenta"] = 80, ["noventa"] = 90, }; private static readonly Dictionary s_hundredValues = new(StringComparer.Ordinal) @@ -41,7 +41,7 @@ internal static class SpanishNumberWordParser ["trescientos"] = 300, ["trescientas"] = 300, ["cuatrocientos"] = 400, ["cuatrocientas"] = 400, ["quinientos"] = 500, ["quinientas"] = 500, ["seiscientos"] = 600, ["seiscientas"] = 600, ["setecientos"] = 700, ["setecientas"] = 700, ["ochocientos"] = 800, ["ochocientas"] = 800, - ["novecientos"] = 900, ["novecientas"] = 900 + ["novecientos"] = 900, ["novecientas"] = 900, }; public static NumberWordNormalizer.ParsedWords? Parse(IReadOnlyList words) @@ -61,7 +61,12 @@ internal static class SpanishNumberWordParser return null; } - var integer = ParseInteger(normalizedWords, index, isNegative); + // isNegative only decides the sign: "menos" also means "except/less", so it is not a + // number context that licenses the articles "un"/"una". Telling the two senses apart + // needs the token LEFT of "menos", which Parse(words) does not receive. Accepted cost: + // "menos un grado" stays spoken, which beats rewriting "todos menos un estudiante" + // into "todos -1 estudiante". "menos uno"/"menos un millón" are unaffected. + var integer = ParseInteger(normalizedWords, index); if (integer is null) return null; @@ -84,10 +89,7 @@ internal static class SpanishNumberWordParser return new NumberWordNormalizer.ParsedWords(replacement, index); } - private static (int Value, int NextIndex)? ParseInteger( - IReadOnlyList words, - int startIndex, - bool allowLeadingArticleOne) + private static (int Value, int NextIndex)? ParseInteger(IReadOnlyList words, int startIndex) { if (startIndex >= words.Count) return null; @@ -104,9 +106,10 @@ private static (int Value, int NextIndex)? ParseInteger( if (word is "millon" or "millones") { - // A plural scale word ("millones") is a count noun without a leading number - // ("millones de personas"); only treat it as a number when a count precedes it. - if (word == "millones" && current == 0 && total == 0) + // Without a leading count these are nouns, not numbers ("millones de + // personas", "medio millón"). "un millón" still converts: AllowsArticleOne + // consumes the article as 1 before this branch is reached. + if (word is "millon" or "millones" && current == 0 && total == 0) break; total += Math.Max(current, 1) * 1_000_000; @@ -136,7 +139,7 @@ private static (int Value, int NextIndex)? ParseInteger( continue; } - var allowArticleOne = AllowsArticleOne(index, words, startIndex, allowLeadingArticleOne, current, total); + var allowArticleOne = AllowsArticleOne(index, words, current, total); var segment = ParseUnderHundred(words, index, allowArticleOne); if (segment is null) break; @@ -198,19 +201,12 @@ private static (int Value, int NextIndex) AppendSpanishUnit( IReadOnlyList words, int startIndex) { - var index = startIndex; - - if (index < words.Count && words[index] == "y") + if (startIndex < words.Count && + words[startIndex] == "y" && + startIndex + 1 < words.Count && + UnitValue(words[startIndex + 1], true) is { } unit and > 0) { - var afterY = index + 1; - if (afterY < words.Count && - UnitValue(words[afterY], true) is { } unit && - unit > 0) - { - return (baseValue + unit, afterY + 1); - } - - return (baseValue, startIndex); + return (baseValue + unit, startIndex + 2); } return (baseValue, startIndex); @@ -249,14 +245,9 @@ private static (string Digits, int NextIndex) ParseDecimalDigits(IReadOnlyList words, - int startIndex, - bool allowLeadingArticleOne, int current, int total) { - if (index == startIndex && allowLeadingArticleOne) - return true; - if (current >= 100 || total > 0) return true; diff --git a/src/TypeWhisper.Core/Services/NumberNormalization/TranscriptionNumberNormalizationService.cs b/src/TypeWhisper.Core/Services/NumberNormalization/TranscriptionNumberNormalizationService.cs index 23246b92b..c17649745 100644 --- a/src/TypeWhisper.Core/Services/NumberNormalization/TranscriptionNumberNormalizationService.cs +++ b/src/TypeWhisper.Core/Services/NumberNormalization/TranscriptionNumberNormalizationService.cs @@ -4,7 +4,7 @@ namespace TypeWhisper.Core.Services.NumberNormalization; public static class TranscriptionNumberNormalizationService { - public static bool IsEnabled(bool globalEnabled = true, bool? normalizeNumbersOverride = null) => + private static bool IsEnabled(bool globalEnabled = true, bool? normalizeNumbersOverride = null) => normalizeNumbersOverride ?? globalEnabled; public static string NormalizeText( @@ -19,18 +19,15 @@ public static string NormalizeText( if (!IsEnabled(globalEnabled, normalizeNumbersOverride)) return text; - foreach (var language in NormalizationLanguages( - transcriptionTask, - detectedLanguage, - configuredLanguage, - configuredLanguageCandidates)) - { - var normalized = NumberWordNormalizer.Normalize(text, language); - if (!string.Equals(normalized, text, StringComparison.Ordinal)) - return normalized; - } - - return text; + return NormalizeText( + text, + NormalizationLanguages( + transcriptionTask, + detectedLanguage, + configuredLanguage, + configuredLanguageCandidates), + globalEnabled, + normalizeNumbersOverride); } public static TranscriptionResult NormalizeResult( @@ -58,29 +55,11 @@ public static TranscriptionResult NormalizeResult( result.Segments, languages, globalEnabled, - normalizeNumbersOverride) + normalizeNumbersOverride), }; } - public static IReadOnlyList NormalizeSegments( - IReadOnlyList segments, - TranscriptionTask transcriptionTask, - string? detectedLanguage, - string? configuredLanguage, - IReadOnlyList configuredLanguageCandidates, - bool globalEnabled = true, - bool? normalizeNumbersOverride = null) - { - var languages = NormalizationLanguages( - transcriptionTask, - detectedLanguage, - configuredLanguage, - configuredLanguageCandidates); - - return NormalizeSegments(segments, languages, globalEnabled, normalizeNumbersOverride); - } - - internal static IReadOnlyList NormalizationLanguages( + private static List NormalizationLanguages( TranscriptionTask transcriptionTask, string? detectedLanguage, string? configuredLanguage, @@ -121,7 +100,7 @@ private static List NormalizeSegments( segments .Select(segment => segment with { - Text = NormalizeText(segment.Text, languages, globalEnabled, normalizeNumbersOverride) + Text = NormalizeText(segment.Text, languages, globalEnabled, normalizeNumbersOverride), }) .ToList(); @@ -130,6 +109,8 @@ private static List PrioritizedLanguages(string? primary, IReadOnlyList< var seen = new HashSet(StringComparer.Ordinal); var result = new List(); + // ReSharper disable once LoopCanBeConvertedToQuery -- the loop's filter is the side + // effect (seen.Add dedupes); as a query that mutation would hide inside a Where. foreach (var rawLanguage in new[] { primary }.Where(static language => language is not null).Select(static language => language!).Concat(candidates)) { var normalized = NumberWordNormalizer.NormalizeLanguageCode(rawLanguage); diff --git a/src/TypeWhisper.Core/Services/PostProcessingPipeline.cs b/src/TypeWhisper.Core/Services/PostProcessingPipeline.cs index 347286a4e..27611151f 100644 --- a/src/TypeWhisper.Core/Services/PostProcessingPipeline.cs +++ b/src/TypeWhisper.Core/Services/PostProcessingPipeline.cs @@ -64,7 +64,7 @@ public async Task ProcessAsync( ) ); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } @@ -264,6 +264,8 @@ Func> Execute ); } + // ReSharper disable once InvertIf -- last of a run of uniform "if configured, + // steps.Add(...)" blocks; inverting it alone would duplicate the return. if ( options.TranslationHandler is not null && !string.IsNullOrEmpty(options.TranslationTarget) @@ -327,4 +329,4 @@ options.TranslationHandler is not null // Matches only "?/!" + one space + newline/end, leaving other whitespace intact. [GeneratedRegex(@"([?!]) (?=\n|$)")] private static partial Regex TrailingInsertedSpaceRegex(); -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Core/Services/ProfileService.cs b/src/TypeWhisper.Core/Services/ProfileService.cs index fb6b8cc9e..e87b6f388 100644 --- a/src/TypeWhisper.Core/Services/ProfileService.cs +++ b/src/TypeWhisper.Core/Services/ProfileService.cs @@ -12,21 +12,41 @@ public sealed class ProfileService : IProfileService { private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + private readonly Action _atomicWrite; private readonly string _filePath; + private readonly IErrorLogService? _errorLog; + 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; + private bool _loadFailed; + private bool _loadFailureReported; + + public ProfileService(string filePath, IErrorLogService? errorLog = null) + : this(filePath, AtomicFileWrite.WriteAllText, errorLog) { } - public ProfileService(string filePath) + internal ProfileService( + string filePath, + Action? atomicWrite, + IErrorLogService? errorLog = null + ) { _filePath = filePath; + _atomicWrite = atomicWrite ?? AtomicFileWrite.WriteAllText; + _errorLog = errorLog; } public IReadOnlyList Profiles { get { - EnsureCacheLoaded(); - return _cache; + lock (_gate) + { + EnsureCacheLoadedLocked(); + return _cache; + } } } @@ -34,63 +54,97 @@ 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() }; + CommitLocked(newCache); } - var newCache = new List(_cache) { FirstRunDefaults.CreateAutoFormatProfile() }; - SortList(newCache); - SaveToDisk(newCache); - _cache = newCache; - ProfilesChanged?.Invoke(); + NotifyProfilesChanged(); } 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); + } + + NotifyProfilesChanged(); } 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) { + 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) + { + return; + } + newCache[idx] = updated; + CommitLocked(newCache); } - SortList(newCache); - SaveToDisk(newCache); - _cache = newCache; - ProfilesChanged?.Invoke(); + NotifyProfilesChanged(); } 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); + } + + NotifyProfilesChanged(); + } + + public Profile? ToggleProfileEnabled(string id) + { + Profile updated; + lock (_gate) + { + EnsureCacheLoadedLocked(); + var newCache = new List(_cache); + var idx = newCache.FindIndex(profile => profile.Id == id); + if (idx < 0) + { + return null; + } + + updated = newCache[idx] with + { + IsEnabled = !newCache[idx].IsEnabled, + UpdatedAt = DateTime.UtcNow, + }; + newCache[idx] = updated; + CommitLocked(newCache); + } + + NotifyProfilesChanged(); + return updated; } public MatchResult MatchProfile( @@ -99,8 +153,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,24 +313,54 @@ private static void SortList(List profiles) profiles.Sort((a, b) => b.Priority.CompareTo(a.Priority)); } - private void EnsureCacheLoaded() + private void EnsureCacheLoadedLocked() { - 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 +369,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)) { @@ -281,6 +388,29 @@ 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 keeps its prior list and the caller never reaches its notify. + SaveToDisk(newCache); + _cache = newCache; + } + + /// + /// 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.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/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/SettingsService.cs b/src/TypeWhisper.Core/Services/SettingsService.cs index 7e4a15b44..8c95198d5 100644 --- a/src/TypeWhisper.Core/Services/SettingsService.cs +++ b/src/TypeWhisper.Core/Services/SettingsService.cs @@ -14,9 +14,16 @@ 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(); + + // 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) @@ -33,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) @@ -60,6 +89,99 @@ public AppSettings Load() } public void Save(AppSettings settings) + { + lock (_gate) + { + 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) + { + 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; + } + } + + // 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)) @@ -79,7 +201,8 @@ public void Save(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) @@ -92,6 +215,7 @@ public void Save(AppSettings settings) } var json = File.ReadAllText(path); + // ReSharper disable once InconsistentlySynchronizedField -- s_jsonOptions is static readonly (immutable reference); _gate guards file I/O and Current, not this field. var settings = JsonSerializer.Deserialize(json, s_jsonOptions); if (settings is null) { @@ -147,13 +271,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, + }, }; } @@ -185,7 +309,7 @@ private static AppSettings ApplyAccelerationMigration(AppSettings settings, stri { LocalModelAcceleration = AppSettings.NormalizeLocalModelAcceleration( settings.LocalModelAcceleration - ) + ), }; } @@ -197,7 +321,7 @@ private static AppSettings ApplyAccelerationMigration(AppSettings settings, stri { LocalModelAcceleration = AppSettings.NormalizeLocalModelAcceleration( settings.LocalModelAcceleration - ) + ), }; } @@ -230,4 +354,4 @@ private static void LogWarning(string message) /* logging must never throw */ } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Core/Services/SnippetService.cs b/src/TypeWhisper.Core/Services/SnippetService.cs index 69f067800..fa2799134 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; } @@ -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.Core/Services/SubtitleExporter.cs b/src/TypeWhisper.Core/Services/SubtitleExporter.cs index 3d51bd6d7..cfb872f6d 100644 --- a/src/TypeWhisper.Core/Services/SubtitleExporter.cs +++ b/src/TypeWhisper.Core/Services/SubtitleExporter.cs @@ -46,12 +46,12 @@ public static string ToWebVtt(IReadOnlyList segments) private static string FormatSrtTime(double seconds) { var ts = TimeSpan.FromSeconds(seconds); - return $"{ts.Hours:D2}:{ts.Minutes:D2}:{ts.Seconds:D2},{ts.Milliseconds:D3}"; + return $"{(int)ts.TotalHours:D2}:{ts.Minutes:D2}:{ts.Seconds:D2},{ts.Milliseconds:D3}"; } private static string FormatVttTime(double seconds) { var ts = TimeSpan.FromSeconds(seconds); - return $"{ts.Hours:D2}:{ts.Minutes:D2}:{ts.Seconds:D2}.{ts.Milliseconds:D3}"; + return $"{(int)ts.TotalHours:D2}:{ts.Minutes:D2}:{ts.Seconds:D2}.{ts.Milliseconds:D3}"; } } \ 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.Core/Translation/MarianTokenizer.cs b/src/TypeWhisper.Core/Translation/MarianTokenizer.cs index a593332b0..a614a31f6 100644 --- a/src/TypeWhisper.Core/Translation/MarianTokenizer.cs +++ b/src/TypeWhisper.Core/Translation/MarianTokenizer.cs @@ -91,7 +91,7 @@ public int[] Encode(string text) var tokens = new List(); - var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); foreach (var t in words) { var word = MetaspacePrefix + t; 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.Core/TypeWhisperEnvironment.cs b/src/TypeWhisper.Core/TypeWhisperEnvironment.cs index 4fae3d79a..36810b557 100644 --- a/src/TypeWhisper.Core/TypeWhisperEnvironment.cs +++ b/src/TypeWhisper.Core/TypeWhisperEnvironment.cs @@ -4,9 +4,6 @@ namespace TypeWhisper.Core; public static class TypeWhisperEnvironment { - private const UnixFileMode DirMode0700 = - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; - public static string BasePath { get; } = Path.Join( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "TypeWhisper" @@ -19,62 +16,83 @@ public static class TypeWhisperEnvironment public static string AudioPath => Path.Join(BasePath, "Audio"); public static string PluginDataPath => Path.Join(BasePath, "PluginData"); public static string SettingsFilePath => Path.Join(BasePath, "settings.json"); + public static string SecretProtectionKeyFilePath => + Path.Join(BasePath, "secret-protection.key"); + + private const UnixFileMode DirMode0700 = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + /// + /// Whether is owner-only after the last . + /// 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; public static void EnsureDirectories() { - // CreateDirectory honors the umask (0002 leaves this group-writable), and write access - // to the parent governs renaming -- a loose BasePath lets a peer swap out a child. Directory.CreateDirectory(BasePath); - EnsureDirectoryMode0700(BasePath); - Directory.CreateDirectory(ModelsPath); Directory.CreateDirectory(DataPath); Directory.CreateDirectory(LogsPath); - Directory.CreateDirectory(AudioPath); Directory.CreateDirectory(PluginsPath); Directory.CreateDirectory(PluginDataPath); - // Defense in depth: recordings stay owner-only even if BasePath is loosened. - EnsureDirectoryMode0700(AudioPath); + // 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); + } + else + { + Directory.CreateDirectory(AudioPath, DirMode0700); + } + + // 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); } - private static void EnsureDirectoryMode0700(string path) + /// + /// 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) { - if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + if (OperatingSystem.IsWindows()) { - return; + return true; } try { File.SetUnixFileMode(path, DirMode0700); + + // 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; + if ((File.GetUnixFileMode(path) & exposed) == 0) + { + return true; + } } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - // Not fatal by itself -- the mode check below decides. A mount can reject chmod - // while already presenting an owner-only mode. - Trace.WriteLine( - $"[TypeWhisperEnvironment] Could not set 0700 mode on '{path}': {ex.Message}" - ); + Trace.WriteLine($"[TypeWhisperEnvironment] Could not secure '{path}': {ex.Message}"); + return false; } - // Verify rather than trust the call: FAT and some CIFS mounts accept chmod and ignore - // it. Startup must not continue with recordings and settings readable by other accounts, - // so fail closed instead of returning as if the directory had been tightened. - const UnixFileMode forbidden = - UnixFileMode.GroupRead - | UnixFileMode.GroupWrite - | UnixFileMode.GroupExecute - | UnixFileMode.OtherRead - | UnixFileMode.OtherWrite - | UnixFileMode.OtherExecute; - - var mode = File.GetUnixFileMode(path); - if ((mode & forbidden) != 0) - { - throw new IOException( - $"'{path}' must be owner-only (0700) but is {mode}. Fix it with: chmod 700 '{path}'" - ); - } + 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/App.axaml.cs b/src/TypeWhisper.Linux/App.axaml.cs index 26568570c..af6faaa2a 100644 --- a/src/TypeWhisper.Linux/App.axaml.cs +++ b/src/TypeWhisper.Linux/App.axaml.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; @@ -40,6 +42,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"); @@ -66,6 +71,69 @@ public override void OnFrameworkInitializationCompleted() Loc.Instance.CurrentLanguage = Loc.Instance.ResolveLanguage(settings.Current.UiLanguage); BootTrace.Stage("Loc.Initialize"); + var secretMigration = services.GetRequiredService(); + var secretMigrationResult = secretMigration.MigrateAll(); + if (secretMigrationResult.RootSettingsChanged) + { + settings.Load(); + } + + string? secretMigrationWarning = null; + if (secretMigrationResult.HasUnresolvedSecrets) + { + secretMigrationWarning = Loc.Instance.GetString( + "Security.SecretMigrationWarning", + secretMigrationResult.UnresolvedSecretCount + ); + var startupErrorLog = services.GetRequiredService(); + if ( + startupErrorLog.Entries.All( + entry => entry.Message != secretMigrationWarning + ) + ) + { + startupErrorLog.AddEntry(secretMigrationWarning); + } + } + + BootTrace.Stage("secret protection migration"); + + 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. + var hotkey = services.GetRequiredService(); + ReconcileHotkeyOnStartup(hotkey, settings); + var shortcuts = services.GetRequiredService(); + using (var nativeBindingProbeCts = new CancellationTokenSource(TimeSpan.FromSeconds(2))) + { + try + { + shortcuts + .RefreshNativeDictationBindingStateAsync(nativeBindingProbeCts.Token) + .GetAwaiter() + .GetResult(); + } + catch (OperationCanceledException ex) + { + hotkey.SetNativeDictationBindingActive(false); + Trace.WriteLine($"[App] Native dictation binding probe timed out: {ex.Message}"); + } + catch (Exception ex) + { + hotkey.SetNativeDictationBindingActive(false); + Trace.WriteLine($"[App] Native dictation binding probe failed: {ex}"); + } + } + + BootTrace.Stage("native dictation binding reconciled"); + // Tray must be initialized before MainWindow so IsTrayAvailable is set when // GeneralSection's close-to-tray binding latches (the probe raises no PropertyChanged). var tray = services.GetRequiredService(); @@ -75,6 +143,25 @@ public override void OnFrameworkInitializationCompleted() var main = services.GetRequiredService(); desktop.MainWindow = main; BootTrace.Stage("MainWindow constructed"); + if (secretMigrationWarning is not null) + { + var warningShown = false; + main.Opened += async (_, _) => + { + if (warningShown) + { + return; + } + + warningShown = true; + var dialog = new MessageDialogWindow(); + await dialog.ShowMessageAsync( + Loc.Instance["Security.SecretMigrationWarningTitle"], + secretMigrationWarning + ); + }; + } + main.Opened += (_, _) => BootTrace.Stage("MainWindow.Opened fired"); // We're up and on screen — end the desktop's "launching" busy // cursor. Avalonia never completes the startup-notification @@ -140,9 +227,28 @@ 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}"); + StartupCancellation.NotifyUnverifiedInstance(); + ShuttingDown = true; + // 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(); @@ -191,14 +297,10 @@ main.DataContext as MainWindowViewModel services.GetRequiredService().Initialize(); BootTrace.Stage("recordingNotification.Initialize"); - // ReconcileHotkeyOnStartup migrates any upstream default and writes the service's - // current binding back to settings so subsequent SettingsChanged events don't - // silently rebind to a key the user never chose. - var hotkey = services.GetRequiredService(); - ReconcileHotkeyOnStartup(hotkey, settings); + var errorLog = services.GetRequiredService(); var promptActions = services.GetRequiredService(); // Seed the disabled auto-cleanup prompt + profile on a first install, - // before the hotkey snapshots below read them (both are disabled, so + // before dynamic reconciliation reads them (both are disabled, so // their Ctrl+Alt+E binding stays inert until the user enables them). try { @@ -207,28 +309,62 @@ main.DataContext as MainWindowViewModel catch (Exception ex) { Trace.WriteLine($"[App] Failed to seed first-run prompt actions: {ex}"); - services.GetRequiredService().AddEntry( + errorLog.AddEntry( $"Could not seed first-run prompt actions: {ex.Message}", ErrorCategory.Prompt ); } - hotkey.SetPromptActionHotkeys( - HotkeyService.ParsePromptActionHotkeys(promptActions.Actions) - ); - promptActions.ActionsChanged += () => - hotkey.SetPromptActionHotkeys( - HotkeyService.ParsePromptActionHotkeys(promptActions.Actions) - ); var profileService = services.GetRequiredService(); - profileService.SeedFirstRunDefaultsIfMissing(); - hotkey.SetProfileHotkeys( - HotkeyService.ParseProfileHotkeys(profileService.Profiles) - ); - profileService.ProfilesChanged += () => - hotkey.SetProfileHotkeys( - HotkeyService.ParseProfileHotkeys(profileService.Profiles) - ); + // Best-effort seed: a failure here must not abort startup. + try + { + profileService.SeedFirstRunDefaultsIfMissing(); + } + catch (Exception ex) + { + Trace.WriteLine($"[App] Failed to seed first-run profiles: {ex}"); + errorLog.AddEntry($"Could not seed first-run profiles: {ex.Message}"); + } + + // 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. 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(); + var reconcileRevision = 0L; + + void ReconcileDynamicHotkeys() + { + var revision = Interlocked.Increment(ref reconcileRevision); + 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(promptActions.Actions), + HotkeyService.ParseProfileHotkeys(profileService.Profiles) + ); + } + + foreach (var message in rejections) + { + errorLog.AddEntry(message); + } + } + + ReconcileDynamicHotkeys(); + promptActions.ActionsChanged += ReconcileDynamicHotkeys; + profileService.ProfilesChanged += ReconcileDynamicHotkeys; var lastApplied = hotkey.CurrentHotkeyString; var lastPromptPaletteApplied = hotkey.CurrentPromptPaletteHotkeyString; var lastRecentTranscriptionsApplied = hotkey.CurrentRecentTranscriptionsHotkeyString; @@ -320,11 +456,12 @@ main.DataContext as MainWindowViewModel 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(); @@ -453,7 +590,8 @@ private static void FireAndForget(Task task) => private static async Task ShutdownAndExitAsync( IServiceProvider services, - IClassicDesktopStyleApplicationLifetime desktop) + IClassicDesktopStyleApplicationLifetime desktop, + int exitCode = 0) { try { @@ -468,7 +606,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); } } @@ -528,6 +666,11 @@ private static async Task TearDownAsync(IServiceProvider services) Debug.WriteLine($"[App] Tray dispose failed: {ex.Message}"); } + DisposeDictationBeforeAudio( + services.GetService(), + services.GetService() + ); + try { var models = services.GetService(); @@ -543,19 +686,10 @@ private static async Task TearDownAsync(IServiceProvider services) try { - var audio = services.GetService(); - audio?.Dispose(); - } - catch (Exception ex) - { - Debug.WriteLine($"[App] Audio dispose failed: {ex.Message}"); - } - - try - { - // Kill the pactl subscribe child process if it's still running. Audio - // dispose already Stop()s it; this is a belt-and-braces cleanup of the - // singleton in case follow-default was never active on the audio service. + // Kill the pactl subscribe child process if it's still running. + // DisposeDictationBeforeAudio already Stop()s it; this is a belt-and-braces + // cleanup of the singleton in case follow-default was never active on the + // audio service. var deviceWatcher = services.GetService(); deviceWatcher?.Dispose(); } @@ -595,50 +729,190 @@ private static async Task TearDownAsync(IServiceProvider services) } } - private static async Task BootstrapAsync(IServiceProvider services) + internal static void DisposeDictationBeforeAudio( + IDisposable? dictation, + IDisposable? audio + ) { - 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"); + try + { + dictation?.Dispose(); + } + catch (Exception ex) + { + Debug.WriteLine($"[App] Dictation dispose failed: {ex.Message}"); + } - var pluginManager = services.GetRequiredService(); - await pluginManager.InitializeAsync(); - BootTrace.Stage("PluginManager.InitializeAsync"); + try + { + audio?.Dispose(); + } + catch (Exception ex) + { + Debug.WriteLine($"[App] Audio dispose failed: {ex.Message}"); + } + } - // PluginRegistryService targets the upstream Windows registry (Windows-built artifacts); - // the Linux fork ships its own plugins via BundledPluginDeployer, so the registry is not used. + 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 + ); + } - var historyRetention = services.GetRequiredService(); - historyRetention.Initialize(); + return published; + } - var modelManager = services.GetRequiredService(); - modelManager.MigrateSettings(); + private static Task BootstrapAsync(IServiceProvider services) + { + BootTrace.Stage("BootstrapAsync begin"); + 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 + ), + new( + BootstrapStageNames.WatchFolderAutoStart, + [BootstrapStageNames.PluginInitialization], + () => + { + services + .GetRequiredService() + .TryAutoStartWatchFolder(); + return Task.CompletedTask; + }, + Required: false + ), + ]; } /// @@ -661,19 +935,243 @@ 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 { - await BootstrapAsync(services); + 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 + { + 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"; + public const string WatchFolderAutoStart = "Watch-folder auto-start"; + } + + 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) + { + // ReSharper disable once InvertIf -- the positive form states the skip condition + // directly; inverting it into a `continue` guard reads worse here. + 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); + // ReSharper disable once ConvertIfStatementToReturnStatement -- the suggested + // `return cond ? throw ... : report;` obscures the failure path. + 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 + ); + } } } - private static void ApplyConfiguredMicrophone( + internal static void ApplyConfiguredMicrophone( AudioRecordingService audio, ISettingsService settings ) @@ -701,6 +1199,7 @@ ISettingsService settings var resolved = audio.ResolveConfiguredDevice(configuredIndex, configuredId); if (resolved is null) { + audio.SelectedDeviceIndex = null; return; } @@ -713,7 +1212,7 @@ ISettingsService settings settings.Current with { SelectedMicrophoneDevice = resolved.Index, - SelectedMicrophoneDeviceId = resolved.PersistentId + SelectedMicrophoneDeviceId = resolved.PersistentId, } ); } 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/Program.cs b/src/TypeWhisper.Linux/Program.cs index 4e9cd6690..2ec1842b1 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 its one privacy- + // relevant outcome here. + 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(); @@ -82,6 +92,8 @@ public static int Main(string[] args) if (!string.IsNullOrEmpty(probeError)) { Trace.WriteLine($"[Program] Control socket probe: {probeError}"); + StartupCancellation.NotifyUnverifiedInstance(); + return 1; } BootTrace.Stage("ControlSocketClient.TrySendToggle (no live peer)"); @@ -92,6 +104,11 @@ public static int Main(string[] args) LinuxStartupNotification.NotifyComplete(); // clear launcher's busy cursor return 0; } + else if (File.Exists(socketPath)) + { + StartupCancellation.NotifyUnverifiedInstance(); + return 1; + } else { BootTrace.Stage("ControlSocketClient.IsLivePeer (none)"); @@ -101,6 +118,60 @@ public static int Main(string[] args) { Trace.WriteLine($"[Program] Control socket probe failed: {ex.Message}"); BootTrace.Stage($"control socket probe threw: {ex.GetType().Name}"); + StartupCancellation.NotifyUnverifiedInstance(); + return 1; + } + + var restoreResult = SettingsBackupService.ApplyPendingRestoreAtStartup( + TypeWhisperEnvironment.BasePath + ); + switch (restoreResult.Status) + { + case StartupRestoreStatus.None: + 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; + + 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(); @@ -144,7 +215,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 @@ -188,4 +259,4 @@ private static bool IsImeDisabled() || value.Equals("yes", StringComparison.OrdinalIgnoreCase) ); } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Resources/Localization/de.json b/src/TypeWhisper.Linux/Resources/Localization/de.json index 32e71b0b9..b36c4051c 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/de.json +++ b/src/TypeWhisper.Linux/Resources/Localization/de.json @@ -6,7 +6,9 @@ "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}", "About.BackupUnsupportedPath": "Diese Sicherung enthält einen nicht unterstützten Pfad und wurde möglicherweise manipuliert: {0}", "About.CheckForUpdates": "Nach Updates suchen", @@ -122,6 +124,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", @@ -326,6 +330,7 @@ "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.CorrectionUndoFailed": "Rückgängig fehlgeschlagen – bitte erneut versuchen.", "Feedback.LearnedCorrectionFormat": "„{0}“ → „{1}“ gelernt", "Feedback.LearnedCorrectionsFormat": "{0} Korrekturen gelernt", "Feedback.Undo": "Rückgängig", @@ -373,10 +378,11 @@ "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}", - "General.CliInstallHint": "Installiert den mitgelieferten CLI-Starter nach ~/.local/bin/typewhisper. Verwenden Sie das obige Bearer-Token über TYPEWHISPER_API_TOKEN oder --token.", + "General.CliInstallHint": "Installiert den mitgelieferten CLI-Starter nach ~/.local/bin/typewhisper-cli. Verwenden Sie das obige Bearer-Token über TYPEWHISPER_API_TOKEN oder --token.", "General.CliInstallerTarget": "Installationsziel: {0}", "General.CloseToTray": "Schließen-Schaltfläche minimiert in den Tray", "General.CloseToTrayHint": "An: Die Schließen-Schaltfläche (X) blendet das Fenster ins Tray-Symbol aus — es verschwindet aus dem Dock und die App läuft weiter, erreichbar über das Tray-Menü. Aus: Die Schließen-Schaltfläche beendet TypeWhisper.", @@ -454,6 +460,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 oder transkribiert werden.", "Overlay.NoRecentTranscriptions": "Keine letzten Transkriptionen.", "Overlay.NoSpeech": "Keine Sprache erkannt", "Overlay.Processing": "Wird verarbeitet…", @@ -469,11 +476,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.", @@ -518,6 +530,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", @@ -544,6 +559,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", @@ -590,6 +606,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", @@ -600,14 +618,22 @@ "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", "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", @@ -625,6 +651,10 @@ "Recorder.Stop": "Stoppen", "Recorder.Subtitle": "Nehmen Sie eine längere Aufnahme in eine gespeicherte WAV-Datei auf und transkribieren Sie sie automatisch beim Stoppen der Aufnahme.", "Recorder.Title": "Aufnahmen", + "Security.BackupBlockedByUnresolvedSecrets": "Die Sicherung kann nicht erstellt werden, weil geschützte Geheimnisse nicht entschlüsselt werden konnten (Anzahl: {0}). Geben Sie die betroffenen Geheimnisse erneut ein und versuchen Sie es noch einmal.", + "Security.SecretMigrationWarning": "Geschützte Geheimnisse konnten nicht entschlüsselt werden und blieben unverändert (Anzahl: {0}). Geben Sie die betroffenen Anbieter- oder Plugin-Geheimnisse erneut ein. TypeWhisper versucht die Migration beim nächsten Start erneut.", + "Security.SecretMigrationWarningTitle": "Geschützte Geheimnisse nicht verfügbar", + "Security.SecretProtectionUnavailable": "Die lokale API ist deaktiviert, weil ihr Bearer-Token nicht geschützt werden konnte.", "Setup.ActiveWindowCheckInstallation": "Installation prüfen", "Setup.ActiveWindowCouldNotOpenInstallPage": "Installationsseite konnte nicht geöffnet werden.", "Setup.ActiveWindowCouldNotOpenInstallPageDetail": "Besuchen Sie extensions.gnome.org und suchen Sie nach „Window Calls“.", @@ -659,6 +689,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 +722,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 +750,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 +773,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 +795,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})", @@ -790,6 +833,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 b42579b13..4e1438fdc 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/en.json +++ b/src/TypeWhisper.Linux/Resources/Localization/en.json @@ -6,7 +6,9 @@ "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.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", @@ -122,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", @@ -326,6 +330,7 @@ "Dictionary.TypeTerm": "Term", "Dictionary.VocabularyBoostingHint": "Improves recognition of active dictionary terms and packs for local transcriptions", "Feedback.CorrectionLearningUndone": "Correction learning undone.", + "Feedback.CorrectionUndoFailed": "Couldn't undo — try again.", "Feedback.LearnedCorrectionFormat": "Learned \"{0}\" → \"{1}\"", "Feedback.LearnedCorrectionsFormat": "Learned {0} corrections", "Feedback.Undo": "Undo", @@ -373,10 +378,11 @@ "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}", - "General.CliInstallHint": "Installs the bundled CLI launcher to ~/.local/bin/typewhisper. Use the bearer token above through TYPEWHISPER_API_TOKEN or --token.", + "General.CliInstallHint": "Installs the bundled CLI launcher to ~/.local/bin/typewhisper-cli. Use the bearer token above through TYPEWHISPER_API_TOKEN or --token.", "General.CliInstallerTarget": "Installer target: {0}", "General.CloseToTray": "Close button hides to tray", "General.CloseToTrayHint": "On: the close (X) button hides the window to the tray icon — it leaves the dock and the app keeps running, reachable from the tray menu. Off: the close button quits TypeWhisper.", @@ -454,6 +460,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 or transcribe the recording.", "Overlay.NoRecentTranscriptions": "No recent transcriptions.", "Overlay.NoSpeech": "No speech detected", "Overlay.Processing": "Processing…", @@ -469,11 +476,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.", @@ -518,6 +530,9 @@ "Profiles.Enabled": "Enabled", "Profiles.HotkeyBehaviorProcessSelectedText": "Process selected text", "Profiles.HotkeyBehaviorStartDictation": "Start dictation", + "Profiles.HotkeyCollision": "This hotkey conflicts with another enabled shortcut.", + "Profiles.HotkeyMalformed": "Could not parse this hotkey. Try e.g. Ctrl+Alt+E or Meta+F9.", + "Profiles.HotkeyPromptActionRequired": "Select an enabled prompt action before assigning a selected-text hotkey.", "Profiles.HotkeyWatermark": "e.g. Ctrl+Alt+E", "Profiles.InstallWindowCallsExtension": "Install Window Calls extension", "Profiles.Language": "Language", @@ -544,6 +559,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", @@ -590,6 +606,8 @@ "Prompts.EmptyState": "No prompts yet.", "Prompts.Hint": "AI prompts for the Prompt Palette. Select text + hotkey = AI processes the text.", "Prompts.Hotkey": "Hotkey", + "Prompts.HotkeyCollision": "This hotkey conflicts with another enabled shortcut.", + "Prompts.HotkeyMalformed": "Could not parse this hotkey. Try e.g. Ctrl+Alt+R or Meta+F9.", "Prompts.HotkeyPlaceholder": "e.g. Ctrl+Alt+R", "Prompts.InsertTextNormally": "Insert text normally", "Prompts.ManualOnly": "Manual only", @@ -600,14 +618,22 @@ "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", "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", @@ -625,6 +651,10 @@ "Recorder.Stop": "Stop", "Recorder.Subtitle": "Capture a longer take into a saved WAV file, then transcribe it automatically when recording stops.", "Recorder.Title": "Recorder", + "Security.BackupBlockedByUnresolvedSecrets": "The backup cannot be created because {0} protected secret(s) could not be decrypted. Re-enter the affected secrets, then try again.", + "Security.SecretMigrationWarning": "{0} protected secret(s) could not be decrypted and were left unchanged. Re-enter the affected provider or plugin secrets. TypeWhisper will retry migration the next time it starts.", + "Security.SecretMigrationWarningTitle": "Protected secrets unavailable", + "Security.SecretProtectionUnavailable": "The local API is disabled because its bearer token could not be protected.", "Setup.ActiveWindowCheckInstallation": "Check installation", "Setup.ActiveWindowCouldNotOpenInstallPage": "Could not open the install page.", "Setup.ActiveWindowCouldNotOpenInstallPageDetail": "Visit extensions.gnome.org and search for \"Window Calls\".", @@ -659,6 +689,10 @@ "Setup.GlobalHotkeyAlreadyActive": "Global shortcut already active.", "Setup.GlobalHotkeyNeedsInputGroup": "Global shortcut needs keyboard access.", "Setup.GlobalHotkeyNeedsInputGroupHint": "On Wayland the hotkey reads keyboard input directly so it can do hold-to-talk. This installs a small udev rule that grants your current session read access to keyboard devices — one admin prompt, applied immediately with no logout or reboot. It's scoped to keyboards and to your session, so it's narrower than joining the 'input' group.", + "Setup.GlobalHotkeyOptedOut": "Focused-only shortcut active — raw keyboard reads are turned off in Shortcuts.", + "Setup.GlobalHotkeyOptedOutRuleInstalled": "Focused-only shortcut active, but a keyboard-access rule from before you turned this off is still installed.", + "Setup.GlobalHotkeyOptedOutRuleInstalledDetail": "TypeWhisper no longer reads raw keyboard events, but the udev rule that granted your session access is still on disk. Revoke it to fully undo the grant.", + "Setup.GlobalHotkeyRevokeButton": "Revoke keyboard access", "Setup.GlobalHotkeyReloginToActivate": "Log out and back in (or reboot) once to activate the global shortcut.", "Setup.GlobalHotkeyTitle": "Global dictation shortcut", "Setup.InstallFailed": "Install failed (exit {0}).", @@ -688,6 +722,7 @@ "Shortcuts.ActivationModeHint": "Toggle starts and stops on repeated presses. Push to talk records while held. Hybrid starts immediately, keeps recording after a short press, and stops on release after a long hold.", "Shortcuts.AlreadyRunningHint": "When TypeWhisper is already running, invoking this command toggles dictation in the existing instance — your shortcut won't launch a second copy.", "Shortcuts.AutoSetupHint": "Write the dictation shortcut directly to your desktop's settings. The lines below show exactly what will be added — your existing shortcuts are preserved.", + "Shortcuts.AutoSetupModeUnsupported": "{0} can't install a native shortcut for {1} mode. Switch to Toggle mode, or use TypeWhisper's built-in hotkey (Settings → Shortcuts) instead.", "Shortcuts.Backend": "Backend", "Shortcuts.BackendSwitchFailed": "Backend switch failed: {0}", "Shortcuts.BindCustom": "Bind a custom shortcut", @@ -715,6 +750,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).", @@ -735,6 +773,10 @@ "Shortcuts.MainCapture": "Main capture", "Shortcuts.MainHotkey": "Main dictation hotkey", "Shortcuts.MainHotkeyHint": "Global shortcut that starts dictation. Its behavior depends on the activation mode below.", + "Shortcuts.NativeDictationInstallDeferred": "TypeWhisper will keep managing dictation until a later startup verifies the desktop binding.", + "Shortcuts.NativeDictationOwnershipActive": "The desktop now manages TypeWhisper's dictation hotkey; TypeWhisper keeps all other app shortcuts active.", + "Shortcuts.NativeDictationRemovalActive": "TypeWhisper now manages its dictation hotkey again.", + "Shortcuts.NativeDictationRemovalDeferred": "The desktop may still own the live dictation chord until reload or re-login; the next startup will reconcile it.", "Shortcuts.ModeHybridStatus": "Starts immediately. Short press keeps recording; hold past ~600 ms stops on release.", "Shortcuts.ModePushToTalkStatus": "Hold the hotkey to record; release to stop and transcribe.", "Shortcuts.ModeToggleStatus": "Press the hotkey to start, press again to stop.", @@ -753,6 +795,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})", @@ -790,6 +833,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 2f93cca74..ed398eab1 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/es.json +++ b/src/TypeWhisper.Linux/Resources/Localization/es.json @@ -6,7 +6,9 @@ "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}", "About.BackupUnsupportedPath": "Esta copia de seguridad contiene una ruta no admitida y puede haber sido manipulada: {0}", "About.CheckForUpdates": "Buscar actualizaciones", @@ -122,6 +124,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", @@ -326,6 +330,7 @@ "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.CorrectionUndoFailed": "No se pudo deshacer. Inténtalo de nuevo.", "Feedback.LearnedCorrectionFormat": "Se aprendió «{0}» → «{1}»", "Feedback.LearnedCorrectionsFormat": "Se aprendieron {0} correcciones", "Feedback.Undo": "Deshacer", @@ -373,10 +378,11 @@ "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}", - "General.CliInstallHint": "Instala el lanzador CLI incluido en ~/.local/bin/typewhisper. Usa el token Bearer de arriba a través de TYPEWHISPER_API_TOKEN o --token.", + "General.CliInstallHint": "Instala el lanzador CLI incluido en ~/.local/bin/typewhisper-cli. Usa el token Bearer de arriba a través de TYPEWHISPER_API_TOKEN o --token.", "General.CliInstallerTarget": "Destino del instalador: {0}", "General.CloseToTray": "El botón de cerrar oculta en la bandeja del sistema", "General.CloseToTrayHint": "Activado: el botón de cerrar (X) oculta la ventana en el icono de la bandeja del sistema — desaparece del dock y la app sigue ejecutándose, accesible desde el menú de la bandeja. Desactivado: el botón de cerrar sale de TypeWhisper.", @@ -454,6 +460,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 ni transcribir la grabación.", "Overlay.NoRecentTranscriptions": "No hay transcripciones recientes.", "Overlay.NoSpeech": "No se detectó voz", "Overlay.Processing": "Procesando…", @@ -469,11 +476,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.", @@ -518,6 +530,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", @@ -544,6 +559,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", @@ -590,6 +606,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", @@ -600,14 +618,22 @@ "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", "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", @@ -625,6 +651,10 @@ "Recorder.Stop": "Detener", "Recorder.Subtitle": "Captura una toma más larga en un archivo WAV guardado y luego transcríbelo automáticamente cuando se detenga la grabación.", "Recorder.Title": "Grabadora", + "Security.BackupBlockedByUnresolvedSecrets": "No se puede crear la copia de seguridad porque no se pudieron descifrar secretos protegidos (cantidad: {0}). Vuelve a introducir los secretos afectados e inténtalo de nuevo.", + "Security.SecretMigrationWarning": "No se pudieron descifrar secretos protegidos y se dejaron sin cambios (cantidad: {0}). Vuelve a introducir los secretos del proveedor o complemento afectados. TypeWhisper reintentará la migración la próxima vez que se inicie.", + "Security.SecretMigrationWarningTitle": "Secretos protegidos no disponibles", + "Security.SecretProtectionUnavailable": "La API local está desactivada porque no se pudo proteger su token de portador.", "Setup.ActiveWindowCheckInstallation": "Comprobar instalación", "Setup.ActiveWindowCouldNotOpenInstallPage": "No se pudo abrir la página de instalación.", "Setup.ActiveWindowCouldNotOpenInstallPageDetail": "Visita extensions.gnome.org y busca \"Window Calls\".", @@ -659,6 +689,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 +722,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 +750,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 +773,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 +795,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})", @@ -790,6 +833,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 07226eee8..f0aab1ac1 100644 --- a/src/TypeWhisper.Linux/Resources/Localization/ru.json +++ b/src/TypeWhisper.Linux/Resources/Localization/ru.json @@ -6,7 +6,9 @@ "About.BackupInvalid": "Этот файл не является допустимой резервной копией настроек TypeWhisper.", "About.BackupInvalidManifest": "Манифест резервной копии TypeWhisper повреждён или не читается. Восстановление отменено.", "About.BackupRestored": "Резервная копия восстановлена, файлов: {0}. Некоторые восстановленные настройки могут потребовать перезапуска приложения.", + "About.BackupStaged": "Резервная копия проверена и подготовлена, файлов: {0}. Закройте и снова откройте TypeWhisper, чтобы применить её.", "About.BackupStatusDefault": "Резервное копирование настроек, профилей, сниппетов и данных плагинов.", + "About.BackupTooLarge": "Эта резервная копия распаковывается в гораздо больший объём данных, чем содержит копия настроек, и, возможно, была подменена. Восстановление отменено.", "About.BackupUnsafePath": "Эта резервная копия содержит небезопасный путь и, возможно, была подменена: {0}", "About.BackupUnsupportedPath": "Эта резервная копия содержит неподдерживаемый путь и, возможно, была подменена: {0}", "About.CheckForUpdates": "Проверить обновления", @@ -122,6 +124,8 @@ "Common.On": "Вкл", "Common.Yes": "Да", "Common.OpenWizard": "Открыть мастер", + "Common.OperationFailed": "Не удалось выполнить действие «{0}»: {1}", + "Common.OperationFailedTitle": "Не удалось выполнить операцию", "Common.Refresh": "Обновить", "Common.Remove": "Удалить", "Common.RemoveIntegration": "Удалить интеграцию", @@ -326,6 +330,7 @@ "Dictionary.TypeTerm": "Термин", "Dictionary.VocabularyBoostingHint": "Улучшает распознавание активных терминов словаря и пакетов для локальных транскрипций", "Feedback.CorrectionLearningUndone": "Обучение исправлению отменено.", + "Feedback.CorrectionUndoFailed": "Не удалось отменить. Попробуйте ещё раз.", "Feedback.LearnedCorrectionFormat": "Запомнено «{0}» → «{1}»", "Feedback.LearnedCorrectionsFormat": "Запомнено исправлений: {0}", "Feedback.Undo": "Отменить", @@ -373,10 +378,11 @@ "FileTranscription.WatchingForNewFiles": "Наблюдение за новыми файлами", "General.ApiExamples": "Примеры API", "General.Autostart": "Запускать автоматически при старте системы", + "General.AutostartEntryPreserved": "TypeWhisper оставил сторонний или изменённый элемент автозапуска в {0} нетронутым и не будет его перезаписывать или удалять.", "General.AutostartHint": "TypeWhisper запускается автоматически при входе в систему.", "General.BearerToken": "Bearer-токен", "General.CliBundledTarget": "Встроенный: {0} | Цель: {1}", - "General.CliInstallHint": "Устанавливает встроенный CLI-лаунчер в ~/.local/bin/typewhisper. Используйте bearer-токен выше через TYPEWHISPER_API_TOKEN или --token.", + "General.CliInstallHint": "Устанавливает встроенный CLI-лаунчер в ~/.local/bin/typewhisper-cli. Используйте bearer-токен выше через TYPEWHISPER_API_TOKEN или --token.", "General.CliInstallerTarget": "Цель установки: {0}", "General.CloseToTray": "Кнопка закрытия сворачивает в трей", "General.CloseToTrayHint": "Вкл: кнопка закрытия (X) сворачивает окно в значок в трее — окно покидает панель, а приложение продолжает работать и доступно из меню трея. Выкл: кнопка закрытия завершает работу TypeWhisper.", @@ -454,6 +460,7 @@ "Notify.BodyPushToTalk": "Говорите — отпустите, чтобы вставить", "Notify.BodyToggle": "Говорите — нажмите сочетание ещё раз, чтобы остановить", "Overlay.Canceled": "Отменено", + "Overlay.CaptureSaveFailed": "Не удалось сохранить или расшифровать запись.", "Overlay.NoRecentTranscriptions": "Нет недавних транскрипций.", "Overlay.NoSpeech": "Речь не обнаружена", "Overlay.Processing": "Обработка…", @@ -469,11 +476,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": "Разверните, чтобы изменить настройки плагина.", @@ -518,6 +530,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": "Язык", @@ -544,6 +559,7 @@ "Profiles.RulesSummary": "Правил приложений: {0}, правил URL: {1}", "Profiles.SaveHint": "Сохраните один раз после изменения правил, переопределений или активации.", "Profiles.SelectProfile": "Выбрать профиль", + "Profiles.SaveFailed": "Не удалось сохранить профили: {0}", "Profiles.SelectProfileHint": "Выберите профиль из списка или создайте новый", "Profiles.StylePreset": "Стилевой пресет", "Profiles.StylePresetCasualMessage": "Неформальное сообщение", @@ -590,6 +606,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": "Только вручную", @@ -600,14 +618,22 @@ "Prompts.NoProvider": "LLM-провайдер не настроен", "Prompts.Provider": "Провайдер", "Prompts.ProviderWarning": "Включите OpenAI или Groq в разделе «Расширения».", + "Prompts.SaveFailed": "Не удалось сохранить промпт-действия: {0}", "Prompts.Summary": "Промптов: {0}, включено: {1}", "Prompts.SystemPrompt": "Системный промпт", "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": "Запись", @@ -625,6 +651,10 @@ "Recorder.Stop": "Стоп", "Recorder.Subtitle": "Запишите длинный фрагмент в сохранённый WAV-файл, который автоматически транскрибируется после остановки записи.", "Recorder.Title": "Диктофон", + "Security.BackupBlockedByUnresolvedSecrets": "Резервную копию нельзя создать: не удалось расшифровать защищённые секреты ({0}). Введите затронутые секреты заново и повторите попытку.", + "Security.SecretMigrationWarning": "Не удалось расшифровать защищённые секреты ({0}), поэтому они оставлены без изменений. Введите заново затронутые секреты поставщиков или плагинов. TypeWhisper повторит миграцию при следующем запуске.", + "Security.SecretMigrationWarningTitle": "Защищённые секреты недоступны", + "Security.SecretProtectionUnavailable": "Локальный API отключён, поскольку не удалось защитить его токен доступа.", "Setup.ActiveWindowCheckInstallation": "Проверить установку", "Setup.ActiveWindowCouldNotOpenInstallPage": "Не удалось открыть страницу установки.", "Setup.ActiveWindowCouldNotOpenInstallPageDetail": "Откройте extensions.gnome.org и найдите «Window Calls».", @@ -659,6 +689,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 +722,7 @@ "Shortcuts.ActivationModeHint": "«Переключение» запускает и останавливает повторными нажатиями. «Удерживать для речи» записывает, пока клавиша удерживается. «Гибрид» запускается сразу, продолжает запись после короткого нажатия и останавливается при отпускании после долгого удержания.", "Shortcuts.AlreadyRunningHint": "Когда TypeWhisper уже запущен, вызов этой команды переключает диктовку в существующем экземпляре — ваше сочетание не запустит вторую копию.", "Shortcuts.AutoSetupHint": "Записать сочетание для диктовки напрямую в настройки вашего рабочего стола. Строки ниже показывают, что именно будет добавлено — ваши существующие сочетания сохраняются.", + "Shortcuts.AutoSetupModeUnsupported": "{0} не может установить системное сочетание для режима «{1}». Переключитесь на режим «Переключение» или используйте встроенное сочетание TypeWhisper (Настройки → Сочетания клавиш).", "Shortcuts.Backend": "Бэкенд", "Shortcuts.BackendSwitchFailed": "Не удалось переключить бэкенд: {0}", "Shortcuts.BindCustom": "Назначить своё сочетание", @@ -715,6 +750,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 +773,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 +795,7 @@ "Shortcuts.RecentTranscriptionsHotkeySet": "Сочетание недавних транскрипций задано: {0}.", "Shortcuts.RemovalFailed": "Не удалось удалить: {0}", "Shortcuts.RemovingShortcut": "Удаление сочетания из {0}…", + "Shortcuts.RefreshDesktopIntegrationOn": "Обновить интеграцию с рабочим столом ({0})", "Shortcuts.ScopeFocusedOnly": "Только активное (окно TypeWhisper)", "Shortcuts.ScopeGlobal": "Глобально (работает в любом активном окне)", "Shortcuts.SetupAutomaticallyOn": "Настроить автоматически ({0})", @@ -790,6 +833,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 b2656037f..c50c185f2 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; @@ -9,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; @@ -31,7 +33,40 @@ public static void Register(IServiceCollection services) services.AddSingleton( new SettingsService(TypeWhisperEnvironment.SettingsFilePath) ); - services.AddSingleton(new ErrorLogService(dataPath)); + var errorLog = new ErrorLogService(dataPath); + // 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 = + $"Recordings folder '{TypeWhisperEnvironment.AudioPath}' could not be made " + + "owner-only; recordings saved there may be readable by other users of this " + + "machine."; + + // 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.All(e => e.Message != warning)) + { + errorLog.AddEntry(warning, ErrorCategory.Recording); + } + } + + 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"), @@ -46,8 +81,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( @@ -166,7 +204,13 @@ public static void Register(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + new SettingsBackupService( + TypeWhisperEnvironment.BasePath, + secretMigration: sp.GetRequiredService() + ) + ); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); 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 48447a612..409b19fb7 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 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 // 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; @@ -1245,7 +1304,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 +1317,7 @@ private async Task TryStartAsync() { Type = MessageType.Signal, Interface = EventObjectInterface, - Member = "TextChanged" + Member = "TextChanged", }, s_readSignal, HandleTextChanged, @@ -1278,7 +1337,7 @@ private async Task TryStartAsync() Sender = "org.freedesktop.DBus", Interface = "org.freedesktop.DBus", Member = "NameOwnerChanged", - Arg0 = RegistryBusName + Arg0 = RegistryBusName, }, s_readNameOwnerChanged, HandleRegistryOwnerChanged, @@ -1322,27 +1381,12 @@ 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; } } - private void HandleStateChanged(Exception? exception, AtSpiSignal signal, object? readerState, object? handlerState) + internal 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; schedule a reconnect rather than @@ -1353,10 +1397,7 @@ private void HandleStateChanged(Exception? exception, AtSpiSignal signal, object return; } - if ( - !string.Equals(signal.Detail, FocusedStateName, StringComparison.Ordinal) - || signal.Detail1 != StateGained - ) + if (!string.Equals(signal.Detail, FocusedStateName, StringComparison.Ordinal)) { return; } @@ -1367,6 +1408,21 @@ private void HandleStateChanged(Exception? exception, AtSpiSignal signal, object return; } + if (signal.Detail1 != StateGained) + { + // Focus loss can arrive after a newer gain, so only clear the element that actually + // lost focus; a stale loss must not clobber the newer focus anchor. + lock (_focusLock) + { + if (_currentFocused == element) + { + _currentFocused = null; + } + } + + return; + } + lock (_focusLock) { _currentFocused = element; @@ -1676,7 +1732,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 @@ -1733,7 +1789,7 @@ private void LogOnce(string message) _errorLog.AddEntry(message, ErrorCategory.Detection); } - private readonly record struct AtSpiSignal(string Sender, string Path, string Detail, int Detail1); + internal 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 diff --git a/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs b/src/TypeWhisper.Linux/Services/ActiveWindow/AtSpiUrlExtractor.cs index 455d7e012..5d1b2cd83 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); @@ -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; } @@ -685,7 +709,7 @@ params string[] signatureAndArgs destination, path, @interface, - method + method, }; args.AddRange(signatureAndArgs); @@ -716,7 +740,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 +761,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 +803,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/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/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/ActiveWindowService.cs b/src/TypeWhisper.Linux/Services/ActiveWindowService.cs index b0fb0e7eb..eec87c2a6 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; @@ -37,7 +38,7 @@ public sealed class ActiveWindowService : IActiveWindowService "waterfox", "zen", "zen-browser", - "zen-bin" + "zen-bin", }; private static readonly string[] s_browserAppNameHints = @@ -53,7 +54,7 @@ public sealed class ActiveWindowService : IActiveWindowService "firefox", "waterfox", "zen browser", - "zen" + "zen", ]; private readonly AtSpiUrlExtractor _atSpiUrlExtractor; @@ -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() { @@ -520,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); @@ -547,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) @@ -593,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 4097f58ca..d876d58fc 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; @@ -6,7 +7,7 @@ namespace TypeWhisper.Linux.Services; /// /// Writes ~/.config/typewhisper/api-discovery.json (XDG_CONFIG_HOME-aware) -/// so CLI clients can discover the running app's port and bearer token. +/// so clients can discover the running app's TCP port, Unix socket, and bearer token. /// File is created at API start and deleted at stop. Mode 0600 is set via /// open(2) (not chmod-after-write) to avoid a race exposing the token; /// the parent directory is tightened to 0700 to hide even the file's existence. @@ -43,10 +44,15 @@ private static string DirectoryPath private static string FilePath => Path.Join(DirectoryPath, FileName); + /// + /// Publishes the discovery file. Returns false when it could not be + /// written — the CLI has no other way to reach the API, so callers must + /// surface the failure rather than report a healthy API. + /// // 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) + public bool Write(int port, string token, string socketPath) { try { @@ -56,7 +62,7 @@ public void Write(int port, string token) var final = FilePath; var tmp = final + ".tmp"; var json = JsonSerializer.Serialize( - new { version = 1, port, token }, + new { version = 2, port, token, socket_path = socketPath }, s_jsonOptions ); @@ -79,7 +85,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()) @@ -96,15 +102,17 @@ public void Write(int port, string token) // Atomic rename: a CLI client mid-read never sees a partial JSON file. // The renamed inode keeps its 0600 perms — no re-chmod needed. File.Move(tmp, final, true); + return true; } catch (Exception ex) { Trace.WriteLine($"[ApiDiscoveryFile] Write failed: {ex.Message}"); + return false; } } // 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() { @@ -140,4 +148,4 @@ private static void EnsureDirectoryMode(string path) ); } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/ApiKeyProtection.cs b/src/TypeWhisper.Linux/Services/ApiKeyProtection.cs index 600c30189..e98ae859d 100644 --- a/src/TypeWhisper.Linux/Services/ApiKeyProtection.cs +++ b/src/TypeWhisper.Linux/Services/ApiKeyProtection.cs @@ -1,113 +1,375 @@ using System.Security.Cryptography; using System.Text; +using TypeWhisper.Core; +using TypeWhisper.Core.Services; namespace TypeWhisper.Linux.Services; +internal enum SecretProtectionFormat +{ + Current, + LegacyGcm, + LegacyCbc, + LegacyPlaintext, + Failure, +} + +internal readonly record struct SecretDecryptionResult( + SecretProtectionFormat Format, + string? PlainText +) +{ + public bool Succeeded => Format != SecretProtectionFormat.Failure; + public bool RequiresMigration => + Format is SecretProtectionFormat.LegacyGcm + or SecretProtectionFormat.LegacyCbc + or SecretProtectionFormat.LegacyPlaintext; + + public static SecretDecryptionResult Failure => + new(SecretProtectionFormat.Failure, null); +} + +internal sealed class SecretProtectionException(string message, Exception? innerException = null) + : CryptographicException(message, innerException); + /// -/// At-rest protection for plugin secrets (DPAPI-equivalent obfuscation, not strong -/// crypto — an attacker with file access can decrypt, same as DPAPI). Uses -/// AES-GCM v1 with a per-user key derived from UID + HOME. File-at-rest is -/// unconditional because no Secret Service provider is guaranteed on all Linux -/// setups (tiling WMs often have none); libsecret could be added later as an opt-in. +/// Authenticated at-rest protection for application and plugin secrets. /// -public static class ApiKeyProtection +internal static class ApiKeyProtection { - private const byte AesGcmVersion = 1; + private const byte LegacyAesGcmVersion = 1; + private const byte CurrentVersion = 2; + private const int KeySize = 32; private const int NonceSize = 12; private const int TagSize = 16; private const int LegacyIvSize = 16; - private static readonly byte[] s_entropy = "TypeWhisper.ApiKey.v1.linux"u8.ToArray(); + private const int CurrentHeaderSize = 5; + + private const UnixFileMode KeyFileMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite; - public static string Encrypt(string plainText) + private static readonly byte[] s_magic = "TWSP"u8.ToArray(); + private static readonly byte[] s_legacyEntropy = + "TypeWhisper.ApiKey.v1.linux"u8.ToArray(); + private static readonly UTF8Encoding s_strictUtf8 = new(false, true); + + public static string Encrypt(string plainText, string? keyFilePath = null) { if (string.IsNullOrEmpty(plainText)) { return ""; } - var key = DeriveKey(); - var bytes = Encoding.UTF8.GetBytes(plainText); - var nonce = RandomNumberGenerator.GetBytes(NonceSize); - var cipher = new byte[bytes.Length]; - var tag = new byte[TagSize]; - using var aes = new AesGcm(key, TagSize); - aes.Encrypt(nonce, bytes, cipher, tag); - var combined = new byte[1 + NonceSize + TagSize + cipher.Length]; - combined[0] = AesGcmVersion; - Buffer.BlockCopy(nonce, 0, combined, 1, NonceSize); - Buffer.BlockCopy(tag, 0, combined, 1 + NonceSize, TagSize); - Buffer.BlockCopy(cipher, 0, combined, 1 + NonceSize + TagSize, cipher.Length); - return Convert.ToBase64String(combined); + var key = EnsureKeyFile(keyFilePath); + try + { + var bytes = Encoding.UTF8.GetBytes(plainText); + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var cipher = new byte[bytes.Length]; + var tag = new byte[TagSize]; + using var aes = new AesGcm(key, TagSize); + aes.Encrypt(nonce, bytes, cipher, tag); + + var combined = new byte[ + CurrentHeaderSize + NonceSize + TagSize + cipher.Length + ]; + s_magic.CopyTo(combined, 0); + combined[s_magic.Length] = CurrentVersion; + Buffer.BlockCopy(nonce, 0, combined, CurrentHeaderSize, NonceSize); + Buffer.BlockCopy( + tag, + 0, + combined, + CurrentHeaderSize + NonceSize, + TagSize + ); + Buffer.BlockCopy( + cipher, + 0, + combined, + CurrentHeaderSize + NonceSize + TagSize, + cipher.Length + ); + return Convert.ToBase64String(combined); + } + finally + { + CryptographicOperations.ZeroMemory(key); + } } - public static string Decrypt(string encrypted) + public static SecretDecryptionResult Decrypt( + string encrypted, + string? keyFilePath = null + ) { if (string.IsNullOrEmpty(encrypted)) { - return ""; + return new SecretDecryptionResult( + SecretProtectionFormat.LegacyPlaintext, + "" + ); } + byte[] combined; try { - var combined = Convert.FromBase64String(encrypted); - if (TryDecryptAesGcm(combined, out var decryptedText)) + combined = Convert.FromBase64String(encrypted); + } + catch (FormatException) + { + return new SecretDecryptionResult( + SecretProtectionFormat.LegacyPlaintext, + encrypted + ); + } + + if (HasCurrentMagic(combined)) + { + return TryDecryptCurrent(combined, keyFilePath); + } + + if ( + combined.Length >= 1 + NonceSize + TagSize + && combined[0] == LegacyAesGcmVersion + ) + { + var legacyGcm = TryDecryptLegacyGcm(combined); + if (legacyGcm.Succeeded) + { + return legacyGcm; + } + } + + if (IsLegacyCbcShape(combined)) + { + return TryDecryptLegacyCbc(combined); + } + + if (combined.Length < LegacyIvSize) + { + return new SecretDecryptionResult( + SecretProtectionFormat.LegacyPlaintext, + encrypted + ); + } + + return SecretDecryptionResult.Failure; + } + + public static byte[] EnsureKeyFile(string? keyFilePath = null) + { + if (!OperatingSystem.IsLinux()) + { + throw new SecretProtectionException( + "Secret protection requires verifiable Unix file permissions." + ); + } + + var path = keyFilePath ?? TypeWhisperEnvironment.SecretProtectionKeyFilePath; + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + if (!File.Exists(path)) + { + var generated = RandomNumberGenerator.GetBytes(KeySize); + // ReSharper disable once TryStatementsCanBeMerged -- the outer try/finally exists solely to + // guarantee the generated key material is zeroed; keeping it separate from the + // concurrent-creator catch keeps that guarantee obvious in this security-critical path. + try { - return decryptedText; + try + { + AtomicFileWrite.WriteAllBytesCreateNew(path, generated, KeyFileMode); + } + catch (IOException) when (File.Exists(path)) + { + // A concurrent creator won. Its file is validated below. + } } + finally + { + CryptographicOperations.ZeroMemory(generated); + } + } - // Fall back to legacy CBC layout [16-byte IV][ciphertext]. - // Blobs shorter than an IV are pre-encryption plaintext — return as-is. - if (combined.Length < LegacyIvSize) + try + { + var mode = File.GetUnixFileMode(path); + if (mode != KeyFileMode) { - return encrypted; + throw new SecretProtectionException( + $"Secret protection key '{path}' must have Unix mode 0600." + ); } - var key = DeriveKey(); + var key = File.ReadAllBytes(path); + // ReSharper disable once InvertIf -- reject-then-return matches the guard style used by the + // mode check above; inverting would hide the zero-and-throw rejection behind the happy path. + if (key.Length != KeySize) + { + CryptographicOperations.ZeroMemory(key); + throw new SecretProtectionException( + $"Secret protection key '{path}' must contain exactly {KeySize} bytes." + ); + } + + return key; + } + catch (SecretProtectionException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new SecretProtectionException( + $"Secret protection key '{path}' could not be validated.", + ex + ); + } + } + + private static SecretDecryptionResult TryDecryptCurrent( + byte[] combined, + string? keyFilePath + ) + { + if ( + combined.Length < CurrentHeaderSize + NonceSize + TagSize + || combined[s_magic.Length] != CurrentVersion + ) + { + return SecretDecryptionResult.Failure; + } + + var path = keyFilePath ?? TypeWhisperEnvironment.SecretProtectionKeyFilePath; + if (!File.Exists(path)) + { + return SecretDecryptionResult.Failure; + } + + byte[] key; + try + { + key = EnsureKeyFile(path); + } + catch (SecretProtectionException) + { + return SecretDecryptionResult.Failure; + } + + try + { + var nonce = combined.AsSpan(CurrentHeaderSize, NonceSize); + var tag = combined.AsSpan(CurrentHeaderSize + NonceSize, TagSize); + var cipher = combined.AsSpan( + CurrentHeaderSize + NonceSize + TagSize + ); + var plaintext = new byte[cipher.Length]; + using var aes = new AesGcm(key, TagSize); + aes.Decrypt(nonce, cipher, tag, plaintext); + return new SecretDecryptionResult( + SecretProtectionFormat.Current, + s_strictUtf8.GetString(plaintext) + ); + } + catch (Exception ex) when ( + ex is CryptographicException or DecoderFallbackException + ) + { + return SecretDecryptionResult.Failure; + } + finally + { + CryptographicOperations.ZeroMemory(key); + } + } + + private static SecretDecryptionResult TryDecryptLegacyGcm(byte[] combined) + { + var key = DeriveLegacyKey(); + try + { + var nonce = combined.AsSpan(1, NonceSize); + var tag = combined.AsSpan(1 + NonceSize, TagSize); + var cipher = combined.AsSpan(1 + NonceSize + TagSize); + var plaintext = new byte[cipher.Length]; + using var aes = new AesGcm(key, TagSize); + aes.Decrypt(nonce, cipher, tag, plaintext); + return new SecretDecryptionResult( + SecretProtectionFormat.LegacyGcm, + s_strictUtf8.GetString(plaintext) + ); + } + catch (Exception ex) when ( + ex is CryptographicException or DecoderFallbackException + ) + { + return SecretDecryptionResult.Failure; + } + finally + { + CryptographicOperations.ZeroMemory(key); + } + } + + private static SecretDecryptionResult TryDecryptLegacyCbc(byte[] combined) + { + var key = DeriveLegacyKey(); + try + { using var aes = Aes.Create(); aes.Key = key; - var iv = new byte[LegacyIvSize]; - Buffer.BlockCopy(combined, 0, iv, 0, LegacyIvSize); - aes.IV = iv; + aes.IV = combined.AsSpan(0, LegacyIvSize).ToArray(); using var decryptor = aes.CreateDecryptor(); - var cipher = new byte[combined.Length - LegacyIvSize]; - Buffer.BlockCopy(combined, LegacyIvSize, cipher, 0, cipher.Length); - var decrypted = decryptor.TransformFinalBlock(cipher, 0, cipher.Length); - return Encoding.UTF8.GetString(decrypted); + var decrypted = decryptor.TransformFinalBlock( + combined, + LegacyIvSize, + combined.Length - LegacyIvSize + ); + return new SecretDecryptionResult( + SecretProtectionFormat.LegacyCbc, + s_strictUtf8.GetString(decrypted) + ); } - catch (CryptographicException) + catch (Exception ex) when ( + ex is CryptographicException or DecoderFallbackException + ) { - return encrypted; + return SecretDecryptionResult.Failure; } - catch (FormatException) + finally { - return encrypted; + CryptographicOperations.ZeroMemory(key); } } - private static byte[] DeriveKey() + private static byte[] DeriveLegacyKey() { var home = Environment.GetEnvironmentVariable("HOME") ?? "/"; var user = Environment.UserName; var material = Encoding.UTF8.GetBytes($"{user}:{home}"); - return Rfc2898DeriveBytes.Pbkdf2(material, s_entropy, 10_000, HashAlgorithmName.SHA256, 32); + return Rfc2898DeriveBytes.Pbkdf2( + material, + s_legacyEntropy, + 10_000, + HashAlgorithmName.SHA256, + KeySize + ); } - private static bool TryDecryptAesGcm(byte[] combined, out string decryptedText) + private static bool HasCurrentMagic(byte[] combined) { - decryptedText = ""; - if (combined.Length < 1 + NonceSize + TagSize || combined[0] != AesGcmVersion) - { - return false; - } + return combined.Length >= CurrentHeaderSize + && combined.AsSpan(0, s_magic.Length).SequenceEqual(s_magic); + } - var nonce = combined.AsSpan(1, NonceSize); - var tag = combined.AsSpan(1 + NonceSize, TagSize); - var cipher = combined.AsSpan(1 + NonceSize + TagSize); - var plaintext = new byte[cipher.Length]; - var key = DeriveKey(); - using var aes = new AesGcm(key, TagSize); - aes.Decrypt(nonce, cipher, tag, plaintext); - decryptedText = Encoding.UTF8.GetString(plaintext); - return true; + private static bool IsLegacyCbcShape(byte[] combined) + { + return combined.Length >= LegacyIvSize * 2 + && (combined.Length - LegacyIvSize) % LegacyIvSize == 0; } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/AppVersion.cs b/src/TypeWhisper.Linux/Services/AppVersion.cs index a0830b614..5344cd065 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,116 @@ 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; + } + + // ReSharper disable once ConvertIfStatementToSwitchStatement -- independent pre-release guard chain over two operands; no single value to switch on. + 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; + } + + // ReSharper disable once LoopCanBeConvertedToQuery -- the reject condition is a multi-line boolean; an All(...) lambda would read worse. + 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; @@ -107,7 +337,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), }; } @@ -149,4 +379,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/AudioDuckingService.cs b/src/TypeWhisper.Linux/Services/AudioDuckingService.cs index 7cf5a580c..34176ffe4 100644 --- a/src/TypeWhisper.Linux/Services/AudioDuckingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioDuckingService.cs @@ -11,19 +11,32 @@ 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); + 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 IErrorLogService _errorLog; + private readonly Dictionary _savedVolumes = new(StringComparer.Ordinal); private bool _isDucked; + public AudioDuckingService(IProcessRunner processRunner, IErrorLogService errorLog) + { + _processRunner = processRunner; + _errorLog = errorLog; + } + public void DuckAudio(float factor) { if (_isDucked) @@ -35,17 +48,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; @@ -65,29 +88,44 @@ public void RestoreAudio() return; } - try + foreach (var (inputId, volumes) in _savedVolumes.ToArray()) { - foreach (var (inputId, volume) in _savedVolumes) + try { - CommandRunner.Run("pactl", "set-sink-input-volume", inputId, volume); + 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(); } /// - /// 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 +143,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 +157,82 @@ public void RestoreAudio() } } - private static string ScaleVolume(string volumePercent, float factor) + private ProcessRunResult SetSinkInputVolume(string inputId, string[] volumes) + { + var arguments = new List(2 + volumes.Length) + { + "set-sink-input-volume", + inputId, + }; + arguments.AddRange(volumes); + return RunPactl(arguments); + } + + private ProcessRunResult RunPactl(IReadOnlyList arguments) + { + return _processRunner + .RunAsync( + "pactl", + arguments, + environment: s_pactlEnvironment, + timeout: s_pactlTimeout + ) + .GetAwaiter() + .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) { - 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/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..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. @@ -92,7 +108,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 93b7fa1ef..34c6fe41a 100644 --- a/src/TypeWhisper.Linux/Services/AudioRecordingService.cs +++ b/src/TypeWhisper.Linux/Services/AudioRecordingService.cs @@ -15,6 +15,29 @@ namespace TypeWhisper.Linux.Services; /// 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 sealed record RecordedAudioSnapshot( + float[][] Chunks, + int SampleCount, + int CaptureSampleRate + ); + private const int SampleRate = 16000; private const int Channels = 1; private const uint FramesPerBuffer = 512; @@ -26,63 +49,55 @@ public sealed class AudioRecordingService : IDisposable private static int s_paInitCount; private static readonly Lock s_paInitLock = new(); + private static string? s_nativeAudioUnavailable; - private readonly List _sampleChunks = []; - private readonly Lock _sampleLock = new(); - private float _currentRmsLevel; - private int _disposed; - private int _isPreviewing; - private int _isRecording; - private long _lastLevelPostedTicksUtc; + private readonly Lock _captureLock = new(); + private readonly Func _defaultInputDeviceIndexProvider; + private readonly Action _ensurePortAudioInitialized; + private readonly IErrorLogService? _errorLog; + private readonly Func> _inputDeviceListProvider; - // Device enumeration seam. Production uses the PortAudio-backed enumerator; - // tests inject a fake so the follow-default selection policy and the - // migration-deferral state machine can be exercised without real hardware - // or a native PortAudio table. Instance-level (not static) so a test service - // is fully isolated; GetInputDevices() (the static view helper) uses the - // process-wide PortAudio enumerator. - private readonly IAudioDeviceEnumerator _deviceEnumerator; + // Reactive trigger for CheckForDefaultDeviceChange: detects OS default capture + // changes at runtime (pactl subscribe) and, debounced, calls back here. Optional + // so the buffer/selection paths unit-test without it; when null (or when pactl is + // absent) the service degrades to lazy re-resolve at the next recording start. + // Started/stopped as FollowSystemDefault toggles; see StartOrStopDeviceWatcher. + private readonly IDefaultDeviceChangeWatcher? _deviceWatcher; + private int _watcherStarted; - // The stable id of the device the live/last-created capture stream is bound to. + // The stable id of the device the live/last-opened capture stream is bound to. // Migration compares this against the freshly-resolved OS default to decide - // whether a default change requires a swap. + // whether a default change requires a swap. Guarded by _captureLock. private string? _activeDeviceId; // Set when a default-device migration was requested while a recording was in // flight; the swap is deferred (never tear down the live buffer) and applied - // on the next CheckForDefaultDeviceChange() once recording has stopped. Mirrors - // upstream's _preferredDeviceMigrationPending. + // from StopRecording once the WAV has been materialized. Guarded by _captureLock. private bool _preferredDeviceMigrationPending; + private bool _followSystemDefault; + private readonly Action _openInputStream; + private readonly List _sampleChunks = []; + 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; + private int _disposed; + private int _isPreviewing; + private int _isRecording; + private long _lastLevelPostedTicksUtc; - private readonly Lock _migrationLock = new(); - - // 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? _openStreamDeviceIndex; private int _sampleCount; + private int? _selectedDeviceIndex; private PaStream? _stream; - - // Serializes every capture-stream lifecycle transition (open / stop+dispose / - // migrate) AND the PortAudio Terminate()+Initialize() device-table refresh, so a - // watcher-thread migration can never race a UI-thread StartRecording/StartPreview/ - // StopRecording that is opening or disposing the native stream. Held only around - // the brief transition — never for the duration of a recording — so the - // never-interrupt-a-live-recording guarantee is preserved (migration still defers - // while IsRecording). Ordering: acquire _streamLock as the outermost lock; nested - // acquisition of s_paInitLock (inside RefreshPortAudioDeviceTable) is fine because - // the reverse order never occurs. - private readonly Lock _streamLock = new(); - private readonly IErrorLogService? _errorLog; - - // Reactive trigger for CheckForDefaultDeviceChange: detects OS default capture - // changes at runtime (pactl subscribe) and, debounced, calls back here. Optional - // so the buffer/selection paths unit-test without it; when null (or when pactl is - // absent) the service degrades to lazy re-resolve at the next recording start. - // Started/stopped as FollowSystemDefault toggles; see StartOrStopDeviceWatcher. - private readonly IDefaultDeviceChangeWatcher? _deviceWatcher; - private int _watcherStarted; - + private int _whisperModeEnabled; internal int CaptureSampleRate { get; private set; } = SampleRate; // PortAudio is initialized lazily via EnsurePortAudioInitialized, so @@ -91,10 +106,9 @@ public sealed class AudioRecordingService : IDisposable // errorLog is optional so the buffer-processing path can still be unit-tested // with a bare `new AudioRecordingService()`; DI supplies the real instance. - // deviceEnumerator is optional so production/DI gets the PortAudio-backed - // enumerator by default while tests can inject a fake device table. - // deviceWatcher is optional so tests exercise the migration state machine - // without a real pactl process; DI supplies the pactl-backed watcher. + // deviceEnumerator is optional so production/DI reads the PortAudio device table + // while tests can inject a fake one; deviceWatcher is optional so the migration + // state machine is exercised without a real pactl process. public AudioRecordingService( IErrorLogService? errorLog = null, IAudioDeviceEnumerator? deviceEnumerator = null, @@ -102,8 +116,56 @@ public AudioRecordingService( ) { _errorLog = errorLog; - _deviceEnumerator = deviceEnumerator ?? PortAudioDeviceEnumerator.Shared; _deviceWatcher = deviceWatcher; + _defaultInputDeviceIndexProvider = static () => PortAudio.DefaultInputDevice; + _ensurePortAudioInitialized = EnsurePortAudioInitialized; + _inputDeviceListProvider = + deviceEnumerator is null ? GetInputDevices : deviceEnumerator.GetDevices; + _openInputStream = OpenInputStream; + _stopAndDisposeInputStreamCore = StopAndDisposeInputStreamCore; + _terminatePortAudioOnDispose = true; + _wavMaterializationObserver = null; + } + + // Test seam: exercises the production device-selection and ownership state machines + // while replacing only PortAudio initialization and stream operations. + internal AudioRecordingService( + Action openInputStream, + Func defaultInputDeviceIndexProvider, + Action stopAndDisposeInputStream, + IErrorLogService? errorLog = null, + Action? wavMaterializationObserver = null + ) + : this( + static () => [], + openInputStream, + defaultInputDeviceIndexProvider, + stopAndDisposeInputStream, + errorLog, + wavMaterializationObserver + ) + { + } + + // 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, + Action? wavMaterializationObserver = null + ) + { + _errorLog = errorLog; + _defaultInputDeviceIndexProvider = defaultInputDeviceIndexProvider; + _ensurePortAudioInitialized = static () => { }; + _inputDeviceListProvider = inputDeviceListProvider; + _openInputStream = openInputStream; + _stopAndDisposeInputStreamCore = stopAndDisposeInputStream; + _terminatePortAudioOnDispose = false; + _wavMaterializationObserver = wavMaterializationObserver; } public bool IsRecording => Volatile.Read(ref _isRecording) == 1; @@ -111,7 +173,23 @@ public AudioRecordingService( public float CurrentRmsLevel => Volatile.Read(ref _currentRmsLevel); public bool HasSpeechEnergy => CurrentRmsLevel >= SpeechEnergyThreshold; - public int? SelectedDeviceIndex { get; set; } + public int? SelectedDeviceIndex + { + get + { + lock (_captureLock) + { + return _selectedDeviceIndex; + } + } + set + { + lock (_captureLock) + { + _selectedDeviceIndex = value; + } + } + } /// /// When true the service captures from the current OS default input device @@ -126,37 +204,28 @@ public AudioRecordingService( /// public bool FollowSystemDefault { - get => _followSystemDefault; - set + get { - _followSystemDefault = value; - StartOrStopDeviceWatcher(); + lock (_captureLock) + { + return _followSystemDefault; + } } - } - - private bool _followSystemDefault; - - public bool WhisperModeEnabled { get; set; } + set + { + lock (_captureLock) + { + _followSystemDefault = value; + } - internal Action? LiveFrameSink - { - get => _liveFrameSink; - set => _liveFrameSink = value; + StartOrStopDeviceWatcher(value); + } } public void Dispose() { - if (Interlocked.Exchange(ref _disposed, 1) == 1) - { - return; - } - - Volatile.Write(ref _isPreviewing, 0); - Volatile.Write(ref _isRecording, 0); - - // Stop the reactive default-device watcher first so no debounced callback can - // race a partially-disposed service (CheckForDefaultDeviceChange also guards on - // _disposed, but killing the child process here is the clean primary path). + // Stop the reactive watcher before taking the capture lock so no debounced + // callback can be mid-CheckForDefaultDeviceChange against a disposing service. try { _deviceWatcher?.Stop(); @@ -168,38 +237,94 @@ public void Dispose() ); } - StopAndDisposeInputStream(); + lock (_captureLock) + { + 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(); + } + UpdateLevel(0f); - TerminatePortAudioIfInitialized(); + if (_terminatePortAudioOnDispose) + { + TerminatePortAudioIfInitialized(); + } } - public static IReadOnlyList GetInputDevices() - { - return PortAudioDeviceEnumerator.Shared.GetDevices(); - } + /// + /// 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 void StartRecording() + public static IReadOnlyList GetInputDevices() { - if (IsRecording || Volatile.Read(ref _disposed) == 1) + // 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 { - return; + 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 []; } - lock (_sampleLock) + // Any reason recorded by an earlier failure is now stale; leaving it set would make + // the UI report audio as unavailable for the rest of the session. + Volatile.Write(ref s_nativeAudioUnavailable, null); + + var result = new List(); + for (var i = 0; i < PortAudio.DeviceCount; i++) { - _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. + try + { + var info = PortAudio.GetDeviceInfo(i); + if (info.maxInputChannels > 0) + { + result.Add( + new AudioInputDevice( + i, + info.name, + info.maxInputChannels, + i == PortAudio.DefaultInputDevice, + GetStableDeviceId(info.name, info.maxInputChannels) + ) + ); + } + } + catch + { + /* ignore broken devices */ + } } - // Open the stream AND flip into the recording state atomically under _streamLock - // so a watcher-thread migration can never observe an open stream that is not yet - // marked as recording and dispose it out from under the imminent capture. - lock (_streamLock) + return result; + } + + internal AudioCaptureSession? TryStartRecording(bool whisperModeEnabled) + { + lock (_captureLock) { + if (_activeCaptureSession is not null || Volatile.Read(ref _disposed) == 1) + { + return null; + } + try { if (!EnsureInputStreamStarted()) @@ -209,7 +334,7 @@ public void StartRecording() + "Check that an input device is connected and selected in Recorder settings.", ErrorCategory.Recording ); - return; + return null; } } catch (Exception ex) @@ -223,46 +348,97 @@ public void StartRecording() 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: captureSampleRate={CaptureSampleRate} Hz, target={SampleRate} Hz." + $"[AudioRecordingService] Recording started: session={session.DiagnosticId}, " + + $"captureSampleRate={CaptureSampleRate} Hz, target={SampleRate} Hz." ); + return session; + } + } - Volatile.Write(ref _isRecording, 1); + internal bool IsRecordingOwnedBy(AudioCaptureSession? session) + { + lock (_captureLock) + { + return session is not null && ReferenceEquals(_activeCaptureSession, session); } } - public byte[] StopRecording() + internal bool TrySetWhisperMode(AudioCaptureSession session, bool enabled) { - if (!IsRecording) + lock (_captureLock) { - return []; + if (!ReferenceEquals(_activeCaptureSession, session)) + { + return false; + } + + Volatile.Write(ref _whisperModeEnabled, enabled ? 1 : 0); + return true; + } + } + + internal bool TrySetLiveFrameSink(AudioCaptureSession session, Action? sink) + { + lock (_captureLock) + { + if (!ReferenceEquals(_activeCaptureSession, session)) + { + return false; + } + + Volatile.Write( + ref _liveFrameSink, + sink is null ? null : new LiveFrameSubscription(session, sink) + ); + return true; } + } - // Flip out of the recording state AND dispose the stream atomically under - // _streamLock so a watcher-thread migration can't observe IsRecording==false - // mid-teardown and dispose the same live capture stream concurrently. - lock (_streamLock) + internal byte[] StopRecording(AudioCaptureSession session) + { + byte[] wav; + bool migrationPending; + lock (_captureLock) { + if (!ReferenceEquals(_activeCaptureSession, session)) + { + return []; + } + + Volatile.Write(ref _activeCaptureSession, null); + Volatile.Write(ref _liveFrameSink, null); Volatile.Write(ref _isRecording, 0); if (!IsPreviewing) { StopAndDisposeInputStream(); } - } - - var wav = BuildWavFromRecordedAudio(); - // A default-device change may have been deferred while this recording was - // in flight (see CheckForDefaultDeviceChange). The live buffer has now been - // finalized above, so it is safe to migrate. Re-check to complete the swap. - bool pending; - lock (_migrationLock) - { - pending = _preferredDeviceMigrationPending; + // Keep the capture lock through materialization. A new owner cannot + // clear or reuse the sample list until this WAV is complete. + wav = BuildWavFromRecordedAudio(SnapshotRecordedAudio()); + migrationPending = _preferredDeviceMigrationPending; } - if (pending) + // A default-device change may have been deferred while this recording was in + // flight (see CheckForDefaultDeviceChange). The buffer is now finalized and the + // lock released, so it is safe to complete the swap. + if (migrationPending) { CheckForDefaultDeviceChange(); } @@ -270,9 +446,12 @@ public byte[] StopRecording() return wav; } - public async Task StopRecordingAsync(CancellationToken cancellationToken = default) + internal async Task StopRecordingAsync( + AudioCaptureSession session, + CancellationToken cancellationToken = default + ) { - if (!IsRecording) + if (!IsRecordingOwnedBy(session)) { return []; } @@ -286,38 +465,38 @@ 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(); + var snapshot = SnapshotRecordedAudio(); + return snapshot.SampleCount == 0 ? null : BuildWavFromRecordedAudio(snapshot); + } } public bool StartPreview() { - if (Volatile.Read(ref _disposed) == 1 || IsRecording || IsPreviewing) + lock (_captureLock) { - return false; - } + if ( + Volatile.Read(ref _disposed) == 1 + || _activeCaptureSession is not null + || IsPreviewing + ) + { + return false; + } - // Open + flip into preview atomically under _streamLock (same rationale as - // StartRecording) so a migration can't dispose the freshly opened stream. - lock (_streamLock) - { try { if (!EnsureInputStreamStarted()) @@ -336,7 +515,7 @@ public bool StartPreview() ErrorCategory.Recording ); Volatile.Write(ref _isPreviewing, 0); - if (!IsRecording) + if (_activeCaptureSession is null) { StopAndDisposeInputStream(); } @@ -348,15 +527,15 @@ public bool StartPreview() public void StopPreview() { - if (!IsPreviewing) + lock (_captureLock) { - return; - } + if (!IsPreviewing) + { + return; + } - lock (_streamLock) - { Volatile.Write(ref _isPreviewing, 0); - if (!IsRecording) + if (_activeCaptureSession is null) { StopAndDisposeInputStream(); } @@ -365,55 +544,31 @@ public void StopPreview() UpdateLevel(0f); } - /// - /// Resolve the microphone the service should capture from, given a saved - /// selection. Resolution order: - /// - /// - /// The "follow system default" sentinel — always resolves to the - /// current OS default (or first device if the default is unknown), - /// so a user who once pinned a device can opt back into auto-follow. - /// - /// An explicit device matched by stable id. - /// An explicit device matched by legacy index (id churn fallback). - /// - /// Automatic (nothing configured): the system default endpoint first, - /// then the first available device. - /// - /// - /// public AudioInputDevice? ResolveConfiguredDevice(int? preferredIndex, string? preferredDeviceId) { - var devices = _deviceEnumerator.GetDevices(); + var devices = _inputDeviceListProvider(); - // Follow-default sentinel: ignore any pinned index/id and take the current default. + // Follow-default sentinel: ignore any pinned index/id and take the current + // default, so a user who once pinned a device can opt back into auto-follow. if (IsFollowSystemDefault(preferredDeviceId)) { return ResolveSystemDefault(devices); } + // ReSharper disable once InvertIf -- keeps the three resolution cases as a flat + // ladder; inverting pulls the last case's return into the middle branch. if (!string.IsNullOrWhiteSpace(preferredDeviceId)) { - var byId = devices.FirstOrDefault(d => d.PersistentId == preferredDeviceId); - if (byId is not null) - { - return byId; - } - } - - // Parallel to the by-id guard above; inverting would duplicate the - // ResolveSystemDefault fallback and break this symmetric resolve chain. - // ReSharper disable once InvertIf - if (preferredIndex.HasValue) - { - var byIndex = devices.FirstOrDefault(d => d.Index == preferredIndex.Value); - if (byIndex is not null) - { - return byIndex; - } + var matches = devices + .Where(d => string.Equals(d.PersistentId, preferredDeviceId, StringComparison.Ordinal)) + .Take(2) + .ToArray(); + return matches.Length == 1 ? matches[0] : null; } - return ResolveSystemDefault(devices); + // A pinned index that survived the id lookup above is a device that no longer exists; + // fall back to the system default only when nothing was pinned at all. + return preferredIndex.HasValue ? null : ResolveSystemDefault(devices); } internal static bool IsFollowSystemDefault(string? deviceId) => @@ -431,13 +586,7 @@ internal static bool IsFollowSystemDefault(string? deviceId) => /// capture then follows the current OS default. /// public static AudioInputDevice CreateFollowSystemDefaultOption(string displayName) => - new( - -1, - displayName, - 0, - false, - AppSettings.FollowSystemDefaultMicrophoneId - ); + new(-1, displayName, 0, false, AppSettings.FollowSystemDefaultMicrophoneId); private static AudioInputDevice? ResolveSystemDefault(IReadOnlyList devices) { @@ -493,8 +642,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, @@ -513,6 +663,42 @@ 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; + // 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]; + 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; @@ -526,7 +712,57 @@ int targetSampleRate return output; } - internal StreamCallbackResult ProcessAudioBufferForTest(float[] frame, bool copySamples) + 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); try @@ -534,7 +770,7 @@ internal StreamCallbackResult ProcessAudioBufferForTest(float[] frame, bool copy return ProcessAudioBuffer( handle.AddrOfPinnedObject(), (uint)frame.Length, - copySamples + Volatile.Read(ref _activeCaptureSession) ); } finally @@ -560,10 +796,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) { @@ -573,29 +817,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) { @@ -605,7 +862,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; @@ -661,29 +918,25 @@ private bool ShouldPostLevelUpdate(long nowTicks) private int? ResolveSelectedDeviceIndex() { - // In follow-default mode always re-resolve the current OS default from the - // enumerator and remember its stable id as the preferred device so a later - // default change can be detected. NOTE: the enumerator reads PortAudio's CACHED - // table (it only ensures init, it does not cycle the library), so callers that - // need the freshest default must refresh the table first — EnsureInputStreamStarted - // and CheckForDefaultDeviceChange both call RefreshPortAudioDeviceTable ahead of - // this. Also honors an explicit pin by index. - if (FollowSystemDefault) - { - var devices = _deviceEnumerator.GetDevices(); - var preferred = ResolveSystemDefault(devices); + // Called with _captureLock held. Read the selected index once so + // resolve/compare/rebuild sees one consistent device snapshot. + + // In follow-default mode re-resolve the current OS default from the device + // list and remember its stable id, so a later default change is detectable. + // NOTE: the provider reads PortAudio's CACHED table, so callers needing the + // freshest default refresh it first (EnsureInputStreamStarted and + // CheckForDefaultDeviceChange both call RefreshPortAudioDeviceTable ahead of this). + if (_followSystemDefault) + { + var preferred = ResolveSystemDefault(_inputDeviceListProvider()); if (preferred is not null) { _activeDeviceId = preferred.PersistentId; return preferred.Index; } } - else if (SelectedDeviceIndex.HasValue) - { - _activeDeviceId = TryGetStableDeviceId(SelectedDeviceIndex.Value); - } - var deviceIndex = SelectedDeviceIndex ?? PortAudio.DefaultInputDevice; + var deviceIndex = _selectedDeviceIndex ?? _defaultInputDeviceIndexProvider(); if (deviceIndex != PortAudio.NoDevice) { _activeDeviceId ??= TryGetStableDeviceId(deviceIndex); @@ -694,318 +947,161 @@ private bool ShouldPostLevelUpdate(long nowTicks) return null; } - private static string? TryGetStableDeviceId(int deviceIndex) + private bool EnsureInputStreamStarted() { - try + _ensurePortAudioInitialized(); + + // In follow-default mode, cycle PortAudio's cached device table BEFORE resolving + // so recording starts on the CURRENT OS default rather than whatever default was + // captured at the last Pa_Initialize. Without this a default change that happened + // while the app was idle (no watcher event, or pactl unavailable) would leave a new + // recording bound to the STALE default. Safe here: _captureLock is held and the + // refresh no-ops while any stream is open or a recording is live. + if (_followSystemDefault) { - if (deviceIndex < 0 || deviceIndex >= PortAudio.DeviceCount) - { - return null; - } - - var info = PortAudio.GetDeviceInfo(deviceIndex); - return GetStableDeviceId(info.name, info.maxInputChannels); + RefreshPortAudioDeviceTable(); } - catch + + // Resolve before considering reuse: a preview stream is reusable only + // when it was opened for this exact requested/default device index. + var deviceIndex = ResolveSelectedDeviceIndex(); + if (deviceIndex is not null && _openStreamDeviceIndex == deviceIndex) { - return null; + return true; } - } - private bool EnsureInputStreamStarted() - { - // _streamLock serializes the native open against a concurrent watcher-thread - // device-table refresh / migration so PortAudio is never re-initialized while - // Pa_OpenStream/Pa_StartStream is running on this thread. - lock (_streamLock) + var replacingPreviewStream = IsPreviewing && _openStreamDeviceIndex is not null; + try { - if (_stream is not null) - { - return true; - } - - EnsurePortAudioInitialized(); - - // In follow-default mode, cycle PortAudio's cached device table BEFORE - // resolving the device so recording starts on the CURRENT OS default rather - // than whatever default was captured at the last Pa_Initialize. Without this - // a default change that happened while the app was idle (no watcher event, or - // pactl unavailable) would leave a new recording bound to the STALE default. - // Safe here: we hold _streamLock and _stream is null (checked above) and - // IsRecording is still false (StartRecording/StartPreview flip it only AFTER - // this returns), so RefreshPortAudioDeviceTable does not skip and never - // re-inits under a live stream. In pinned mode the table is left untouched. - if (FollowSystemDefault) - { - RefreshPortAudioDeviceTable(); - } + StopAndDisposeInputStream(); - var deviceIndex = ResolveSelectedDeviceIndex(); if (deviceIndex is null) { + if (replacingPreviewStream) + { + Volatile.Write(ref _isPreviewing, 0); + } + return false; } - // _captureSampleRate is committed only after Start() succeeds — the - // PaStream constructor accepts rates that the device rejects at start time. - _stream = CreateInputStream(deviceIndex.Value, InputAudioCallback); + // CaptureSampleRate is committed only after Start() succeeds; publish + // the owning device only after the opener returns successfully. + _openInputStream(deviceIndex.Value); + _openStreamDeviceIndex = deviceIndex.Value; return true; } - } - - private void StopAndDisposeInputStream() - { - lock (_streamLock) + catch { - try - { - _stream?.Stop(); - } - catch + // The old preview no longer owns a live stream. Do not let a failed + // replacement leave the service logically previewing disposed input. + if (replacingPreviewStream) { - /* best effort */ + Volatile.Write(ref _isPreviewing, 0); } - _stream?.Dispose(); - _stream = null; + throw; } } - internal static string GetStableDeviceId(string deviceName, int maxInputChannels) - { - return $"{deviceName}|{maxInputChannels}"; - } - - // Init wrapper for PortAudioDeviceEnumerator (which lives outside this class but - // must share the ref-counted global init). Keeps the s_paInitLock/s_paInitCount - // discipline in one place. - internal static void EnsurePortAudioInitializedForEnumerator() - { - EnsurePortAudioInitialized(); - } - - private PaStream CreateInputStream(int deviceIndex, PaStream.Callback callback) + private void StopAndDisposeInputStream() { - var inputInfo = PortAudio.GetDeviceInfo(deviceIndex); - var candidateRates = CandidateSampleRates(inputInfo.defaultSampleRate); - Exception? lastError = null; - - foreach (var sampleRate in candidateRates) + if (_openStreamDeviceIndex is null && _stream is null) { - PaStream? stream = null; - try - { - stream = CreateInputStream(deviceIndex, inputInfo, sampleRate, callback); - stream.Start(); - CaptureSampleRate = sampleRate; - Trace.WriteLine( - $"[AudioRecordingService] Opened input stream: device={deviceIndex} ('{inputInfo.name}'), " - + $"negotiatedRate={sampleRate} Hz, deviceDefaultRate={inputInfo.defaultSampleRate} Hz, " - + $"resampleToTarget={(sampleRate != SampleRate ? "yes" : "no")}." - ); - - return stream; - } - catch (Exception ex) - { - lastError = ex; - Trace.WriteLine( - $"[AudioRecordingService] Failed to open input stream at {sampleRate} Hz: {ex.Message}" - ); - try { stream?.Dispose(); } - catch - { - /* best effort */ - } - } + return; } - throw lastError - ?? new InvalidOperationException( - "No compatible input sample rate was accepted by PortAudio." - ); + try + { + _stopAndDisposeInputStreamCore(); + } + finally + { + // Keep the physical stream and its concrete device owner synchronized, + // even when best-effort native teardown throws. + _stream = null; + _openStreamDeviceIndex = null; + } } - private static PaStream CreateInputStream( - int deviceIndex, - DeviceInfo inputInfo, - int sampleRate, - PaStream.Callback callback - ) + private void StopAndDisposeInputStreamCore() { - var inputParams = new StreamParameters + try { - device = deviceIndex, - channelCount = Channels, - sampleFormat = SampleFormat.Float32, - suggestedLatency = inputInfo.defaultLowInputLatency, - hostApiSpecificStreamInfo = IntPtr.Zero - }; + _stream?.Stop(); + } + catch + { + /* best effort */ + } - return new PaStream( - inputParams, - null, - sampleRate, - FramesPerBuffer, - StreamFlags.ClipOff, - callback, - IntPtr.Zero - ); + _stream?.Dispose(); } - private static List CandidateSampleRates(double defaultSampleRate) + private void OpenInputStream(int deviceIndex) { - // Try the device's native rate first to avoid PortAudio internal resampling; - // fall through common rates in descending order. Captured audio is always - // resampled to 16 kHz in software before transcription. - var rates = new List(); - AddRate((int)Math.Round(defaultSampleRate)); - AddRate(48000); - AddRate(44100); - AddRate(32000); - AddRate(24000); - AddRate(SampleRate); - return rates; - - void AddRate(int rate) - { - if (rate > 0 && !rates.Contains(rate)) - { - rates.Add(rate); - } - } + // The constructor can accept rates that the device rejects at Start(), + // so assign the physical stream only after CreateInputStream has started it. + _stream = CreateInputStream(deviceIndex, InputAudioCallback); } - private static byte[] FloatSamplesToWav(float[] samples, int sampleRate) + private static string GetStableDeviceId(string deviceName, int maxInputChannels) { - return WriteWav( - sampleRate, - samples.Length, - writer => - { - foreach (var sample in samples) - { - writer.Write(ToPcm16(sample)); - } - } - ); + return $"{deviceName}|{maxInputChannels}"; } - private byte[] BuildWavFromRecordedAudio() + private static string? TryGetStableDeviceId(int deviceIndex) { - lock (_sampleLock) + try { - var samples = new float[_sampleCount]; - var offset = 0; - foreach (var chunk in _sampleChunks) + if (deviceIndex < 0 || deviceIndex >= PortAudio.DeviceCount) { - Array.Copy(chunk, 0, samples, offset, chunk.Length); - offset += chunk.Length; + return null; } - 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 FloatSamplesToWav(outputSamples, SampleRate); + var info = PortAudio.GetDeviceInfo(deviceIndex); + return GetStableDeviceId(info.name, info.maxInputChannels); + } + catch + { + return null; } - } - - private static byte[] WriteWav( - int sampleRate, - int sampleCount, - Action writeSamples - ) - { - const short bitsPerSample = 16; - const short channels = 1; - var byteRate = sampleRate * channels * bitsPerSample / 8; - const int blockAlign = channels * bitsPerSample / 8; - var dataSize = sampleCount * 2; - - using var ms = new MemoryStream(); - using var w = new BinaryWriter(ms); - w.Write("RIFF"u8); - w.Write(36 + dataSize); - w.Write("WAVE"u8); - w.Write("fmt "u8); - w.Write(16); // fmt chunk size - w.Write((short)1); // PCM - w.Write(channels); - w.Write(sampleRate); - w.Write(byteRate); - w.Write((short)blockAlign); - w.Write(bitsPerSample); - w.Write("data"u8); - w.Write(dataSize); - - writeSamples(w); - - return ms.ToArray(); } /// - /// Re-resolve the current OS default input device and, in follow-default - /// mode, migrate the capture to it when it has changed. IN-FLIGHT-SAFE: the - /// live capture stream is NEVER torn down while a recording is in progress — - /// the migration is deferred (mirroring upstream's - /// _preferredDeviceMigrationPending) and re-applied from - /// once the buffer has been finalized. + /// Re-resolve the current OS default input device and, in follow-default mode, + /// migrate the capture to it when it has changed. IN-FLIGHT-SAFE: the live capture + /// stream is NEVER torn down while a recording is in progress — the migration is + /// deferred and replayed from once the WAV has been + /// materialized. /// /// - /// Migration decisions are made on the stable PersistentId - /// ("name|channels"), not the PortAudio index, because PipeWire/PulseAudio - /// reorder and re-index devices freely; only the name-derived id is stable - /// across a reconnect. - /// - /// This is the unit-testable decision entry point. At runtime it is TRIGGERED - /// by the injected (a debounced - /// `pactl subscribe` change on the server/source), which calls it from a - /// background thread. It degrades safely when no watcher is present (or pactl - /// is absent): nothing calls it, so behavior falls back to lazy re-resolve. - /// - /// - /// THREAD-SAFETY: the whole refresh→resolve→migrate sequence runs under - /// _streamLock so it can never race a UI-thread start/stop that is - /// opening or disposing the native stream, and it bails (deferring) while a - /// recording is in flight so a live capture is never interrupted. - /// + /// Decisions are made on the stable PersistentId ("name|channels"), not the + /// PortAudio index, because PipeWire/PulseAudio reorder and re-index devices freely. + /// At runtime this is triggered from a background thread by the injected + /// ; with no watcher (or no pactl) nothing + /// calls it and behavior falls back to lazy re-resolve at the next capture start. + /// The whole refresh→resolve→migrate sequence runs under _captureLock, so it + /// can never race a start/stop that is opening or disposing the stream. /// public void CheckForDefaultDeviceChange() { - if (Volatile.Read(ref _disposed) == 1 || !FollowSystemDefault) - { - return; - } - - // Hold _streamLock across the whole refresh→resolve→migrate sequence so a - // concurrent UI-thread StartRecording/StartPreview/StopRecording cannot open or - // dispose the native stream while this watcher-thread callback re-inits PortAudio - // and swaps the device. The lock is released well before any recording runs (the - // deferral path below bails while IsRecording), so a live recording is never - // blocked or interrupted. Re-check the guards under the lock in case the state - // changed between the outer early-out and acquiring it. - lock (_streamLock) + lock (_captureLock) { - if (Volatile.Read(ref _disposed) == 1 || !FollowSystemDefault) + if (Volatile.Read(ref _disposed) == 1 || !_followSystemDefault) { return; } - // Refresh PortAudio's cached device table so the enumerator reports the NEW - // default. No-ops (returns false) while a stream is live / recording (checked - // under the lock) — in which case the table below is STALE and still reports - // the OLD default. We must not treat that stale reading as authoritative when - // deciding to clear a pending migration. + // Refresh PortAudio's cached table so the provider reports the NEW default. + // Returns false while a stream is live / recording — the reading is then STALE + // and must not be treated as authoritative when clearing a pending migration. var deviceTableFresh = RefreshPortAudioDeviceTable(); AudioInputDevice? preferred; try { - preferred = ResolveSystemDefault(_deviceEnumerator.GetDevices()); + preferred = ResolveSystemDefault(_inputDeviceListProvider()); } catch (Exception ex) { @@ -1017,72 +1113,58 @@ public void CheckForDefaultDeviceChange() if (preferred is null) { - // No devices right now (all unplugged); keep whatever we have and retry - // on the next check rather than tearing down a possibly-live stream. + // No devices right now (all unplugged); keep what we have and retry on the + // next check rather than tearing down a possibly-live stream. return; } - lock (_migrationLock) + if (string.Equals(_activeDeviceId, preferred.PersistentId, StringComparison.Ordinal)) { - // Already on the preferred device — nothing to migrate. - if ( - string.Equals(_activeDeviceId, preferred.PersistentId, StringComparison.Ordinal) - ) + // Only clear a pending defer when this reading is TRUSTWORTHY. If the refresh + // was skipped, the table still reports the OLD default — which of course equals + // the device we are recording on — so "already on default" is an artifact of the + // stale table, not proof the pending migration is moot. Clearing it would drop a + // real deferred migration that StopRecording is relying on replaying. + if (deviceTableFresh) { - // Only clear a pending defer when this reading is TRUSTWORTHY. If the - // device-table refresh was skipped (a stream is live / recording), the - // enumerator still reports the OLD default, which of course equals the - // device we are recording on — so "already on default" here is an - // artifact of the stale table, NOT proof the pending migration is moot. - // Clearing it would silently drop a real deferred migration that - // StopRecording is relying on replaying. Leave it set; the next check - // (after recording stops, table refreshable) re-resolves the true - // default and completes or clears the migration correctly. - if (deviceTableFresh) - { - _preferredDeviceMigrationPending = false; - } - - return; + _preferredDeviceMigrationPending = false; } - // Never tear down an in-flight recording to migrate. Defer and let - // StopRecording re-invoke this method once the buffer is finalized. - // Checked under _streamLock so it can't race a StartRecording that is - // mid-transition (about to flip _isRecording / assign _stream). - if (IsRecording) - { - _preferredDeviceMigrationPending = true; - Trace.WriteLine( - "[AudioRecordingService] Default device changed while recording; migration deferred." - ); - return; - } + return; + } - _preferredDeviceMigrationPending = false; + // Never tear down an in-flight recording to migrate. Defer and let StopRecording + // re-invoke this once the buffer is finalized. + if (Volatile.Read(ref _isRecording) == 1 || _activeCaptureSession is not null) + { + _preferredDeviceMigrationPending = true; + Trace.WriteLine( + "[AudioRecordingService] Default device changed while recording; migration deferred." + ); + return; } + _preferredDeviceMigrationPending = false; MigrateActiveCaptureToDevice(preferred); } } - // Swap the live capture stream (preview only; recording is guaranteed stopped by - // the caller) to the preferred device. Safe to no-op if no stream is open — the - // next EnsureInputStreamStarted picks up the new default via ResolveSelectedDeviceIndex. + // Swap the capture to the preferred device. Called with _captureLock held and with a + // recording guaranteed not to be in flight, so only an idle service or a live preview + // is affected. A no-op when no stream is open — the next EnsureInputStreamStarted picks + // up the new default via ResolveSelectedDeviceIndex. private void MigrateActiveCaptureToDevice(AudioInputDevice preferred) { var wasPreviewing = IsPreviewing; - SelectedDeviceIndex = preferred.Index; + _selectedDeviceIndex = preferred.Index; _activeDeviceId = preferred.PersistentId; - if (_stream is null) + if (_openStreamDeviceIndex is null && _stream is null) { - // No open stream (idle): the new default is applied lazily on the next - // StartRecording/StartPreview, so there is nothing to do now. + // Idle: the new default is applied lazily on the next capture start. return; } - // Tear down and reopen only when NOT recording (caller guarantees this). StopAndDisposeInputStream(); if (!wasPreviewing) @@ -1092,9 +1174,7 @@ private void MigrateActiveCaptureToDevice(AudioInputDevice preferred) try { - // Compact "if reopen failed, handle it" guard (one level of nesting); inverting - // would push an early return into the try for no readability gain. - // ReSharper disable once InvertIf + // ReSharper disable once InvertIf -- compact "if reopen failed, handle it" guard. if (!EnsureInputStreamStarted()) { Trace.WriteLine( @@ -1113,67 +1193,40 @@ private void MigrateActiveCaptureToDevice(AudioInputDevice preferred) } } - // Refresh PortAudio's device table (which is snapshotted at Pa_Initialize and - // does NOT observe OS default changes until re-initialized) by cycling - // Terminate()+Initialize() under the global init lock. CRITICAL: this is a - // no-op while a stream is live — re-initializing PortAudio would invalidate the - // native stream handle and could crash the realtime callback. Callers must have - // already ensured no recording is in flight; here we additionally bail if any - // stream (e.g. a preview) is still open, deferring the refresh implicitly. + // Refresh PortAudio's device table (snapshotted at Pa_Initialize; it does NOT observe + // OS default changes until re-initialized) by cycling Terminate()+Initialize(). CRITICAL: + // a no-op while any stream is live — re-initializing would invalidate the native handle + // and could crash the realtime callback. // - // Returns TRUE when the caller can trust the device table to reflect the current OS - // default afterward — i.e. the table was cycled, OR PortAudio was not yet initialized - // (in which case the next enumeration initializes it and reads a fresh table). Returns - // FALSE ONLY when the refresh was SKIPPED because a stream is live / recording: the - // table is then STALE, so callers (see CheckForDefaultDeviceChange) must not treat a - // "still on the same default" reading as authoritative and must not clear a pending - // migration off it. + // Returns TRUE when the caller can trust the table afterward (cycled, or PortAudio was + // never initialized so the next enumeration reads a fresh table). Returns FALSE ONLY when + // the refresh was SKIPPED for a live stream, leaving the table stale. private bool RefreshPortAudioDeviceTable() { - // Never re-init the native library out from under a live stream. Skipped => - // table is stale. - if (_stream is not null || IsRecording) + // Called with _captureLock held, so the stream state cannot change underneath. + if (_stream is not null || _openStreamDeviceIndex is not null || IsRecording) { return false; } lock (s_paInitLock) { - // Re-check under the lock: a concurrent StartRecording/StartPreview may - // have opened a stream between the outer check and acquiring the lock. - if (_stream is not null || IsRecording) - { - return false; - } - - // Delegate the actual Terminate()+Initialize() cycle (and the s_paInitCount - // mutation it implies) to the static lifetime helper, so the process-global - // init counter is only ever written by static methods — matching - // EnsurePortAudioInitialized/TerminatePortAudioIfInitialized rather than being - // poked directly from this instance method. + // Delegated to a static helper so the process-global init counter is only ever + // written by the static lifetime methods, matching EnsurePortAudioInitialized. CyclePortAudioDeviceTableLocked(); } - // Reached only when the refresh was attempted (not skipped for a live stream): - // Terminate()+Initialize() cycled the table, so the reading is fresh. Even on the - // best-effort failure path the library was re-initialized against the current OS - // state, so the table is not stale in the sense that matters for migration. return true; } - // Terminate()+Initialize() cycle that refreshes PortAudio's device-table snapshot - // (captured at Pa_Initialize; it does not otherwise observe OS default changes). - // Kept static and s_paInitLock-guarded so the process-global s_paInitCount is only - // ever mutated by the static lifetime helpers, never written directly from an instance - // method. CONTRACT: the caller MUST already hold s_paInitLock and have verified no - // stream is live (see RefreshPortAudioDeviceTable). + // Terminate()+Initialize() cycle that refreshes PortAudio's device-table snapshot. + // CONTRACT: the caller MUST hold s_paInitLock and have verified no stream is live. private static void CyclePortAudioDeviceTableLocked() { if (s_paInitCount <= 0) { - // Not initialized yet; the next EnsurePortAudioInitialized (called by the - // enumerator) will read a fresh table anyway, so there is nothing cached - // to refresh and the resulting reading is NOT stale. + // Not initialized yet; the next EnsurePortAudioInitialized reads a fresh table + // anyway, so there is nothing cached to refresh and the reading is NOT stale. return; } @@ -1181,10 +1234,9 @@ private static void CyclePortAudioDeviceTableLocked() { PortAudio.Terminate(); - // From here PortAudio is terminated: s_paInitCount must NOT stay positive - // unless a matching Initialize() succeeds, otherwise the next - // EnsurePortAudioInitialized() would see a positive count and skip the - // re-init, leaving the library terminated-but-"initialized" (unusable). + // From here PortAudio is terminated: the count must not stay positive unless a + // matching Initialize() succeeds, or the next EnsurePortAudioInitialized would + // skip the re-init and leave the library terminated-but-"initialized". s_paInitCount = 0; PortAudio.Initialize(); @@ -1192,11 +1244,6 @@ private static void CyclePortAudioDeviceTableLocked() } catch (Exception ex) { - // Best-effort refresh: if the cycle fails, fall back to the stale table - // and try to restore a usable init state. s_paInitCount now reflects - // actual PortAudio state: 0 if Terminate() ran but Initialize() has not - // yet succeeded (so a later EnsurePortAudioInitialized() recovers), or - // still 1 if Terminate() itself threw before changing state. Trace.WriteLine( $"[AudioRecordingService] PortAudio device-table refresh failed: {ex.Message}" ); @@ -1207,43 +1254,24 @@ private static void CyclePortAudioDeviceTableLocked() } catch { - // Leave s_paInitCount as set above (0 after a successful Terminate); - // a later EnsurePortAudioInitialized() then retries the init. + // Leave the count as set above (0 after a successful Terminate) so a later + // EnsurePortAudioInitialized retries the init. } } } - // ======================= RUNTIME DEFAULT-DEVICE WATCHER ======================= - // The reactive trigger for CheckForDefaultDeviceChange is now wired: the injected - // IDefaultDeviceChangeWatcher (production: PactlDefaultDeviceWatcher, running - // `pactl subscribe`) detects OS default capture-device changes at runtime, - // debounces a burst into one re-resolve, and calls CheckForDefaultDeviceChange - // from a background thread (never the PortAudio realtime thread). - // - // - The watcher is started only while FollowSystemDefault is active and stopped - // when it is turned off (see the FollowSystemDefault setter / this method), so - // no child process runs when the user has pinned a specific microphone. - // - GRACEFUL FALLBACK: if pactl is unavailable (or _deviceWatcher is null in a - // test), the watcher never starts and behavior degrades to the existing lazy - // re-resolve at the next StartRecording/StartPreview. Starting is best-effort - // and never throws. - // - The watcher NEVER tears down a live _stream: it only calls - // CheckForDefaultDeviceChange, which already defers migration while recording - // (and RefreshPortAudioDeviceTable no-ops while a stream is open), so the - // mid-recording safety is preserved. - // // Start/stop is idempotent via _watcherStarted so repeated FollowSystemDefault // assignments (App bootstrap + ViewModel selection) don't spawn duplicate processes. - private void StartOrStopDeviceWatcher() + // Never throws: a watcher that cannot start degrades to lazy re-resolve. + private void StartOrStopDeviceWatcher(bool follow) { if (_deviceWatcher is null || Volatile.Read(ref _disposed) == 1) { return; } - if (_followSystemDefault) + if (follow) { - // Idempotent start: only the first transition into follow mode launches it. if (Interlocked.Exchange(ref _watcherStarted, 1) == 1) { return; @@ -1255,86 +1283,264 @@ private void StartOrStopDeviceWatcher() } catch (Exception ex) { - // Never let a watcher launch failure break follow-default mode; the - // lazy re-resolve path still applies. Volatile.Write(ref _watcherStarted, 0); Trace.WriteLine( $"[AudioRecordingService] Default-device watcher start failed: {ex.Message}" ); } + + return; } - else + + if (Interlocked.Exchange(ref _watcherStarted, 0) == 0) { - if (Interlocked.Exchange(ref _watcherStarted, 0) == 0) - { - return; - } + return; + } - try - { - _deviceWatcher.Stop(); - } - catch (Exception ex) - { - Trace.WriteLine( - $"[AudioRecordingService] Default-device watcher stop failed: {ex.Message}" - ); - } + try + { + _deviceWatcher.Stop(); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[AudioRecordingService] Default-device watcher stop failed: {ex.Message}" + ); } } - // ============================================================================= - // ---- Test seams ------------------------------------------------------- - // The migration state machine (CheckForDefaultDeviceChange) is unit-tested - // through the injected IAudioDeviceEnumerator with NO real PortAudio stream: + // ---- Test seams for the migration state machine ----------------------- + // Exercised through the injected device enumerator with no real PortAudio stream: // _stream stays null (so MigrateActiveCaptureToDevice only updates the target // index/id) and RefreshPortAudioDeviceTable no-ops (PortAudio uninitialized). - // These seams let a test seed the "currently active" device and toggle the - // in-flight-recording flag without opening a native stream. - // _activeDeviceId is production capture state (written by ResolveSelectedDeviceIndex, - // MigrateActiveCaptureToDevice, etc.); this is only a read-only TEST seam over it. - // Merging into an auto-property would route production writes through a ...ForTest name. - // ReSharper disable once ConvertToAutoPropertyWithPrivateSetter - internal string? ActiveDeviceIdForTest => _activeDeviceId; + internal string? ActiveDeviceIdForTest + { + get + { + lock (_captureLock) + { + return _activeDeviceId; + } + } + } internal bool MigrationPendingForTest { get { - lock (_migrationLock) + lock (_captureLock) { return _preferredDeviceMigrationPending; } } } - // Seed the device the (notional) capture is currently bound to, as if a - // stream had been opened on it. Test-only. + // Seed the device the (notional) capture is bound to, as if a stream had been opened. internal void SetActiveDeviceIdForTest(string? deviceId, int? deviceIndex) { - _activeDeviceId = deviceId; - SelectedDeviceIndex = deviceIndex; + lock (_captureLock) + { + _activeDeviceId = deviceId; + _selectedDeviceIndex = deviceIndex; + } } - // Simulate an in-flight recording without a native stream, so the deferral - // path in CheckForDefaultDeviceChange can be exercised. Test-only. + // Simulate an in-flight recording without a native stream, so the deferral path + // in CheckForDefaultDeviceChange can be exercised. internal void SetRecordingForTest(bool recording) { Volatile.Write(ref _isRecording, recording ? 1 : 0); } - // Seed the deferred-migration flag directly, so the "pending survives a stale/ - // skipped device-table refresh" path in CheckForDefaultDeviceChange can be - // exercised without reconstructing a full defer→stale-recheck sequence. Test-only. + // Seed the deferred-migration flag so the "pending survives a stale/skipped device-table + // refresh" path can be exercised without reconstructing a full defer→recheck sequence. internal void SetMigrationPendingForTest(bool pending) { - lock (_migrationLock) + lock (_captureLock) { _preferredDeviceMigrationPending = pending; } } + private PaStream CreateInputStream(int deviceIndex, PaStream.Callback callback) + { + var inputInfo = PortAudio.GetDeviceInfo(deviceIndex); + var candidateRates = CandidateSampleRates(inputInfo.defaultSampleRate); + Exception? lastError = null; + + foreach (var sampleRate in candidateRates) + { + PaStream? stream = null; + try + { + stream = CreateInputStream(deviceIndex, inputInfo, sampleRate, callback); + stream.Start(); + CaptureSampleRate = sampleRate; + Trace.WriteLine( + $"[AudioRecordingService] Opened input stream: device={deviceIndex} ('{inputInfo.name}'), " + + $"negotiatedRate={sampleRate} Hz, deviceDefaultRate={inputInfo.defaultSampleRate} Hz, " + + $"resampleToTarget={(sampleRate != SampleRate ? "yes" : "no")}." + ); + + return stream; + } + catch (Exception ex) + { + lastError = ex; + Trace.WriteLine( + $"[AudioRecordingService] Failed to open input stream at {sampleRate} Hz: {ex.Message}" + ); + try { stream?.Dispose(); } + catch + { + /* best effort */ + } + } + } + + throw lastError + ?? new InvalidOperationException( + "No compatible input sample rate was accepted by PortAudio." + ); + } + + private static PaStream CreateInputStream( + int deviceIndex, + DeviceInfo inputInfo, + int sampleRate, + PaStream.Callback callback + ) + { + var inputParams = new StreamParameters + { + device = deviceIndex, + channelCount = Channels, + sampleFormat = SampleFormat.Float32, + suggestedLatency = inputInfo.defaultLowInputLatency, + hostApiSpecificStreamInfo = IntPtr.Zero, + }; + + return new PaStream( + inputParams, + null, + sampleRate, + FramesPerBuffer, + StreamFlags.ClipOff, + callback, + IntPtr.Zero + ); + } + + private static List CandidateSampleRates(double defaultSampleRate) + { + // Try the device's native rate first to avoid PortAudio internal resampling; + // fall through common rates in descending order. Captured audio is always + // resampled to 16 kHz in software before transcription. + var rates = new List(); + AddRate((int)Math.Round(defaultSampleRate)); + AddRate(48000); + AddRate(44100); + AddRate(32000); + AddRate(24000); + AddRate(SampleRate); + return rates; + + void AddRate(int rate) + { + if (rate > 0 && !rates.Contains(rate)) + { + rates.Add(rate); + } + } + } + + private static byte[] FloatSamplesToWav(float[] samples, int sampleRate) + { + return WriteWav( + sampleRate, + samples.Length, + writer => + { + foreach (var sample in samples) + { + writer.Write(ToPcm16(sample)); + } + } + ); + } + + private RecordedAudioSnapshot SnapshotRecordedAudio() + { + lock (_sampleLock) + { + return new RecordedAudioSnapshot( + _sampleChunks.ToArray(), + _sampleCount, + CaptureSampleRate + ); + } + } + + 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, + Action writeSamples + ) + { + const short bitsPerSample = 16; + const short channels = 1; + var byteRate = sampleRate * channels * bitsPerSample / 8; + const int blockAlign = channels * bitsPerSample / 8; + var dataSize = sampleCount * 2; + + using var ms = new MemoryStream(); + using var w = new BinaryWriter(ms); + w.Write("RIFF"u8); + w.Write(36 + dataSize); + w.Write("WAVE"u8); + w.Write("fmt "u8); + w.Write(16); // fmt chunk size + w.Write((short)1); // PCM + w.Write(channels); + w.Write(sampleRate); + w.Write(byteRate); + w.Write((short)blockAlign); + w.Write(bitsPerSample); + w.Write("data"u8); + w.Write(dataSize); + + writeSamples(w); + + return ms.ToArray(); + } + // Idempotent: initializes on first call only. GetInputDevices also calls // this; without idempotence the count would leak and Dispose would never // terminate PortAudio. @@ -1387,61 +1593,21 @@ string PersistentId ); /// -/// Device-enumeration seam for . Abstracts -/// the PortAudio device table so the follow-default selection policy and the +/// Device-enumeration seam for . Abstracts the +/// PortAudio device table so the follow-default selection policy and the /// migration-deferral state machine can be unit-tested without real hardware. /// must report which device is the current OS default /// (via ). +/// +/// PortAudio snapshots its device list at Pa_Initialize and does NOT observe +/// OS default changes until re-initialized, so a production implementation only +/// reflects a changed default after has cycled +/// the native library — which it does at the start of +/// and only when no +/// stream is live. +/// /// public interface IAudioDeviceEnumerator { IReadOnlyList GetDevices(); } - -/// -/// Production enumerator backed by PortAudio's cached device table. -/// -/// IMPORTANT: PortAudio snapshots the device list at Pa_Initialize and -/// does NOT observe OS default changes until it is re-initialized. This -/// enumerator therefore only reflects a changed default AFTER -/// has cycled the native library -/// (see RefreshPortAudioDeviceTable) — which it does at the start of -/// and only -/// when no stream is live. -/// -/// -public sealed class PortAudioDeviceEnumerator : IAudioDeviceEnumerator -{ - public static PortAudioDeviceEnumerator Shared { get; } = new(); - - public IReadOnlyList GetDevices() - { - AudioRecordingService.EnsurePortAudioInitializedForEnumerator(); - var result = new List(); - for (var i = 0; i < PortAudio.DeviceCount; i++) - { - try - { - var info = PortAudio.GetDeviceInfo(i); - if (info.maxInputChannels > 0) - { - result.Add( - new AudioInputDevice( - i, - info.name, - info.maxInputChannels, - i == PortAudio.DefaultInputDevice, - AudioRecordingService.GetStableDeviceId(info.name, info.maxInputChannels) - ) - ); - } - } - catch - { - /* ignore broken devices */ - } - } - - return result; - } -} \ No newline at end of file diff --git a/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs b/src/TypeWhisper.Linux/Services/BrowserAccessibilitySetupHelper.cs index d57556476..1ff7105bf 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", - "opera.desktop" + "com.vivaldi.Vivaldi.desktop", + "opera.desktop", + "com.opera.Opera.desktop", ]; private static readonly string[] s_firefoxLauncherNames = @@ -61,13 +69,16 @@ 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 = + // 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", ]; /// @@ -561,7 +572,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) { @@ -895,9 +906,48 @@ 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(XdgPaths.ResolveDataHome(), "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); } @@ -1090,14 +1140,12 @@ private static string EnvFilePath() private static string UserApplicationsDir() { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return Path.Join(home, ".local", "share", "applications"); + return Path.Join(XdgPaths.ResolveDataHome(), "applications"); } private static string LauncherBackupDir() { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return Path.Join(home, ".local", "share", "typewhisper", "launcher-backups"); + return Path.Join(XdgPaths.ResolveDataHome(), "typewhisper", "launcher-backups"); } public sealed record Status( @@ -1124,8 +1172,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/BundledPluginDeployer.cs b/src/TypeWhisper.Linux/Services/BundledPluginDeployer.cs index 939b3100e..e6b954362 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,10 +14,15 @@ 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() { var source = FindBundledPluginsDir(); + // ReSharper disable once InvertIf -- guard clause; inverting would bury the skip trace. if (source is null) { Trace.WriteLine( @@ -23,20 +31,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 +94,436 @@ 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)) + { + return; + } + + 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/src/TypeWhisper.Linux/Services/CliInstallService.cs b/src/TypeWhisper.Linux/Services/CliInstallService.cs index cb0952e35..4af717a1a 100644 --- a/src/TypeWhisper.Linux/Services/CliInstallService.cs +++ b/src/TypeWhisper.Linux/Services/CliInstallService.cs @@ -11,29 +11,62 @@ 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 { - private const string CliFileName = "typewhisper"; + private const string CliFileName = "typewhisper-cli"; + private const UnixFileMode CliExecutableMode = + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute; + + private static readonly TimeSpan s_verificationTimeout = TimeSpan.FromSeconds(10); + + // Old install name, kept only so RemoveLegacyLauncher can find and retire it. + private const string LegacyCliFileName = "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; + private readonly Func _unixFileModeReader; + private readonly Func _verificationRunner; public CliInstallService() - : this(FindBundledCliPath, DefaultInstallDirectory, DefaultLauncherDirectory) + : this( + FindBundledCliPath, + DefaultInstallDirectory, + DefaultLauncherDirectory, + RunCliVerification, + ReadUnixFileMode + ) { } internal CliInstallService( Func bundledPathProvider, Func installDirectoryProvider, - Func launcherDirectoryProvider + Func launcherDirectoryProvider, + Func? verificationRunner = null, + Func? unixFileModeReader = null ) { _bundledPathProvider = bundledPathProvider; _installDirectoryProvider = installDirectoryProvider; _launcherDirectoryProvider = launcherDirectoryProvider; + _verificationRunner = verificationRunner ?? RunCliVerification; + _unixFileModeReader = unixFileModeReader ?? ReadUnixFileMode; } public CliInstallState GetState() @@ -43,26 +76,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,39 +95,74 @@ public CliInstallState Install() return state; } - 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."); + // 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( + state.BundledPath, + state.InstallPath, + state.LauncherPath, + launcherDirectory, + launcherEntry + ); + } + + var installDirectory = + Path.GetDirectoryName(state.InstallPath) + ?? throw new InvalidOperationException("Missing CLI install directory."); Directory.CreateDirectory(installDirectory); Directory.CreateDirectory(launcherDirectory); - File.Copy(state.BundledPath, state.InstallPath, true); - CopyCliPayload(sourceDirectory, installDirectory); - MarkExecutable(state.InstallPath); + if ( + !string.Equals( + Path.GetFullPath(state.BundledPath), + Path.GetFullPath(state.InstallPath), + StringComparison.Ordinal + ) + ) + { + InstallBundledCli(state.BundledPath, state.InstallPath, installDirectory); + } - File.WriteAllText(state.LauncherPath, BuildLauncherScript(state.InstallPath)); - MarkExecutable(state.LauncherPath); + launcherEntry = ClassifyLauncherEntry(state.LauncherPath, state.InstallPath); + if (launcherEntry == LauncherEntryClassification.Foreign) + { + return CreateState( + state.BundledPath, + state.InstallPath, + state.LauncherPath, + launcherDirectory, + launcherEntry + ); + } + + WriteLauncherAtomically( + state.LauncherPath, + launcherDirectory, + BuildLauncherScript(state.InstallPath) + ); + RemoveLegacyLauncher(launcherDirectory, installDirectory); return GetState(); } public static IReadOnlyList BuildCliExamples(int port) { + _ = port; return [ "export TYPEWHISPER_API_TOKEN=\"paste-token-here\"", - "typewhisper --help", - $"typewhisper status --port {port}", - $"typewhisper models --port {port}", - $"typewhisper transcribe recording.wav --port {port}", - $"typewhisper transcribe recording.wav --language de --json --port {port}" + "typewhisper-cli --help", + "typewhisper-cli status", + "typewhisper-cli models", + "typewhisper-cli transcribe recording.wav", + "typewhisper-cli transcribe recording.wav --language de --json", ]; } @@ -119,25 +175,205 @@ 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", ]; } - private static void CopyCliPayload(string sourceDirectory, string installDirectory) + private void InstallBundledCli( + string bundledPath, + string installPath, + string installDirectory + ) + { + var tempPath = Path.Join( + installDirectory, + $".{CliFileName}.{Guid.NewGuid():N}.tmp" + ); + try + { + File.Copy(bundledPath, tempPath); + SetExecutableAndVerify(tempPath); + VerifyCliIdentityAndVersion(tempPath); + File.Move(tempPath, installPath, true); + } + finally + { + TryDeleteTemporaryFile(tempPath); + } + } + + // Commit by rename, like the binary above, so an interrupted install can't leave a + // truncated script at the name users type. The mode is verified before the rename: + // a fresh temp file starts non-executable, and committing one whose chmod failed + // would replace a working launcher with a broken one. + private void WriteLauncherAtomically( + string launcherPath, + string launcherDirectory, + string script + ) + { + var tempPath = Path.Join( + launcherDirectory, + $".{CliFileName}.{Guid.NewGuid():N}.tmp" + ); + try + { + File.WriteAllText(tempPath, script); + SetExecutableAndVerify(tempPath); + File.Move(tempPath, launcherPath, true); + } + finally + { + TryDeleteTemporaryFile(tempPath); + } + } + + private static void TryDeleteTemporaryFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Cleanup must never replace the copy/chmod/verify failure that got us here. + Trace.WriteLine($"[CliInstallService] could not remove {path}: {ex.Message}"); + } + } + + private void VerifyCliIdentityAndVersion(string path) + { + var result = _verificationRunner(path); + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"CLI verification failed with exit code {result.ExitCode}: {result.StandardError.Trim()}" + ); + } + + var expected = $"{CliFileName} {AppVersion.Display}"; + var actual = result.StandardOutput.TrimEnd('\r', '\n'); + if (!string.Equals(actual, expected, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"CLI verification returned '{actual}'; expected '{expected}'." + ); + } + } + + private static CliVerificationResult RunCliVerification(string path) + { + return RunCliVerification(path, s_verificationTimeout); + } + + // Parameterized so tests can use a short deadline instead of the production one. + internal static CliVerificationResult RunCliVerification(string path, TimeSpan timeout) + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo(path) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + process.StartInfo.ArgumentList.Add("--version"); + bool started; + try + { + started = process.Start(); + } + catch (System.ComponentModel.Win32Exception ex) + { + // exec fails here rather than at chmod when the install directory is mounted + // noexec or the copy is not a valid binary. Normalized because callers only + // filter on this type. + throw new InvalidOperationException( + $"Could not start CLI verification: {ex.Message}", + ex + ); + } + + if (!started) + { + throw new InvalidOperationException("Could not start CLI verification."); + } + + // One deadline covering process exit *and* both reads. WaitForExit(int) does + // not drain redirected pipes, so a grandchild inheriting them keeps ReadToEnd + // blocked forever even after the CLI itself has exited. + using var deadline = new CancellationTokenSource(timeout); + var standardOutput = process.StandardOutput.ReadToEndAsync(deadline.Token); + var standardError = process.StandardError.ReadToEndAsync(deadline.Token); + try + { + process.WaitForExitAsync(deadline.Token).GetAwaiter().GetResult(); + return new CliVerificationResult( + process.ExitCode, + standardOutput.GetAwaiter().GetResult(), + standardError.GetAwaiter().GetResult() + ); + } + catch (OperationCanceledException) + { + try + { + process.Kill(true); + // Bounded: the parameterless overload also waits for pipe EOF, which is + // the very thing that may be stuck. + process.WaitForExit(5_000); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + Trace.WriteLine($"[CliInstallService] could not stop CLI verification: {ex.Message}"); + } + + throw new TimeoutException( + $"CLI verification did not complete within {timeout.TotalSeconds:0} seconds." + ); + } + } + + // Earlier versions installed as "typewhisper", shadowing the desktop app's own command. + // Renaming leaves that launcher behind, so delete it here — but only when it's provably + // ours; e.g. the desktop app's own symlink at this name is foreign and left untouched. + private static void RemoveLegacyLauncher(string launcherDirectory, string installDirectory) { - foreach (var file in Directory.EnumerateFiles(sourceDirectory, "typewhisper.*")) + var legacyLauncherPath = Path.Join(launcherDirectory, LegacyCliFileName); + var legacyInstallPath = Path.Join(installDirectory, LegacyCliFileName); + if ( + ClassifyLauncherEntry(legacyLauncherPath, legacyInstallPath) + != LauncherEntryClassification.Owned + ) + { + return; + } + + try { - var fileName = Path.GetFileName(file); - File.Copy(file, Path.Join(installDirectory, fileName), true); + File.Delete(legacyLauncherPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Trace.WriteLine( + $"[CliInstallService] could not remove legacy launcher {legacyLauncherPath}: {ex.Message}" + ); } } 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() @@ -186,7 +422,7 @@ private static string DefaultLauncherDirectory() "Release", "net10.0", CliFileName - ) + ), }; return candidates.Select(Path.GetFullPath).FirstOrDefault(IsCliAppHost); @@ -197,6 +433,129 @@ 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 + ) + { + LauncherEntry = launcherEntry, + }; + } + + 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); @@ -212,7 +571,7 @@ private static bool FileExistsWithExactName(string path) // Use EnumerateFiles + exact name comparison rather than File.Exists // to guard against case-insensitive filesystems (FAT32, case-folded - // ext4 directories) that would treat "TypeWhisper" == "typewhisper". + // ext4 directories) that would treat "TypeWhisper-Cli" == "typewhisper-cli". return Directory .EnumerateFiles(directory, fileName) .Any(candidate => @@ -244,30 +603,50 @@ private static string NormalizeDirectory(string directory) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } - private static void MarkExecutable(string path) + private void SetExecutableAndVerify(string path) { - if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { - return; + File.SetUnixFileMode(path, CliExecutableMode); + } + else + { + throw new PlatformNotSupportedException( + "CLI executable permissions can only be verified on Unix." + ); } - try + var actualMode = _unixFileModeReader(path); + if (actualMode != CliExecutableMode) { - File.SetUnixFileMode( - path, - UnixFileMode.UserRead - | UnixFileMode.UserWrite - | UnixFileMode.UserExecute - | UnixFileMode.GroupRead - | UnixFileMode.GroupExecute - | UnixFileMode.OtherRead - | UnixFileMode.OtherExecute + throw new InvalidOperationException( + $"CLI executable mode verification failed for {path}: expected {CliExecutableMode}, found {actualMode}." ); } - catch (Exception ex) - when (ex is PlatformNotSupportedException or IOException or UnauthorizedAccessException) + } + + private static UnixFileMode ReadUnixFileMode(string path) + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { - Trace.WriteLine($"[CliInstallService] chmod failed for {path}: {ex.Message}"); + return File.GetUnixFileMode(path); } + + throw new PlatformNotSupportedException( + "CLI executable permissions can only be verified on Unix." + ); + } + + internal readonly record struct CliVerificationResult( + int ExitCode, + string StandardOutput, + string StandardError + ); + + internal enum LauncherEntryClassification + { + Absent, + Owned, + Foreign, } -} \ No newline at end of file +} 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/DefaultDeviceWatcher.cs b/src/TypeWhisper.Linux/Services/DefaultDeviceWatcher.cs index bcee3402f..c3f96bb95 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,79 @@ 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); - - PactlSubscription subscription; - try - { - subscription = _subscriptionFactory(); - } - catch (Exception ex) + _callback = onDefaultDeviceChanged; + _wantRunning = true; + _autoRestarts = 0; + // 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++; + if (SpawnLocked() is null) { - // 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; + // 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); } + } + } - 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) + // 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; + } + + _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() @@ -399,9 +458,11 @@ private static PactlSubscription LaunchPactlSubscribe() var psi = new ProcessStartInfo("pactl") { RedirectStandardOutput = true, - RedirectStandardError = true, + // Deliberately NOT redirected: nothing here drains it, so a chatty pactl would + // fill the pipe buffer and block the child, stalling the event stream. + RedirectStandardError = false, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, }; psi.ArgumentList.Add("subscribe"); // Force a stable, parseable locale for the event lines. @@ -417,14 +478,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; @@ -458,31 +521,145 @@ 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) + { + 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. + ScheduleRetryLocked(session); + } + } + + // 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; + } + + var attempt = ++_autoRestarts; + _ = 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; @@ -492,6 +669,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(); @@ -501,7 +690,9 @@ private void ClearRunState( /* best effort: process may already be gone */ } + cts?.Dispose(); dispatcher?.Dispose(); + return true; } public void Stop() @@ -513,6 +704,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/src/TypeWhisper.Linux/Services/DictationInFlightSessionTracker.cs b/src/TypeWhisper.Linux/Services/DictationInFlightSessionTracker.cs new file mode 100644 index 000000000..bc262650c --- /dev/null +++ b/src/TypeWhisper.Linux/Services/DictationInFlightSessionTracker.cs @@ -0,0 +1,52 @@ +namespace TypeWhisper.Linux.Services; + +/// +/// Tracks dictation session ids that are recording or in their post-stop +/// pipeline (save/transcribe/insert). is the single +/// chokepoint every post-stop pipeline must run through: it guarantees the +/// session id is removed on every exit of the pipeline — success, +/// cancellation, or an exception from any step, even one (e.g. persisting +/// the capture) that throws before later steps ever start. +/// +internal sealed class DictationInFlightSessionTracker +{ + private readonly Lock _lock = new(); + private readonly HashSet _sessions = []; + + internal void Begin(int sessionId) + { + lock (_lock) + { + _sessions.Add(sessionId); + } + } + + /// Idempotent: removing an id that is not tracked is a no-op. + internal void End(int sessionId) + { + lock (_lock) + { + _sessions.Remove(sessionId); + } + } + + internal bool Contains(int sessionId) + { + lock (_lock) + { + return _sessions.Contains(sessionId); + } + } + + internal async Task RunAsync(int sessionId, Func pipeline) + { + try + { + await pipeline().ConfigureAwait(false); + } + finally + { + End(sessionId); + } + } +} diff --git a/src/TypeWhisper.Linux/Services/DictationInsertionOrderGate.cs b/src/TypeWhisper.Linux/Services/DictationInsertionOrderGate.cs new file mode 100644 index 000000000..d98e801b8 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/DictationInsertionOrderGate.cs @@ -0,0 +1,113 @@ +namespace TypeWhisper.Linux.Services; + +/// +/// Orders concurrent post-stop dictation pipelines' text insertion so delivery +/// lands in session-start order even when a later session's transcription or +/// post-processing (including a slow prompt action) finishes first. Capture and +/// transcription stay fully concurrent — only the narrow span immediately around +/// the actual insertion call waits (audit §2 H3). +/// +/// must run for every stopped session, in session-start +/// order, before that session's toggle-gate release — so a session that hasn't +/// even entered its own post-stop pipeline yet is still known to block a faster +/// successor. must run immediately before the +/// session inserts; must run immediately after (success or +/// failure) so a waiting successor isn't held up by unrelated post-insertion +/// bookkeeping. Release is idempotent and must also be called, unconditionally, +/// from a terminal safety net for every reserved session — mirroring +/// 's RunAsync finally — so a +/// session that fails/cancels/discards before ever reaching insertion can never +/// block a successor forever. +/// +internal sealed class DictationInsertionOrderGate +{ + private static readonly TimeSpan s_defaultMaxWait = TimeSpan.FromMinutes(2); + + private readonly Lock _lock = new(); + private readonly SortedSet _pending = []; + private readonly Dictionary _waiters = new(); + private readonly TimeSpan _maxWait; + + internal DictationInsertionOrderGate() + : this(s_defaultMaxWait) + { + } + + /// Test-only hook so the defensive backstop timeout can be exercised quickly. + internal DictationInsertionOrderGate(TimeSpan maxWait) + { + _maxWait = maxWait; + } + + internal void Reserve(int sessionId) + { + lock (_lock) + { + _pending.Add(sessionId); + } + } + + /// + /// Waits until every reserved session with a smaller id has released its slot. + /// Returns immediately if is already the oldest + /// reservation (or nothing is reserved). The real correctness guarantee is that + /// is always eventually called for every reservation + /// (from a guaranteed terminal path); the maxWait backstop below only + /// protects against a future bug that forgets to release, and fails OPEN + /// (lets insertion proceed out of order) rather than hanging the pipeline. + /// A real cancellation of instead + /// completes the wait as canceled, so the caller's existing + /// OperationCanceledException handling short-circuits before ever + /// attempting the insertion. + /// + 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) + { + // 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; + } + + tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _waiters[sessionId] = tcs; + } + + using var timeoutCts = new CancellationTokenSource(_maxWait); + await using var timeoutReg = timeoutCts.Token.Register(() => tcs.TrySetResult()); + await using var cancelReg = cancellationToken.Register(() => + tcs.TrySetCanceled(cancellationToken) + ); + await tcs.Task.ConfigureAwait(false); + } + + /// Idempotent: releasing a session that is not (or no longer) reserved is a no-op. + internal void Release(int sessionId) + { + lock (_lock) + { + if (!_pending.Remove(sessionId)) + { + return; + } + + // Clean up sessionId's own waiter entry too, in case its wait already + // resolved via the timeout/cancel path above instead of being unblocked + // by a predecessor's Release. + _waiters.Remove(sessionId); + + if (_pending.Count > 0 && _waiters.Remove(_pending.Min, out var next)) + { + next.TrySetResult(); + } + } + } +} diff --git a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs index ab7bb4084..7525c1411 100644 --- a/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs +++ b/src/TypeWhisper.Linux/Services/DictationOrchestrator.cs @@ -65,7 +65,8 @@ public sealed class DictationOrchestrator : IDisposable private readonly IHistoryService _history; private readonly HotkeyService _hotkey; private readonly IdeFileReferenceService _ideFileReferences; - private readonly HashSet _inFlightSessions = []; + private readonly DictationInFlightSessionTracker _inFlightTracker = new(); + private readonly DictationInsertionOrderGate _insertionOrder = new(); private readonly IMediaPauseService _mediaPause; private readonly MemoryService _memory; private readonly ModelManagerService _models; @@ -93,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 @@ -206,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 @@ -217,7 +219,7 @@ public string CurrentStateLabel { get { - if (_audio.IsRecording) + if (IsRecording) { return "recording"; } @@ -276,37 +278,14 @@ public void Dispose() _sessionActivityMonitor.InputAllowedChanged -= _sessionActivityHandler; } - // 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) - { - try - { - _audio.StopRecording(); - } - catch (Exception ex) - { - Trace.WriteLine($"[Dictation] StopRecording during dispose failed: {ex.Message}"); - } - - try - { - _audioDucking.RestoreAudio(); - } - catch (Exception ex) - { - Trace.WriteLine($"[Dictation] RestoreAudio during dispose failed: {ex.Message}"); - } - - try - { - _mediaPause.ResumeMedia(); - } - catch (Exception ex) - { - Trace.WriteLine($"[Dictation] ResumeMedia during dispose failed: {ex.Message}"); - } - } + var captureSession = _audioCaptureSession; + StopCaptureAndRestoreSystemAudio( + _audio, + captureSession, + _audioDucking, + _mediaPause + ); + Interlocked.CompareExchange(ref _audioCaptureSession, null, captureSession); ShutdownPartialTranscriptionSession(); @@ -319,11 +298,6 @@ public void Dispose() _streamingProviderId = null; _streamingModelId = null; _streamingLanguageHint = null; - if (disposingCoordinator is not null) - { - _audio.LiveFrameSink = null; - } - var streamingTeardown = TeardownStreamingSessionAsync( disposingCoordinator, disposingStartupCts, @@ -351,6 +325,44 @@ public void Dispose() _toggleGate.Dispose(); } + internal static void StopCaptureAndRestoreSystemAudio( + AudioRecordingService audio, + AudioRecordingService.AudioCaptureSession? captureSession, + IAudioDuckingService audioDucking, + IMediaPauseService mediaPause + ) + { + if (captureSession is not null) + { + try + { + audio.StopRecording(captureSession); + } + catch (Exception ex) + { + Trace.WriteLine($"[Dictation] StopRecording during dispose failed: {ex.Message}"); + } + } + + try + { + audioDucking.RestoreAudio(); + } + catch (Exception ex) + { + Trace.WriteLine($"[Dictation] RestoreAudio during dispose failed: {ex.Message}"); + } + + try + { + mediaPause.ResumeMedia(); + } + catch (Exception ex) + { + Trace.WriteLine($"[Dictation] ResumeMedia during dispose failed: {ex.Message}"); + } + } + public void Initialize() { if (_initialized || _disposed) @@ -451,7 +463,7 @@ public async Task ToggleAsync(string? forcedProfileId = null) _lastToggleUtc = now; } - if (_audio.IsRecording) + if (IsRecording) { await StopAsync(); } @@ -479,13 +491,57 @@ 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); } } + /// + /// 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)) @@ -497,7 +553,7 @@ public async Task StartAsync(string? forcedProfileId = null) DictationDeferredStop pendingStop; try { - if (_audio.IsRecording) + if (IsRecording) { goto StartupComplete; } @@ -513,15 +569,31 @@ 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; + // 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 { - _audio.StartRecording(); + 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) { @@ -532,16 +604,29 @@ public async Task StartAsync(string? forcedProfileId = null) goto StartupComplete; } - if (!_audio.IsRecording) + 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); goto StartupComplete; } + _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. @@ -557,35 +642,28 @@ state with StatusText = Localization.Loc.Instance["Dictation.StatusRecording"], ActiveProfileName = null, ActiveAppName = null, - SessionStartedAtUtc = DateTime.UtcNow + SessionStartedAtUtc = DateTime.UtcNow, } ); 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 @@ -626,18 +704,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; @@ -646,7 +728,6 @@ state with _streamingProviderId = null; _streamingModelId = null; _streamingLanguageHint = null; - _audio.LiveFrameSink = null; _ = await TeardownStreamingSessionAsync( faultedCoordinator, faultedStartupCts, @@ -670,7 +751,7 @@ state with lock (_recordingSessionLock) { sessionId = ++_recordingSession; - _inFlightSessions.Add(sessionId); + _inFlightTracker.Begin(sessionId); _recordingAppProcess = null; _recordingAppTitle = null; _recordingAppUrl = null; @@ -715,7 +796,7 @@ state with "kde" => "kwin", "hyprland" => "hyprland", "sway" => "sway", - _ => "xdotool" + _ => "xdotool", }, "No active-window provider returned a snapshot" ); @@ -753,8 +834,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 } ); @@ -849,9 +933,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 + ); } } } @@ -873,8 +959,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(captureSession); + } + 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; @@ -892,13 +997,21 @@ rematch.Profile is not null _streamingLanguageHint = null; } - _audio.LiveFrameSink = null; - _ = await TeardownStreamingSessionAsync( - rolledBackCoordinator, - rolledBackStartupCts, - false, - CancellationToken.None - ); + // No explicit live-frame-sink detach here: the sink is session-scoped and + // StopRecording above already cleared it during the rollback. + 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(); @@ -1022,6 +1135,30 @@ public Task StopAsync() return StopAsync(cancelRequested: false); } + internal static void ReportShortSpeechDiscardOutcome( + LinuxShortSpeechDecision discardReason, + RecordingContext recordingContext, + Action reportStatus, + 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", + 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 @@ -1048,9 +1185,11 @@ private async Task StopWhileHoldingGateAsync() var wasRecording = false; var gateReleased = false; CancellationTokenSource? snapshotCts = null; + int? insertionOrderSessionId = null; try { - if (!_audio.IsRecording) + var captureSession = _audioCaptureSession; + if (!_audio.IsRecordingOwnedBy(captureSession)) { return; } @@ -1061,8 +1200,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(); @@ -1132,169 +1279,222 @@ 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 + // gate so reservations happen in strict session-start order — a new + // StartAsync's own future stop cannot reach this point until this one + // has passed it (audit §2 H3). + _insertionOrder.Reserve(recordingContext.SessionId); + insertionOrderSessionId = recordingContext.SessionId; + _toggleGate.Release(); gateReleased = true; - if (canceledThisStop) + // Single terminal guard (audit §2 H1): every post-stop step below + // can throw, and the session id must leave `_inFlightTracker` no + // matter which step fails — `RunAsync`'s finally is the chokepoint + // that guarantees that. The catch below additionally turns an + // otherwise-silent failure (e.g. disk-full saving the WAV, a + // throwing RecordingCaptured subscriber) into a published "failed" + // terminal and visible overlay feedback instead of leaving + // IsSessionInFlight stuck true and the overlay on "Processing…" + // forever. + try { - // User hit Escape while still recording: clean up audio/media - // (already done above) and surface "Canceled" without saving - // the WAV or running transcription. - SetOverlayState(state => - state with - { - IsOverlayVisible = true, - ShowFeedback = true, - FeedbackText = Localization.Loc.Instance["Overlay.Canceled"], - FeedbackIsError = false, - IsRecording = false, - StatusText = Localization.Loc.Instance["Overlay.Canceled"], - PartialText = null, - SessionStartedAtUtc = null - } - ); - StatusMessage?.Invoke(this, "Canceled"); - _models.PluginManager.EventBus.Publish( - new RecordingStoppedEvent + await _inFlightTracker.RunAsync(recordingContext.SessionId, async () => + { + if (canceledThisStop) { - DurationSeconds = LinuxDictationShortSpeechPolicy.ComputeDurationSeconds( - wav - ) + // User hit Escape while still recording: clean up audio/media + // (already done above) and surface "Canceled" without saving + // the WAV or running transcription. + SetOverlayState(state => + state with + { + IsOverlayVisible = true, + ShowFeedback = true, + FeedbackText = Localization.Loc.Instance["Overlay.Canceled"], + FeedbackIsError = false, + IsRecording = false, + StatusText = Localization.Loc.Instance["Overlay.Canceled"], + PartialText = null, + SessionStartedAtUtc = null, + } + ); + StatusMessage?.Invoke(this, "Canceled"); + _models.PluginManager.EventBus.Publish( + new RecordingStoppedEvent + { + DurationSeconds = LinuxDictationShortSpeechPolicy.ComputeDurationSeconds( + wav + ), + } + ); + _ = await TeardownStreamingSessionAsync( + stoppedStreamingCoordinator, + stoppedStreamingStartupCts, + false, + CancellationToken.None + ); + FinalizeSession(recordingContext.SessionId, "canceled", "Canceled"); + return; } - ); - _ = await TeardownStreamingSessionAsync( - stoppedStreamingCoordinator, - stoppedStreamingStartupCts, - false, - CancellationToken.None - ); - FinalizeSession(recordingContext.SessionId, "canceled", "Canceled"); - return; - } - - SetOverlayState(state => - state with - { - IsOverlayVisible = true, - ShowFeedback = false, - FeedbackText = null, - FeedbackIsError = false, - IsRecording = false, - StatusText = Localization.Loc.Instance["Overlay.Processing"], - SessionStartedAtUtc = null - } - ); - var duration = LinuxDictationShortSpeechPolicy.ComputeDurationSeconds(wav); - _models.PluginManager.EventBus.Publish( - new RecordingStoppedEvent { DurationSeconds = duration } - ); - - var shortSpeechDecision = LinuxDictationShortSpeechPolicy.Classify( - duration, - LinuxDictationShortSpeechPolicy.ComputePeakLevel(wav), - _settings.Current.TranscribeShortQuietClipsAggressively - ); - // Transcribe intentionally falls through to the normal transcription path below. - // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (shortSpeechDecision) - { - case LinuxShortSpeechDecision.DiscardTooShort: SetOverlayState(state => state with { IsOverlayVisible = true, - ShowFeedback = true, - FeedbackText = Localization.Loc.Instance["Overlay.TooShort"], - FeedbackIsError = true, + ShowFeedback = false, + FeedbackText = null, + FeedbackIsError = false, IsRecording = false, - StatusText = Localization.Loc.Instance["Overlay.TooShort"], - PartialText = null + StatusText = Localization.Loc.Instance["Overlay.Processing"], + SessionStartedAtUtc = null, } ); - StatusMessage?.Invoke(this, "Too short"); - _ = await TeardownStreamingSessionAsync( - stoppedStreamingCoordinator, - stoppedStreamingStartupCts, - false, - CancellationToken.None - ); - 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 - } + var duration = LinuxDictationShortSpeechPolicy.ComputeDurationSeconds(wav); + _models.PluginManager.EventBus.Publish( + new RecordingStoppedEvent { DurationSeconds = duration } ); - StatusMessage?.Invoke(this, "No speech detected"); - _ = await TeardownStreamingSessionAsync( - stoppedStreamingCoordinator, - stoppedStreamingStartupCts, - false, - CancellationToken.None + + var shortSpeechDecision = LinuxDictationShortSpeechPolicy.Classify( + duration, + LinuxDictationShortSpeechPolicy.ComputePeakLevel(wav), + _settings.Current.TranscribeShortQuietClipsAggressively ); - FinalizeSession(recordingContext.SessionId, "discarded", "No speech detected"); - return; - } - // Streaming finalize must run BEFORE pad/save so the EOF grace-window - // flush captures any trailing partials. Read fault state from the - // just-torn-down coordinator, never from a shared field a racing - // StartAsync could have reset. - if (stoppedStreamingCoordinator is not null) - { - var streamingCancelToken = snapshotCts?.Token ?? CancellationToken.None; - var (streamingFinalText, streamingFaulted) = await TeardownStreamingSessionAsync( - stoppedStreamingCoordinator, - stoppedStreamingStartupCts, - true, - streamingCancelToken - ); - recordingContext = recordingContext with - { - StreamingFinalText = streamingFinalText, StreamingFaulted = streamingFaulted - }; - } + // Transcribe intentionally falls through to the normal transcription path below. + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (shortSpeechDecision) + { + case LinuxShortSpeechDecision.DiscardTooShort: + ReportShortSpeechDiscardOutcome( + LinuxShortSpeechDecision.DiscardTooShort, + recordingContext, + ReportStatus, + ShowFeedback + ); + _ = await TeardownStreamingSessionAsync( + stoppedStreamingCoordinator, + stoppedStreamingStartupCts, + false, + CancellationToken.None + ); + FinalizeSession(recordingContext.SessionId, "discarded", "Too short"); + return; + case LinuxShortSpeechDecision.DiscardNoSpeech: + ReportShortSpeechDiscardOutcome( + LinuxShortSpeechDecision.DiscardNoSpeech, + recordingContext, + ReportStatus, + ShowFeedback + ); + _ = await TeardownStreamingSessionAsync( + stoppedStreamingCoordinator, + stoppedStreamingStartupCts, + false, + CancellationToken.None + ); + FinalizeSession( + recordingContext.SessionId, + "discarded", + "No speech detected" + ); + return; + } - // Keep the recorded (pre-padding) length: PadWavForFinalTranscription adds ~0.3s of - // silence, which would push a borderline-short silent clip past the hallucination filter's - // duration cutoff and let a stock "Thank you." artifact through. - var recordedDuration = duration; - wav = LinuxDictationShortSpeechPolicy.PadWavForFinalTranscription(wav, duration); - duration = LinuxDictationShortSpeechPolicy.ComputeDurationSeconds(wav); + // Streaming finalize must run BEFORE pad/save so the EOF grace-window + // flush captures any trailing partials. Read fault state from the + // just-torn-down coordinator, never from a shared field a racing + // StartAsync could have reset. + if (stoppedStreamingCoordinator is not null) + { + var streamingCancelToken = snapshotCts?.Token ?? CancellationToken.None; + var (streamingFinalText, streamingFaulted) = + await TeardownStreamingSessionAsync( + stoppedStreamingCoordinator, + stoppedStreamingStartupCts, + true, + streamingCancelToken + ); + recordingContext = recordingContext with + { + StreamingFinalText = streamingFinalText, + StreamingFaulted = streamingFaulted, + }; + } + + // Keep the recorded (pre-padding) length: PadWavForFinalTranscription adds ~0.3s of + // silence, which would push a borderline-short silent clip past the hallucination filter's + // duration cutoff and let a stock "Thank you." artifact through. + var recordedDuration = duration; + wav = LinuxDictationShortSpeechPolicy.PadWavForFinalTranscription( + wav, + duration + ); + duration = LinuxDictationShortSpeechPolicy.ComputeDurationSeconds(wav); - var path = _sessionAudioFiles.SaveDictationCapture(wav); - RecordingCaptured?.Invoke(this, path); - Trace.WriteLine($"[Dictation] Captured → {path} ({wav.Length} bytes)"); + var path = _sessionAudioFiles.SaveDictationCapture(wav); + RecordingCaptured?.Invoke(this, path); + Trace.WriteLine($"[Dictation] Captured → {path} ({wav.Length} bytes)"); - try + await TranscribeAndInsertAsync( + wav, + path, + duration, + recordedDuration, + recordingContext + ); + }); + } + catch (OperationCanceledException) { - await TranscribeAndInsertAsync(wav, path, duration, recordedDuration, recordingContext); + Trace.WriteLine("[Dictation] Post-stop pipeline canceled before completion."); + PublishSessionTerminal(recordingContext.SessionId, "canceled", "Canceled"); } - finally + catch (Exception ex) { - // Guarantee the session leaves the in-flight set even on early - // exception or cancel before a terminal status is published. - ClearSessionInFlight(recordingContext.SessionId); + // Reached only for a step BEFORE TranscribeAndInsertAsync's own + // try/catch chain (streaming teardown, WAV padding, capture + // persistence, the RecordingCaptured event), or a rethrow from + // its `await using` lease disposal. Either way the session must + // not stay "in_progress" forever (audit §2 H1). + Trace.WriteLine($"[Dictation] Post-stop pipeline failed before completion: {ex}"); + // Publish the terminal result FIRST: RunAsync's finally has + // already dropped the id from the in-flight set, so a throw from + // any log/UI callback below must not stop the "failed" result + // from being recorded — otherwise the session would poll as + // not_found forever, defeating audit §2 H1. + PublishSessionTerminal(recordingContext.SessionId, "failed", ex.Message); + _errorLog.AddEntry( + $"Dictation capture could not be saved or transcribed ({ex.Message}).", + ErrorCategory.Recording + ); + ReportStatus( + recordingContext, + Localization.Loc.Instance["Overlay.CaptureSaveFailed"] + ); + ShowFeedback( + recordingContext, + Localization.Loc.Instance["Overlay.CaptureSaveFailed"], + true + ); } } finally { + // Safety net: every early-discard branch above and every early return + // in TranscribeAndInsertAsync that never reaches the insertion call + // must still release this session's insertion-order slot, or a + // successor blocked in WaitForTurnAsync would wait forever. Release is + // idempotent, so this is a no-op on the common path, which already + // released around the insertion call. + if (insertionOrderSessionId is { } reservedSessionId) + { + _insertionOrder.Release(reservedSessionId); + } + // Restore ducking/media only when there was an active recording and // the normal cleanup path didn't already run (earlyCleanupDone). if (wasRecording && !earlyCleanupDone) @@ -1336,10 +1536,7 @@ state with /// public bool IsSessionInFlight(int sessionId) { - lock (_recordingSessionLock) - { - return _inFlightSessions.Contains(sessionId); - } + return _inFlightTracker.Contains(sessionId); } /// @@ -1418,6 +1615,67 @@ out bool usedPreviewFallback return ""; } + /// + /// Null when is true: that text came from + /// the live preview, which already ran the side-effect-free PreviewCorrections on + /// every tick — re-running ApplyCorrections here would re-persist usage stats and, + /// since corrections aren't guaranteed idempotent, transform the text a second + /// time (audit §2 M3). + /// Exposed internally for unit testing. + /// + internal static Func? SelectFinalDictionaryCorrector( + bool usedPreviewFallback, + Func applyCorrections + ) + { + return usedPreviewFallback ? null : applyCorrections; + } + + /// + /// Resolves the language post-processing should treat the transcript as. + /// "en" only when a translate task was requested AND the engine supports + /// translation — engines with SupportsTranslation=false ignore the + /// translate task and return source-language text; otherwise the detected + /// (else configured) source language (audit §2 M1). + /// Exposed internally for unit testing. + /// + internal static string? ResolvePostProcessingSourceLanguage( + string? detectedLanguage, + string? configuredLanguage, + bool translateRequested, + bool engineSupportsTranslation + ) + { + var engineTranslatedToEnglish = translateRequested && engineSupportsTranslation; + return engineTranslatedToEnglish ? "en" : detectedLanguage ?? configuredLanguage; + } + + /// + /// True when a prompt action explicitly names a target action plugin but + /// no loaded plugin matches (disabled, removed, or renamed) — unlike "no + /// action plugin configured", which still falls through to plain text + /// insertion. An explicit-but-missing destination must fail with a routing + /// error, not silently substitute another destination (audit §2 M2). + /// Exposed internally for unit testing. + /// + internal static bool IsActionPluginTargetUnavailable( + string? targetActionPluginId, + bool actionPluginResolved + ) + { + return !string.IsNullOrWhiteSpace(targetActionPluginId) && !actionPluginResolved; + } + + /// + /// Classifies a thrown insertion/action exception into the InsertionResult that + /// should be recorded in Recents/History instead of silently dropping the + /// transcription (audit §2 M6). Exposed internally for unit testing. + /// + internal static InsertionResult ClassifyThrownInsertionFailure(bool viaActionPlugin) + { + return viaActionPlugin ? InsertionResult.ActionFailed : InsertionResult.Failed; + } + public event EventHandler? RecordingCaptured; // arg = WAV file path public event EventHandler? RecordingStateChanged; public event EventHandler? TranscriptionCompleted; @@ -1459,7 +1717,10 @@ RecordingContext context ModelManagerService.TranscriptionLease lease; try { - lease = await _models.AcquireTranscriptionAsync(effectiveModelId, cancelToken); + lease = await _models.AcquireTranscriptionAsync( + effectiveModelId, + cancellationToken: cancelToken + ); } catch (OperationCanceledException) when (cancelToken.IsCancellationRequested) { @@ -1492,6 +1753,7 @@ RecordingContext context // race a concurrent dictation's model swap. var engineProviderId = plugin.ProviderId; var engineModelId = plugin.SelectedModelId; + var engineSupportsTranslation = plugin.SupportsTranslation; ReportStatus(context, $"Transcribing via {plugin.ProviderDisplayName}…"); @@ -1582,7 +1844,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}"); @@ -1676,6 +1938,13 @@ out var spokenCommand ) ) { + // Spoken commands insert through their own path (one-shot or + // streamed-while-typing), not the InsertTextAsync/ + // ExecuteActionPluginAsync boundary this gate orders — their + // delivery is out of scope for audit §2 H3. Release now rather + // than hold a waiting successor for the whole LLM+typing round + // trip. + _insertionOrder.Release(context.SessionId); var outcome = await RunSpokenCommandAsync(spokenCommand, context, cancelToken); // A spoken command is still a dictation the user issued: record it in // history (with the LLM request/response captured on context.Capture) @@ -1701,13 +1970,20 @@ out var spokenCommand return; } + var postProcessingLanguage = ResolvePostProcessingSourceLanguage( + result?.DetectedLanguage, + languageHint, + translate, + engineSupportsTranslation + ); + var pipelineContext = new PostProcessingContext { - SourceLanguage = result?.DetectedLanguage ?? languageHint, + SourceLanguage = postProcessingLanguage, ActiveAppName = context.AppTitle, ActiveAppProcessName = context.AppProcess, ProfileName = context.Profile?.Name, - AudioDurationSeconds = duration + AudioDurationSeconds = duration, }; var promptAction = ResolvePromptAction(context); @@ -1756,7 +2032,10 @@ out var spokenCommand NormalizeSpokenPunctuation = true, AppFormatter = AppFormatterService.Format, TargetProcessName = context.AppProcess, - DictionaryCorrector = _dictionary.ApplyCorrections, + DictionaryCorrector = SelectFinalDictionaryCorrector( + usedPreviewFallback, + _dictionary.ApplyCorrections + ), VocabularyBooster = _settings.Current.VocabularyBoostingEnabled ? _vocabularyBoosting.Apply : null, @@ -1789,11 +2068,15 @@ out var spokenCommand ? null : translationTarget, RequireTranslationSuccess = !string.IsNullOrWhiteSpace(translationTarget), - EffectiveSourceLanguage = languageHint, - DetectedLanguage = result?.DetectedLanguage, - TranscriptionTask = translate - ? TranscriptionTask.Translate - : TranscriptionTask.Transcribe, + EffectiveSourceLanguage = postProcessingLanguage, + DetectedLanguage = postProcessingLanguage, + // Same rule as postProcessingLanguage above: an engine that ignores the + // translate task returns source-language text, and reporting Translate + // would make number normalization treat it as English. + TranscriptionTask = + translate && engineSupportsTranslation + ? TranscriptionTask.Translate + : TranscriptionTask.Transcribe, ConfiguredLanguage = languageHint, TranscriptionNumberNormalizationEnabled = _settings.Current.TranscriptionNumberNormalizationEnabled, @@ -1805,7 +2088,7 @@ out var spokenCommand status == "AI" ? "Processing prompt action…" : $"Processing {status}…" ); return Task.CompletedTask; - } + }, }, cancelToken ); @@ -1842,7 +2125,7 @@ out var spokenCommand ProfileName = context.Profile?.Name, AppName = context.AppTitle, AppProcessName = context.AppProcess, - Url = context.AppUrl + Url = context.AppUrl, } ); PublishSessionResult( @@ -1860,13 +2143,39 @@ out var spokenCommand transcriptionCompletedPublished = true; var actionPlugin = ResolveActionPlugin(promptAction); + var actionPluginUnavailable = IsActionPluginTargetUnavailable( + promptAction?.TargetActionPluginId, + actionPlugin is not null + ); + if (actionPluginUnavailable) + { + Trace.WriteLine( + $"[Dictation] Configured action plugin target " + + $"'{promptAction?.TargetActionPluginId}' is unavailable (disabled, removed, " + + "or renamed) — routing as a failure instead of falling back to ordinary text " + + "insertion (audit §2 M2)." + ); + } + + // 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 // GNOME/KDE was always compositor-rejected, so this was latent // until the ydotool backend was added. - if (actionPlugin is null && !commandResult.CancelInsertion) + if ( + actionPlugin is null + && !actionPluginUnavailable + && !commandResult.CancelInsertion + ) { // Surface the inject phase so `typewhisper status` reports // `injecting` while a long transcript is still being typed. @@ -1874,18 +2183,6 @@ out var spokenCommand await YieldFocusForInsertionAsync().ConfigureAwait(false); } - // Final lock check before synthesizing any keystroke. A session-loss discard cancels - // recording, but a normal stop nulls _activeDictationCts and releases the gate before - // transcription finishes, so a lock landing during transcription cannot reach that - // token — this authoritative check keeps synthesized paste/type off the lock screen. - if (!_sessionActivityMonitor.IsInputAllowed) - { - Trace.WriteLine("[Dictation] Insertion suppressed: session locked or inactive."); - ReportStatus(context, "Canceled"); - ShowFeedback(context, "Canceled", false, true); - return; - } - // Pad with a trailing space so back-to-back dictations don't run // together. Only the insertion and TextInsertedEvent use this; // history, recent transcriptions, and completion events keep the @@ -1893,31 +2190,49 @@ out var spokenCommand var insertionText = DictationInsertionTextFormatter.TextForInsertion(finalText); InsertionResult insertion; + var insertionThrew = false; try { + // 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 + // finishes, and the wait can span an arbitrary predecessor (up to + // the fail-open backstop), so a lock landing during transcription + // OR while queued cannot reach that token — this authoritative + // check keeps synthesized paste/type off the lock screen. + if (!_sessionActivityMonitor.IsInputAllowed) + { + Trace.WriteLine("[Dictation] Insertion suppressed: session locked or inactive."); + ReportStatus(context, "Canceled"); + ShowFeedback(context, "Canceled", false, true); + return; + } + insertion = commandResult.CancelInsertion ? InsertionResult.NoText - : actionPlugin is null - ? await _textInsertion.InsertTextAsync( - new TextInsertionRequest( - insertionText, - _settings.Current.AutoPaste, - context.WindowId, - context.AppProcess, - context.AppTitle, - commandResult.AutoEnter, - ResolveInsertionStrategy(context.AppProcess) + : actionPluginUnavailable + ? InsertionResult.ActionUnavailable + : actionPlugin is null + ? await _textInsertion.InsertTextAsync( + new TextInsertionRequest( + insertionText, + _settings.Current.AutoPaste, + context.WindowId, + context.AppProcess, + context.AppTitle, + commandResult.AutoEnter, + ResolveInsertionStrategy(context.AppProcess) + ) ) - ) - : await ExecuteActionPluginAsync( - actionPlugin, - context, - finalText, - rawText, - result?.DetectedLanguage, - cancelToken - ); + : await ExecuteActionPluginAsync( + actionPlugin, + context, + finalText, + rawText, + result?.DetectedLanguage, + cancelToken + ); } catch (OperationCanceledException) when (cancelToken.IsCancellationRequested) { @@ -1940,59 +2255,73 @@ out var spokenCommand ); ReportStatus(context, $"Insertion failed: {ex.Message}"); ShowFeedback(context, "Insertion failed.", true); - return; + insertion = ClassifyThrownInsertionFailure(actionPlugin is not null); + insertionThrew = true; } - - var completionMessage = insertion switch - { - InsertionResult.Pasted when commandResult.AutoEnter && finalText.Length == 0 => - "Pressed Enter.", - InsertionResult.Pasted or InsertionResult.Typed => - $"Typed {finalText.Length} char(s).", - InsertionResult.CopiedToClipboard => ClipboardFallbackMessage(), - InsertionResult.ActionHandled => "Action completed.", - InsertionResult.ActionFailed => "Action failed.", - InsertionResult.MissingClipboardTool => ClipboardToolMissingMessage(), - InsertionResult.MissingPasteTool => - $"Text insertion failed. {_commands.GetSnapshot().PasteToolInstallHint}", - InsertionResult.Failed => - "Text insertion failed. Dictated text could not be copied or pasted.", - InsertionResult.NoText when commandResult.CancelInsertion => "Dictation canceled.", - _ => "Done." - }; - var isError = - insertion - is InsertionResult.Failed - or InsertionResult.ActionFailed - or InsertionResult.MissingClipboardTool - or InsertionResult.MissingPasteTool; - var isCanceled = - insertion is InsertionResult.NoText && commandResult.CancelInsertion; - ReportStatus(context, completionMessage); - ShowFeedback(context, completionMessage, isError, isCanceled); - - if ( - insertion - is InsertionResult.Pasted - or InsertionResult.Typed - or InsertionResult.CopiedToClipboard - ) + finally { - _models.PluginManager.EventBus.Publish( - new TextInsertedEvent { Text = insertionText, AppName = context.AppTitle } - ); + _insertionOrder.Release(context.SessionId); } - if (ShouldArmTargetAppLearning(insertion, actionPlugin, insertionText)) + if (!insertionThrew) { - // 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 completionMessage = insertion switch + { + InsertionResult.Pasted when commandResult.AutoEnter && finalText.Length == 0 => + "Pressed Enter.", + InsertionResult.Pasted or InsertionResult.Typed => + $"Typed {finalText.Length} char(s).", + InsertionResult.CopiedToClipboard => ClipboardFallbackMessage(), + InsertionResult.ActionHandled => "Action completed.", + InsertionResult.ActionFailed => "Action failed.", + InsertionResult.ActionUnavailable => "Action destination unavailable.", + InsertionResult.MissingClipboardTool => ClipboardToolMissingMessage(), + InsertionResult.MissingPasteTool => + $"Text insertion failed. {_commands.GetSnapshot().PasteToolInstallHint}", + InsertionResult.Failed when _textInsertion.LastTypingDeliveredPartialText => + "Text insertion failed partway through typing. Some of the dictated text may " + + "already be in the target app — check before dictating again.", + InsertionResult.Failed => + "Text insertion failed. Dictated text could not be copied or pasted.", + InsertionResult.NoText when commandResult.CancelInsertion => + "Dictation canceled.", + _ => "Done.", + }; + var isError = + insertion + is InsertionResult.Failed + or InsertionResult.ActionFailed + or InsertionResult.ActionUnavailable + or InsertionResult.MissingClipboardTool + or InsertionResult.MissingPasteTool; + var isCanceled = + insertion is InsertionResult.NoText && commandResult.CancelInsertion; + ReportStatus(context, completionMessage); + ShowFeedback(context, completionMessage, isError, isCanceled); + + if ( + insertion + is InsertionResult.Pasted + or InsertionResult.Typed + or InsertionResult.CopiedToClipboard + ) + { + _models.PluginManager.EventBus.Publish( + new TextInsertedEvent { Text = insertionText, AppName = context.AppTitle } + ); + } + + 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(); @@ -2010,7 +2339,7 @@ or InsertionResult.CopiedToClipboard // active, run it (awaited) before writing history so its request is // recorded on the entry; otherwise keep it fire-and-forget so the // common path isn't delayed by the extraction round-trip. - if (_settings.Current.MemoryEnabled) + if (_settings.Current.MemoryEnabled && !insertionThrew) { if (context.Capture is not null) { @@ -2075,7 +2404,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}"); @@ -2091,10 +2420,6 @@ or InsertionResult.CopiedToClipboard // dictation. Trace.WriteLine($"[Dictation] Post-completion bookkeeping failed: {ex}"); } - finally - { - _models.ScheduleAutoUnload(); - } } private PromptAction? ResolvePromptAction(RecordingContext context) @@ -2117,11 +2442,20 @@ CancellationToken token var pump = new LlmStreamPump(accumulated => { - SetOverlayState(state => state with { LlmResponseText = accumulated }); + // Match ReportStatus(context,...)/ShowFeedback(context,...): a + // newer session that has taken over the overlay must not have its + // LlmResponseText clobbered by an older session's still-running + // prompt action (audit §2 H3). The event still publishes + // unconditionally for non-overlay observers. + if (IsContextStillOwningOverlay(context)) + { + SetOverlayState(state => state with { LlmResponseText = accumulated }); + } + _models.PluginManager.EventBus.Publish( new LlmResponseTokenEvent { - AccumulatedText = accumulated, StepName = PostProcessingStepNames.Llm + AccumulatedText = accumulated, StepName = PostProcessingStepNames.Llm, }); }); @@ -2147,7 +2481,7 @@ CancellationToken token AccumulatedText = result, IsFinal = true, Faulted = pump.Faulted, - StepName = PostProcessingStepNames.Llm + StepName = PostProcessingStepNames.Llm, }); return result; @@ -2354,7 +2688,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 @@ -2462,7 +2796,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; } @@ -2699,7 +3033,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 @@ -2759,7 +3093,7 @@ private PromptAction BuildTransientCommandAction(string id, string systemPrompt) Id = id, Name = "Spoken command", SystemPrompt = systemPrompt, - ProviderOverride = _settings.Current.SpokenCommandLlmProvider + ProviderOverride = _settings.Current.SpokenCommandLlmProvider, }; } @@ -2856,7 +3190,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).", }; } @@ -2908,7 +3242,7 @@ CancellationToken cancelToken ActionId = actionPlugin.ActionId, Success = result.Success, Message = result.Message, - AppName = context.AppTitle + AppName = context.AppTitle, } ); @@ -3047,7 +3381,7 @@ private TranscriptionRecord BuildHistoryRecord( ProfileName = context.Profile?.Name, EngineUsed = engine, ModelUsed = modelUsed, - AudioFileName = Path.GetFileName(wavPath) + AudioFileName = Path.GetFileName(wavPath), }; } @@ -3092,7 +3426,7 @@ TextInsertionStatus insertionStatus CleanupLevelUsed = CleanupLevel.None, PromptActionApplied = true, IsSpokenCommand = true, - LlmCalls = context.Capture?.Calls ?? [] + LlmCalls = context.Capture?.Calls ?? [], } ); } @@ -3157,7 +3491,7 @@ private void AddHistoryRecord( pipelineResult, PostProcessingStepNames.Translation ), - LlmCalls = context.Capture?.Calls ?? [] + LlmCalls = context.Capture?.Calls ?? [], } ); } @@ -3177,10 +3511,11 @@ private static TextInsertionStatus ToTextInsertionStatus(InsertionResult inserti InsertionResult.NoText => TextInsertionStatus.NoText, InsertionResult.ActionHandled => TextInsertionStatus.ActionHandled, InsertionResult.ActionFailed => TextInsertionStatus.ActionFailed, + InsertionResult.ActionUnavailable => TextInsertionStatus.ActionUnavailable, InsertionResult.MissingClipboardTool => TextInsertionStatus.MissingClipboardTool, InsertionResult.MissingPasteTool => TextInsertionStatus.MissingPasteTool, InsertionResult.Failed => TextInsertionStatus.Failed, - _ => TextInsertionStatus.Unknown + _ => TextInsertionStatus.Unknown, }; } @@ -3203,10 +3538,12 @@ private static bool WasPipelineStepSucceeded(PostProcessingResult result, string return insertion switch { InsertionResult.ActionFailed => "Action plugin failed.", + InsertionResult.ActionUnavailable => + "Configured action plugin destination is unavailable.", InsertionResult.MissingClipboardTool => ClipboardToolMissingMessage(), InsertionResult.MissingPasteTool => "Automatic paste tool is unavailable.", InsertionResult.Failed => "Text insertion failed.", - _ => null + _ => null, }; } @@ -3265,7 +3602,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 @@ -3279,7 +3621,7 @@ state with IsRecording = false, ActiveProfileName = null, ActiveAppName = null, - SessionStartedAtUtc = null + SessionStartedAtUtc = null, } ); @@ -3288,8 +3630,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; } @@ -3305,7 +3650,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. @@ -3343,13 +3748,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) @@ -3358,6 +3763,10 @@ private void RollBackStartedRecording() $"[Dictation] Failed to stop recording during start rollback: {ex.Message}" ); } + finally + { + Interlocked.CompareExchange(ref _audioCaptureSession, null, captureSession); + } try { @@ -3396,7 +3805,7 @@ state with StatusText = Localization.Loc.Instance["Overlay.Ready"], ActiveProfileName = null, ActiveAppName = null, - SessionStartedAtUtc = null + SessionStartedAtUtc = null, } ); } @@ -3415,7 +3824,10 @@ private void SetOverlayState(Func } } - private void StartPartialTranscriptionSession(int sessionVersion) + private void StartPartialTranscriptionSession( + int sessionVersion, + AudioRecordingService.AudioCaptureSession captureSession + ) { _partialTranscriptionCts?.Cancel(); _partialTranscriptionCts?.Dispose(); @@ -3425,21 +3837,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, + ITranscriptionEngineRole 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. @@ -3454,10 +3867,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 @@ -3667,7 +4087,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); @@ -3675,7 +4099,10 @@ private async Task RunPartialTranscriptionLoopAsync(int sessionVersion, Cancella try { - while (!ct.IsCancellationRequested && _audio.IsRecording) + while ( + !ct.IsCancellationRequested + && _audio.IsRecordingOwnedBy(captureSession) + ) { if (_audio.HasSpeechEnergy) { @@ -3692,7 +4119,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 @@ -3709,7 +4136,7 @@ wav is not null ?? _settings.Current.SelectedModelId; await using var lease = await _models.TryAcquireTranscriptionAsync( partialModelId, - ct + cancellationToken: ct ); // Local engines poll cheaply; online batch providers // re-upload the whole growing buffer each poll, so they @@ -3728,6 +4155,7 @@ await PollPartialTranscriptOnceAsync( lease.Plugin, wav, sessionVersion, + captureSession, ct ); } @@ -3763,9 +4191,10 @@ private bool ShouldAutoStopForSilence() } private async Task PollPartialTranscriptOnceAsync( - ITranscriptionEnginePlugin plugin, + ITranscriptionEngineRole plugin, byte[] wav, int sessionVersion, + AudioRecordingService.AudioCaptureSession captureSession, CancellationToken ct ) { @@ -3788,13 +4217,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) @@ -3805,10 +4235,7 @@ CancellationToken ct private void ClearSessionInFlight(int sessionId) { - lock (_recordingSessionLock) - { - _inFlightSessions.Remove(sessionId); - } + _inFlightTracker.End(sessionId); } private void PublishSessionResult(DictationSessionResult result) @@ -3851,13 +4278,17 @@ 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( sessionVersion, text ?? "", - _dictionary.ApplyCorrections, + _dictionary.PreviewCorrections, out var partialText ) ) @@ -3875,11 +4306,11 @@ out var partialText new PartialTranscriptionUpdateEvent { PartialText = partialText, - IsRecording = _audio.IsRecording, + IsRecording = _audio.IsRecordingOwnedBy(captureSession), 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/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 d3d9a7488..588c64c3e 100644 --- a/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs +++ b/src/TypeWhisper.Linux/Services/FileTranscriptionProcessor.cs @@ -90,10 +90,15 @@ CancellationToken cancellationToken // the post-processing pipeline would block a concurrent dictation or // watch-folder transcription from loading a different model. PluginTranscriptionResult pluginResult; + bool engineSupportsTranslation; await using ( - var lease = await modelManager.AcquireTranscriptionAsync(modelId, cancellationToken) + var lease = await modelManager.AcquireTranscriptionAsync( + modelId, + cancellationToken: cancellationToken + ) ) { + engineSupportsTranslation = lease.Plugin.SupportsTranslation; pluginResult = await lease.Plugin.TranscribeAsync( wav, language, @@ -103,6 +108,13 @@ CancellationToken cancellationToken ); } + // An engine that ignores the translate task returns source-language text; reporting + // Translate downstream would make number normalization treat it as English. + var effectiveTask = + task == TranscriptionTask.Translate && engineSupportsTranslation + ? TranscriptionTask.Translate + : TranscriptionTask.Transcribe; + var result = new TranscriptionResult { Text = pluginResult.Text, @@ -116,7 +128,7 @@ CancellationToken cancellationToken segment.Start, segment.End )) - .ToArray() + .ToArray(), }; var pipelineResult = await pipeline.ProcessAsync( @@ -127,17 +139,15 @@ CancellationToken cancellationToken ? vocabularyBoosting.Apply : null, DictionaryCorrector = dictionary.ApplyCorrections, - TranscriptionTask = task, + TranscriptionTask = effectiveTask, DetectedLanguage = result.DetectedLanguage, ConfiguredLanguage = language, TranscriptionNumberNormalizationEnabled = - currentSettings.TranscriptionNumberNormalizationEnabled + currentSettings.TranscriptionNumberNormalizationEnabled, }, cancellationToken ); - modelManager.ScheduleAutoUnload(); - return new FileTranscriptionProcessResult(result, pipelineResult.Text); } @@ -210,7 +220,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), }; } -} \ No newline at end of file +} 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/BackendSelector.cs b/src/TypeWhisper.Linux/Services/Hotkey/BackendSelector.cs index 9c5f8e66b..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 @@ -106,7 +107,6 @@ private static Func DefaultFactory( private static bool IsWaylandSession() { - var session = Environment.GetEnvironmentVariable("XDG_SESSION_TYPE"); - return string.Equals(session, "wayland", StringComparison.OrdinalIgnoreCase); + return WaylandSessionDetector.IsWaylandSession(); } } diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/AtomicFileWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/AtomicFileWriter.cs index 793e5d0fb..20f6eef01 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/AtomicFileWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/AtomicFileWriter.cs @@ -1,5 +1,17 @@ namespace TypeWhisper.Linux.Services.Hotkey.DeSetup; +/// +/// Exact file state used by . +/// The requested path is kept separately from the resolved path so a symlink +/// retarget between capture and commit can be detected. +/// +internal readonly record struct AtomicFileSnapshot( + string RequestedTarget, + string ResolvedTarget, + bool Existed, + string Contents +); + /// /// Shared atomic file-write helper for the per-desktop shortcut writers. /// Writes to a sibling temp file then s @@ -7,27 +19,134 @@ namespace TypeWhisper.Linux.Services.Hotkey.DeSetup; /// When the target already exists, its Unix permission bits are copied /// onto the temp file first — a user who hardened their compositor /// config to e.g. 0600 keeps that mode across our writes. +/// If the target is (or sits behind a chain of) a symbolic link, the +/// write is redirected through the chain to its final regular file so replacing the +/// destination directory entry never unlinks a dotfile-manager-owned symlink +/// (stow/chezmoi/home-manager commonly manage hyprland.conf/sway config this way). /// internal static class AtomicFileWriter { + public static async Task CaptureAsync( + string target, + CancellationToken ct + ) + { + var resolvedTarget = Path.GetFullPath(ResolveWriteTarget(target)); + if (!File.Exists(resolvedTarget)) + { + return new AtomicFileSnapshot(target, resolvedTarget, false, string.Empty); + } + + var contents = await File.ReadAllTextAsync(resolvedTarget, ct).ConfigureAwait(false); + return new AtomicFileSnapshot(target, resolvedTarget, true, contents); + } + public static async Task WriteAsync(string target, string contents, CancellationToken ct) { - var dir = Path.GetDirectoryName(target); + var resolvedTarget = ResolveWriteTarget(target); + var tmp = await StageAsync(resolvedTarget, contents, nameof(target), ct) + .ConfigureAwait(false); + try + { + File.Move(tmp, resolvedTarget, true); + } + finally + { + DeleteTempBestEffort(tmp); + } + } + + /// + /// Atomically replaces the snapshot's resolved file only when the configured + /// path still resolves to the same final target and that target's existence and + /// exact contents still match the captured state. Returns false on a conflict. + /// + public static async Task WriteIfUnchangedAsync( + AtomicFileSnapshot snapshot, + string contents, + CancellationToken ct + ) + { + var tmp = await StageAsync( + snapshot.ResolvedTarget, + contents, + nameof(snapshot), + ct + ) + .ConfigureAwait(false); + try + { + var currentResolved = Path.GetFullPath(ResolveWriteTarget(snapshot.RequestedTarget)); + if (!string.Equals(currentResolved, snapshot.ResolvedTarget, StringComparison.Ordinal)) + { + return false; + } + + var currentExists = File.Exists(currentResolved); + if (currentExists != snapshot.Existed) + { + return false; + } + + if (currentExists) + { + string currentContents; + try + { + currentContents = await File.ReadAllTextAsync(currentResolved, ct) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + return false; + } + + if (!string.Equals(currentContents, snapshot.Contents, StringComparison.Ordinal)) + { + return false; + } + } + + ct.ThrowIfCancellationRequested(); + File.Move(tmp, snapshot.ResolvedTarget, true); + return true; + } + finally + { + DeleteTempBestEffort(tmp); + } + } + + private static async Task StageAsync( + string resolvedTarget, + string contents, + string argumentName, + CancellationToken ct + ) + { + var dir = Path.GetDirectoryName(resolvedTarget); if (string.IsNullOrEmpty(dir)) { - throw new ArgumentException("Target path must include a directory.", nameof(target)); + throw new ArgumentException( + "Target path must include a directory.", + argumentName + ); } - var tmp = Path.Join(dir, $".{Path.GetFileName(target)}.{Path.GetRandomFileName()}.tmp"); + var tmp = Path.Join( + dir, + $".{Path.GetFileName(resolvedTarget)}.{Path.GetRandomFileName()}.tmp" + ); try { await File.WriteAllTextAsync(tmp, contents, ct).ConfigureAwait(false); - if (File.Exists(target) && !OperatingSystem.IsWindows()) + // ReSharper disable once InvertIf -- inverting would duplicate the `return tmp` and turn the intent (preserve perms when the target exists) into a harder-to-read early-out. + if (File.Exists(resolvedTarget) && !OperatingSystem.IsWindows()) { // Preserve a user-hardened config's permission bits. try { - File.SetUnixFileMode(tmp, File.GetUnixFileMode(target)); + File.SetUnixFileMode(tmp, File.GetUnixFileMode(resolvedTarget)); } catch { @@ -35,23 +154,78 @@ public static async Task WriteAsync(string target, string contents, Cancellation } } - File.Move(tmp, target, true); + return tmp; } catch { - try - { - if (File.Exists(tmp)) - { - File.Delete(tmp); - } - } - catch + DeleteTempBestEffort(tmp); + throw; + } + } + + private static void DeleteTempBestEffort(string tmp) + { + try + { + if (File.Exists(tmp)) { - // Cleanup of the temp file is best-effort; the original failure is rethrown below. + File.Delete(tmp); } + } + catch + { + // Cleanup of the temp file is best-effort; the original failure wins. + } + } - throw; + /// + /// Resolves to the file that should actually receive the + /// write: itself, when it is not a symlink (including when nothing exists there yet — + /// the common first-run case), or the final regular file at the end of its symlink + /// chain (following relative targets and multiple hops), so the atomic replace never + /// unlinks a dotfile-manager-owned symlink. Refuses with an actionable message if the + /// chain is broken, cyclic, or resolves to something other than a regular file. + /// + private static string ResolveWriteTarget(string target) + { + FileSystemInfo? resolved; + try + { + resolved = File.ResolveLinkTarget(target, returnFinalTarget: true); + } + catch (FileNotFoundException) + { + // Nothing exists at this path yet (first-run install) — write it directly. + return target; + } + catch (DirectoryNotFoundException) + { + return target; } + catch (IOException ex) + { + throw new IOException( + $"'{target}' is a symbolic link that could not be resolved (possible cycle): " + + $"{ex.Message}", + ex + ); + } + + if (resolved is null) + { + // Not a symlink: a plain file (or nothing there yet). + return target; + } + + if (!File.Exists(resolved.FullName)) + { + throw new IOException( + $"'{target}' is a symbolic link to '{resolved.FullName}', which does not exist " + + "or is not a regular file. Refusing to write through a broken link — fix or " + + "remove it and try again." + ); + } + + return resolved.FullName; } -} \ 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 7d416933e..5e3c258d7 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/DictationShortcutSpecFactory.cs @@ -1,9 +1,10 @@ using TypeWhisper.Core.Interfaces; +using TypeWhisper.Core.Models; namespace TypeWhisper.Linux.Services.Hotkey.DeSetup; /// -/// Builds the for TypeWhisper's dictation toggle. Shared by the +/// Builds the for TypeWhisper's selected recording mode. Shared by the /// Shortcuts panel and onboarding checklist so both register the same id/trigger/command — /// a divergence would let one surface install a shortcut the other can't detect. /// @@ -14,38 +15,40 @@ public static class DictationShortcutSpecFactory private const string DefaultTrigger = "Ctrl+Shift+Space"; /// - /// Builds the spec for . PTT desktops (Hyprland/Sway) get - /// press/release/cancel triplet; toggle-only desktops (GNOME/KDE) get a single command. + /// Builds the spec for the selected recording mode and . Toggle uses + /// a press-only command on every desktop, PushToTalk requires press/release support, and Hybrid + /// is unsupported because native desktop bindings cannot reproduce its tap/hold threshold. /// - public static DeShortcutSpec Build(ISettingsService settings, IDeShortcutWriter writer) + public static DeShortcutSpec? Build(ISettingsService settings, IDeShortcutWriter writer) { var trigger = string.IsNullOrWhiteSpace(settings.Current.ToggleHotkey) ? DefaultTrigger : settings.Current.ToggleHotkey; var gui = ResolveGuiCommand(); + var cancelTrigger = SwapKeyForCancel(trigger); - if (writer.SupportsPushToTalk) + return settings.Current.Mode switch { - return new DeShortcutSpec( + RecordingMode.Toggle => new DeShortcutSpec( + DictationShortcutId, + DictationDisplayName, + trigger, + gui, + null, + null, + null + ), + RecordingMode.PushToTalk when writer.SupportsPushToTalk => new DeShortcutSpec( DictationShortcutId, DictationDisplayName, trigger, $"{gui} record start", $"{gui} record stop", - SwapKeyForCancel(trigger), - $"{gui} record cancel" - ); - } - - return new DeShortcutSpec( - DictationShortcutId, - DictationDisplayName, - trigger, - gui, - null, - null, - null - ); + cancelTrigger, + cancelTrigger is null ? null : $"{gui} record cancel" + ), + _ => null, + }; } /// @@ -65,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( '+', @@ -76,7 +85,18 @@ private static string SwapKeyForCancel(string trigger) return "Ctrl+Shift+Escape"; } + // 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"; - return string.Join('+', parts); + var cancel = string.Join('+', parts); + return string.Equals(cancel, normalizedTrigger, StringComparison.OrdinalIgnoreCase) + ? null + : cancel; } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs index 893a53bc8..5c854d5d7 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/GnomeShortcutWriter.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Globalization; using System.Text; using TypeWhisper.Core; @@ -17,6 +16,8 @@ namespace TypeWhisper.Linux.Services.Hotkey.DeSetup; /// public sealed class GnomeShortcutWriter : IDeShortcutWriter { + private const int MaxListMutationAttempts = 3; + private const int MaxSnapshotAttempts = 5; private const string MediaKeysSchema = "org.gnome.settings-daemon.plugins.media-keys"; private const string CustomKeybindingSchema = @@ -24,6 +25,30 @@ public sealed class GnomeShortcutWriter : IDeShortcutWriter private const string ListKey = "custom-keybindings"; + private static readonly TimeSpan s_gsettingsTimeout = TimeSpan.FromSeconds(5); + + private readonly string _backupDirectory; + + // The writer is a DI singleton and the Shortcuts panel exposes install and remove as + // separate commands, so one flow could otherwise roll back the other's half-published path. + // Cross-process edits are separate, handled by the read/confirm/retry loop in MutateListAsync. + private readonly SemaphoreSlim _mutationGate = new(1, 1); + private readonly IProcessRunner _processRunner; + + public GnomeShortcutWriter() + : this(new ProcessRunner()) { } + + // ReSharper disable once MemberCanBePrivate.Global -- public DI seam: callers inject an IProcessRunner; the parameterless overload chains here with a real ProcessRunner. + public GnomeShortcutWriter(IProcessRunner processRunner) + : this(processRunner, Path.Join(TypeWhisperEnvironment.BasePath, "backups")) { } + + internal GnomeShortcutWriter(IProcessRunner processRunner, string backupDirectory) + { + _processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner)); + ArgumentException.ThrowIfNullOrWhiteSpace(backupDirectory); + _backupDirectory = backupDirectory; + } + public string DesktopId => "gnome"; public string DisplayName => "GNOME"; public bool SupportsPushToTalk => false; @@ -49,31 +74,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; } @@ -86,112 +88,94 @@ public async Task IsInstalledAsync(DeShortcutSpec spec, CancellationToken return command == spec.OnPressCommand && binding == FormatGnomeAccel(spec.Trigger); } - public async Task WriteAsync(DeShortcutSpec spec, CancellationToken ct) + public Task IsManagedShortcutPresentAsync(string shortcutId, CancellationToken ct) { - var path = BuildCustomPath(spec.ShortcutId); + return IsManagedPathListedAsync(BuildCustomPath(shortcutId), ct); + } - // 1. Snapshot before touching anything — refuse if backup fails. - var (listOk, listOut, listErr) = await RunAsync( - "gsettings", - ["get", MediaKeysSchema, ListKey], - ct - ) - .ConfigureAwait(false); - if (!listOk) + public async Task WriteAsync(DeShortcutSpec spec, CancellationToken ct) + { + await _mutationGate.WaitAsync(ct).ConfigureAwait(false); + try { - return new DeShortcutWriteResult( - false, - $"Could not read GNOME shortcut list: {listErr.Trim()}", - [] - ); + return await WriteLockedAsync(spec, ct).ConfigureAwait(false); } - - var backupPath = await SnapshotListAsync(listOut, ct).ConfigureAwait(false); - if (backupPath is null) + finally { - return new DeShortcutWriteResult( - false, - "Could not write GNOME backup file. Refusing to modify shortcuts.", - [] - ); + _mutationGate.Release(); } + } - List list; - try - { - list = ParseGSettingsList(listOut); - } - catch (FormatException ex) + private async Task WriteLockedAsync( + DeShortcutSpec spec, + CancellationToken ct + ) + { + var path = BuildCustomPath(spec.ShortcutId); + var mutation = await MutateListAsync(path, add: true, ct).ConfigureAwait(false); + if (mutation.Failure is not null) { - // The backup is already on disk — surface its path so the - // user knows where to look if they need to recover. - return new DeShortcutWriteResult( - false, - $"Could not parse GNOME shortcut list ({ex.Message}). Refusing to modify shortcuts; backup at {backupPath}.", - [backupPath] - ); + return mutation.Failure; } - var added = false; - if (!list.Contains(path)) + var changed = new List(); + if (mutation.BackupPath is not null) { - list.Add(path); - added = true; + changed.Add(mutation.BackupPath); } - // 2. Write merged list only when a path was actually added (repeat invocations are no-ops). - var changed = new List { backupPath }; - if (added) + if (mutation.Changed) { - var (ok, _, err) = await RunAsync( - "gsettings", - ["set", MediaKeysSchema, ListKey, FormatGSettingsList(list)], - ct - ) - .ConfigureAwait(false); - if (!ok) - { - return new DeShortcutWriteResult( - false, - $"Could not update GNOME shortcut list: {err.Trim()}", - [backupPath] - ); - } - changed.Add($"{MediaKeysSchema}.{ListKey}"); } - // 3. Set name/command/binding via "schema:path" form (gsettings treats the path as dconf prefix). + // Set name/command/binding only after the complete-list add is stable. var schemaWithPath = $"{CustomKeybindingSchema}:{path}"; - foreach ( - var (key, value) in new[] - { - ("name", spec.DisplayName), ("command", spec.OnPressCommand), - ("binding", FormatGnomeAccel(spec.Trigger)) - } - ) + try { - var (ok, _, err) = await RunAsync( - "gsettings", - ["set", schemaWithPath, key, value], - ct - ) - .ConfigureAwait(false); - if (!ok) + foreach ( + var (key, value) in new[] + { + ("name", spec.DisplayName), ("command", spec.OnPressCommand), + ("binding", FormatGnomeAccel(spec.Trigger)) + } + ) { + var (ok, _, err) = await RunAsync( + "gsettings", + ["set", schemaWithPath, key, value], + ct + ) + .ConfigureAwait(false); + if (ok) + { + continue; + } + + var cleanup = await TryUnpublishAddedPathAsync(spec, path, mutation.Changed) + .ConfigureAwait(false); + // The cleanup writes its own list snapshot; FilesChanged must report it. + changed.AddRange(cleanup.FilesChanged); return new DeShortcutWriteResult( false, $"Could not set {key}: {err.Trim()}", - changed + changed, + cleanup.Unpublished ? null : LeftoverEntryWarning(path) ); } } + catch (OperationCanceledException) + { + // Cancellation has no result to carry a warning; the helper traces a failed cleanup. + await TryUnpublishAddedPathAsync(spec, path, mutation.Changed).ConfigureAwait(false); + throw; + } changed.Add(schemaWithPath); return new DeShortcutWriteResult( true, - added + mutation.Changed ? "GNOME shortcut installed. It will appear under Settings → Keyboard → Custom Shortcuts." : "GNOME shortcut updated.", changed @@ -200,73 +184,40 @@ public async Task WriteAsync(DeShortcutSpec spec, Cancell public async Task RemoveAsync(string shortcutId, CancellationToken ct) { - var path = BuildCustomPath(shortcutId); - var (listOk, listOut, listErr) = await RunAsync( - "gsettings", - ["get", MediaKeysSchema, ListKey], - ct - ) - .ConfigureAwait(false); - if (!listOk) - { - return new DeShortcutWriteResult( - false, - $"Could not read GNOME shortcut list: {listErr.Trim()}", - [] - ); - } - - List list; + await _mutationGate.WaitAsync(ct).ConfigureAwait(false); try { - list = ParseGSettingsList(listOut); + return await RemoveLockedAsync(shortcutId, ct).ConfigureAwait(false); } - catch (FormatException ex) + finally { - return new DeShortcutWriteResult( - false, - $"Could not parse GNOME shortcut list ({ex.Message}). Refusing to modify shortcuts.", - [] - ); + _mutationGate.Release(); } + } - if (!list.Contains(path)) + private async Task RemoveLockedAsync( + string shortcutId, + CancellationToken ct + ) + { + var path = BuildCustomPath(shortcutId); + var mutation = await MutateListAsync(path, add: false, ct).ConfigureAwait(false); + if (mutation.Failure is not null) { - return new DeShortcutWriteResult( - true, - "No GNOME integration to remove.", - [] - ); + return mutation.Failure; } - var backupPath = await SnapshotListAsync(listOut, ct).ConfigureAwait(false); - if (backupPath is null) + if (!mutation.Changed) { return new DeShortcutWriteResult( - false, - "Could not write GNOME backup file. Refusing to modify shortcuts.", + true, + "No GNOME integration to remove.", [] ); } - list.Remove(path); - var (setOk, _, setErr) = await RunAsync( - "gsettings", - ["set", MediaKeysSchema, ListKey, FormatGSettingsList(list)], - ct - ) - .ConfigureAwait(false); - if (!setOk) - { - return new DeShortcutWriteResult( - false, - $"Could not update GNOME shortcut list: {setErr.Trim()}", - [backupPath] - ); - } - // gsettings has no "reset path" verb; reset individual keys so dconf-editor - // stops showing stale values. Reset failures are non-fatal (entry no longer listed). + // stops showing stale values. This only runs after the list removal is stable. var schemaWithPath = $"{CustomKeybindingSchema}:{path}"; foreach (var key in new[] { "name", "command", "binding" }) { @@ -276,10 +227,212 @@ 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.", - [backupPath, $"{MediaKeysSchema}.{ListKey}"] + removed + ); + } + + /// + /// Drops a path this call just added when its name/command/binding writes didn't land, + /// so a failed or cancelled install leaves no empty custom shortcut in GNOME's Settings + /// UI. An already-listed path is left alone — it is the user's entry, not this call's + /// litter. Reports Unpublished: false if it is still published, plus any backup + /// the cleanup wrote. + /// + private async Task TryUnpublishAddedPathAsync( + DeShortcutSpec spec, + string path, + bool addedByThisCall + ) + { + if (!addedByThisCall) + { + return new UnpublishOutcome(true, []); + } + + try + { + // CancellationToken.None throughout: the caller's token may already be cancelled and + // this cleanup is what makes that safe. Each call is still bounded by the timeout. + // A fully configured entry is no longer this call's litter — another writer completed + // the install between our failed property write and this cleanup, so leave it alone. + if (await IsInstalledAsync(spec, CancellationToken.None).ConfigureAwait(false)) + { + return new UnpublishOutcome(true, []); + } + + var cleanup = await MutateListAsync(path, add: false, CancellationToken.None) + .ConfigureAwait(false); + if (cleanup.Failure is null) + { + return new UnpublishOutcome( + true, + cleanup.BackupPath is null ? [] : [cleanup.BackupPath] + ); + } + + System.Diagnostics.Trace.WriteLine( + $"[GnomeShortcutWriter] Could not unpublish {path}: {cleanup.Failure.UserMessage}" + ); + return new UnpublishOutcome(false, cleanup.Failure.FilesChanged); + } + catch (Exception ex) + { + System.Diagnostics.Trace.WriteLine( + $"[GnomeShortcutWriter] Could not unpublish {path} after a failed install: {ex.Message}" + ); + return new UnpublishOutcome(false, []); + } + } + + private sealed record UnpublishOutcome(bool Unpublished, IReadOnlyList FilesChanged); + + private static void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) + { + System.Diagnostics.Trace.WriteLine( + $"[GnomeShortcutWriter] Could not delete partial snapshot {path}: {ex.Message}" + ); + } + } + + private static string LeftoverEntryWarning(string path) + { + return $"The half-configured entry at {path} is still listed and could not be removed. " + + "Remove it under Settings → Keyboard → Custom Shortcuts, or retry once the " + + "settings backend is writable again."; + } + + private async Task MutateListAsync( + string path, + bool add, + CancellationToken ct + ) + { + string? candidateRaw = null; + for (var attempt = 0; attempt < MaxListMutationAttempts; attempt++) + { + if (candidateRaw is null) + { + var read = await ReadListAsync(ct).ConfigureAwait(false); + if (!read.Ok) + { + return ListMutationOutcome.Fail( + $"Could not read GNOME shortcut list: {read.Error.Trim()}" + ); + } + + candidateRaw = read.Raw; + } + + List list; + try + { + list = ParseGSettingsList(candidateRaw); + } + catch (FormatException ex) + { + if (!add) + { + return ListMutationOutcome.Fail( + $"Could not parse GNOME shortcut list ({ex.Message}). Refusing to modify shortcuts." + ); + } + + // Back up the malformed value on add so the user has it while repairing. + var malformedBackup = await SnapshotListAsync(candidateRaw, ct) + .ConfigureAwait(false); + if (malformedBackup is null) + { + return ListMutationOutcome.Fail( + "Could not write GNOME backup file. Refusing to modify shortcuts." + ); + } + + return ListMutationOutcome.Fail( + $"Could not parse GNOME shortcut list ({ex.Message}). Refusing to modify shortcuts; backup at {malformedBackup}.", + [malformedBackup] + ); + } + + var containsPath = list.Contains(path); + // ReSharper disable once ConvertIfStatementToSwitchStatement -- the add/contains no-op guard reads clearer as a single boolean condition than a two-tuple switch. + if ((add && containsPath) || (!add && !containsPath)) + { + return ListMutationOutcome.NoChange(); + } + + if (add) + { + list.Add(path); + } + else + { + // RemoveAll, not Remove: an externally duplicated entry would otherwise survive + // removal and leave the shortcut registered. + list.RemoveAll(entry => string.Equals(entry, path, StringComparison.Ordinal)); + } + + // Compare the exact raw value immediately before replacing the complete list. + var confirmation = await ReadListAsync(ct).ConfigureAwait(false); + if (!confirmation.Ok) + { + return ListMutationOutcome.Fail( + $"Could not read GNOME shortcut list: {confirmation.Error.Trim()}" + ); + } + + if ( + !string.Equals(candidateRaw, confirmation.Raw, StringComparison.Ordinal) + ) + { + candidateRaw = confirmation.Raw; + continue; + } + + var backupPath = await SnapshotListAsync(candidateRaw, ct).ConfigureAwait(false); + if (backupPath is null) + { + return ListMutationOutcome.Fail( + "Could not write GNOME backup file. Refusing to modify shortcuts." + ); + } + + var (setOk, _, setErr) = await RunAsync( + "gsettings", + ["set", MediaKeysSchema, ListKey, FormatGSettingsList(list)], + ct + ) + .ConfigureAwait(false); + if (!setOk) + { + return ListMutationOutcome.Fail( + $"Could not update GNOME shortcut list: {setErr.Trim()}", + [backupPath] + ); + } + + return ListMutationOutcome.ChangedList(backupPath); + } + + return ListMutationOutcome.Fail( + "GNOME shortcut list kept changing while TypeWhisper was updating it. Please retry." ); } @@ -461,9 +614,9 @@ public static string FormatGnomeAccel(string trigger) { "ctrl" or "control" => "Control", "shift" => "Shift", - "alt" or "meta" => "Alt", - "super" or "win" or "windows" or "cmd" => "Super", - _ => null + "alt" => "Alt", + "super" or "win" or "windows" or "cmd" or "meta" => "Super", + _ => null, }; if (modifier is null) { @@ -492,7 +645,7 @@ public static string FormatGnomeAccel(string trigger) /// single quotes gsettings prints (unescaping \' and \\). /// Returns null when the key can't be read. /// - private static async Task GetStringValueAsync( + private async Task GetStringValueAsync( string schemaWithPath, string key, CancellationToken ct @@ -557,21 +710,105 @@ private static bool IsFunctionKey(string k) return true; } - private static async Task SnapshotListAsync(string currentValue, CancellationToken ct) + private async Task<(bool Ok, string Raw, string Error)> ReadListAsync( + CancellationToken ct + ) + { + var (ok, raw, error) = await RunAsync( + "gsettings", + ["get", MediaKeysSchema, ListKey], + ct + ) + .ConfigureAwait(false); + return (ok, raw, error); + } + + private async Task IsManagedPathListedAsync(string path, CancellationToken ct) + { + if (!DesktopDetector.BinaryExists("gsettings")) + { + return false; + } + + var (ok, listOut, _) = await ReadListAsync(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 { - var dir = Path.Join(TypeWhisperEnvironment.BasePath, "backups"); - Directory.CreateDirectory(dir); - var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); - var file = Path.Join(dir, $"gnome-keybindings-{stamp}.txt"); - var contents = + Directory.CreateDirectory(_backupDirectory); + var contents = Encoding.UTF8.GetBytes( $"# GNOME custom-keybindings list snapshot taken {DateTime.UtcNow:O}\n" + $"# Restore with:\n" + $"# gsettings set {MediaKeysSchema} {ListKey} \"\"\n" - + $"\n{currentValue.TrimEnd()}\n"; - await File.WriteAllTextAsync(file, contents, ct).ConfigureAwait(false); - return file; + + $"\n{currentValue.TrimEnd()}\n" + ); + + // CreateNew reserves the name, so a colliding stamp (a second instance, or a clock + // step) can never overwrite a snapshot the user may still need. + for (var attempt = 0; attempt < MaxSnapshotAttempts; attempt++) + { + var stamp = DateTime.UtcNow.ToString( + "yyyyMMdd-HHmmss-fffffff", + CultureInfo.InvariantCulture + ); + var suffix = attempt == 0 + ? "" + : $"-{attempt.ToString(CultureInfo.InvariantCulture)}"; + var file = Path.Join(_backupDirectory, $"gnome-keybindings-{stamp}{suffix}.txt"); + FileStream stream; + try + { + stream = new FileStream( + file, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None + ); + } + catch (IOException) when (File.Exists(file)) + { + // Name already taken — regenerate and retry. Only the create step retries: + // a write that fails after the file exists is a real failure, not a collision. + continue; + } + + try + { + await using (stream) + { + await stream.WriteAsync(contents, ct).ConfigureAwait(false); + } + + return file; + } + catch + { + // Never leave a truncated file behind — it would read as a valid snapshot. + TryDeleteFile(file); + throw; + } + } + + return null; + } + catch (OperationCanceledException) + { + throw; } catch { @@ -579,47 +816,53 @@ private static bool IsFunctionKey(string k) } } - private static async Task<(bool ok, string stdout, string stderr)> RunAsync( + private async Task<(bool ok, string stdout, string stderr)> RunAsync( string fileName, IReadOnlyList args, CancellationToken ct ) { - var psi = new ProcessStartInfo - { - FileName = fileName, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - foreach (var a in args) - { - psi.ArgumentList.Add(a); - } + var result = await _processRunner.RunAsync( + fileName, + args, + timeout: s_gsettingsTimeout, + ct: ct + ) + .ConfigureAwait(false); + // Some runners report cancellation as a result rather than throwing; enforce it either way. + ct.ThrowIfCancellationRequested(); + var error = result.TimedOut + ? $"{fileName} timed out after {s_gsettingsTimeout.TotalSeconds:0} seconds." + : result.StandardError; + return (result.Succeeded, result.StandardOutput, error); + } - try + private sealed record ListMutationOutcome( + bool Changed, + string? BackupPath, + DeShortcutWriteResult? Failure + ) + { + public static ListMutationOutcome ChangedList(string backupPath) { - using var proc = Process.Start(psi); - if (proc is null) - { - return (false, string.Empty, $"Could not start {fileName}"); - } - - var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); - var stderrTask = proc.StandardError.ReadToEndAsync(ct); - await proc.WaitForExitAsync(ct).ConfigureAwait(false); - var stdout = await stdoutTask.ConfigureAwait(false); - var stderr = await stderrTask.ConfigureAwait(false); - return (proc.ExitCode == 0, stdout, stderr); + return new ListMutationOutcome(true, backupPath, null); } - catch (OperationCanceledException) + + public static ListMutationOutcome NoChange() { - throw; + return new ListMutationOutcome(false, null, null); } - catch (Exception ex) + + public static ListMutationOutcome Fail( + string message, + IReadOnlyList? filesChanged = null + ) { - return (false, string.Empty, ex.Message); + return new ListMutationOutcome( + false, + null, + new DeShortcutWriteResult(false, message, filesChanged ?? []) + ); } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs index 3b982c0ab..6e53d2878 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/HyprlandShortcutWriter.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Text; namespace TypeWhisper.Linux.Services.Hotkey.DeSetup; @@ -6,23 +5,60 @@ 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 { + private const int MaxWriteAttempts = 3; + private const string RemovalRequiresReloadWarning = + "Hyprland may still have the live binding. Run `hyprctl reload` (or restart Hyprland) to remove it."; + + private static readonly TimeSpan s_reloadTimeout = TimeSpan.FromSeconds(10); + + private readonly Func< + AtomicFileSnapshot, + string, + CancellationToken, + Task + > _conditionalWriteAsync; + + private readonly IProcessRunner _processRunner; + + public HyprlandShortcutWriter() + : this(new ProcessRunner()) { } + + // ReSharper disable once MemberCanBePrivate.Global -- public DI seam: callers inject an IProcessRunner; the parameterless overload chains here with a real ProcessRunner. + public HyprlandShortcutWriter(IProcessRunner processRunner) + : this(processRunner, AtomicFileWriter.WriteIfUnchangedAsync) { } + + internal HyprlandShortcutWriter( + Func> conditionalWriteAsync + ) + : this(new ProcessRunner(), conditionalWriteAsync) { } + + internal HyprlandShortcutWriter( + IProcessRunner processRunner, + Func> conditionalWriteAsync + ) + { + _processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner)); + _conditionalWriteAsync = conditionalWriteAsync + ?? throw new ArgumentNullException(nameof(conditionalWriteAsync)); + } + public string DesktopId => "hyprland"; 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"); } @@ -40,30 +76,21 @@ 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; - } + // 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 + ) + { + return await ReadManagedBlockLinesAsync(ct).ConfigureAwait(false) is not null; } public async Task WriteAsync(DeShortcutSpec spec, CancellationToken ct) @@ -83,97 +110,144 @@ public async Task WriteAsync(DeShortcutSpec spec, Cancell ); } - var existing = File.Exists(path) - ? await File.ReadAllTextAsync(path, ct).ConfigureAwait(false) - : string.Empty; - var scan = SentinelBlock.Scan(existing); - if (scan.Mismatched) - { - return new DeShortcutWriteResult( - false, - $"Your hyprland.conf has an unbalanced TypeWhisper managed block. {scan.Reason} Fix it manually (remove the stray sentinel lines) and try again.", - [] - ); - } - var managed = BuildManagedLines(spec).ToList(); - var updated = SentinelBlock.ReplaceOrAppend(existing, managed); - try + var committed = false; + for (var attempt = 0; attempt < MaxWriteAttempts; attempt++) { - await AtomicFileWriter.WriteAsync(path, updated, ct).ConfigureAwait(false); + try + { + var snapshot = await AtomicFileWriter.CaptureAsync(path, ct) + .ConfigureAwait(false); + var scan = SentinelBlock.Scan(snapshot.Contents); + if (scan.Mismatched) + { + return new DeShortcutWriteResult( + false, + $"Your hyprland.conf has an unbalanced TypeWhisper managed block. {scan.Reason} Fix it manually (remove the stray sentinel lines) and try again.", + [] + ); + } + + var updated = SentinelBlock.ReplaceOrAppend(snapshot.Contents, managed); + // ReSharper disable once InvertIf -- the conditional-write commit/break is the deliberate success path of the capture-and-retry loop; leave it as-is rather than inverting into a continue. + if ( + await _conditionalWriteAsync(snapshot, updated, ct).ConfigureAwait(false) + ) + { + committed = true; + break; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new DeShortcutWriteResult( + false, + $"Could not write {path}: {ex.Message}", + [] + ); + } } - catch (Exception ex) + + if (!committed) { return new DeShortcutWriteResult( false, - $"Could not write {path}: {ex.Message}", + "hyprland.conf kept changing while TypeWhisper was updating it. Please retry.", [] ); } - // 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); } public async Task RemoveAsync(string shortcutId, CancellationToken ct) { var path = ResolveConfigPath(); - if (!File.Exists(path)) + for (var attempt = 0; attempt < MaxWriteAttempts; attempt++) { - return new DeShortcutWriteResult( - true, - "No hyprland.conf to update.", - [] - ); - } + try + { + var snapshot = await AtomicFileWriter.CaptureAsync(path, ct) + .ConfigureAwait(false); + if (!snapshot.Existed) + { + return new DeShortcutWriteResult( + true, + "No hyprland.conf to update.", + [], + RemovalRequiresReloadWarning + ); + } - var existing = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); - var scan = SentinelBlock.Scan(existing); - if (scan.Mismatched) - { - return new DeShortcutWriteResult( - false, - $"Your hyprland.conf has an unbalanced TypeWhisper managed block. {scan.Reason} Fix it manually and try again.", - [] - ); - } + var scan = SentinelBlock.Scan(snapshot.Contents); + if (scan.Mismatched) + { + return new DeShortcutWriteResult( + false, + $"Your hyprland.conf has an unbalanced TypeWhisper managed block. {scan.Reason} Fix it manually and try again.", + [] + ); + } - if (scan.OpenLine is null) - { - return new DeShortcutWriteResult( - true, - "No Hyprland integration to remove.", - [] - ); - } + if (scan.OpenLine is null) + { + return new DeShortcutWriteResult( + true, + "No Hyprland integration to remove.", + [], + RemovalRequiresReloadWarning + ); + } - var updated = SentinelBlock.Remove(existing); - try - { - await AtomicFileWriter.WriteAsync(path, updated, ct).ConfigureAwait(false); - } - catch (Exception ex) - { - return new DeShortcutWriteResult( - false, - $"Could not write {path}: {ex.Message}", - [] - ); + var updated = SentinelBlock.Remove(snapshot.Contents); + if ( + !await _conditionalWriteAsync(snapshot, updated, ct).ConfigureAwait(false) + ) + { + continue; + } + + var reloaded = await ReloadAsync(ct).ConfigureAwait(false); + var warning = reloaded + ? null + : RemovalRequiresReloadWarning; + return new DeShortcutWriteResult( + true, + "Hyprland managed block removed.", + [path], + warning + ); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new DeShortcutWriteResult( + false, + $"Could not write {path}: {ex.Message}", + [] + ); + } } - // Hyprland's unbind syntax varies across versions; asking the user - // to reload is more robust than attempting a live removal. return new DeShortcutWriteResult( - true, - "Hyprland managed block removed. Run `hyprctl reload` (or restart Hyprland) to drop the live binding.", - [path] + false, + "hyprland.conf kept changing while TypeWhisper was removing its managed block. Please retry.", + [] ); } @@ -206,9 +280,9 @@ public static (string mods, string key) ToHyprlandBind(string trigger) { "ctrl" or "control" => "CTRL", "shift" => "SHIFT", - "alt" or "meta" => "ALT", - "super" or "win" or "windows" or "cmd" => "SUPER", - _ => parts[i].ToUpperInvariant() + "alt" => "ALT", + "super" or "win" or "windows" or "cmd" or "meta" => "SUPER", + _ => parts[i].ToUpperInvariant(), } ); } @@ -238,35 +312,54 @@ 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 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. + } - return !anyFailed; + private async Task ReloadAsync(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (!DesktopDetector.BinaryExists("hyprctl")) + { + return false; + } + + var result = await _processRunner.RunAsync( + "hyprctl", + ["reload"], + timeout: s_reloadTimeout, + ct: ct + ) + .ConfigureAwait(false); + // Some runners report cancellation as a result rather than throwing; enforce it either way. + ct.ThrowIfCancellationRequested(); + return result.Succeeded; } private static string ResolveConfigPath() @@ -276,50 +369,4 @@ private static string ResolveConfigPath() var configHome = string.IsNullOrEmpty(xdg) ? Path.Join(home, ".config") : xdg; return Path.Join(configHome, "hypr", "hyprland.conf"); } - - private static async Task<(bool ok, string stdout, string stderr)> RunAsync( - string fileName, - IReadOnlyList args, - CancellationToken ct - ) - { - var psi = new ProcessStartInfo - { - FileName = fileName, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - foreach (var a in args) - { - psi.ArgumentList.Add(a); - } - - try - { - using var proc = Process.Start(psi); - if (proc is null) - { - return (false, string.Empty, $"Could not start {fileName}"); - } - - var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); - var stderrTask = proc.StandardError.ReadToEndAsync(ct); - await proc.WaitForExitAsync(ct).ConfigureAwait(false); - var stdout = await stdoutTask.ConfigureAwait(false); - var stderr = await stderrTask.ConfigureAwait(false); - return (proc.ExitCode == 0, stdout, stderr); - } - catch (OperationCanceledException) - { - // Cancellation must propagate to callers; only genuine process/apply - // errors are flattened into the failure tuple below. - throw; - } - catch (Exception ex) - { - return (false, string.Empty, ex.Message); - } - } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/IDeShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/IDeShortcutWriter.cs index 8ae051602..5e59e860d 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/IDeShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/IDeShortcutWriter.cs @@ -55,6 +55,17 @@ 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. + /// 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); + /// /// Install the shortcut. Idempotent: running twice with the same /// spec must produce the same final state, not duplicate entries. @@ -107,4 +118,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 5479d0a2a..3fab132d7 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/KdeShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/KdeShortcutWriter.cs @@ -7,6 +7,7 @@ namespace TypeWhisper.Linux.Services.Hotkey.DeSetup; /// Writes a .desktop entry into ~/.local/share/kglobalaccel/. /// KGlobalAccel scans that directory on session start; the user can override the /// trigger from System Settings → Shortcuts. +/// Existing targets are changed only when the ownership marker and shortcut ID match. /// The live D-Bus path (org.kde.kglobalaccel.registerShortcut) is avoided /// because it's fragile across Plasma versions and a static toggle doesn't need /// the immediate-effect property. Cost: user must log out once to activate. @@ -51,9 +52,27 @@ 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); + if (File.Exists(target) && !IsOwnedByTypeWhisper(target, spec.ShortcutId)) + { + return new DeShortcutWriteResult( + false, + $"Left {target} untouched — it doesn't carry TypeWhisper's ownership markers, so we won't overwrite it. Remove or rename it manually, then try again.", + [] + ); + } + try { Directory.CreateDirectory(dir); @@ -102,6 +121,18 @@ public Task RemoveAsync(string shortcutId, CancellationTo ); } + if (!IsOwnedByTypeWhisper(target, shortcutId)) + { + return Task.FromResult( + new DeShortcutWriteResult( + true, + "KDE shortcut file left in place.", + [], + $"Left {target} untouched — it doesn't carry TypeWhisper's ownership markers, so we won't delete it. Remove it manually if you want to." + ) + ); + } + try { File.Delete(target); @@ -127,10 +158,7 @@ public Task RemoveAsync(string shortcutId, CancellationTo 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; - var dir = Path.Join(dataHome, "kglobalaccel"); + var dir = Path.Join(XdgPaths.ResolveDataHome(), "kglobalaccel"); return (dir, Path.Join(dir, FileName(shortcutId))); } @@ -174,6 +202,28 @@ private static string BuildDesktopFile(DeShortcutSpec spec) ); } + // Require both exact lines: a marker alone could belong to a different shortcut, + // while full-file equality would reject legitimate trigger updates. + private static bool IsOwnedByTypeWhisper(string target, string shortcutId) + { + string contents; + try + { + contents = File.ReadAllText(target); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Refuse destructive changes when ownership cannot be proven. + return false; + } + + var lines = contents.Split('\n').Select(line => line.TrimEnd('\r')); + var lineSet = lines.ToHashSet(StringComparer.Ordinal); + const string managedLine = "X-TypeWhisper-Managed=true"; + var idLine = $"X-TypeWhisper-ShortcutId={EscapeDesktopValue(shortcutId)}"; + return lineSet.Contains(managedLine) && lineSet.Contains(idLine); + } + private static string EscapeDesktopValue(string value) { // Desktop Entry Specification escaping: \\ for backslash, \n/\r/\t for control chars, @@ -218,4 +268,4 @@ private static string EscapeDesktopValue(string value) return sb.ToString(); } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs index 7cfe2d9cb..4562b8a30 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/DeSetup/SwayShortcutWriter.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Text; namespace TypeWhisper.Linux.Services.Hotkey.DeSetup; @@ -10,6 +9,44 @@ namespace TypeWhisper.Linux.Services.Hotkey.DeSetup; /// 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< + AtomicFileSnapshot, + string, + CancellationToken, + Task + > _conditionalWriteAsync; + + private readonly IProcessRunner _processRunner; + + public SwayShortcutWriter() + : this(new ProcessRunner()) { } + + // ReSharper disable once MemberCanBePrivate.Global -- public DI seam: callers inject an IProcessRunner; the parameterless overload chains here with a real ProcessRunner. + public SwayShortcutWriter(IProcessRunner processRunner) + : this(processRunner, AtomicFileWriter.WriteIfUnchangedAsync) { } + + internal SwayShortcutWriter( + Func> conditionalWriteAsync + ) + : this(new ProcessRunner(), conditionalWriteAsync) { } + + internal SwayShortcutWriter( + IProcessRunner processRunner, + Func> conditionalWriteAsync + ) + { + _processRunner = processRunner ?? throw new ArgumentNullException(nameof(processRunner)); + _conditionalWriteAsync = conditionalWriteAsync + ?? throw new ArgumentNullException(nameof(conditionalWriteAsync)); + } + public string DesktopId => "sway"; public string DisplayName => "Sway"; public bool SupportsPushToTalk => true; @@ -36,29 +73,20 @@ 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; - } + // 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 + ) + { + return await ReadManagedBlockLinesAsync(ct).ConfigureAwait(false) is not null; } public async Task WriteAsync(DeShortcutSpec spec, CancellationToken ct) @@ -78,30 +106,53 @@ public async Task WriteAsync(DeShortcutSpec spec, Cancell ); } - var existing = File.Exists(path) - ? await File.ReadAllTextAsync(path, ct).ConfigureAwait(false) - : string.Empty; - var scan = SentinelBlock.Scan(existing); - if (scan.Mismatched) - { - return new DeShortcutWriteResult( - false, - $"Your sway config has an unbalanced TypeWhisper managed block. {scan.Reason} Fix it manually and try again.", - [] - ); - } - var managed = BuildManagedLines(spec).ToList(); - var updated = SentinelBlock.ReplaceOrAppend(existing, managed); - try + var committed = false; + for (var attempt = 0; attempt < MaxWriteAttempts; attempt++) { - await AtomicFileWriter.WriteAsync(path, updated, ct).ConfigureAwait(false); + try + { + var snapshot = await AtomicFileWriter.CaptureAsync(path, ct) + .ConfigureAwait(false); + var scan = SentinelBlock.Scan(snapshot.Contents); + if (scan.Mismatched) + { + return new DeShortcutWriteResult( + false, + $"Your sway config has an unbalanced TypeWhisper managed block. {scan.Reason} Fix it manually and try again.", + [] + ); + } + + var updated = SentinelBlock.ReplaceOrAppend(snapshot.Contents, managed); + // ReSharper disable once InvertIf -- the conditional-write commit/break is the deliberate success path of the capture-and-retry loop; leave it as-is rather than inverting into a continue. + if ( + await _conditionalWriteAsync(snapshot, updated, ct).ConfigureAwait(false) + ) + { + committed = true; + break; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new DeShortcutWriteResult( + false, + $"Could not write {path}: {ex.Message}", + [] + ); + } } - catch (Exception ex) + + if (!committed) { return new DeShortcutWriteResult( false, - $"Could not write {path}: {ex.Message}", + "Sway config kept changing while TypeWhisper was updating it. Please retry.", [] ); } @@ -117,58 +168,79 @@ public async Task WriteAsync(DeShortcutSpec spec, Cancell public async Task RemoveAsync(string shortcutId, CancellationToken ct) { var path = ResolveConfigPath(); - if (!File.Exists(path)) + for (var attempt = 0; attempt < MaxWriteAttempts; attempt++) { - return new DeShortcutWriteResult( - true, - "No sway config to update.", - [] - ); - } - - var existing = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); - var scan = SentinelBlock.Scan(existing); - if (scan.Mismatched) - { - return new DeShortcutWriteResult( - false, - $"Your sway config has an unbalanced TypeWhisper managed block. {scan.Reason} Fix it manually and try again.", - [] - ); - } - - if (scan.OpenLine is null) - { - return new DeShortcutWriteResult( - true, - "No Sway integration to remove.", - [] - ); - } - - var updated = SentinelBlock.Remove(existing); - try - { - await AtomicFileWriter.WriteAsync(path, updated, ct).ConfigureAwait(false); - } - catch (Exception ex) - { - return new DeShortcutWriteResult( - false, - $"Could not write {path}: {ex.Message}", - [] - ); + try + { + var snapshot = await AtomicFileWriter.CaptureAsync(path, ct) + .ConfigureAwait(false); + if (!snapshot.Existed) + { + return new DeShortcutWriteResult( + true, + "No sway config to update.", + [], + RemovalRequiresReloadWarning + ); + } + + var scan = SentinelBlock.Scan(snapshot.Contents); + if (scan.Mismatched) + { + return new DeShortcutWriteResult( + false, + $"Your sway config has an unbalanced TypeWhisper managed block. {scan.Reason} Fix it manually and try again.", + [] + ); + } + + if (scan.OpenLine is null) + { + return new DeShortcutWriteResult( + true, + "No Sway integration to remove.", + [], + RemovalRequiresReloadWarning + ); + } + + var updated = SentinelBlock.Remove(snapshot.Contents); + if ( + !await _conditionalWriteAsync(snapshot, updated, ct).ConfigureAwait(false) + ) + { + continue; + } + + var reloaded = await ReloadAsync(ct).ConfigureAwait(false); + var warning = reloaded + ? null + : RemovalRequiresReloadWarning; + return new DeShortcutWriteResult( + true, + "Sway managed block removed.", + [path], + warning + ); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new DeShortcutWriteResult( + false, + $"Could not write {path}: {ex.Message}", + [] + ); + } } - 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."; return new DeShortcutWriteResult( - true, - "Sway managed block removed.", - [path], - warning + false, + "Sway config kept changing while TypeWhisper was removing its managed block. Please retry.", + [] ); } @@ -200,9 +272,9 @@ public static string ToSwayBind(string trigger) { "ctrl" or "control" => "Ctrl", "shift" => "Shift", - "alt" or "meta" => "Alt", - "super" or "win" or "windows" or "cmd" => "Mod4", - _ => parts[i] + "alt" => "Alt", + "super" or "win" or "windows" or "cmd" or "meta" => "Mod4", + _ => parts[i], }; if (sb.Length > 0) { @@ -276,68 +348,61 @@ private static bool IsFunctionKey(string k) return true; } - private static string ResolveConfigPath() - { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); - var configHome = string.IsNullOrEmpty(xdg) ? Path.Join(home, ".config") : xdg; - return Path.Join(configHome, "sway", "config"); - } - - private static async Task ReloadAsync(CancellationToken ct) - { - if (!DesktopDetector.BinaryExists("swaymsg")) - { - return false; - } - - var (ok, _, _) = await RunAsync("swaymsg", ["reload"], ct).ConfigureAwait(false); - return ok; - } - - private static async Task<(bool ok, string stdout, string stderr)> RunAsync( - string fileName, - IReadOnlyList args, + private static async Task?> ReadManagedBlockLinesAsync( CancellationToken ct ) { - var psi = new ProcessStartInfo - { - FileName = fileName, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - foreach (var a in args) + var path = ResolveConfigPath(); + if (!File.Exists(path)) { - psi.ArgumentList.Add(a); + return null; } try { - using var proc = Process.Start(psi); - if (proc is null) + var existing = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); + var scan = SentinelBlock.Scan(existing); + if (scan.Mismatched || scan.OpenLine is null) { - return (false, string.Empty, $"Could not start {fileName}"); + return null; } - var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); - var stderrTask = proc.StandardError.ReadToEndAsync(ct); - await proc.WaitForExitAsync(ct).ConfigureAwait(false); - var stdout = await stdoutTask.ConfigureAwait(false); - var stderr = await stderrTask.ConfigureAwait(false); - return (proc.ExitCode == 0, stdout, stderr); + return SentinelBlock.ExtractBlockLines(existing); } - catch (OperationCanceledException) + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { - // Cancellation must propagate to callers; only genuine Sway/process - // errors are flattened into the failure tuple below. - throw; + // Raced with a delete between the Exists probe and the read — not installed. + return null; } - catch (Exception ex) + // Permission and transient I/O failures propagate: callers treat an + // indeterminate probe as "unknown" rather than erasing a known state. + } + + private static string ResolveConfigPath() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + var configHome = string.IsNullOrEmpty(xdg) ? Path.Join(home, ".config") : xdg; + return Path.Join(configHome, "sway", "config"); + } + + private async Task ReloadAsync(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (!DesktopDetector.BinaryExists("swaymsg")) { - return (false, string.Empty, ex.Message); + return false; } + + var result = await _processRunner.RunAsync( + "swaymsg", + ["reload"], + timeout: s_reloadTimeout, + ct: ct + ) + .ConfigureAwait(false); + // Some runners report cancellation as a result rather than throwing; enforce it either way. + ct.ThrowIfCancellationRequested(); + return result.Succeeded; } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevDeviceReader.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevDeviceReader.cs index 3248ccb8c..3e46acc56 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevDeviceReader.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevDeviceReader.cs @@ -14,18 +14,30 @@ internal sealed class EvdevDeviceReader : IEvdevDeviceReader private readonly CancellationTokenSource _cts = new(); private readonly Action _onFailure; private readonly Action _onKeyEvent; + private readonly HashSet _pressedKeys = []; private int _disposed; + private IEvdevInputDevice? _inputDevice; private Task? _readLoop; - private FileStream? _stream; public EvdevDeviceReader( string path, Action onKeyEvent, Action onFailure + ) + : this(path, new EvdevInputDevice(path), onKeyEvent, onFailure) + { + } + + internal EvdevDeviceReader( + string path, + IEvdevInputDevice inputDevice, + Action onKeyEvent, + Action onFailure ) { Path = path; + _inputDevice = inputDevice; _onKeyEvent = onKeyEvent; _onFailure = onFailure; } @@ -45,12 +57,12 @@ public async ValueTask DisposeAsync() // session safety instead comes from EvdevGlobalShortcutBackend.OnKeyEvent: its cached and // live input checks, lifecycle generation, and reader-membership guard drop stale events. // The generation check alone rejects the old reader after the device is reattached on unlock. - var stream = Interlocked.Exchange(ref _stream, null); + var inputDevice = Interlocked.Exchange(ref _inputDevice, null); try { // ReSharper disable once MethodHasAsyncOverload -- synchronous Dispose is deliberate: // it closes promptly when no read is in flight; DisposeAsync would defer even that. - stream?.Dispose(); + inputDevice?.Dispose(); } catch (Exception ex) { @@ -88,14 +100,9 @@ public bool TryStart() { try { - _stream = new FileStream( - Path, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite, - 0, - true - ); + var inputDevice = _inputDevice + ?? throw new ObjectDisposedException(nameof(EvdevDeviceReader)); + inputDevice.Open(); } catch (Exception ex) { @@ -109,13 +116,14 @@ public bool TryStart() private async Task RunAsync(CancellationToken ct) { - var stream = _stream; - if (stream is null) + var inputDevice = _inputDevice; + if (inputDevice is null) { return; } var buf = new byte[InputEvent.SizeBytes]; + var recovering = false; Exception? terminating = null; try { @@ -127,7 +135,7 @@ private async Task RunAsync(CancellationToken ct) int n; try { - n = await stream + n = await inputDevice .ReadAsync(buf.AsMemory(read, InputEvent.SizeBytes - read), ct) .ConfigureAwait(false); } @@ -156,17 +164,37 @@ private async Task RunAsync(CancellationToken ct) } var evt = MemoryMarshal.Read(buf); - if (evt.Type != InputEvent.EvKey) + if (recovering) { + if (evt is { Type: InputEvent.EvSyn, Code: InputEvent.SynReport }) + { + Reconcile(inputDevice.QueryPressedKeyBitmap()); + recovering = false; + } + + continue; + } + + if (evt is { Type: InputEvent.EvSyn, Code: InputEvent.SynDropped }) + { + recovering = true; continue; } - if (evt.Value == InputEvent.Repeated) + if (evt.Type != InputEvent.EvKey) { continue; } - _onKeyEvent(Path, evt.Code, evt.Value == InputEvent.Pressed); + switch (evt.Value) + { + case InputEvent.Pressed when _pressedKeys.Add(evt.Code): + _onKeyEvent(Path, evt.Code, true); + break; + case InputEvent.Released when _pressedKeys.Remove(evt.Code): + _onKeyEvent(Path, evt.Code, false); + break; + } } } catch (Exception ex) @@ -188,4 +216,44 @@ private async Task RunAsync(CancellationToken ct) } } } + + private void Reconcile(ReadOnlySpan keyBits) + { + var snapshot = new HashSet(); + var highestKey = Math.Min(EvdevInputDevice.KeyMax, keyBits.Length * 8 - 1); + for (var keyCode = 0; keyCode <= highestKey; keyCode++) + { + if ((keyBits[keyCode / 8] & (1 << (keyCode % 8))) != 0) + { + snapshot.Add(keyCode); + } + } + + // Clear terminal-key guards before modifiers, then rebuild modifiers before terminal keys + // so reconstructed chords carry the same aggregate modifier snapshot as normal input. + foreach ( + var keyCode in _pressedKeys + .Except(snapshot) + .OrderBy(static code => LinuxKeyMap.IsModifier(code) ? 1 : 0) + .ThenBy(static code => code) + ) + { + _onKeyEvent(Path, keyCode, false); + } + + foreach ( + var keyCode in snapshot + .Except(_pressedKeys) + .OrderBy(static code => LinuxKeyMap.IsModifier(code) ? 0 : 1) + .ThenBy(static code => code) + ) + { + _onKeyEvent(Path, keyCode, true); + } + + // Keep the pre-drop set intact until every logical edge has been accepted. If a callback + // throws, the failure path detaches the reader and the backend subtracts what it received. + _pressedKeys.Clear(); + _pressedKeys.UnionWith(snapshot); + } } diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevGlobalShortcutBackend.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/EvdevGlobalShortcutBackend.cs index 3dffce0d0..b03d30973 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; @@ -28,14 +29,12 @@ public sealed class EvdevGlobalShortcutBackend : IGlobalShortcutBackend private readonly bool _enableDeviceMonitoring; private readonly Lock _lock = new(); private readonly bool _ownsSessionActivityMonitor; + private readonly Dictionary _aggregateKeyCounts = new(); + private readonly Dictionary> _pressedKeysByDevice = new(); private readonly Dictionary _readers = new(); private readonly ISessionActivityMonitor _sessionActivityMonitor; private int _disposed; - // Aggregated modifier state across all keyboards. evdev gives individual key - // transitions; we maintain the mask via Interlocked.Or/And so concurrent - // reader tasks can update bits without a read-modify-write race. - private int _liveModifiersBits; private long _lifecycleGeneration; private CancellationTokenSource? _rescanCts; private bool _inputAllowed = true; @@ -250,9 +249,13 @@ public async ValueTask DisposeAsync() _rescanCts = null; readers = _readers.Values.ToList(); _readers.Clear(); - ResetInputState_NoLock(); + ClearInputState_NoLock(); } + // Whole-input teardown is the security boundary: clear dispatcher guards and discard any + // recording exactly once, outside the backend lock so event handlers cannot invert locks. + _dispatcher.ResetState(); + try { watcher?.Dispose(); @@ -300,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 ( @@ -319,13 +322,17 @@ private void TryAttach_NoLock(string path, long generation) OnKeyEvent(generation, devicePath, linuxKeyCode, pressed), (devicePath, exception) => OnReaderFailure(generation, devicePath, exception) ); + // Before TryStart: a fast first event then waits on this lock and sees fully attached state. + _readers[path] = reader; + _pressedKeysByDevice[path] = []; if (reader.TryStart()) { - _readers[path] = reader; Trace.WriteLine($"[EvdevBackend] Attached {path}"); } else { + _readers.Remove(path); + _pressedKeysByDevice.Remove(path); _ = reader.DisposeAsync(); } } @@ -346,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; @@ -401,26 +408,30 @@ 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; + List releases; lock (_lock) { - _readers.Remove(e.FullPath, out reader); + releases = DetachDevice_NoLock(e.FullPath, out reader); } if (reader is not null) { _ = reader.DisposeAsync(); } + + 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; List? toDispose = null; + List? releases = null; lock (_lock) { if (Volatile.Read(ref _disposed) == 1 || !_inputAllowed) @@ -432,20 +443,28 @@ private bool Rescan() // Prune readers for paths that vanished — guards against FSW dropping Delete events under load. // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- loop mutates _readers and builds toDispose; a LINQ rewrite would obscure the side effects - foreach (var existing in _readers.Keys.ToList()) + foreach (var existing in _readers.Keys.Order(StringComparer.Ordinal).ToList()) { if (_deviceEnumerator.Exists(existing)) { continue; } - if (!_readers.Remove(existing, out var stale)) + var detachedReleases = DetachDevice_NoLock(existing, out var stale); + if (stale is null) { continue; } toDispose ??= []; toDispose.Add(stale); + if (detachedReleases.Count == 0) + { + continue; + } + + releases ??= []; + releases.AddRange(detachedReleases); } foreach (var path in _deviceEnumerator.EnumerateKeyboards()) @@ -470,6 +489,11 @@ private bool Rescan() _ = r.DisposeAsync(); } + if (releases is not null) + { + DispatchEdges(releases); + } + return added; } @@ -480,8 +504,7 @@ private void OnKeyEvent( bool pressed ) { - KeyCode dispatchKey; - ModifierMask dispatchMods; + DispatchEdge dispatchEdge; lock (_lock) { // Reader callbacks can already be queued when a session transition closes the fd. @@ -495,45 +518,16 @@ bool pressed || !_sessionActivityMonitor.IsInputAllowed || generation != _lifecycleGeneration || !_readers.ContainsKey(devicePath) + || !_pressedKeysByDevice.TryGetValue(devicePath, out var deviceKeys) ) { return; } - // Deliberately no per-keyboard modifier refcounting; lock transitions reset the - // whole aggregate instead (ResetInputState_NoLock). - var modBit = LinuxKeyMap.ToModifier(linuxKeyCode); - if (modBit != ModifierMask.None) - { - var bitsInt = (int)modBit; - if (pressed) - { - Interlocked.Or(ref _liveModifiersBits, bitsInt); - } - else - { - Interlocked.And(ref _liveModifiersBits, ~bitsInt); - } - // Modifiers can themselves be the trigger key (e.g. RightCtrl bound to dictation), - // so fall through to the dispatcher. - } - - var sharpHookKey = LinuxKeyMap.ToSharpHook(linuxKeyCode); - if (sharpHookKey is null) + if (!TryApplyDeviceEdge_NoLock(deviceKeys, linuxKeyCode, pressed, out dispatchEdge)) { return; } - - var mods = (ModifierMask)Volatile.Read(ref _liveModifiersBits); - // If the trigger key is itself a modifier, its bit will be set in mods on press. - // Mask it out so a "no other modifiers" binding like RightCtrl still matches. - if (modBit != ModifierMask.None) - { - mods &= ~modBit; - } - - dispatchKey = sharpHookKey.Value; - dispatchMods = mods; } // Dispatch OUTSIDE the backend lock. A shortcut handler runs synchronously up to its first @@ -545,19 +539,15 @@ bool pressed // transition can advance the generation and reset the dispatcher. Dictation starts are also // gated in the orchestrator, but prompt-action/copy-last/transform-selection shortcuts are // not, so this stops any of them from firing after the session has locked. - if (!_sessionActivityMonitor.IsInputAllowed) - { - return; - } - - _dispatcher.Handle(dispatchKey, dispatchMods, 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}"); IEvdevDeviceReader? reader; + List releases; lock (_lock) { if (generation != _lifecycleGeneration || Volatile.Read(ref _disposed) == 1) @@ -565,7 +555,7 @@ private void OnReaderFailure(long generation, string path, Exception ex) return; } - _readers.Remove(path, out reader); + releases = DetachDevice_NoLock(path, out reader); } if (reader is not null) @@ -573,9 +563,7 @@ private void OnReaderFailure(long generation, string path, Exception ex) _ = reader.DisposeAsync(); } - // Clear modifier mask on disconnect: a held modifier on the lost device would - // stay "down" forever otherwise. The next press from any remaining keyboard re-asserts. - Volatile.Write(ref _liveModifiersBits, 0); + DispatchEdges(releases); Failed?.Invoke(this, $"Lost keyboard device {path}: {ex.Message}"); } @@ -584,6 +572,7 @@ private void OnInputAllowedChanged(object? sender, EventArgs e) List? readers = null; long generation; var reopen = false; + var resetState = false; lock (_lock) { @@ -602,7 +591,8 @@ private void OnInputAllowedChanged(object? sender, EventArgs e) { readers = _readers.Values.ToList(); _readers.Clear(); - ResetInputState_NoLock(); + ClearInputState_NoLock(); + resetState = true; } else { @@ -610,6 +600,13 @@ private void OnInputAllowedChanged(object? sender, EventArgs e) } } + if (resetState) + { + // Session loss is a whole-input teardown, unlike one-device detach. Preserve its + // discard semantics without invoking dispatcher event handlers under the backend lock. + _dispatcher.ResetState(); + } + if (readers is not null) { // DisposeAsync on a real reader closes its FileStream synchronously before its first @@ -668,12 +665,175 @@ private void QueueReopen(long generation) }); } - private void ResetInputState_NoLock() + private bool TryApplyDeviceEdge_NoLock( + HashSet deviceKeys, + int linuxKeyCode, + bool pressed, + out DispatchEdge dispatchEdge + ) { - Volatile.Write(ref _liveModifiersBits, 0); - _dispatcher.ResetState(); + dispatchEdge = default; + if (pressed) + { + if (!deviceKeys.Add(linuxKeyCode)) + { + return false; + } + + _aggregateKeyCounts.TryGetValue(linuxKeyCode, out var previousCount); + _aggregateKeyCounts[linuxKeyCode] = previousCount + 1; + if (previousCount != 0) + { + return false; + } + } + else + { + if (!deviceKeys.Remove(linuxKeyCode)) + { + return false; + } + + var previousCount = _aggregateKeyCounts[linuxKeyCode]; + if (previousCount > 1) + { + _aggregateKeyCounts[linuxKeyCode] = previousCount - 1; + return false; + } + + _aggregateKeyCounts.Remove(linuxKeyCode); + } + + return TryCreateDispatchEdge_NoLock(linuxKeyCode, pressed, out dispatchEdge); + } + + private List DetachDevice_NoLock( + string path, + out IEvdevDeviceReader? reader + ) + { + if (!_readers.Remove(path, out reader)) + { + return []; + } + + // ReSharper disable once DuplicatedSequentialIfBodies -- deliberate detach ordering: the reader map must be removed (setting the reader out-param) before the pressed-key map; the two removals have distinct side effects and cannot be merged + if (!_pressedKeysByDevice.Remove(path, out var deviceKeys)) + { + return []; + } + + var releases = new List(); + foreach ( + var linuxKeyCode in deviceKeys + .OrderBy(static code => LinuxKeyMap.IsModifier(code) ? 1 : 0) + .ThenBy(static code => code) + ) + { + if (!_aggregateKeyCounts.TryGetValue(linuxKeyCode, out var previousCount)) + { + continue; + } + + if (previousCount > 1) + { + _aggregateKeyCounts[linuxKeyCode] = previousCount - 1; + continue; + } + + _aggregateKeyCounts.Remove(linuxKeyCode); + if (TryCreateDispatchEdge_NoLock(linuxKeyCode, false, out var release)) + { + releases.Add(release); + } + } + + return releases; + } + + private bool TryCreateDispatchEdge_NoLock( + int linuxKeyCode, + bool pressed, + out DispatchEdge dispatchEdge + ) + { + var sharpHookKey = LinuxKeyMap.ToSharpHook(linuxKeyCode); + if (sharpHookKey is null) + { + dispatchEdge = default; + return false; + } + + var modBit = LinuxKeyMap.ToModifier(linuxKeyCode); + var modifiers = CurrentModifiers_NoLock(); + if (modBit != ModifierMask.None) + { + // A modifier can itself be a trigger. Exclude its own bit so a no-other-modifiers + // binding such as RightCtrl continues to match on both press and release. + modifiers &= ~modBit; + } + + dispatchEdge = new DispatchEdge( + sharpHookKey.Value, + modifiers, + pressed, + _lifecycleGeneration + ); + return true; + } + + private ModifierMask CurrentModifiers_NoLock() + { + var modifiers = ModifierMask.None; + foreach (var (linuxKeyCode, count) in _aggregateKeyCounts) + { + if (count > 0) + { + modifiers |= LinuxKeyMap.ToModifier(linuxKeyCode); + } + } + + return modifiers; + } + + private void ClearInputState_NoLock() + { + _pressedKeysByDevice.Clear(); + _aggregateKeyCounts.Clear(); + } + + private void DispatchEdges(IEnumerable edges) + { + foreach (var edge in edges) + { + DispatchEdgeOutsideLock(edge); + } + } + + private void DispatchEdgeOutsideLock(DispatchEdge edge) + { + // These live checks close races between releasing _lock and calling dispatcher handlers. + // They apply equally to physical events and synthetic detach releases. + if ( + Volatile.Read(ref _disposed) == 1 + || !Volatile.Read(ref _inputAllowed) + || Volatile.Read(ref _lifecycleGeneration) != edge.Generation + || !_sessionActivityMonitor.IsInputAllowed + ) + { + return; + } + + _dispatcher.Handle(edge.Key, edge.Modifiers, edge.Pressed); } + private readonly record struct DispatchEdge( + KeyCode Key, + ModifierMask Modifiers, + bool Pressed, + long Generation + ); + private static async Task DisposeReadersAsync(IEnumerable readers) { var disposals = new List(); diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/IEvdevInputDevice.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/IEvdevInputDevice.cs new file mode 100644 index 000000000..a8e184cb4 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/IEvdevInputDevice.cs @@ -0,0 +1,91 @@ +using Microsoft.Win32.SafeHandles; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace TypeWhisper.Linux.Services.Hotkey.Evdev; + +/// +/// Native-I/O seam for one evdev node. Tests provide an in-memory implementation so reader +/// stream recovery never needs to open or inspect a real input device. +/// +internal interface IEvdevInputDevice : IDisposable +{ + void Open(); + + ValueTask ReadAsync(Memory buffer, CancellationToken ct); + + byte[] QueryPressedKeyBitmap(); +} + +/// File-backed Linux implementation of . +internal sealed partial class EvdevInputDevice(string path) : IEvdevInputDevice +{ + // input-event-codes.h: KEY_MAX = 0x2ff, inclusive. + internal const int KeyMax = 0x2ff; + internal const int KeyBitmapBytes = KeyMax / 8 + 1; + + // _IOC_READ = 2; evdev ioctl type 'E' = 0x45; EVIOCGKEY request number = 0x18. + private const uint IocRead = 2u; + private const uint EvdevIocType = 0x45u; + private const uint EviocgKeyNumber = 0x18u; + + private FileStream? _stream; + + public void Open() + { + if (_stream is not null) + { + throw new InvalidOperationException($"Evdev device {path} is already open."); + } + + _stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite, + 0, + true + ); + } + + public ValueTask ReadAsync(Memory buffer, CancellationToken ct) + { + var stream = _stream + ?? throw new InvalidOperationException($"Evdev device {path} is not open."); + return stream.ReadAsync(buffer, ct); + } + + public byte[] QueryPressedKeyBitmap() + { + var stream = _stream + ?? throw new InvalidOperationException($"Evdev device {path} is not open."); + var keyBits = new byte[KeyBitmapBytes]; + if (ioctl(stream.SafeFileHandle, EviocgKey(KeyBitmapBytes), keyBits) < 0) + { + throw new Win32Exception( + Marshal.GetLastPInvokeError(), + $"EVIOCGKEY failed for {path}" + ); + } + + return keyBits; + } + + public void Dispose() + { + Interlocked.Exchange(ref _stream, null)?.Dispose(); + } + + private static nuint EviocgKey(int len) + { + return (IocRead << 30) + | ((uint)len << 16) + | (EvdevIocType << 8) + | EviocgKeyNumber; + } + + // byte[] is blittable, so the source-generated marshaller pins the EV_KEY bitmap buffer. + // ReSharper disable once InconsistentNaming -- native libc function name. + [LibraryImport("libc", SetLastError = true)] + private static partial int ioctl(SafeFileHandle fd, nuint request, [Out] byte[] buf); +} diff --git a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputAccessSetupHelper.cs index 774d3d393..7c6575876 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 @@ -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/Hotkey/Evdev/InputEventStruct.cs b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputEventStruct.cs index 479727ed2..361044cfc 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputEventStruct.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/Evdev/InputEventStruct.cs @@ -17,11 +17,12 @@ internal struct InputEvent public int Value; public static readonly int SizeBytes = Marshal.SizeOf(); - // ReSharper disable once UnusedMember.Global evdev kernel constant (linux/input-event-codes.h EV_SYN); kept for completeness of the input_event vocabulary public const ushort EvSyn = 0; public const ushort EvKey = 1; + public const ushort SynReport = 0; + public const ushort SynDropped = 3; // ReSharper disable once UnusedMember.Global evdev input_event value (0 = key release); kept for completeness of the input_event vocabulary public const int Released = 0; public const int Pressed = 1; public const int Repeated = 2; -} \ No newline at end of file +} 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 a68c78fa8..2eadcdd83 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/SharpHookGlobalShortcutBackend.cs @@ -59,12 +59,7 @@ public SharpHookGlobalShortcutBackend() /// Global on X11; focus-only on Wayland. Reported honestly so the status /// panel doesn't mislead Wayland users. /// - public bool IsGlobalScope => - !string.Equals( - Environment.GetEnvironmentVariable("XDG_SESSION_TYPE"), - "wayland", - StringComparison.OrdinalIgnoreCase - ); + public bool IsGlobalScope => !WaylandSessionDetector.IsWaylandSession(); public bool IsAvailable() { @@ -208,8 +203,8 @@ 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; } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs index 2a56785c1..a3ce38ccb 100644 --- a/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs +++ b/src/TypeWhisper.Linux/Services/Hotkey/ShortcutDispatcher.cs @@ -23,26 +23,20 @@ internal sealed class ShortcutDispatcher // Profile dictation dedup, keyed by physical KeyCode at press time. Also records the // recording mode and timestamp so the release path can compute hold duration for - // PushToTalk/Hybrid using the press-time mode (mirrors _dictationKeyDownTime). + // PushToTalk/Hybrid using the press-time mode (mirrors _mainDictationHeld). private readonly Dictionary _profileDictationKeyDown = new(); - private readonly Dictionary _profileTextKeyDown = new(); - - // Per-action key-down dedup, keyed by the physical KeyCode at press time. - // Using the press-time key means release-time cleanup works even if the user - // edits or removes the binding mid-hold — otherwise the stranded entry would - // silently suppress all future presses of that action. - private readonly Dictionary _promptActionKeyDown = 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 bool _dictationKeyDown; - private DateTime _dictationKeyDownTime; - private bool _promptKeyDown; + private (KeyCode Key, RecordingMode Mode, DateTime DownAt)? _mainDictationHeld; private bool _recentKeyDown; private GlobalShortcutSet? _shortcuts; - private bool _transformSelectionKeyDown; public void UpdateShortcuts(GlobalShortcutSet shortcuts) { @@ -52,6 +46,19 @@ public void UpdateShortcuts(GlobalShortcutSet shortcuts) public void ClearShortcuts() { Volatile.Write(ref _shortcuts, null); + + // 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; + } } /// @@ -66,15 +73,11 @@ public void ResetState() lock (_lock) { _profileDictationKeyDown.Clear(); - _profileTextKeyDown.Clear(); - _promptActionKeyDown.Clear(); + _pendingSelectionWorkflows.Clear(); _cancelKeyDown = false; _copyLastKeyDown = false; - _dictationKeyDown = false; - _dictationKeyDownTime = default; - _promptKeyDown = false; + _mainDictationHeld = null; _recentKeyDown = false; - _transformSelectionKeyDown = false; } // Main and profile dictation share one recording session, so a single discard covers both. @@ -99,7 +102,7 @@ public void Handle(KeyCode key, ModifierMask mods, bool pressed) } else { - HandleRelease(key, set); + HandleRelease(key, mods, set); } } @@ -178,15 +181,11 @@ out profileBehavior return; } - lock (_lock) - { - if (!_promptActionKeyDown.TryAdd(key, promptActionId)) - { - return; - } - } - - RaisePromptAction(promptActionId); + TryClaimSelectionWorkflow( + key, + SelectionWorkflowKind.PromptAction, + promptActionId + ); return; case ShortcutMatchKind.Profile: if (profileId is null) @@ -196,18 +195,10 @@ out profileBehavior if (profileBehavior == ProfileHotkeyBehavior.ProcessSelectedText) { - lock (_lock) - { - if (!_profileTextKeyDown.TryAdd(key, profileId)) - { - return; - } - } - - RaiseProfile( - ProfileTextProcessingRequested, - profileId, - nameof(ProfileTextProcessingRequested) + TryClaimSelectionWorkflow( + key, + SelectionWorkflowKind.ProfileTextProcessing, + profileId ); return; } @@ -274,40 +265,22 @@ out profileBehavior return; case ShortcutMatchKind.TransformSelection: - if (!TryClaimKeyDown(ref _transformSelectionKeyDown)) - { - return; - } - - Raise(TransformSelectionRequested, nameof(TransformSelectionRequested)); + TryClaimSelectionWorkflow(key, SelectionWorkflowKind.TransformSelection); return; case ShortcutMatchKind.PromptPalette: - if (!TryClaimKeyDown(ref _promptKeyDown)) - { - return; - } - - Raise(PromptPaletteRequested, nameof(PromptPaletteRequested)); + TryClaimSelectionWorkflow(key, SelectionWorkflowKind.PromptPalette); return; case ShortcutMatchKind.Dictation: - bool claimed; lock (_lock) { - if (_dictationKeyDown) + if (_mainDictationHeld is not null) { return; } - _dictationKeyDown = true; - _dictationKeyDownTime = DateTime.UtcNow; - claimed = true; - } - - if (!claimed) - { - return; + _mainDictationHeld = (key, set.Mode, DateTime.UtcNow); } // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault -- all defined RecordingMode values are handled; the default (out-of-range) branch is intentionally omitted. @@ -329,16 +302,51 @@ out profileBehavior } } - private void HandleRelease(KeyCode key, GlobalShortcutSet set) + private void HandleRelease(KeyCode key, ModifierMask mods, GlobalShortcutSet set) { - // Clear repeat-guards on key release (modifier-only releases are ignored). + List? readySelectionWorkflows = null; + lock (_lock) { - if (set.PromptPaletteKey is not null && key == set.PromptPaletteKey.Value) + 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) { - _promptKeyDown = false; + if (!triggerReleased && workflow.TriggerKey == key) + { + (justReleased ??= []).Add(workflow); + } + } + + if (justReleased is not null) + { + foreach (var workflow in justReleased) + { + _pendingSelectionWorkflows[workflow] = true; + } } + if (ShortcutMatcher.ModifiersMatch(mods, ModifierMask.None)) + { + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator -- see above; the explicit loop keeps the non-boxing enumerator. + foreach (var (workflow, triggerReleased) in _pendingSelectionWorkflows) + { + if (triggerReleased) + { + (readySelectionWorkflows ??= []).Add(workflow); + } + } + + if (readySelectionWorkflows is not null) + { + foreach (var workflow in readySelectionWorkflows) + { + _pendingSelectionWorkflows.Remove(workflow); + } + } + } + + // Clear non-selection repeat-guards on their terminal-key release. if (set.RecentTranscriptionsKey is not null && key == set.RecentTranscriptionsKey.Value) { _recentKeyDown = false; @@ -352,20 +360,18 @@ set.CopyLastTranscriptionKey is not null _copyLastKeyDown = false; } - if (set.TransformSelectionKey is not null && key == set.TransformSelectionKey.Value) - { - _transformSelectionKeyDown = false; - } - if (key == set.CancelKey) { _cancelKeyDown = false; } + } - // Clear press-time entries regardless of the current shortcut set — - // an edit/remove mid-hold must not strand the entry and suppress future presses. - _promptActionKeyDown.Remove(key); - _profileTextKeyDown.Remove(key); + if (readySelectionWorkflows is not null) + { + foreach (var workflow in readySelectionWorkflows) + { + DispatchSelectionWorkflow(workflow); + } } // Profile dictation release mirrors main dictation key semantics. @@ -405,26 +411,22 @@ set.CopyLastTranscriptionKey is not null } } - if (key != set.DictationKey) - { - return; - } - - DateTime keyDownAt; + (KeyCode Key, RecordingMode Mode, DateTime DownAt) held; lock (_lock) { - if (!_dictationKeyDown) + var current = _mainDictationHeld; + if (!current.HasValue || current.Value.Key != key) { return; } - _dictationKeyDown = false; - keyDownAt = _dictationKeyDownTime; + held = current.Value; + _mainDictationHeld = null; } - var heldMs = (DateTime.UtcNow - keyDownAt).TotalMilliseconds; + var heldMs = (DateTime.UtcNow - held.DownAt).TotalMilliseconds; // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault -- all defined RecordingMode values are handled; the default (out-of-range) branch is intentionally omitted. - switch (set.Mode) + switch (held.Mode) { case RecordingMode.PushToTalk: Raise(DictationStopRequested, nameof(DictationStopRequested)); @@ -456,6 +458,58 @@ private bool TryClaimKeyDown(ref bool flag) } } + // ReSharper disable once UnusedMethodReturnValue.Local -- the bool completes the Try* contract (mirrors TryClaimKeyDown); callers deliberately rely only on the idempotent claim side effect that de-dupes key auto-repeat. + private bool TryClaimSelectionWorkflow( + KeyCode key, + SelectionWorkflowKind kind, + string? payload = null + ) + { + 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( + new SelectionWorkflowId(key, kind, payload), + false + ); + } + } + + 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 (workflow.Kind) + { + case SelectionWorkflowKind.PromptPalette: + Raise(PromptPaletteRequested, nameof(PromptPaletteRequested)); + break; + case SelectionWorkflowKind.PromptAction: + RaisePromptAction(workflow.Payload!); + break; + case SelectionWorkflowKind.ProfileTextProcessing: + RaiseProfile( + ProfileTextProcessingRequested, + workflow.Payload!, + nameof(ProfileTextProcessingRequested) + ); + break; + case SelectionWorkflowKind.TransformSelection: + Raise(TransformSelectionRequested, nameof(TransformSelectionRequested)); + break; + } + } + private static void Raise(Action? handler, string name) { if (handler is null) @@ -509,4 +563,18 @@ private void RaisePromptAction(string actionId) ); } } + + private enum SelectionWorkflowKind + { + PromptPalette, + PromptAction, + ProfileTextProcessing, + TransformSelection, + } + + private readonly record struct SelectionWorkflowId( + KeyCode TriggerKey, + SelectionWorkflowKind Kind, + string? Payload + ); } 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 5a3a6d61e..390e7961b 100644 --- a/src/TypeWhisper.Linux/Services/HotkeyService.cs +++ b/src/TypeWhisper.Linux/Services/HotkeyService.cs @@ -5,6 +5,24 @@ namespace TypeWhisper.Linux.Services; +public enum HotkeyCandidateValidationStatus +{ + Valid, + Malformed, + CollidesWithFixedBinding, + CollidesWithPromptAction, + CollidesWithProfile, + MissingEnabledPromptAction, +} + +public sealed record HotkeyCandidateValidationResult( + HotkeyCandidateValidationStatus Status, + string? NormalizedHotkey +) +{ + public bool IsValid => Status == HotkeyCandidateValidationStatus.Valid; +} + /// /// Coordinator for global hotkeys. Owns the configured-binding state (the /// eight shortcuts plus mode), parses user-supplied hotkey strings, and @@ -39,6 +57,7 @@ public sealed class HotkeyService : IDisposable private KeyCode _key = KeyCode.VcSpace; private RecordingMode _mode = RecordingMode.Toggle; private ModifierMask _modifiers = ModifierMask.LeftCtrl | ModifierMask.LeftShift; + private volatile bool _nativeDictationBindingActive; private EventHandler? _onBackendFailed; private EventHandler? _onCancelRequested; private EventHandler? _onCopyLastTranscriptionRequested; @@ -58,11 +77,17 @@ public sealed class HotkeyService : IDisposable // Serializes backend updates so a burst of TrySet*/Mode= calls applies in order. private Task _pendingBackendUpdate = Task.CompletedTask; - // Per-profile hotkeys. Rebuilt wholesale by SetProfileHotkeys; snapshot captures by reference. + // Latest requested dynamic hotkeys are retained separately from accepted bindings so a + // rejected candidate can become active when a higher-priority dynamic binding disappears. + private ProfileHotkey[] _profileHotkeyCandidates = []; + private PromptActionHotkey[] _promptActionHotkeyCandidates = []; + + // Accepted per-profile hotkeys. Rebuilt wholesale during dynamic reconciliation; + // backend snapshots capture the list by reference. private IReadOnlyList _profileHotkeys = []; - // Direct-execution prompt action hotkeys (B12). Rebuilt wholesale by SetPromptActionHotkeys; - // snapshot captures by reference so post-push mutations are invisible to the running matcher. + // Accepted direct-execution prompt action hotkeys (B12). Rebuilt wholesale during dynamic + // reconciliation; backend snapshots capture the list by reference. private IReadOnlyList _promptActionHotkeys = []; @@ -90,6 +115,12 @@ public HotkeyService(BackendSelector selector) /// public bool BackendRequiresToggleMode => _backendRequiresToggleMode; + /// + /// True when the current native desktop dictation binding has been verified or applied + /// live, so the app-owned fixed dictation route is omitted from backend snapshots. + /// + public bool NativeDictationBindingActive => _nativeDictationBindingActive; + /// /// Stable identifier of the currently active backend (e.g. /// "linux-sharphook", "linux-evdev", "linux-xdg-portal"). Null until @@ -164,6 +195,17 @@ _transformSelectionKey is null ? "" : FormatHotkey(_transformSelectionKey.Value, _transformSelectionModifiers); + public void SetNativeDictationBindingActive(bool active) + { + if (_nativeDictationBindingActive == active) + { + return; + } + + _nativeDictationBindingActive = active; + PushShortcutsIfRunning(); + } + public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 1) @@ -303,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(); + } } /// @@ -350,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) @@ -373,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; } @@ -397,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; } @@ -429,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; } @@ -461,76 +546,195 @@ public bool TrySetTransformSelectionHotkeyFromString(string? text) return false; } + lock (_lock) + { + if ( + HotkeyMatchesAny( + key!.Value, + modifiers, + GetBoundHotkeys(HotkeyBinding.TransformSelection) + ) + ) + { + return false; + } + + _transformSelectionKey = key; + _transformSelectionModifiers = modifiers; + PushShortcutsIfRunning(); + return true; + } + } + + /// + /// Validates a proposed prompt-action chord against the same parser, formatter, fixed + /// bindings, and collision matcher used by dynamic reconciliation. The canonical source + /// collections are inspected directly so bindings rejected by the current reconciliation + /// remain visible. No coordinator or backend state is changed. + /// + public HotkeyCandidateValidationResult ValidatePromptActionHotkeyCandidate( + string? hotkey, + string? editedActionId, + IEnumerable promptActions, + IEnumerable profiles + ) + { + ArgumentNullException.ThrowIfNull(promptActions); + ArgumentNullException.ThrowIfNull(profiles); + + var parsed = ParseCandidate(hotkey); if ( - HotkeyMatchesAny( - key!.Value, - modifiers, - GetBoundHotkeys(HotkeyBinding.TransformSelection) + !parsed.IsValid + || parsed.NormalizedHotkey is null + || !TryParseHotkey(parsed.NormalizedHotkey, out var key, out var modifiers) + || key is null + ) + { + return parsed; + } + + if (HotkeyMatchesAny(key.Value, modifiers, GetFixedHotkeys())) + { + return parsed with + { + Status = HotkeyCandidateValidationStatus.CollidesWithFixedBinding, + NormalizedHotkey = null, + }; + } + + if ( + promptActions.Any(action => + action.IsEnabled + && !string.Equals(action.Id, editedActionId, StringComparison.Ordinal) + && HotkeyTextMatches(key.Value, modifiers, action.HotkeyKey) ) ) { - return false; + return parsed with + { + Status = HotkeyCandidateValidationStatus.CollidesWithPromptAction, + NormalizedHotkey = null, + }; } - _transformSelectionKey = key; - _transformSelectionModifiers = modifiers; - PushShortcutsIfRunning(); - return true; + if ( + profiles.Any(profile => + profile.IsEnabled + && HotkeyTextMatches(key.Value, modifiers, profile.HotkeyData) + ) + ) + { + return parsed with + { + Status = HotkeyCandidateValidationStatus.CollidesWithProfile, + NormalizedHotkey = null, + }; + } + + return parsed; } /// - /// Replaces the dynamic per-action hotkey list atomically. Entries colliding with a - /// fixed binding or an earlier accepted entry in this batch are dropped (matching - /// the silent-rejection style of TrySet*HotkeyFromString). Pushes a fresh - /// snapshot so the matcher sees the new list immediately. + /// Validates a proposed profile chord against the canonical fixed and dynamic sources. + /// Selected-text bindings additionally require a linked action present in the enabled + /// action collection, matching direct prompt-action execution semantics. /// - public void SetPromptActionHotkeys(IReadOnlyList entries) + public HotkeyCandidateValidationResult ValidateProfileHotkeyCandidate( + string? hotkey, + ProfileHotkeyBehavior behavior, + string? promptActionId, + string? editedProfileId, + IEnumerable promptActions, + IEnumerable profiles + ) { - ArgumentNullException.ThrowIfNull(entries); + ArgumentNullException.ThrowIfNull(promptActions); + ArgumentNullException.ThrowIfNull(profiles); - // Clear first so GetBoundHotkeys() doesn't flag re-submitted unchanged entries as - // already-bound (ActionsChanged fires on every add/update/delete and reuses most - // existing entries). Intra-batch dedup is handled by the accepted.Any(...) check. - _promptActionHotkeys = []; + var parsed = ParseCandidate(hotkey); + if (!parsed.IsValid || parsed.NormalizedHotkey is null) + { + return parsed; + } - var accepted = new List(entries.Count); - foreach (var entry in entries) + var actionSnapshot = promptActions.ToArray(); + if ( + behavior == ProfileHotkeyBehavior.ProcessSelectedText + && !actionSnapshot.Any(action => + action.IsEnabled + && string.Equals(action.Id, promptActionId, StringComparison.Ordinal) + ) + ) { - if (string.IsNullOrWhiteSpace(entry.ActionId)) + return parsed with { - Trace.WriteLine( - "[HotkeyService] Refusing prompt-action hotkey with empty action id." - ); - continue; - } + Status = HotkeyCandidateValidationStatus.MissingEnabledPromptAction, + NormalizedHotkey = null, + }; + } + + if (!TryParseHotkey(parsed.NormalizedHotkey, out var key, out var modifiers) || key is null) + { + return parsed; + } - if (HotkeyMatchesAny(entry.Key, entry.Modifiers, GetBoundHotkeys())) + if (HotkeyMatchesAny(key.Value, modifiers, GetFixedHotkeys())) + { + return parsed with { - Trace.WriteLine( - $"[HotkeyService] Refusing prompt-action hotkey for '{entry.ActionId}' that collides with another shortcut." - ); - continue; - } + Status = HotkeyCandidateValidationStatus.CollidesWithFixedBinding, + NormalizedHotkey = null, + }; + } - // Intra-batch collision check using full HotkeyMatches so prefix-collision - // rules apply between prompt-action entries too. - if ( - accepted.Any(prior => - HotkeyMatches(entry.Key, entry.Modifiers, prior.Key, prior.Modifiers) - ) + if ( + actionSnapshot.Any(action => + action.IsEnabled + && HotkeyTextMatches(key.Value, modifiers, action.HotkeyKey) ) + ) + { + return parsed with { - Trace.WriteLine( - $"[HotkeyService] Refusing prompt-action hotkey for '{entry.ActionId}' that collides with an earlier entry." - ); - continue; - } + Status = HotkeyCandidateValidationStatus.CollidesWithPromptAction, + NormalizedHotkey = null, + }; + } - accepted.Add(entry); + if ( + profiles.Any(profile => + profile.IsEnabled + && !string.Equals(profile.Id, editedProfileId, StringComparison.Ordinal) + && HotkeyTextMatches(key.Value, modifiers, profile.HotkeyData) + ) + ) + { + return parsed with + { + Status = HotkeyCandidateValidationStatus.CollidesWithProfile, + NormalizedHotkey = null, + }; } - _promptActionHotkeys = accepted; - PushShortcutsIfRunning(); + return parsed; + } + + /// + /// Replaces the requested prompt-action candidates, then reconciles both dynamic lists. + /// Rejected candidates remain retained so a later reconciliation can activate them. + /// + // ReSharper disable once UnusedMethodReturnValue.Global returns the rejection list symmetric with SetDynamicHotkeys; part of the public API contract, no in-tree caller consumes it yet + public IReadOnlyList SetPromptActionHotkeys( + IReadOnlyList entries + ) + { + ArgumentNullException.ThrowIfNull(entries); + + lock (_lock) + { + _promptActionHotkeyCandidates = entries.ToArray(); + return ReconcileDynamicHotkeysLocked(); + } } /// @@ -570,40 +774,129 @@ IEnumerable actions } /// - /// Replaces the per-profile hotkey list atomically. Same collision rules as - /// — entries colliding with any fixed binding - /// (including prompt-action and other profile chords) or an earlier batch entry are - /// dropped with a . Pushes a fresh snapshot immediately. + /// Replaces the requested profile candidates, then reconciles both dynamic lists. + /// Rejected candidates remain retained so a later reconciliation can activate them. /// - public void SetProfileHotkeys(IReadOnlyList entries) + // ReSharper disable once UnusedMethodReturnValue.Global returns the rejection list symmetric with SetDynamicHotkeys; part of the public API contract, no in-tree caller consumes it yet + public IReadOnlyList SetProfileHotkeys(IReadOnlyList entries) { ArgumentNullException.ThrowIfNull(entries); - // Clear first — same reason as SetPromptActionHotkeys: reuse of existing entries - // across ProfilesChanged events must not register as collisions. - _profileHotkeys = []; + lock (_lock) + { + _profileHotkeyCandidates = entries.ToArray(); + return ReconcileDynamicHotkeysLocked(); + } + } + + /// + /// Atomically replaces both requested dynamic candidate lists and reconciles them once. + /// + public IReadOnlyList SetDynamicHotkeys( + IReadOnlyList promptActions, + IReadOnlyList profiles + ) + { + ArgumentNullException.ThrowIfNull(promptActions); + ArgumentNullException.ThrowIfNull(profiles); + + 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. + /// + /// Callers must hold _lock. + private List ReconcileDynamicHotkeysLocked() + { + // 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 + ); + var acceptedProfiles = new List(_profileHotkeyCandidates.Length); + var rejections = new List(); + + foreach (var entry in _promptActionHotkeyCandidates) + { + if (string.IsNullOrWhiteSpace(entry.ActionId)) + { + Trace.WriteLine( + "[HotkeyService] Refusing prompt-action hotkey with empty action id." + ); + rejections.Add( + $"Prompt-action hotkey ({FormatHotkey(entry.Key, entry.Modifiers)}) is inactive because its action ID is blank." + ); + continue; + } + + if (HotkeyMatchesAny(entry.Key, entry.Modifiers, fixedBindings)) + { + Trace.WriteLine( + $"[HotkeyService] Refusing prompt-action hotkey for '{entry.ActionId}' that collides with another shortcut." + ); + rejections.Add(DynamicCollisionMessage("Prompt-action", entry.ActionId, entry.Key, entry.Modifiers)); + continue; + } - var accepted = new List(entries.Count); - foreach (var entry in entries) + if ( + acceptedActions.Any(prior => + HotkeyMatches(entry.Key, entry.Modifiers, prior.Key, prior.Modifiers) + ) + ) + { + Trace.WriteLine( + $"[HotkeyService] Refusing prompt-action hotkey for '{entry.ActionId}' that collides with an earlier entry." + ); + rejections.Add(DynamicCollisionMessage("Prompt-action", entry.ActionId, entry.Key, entry.Modifiers)); + continue; + } + + acceptedActions.Add(entry); + } + + foreach (var entry in _profileHotkeyCandidates) { if (string.IsNullOrWhiteSpace(entry.ProfileId)) { Trace.WriteLine( "[HotkeyService] Refusing profile hotkey with empty profile id." ); + rejections.Add( + $"Profile hotkey ({FormatHotkey(entry.Key, entry.Modifiers)}) is inactive because its profile ID is blank." + ); continue; } - if (HotkeyMatchesAny(entry.Key, entry.Modifiers, GetBoundHotkeys())) + if ( + HotkeyMatchesAny(entry.Key, entry.Modifiers, fixedBindings) + || acceptedActions.Any(action => + HotkeyMatches( + entry.Key, + entry.Modifiers, + action.Key, + action.Modifiers + ) + ) + ) { Trace.WriteLine( $"[HotkeyService] Refusing profile hotkey for '{entry.ProfileId}' that collides with another shortcut." ); + rejections.Add(DynamicCollisionMessage("Profile", entry.ProfileId, entry.Key, entry.Modifiers)); continue; } if ( - accepted.Any(prior => + acceptedProfiles.Any(prior => HotkeyMatches(entry.Key, entry.Modifiers, prior.Key, prior.Modifiers) ) ) @@ -611,14 +904,27 @@ public void SetProfileHotkeys(IReadOnlyList entries) Trace.WriteLine( $"[HotkeyService] Refusing profile hotkey for '{entry.ProfileId}' that collides with an earlier entry." ); + rejections.Add(DynamicCollisionMessage("Profile", entry.ProfileId, entry.Key, entry.Modifiers)); continue; } - accepted.Add(entry); + acceptedProfiles.Add(entry); } - _profileHotkeys = accepted; + _promptActionHotkeys = acceptedActions; + _profileHotkeys = acceptedProfiles; PushShortcutsIfRunning(); + return rejections; + } + + private static string DynamicCollisionMessage( + string bindingKind, + string id, + KeyCode key, + ModifierMask modifiers + ) + { + return $"{bindingKind} hotkey '{id}' ({FormatHotkey(key, modifiers)}) is inactive because it conflicts with a higher-priority shortcut."; } /// @@ -787,9 +1093,21 @@ private void UnsubscribeBackendHandlers(IGlobalShortcutBackend? backend) private GlobalShortcutSet BuildShortcutSet() { + var nativeDictationBindingActive = _nativeDictationBindingActive; + // 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( - _key, - _modifiers, + nativeDictationBindingActive ? KeyCode.VcUndefined : _key, + nativeDictationBindingActive ? ModifierMask.None : _modifiers, _promptPaletteKey, _promptPaletteModifiers, _recentTranscriptionsKey, @@ -798,10 +1116,11 @@ private GlobalShortcutSet BuildShortcutSet() _copyLastTranscriptionModifiers, _transformSelectionKey, _transformSelectionModifiers, - CancelKey, - CancelModifiers, + suppressCancel ? KeyCode.VcUndefined : CancelKey, + suppressCancel ? ModifierMask.None : CancelModifiers, _mode, - _cancelShortcutEnabled, + // ReSharper disable once SimplifyConditionalTernaryExpression -- kept parallel with the suppressCancel ? x : y projection lines above for readability. + suppressCancel ? false : _cancelShortcutEnabled, _promptActionHotkeys, _profileHotkeys ); @@ -885,6 +1204,41 @@ ModifierMask otherModifiers || CollidesAsModifierPrefix(otherKey.Value, otherModifiers, key, modifiers); } + private static HotkeyCandidateValidationResult ParseCandidate(string? hotkey) + { + if (string.IsNullOrWhiteSpace(hotkey)) + { + return new HotkeyCandidateValidationResult( + HotkeyCandidateValidationStatus.Valid, + null + ); + } + + if (!TryParseHotkey(hotkey, out var key, out var modifiers) || key is null) + { + return new HotkeyCandidateValidationResult( + HotkeyCandidateValidationStatus.Malformed, + null + ); + } + + return new HotkeyCandidateValidationResult( + HotkeyCandidateValidationStatus.Valid, + FormatHotkey(key.Value, modifiers) + ); + } + + private static bool HotkeyTextMatches( + KeyCode key, + ModifierMask modifiers, + string? otherHotkey + ) + { + return !string.IsNullOrWhiteSpace(otherHotkey) + && TryParseHotkey(otherHotkey, out var otherKey, out var otherModifiers) + && HotkeyMatches(key, modifiers, otherKey, otherModifiers); + } + private static bool CollidesAsModifierPrefix( KeyCode modifierOnlyKey, ModifierMask modifierOnlyMods, @@ -924,13 +1278,37 @@ KeyCode.VcLeftAlt or KeyCode.VcRightAlt => ModifierMask.LeftAlt | ModifierMask.RightAlt, KeyCode.VcLeftMeta or KeyCode.VcRightMeta => ModifierMask.LeftMeta | ModifierMask.RightMeta, - _ => ModifierMask.None + _ => ModifierMask.None, }; } private IEnumerable<(KeyCode? Key, ModifierMask Modifiers)> GetBoundHotkeys( HotkeyBinding? exclude = null ) + { + foreach (var binding in GetFixedHotkeys(exclude)) + { + yield return binding; + } + + // Dynamic prompt-action bindings make collision detection symmetric: fixed-binding + // changes that would shadow a prompt-action chord are also rejected. Dynamic + // reconciliation reads GetFixedHotkeys directly so it never collides with itself. + foreach (var entry in _promptActionHotkeys) + { + yield return (entry.Key, entry.Modifiers); + } + + // Per-profile bindings use the same symmetry rule as prompt actions. + foreach (var entry in _profileHotkeys) + { + yield return (entry.Key, entry.Modifiers); + } + } + + private IEnumerable<(KeyCode? Key, ModifierMask Modifiers)> GetFixedHotkeys( + HotkeyBinding? exclude = null + ) { if (exclude != HotkeyBinding.Dictation) { @@ -956,20 +1334,6 @@ KeyCode.VcLeftMeta or KeyCode.VcRightMeta { yield return (_transformSelectionKey, _transformSelectionModifiers); } - - // Dynamic prompt-action bindings: makes collision detection symmetric — fixed-binding - // changes that would shadow a prompt-action chord are also rejected. No exclude needed - // because SetPromptActionHotkeys clears the list before its reconcile loop. - foreach (var entry in _promptActionHotkeys) - { - yield return (entry.Key, entry.Modifiers); - } - - // Per-profile bindings — same symmetry reason as prompt-action loop above. - foreach (var entry in _profileHotkeys) - { - yield return (entry.Key, entry.Modifiers); - } } private static bool HotkeyMatchesAny( @@ -997,7 +1361,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) { @@ -1120,7 +1484,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) { @@ -1168,7 +1532,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; } @@ -1179,6 +1543,6 @@ private enum HotkeyBinding PromptPalette, RecentTranscriptions, CopyLastTranscription, - TransformSelection + TransformSelection, } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs b/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs index 6ba6a81fa..f7f6f4a45 100644 --- a/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs +++ b/src/TypeWhisper.Linux/Services/HttpApiRequestParser.cs @@ -1,6 +1,6 @@ using System.Collections.Specialized; -using System.Net; using System.Text; +using Microsoft.AspNetCore.Http; using TypeWhisper.Core.Models; namespace TypeWhisper.Linux.Services; @@ -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( @@ -72,62 +72,105 @@ internal sealed record DictionaryTermDeleteRequest(string Term); /// /// Hand-rolled multipart/form-data parser for the local HTTP API. Custom -/// 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. +/// so the transport-neutral request shape and exact parsing behavior stay +/// shared across the TCP and Unix-socket listeners. Body size is capped +/// while streaming so a malicious / runaway client cannot OOM the +/// dictation host. /// internal static class HttpApiRequestParser { - public static async Task FromListenerRequestAsync( - HttpListenerRequest request, + public static async Task FromHttpContextAsync( + HttpContext context, long maxBytes, 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) + var request = context.Request; + var body = await ReadBodyAsync( + request.Body, + request.ContentLength ?? -1, + maxBytes, + ct + ); + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in request.Headers) { - throw new HttpApiRequestException(413, "Request body too large"); + headers[key] = value.ToString(); } - var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var key in request.Headers.AllKeys) + var queryString = new NameValueCollection(); + foreach (var (key, values) in request.Query) { - if (key is not null && request.Headers[key] is { } value) + foreach (var value in values) { - headers[key] = value; + queryString.Add(key, value); } } return new HttpApiRequest( - request.HttpMethod, - request.Url?.AbsolutePath ?? "", - request.QueryString, + request.Method, + request.Path.Value ?? "", + queryString, headers, body ); } + internal static async Task> ReadBodyAsync( + Stream input, + long declaredLength, + long maxBytes, + CancellationToken ct + ) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentOutOfRangeException.ThrowIfLessThan(declaredLength, -1); + + 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(); - TranscriptionTask task; + string? taskValue; string? targetLanguage; - string responseFormat; + string? responseFormatValue; string? prompt; string? engine; string? model; @@ -153,9 +196,9 @@ public static TranscribeApiRequest ParseTranscribe(HttpApiRequest request) language = Field(parts, "language"); languageHints.AddRange(Fields(parts, "language_hint")); - task = ParseTask(Field(parts, "task")); + taskValue = Field(parts, "task"); targetLanguage = Field(parts, "target_language"); - responseFormat = Field(parts, "response_format") ?? "json"; + responseFormatValue = Field(parts, "response_format"); prompt = Field(parts, "prompt"); engine = Field(parts, "engine"); model = Field(parts, "model"); @@ -173,9 +216,9 @@ public static TranscribeApiRequest ParseTranscribe(HttpApiRequest request) ) .Where(v => !string.IsNullOrWhiteSpace(v)) ); - task = ParseTask(Header(request.Headers, "x-task")); + taskValue = Header(request.Headers, "x-task"); targetLanguage = Clean(Header(request.Headers, "x-target-language")); - responseFormat = Clean(Header(request.Headers, "x-response-format")) ?? "json"; + responseFormatValue = Header(request.Headers, "x-response-format"); prompt = Clean(Header(request.Headers, "x-prompt")); engine = Clean(Header(request.Headers, "x-engine")); model = Clean(Header(request.Headers, "x-model")); @@ -185,6 +228,8 @@ public static TranscribeApiRequest ParseTranscribe(HttpApiRequest request) throw new HttpApiRequestException(400, "No audio data provided"); } + var (task, responseFormat) = ParseTranscriptionOptions(taskValue, responseFormatValue); + if (audioData.Length == 0) { throw new HttpApiRequestException(400, "Empty audio data"); @@ -223,16 +268,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(); + var 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 = IndexOfDelimiter(bodySpan, boundaryBytes, searchStart); if (boundaryStart < 0) { break; @@ -240,32 +289,33 @@ 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; } - 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 < 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 = IndexOfDelimiter(bodySpan, boundaryBytes, partBodyStart); if (nextBoundary < 0) { break; @@ -274,8 +324,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 +338,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 +461,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 +469,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))!; } @@ -433,11 +479,34 @@ private static IEnumerable Fields(IEnumerable parts, stri return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned; } - private static TranscriptionTask ParseTask(string? value) + internal static (TranscriptionTask Task, string ResponseFormat) ParseTranscriptionOptions( + string? task, + string? responseFormat + ) { - return string.Equals(value?.Trim(), "translate", StringComparison.OrdinalIgnoreCase) - ? TranscriptionTask.Translate - : TranscriptionTask.Transcribe; + var cleanedTask = Clean(task); + var parsedTask = cleanedTask?.ToLowerInvariant() switch + { + null or "transcribe" => TranscriptionTask.Transcribe, + "translate" => TranscriptionTask.Translate, + _ => throw new HttpApiRequestException( + 400, + $"Invalid task '{cleanedTask}'. Allowed values: transcribe, translate." + ), + }; + + var cleanedResponseFormat = Clean(responseFormat); + var parsedResponseFormat = cleanedResponseFormat?.ToLowerInvariant() switch + { + null or "json" => "json", + "verbose_json" => "verbose_json", + _ => throw new HttpApiRequestException( + 400, + $"Invalid response_format '{cleanedResponseFormat}'. Allowed values: json, verbose_json." + ), + }; + + return (parsedTask, parsedResponseFormat); } private static string? ExtensionFromFileName(string? fileName) @@ -492,37 +561,90 @@ private static TranscriptionTask ParseTask(string? value) return lower.Contains("webm") ? "webm" : null; } - private static int IndexOf(byte[] haystack, byte[] needle, int startIndex) + /// + /// 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 + ) { - if (needle.Length == 0) - { - return startIndex; - } - - for (var i = startIndex; i <= haystack.Length - needle.Length; i++) + var from = startIndex; + while (from < body.Length) { - 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++) + var at = IndexOf(body, boundaryBytes, from); + if (at < 0) { - if (haystack[i + j] == needle[j]) - { - continue; - } - - found = false; - break; + return -1; } - if (found) + if (IsDelimiterAt(body, boundaryBytes, at)) { - return i; + 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, + int startIndex + ) + { + if (needle.Length == 0) + { + return startIndex; + } + + var relativeIndex = haystack[startIndex..].IndexOf(needle); + return relativeIndex < 0 ? -1 : startIndex + relativeIndex; + } + private sealed class LimitedReadStream(Stream inner, long maxBytes) : Stream { private long _bytesRead; @@ -598,8 +720,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 346af05d2..95b135a47 100644 --- a/src/TypeWhisper.Linux/Services/HttpApiService.cs +++ b/src/TypeWhisper.Linux/Services/HttpApiService.cs @@ -1,23 +1,118 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using System.Diagnostics; using System.Net; +using System.Net.Sockets; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; +using TypeWhisper.Core; using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services.Ipc; +using TypeWhisper.Linux.Services.Localization; using TypeWhisper.PluginSDK; using TypeWhisper.PluginSDK.Models; namespace TypeWhisper.Linux.Services; +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}")); + } + + public Task? TryRun(Func handler) + { + ArgumentNullException.ThrowIfNull(handler); + 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 + { + await handler(); + } + catch (Exception ex) + { + _reportException(ex); + } + finally + { + _slots.Release(); + } + } +} + +internal sealed record HttpApiOverCapacityResponse( + int StatusCode, + string RetryAfter, + string Body +); + +internal readonly record struct BearerTokenProtectionResult( + string PlainText, + string StoredValue, + bool Changed +); + /// -/// Local HTTP API for dictation/transcription/history. Binds to localhost -/// only; CORS is echoed only for the same loopback origin and port so a -/// remote page cannot induce a localhost-origin request to leak the API. +/// Local HTTP API for dictation/transcription/history. Kestrel serves the +/// same API over loopback TCP and an owner-only Unix socket. CORS is echoed +/// only for the same loopback origin and port. /// -public sealed class HttpApiService : IDisposable +public sealed partial class HttpApiService : IDisposable { - private const long MaxTranscribeRequestBytes = 100 * 1024 * 1024; + 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 = "Authorization, Content-Type, X-Language, X-Language-Hints, X-Task, X-Target-Language, " @@ -27,7 +122,7 @@ public sealed class HttpApiService : IDisposable { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true, - WriteIndented = false + WriteIndented = false, }; private readonly AudioFileService _audioFiles; @@ -38,16 +133,20 @@ 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; private readonly IVocabularyBoostingService _vocabularyBoosting; - private CancellationTokenSource? _cts; + private readonly string? _apiSocketPathOverride; + private readonly Func _validateUnixPeer; + private readonly string _secretProtectionKeyFilePath; private bool _disposed; - private HttpListener? _listener; - private Task? _listenTask; + private WebApplication? _host; + private ApiSocketOwnership? _ownership; private int _port; + private string? _socketPath; public HttpApiService( ModelManagerService models, @@ -61,7 +160,45 @@ public HttpApiService( ITranslationService translation, DictationOrchestrator dictation, DictationSessionResultStore sessionResults, - ApiDiscoveryFile discoveryFile + ApiDiscoveryFile discoveryFile, + string? secretProtectionKeyFilePath = null + ) + : this( + models, + settings, + audioFiles, + history, + profiles, + dictionary, + vocabularyBoosting, + pipeline, + translation, + dictation, + sessionResults, + discoveryFile, + secretProtectionKeyFilePath, + null, + null + ) + { + } + + internal HttpApiService( + ModelManagerService models, + ISettingsService settings, + AudioFileService audioFiles, + IHistoryService history, + IProfileService profiles, + IDictionaryService dictionary, + IVocabularyBoostingService vocabularyBoosting, + IPostProcessingPipeline pipeline, + ITranslationService translation, + DictationOrchestrator dictation, + DictationSessionResultStore sessionResults, + ApiDiscoveryFile discoveryFile, + string? secretProtectionKeyFilePath, + string? apiSocketPath, + Func? validateUnixPeer ) { _models = models; @@ -76,11 +213,19 @@ ApiDiscoveryFile discoveryFile _dictation = dictation; _sessionResults = sessionResults; _discoveryFile = discoveryFile; + _apiSocketPathOverride = apiSocketPath; + _validateUnixPeer = + validateUnixPeer ?? UnixPeerCredentials.IsOwnedByEffectiveUser; + _secretProtectionKeyFilePath = + secretProtectionKeyFilePath + ?? TypeWhisperEnvironment.SecretProtectionKeyFilePath; } public string StatusText { get; private set; } = "Local API is disabled."; - private bool IsRunning => _listener?.IsListening == true; + private bool IsRunning => _host is not null; + + internal IHostLifetime? HostLifetime => _host?.Services.GetService(); public void Dispose() { @@ -90,16 +235,9 @@ public void Dispose() } Stop(); - _cts?.Dispose(); - try - { - _listenTask?.Wait(TimeSpan.FromSeconds(1)); - } - catch - { - // Best-effort wait for the listener loop to drain during dispose. - } - + // Stop() only tears down the host; admitted handlers run detached, so the dispatcher + // does its own bounded drain before releasing the semaphore. + _requestDispatcher.Dispose(); _disposed = true; } @@ -107,7 +245,7 @@ private void Start(int port) { if (IsRunning && _port == port) { - SetStatus($"Local API is running at http://localhost:{port}/"); + SetStatus(BuildRunningStatus(port, _socketPath, PublishDiscovery())); return; } @@ -120,25 +258,43 @@ private void Start(int port) Stop(false); + ApiSocketOwnership? ownership = null; + WebApplication? host = null; + string? socketPath = null; try { - _port = port; - _cts = new CancellationTokenSource(); - _listener = new HttpListener(); - _listener.Prefixes.Add($"http://localhost:{port}/"); - _listener.Start(); - _listenTask = Task.Run(() => ListenLoopAsync(_cts.Token)); - - var token = ReadBearerToken(_settings.Current); - if (!string.IsNullOrWhiteSpace(token)) + socketPath = _apiSocketPathOverride ?? SocketPathResolver.ResolveApiSocketPath(); + if (!ApiSocketOwnership.TryAcquire(socketPath, out ownership)) { - _discoveryFile.Write(port, token); + throw new IOException($"API socket ownership is already held for {socketPath}."); } - SetStatus($"Local API is running at http://localhost:{port}/"); + var cleanup = ownership.CleanupStaleSocket(); + if (cleanup is not (ApiSocketCleanupResult.Missing or ApiSocketCleanupResult.Removed)) + { + throw new IOException( + $"API socket path {socketPath} could not be prepared ({cleanup})." + ); + } + + host = BuildHost(port, socketPath); + host.StartAsync().GetAwaiter().GetResult(); + SetOwnerOnlySocketMode(socketPath); + + _port = port; + _socketPath = socketPath; + _host = host; + _ownership = ownership; + host = null; + ownership = null; + + SetStatus(BuildRunningStatus(port, socketPath, PublishDiscovery())); } catch (Exception ex) { + StopHost(host); + TryUnlinkSocket(socketPath, ownership); + ownership?.Dispose(); Stop(false); SetStatus($"Local API failed to start: {ex.Message}"); } @@ -155,7 +311,24 @@ public void ApplySettings() var settings = _settings.Current; if (settings.ApiServerEnabled) { - EnsureBearerToken(); + try + { + EnsureBearerToken(); + } + catch (Exception ex) when ( + ex is CryptographicException + or IOException + or UnauthorizedAccessException + ) + { + Trace.WriteLine( + $"[HttpApiService] Bearer token protection unavailable: {ex.Message}" + ); + Stop(); + SetStatus(Loc.Instance["Security.SecretProtectionUnavailable"]); + return; + } + Start(_settings.Current.ApiServerPort); } else @@ -164,15 +337,25 @@ public void ApplySettings() } } - internal static string ReadBearerToken(AppSettings settings) + internal static string ReadBearerToken( + AppSettings settings, + string? secretProtectionKeyFilePath = null + ) { - return string.IsNullOrWhiteSpace(settings.ApiServerBearerToken) - ? "" - : ApiKeyProtection.Decrypt(settings.ApiServerBearerToken); + if (string.IsNullOrWhiteSpace(settings.ApiServerBearerToken)) + { + return ""; + } + + var result = ApiKeyProtection.Decrypt( + settings.ApiServerBearerToken, + secretProtectionKeyFilePath + ); + return result.Succeeded ? result.PlainText ?? "" : ""; } internal static object? BuildAccelerationDto( - ITranscriptionEnginePlugin? plugin, + ITranscriptionEngineRole? plugin, AppSettings settings ) { @@ -187,7 +370,7 @@ AppSettings settings activeBackend = FormatAccelerationBackend(status.ActiveBackend), displayText = status.DisplayText, detail = status.Detail, - requiresRestart = status.RequiresRestart + requiresRestart = status.RequiresRestart, }; } @@ -195,12 +378,17 @@ AppSettings settings private void Stop(bool updateStatus) { - _cts?.Cancel(); - _listener?.Stop(); - _listener?.Close(); - _listener = null; + var host = _host; + var ownership = _ownership; + var socketPath = _socketPath; + _host = null; + _ownership = null; + _socketPath = null; _port = 0; _discoveryFile.Delete(); + StopHost(host); + TryUnlinkSocket(socketPath, ownership); + ownership?.Dispose(); if (updateStatus) { SetStatus("Local API is disabled."); @@ -218,38 +406,222 @@ private void SetStatus(string status) StateChanged?.Invoke(); } - private async Task ListenLoopAsync(CancellationToken ct) + private bool PublishDiscovery() { - while (!ct.IsCancellationRequested && _listener is { IsListening: true } listener) + var token = ReadBearerToken( + _settings.Current, + _secretProtectionKeyFilePath + ); + return !string.IsNullOrWhiteSpace(token) + && _socketPath is not null + && _discoveryFile.Write(_port, token, _socketPath); + } + + // The CLI reaches the API only through the discovery file's socket path, so a + // failed publish is a client-visible outage even though the listeners are up. + private static string BuildRunningStatus(int port, string? socketPath, bool discoveryPublished) + { + var running = $"Local API is running at http://localhost:{port}/ and {socketPath}."; + return discoveryPublished + ? running + : $"{running} Discovery file could not be written — the CLI cannot connect."; + } + + private static void SetOwnerOnlySocketMode(string socketPath) + { + const UnixFileMode ownerReadWrite = + UnixFileMode.UserRead | UnixFileMode.UserWrite; +#pragma warning disable CA1416 // TypeWhisper.Linux is a Linux-only assembly. + File.SetUnixFileMode(socketPath, ownerReadWrite); + if (File.GetUnixFileMode(socketPath) != ownerReadWrite) +#pragma warning restore CA1416 + { + throw new IOException($"Could not secure API socket {socketPath} with mode 0600."); + } + } + + private static void StopHost(WebApplication? host) + { + if (host is null) + { + return; + } + + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + host.StopAsync(timeout.Token).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Trace.WriteLine($"[HttpApiService] Kestrel shutdown failed: {ex.Message}"); + } + finally { try { - var context = await listener.GetContextAsync(); - _ = Task.Run(() => HandleRequestAsync(context, ct), ct); + host.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Trace.WriteLine($"[HttpApiService] Kestrel disposal failed: {ex.Message}"); } - catch (HttpListenerException) when (ct.IsCancellationRequested) + } + } + + private static void TryUnlinkSocket( + string? socketPath, + ApiSocketOwnership? ownership + ) + { + if (socketPath is null || ownership is null) + { + return; + } + + try + { + var cleanup = ownership.CleanupStaleSocket(); + // ReSharper disable once ConvertIfStatementToSwitchStatement -- only the two "leave it alone" outcomes are reported; a switch would need its own missing-enum-cases suppression. + if (cleanup is ApiSocketCleanupResult.Live) { - break; + Trace.WriteLine( + $"[HttpApiService] API socket path {socketPath} is held by a live listener; leaving it in place." + ); } - catch (ObjectDisposedException) + else if (cleanup is ApiSocketCleanupResult.Indeterminate) { - break; + Trace.WriteLine( + $"[HttpApiService] API socket cleanup was indeterminate for {socketPath}; leaving it in place." + ); } - catch + } + catch (Exception ex) + { + Trace.WriteLine( + $"[HttpApiService] Could not remove API socket {socketPath}: {ex.Message}" + ); + } + } + + private WebApplication BuildHost(int port, string socketPath) + { + var builder = WebApplication.CreateSlimBuilder( + new WebApplicationOptions { - // Keep the local API alive after malformed requests. + Args = [], + ApplicationName = typeof(HttpApiService).Assembly.GetName().Name, } + ); + // Drop appsettings.json / environment / command-line sources: Kestrel *adds* + // an ambient Kestrel:Endpoints entry to the listeners configured below rather + // than replacing them, which would bind this local-only API to a public interface. + builder.Configuration.Sources.Clear(); + builder.Logging.ClearProviders(); + // ConsoleLifetime would install SIGINT/SIGQUIT/SIGTERM handlers that cancel + // the signal and only stop this embedded host, leaving the desktop app alive + // and unkillable while the API is enabled. + builder.Services.AddSingleton(); + builder.WebHost.ConfigureKestrel(options => + { + // The request parser's 100 MiB / 1 MiB route-specific limits remain + // authoritative instead of Kestrel's lower 30 MB default. + options.Limits.MaxRequestBodySize = null; + options.ListenLocalhost(port); + options.ListenUnixSocket(socketPath, listenOptions => + { + // This boundary runs before HTTP parses headers or bodies. Rejecting + // here prevents a different UID from presenting bearer data or audio. + listenOptions.Use(next => async connection => + { + var socket = connection.Features.Get()?.Socket; + bool owned; + try + { + // A credential read that fails tells us nothing about the peer, + // so it must fail closed here rather than unwind into Kestrel. + owned = socket is not null && _validateUnixPeer(socket); + } + catch (IOException) + { + owned = false; + } + + if (!owned) + { + connection.Abort(); + return; + } + + await next(connection); + }); + }); + }); + + var app = builder.Build(); + app.Run(DispatchRequestAsync); + return app; + } + + private async Task DispatchRequestAsync(HttpContext context) + { + var handlerTask = _requestDispatcher.TryRun(() => + HandleRequestAsync(context, context.RequestAborted) + ); + if (handlerTask is null) + { + await RejectOverCapacityAsync(context, context.RequestAborted); + return; + } + + await handlerTask; + } + + internal static HttpApiOverCapacityResponse CreateOverCapacityResponse() + { + return new HttpApiOverCapacityResponse( + (int)HttpStatusCode.TooManyRequests, + "1", + Serialize(new { error = "Too many concurrent requests" }) + ); + } + + private async Task RejectOverCapacityAsync( + HttpContext 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 + { + await response.CompleteAsync(); } } - private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken ct) + private async Task HandleRequestAsync(HttpContext context, CancellationToken ct) { var response = context.Response; try { var request = context.Request; - var path = request.Url?.AbsolutePath ?? ""; - var method = request.HttpMethod; + var path = request.Path.Value ?? ""; + var method = request.Method; var allowedOrigin = GetAllowedOrigin(request); // CORS preflight: respond before auth so browsers can complete the handshake. @@ -265,7 +637,7 @@ private async Task HandleRequestAsync(HttpListenerContext context, CancellationT } response.StatusCode = 204; - response.ContentLength64 = 0; + response.ContentLength = 0; return; } @@ -283,7 +655,7 @@ await WriteJsonAsync( return; } - if (!IsValidOrigin(request) || !IsAllowedLoopbackHost(request.Url?.Host)) + if (!IsValidOrigin(request) || !IsAllowedLoopbackHost(request.Host.Host)) { // Origin itself is forbidden — do not send CORS to it. await WriteJsonAsync( @@ -300,9 +672,9 @@ await WriteJsonAsync( { ("/v1/status", "GET") => HandleStatus(), ("/v1/models", "GET") => HandleModels(), - ("/v1/transcribe", "POST") => await HandleTranscribeAsync(request, ct), + ("/v1/transcribe", "POST") => await HandleTranscribeAsync(context, ct), ("/v1/transcribe/local-file", "POST") => - await HandleTranscribeLocalFileAsync(request, ct), + await HandleTranscribeLocalFileAsync(context, ct), ("/v1/history", "GET") => HandleHistorySearch(request), ("/v1/history", "DELETE") => HandleHistoryDelete(request), ("/v1/profiles", "GET") => HandleProfilesList(), @@ -312,15 +684,15 @@ await HandleTranscribeLocalFileAsync(request, ct), ("/v1/dictation/status", "GET") => HandleDictationStatus(), ("/v1/dictation/transcription", "GET") => HandleDictationTranscription(request), ("/v1/dictionary/terms", "GET") => HandleGetDictionaryTerms(), - ("/v1/dictionary/terms", "PUT") => await HandlePutDictionaryTermsAsync(request, ct), + ("/v1/dictionary/terms", "PUT") => await HandlePutDictionaryTermsAsync(context, ct), ("/v1/dictionary/terms", "DELETE") => - await HandleDeleteDictionaryTermAsync(request, ct), + await HandleDeleteDictionaryTermAsync(context, ct), ("/v1/dictionary/corrections", "GET") => HandleGetDictionaryCorrections(), ("/v1/dictionary/corrections", "PUT") => - await HandlePutDictionaryCorrectionAsync(request, ct), + await HandlePutDictionaryCorrectionAsync(context, ct), ("/v1/dictionary/corrections", "DELETE") => - await HandleDeleteDictionaryCorrectionAsync(request, ct), - _ => (404, Serialize(new { error = "Not found" })) + await HandleDeleteDictionaryCorrectionAsync(context, ct), + _ => (404, Serialize(new { error = "Not found" })), }; await WriteJsonAsync(response, statusCode, body, allowedOrigin, ct); @@ -351,7 +723,7 @@ await WriteJsonAsync( } finally { - response.Close(); + await response.CompleteAsync(); } } @@ -375,7 +747,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), } ) ); @@ -386,7 +758,7 @@ private static string FormatAccelerationBackend(TranscriptionAccelerationBackend return backend switch { TranscriptionAccelerationBackend.NvidiaCuda => "nvidia-cuda", - _ => "cpu" + _ => "cpu", }; } @@ -409,7 +781,7 @@ private static string FormatAccelerationBackend(TranscriptionAccelerationBackend active = _models.ActiveModelId == id, status = _models.IsDownloaded(id) ? "ready" : engine.SupportsModelDownload ? "not_downloaded" - : "not_configured" + : "not_configured", }; }) ); @@ -418,19 +790,34 @@ private static string FormatAccelerationBackend(TranscriptionAccelerationBackend } private async Task<(int, string)> HandleTranscribeAsync( - HttpListenerRequest request, + HttpContext context, 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) + // Empty body — answer with the same contract ParseTranscribe would produce. + if (context.Request.ContentLength == 0) { - return (413, Serialize(new { error = "Request body too large" })); + return (400, Serialize(new { error = "No audio data provided" })); } - var apiRequest = await HttpApiRequestParser.FromListenerRequestAsync( - request, + var prepared = await PrepareTranscriptionRequestAsync(context, ct); + try + { + return await RunTranscriptionAsync(prepared.TempPath, prepared.Options, ct); + } + finally + { + DeleteTemporaryFileBestEffort(prepared.TempPath); + } + } + + private static async Task PrepareTranscriptionRequestAsync( + HttpContext context, + CancellationToken ct + ) + { + var apiRequest = await HttpApiRequestParser.FromHttpContextAsync( + context, MaxTranscribeRequestBytes, ct ); @@ -454,29 +841,35 @@ 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. } } private async Task<(int, string)> HandleTranscribeLocalFileAsync( - HttpListenerRequest request, + HttpContext context, CancellationToken ct ) { - var apiRequest = await HttpApiRequestParser.FromListenerRequestAsync( - request, - MaxTranscribeRequestBytes, + var apiRequest = await HttpApiRequestParser.FromHttpContextAsync( + context, + MaxJsonRequestBytes, ct ); if (apiRequest.Body.Length == 0) @@ -488,7 +881,7 @@ CancellationToken ct try { payload = JsonSerializer.Deserialize( - apiRequest.Body, + apiRequest.Body.Span, s_jsonOptions ); } @@ -512,16 +905,17 @@ CancellationToken ct return (400, Serialize(new { error = "Unsupported format" })); } - var task = string.Equals(payload.Task, "translate", StringComparison.OrdinalIgnoreCase) - ? TranscriptionTask.Translate - : TranscriptionTask.Transcribe; + var (task, responseFormat) = HttpApiRequestParser.ParseTranscriptionOptions( + payload.Task, + payload.ResponseFormat + ); var opts = new TranscriptionRunOptions( payload.Language, // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract -- LanguageHints is deserialized from JSON and can be null when the field is omitted payload.LanguageHints ?? [], task, payload.TargetLanguage, - string.IsNullOrWhiteSpace(payload.ResponseFormat) ? "json" : payload.ResponseFormat, + responseFormat, payload.Prompt, payload.Engine, payload.Model, @@ -573,11 +967,12 @@ CancellationToken ct PluginTranscriptionResult result; string engineProviderId; string? selectedModelId; + bool engineSupportsTranslation; ModelManagerService.TranscriptionLease lease; try { - lease = await _models.AcquireTranscriptionAsync(modelId, ct); + lease = await _models.AcquireTranscriptionAsync(modelId, cancellationToken: ct); } catch (InvalidOperationException) { @@ -596,8 +991,16 @@ CancellationToken ct ); engineProviderId = plugin.ProviderId; selectedModelId = plugin.SelectedModelId; + engineSupportsTranslation = plugin.SupportsTranslation; } + // An engine that ignores the translate task returns source-language text; reporting + // Translate downstream would make number normalization treat it as English. + var effectiveTask = + opts.Task == TranscriptionTask.Translate && engineSupportsTranslation + ? TranscriptionTask.Translate + : TranscriptionTask.Transcribe; + var processed = await _pipeline.ProcessAsync( result.Text, new PipelineOptions @@ -606,12 +1009,12 @@ CancellationToken ct ? _vocabularyBoosting.Apply : null, DictionaryCorrector = _dictionary.ApplyCorrections, - TranscriptionTask = opts.Task, + TranscriptionTask = effectiveTask, DetectedLanguage = result.DetectedLanguage, ConfiguredLanguage = language, ConfiguredLanguageCandidates = opts.LanguageHints, TranscriptionNumberNormalizationEnabled = - settings.TranscriptionNumberNormalizationEnabled + settings.TranscriptionNumberNormalizationEnabled, }, ct ); @@ -653,8 +1056,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, + }), } ) ); @@ -670,19 +1073,19 @@ CancellationToken ct duration = result.DurationSeconds, noSpeechProbability = result.NoSpeechProbability, engine = engineProviderId, - model = selectedModelId + model = selectedModelId, } ) ); } - private (int, string) HandleHistorySearch(HttpListenerRequest request) + private (int, string) HandleHistorySearch(HttpRequest request) { - var query = request.QueryString["q"] ?? ""; - var limit = int.TryParse(request.QueryString["limit"], out var parsedLimit) + var query = request.Query["q"].ToString(); + var limit = int.TryParse(request.Query["limit"].ToString(), out var parsedLimit) ? parsedLimit : 50; - var offset = int.TryParse(request.QueryString["offset"], out var parsedOffset) + var offset = int.TryParse(request.Query["offset"].ToString(), out var parsedOffset) ? parsedOffset : 0; @@ -703,7 +1106,7 @@ CancellationToken ct engine = record.EngineUsed, model = record.ModelUsed, profile = record.ProfileName, - words = record.WordCount + words = record.WordCount, }); return ( @@ -714,9 +1117,9 @@ CancellationToken ct ); } - private (int, string) HandleHistoryDelete(HttpListenerRequest request) + private (int, string) HandleHistoryDelete(HttpRequest request) { - var id = request.QueryString["id"]; + var id = request.Query["id"].ToString(); if (string.IsNullOrWhiteSpace(id)) { return (400, Serialize(new { error = "Missing id parameter" })); @@ -740,28 +1143,27 @@ CancellationToken ct translationTarget = profile.TranslationTarget, selectedTask = profile.SelectedTask, modelOverride = profile.TranscriptionModelOverride, - promptActionId = profile.PromptActionId + promptActionId = profile.PromptActionId, }); return (200, Serialize(new { profiles })); } - private (int, string) HandleProfileToggle(HttpListenerRequest request) + private (int, string) HandleProfileToggle(HttpRequest request) { - var id = request.QueryString["id"]; + var id = request.Query["id"].ToString(); if (string.IsNullOrWhiteSpace(id)) { 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 })); } @@ -795,9 +1197,9 @@ CancellationToken ct return (200, Serialize(new { started = true, sessionId })); } - private (int, string) HandleDictationTranscription(HttpListenerRequest request) + private (int, string) HandleDictationTranscription(HttpRequest request) { - var sessionIdRaw = request.QueryString["sessionId"]; + var sessionIdRaw = request.Query["sessionId"].ToString(); if (string.IsNullOrWhiteSpace(sessionIdRaw) || !int.TryParse(sessionIdRaw, out var sessionId)) { return (400, Serialize(new { error = "Missing or invalid sessionId" })); @@ -817,7 +1219,7 @@ CancellationToken ct durationSeconds = stored.DurationSeconds, engine = stored.EngineUsed, model = stored.ModelUsed, - message = stored.Message + message = stored.Message, } ) ); @@ -851,7 +1253,7 @@ CancellationToken ct { state = _dictation.IsRecording ? "recording" : "idle", isRecording = _dictation.IsRecording, - activeModel = _models.ActiveModelId + activeModel = _models.ActiveModelId, } ) ); @@ -864,13 +1266,13 @@ CancellationToken ct } private async Task<(int, string)> HandlePutDictionaryTermsAsync( - HttpListenerRequest request, + HttpContext context, CancellationToken ct ) { - var apiRequest = await HttpApiRequestParser.FromListenerRequestAsync( - request, - MaxTranscribeRequestBytes, + var apiRequest = await HttpApiRequestParser.FromHttpContextAsync( + context, + MaxJsonRequestBytes, ct ); if (apiRequest.Body.Length == 0) @@ -882,7 +1284,7 @@ CancellationToken ct try { payload = JsonSerializer.Deserialize( - apiRequest.Body, + apiRequest.Body.Span, s_jsonOptions ); } @@ -902,13 +1304,13 @@ CancellationToken ct } private async Task<(int, string)> HandleDeleteDictionaryTermAsync( - HttpListenerRequest request, + HttpContext context, CancellationToken ct ) { - var apiRequest = await HttpApiRequestParser.FromListenerRequestAsync( - request, - MaxTranscribeRequestBytes, + var apiRequest = await HttpApiRequestParser.FromHttpContextAsync( + context, + MaxJsonRequestBytes, ct ); if (apiRequest.Body.Length == 0) @@ -920,7 +1322,7 @@ CancellationToken ct try { payload = JsonSerializer.Deserialize( - apiRequest.Body, + apiRequest.Body.Span, s_jsonOptions ); } @@ -949,22 +1351,22 @@ 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, } ) ); } private async Task<(int, string)> HandlePutDictionaryCorrectionAsync( - HttpListenerRequest request, + HttpContext context, CancellationToken ct ) { - var apiRequest = await HttpApiRequestParser.FromListenerRequestAsync( - request, - MaxTranscribeRequestBytes, + var apiRequest = await HttpApiRequestParser.FromHttpContextAsync( + context, + MaxJsonRequestBytes, ct ); if (apiRequest.Body.Length == 0) @@ -976,7 +1378,7 @@ CancellationToken ct try { payload = JsonSerializer.Deserialize( - apiRequest.Body, + apiRequest.Body.Span, s_jsonOptions ); } @@ -1009,22 +1411,22 @@ 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, } ) ); } private async Task<(int, string)> HandleDeleteDictionaryCorrectionAsync( - HttpListenerRequest request, + HttpContext context, CancellationToken ct ) { - var apiRequest = await HttpApiRequestParser.FromListenerRequestAsync( - request, - MaxTranscribeRequestBytes, + var apiRequest = await HttpApiRequestParser.FromHttpContextAsync( + context, + MaxJsonRequestBytes, ct ); if (apiRequest.Body.Length == 0) @@ -1036,7 +1438,7 @@ CancellationToken ct try { payload = JsonSerializer.Deserialize( - apiRequest.Body, + apiRequest.Body.Span, s_jsonOptions ); } @@ -1060,9 +1462,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, } ) ); @@ -1135,12 +1537,12 @@ 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), }; } private static async Task WriteJsonAsync( - HttpListenerResponse response, + HttpResponse response, int statusCode, string body, string? origin, @@ -1156,8 +1558,8 @@ CancellationToken ct } var bytes = Encoding.UTF8.GetBytes(body); - response.ContentLength64 = bytes.Length; - await response.OutputStream.WriteAsync(bytes, ct); + response.ContentLength = bytes.Length; + await response.Body.WriteAsync(bytes, ct); } private static string Serialize(T value) @@ -1192,37 +1594,86 @@ private static string SanitizeExtension(string extension) private void EnsureBearerToken() { var current = _settings.Current; - var storedToken = current.ApiServerBearerToken; - var decryptedToken = ReadBearerToken(current); - if (!string.IsNullOrWhiteSpace(decryptedToken)) - { - // Token exists. storedToken == decryptedToken only when stored as plaintext - // by an older build (Decrypt is a no-op on non-base64 blobs) — re-encrypt - // on the way through so the stored value is always at-rest protected. - if (!string.Equals(storedToken, decryptedToken, StringComparison.Ordinal)) + var protectedToken = ProtectBearerToken( + current.ApiServerBearerToken, + _secretProtectionKeyFilePath + ); + if (protectedToken.Changed) + { + _settings.Save( + current with { ApiServerBearerToken = protectedToken.StoredValue } + ); + } + } + + internal static BearerTokenProtectionResult ProtectBearerToken( + string? storedValue, + string? secretProtectionKeyFilePath = null + ) + { + if (!string.IsNullOrWhiteSpace(storedValue)) + { + var decrypted = ApiKeyProtection.Decrypt( + storedValue, + secretProtectionKeyFilePath + ); + if ( + decrypted.Succeeded + && !string.IsNullOrWhiteSpace(decrypted.PlainText) + ) { - return; + if (decrypted.Format == SecretProtectionFormat.Current) + { + return new BearerTokenProtectionResult( + decrypted.PlainText, + storedValue, + false + ); + } + + return new BearerTokenProtectionResult( + decrypted.PlainText, + ApiKeyProtection.Encrypt( + decrypted.PlainText, + secretProtectionKeyFilePath + ), + true + ); } - _settings.Save( - current with { ApiServerBearerToken = ApiKeyProtection.Encrypt(decryptedToken) } - ); - return; + // Pre-encryption builds stored the generated token as plaintext hex. That decodes to + // a CBC-shaped envelope that cannot be authenticated, and rotating it would break + // external clients already holding the token, so re-protect it instead. + if (LegacyPlaintextTokenRegex().IsMatch(storedValue)) + { + return new BearerTokenProtectionResult( + storedValue, + ApiKeyProtection.Encrypt(storedValue, secretProtectionKeyFilePath), + true + ); + } } var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); - _settings.Save(current with { ApiServerBearerToken = ApiKeyProtection.Encrypt(token) }); + return new BearerTokenProtectionResult( + token, + ApiKeyProtection.Encrypt(token, secretProtectionKeyFilePath), + true + ); } - private bool IsAuthorized(HttpListenerRequest request) + private bool IsAuthorized(HttpRequest request) { - var expectedToken = ReadBearerToken(_settings.Current); + var expectedToken = ReadBearerToken( + _settings.Current, + _secretProtectionKeyFilePath + ); if (string.IsNullOrWhiteSpace(expectedToken)) { return false; } - var authorization = request.Headers["Authorization"]; + var authorization = request.Headers.Authorization.ToString(); if ( string.IsNullOrWhiteSpace(authorization) || !authorization.StartsWith("Bearer ", StringComparison.Ordinal) @@ -1245,9 +1696,9 @@ private bool IsAuthorized(HttpListenerRequest request) ); } - private string? GetAllowedOrigin(HttpListenerRequest request) + private string? GetAllowedOrigin(HttpRequest request) { - var origin = request.Headers["Origin"]; + var origin = request.Headers.Origin.ToString(); if (string.IsNullOrWhiteSpace(origin)) { return null; @@ -1267,9 +1718,9 @@ private bool IsAuthorized(HttpListenerRequest request) return null; } - private bool IsValidOrigin(HttpListenerRequest request) + private bool IsValidOrigin(HttpRequest request) { - var origin = request.Headers["Origin"]; + var origin = request.Headers.Origin.ToString(); return string.IsNullOrWhiteSpace(origin) || string.Equals(origin, GetAllowedOrigin(request), StringComparison.OrdinalIgnoreCase); } @@ -1287,6 +1738,9 @@ private static bool IsAllowedLoopbackHost(string? host) || string.Equals(host, "[::1]", StringComparison.OrdinalIgnoreCase); } + [GeneratedRegex("^[0-9A-Fa-f]{64}$")] + private static partial Regex LegacyPlaintextTokenRegex(); + private sealed record TranscriptionRunOptions( string? Language, IReadOnlyList LanguageHints, @@ -1298,6 +1752,25 @@ private sealed record TranscriptionRunOptions( string? Model, bool AwaitDownload ); + + private sealed record PreparedTranscriptionRequest( + string TempPath, + TranscriptionRunOptions Options + ); + + /// Host lifetime that owns no process signals — the desktop app owns shutdown. + private sealed class EmbeddedHostLifetime : IHostLifetime + { + public Task WaitForStartAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } } -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/src/TypeWhisper.Linux/Services/Insertion/AtSpiPasteConfirmation.cs b/src/TypeWhisper.Linux/Services/Insertion/AtSpiPasteConfirmation.cs index 2157f167b..dbb3f9f1a 100644 --- a/src/TypeWhisper.Linux/Services/Insertion/AtSpiPasteConfirmation.cs +++ b/src/TypeWhisper.Linux/Services/Insertion/AtSpiPasteConfirmation.cs @@ -1,15 +1,15 @@ +using System.Diagnostics; 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. +/// stream and verifying that the changed element contains the exact expected clipboard +/// text. A same-application object:text-changed event alone is indeterminate. +/// 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 @@ -19,6 +19,14 @@ namespace TypeWhisper.Linux.Services.Insertion; /// 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. /// +/// +/// Targets without a readable AT-SPI Text interface (including terminals and some +/// Electron surfaces) cannot positively confirm this way and fall through to the +/// existing timeout/floor delay. The exact-substring check also cannot prove the +/// caret position or inserted range: an unrelated edit to an element that already +/// contained the expected text can still satisfy the heuristic. Closing that gap +/// requires AT-SPI signal payload details that this event abstraction does not expose. +/// /// public sealed class AtSpiPasteConfirmation : IPasteConfirmationSource { @@ -32,18 +40,21 @@ public AtSpiPasteConfirmation(IAtSpiEventClient client) public bool? HasFocusedElement => _client.IsRunning ? _client.CurrentFocusedElement is not null : null; - public IPasteWatch? BeginWatch() + public IPasteWatch? BeginWatch(string expectedText) { - return _client.IsRunning ? new AtSpiPasteWatch(_client) : null; + return _client.IsRunning ? new AtSpiPasteWatch(_client, expectedText) : null; } private sealed class AtSpiPasteWatch : IPasteWatch { private readonly IAtSpiEventClient _client; + private readonly string _expectedText; + private readonly int _readLength; // 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. + // where the paste is about to land. This is a cheap first-pass filter; the changed + // element's text must still contain the expected paste. When focus is unknown, the + // content check prevents an arbitrary app's event alone from confirming delivery. private readonly string? _targetBusName; private readonly TaskCompletionSource _textChanged = new( @@ -55,16 +66,20 @@ private sealed class AtSpiPasteWatch : IPasteWatch // own lease a paste with no armed field would observe no text-changed at all. private readonly IDisposable _textEventsLease; - internal AtSpiPasteWatch(IAtSpiEventClient client) + internal AtSpiPasteWatch(IAtSpiEventClient client, string expectedText) { _client = client; + _expectedText = expectedText; + // Bound document reads while leaving room for the paste and nearby context. + // 8192 mirrors the correction-learning service's maximum tracked text length. + _readLength = Math.Clamp(expectedText.Length + 256, 512, 8192); _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. + // WaitAsync can verify and latch in the TCS so 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 degrades to the existing timeout fallback. _textEventsLease = client.AcquireTextChangedEvents(); _client.TextChanged += OnTextChanged; } @@ -86,8 +101,8 @@ internal AtSpiPasteWatch(IAtSpiEventClient client) } // Propagates OperationCanceledException when ct fired; otherwise the window - // elapsed without an event — indeterminate, never false (some targets simply - // don't emit text-changed). + // elapsed without a verified event — indeterminate, never false (some targets + // do not emit text-changed or expose readable text). await completed.ConfigureAwait(false); return null; } @@ -100,21 +115,48 @@ public void Dispose() _textEventsLease.Dispose(); } - // 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) + // The event source identifies the element to read; only its exact expected substring + // can confirm. Unreadable or non-matching elements stay indeterminate, leaving the + // watch armed for a later event or the timeout fallback. + private async void OnTextChanged(AtSpiElementRef element) { - if ( - _targetBusName is null - || string.Equals(element.BusName, _targetBusName, StringComparison.Ordinal) - ) + try + { + if ( + _targetBusName is not null + && !string.Equals( + element.BusName, + _targetBusName, + StringComparison.Ordinal + ) + ) + { + return; + } + + // Never read a password (or role-unreadable) element — the same privacy + // boundary every correction-learning read honors. Null fails closed: an + // unknown role stays indeterminate rather than risk reading a password field. + if (await _client.IsPasswordFieldAsync(element).ConfigureAwait(false) != false) + { + return; + } + + var currentText = await _client + .TryReadTextAsync(element, _readLength) + .ConfigureAwait(false); + if (currentText?.Contains(_expectedText, StringComparison.Ordinal) == true) + { + _textChanged.TrySetResult(true); + } + } + catch (Exception ex) { - _textChanged.TrySetResult(true); + // Event handlers must never fault the AT-SPI dispatch path. The client's + // text read already maps expected D-Bus failures to null; this is defense-in-depth. + Trace.WriteLine( + $"[AtSpiPasteConfirmation] Failed to verify text-changed event: {ex.Message}" + ); } } } diff --git a/src/TypeWhisper.Linux/Services/Insertion/IPasteConfirmationSource.cs b/src/TypeWhisper.Linux/Services/Insertion/IPasteConfirmationSource.cs index 264eb53d0..a49f1b299 100644 --- a/src/TypeWhisper.Linux/Services/Insertion/IPasteConfirmationSource.cs +++ b/src/TypeWhisper.Linux/Services/Insertion/IPasteConfirmationSource.cs @@ -25,12 +25,13 @@ public interface IPasteConfirmationSource 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. + /// Starts watching for a text mutation that verifies + /// was delivered; 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(); + /// The exact clipboard text the target is expected to insert. + IPasteWatch? BeginWatch(string expectedText); } /// 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/Ipc/ApiSocketOwnership.cs b/src/TypeWhisper.Linux/Services/Ipc/ApiSocketOwnership.cs new file mode 100644 index 000000000..235c7e4c9 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/Ipc/ApiSocketOwnership.cs @@ -0,0 +1,251 @@ +using Microsoft.Win32.SafeHandles; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +namespace TypeWhisper.Linux.Services.Ipc; + +internal enum ApiSocketCleanupResult +{ + Missing, + Removed, + Live, + Indeterminate, + OwnershipContended, +} + +/// +/// Owns the stable advisory lock that serializes every API-socket bind and unlink. +/// The API lock is distinct from the live control socket's lock. +/// +internal sealed partial class ApiSocketOwnership : 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 ApiSocketOwnership(string socketPath, string lockPath, SafeFileHandle lockHandle) + { + SocketPath = socketPath; + LockPath = lockPath; + _lockHandle = lockHandle; + } + + private string SocketPath { get; } + + internal string LockPath { get; } + + internal static bool TryAcquire( + string socketPath, + [NotNullWhen(true)] out ApiSocketOwnership? ownership + ) + { + ownership = null; + var lockPath = Path.Join(Path.GetDirectoryName(socketPath)!, "api.lock"); + // ReSharper disable once SuggestVarOrType_SimpleTypes -- nullable enables ownership transfer below. + SafeFileHandle? handle = OpenLockFile(lockPath); + try + { + SetOwnerOnlyMode(handle, lockPath); + while (flock(handle, LockExclusive | LockNonBlocking) != 0) + { + var error = Marshal.GetLastPInvokeError(); + // ReSharper disable once ConvertIfStatementToSwitchStatement -- independent errno guard clauses; a switch would hide that the fallthrough throws. + if (error == ErrorInterrupted) + { + continue; + } + + if (error == ErrorTryAgain) + { + return false; + } + + throw new IOException( + $"Could not acquire API socket ownership lock {lockPath}.", + new Win32Exception(error) + ); + } + + ownership = new ApiSocketOwnership(socketPath, lockPath, handle); + handle = null; + return true; + } + finally + { + handle?.Dispose(); + } + } + + internal static ApiSocketCleanupResult TryCleanupStaleSocket(string socketPath) + { + try + { + if (!TryAcquire(socketPath, out var ownership)) + { + return ApiSocketCleanupResult.OwnershipContended; + } + + using (ownership) + { + return ownership.CleanupStaleSocket(); + } + } + catch (Exception ex) + { + Trace.WriteLine( + $"[ApiSocketOwnership] Could not acquire cleanup ownership for {socketPath}: {ex.Message}" + ); + return ApiSocketCleanupResult.Indeterminate; + } + } + + /// + /// Re-probes and, only on ECONNREFUSED, unlinks a stale socket while ownership is held. + /// + internal ApiSocketCleanupResult CleanupStaleSocket() + { + ObjectDisposedException.ThrowIf(_disposed != 0, this); + + if (!File.Exists(SocketPath)) + { + return ApiSocketCleanupResult.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 ApiSocketCleanupResult.Live; + } + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionRefused) + { + if (!File.Exists(SocketPath)) + { + return ApiSocketCleanupResult.Missing; + } + + try + { + File.Delete(SocketPath); + if (!File.Exists(SocketPath)) + { + Trace.WriteLine($"[ApiSocketOwnership] Removed stale socket at {SocketPath}."); + return ApiSocketCleanupResult.Removed; + } + } + catch (Exception deleteException) + { + Trace.WriteLine( + $"[ApiSocketOwnership] Failed to remove stale socket {SocketPath}: {deleteException.Message}" + ); + return ApiSocketCleanupResult.Indeterminate; + } + + Trace.WriteLine( + $"[ApiSocketOwnership] Stale socket {SocketPath} remained after deletion." + ); + return ApiSocketCleanupResult.Indeterminate; + } + catch (Exception ex) + { + if (!File.Exists(SocketPath)) + { + return ApiSocketCleanupResult.Missing; + } + + Trace.WriteLine( + $"[ApiSocketOwnership] Probe of {SocketPath} was indeterminate: {ex.Message}" + ); + return ApiSocketCleanupResult.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) + { + return new SafeFileHandle(fd, ownsHandle: true); + } + + var error = Marshal.GetLastPInvokeError(); + if (error == ErrorInterrupted) + { + continue; + } + + throw new IOException( + $"Could not open API 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 API socket ownership lock {lockPath} with mode 0600.", + new Win32Exception(error) + ); + } + } + + // ReSharper disable once InconsistentNaming -- native libc function name. + [LibraryImport("libc", SetLastError = true)] + private static partial int flock(SafeFileHandle fd, int operation); + + // ReSharper disable once InconsistentNaming -- native libc function name. + [LibraryImport("libc", SetLastError = true)] + private static partial int fchmod(SafeFileHandle fd, uint mode); + + // ReSharper disable once InconsistentNaming -- native libc function 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/ControlSocketClient.cs b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketClient.cs index 003f94ebb..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) { @@ -40,14 +41,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 +133,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 +238,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 +249,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..9515b3a72 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketOwnership.cs @@ -0,0 +1,271 @@ +using Microsoft.Win32.SafeHandles; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +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"); + // 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 + { + SetOwnerOnlyMode(handle, lockPath); + 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; + } + + 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] Stale-socket cleanup for {socketPath} failed: {ex.Message}" + ); + return ControlSocketCleanupResult.Indeterminate; + } + } + + /// + /// 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() + { + 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 8295282bb..12633a7a5 100644 --- a/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs +++ b/src/TypeWhisper.Linux/Services/Ipc/ControlSocketServer.cs @@ -42,20 +42,16 @@ 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 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; + private ControlSocketOwnership? _ownership; // ReSharper disable once IntroduceOptionalParameters.Global -- kept as explicit overloads; collapsing into optional parameters would delete a member. public ControlSocketServer(DictationOrchestrator orchestrator) @@ -72,6 +68,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(); } @@ -85,20 +88,234 @@ 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 + { + // 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(); + 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), 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); + } + + /// Callers must hold _lifecycleGate. + 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(); @@ -119,95 +336,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; @@ -403,7 +577,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); @@ -417,32 +591,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 +603,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 @@ -535,82 +685,146 @@ 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); } - /// 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(); + } + +} + +/// +/// 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; } /// - /// 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. + /// 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. /// - private static void TryRemoveStaleSocket(string path) + public Task DispatchStart(Func start) { - if (!File.Exists(path)) + var prev = SnapshotState(); + TaskCompletionSource? startCompletion = null; + + lock (_gate) { - return; + 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 { - using var probe = new Socket( - AddressFamily.Unix, - SocketType.Stream, - ProtocolType.Unspecified + startTask = start(); + } + catch (Exception ex) + { + ReportFault(ex); + startCompletion.TrySetResult(); + return Task.FromResult( + JsonControlProtocol.SerializeError(JsonControlProtocol.ErrInternal) ); - probe.Connect(new UnixDomainSocketEndPoint(path)); - // A live peer accepted us; do NOT delete. } - catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionRefused) + + _ = ObserveStartAsync(startTask, startCompletion); + + // A failure that settled synchronously is known before the response is committed. + return startTask is { IsCompleted: true, IsCompletedSuccessfully: false } + ? Task.FromResult( + JsonControlProtocol.SerializeError(JsonControlProtocol.ErrInternal) + ) + : 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) { - 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}" - ); - } + return state; } - catch (Exception ex) + + lock (_gate) { - Trace.WriteLine($"[ControlSocketServer] Probe of {path} threw: {ex.Message}"); + return _lastStartTask is { IsCompleted: false } + ? JsonControlProtocol.StateStarting + : state; } } - /// True when ECONNREFUSED — no live listener, safe to unlink. False on any other outcome. - private static bool NoLivePeer(string path) + private async Task ObserveStartAsync(Task startTask, TaskCompletionSource startCompletion) { try { - using var probe = new Socket( - AddressFamily.Unix, - SocketType.Stream, - ProtocolType.Unspecified - ); - probe.Connect(new UnixDomainSocketEndPoint(path)); - return false; + await startTask.ConfigureAwait(false); } - catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionRefused) + 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 { - return true; + _onFault(ex); } 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; + // Diagnostics must never turn the observer into an unobserved faulted task. } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs b/src/TypeWhisper.Linux/Services/Ipc/JsonControlProtocol.cs index 854f1c4fa..abfb4eed6 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"; @@ -34,6 +42,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"; @@ -53,7 +62,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) @@ -151,4 +160,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/src/TypeWhisper.Linux/Services/Ipc/SocketPathResolver.cs b/src/TypeWhisper.Linux/Services/Ipc/SocketPathResolver.cs index 2304fc049..52c05a9a5 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,12 +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"; + private const string ControlSocketFileName = "control.sock"; + private const string ApiSocketFileName = "api.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). @@ -33,50 +39,53 @@ internal static partial class SocketPathResolver /// public static string ResolveControlSocketPath() { + return ResolveControlSocketPath(DefaultFallbackDirectory); + } + + internal static string ResolveControlSocketPath(string fallbackDirectory) + { + return ResolveSocketPath(fallbackDirectory, ControlSocketFileName); + } + + /// + /// Resolves the API-socket path in the same private runtime directory + /// as the control socket without creating the socket file. + /// + public static string ResolveApiSocketPath() + { + return ResolveApiSocketPath(DefaultFallbackDirectory); + } + + internal static string ResolveApiSocketPath(string fallbackDirectory) + { + return ResolveSocketPath(fallbackDirectory, ApiSocketFileName); + } + + private static string ResolveSocketPath(string fallbackDirectory, string socketFileName) + { + 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 - return Path.Join(dir, SocketFileName); + 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 +107,36 @@ 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 + { + // 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) { - 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); + // 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)) + { + 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 +242,4 @@ private static partial int statx( uint mask, IntPtr statxbuf ); -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/Ipc/UnixPeerCredentials.cs b/src/TypeWhisper.Linux/Services/Ipc/UnixPeerCredentials.cs new file mode 100644 index 000000000..6d78bab29 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/Ipc/UnixPeerCredentials.cs @@ -0,0 +1,73 @@ +using System.ComponentModel; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +namespace TypeWhisper.Linux.Services.Ipc; + +/// Reads Linux peer credentials before a Unix-socket connection reaches HTTP. +internal static partial class UnixPeerCredentials +{ + private const int SolSocket = 1; + private const int SoPeerCred = 17; + + internal static bool IsOwnedByEffectiveUser(Socket socket) + { + ArgumentNullException.ThrowIfNull(socket); + + var credentials = Get(socket); + return credentials.Uid == geteuid(); + } + + private static PeerCredentials Get(Socket socket) + { + ArgumentNullException.ThrowIfNull(socket); + + var credentials = new PeerCredentials(); + var length = (uint)Marshal.SizeOf(); + if ( + getsockopt( + socket.Handle, + SolSocket, + SoPeerCred, + ref credentials, + ref length + ) != 0 + ) + { + throw new IOException( + "Could not read Unix-socket peer credentials.", + new Win32Exception(Marshal.GetLastPInvokeError()) + ); + } + + // ReSharper disable once ConvertIfStatementToReturnStatement -- keeps the two size/errno guards in the same throw-on-failure shape; the suggested ternary-throw does not. + if (length != Marshal.SizeOf()) + { + throw new IOException("Unix-socket peer credentials had an unexpected size."); + } + + return credentials; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct PeerCredentials + { + internal int Pid; + internal uint Uid; + internal uint Gid; + } + + // ReSharper disable once InconsistentNaming -- native libc function name. + [LibraryImport("libc", SetLastError = true)] + private static partial int getsockopt( + IntPtr socket, + int level, + int optionName, + ref PeerCredentials optionValue, + ref uint optionLength + ); + + // ReSharper disable once InconsistentNaming -- native libc function name. + [LibraryImport("libc", SetLastError = true)] + private static partial uint geteuid(); +} diff --git a/src/TypeWhisper.Linux/Services/LearnedCorrectionsFeedbackPresenter.cs b/src/TypeWhisper.Linux/Services/LearnedCorrectionsFeedbackPresenter.cs index 7ede4e144..487486382 100644 --- a/src/TypeWhisper.Linux/Services/LearnedCorrectionsFeedbackPresenter.cs +++ b/src/TypeWhisper.Linux/Services/LearnedCorrectionsFeedbackPresenter.cs @@ -33,6 +33,7 @@ public sealed class LearnedCorrectionsFeedbackPresenter private static readonly TimeSpan s_confirmationAutoHide = TimeSpan.FromSeconds(2); private readonly IDictionaryService _dictionary; + private readonly IErrorLogService _errorLog; // 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. @@ -49,10 +50,12 @@ public sealed class LearnedCorrectionsFeedbackPresenter public LearnedCorrectionsFeedbackPresenter( IDictionaryService dictionary, + IErrorLogService errorLog, Func scheduleDelay ) { _dictionary = dictionary; + _errorLog = errorLog; _scheduleDelay = scheduleDelay; } @@ -99,7 +102,22 @@ public void Undo() return; } - _dictionary.UndoLearnedCorrections(_pending); + try + { + _dictionary.UndoLearnedCorrections(_pending); + } + catch (Exception ex) + { + _errorLog.AddEntry( + $"Failed to undo learned correction: {ex.Message}"); + Emit( + new LearnedCorrectionsFeedback( + Loc.Instance["Feedback.CorrectionUndoFailed"], + ShowUndo: true), + s_learnedAutoHide); + return; + } + _pending = []; Emit( diff --git a/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs b/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs index 0a958c908..2e3081511 100644 --- a/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs +++ b/src/TypeWhisper.Linux/Services/LearnedCorrectionsNotificationService.cs @@ -97,7 +97,7 @@ internal LearnedCorrectionsNotificationService( // 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); + _presenter = new LearnedCorrectionsFeedbackPresenter(dictionary, errorLog, schedule); } public void Dispose() @@ -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/LearnedCorrectionsToastController.cs b/src/TypeWhisper.Linux/Services/LearnedCorrectionsToastController.cs index c897da38b..3b5b89c49 100644 --- a/src/TypeWhisper.Linux/Services/LearnedCorrectionsToastController.cs +++ b/src/TypeWhisper.Linux/Services/LearnedCorrectionsToastController.cs @@ -39,6 +39,7 @@ public sealed class LearnedCorrectionsToastController : IDisposable public LearnedCorrectionsToastController( TargetAppCorrectionLearningService learning, IDictionaryService dictionary, + IErrorLogService errorLog, LearnedCorrectionToastWindow window ) { @@ -50,7 +51,10 @@ LearnedCorrectionToastWindow window // The presenter's one-shot auto-hide callback re-enters the presenter, so back it with a // DispatcherTimer to keep every access on the UI thread (like the overlay VM did). - _presenter = new LearnedCorrectionsFeedbackPresenter(dictionary, ScheduleUiDelay); + _presenter = new LearnedCorrectionsFeedbackPresenter( + dictionary, + errorLog, + ScheduleUiDelay); } public void Initialize() diff --git a/src/TypeWhisper.Linux/Services/LinuxDictationFinalTextPolicy.cs b/src/TypeWhisper.Linux/Services/LinuxDictationFinalTextPolicy.cs index 4ea6b55f2..e61066786 100644 --- a/src/TypeWhisper.Linux/Services/LinuxDictationFinalTextPolicy.cs +++ b/src/TypeWhisper.Linux/Services/LinuxDictationFinalTextPolicy.cs @@ -16,6 +16,14 @@ internal static partial class LinuxDictationFinalTextPolicy // pathological transcript can never spin indefinitely. private const int MaximumRepeatReductionPasses = 8; + // Rolling hashes use explicit, stable per-token hashes. Two independent + // polynomial bases make accidental range collisions vanishingly unlikely; + // matching ranges are still compared token-by-token before removal. + private const ulong StableTokenHashOffset = 14_695_981_039_346_656_037UL; + private const ulong StableTokenHashPrime = 1_099_511_628_211UL; + private const ulong FirstRollingHashBase = 1_000_000_007UL; + private const ulong SecondRollingHashBase = 1_000_000_009UL; + [GeneratedRegex(@"\s*(?:\.{3,}|…)\s*", RegexOptions.CultureInvariant)] private static partial Regex AutomaticEllipsisRegex(); @@ -68,6 +76,25 @@ private static bool TryFindAdjacentRepeatedPhrase( removalStart = 0; removalEnd = 0; + var characterPrefix = new int[tokens.Count + 1]; + var tokenHashes = new ulong[tokens.Count]; + var firstHashPrefix = new ulong[tokens.Count + 1]; + var secondHashPrefix = new ulong[tokens.Count + 1]; + var firstHashPowers = new ulong[tokens.Count + 1]; + var secondHashPowers = new ulong[tokens.Count + 1]; + firstHashPowers[0] = 1; + secondHashPowers[0] = 1; + + for (var i = 0; i < tokens.Count; i++) + { + characterPrefix[i + 1] = characterPrefix[i] + tokens[i].Normalized.Length; + tokenHashes[i] = ComputeStableTokenHash(tokens[i].Normalized); + firstHashPrefix[i + 1] = unchecked(firstHashPrefix[i] * FirstRollingHashBase + tokenHashes[i]); + secondHashPrefix[i + 1] = unchecked(secondHashPrefix[i] * SecondRollingHashBase + tokenHashes[i]); + firstHashPowers[i + 1] = unchecked(firstHashPowers[i] * FirstRollingHashBase); + secondHashPowers[i + 1] = unchecked(secondHashPowers[i] * SecondRollingHashBase); + } + for (var boundary = MinimumRepeatedPhraseWords; boundary <= tokens.Count - MinimumRepeatedPhraseWords; boundary++) @@ -75,8 +102,16 @@ private static bool TryFindAdjacentRepeatedPhrase( var maxLength = Math.Min(boundary, tokens.Count - boundary); for (var length = maxLength; length >= MinimumRepeatedPhraseWords; length--) { - if (!HasMinimumRepeatedPhraseLength(tokens, boundary, length) - || !TokensMatch(tokens, boundary - length, boundary, length)) + if (!HasMinimumRepeatedPhraseLength(characterPrefix, boundary, length) + || !TokensMatch( + tokens, + firstHashPrefix, + secondHashPrefix, + firstHashPowers, + secondHashPowers, + boundary - length, + boundary, + length)) { continue; } @@ -101,19 +136,32 @@ private static bool TryFindAdjacentRepeatedPhrase( return false; } - private static bool HasMinimumRepeatedPhraseLength(IReadOnlyList tokens, int boundary, int length) + private static bool HasMinimumRepeatedPhraseLength(int[] characterPrefix, int boundary, int length) { - var characterCount = 0; - for (var i = boundary; i < boundary + length; i++) - { - characterCount += tokens[i].Normalized.Length; - } - + var characterCount = characterPrefix[boundary + length] - characterPrefix[boundary]; return characterCount >= MinimumRepeatedPhraseCharacters; } - private static bool TokensMatch(IReadOnlyList tokens, int leftStart, int rightStart, int length) + private static bool TokensMatch( + IReadOnlyList tokens, + ulong[] firstHashPrefix, + ulong[] secondHashPrefix, + ulong[] firstHashPowers, + ulong[] secondHashPowers, + int leftStart, + int rightStart, + int length) { + if (GetRangeHash(firstHashPrefix, firstHashPowers, leftStart, length) + != GetRangeHash(firstHashPrefix, firstHashPowers, rightStart, length) + || GetRangeHash(secondHashPrefix, secondHashPowers, leftStart, length) + != GetRangeHash(secondHashPrefix, secondHashPowers, rightStart, length)) + { + return false; + } + + // Hashes only reject non-matches. Exact comparison preserves behavior + // even on a rolling-hash or per-token-hash collision. for (var offset = 0; offset < length; offset++) { if (!string.Equals( @@ -128,6 +176,27 @@ private static bool TokensMatch(IReadOnlyList tokens, int leftStart, return true; } + private static ulong GetRangeHash( + ulong[] hashPrefix, + ulong[] hashPowers, + int start, + int length) + { + return unchecked(hashPrefix[start + length] - hashPrefix[start] * hashPowers[length]); + } + + private static ulong ComputeStableTokenHash(string normalized) + { + var hash = StableTokenHashOffset; + // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator -- hot unchecked FNV accumulation; a LINQ Aggregate switches the char enumerator and is slower here. + foreach (var ch in normalized) + { + hash = unchecked((hash ^ ch) * StableTokenHashPrime); + } + + return hash; + } + private static bool RightMatchContinuesPhrase(string text, IReadOnlyList tokens, int boundary, int length) { @@ -191,4 +260,4 @@ private static string NormalizeWord(string word) } private readonly record struct WordToken(int Start, int End, string Normalized); -} \ No newline at end of file +} 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..8651d3f3c 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 @@ -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/LinuxPreferencesService.cs b/src/TypeWhisper.Linux/Services/LinuxPreferencesService.cs index 838d984db..234523b56 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; @@ -53,20 +54,41 @@ public sealed class LinuxPreferencesService { private static readonly JsonSerializerOptions s_jsonOptions = new() { - WriteIndented = true, PropertyNameCaseInsensitive = true + 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(); } 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() + { + // 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)) { @@ -91,19 +113,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/LinuxSystemTtsProvider.cs b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs index 2e919706f..fca292fa4 100644 --- a/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs +++ b/src/TypeWhisper.Linux/Services/LinuxSystemTtsProvider.cs @@ -9,17 +9,45 @@ namespace TypeWhisper.Linux.Services; public sealed class LinuxSystemTtsProvider : ITtsProviderPlugin { private const string BuiltInProviderId = AppSettings.DefaultSpokenFeedbackProviderId; - private readonly SystemCommandAvailabilityService _commands; - + private const long PlaybackStartupMilliseconds = 5_000; + 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; private readonly ISettingsService _settings; public LinuxSystemTtsProvider( ISettingsService settings, - SystemCommandAvailabilityService commands + SystemCommandAvailabilityService commands, + IProcessRunner processRunner + ) + : this(settings, processRunner, () => commands.SpeechFeedbackCommand) + { + } + + internal LinuxSystemTtsProvider( + ISettingsService settings, + IProcessRunner processRunner, + string? speechFeedbackCommand + ) + : this(settings, processRunner, () => speechFeedbackCommand) + { + } + + private LinuxSystemTtsProvider( + ISettingsService settings, + IProcessRunner processRunner, + Func speechFeedbackCommand ) { _settings = settings; - _commands = commands; + _processRunner = processRunner; + _speechFeedbackCommand = speechFeedbackCommand; } public string PluginId => "com.typewhisper.tts.linux-system"; @@ -27,7 +55,7 @@ SystemCommandAvailabilityService commands public string PluginVersion => "1.0.0"; public string ProviderId => BuiltInProviderId; public string ProviderDisplayName => "Linux system voice"; - public bool IsConfigured => _commands.SpeechFeedbackCommand is not null; + public bool IsConfigured => _speechFeedbackCommand() is not null; public string? SelectedVoiceId => _settings.Current.SpokenFeedbackVoiceId; public string SettingsSummary => SelectedVoiceId ?? "System default voice"; @@ -61,7 +89,7 @@ public Task SpeakAsync(TtsSpeakRequest request, Cancellatio return Task.FromResult(InactiveTtsPlaybackSession.Instance); } - var command = _commands.SpeechFeedbackCommand; + var command = _speechFeedbackCommand(); if (command is null) { return Task.FromResult(InactiveTtsPlaybackSession.Instance); @@ -69,111 +97,178 @@ public Task SpeakAsync(TtsSpeakRequest request, Cancellatio ct.ThrowIfCancellationRequested(); - var startInfo = new ProcessStartInfo(command) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - // spd-say handles its own audio output; espeak/espeak-ng use --stdout - // and are piped into paplay/aplay (see StartEspeakPlayback). - if (command == "spd-say") - { - startInfo.ArgumentList.Add(request.Text); - var process = Process.Start(startInfo); - return Task.FromResult( - process is null - ? InactiveTtsPlaybackSession.Instance - : new ProcessTtsPlaybackSession(process, ct) - ); - } - - startInfo.ArgumentList.Add("--stdout"); - startInfo.ArgumentList.Add(request.Text); - var espeakProcess = StartEspeakPlayback(startInfo); - return Task.FromResult( - espeakProcess is null - ? InactiveTtsPlaybackSession.Instance - : new ProcessTtsPlaybackSession(espeakProcess, ct) + 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; + 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. + var session = new TaskBackedTtsPlaybackSession( + _processRunner, + command, + args, + fallbackArgs, + cancellationArgs, + s_dispatcherCancellationTimeout, + CalculatePlaybackTimeout(request.Text.Length), + ct ); + return Task.FromResult(session); } public void Dispose() { } - private static Process? StartEspeakPlayback(ProcessStartInfo espeakStartInfo) + private static string? NormalizeLanguageHint(string? language) { - // Pipe espeak stdout into paplay/aplay so we don't depend on espeak's - // built-in audio library. `sh -c '...' sh "$text"` avoids word-splitting - // on the TTS text while still supporting the shell pipe operator. - var player = ResolvePlayer(); - if (player is null) - { - return Process.Start(espeakStartInfo); - } - - var shell = new ProcessStartInfo("sh") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - shell.ArgumentList.Add("-c"); - shell.ArgumentList.Add($"{Quote(espeakStartInfo.FileName)} --stdout \"$1\" | {player}"); - shell.ArgumentList.Add("sh"); - shell.ArgumentList.Add(espeakStartInfo.ArgumentList.LastOrDefault() ?? ""); - return Process.Start(shell); + var normalized = language?.Trim(); + return string.IsNullOrEmpty(normalized) + || string.Equals(normalized, "auto", StringComparison.OrdinalIgnoreCase) + ? null + : normalized; } - private static string? ResolvePlayer() + private static IReadOnlyList BuildArguments( + string command, + string text, + string? language + ) { - if (CommandExists("paplay")) + if (language is null) { - return "paplay"; + return BuildDefaultArguments(command, text); } - return CommandExists("aplay") ? "aplay" : null; + return command switch + { + "espeak" or "espeak-ng" => ["-v", language, text], + "spd-say" => ["--wait", "-l", language, text], + _ => BuildDefaultArguments(command, text), + }; } - private static bool CommandExists(string name) + private static IReadOnlyList BuildDefaultArguments(string command, string text) { - var path = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrWhiteSpace(path)) - { - return false; - } - - return path - .Split( - Path.PathSeparator, - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Any(dir => File.Exists(Path.Join(dir, name))); + return command == "spd-say" ? ["--wait", text] : [text]; } - private static string Quote(string value) + internal static TimeSpan CalculatePlaybackTimeout(int utf16CharacterCount) { - return "'" + value.Replace("'", "'\\''") + "'"; + ArgumentOutOfRangeException.ThrowIfNegative(utf16CharacterCount); + + var calculatedMilliseconds = PlaybackStartupMilliseconds + // ReSharper disable once RedundantCast -- explicit long cast documents that the per-character scaling stays in the long domain; part of the deliberate overflow-safe timeout arithmetic. + + (long)utf16CharacterCount + * PlaybackMillisecondsPerUtf16Character; + return TimeSpan.FromMilliseconds( + Math.Clamp( + calculatedMilliseconds, + MinimumPlaybackMilliseconds, + MaximumPlaybackMilliseconds + ) + ); } } -internal sealed class ProcessTtsPlaybackSession : ITtsPlaybackSession, IDisposable +internal sealed class TaskBackedTtsPlaybackSession : ITtsPlaybackSession, IDisposable { - private readonly Process _process; - private readonly CancellationTokenRegistration _registration; + private readonly Lock _sync = new(); + private readonly CancellationTokenSource _invocationCts; + private readonly Task _runnerTask; + private EventHandler? _completedHandlers; private int _completed; + private int _resourcesDisposed; + private int _stopRequested; + + public TaskBackedTtsPlaybackSession( + IProcessRunner processRunner, + string command, + IReadOnlyList args, + IReadOnlyList? fallbackArgs, + IReadOnlyList? cancellationArgs, + TimeSpan cancellationTimeout, + TimeSpan timeout, + CancellationToken ct + ) + { + _invocationCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _runnerTask = RunInvocationSequenceAsync( + processRunner, + command, + args, + fallbackArgs, + cancellationArgs, + cancellationTimeout, + timeout, + _invocationCts.Token + ); + _ = ObserveRunnerAsync(command); + } - public ProcessTtsPlaybackSession(Process process, CancellationToken ct) + public bool IsActive => !_runnerTask.IsCompleted; + + public event EventHandler? Completed { - _process = process; - _process.EnableRaisingEvents = true; - _process.Exited += OnExited; - _registration = ct.Register(Stop); + add + { + if (value is null) + { + return; + } - if (_process.HasExited) + var alreadyCompleted = false; + lock (_sync) + { + if (_completed != 0) + { + alreadyCompleted = true; + } + else + { + _completedHandlers += value; + } + } + + if (alreadyCompleted) + { + InvokeCompletedHandler(value); + } + } + remove { - Finish(); + lock (_sync) + { + _completedHandlers -= value; + } + } + } + + public void Stop() + { + if ( + Volatile.Read(ref _completed) != 0 + || Interlocked.Exchange(ref _stopRequested, 1) != 0 + ) + { + return; + } + + try + { + _invocationCts.Cancel(); + } + catch (ObjectDisposedException) + { + // Runner completion won the race and already released the source. } } @@ -182,48 +277,234 @@ public void Dispose() Stop(); } - public bool IsActive => Volatile.Read(ref _completed) == 0 && !_process.HasExited; + private static async Task RunInvocationSequenceAsync( + IProcessRunner processRunner, + string command, + IReadOnlyList args, + IReadOnlyList? fallbackArgs, + IReadOnlyList? cancellationArgs, + TimeSpan cancellationTimeout, + TimeSpan timeout, + CancellationToken ct + ) + { + 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; + } - public event EventHandler? Completed; + ct.ThrowIfCancellationRequested(); + var remainingTimeout = timeout - stopwatch.Elapsed; + if (remainingTimeout <= TimeSpan.Zero) + { + return result; + } - public void Stop() + 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) + { + await RunCancellationAsync( + processRunner, + command, + cancellationArgs, + cancellationTimeout + ) + .ConfigureAwait(false); + throw; + } + } + + private static async Task RunCancellationAsync( + IProcessRunner processRunner, + string command, + IReadOnlyList? cancellationArgs, + TimeSpan cancellationTimeout + ) { - if (Volatile.Read(ref _completed) != 0) + if (cancellationArgs is null) { return; } try { - if (!_process.HasExited) + var result = await processRunner + .RunAsync( + command, + cancellationArgs, + timeout: cancellationTimeout, + ct: CancellationToken.None + ) + .ConfigureAwait(false); + if (result.Succeeded) + { + return; + } + + if (result.TimedOut) { - _process.Kill(true); + 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($"[ProcessTtsPlaybackSession] stop failed: {ex.Message}"); + Debug.WriteLine( + $"[LinuxSystemTtsProvider] Speech Dispatcher cancellation failed ({ex.GetType().Name})." + ); } + } - Finish(); + private static async Task RunInvocationAsync( + IProcessRunner processRunner, + string command, + IReadOnlyList args, + TimeSpan timeout, + CancellationToken ct + ) + { + // Turns a synchronous launch exception into a faulted task instead of a + // throw from the constructor. + return await processRunner + .RunAsync(command, args, timeout: timeout, ct: ct) + .ConfigureAwait(false); } - private void OnExited(object? sender, EventArgs e) + private async Task ObserveRunnerAsync(string command) { - Finish(); + try + { + var result = await _runnerTask.ConfigureAwait(false); + if (result.Succeeded) + { + return; + } + + if (result.TimedOut) + { + Debug.WriteLine($"[LinuxSystemTtsProvider] {command} playback timed out."); + } + else if (!result.Started) + { + Debug.WriteLine($"[LinuxSystemTtsProvider] {command} playback did not start."); + } + else + { + Debug.WriteLine( + $"[LinuxSystemTtsProvider] {command} playback exited with code {result.ExitCode}." + ); + } + } + catch (OperationCanceledException) + { + Debug.WriteLine($"[LinuxSystemTtsProvider] {command} playback was canceled."); + } + catch (Exception ex) + { + Debug.WriteLine( + $"[LinuxSystemTtsProvider] {command} playback failed ({ex.GetType().Name})." + ); + } + finally + { + Finish(); + } } private void Finish() { - if (Interlocked.Exchange(ref _completed, 1) != 0) + EventHandler? handlers; + lock (_sync) + { + if (_completed != 0) + { + return; + } + + Volatile.Write(ref _completed, 1); + handlers = _completedHandlers; + _completedHandlers = null; + } + + if (Interlocked.Exchange(ref _resourcesDisposed, 1) == 0) + { + _invocationCts.Dispose(); + } + + if (handlers is null) { return; } - _process.Exited -= OnExited; - _registration.Dispose(); - _process.Dispose(); - Completed?.Invoke(this, EventArgs.Empty); + // ReSharper disable once PossibleInvalidCastExceptionInForeachLoop -- handlers is an EventHandler-typed multicast delegate, so its invocation list contains only EventHandler instances; the cast cannot fail. + foreach (EventHandler handler in handlers.GetInvocationList()) + { + InvokeCompletedHandler(handler); + } + } + + private void InvokeCompletedHandler(EventHandler handler) + { + try + { + handler(this, EventArgs.Empty); + } + catch (Exception ex) + { + Debug.WriteLine( + $"[LinuxSystemTtsProvider] playback completion handler failed ({ex.GetType().Name})." + ); + } } } @@ -245,4 +526,4 @@ public event EventHandler? Completed } public void Stop() { } -} \ No newline at end of file +} 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/Localization/StrExtension.cs b/src/TypeWhisper.Linux/Services/Localization/StrExtension.cs index c24e24972..13c11adba 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; @@ -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/MediaPauseService.cs b/src/TypeWhisper.Linux/Services/MediaPauseService.cs index 198009a0f..434f106d6 100644 --- a/src/TypeWhisper.Linux/Services/MediaPauseService.cs +++ b/src/TypeWhisper.Linux/Services/MediaPauseService.cs @@ -8,33 +8,63 @@ 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 readonly HashSet _pausedPlayers = new(StringComparer.OrdinalIgnoreCase); + private static readonly TimeSpan s_playerctlTimeout = TimeSpan.FromMilliseconds(1500); + 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; + + // 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) + { + _processRunner = processRunner; + _errorLog = errorLog; + } public void PauseMedia() { - if (_pausedPlayers.Count > 0) + lock (_playersGate) { - return; + if (_pauseActive) + { + return; + } + + _pauseActive = true; } 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,40 +83,154 @@ var line in players.Split( continue; } - if (CommandRunner.Run("playerctl", "-p", parts[0], "pause") is not null) + 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]; } - try + foreach (var player in players) + { + string failure; + try + { + var result = RunPlayerctl(["-p", player, "play"]); + if (result.Succeeded) + { + lock (_playersGate) + { + _pausedPlayers.Remove(player); + } + + continue; + } + + failure = DescribeFailure(result); + } + catch (Exception ex) + { + 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) { - foreach (var player in _pausedPlayers) + if (!_pausedPlayers.TryGetValue(player, out var attempts)) + { + return; + } + + attempts++; + retired = attempts >= MaxResumeAttempts; + if (retired) { - CommandRunner.Run("playerctl", "-p", player, "play"); + _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() + { + 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}"); + } + } + + 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); } - finally + catch { - _pausedPlayers.Clear(); + // Restoration and retries must not depend on diagnostic output. } } } diff --git a/src/TypeWhisper.Linux/Services/MemoryService.cs b/src/TypeWhisper.Linux/Services/MemoryService.cs index c90db2876..2a8a25619 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 ) @@ -126,8 +126,10 @@ string userPrompt } var providerId = provider.GetLlmSelectionId(); - var plugin = _pluginManager.GetPlugin(providerId); - var ranLocally = plugin is not null && PluginLocalityClassifier.IsLocal(plugin.Manifest); + // 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 { @@ -138,7 +140,7 @@ string userPrompt ProviderId = providerId, ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = null + InjectedMemoryContext = null, }; capture.Add(provenance); return provenance; @@ -171,4 +173,4 @@ string userPrompt return null; } } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/ModelManagerService.cs b/src/TypeWhisper.Linux/Services/ModelManagerService.cs index 1c919cb36..d02e4ee89 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( @@ -41,6 +46,18 @@ 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 + { + get + { + lock (_timerGate) + { + return _autoUnloadTimer is { Enabled: true }; + } + } + } + public string? ActiveModelId { get => _activeModelId; @@ -75,7 +92,7 @@ public ITranscriptionEngine Engine } } - public ITranscriptionEnginePlugin? ActiveTranscriptionPlugin => GetTranscriptionPlugin(_activeModelId); + public ITranscriptionEngineRole? ActiveTranscriptionPlugin => GetTranscriptionPlugin(_activeModelId); /// /// Resolves the transcription plugin that owns (a @@ -83,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)) { @@ -96,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). @@ -205,7 +226,14 @@ public async Task DownloadAndLoadModelAsync( } finally { - _modelLock.Release(); + try + { + ScheduleAutoUnload(); + } + finally + { + _modelLock.Release(); + } } } @@ -218,7 +246,14 @@ public async Task LoadModelAsync(string modelId, CancellationToken cancellationT } finally { - _modelLock.Release(); + try + { + ScheduleAutoUnload(); + } + finally + { + _modelLock.Release(); + } } } @@ -245,23 +280,38 @@ public async Task UnloadModelAsync() } } - public void ScheduleAutoUnload() + private void ScheduleAutoUnload() { - CancelAutoUnload(); - var seconds = _settings.Current.ModelAutoUnloadSeconds; - if (seconds <= 0 || ActiveModelId is null) - { - return; - } + var armable = seconds > 0 && ActiveModelId is not null; - _autoUnloadTimer = new Timer(seconds * 1000.0) { 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) @@ -374,7 +424,14 @@ public async Task EnsureModelLoadedAsync( } finally { - _modelLock.Release(); + try + { + ScheduleAutoUnload(); + } + finally + { + _modelLock.Release(); + } } } @@ -385,6 +442,7 @@ public async Task EnsureModelLoadedAsync( /// public async Task AcquireTranscriptionAsync( string? modelId = null, + bool keepModelWarm = false, CancellationToken cancellationToken = default ) { @@ -400,11 +458,19 @@ public async Task AcquireTranscriptionAsync( ActiveTranscriptionPlugin ?? throw new InvalidOperationException("No transcription engine loaded."); - return new TranscriptionLease(_modelLock, plugin); + return new TranscriptionLease(_modelLock, plugin, this, keepModelWarm); } catch { - _modelLock.Release(); + try + { + ScheduleAutoUnload(); + } + finally + { + _modelLock.Release(); + } + throw; } } @@ -417,6 +483,7 @@ public async Task AcquireTranscriptionAsync( /// public async Task TryAcquireTranscriptionAsync( string? modelId = null, + bool keepModelWarm = false, CancellationToken cancellationToken = default ) { @@ -429,23 +496,39 @@ public async Task AcquireTranscriptionAsync( { var targetModelId = modelId ?? _settings.Current.SelectedModelId; if ( - string.IsNullOrWhiteSpace(targetModelId) - || ActiveModelId != targetModelId - || ActiveTranscriptionPlugin is not { } plugin + !string.IsNullOrWhiteSpace(targetModelId) + && ActiveModelId == targetModelId + && ActiveTranscriptionPlugin is { } plugin ) + { + CancelAutoUnload(); + return new TranscriptionLease(_modelLock, plugin, this, keepModelWarm); + } + } + catch + { + try + { + ScheduleAutoUnload(); + } + finally { _modelLock.Release(); - return null; } - CancelAutoUnload(); - return new TranscriptionLease(_modelLock, plugin); + throw; } - catch + + try + { + ScheduleAutoUnload(); + } + finally { _modelLock.Release(); - throw; } + + return null; } /// @@ -498,7 +581,7 @@ public void MigrateSettings() ), "plugin:com.typewhisper.voxtral:mistral-whisper" => GetPluginModelId("com.typewhisper.voxtral", "voxtral-mini-latest"), - _ => modelId + _ => modelId, }; } @@ -513,7 +596,7 @@ public void MigrateSettings() { "plugin:com.typewhisper.voxtral:mistral-whisper" => GetPluginModelId("com.typewhisper.voxtral", "voxtral-mini-latest"), - _ => modelId + _ => modelId, }; } @@ -525,7 +608,7 @@ private static TranscriptionAccelerationPreference GetAccelerationPreference(str AppSettings.LocalModelAccelerationNvidiaCuda => TranscriptionAccelerationPreference.NvidiaCuda, AppSettings.LocalModelAccelerationCpu => TranscriptionAccelerationPreference.Cpu, - _ => TranscriptionAccelerationPreference.Auto + _ => TranscriptionAccelerationPreference.Auto, }; } @@ -785,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; @@ -876,20 +998,47 @@ private void OnPropertyChanged([CallerMemberName] string? name = null) public sealed class TranscriptionLease : IAsyncDisposable { private readonly SemaphoreSlim _modelLock; + private readonly ModelManagerService _owner; + private readonly bool _keepModelWarm; private int _released; - internal TranscriptionLease(SemaphoreSlim modelLock, ITranscriptionEnginePlugin plugin) + internal TranscriptionLease( + SemaphoreSlim modelLock, + ITranscriptionEngineRole plugin, + ModelManagerService owner, + bool keepModelWarm + ) { _modelLock = modelLock; Plugin = plugin; + _owner = owner; + _keepModelWarm = keepModelWarm; } /// The plugin pinned for the lifetime of this lease. - public ITranscriptionEnginePlugin Plugin { get; } + public ITranscriptionEngineRole Plugin { get; } public ValueTask DisposeAsync() { - if (Interlocked.Exchange(ref _released, 1) == 0) + if (Interlocked.Exchange(ref _released, 1) != 0) + { + return ValueTask.CompletedTask; + } + + // Re-arm (unless the caller asked to keep the model warm) BEFORE releasing + // _modelLock: the lock is still held here, so this is the last point at which + // touching _autoUnloadTimer is guaranteed serialized against every other + // load/unload/acquire path. Never let a caller thread touch the timer after + // the lock is released. Release in finally so a scheduling failure can never + // strand the lock and deadlock every subsequent load/unload/acquire. + try + { + if (!_keepModelWarm) + { + _owner.ScheduleAutoUnload(); + } + } + finally { _modelLock.Release(); } @@ -925,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; } @@ -962,7 +1111,7 @@ public async Task TranscribeAsync( Text = result.Text, DetectedLanguage = result.DetectedLanguage, Duration = result.DurationSeconds, - NoSpeechProbability = result.NoSpeechProbability + NoSpeechProbability = result.NoSpeechProbability, }; } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginEventBus.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginEventBus.cs index 428dbc63e..1ef047009 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,346 @@ 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); + 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; + + Task WrappedHandler(object obj) => handler((T)obj); + } + + 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.Linux/Services/Plugins/PluginHostServices.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginHostServices.cs index 41af8b527..364b36f88 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; @@ -36,6 +36,7 @@ public sealed class PluginHostServices : IPluginHostServices private readonly string _pluginId; private readonly IProfileService _profiles; + private readonly string _secretProtectionKeyFilePath; private readonly string _settingsFilePath; private readonly Lock _settingsLock = new(); @@ -53,7 +54,8 @@ public PluginHostServices( IErrorLogService? errorLog = null, string? errorCategory = null, string? pluginDisplayName = null, - string? pluginDataRoot = null + string? pluginDataRoot = null, + string? secretProtectionKeyFilePath = null ) { _pluginId = pluginId; @@ -70,6 +72,10 @@ public PluginHostServices( _pluginDataRoot = pluginDataRoot ?? TypeWhisperEnvironment.PluginDataPath; _pluginDataDirectory = Path.Join(_pluginDataRoot, pluginId); _settingsFilePath = Path.Join(_pluginDataDirectory, "settings.json"); + _secretProtectionKeyFilePath = ResolveSecretProtectionKeyFilePath( + _pluginDataRoot, + secretProtectionKeyFilePath + ); } public string PluginDataDirectory @@ -142,13 +148,13 @@ public void NotifyCapabilitiesChanged() public Task StoreSecretAsync(string key, string value) { - var encrypted = ApiKeyProtection.Encrypt(value); + var encrypted = ApiKeyProtection.Encrypt(value, _secretProtectionKeyFilePath); lock (_settingsLock) { var current = LoadSettings(); var next = new Dictionary(current) { - [$"{SecretPrefix}{key}"] = JsonSerializer.SerializeToElement(encrypted) + [$"{SecretPrefix}{key}"] = JsonSerializer.SerializeToElement(encrypted), }; SaveSettings(next); _settingsCache = next; @@ -159,16 +165,95 @@ public Task StoreSecretAsync(string key, string value) public Task LoadSecretAsync(string key) { - string? encrypted; lock (_settingsLock) { var settings = LoadSettings(); - encrypted = settings.TryGetValue($"{SecretPrefix}{key}", out var element) - ? element.Deserialize() - : null; - } + if (!settings.TryGetValue($"{SecretPrefix}{key}", out var element)) + { + return Task.FromResult(null); + } + + string? encrypted; + try + { + encrypted = element.Deserialize(); + } + catch (JsonException ex) + { + LogSecretUnavailable(key, ex.Message); + return Task.FromResult(null); + } + + if (encrypted is null) + { + return Task.FromResult(null); + } + + var requested = ApiKeyProtection.Decrypt( + encrypted, + _secretProtectionKeyFilePath + ); + if (!requested.Succeeded) + { + LogSecretUnavailable(key, "the protected value could not be authenticated"); + return Task.FromResult(null); + } + + if (!requested.RequiresMigration) + { + return Task.FromResult(requested.PlainText); + } + + try + { + var next = new Dictionary(settings); + foreach (var property in settings) + { + if (!property.Key.StartsWith(SecretPrefix, StringComparison.Ordinal)) + { + continue; + } + + var stored = property.Value.Deserialize(); + if (stored is null) + { + continue; + } + + var result = ApiKeyProtection.Decrypt( + stored, + _secretProtectionKeyFilePath + ); + if (!result.Succeeded || result.PlainText is null) + { + LogSecretUnavailable( + key, + $"'{property.Key}' could not be authenticated" + ); + return Task.FromResult(null); + } + + if (result.RequiresMigration) + { + next[property.Key] = JsonSerializer.SerializeToElement( + ApiKeyProtection.Encrypt( + result.PlainText, + _secretProtectionKeyFilePath + ) + ); + } + } - return Task.FromResult(encrypted is null ? null : ApiKeyProtection.Decrypt(encrypted)); + SaveSettings(next); + _settingsCache = next; + return Task.FromResult(requested.PlainText); + } + catch (Exception ex) + { + LogSecretUnavailable(key, $"migration failed: {ex.Message}"); + return Task.FromResult(null); + } + } } public Task DeleteSecretAsync(string key) @@ -198,6 +283,7 @@ public Task DeleteSecretAsync(string key) public T? GetSetting(string key) { + ThrowIfReservedSecretKey(key); lock (_settingsLock) { var settings = LoadSettings(); @@ -222,18 +308,32 @@ public Task DeleteSecretAsync(string key) public void SetSetting(string key, T value) { + ThrowIfReservedSecretKey(key); lock (_settingsLock) { var current = LoadSettings(); var next = new Dictionary(current) { - [key] = JsonSerializer.SerializeToElement(value, s_jsonOptions) + [key] = JsonSerializer.SerializeToElement(value, s_jsonOptions), }; SaveSettings(next); _settingsCache = next; } } + // The generic accessors share the settings dictionary with the secret store, so they must + // not read raw ciphertext back out or write plaintext into it behind the encryption. + private static void ThrowIfReservedSecretKey(string key) + { + if (key.StartsWith(SecretPrefix, StringComparison.Ordinal)) + { + throw new ArgumentException( + "The 'secret:' key namespace is reserved for StoreSecretAsync/LoadSecretAsync.", + nameof(key) + ); + } + } + private Dictionary LoadSettings() { // System.Threading.Lock is re-entrant for the same thread, so callers already holding @@ -252,7 +352,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 +368,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 +380,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 @@ -323,6 +427,34 @@ private void SaveSettings(Dictionary settings) } } + private void LogSecretUnavailable(string key, string reason) + { + var message = + $"Plugin '{_pluginDisplayName}' ({_pluginId}) secret '{key}' is unavailable: {reason}."; + Trace.WriteLine($"[Plugin:{_pluginId}] {message}"); + AddSettingsError(message); + } + + private static string ResolveSecretProtectionKeyFilePath( + string pluginDataRoot, + string? secretProtectionKeyFilePath + ) + { + if (!string.IsNullOrWhiteSpace(secretProtectionKeyFilePath)) + { + return Path.GetFullPath(secretProtectionKeyFilePath); + } + + var fullPluginDataRoot = Path.GetFullPath(pluginDataRoot); + var basePath = Directory.GetParent( + Path.TrimEndingDirectorySeparator(fullPluginDataRoot) + )?.FullName; + return Path.Join( + basePath ?? TypeWhisperEnvironment.BasePath, + "secret-protection.key" + ); + } + [DoesNotReturn] private void ThrowRefusingToSave() { diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginLoader.cs index e8a935fb0..9d63367bf 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. @@ -78,6 +106,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 +174,23 @@ public List DiscoverAndLoad(IEnumerable searchDirectories) return null; } + var metadata = ResolveMetadata(manifest); + + 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)) { @@ -242,6 +289,185 @@ public List DiscoverAndLoad(IEnumerable searchDirectories) 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; + // ReSharper disable once ConvertIfStatementToSwitchStatement -- independent guard clauses with unrelated outcomes (throw vs legacy inference); a switch would obscure that. + if (categories is { Length: 0 }) + { + throw new InvalidDataException( + $"Plugin '{manifest.Id}' declares an empty categories array." + ); + } + + if (categories is null) + { + return new PluginMetadataDescriptor( + networkAccess.Value, + [InferLegacyCategory(manifest)] + ); + } + + 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); + } + + 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 1e7972e5a..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 f061063cf..8eeb03fa0 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; @@ -14,13 +15,7 @@ namespace TypeWhisper.Linux.Services.Plugins; /// public sealed class PluginManager : IDisposable { - // 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 static readonly TimeSpan s_defaultPluginShutdownTimeout = TimeSpan.FromSeconds(5); private readonly HashSet _activatedPlugins = []; private readonly ConcurrentDictionary> _activationTasks = new(); @@ -33,16 +28,18 @@ 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 readonly string _secretProtectionKeyFilePath; private List _actionPlugins = []; // Debounce guard for on-demand model re-polls (triggered when a dropdown opens). 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( @@ -72,7 +69,9 @@ internal PluginManager( IProfileService profiles, ISettingsService settings, IEnumerable searchDirectories, - IErrorLogService? errorLog = null + IErrorLogService? errorLog = null, + TimeSpan? pluginShutdownTimeout = null, + string? secretProtectionKeyFilePath = null ) { _loader = loader; @@ -82,6 +81,25 @@ internal PluginManager( _settings = settings; _searchDirectories = searchDirectories.ToArray(); _errorLog = errorLog; + _secretProtectionKeyFilePath = secretProtectionKeyFilePath + ?? Path.Join( + Directory.GetParent( + Path.TrimEndingDirectorySeparator( + Path.GetFullPath(_loader.PluginDataRoot) + ) + )?.FullName + ?? TypeWhisperEnvironment.BasePath, + "secret-protection.key" + ); + _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 @@ -95,7 +113,7 @@ public IReadOnlyList AllPlugins } } - public IReadOnlyList LlmProviders + public IReadOnlyList LlmProviders { get { @@ -106,7 +124,7 @@ public IReadOnlyList LlmProviders } } - public IReadOnlyList TranscriptionEngines + public IReadOnlyList TranscriptionEngines { get { @@ -164,23 +182,25 @@ public void Dispose() activated = [.. _activatedPlugins]; } + // One budget for the whole pass: a per-plugin timeout multiplies by the plugin count. + var shutdownBudget = Stopwatch.StartNew(); + foreach (var plugin in plugins) { - try - { - if (activated.Contains(plugin.Manifest.Id)) - { - plugin.Instance.DeactivateAsync().GetAwaiter().GetResult(); - } + // 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 + ); - plugin.Instance.Dispose(); - } - catch (Exception ex) - { - Trace.WriteLine( - $"[PluginManager] Error disposing plugin {plugin.Manifest.Id}: {ex.Message}" - ); - } + AwaitPluginShutdown( + shutdownTask, + plugin.Manifest.Id, + _pluginShutdownTimeout - shutdownBudget.Elapsed + ); try { @@ -207,6 +227,166 @@ public void Dispose() } } + private void AwaitPluginShutdown(Task shutdownTask, string pluginId, TimeSpan remaining) + { + if (remaining <= TimeSpan.Zero) + { + Trace.WriteLine( + "[PluginManager] Shutdown budget of " + + $"{_pluginShutdownTimeout.TotalSeconds:0.###} seconds is spent; " + + $"not waiting for plugin {pluginId}" + ); + ObserveLateShutdown(shutdownTask, pluginId); + return; + } + + var completedTask = Task.WhenAny(shutdownTask, Task.Delay(remaining)) + .GetAwaiter() + .GetResult(); + + if (completedTask != shutdownTask) + { + Trace.WriteLine( + $"[PluginManager] Timed out shutting down plugin {pluginId} " + + $"after {remaining.TotalSeconds:0.###} seconds" + ); + ObserveLateShutdown(shutdownTask, pluginId); + return; + } + + try + { + shutdownTask.GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[PluginManager] Error shutting down plugin {pluginId}: {ex.Message}" + ); + } + } + + 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 { @@ -245,12 +425,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) { @@ -262,6 +441,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); @@ -517,6 +701,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) @@ -530,15 +718,12 @@ private async Task ActivatePluginAsync(LoadedPlugin plugin) EventBus, _profiles, _settings, - () => - { - RebuildCapabilityIndices(); - PluginStateChanged?.Invoke(this, EventArgs.Empty); - }, + RebuildCapabilityIndices, _errorLog, ResolveErrorCategory(plugin), plugin.Manifest.Name, - _loader.PluginDataRoot + _loader.PluginDataRoot, + _secretProtectionKeyFilePath ); await plugin.Instance.ActivateAsync(hostServices); @@ -565,22 +750,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, }; } @@ -621,28 +810,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() @@ -656,11 +850,78 @@ private void RebuildCapabilityIndices() PluginStateChanged?.Invoke(this, EventArgs.Empty); } + private IEnumerable<(ILlmProviderRole 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<( + ITranscriptionEngineRole 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 // contributes nothing and is logged. Grouping/dedup downstream is unchanged. - private static IEnumerable SafeAdditionalLlmProviders( + private static IEnumerable SafeAdditionalLlmProviders( IAdditionalLlmProvidersProvider provider ) { @@ -682,7 +943,7 @@ IAdditionalLlmProvidersProvider provider } } - private static IEnumerable SafeAdditionalTranscriptionEngines( + private static IEnumerable SafeAdditionalTranscriptionEngines( IAdditionalTranscriptionEnginesProvider provider ) { @@ -757,7 +1018,7 @@ private async Task MigrateApiKeysAsync() current with { GroqApiKey = migratedGroq ? "" : current.GroqApiKey, - OpenAiApiKey = migratedOpenAi ? "" : current.OpenAiApiKey + OpenAiApiKey = migratedOpenAi ? "" : current.OpenAiApiKey, } ); } @@ -782,13 +1043,22 @@ string encryptedValue try { - var decrypted = ApiKeyProtection.Decrypt(encryptedValue); - if (string.IsNullOrEmpty(decrypted)) + var decrypted = ApiKeyProtection.Decrypt( + encryptedValue, + _secretProtectionKeyFilePath + ); + if ( + decrypted.Format is not ( + SecretProtectionFormat.Current + or SecretProtectionFormat.LegacyGcm + ) + || string.IsNullOrEmpty(decrypted.PlainText) + ) { return false; } - await hostServices.StoreSecretAsync(secretKey, decrypted); + await hostServices.StoreSecretAsync(secretKey, decrypted.PlainText); Trace.WriteLine($"[PluginManager] Migrated API key to plugin: {pluginId}"); return true; } diff --git a/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs b/src/TypeWhisper.Linux/Services/Plugins/PluginRegistryService.cs index 2828d1fb7..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; @@ -48,7 +47,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; @@ -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/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 bc4a54b2c..66ccf51c0 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 ); } @@ -67,101 +76,312 @@ Task RunAsync( /// public sealed class ProcessRunner : IProcessRunner { + private static readonly TimeSpan s_minimumDrainGrace = TimeSpan.FromMilliseconds(250); + public async Task RunAsync( string fileName, IReadOnlyList args, IReadOnlyDictionary? environment = null, string? standardInput = null, TimeSpan? timeout = null, + bool detachAfterExit = false, 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; + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = standardInput is not null, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var arg in args) + { + psi.ArgumentList.Add(arg); } - } - try - { - using var process = Process.Start(psi); + if (environment is not null) + { + foreach (var (key, value) in environment) + { + psi.Environment[key] = value; + } + } + + process = Process.Start(psi); if (process is null) { + ct.ThrowIfCancellationRequested(); return ProcessRunResult.NotStarted($"Could not start {fileName}"); } - if (standardInput 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) { - await process - .StandardInput.WriteAsync(standardInput.AsMemory(), ct) - .ConfigureAwait(false); - process.StandardInput.Close(); + timeoutCts!.CancelAfter(limit); } - var stdoutTask = process.StandardOutput.ReadToEndAsync(ct); - var stderrTask = process.StandardError.ReadToEndAsync(ct); - - if (timeout is { } limit) + var lifecycleToken = timeoutCts?.Token ?? ct; + standardInputWriter = standardInput is not null + ? process.StandardInput + : null; + if (standardInput is not null) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeoutCts.CancelAfter(limit); try { - await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false); + await standardInputWriter! + .WriteAsync(standardInput.AsMemory(), lifecycleToken) + .ConfigureAwait(false); + standardInputWriter.Close(); } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) + 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. - try - { - process.Kill(true); - } - catch - { - /* best effort */ - } - - return new ProcessRunResult( - true, - true, - -1, - string.Empty, - string.Empty - ); + await KillAndReapProcessTreeAsync(process).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + return TimedOutResult(); } } + + standardOutputReader = process.StandardOutput; + standardErrorReader = process.StandardError; + stdoutTask = standardOutputReader.ReadToEndAsync(ct); + stderrTask = standardErrorReader.ReadToEndAsync(ct); + + 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; + + // 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 { - await process.WaitForExitAsync(ct).ConfigureAwait(false); + drainLimit = null; } + if (drainLimit is not { } drainWindow) + { + var standardOutput = await stdoutTask.ConfigureAwait(false); + var standardError = await stderrTask.ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + return new ProcessRunResult( + true, + false, + exitCode, + standardOutput, + standardError + ); + } + + using var drainCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + drainCts.CancelAfter(drainWindow); + 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, - process.ExitCode, - await stdoutTask.ConfigureAwait(false), - await stderrTask.ConfigureAwait(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) + { + 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) { + // 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 (standardOutputReader is not null && stdoutTask is not null) + { + AbandonRead(standardOutputReader, stdoutTask); + } + + if (standardErrorReader is not null && stderrTask is not null) + { + AbandonRead(standardErrorReader, stderrTask); + } + + ct.ThrowIfCancellationRequested(); + } + return ProcessRunResult.NotStarted(ex.Message); } + finally + { + DisposeSafely(standardInputWriter); + DisposeSafely(standardOutputReader); + DisposeSafely(standardErrorReader); + DisposeSafely(process); + } + } + + private static async Task KillAndReapProcessTreeAsync(Process process) + { + KillProcessTree(process); + using var reapCts = new CancellationTokenSource(s_minimumDrainGrace); + try + { + await process.WaitForExitAsync(reapCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Reaping is bounded; Process.Dispose remains the final best effort. + } + catch + { + // Reaping is bounded and best effort; cleanup must not replace the + // original timeout, cancellation, or process failure. + } + } + + private static string CompletedOutput(Task readTask) + { + return readTask.Status == TaskStatus.RanToCompletion + ? readTask.Result + : string.Empty; + } + + private static void KillProcessTree(Process process) + { + try + { + process.Kill(true); + } + catch + { + /* best effort */ + } } -} \ No newline at end of file + + private static void DisposeSafely(IDisposable? resource) + { + try + { + resource?.Dispose(); + } + catch + { + /* best effort */ + } + } + + private static void AbandonRead(StreamReader reader, Task readTask) + { + ObserveFault(readTask); + try + { + // Process.Dispose does not close a redirected stream once its reader + // has been accessed; close the caller-owned pipe handle explicitly. + reader.BaseStream.Close(); + } + catch + { + /* best effort */ + } + } + + private static void ObserveFault(Task task) + { + _ = task.ContinueWith( + static completedTask => _ = completedTask.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default + ); + } + + private static ProcessRunResult TimedOutResult() + { + return new ProcessRunResult( + true, + true, + -1, + string.Empty, + string.Empty + ); + } +} diff --git a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs index a20f37e87..dd1e042d9 100644 --- a/src/TypeWhisper.Linux/Services/PromptProcessingService.cs +++ b/src/TypeWhisper.Linux/Services/PromptProcessingService.cs @@ -159,10 +159,7 @@ public async IAsyncEnumerable ProcessStreamingAsync( finally { // responseBuilder is non-null whenever provenance is (same capture gate) - if (provenance is not null) - { - provenance.ResponseReceived = responseBuilder!.ToString(); - } + provenance?.ResponseReceived = responseBuilder!.ToString(); } } @@ -204,7 +201,7 @@ public async Task ProcessSystemPromptAsync( private LlmCallProvenance? RecordProvenance( LlmCallCapture? capture, string stage, - ILlmProviderPlugin provider, + ILlmProviderRole provider, string modelId, string systemPrompt, string userPrompt, @@ -217,8 +214,10 @@ public async Task ProcessSystemPromptAsync( } var providerId = provider.GetLlmSelectionId(); - var plugin = _pluginManager.GetPlugin(providerId); - var ranLocally = plugin is not null && PluginLocalityClassifier.IsLocal(plugin.Manifest); + // 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 { @@ -229,7 +228,7 @@ public async Task ProcessSystemPromptAsync( ProviderId = providerId, ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = injectedMemoryContext + InjectedMemoryContext = injectedMemoryContext, }; capture.Add(provenance); return provenance; @@ -251,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)) { @@ -293,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 ) { @@ -315,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/RecentTranscriptionsService.cs b/src/TypeWhisper.Linux/Services/RecentTranscriptionsService.cs index 299c1d45f..d08663291 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 _pasteToolHintProvider; private readonly RecentTranscriptionStore _store; - private readonly TextInsertionService _textInsertion; + private bool _paletteOpening; private RecentTranscriptionsPaletteWindow? _paletteWindow; public RecentTranscriptionsService( @@ -27,14 +35,43 @@ public RecentTranscriptionsService( ISettingsService settings, ActiveWindowService activeWindow, SystemCommandAvailabilityService commands + ) + : this( + history, + store, + () => settings.Current.AutoPaste, + activeWindow.GetActiveWindowId, + activeWindow.GetActiveWindowSnapshotAsync, + textInsertion.InsertTextAsync, + Task.Delay, + () => PasteToolHintFor(commands.GetSnapshot()), + Environment.GetEnvironmentVariable("WAYLAND_DISPLAY") is { Length: > 0 } + ) + { + } + + internal RecentTranscriptionsService( + IHistoryService history, + RecentTranscriptionStore store, + Func autoPasteProvider, + Func activeWindowIdProvider, + Func> activeWindowSnapshotProvider, + Func> insertTextAsync, + Func delay, + Func? pasteToolHintProvider = 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; + _pasteToolHintProvider = + pasteToolHintProvider ?? (() => RecentTranscriptionPasteToolHint.X11); + _isWaylandSession = isWaylandSession; } public void RecordTranscription( @@ -58,17 +95,37 @@ 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; } - 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,37 +133,93 @@ private void TogglePaletteCore() return; } + if (_paletteOpening) + { + return; + } + 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; } - // 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 + { + _paletteOpening = false; + } + } + + internal async Task CaptureInsertionTargetAsync() + { + var windowId = _activeWindowIdProvider(); + ActiveWindowSnapshot? snapshot = null; + try { - if (ReferenceEquals(_paletteWindow, window)) + 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,43 +232,226 @@ 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; + } + } + + // ReSharper disable once InvertIf -- kept symmetrical with the identical AppId block above. + 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) { + // Inverted so an unrecognized result reports failure instead of claiming the text landed. + // ActionHandled is a success: a plugin action consumed the text in place of insertion. return result - is InsertionResult.Failed - or InsertionResult.MissingClipboardTool - or InsertionResult.MissingPasteTool; + is not (InsertionResult.Typed + or InsertionResult.Pasted + or InsertionResult.CopiedToClipboard + or InsertionResult.NoText + or InsertionResult.ActionHandled); } 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 => _commands.GetSnapshot().PasteToolInstallHint, - 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 + ); } -} \ No newline at end of file + + 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( + string? WindowId, + ActiveWindowSnapshot? Snapshot +); diff --git a/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs b/src/TypeWhisper.Linux/Services/RecordingNotificationService.cs index e27189e53..ce01f7e42 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); } /// @@ -70,125 +103,208 @@ 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"], }; } 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) + // 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) { - 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) @@ -201,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 ) @@ -236,4 +352,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/src/TypeWhisper.Linux/Services/SecretProtectionMigrationService.cs b/src/TypeWhisper.Linux/Services/SecretProtectionMigrationService.cs new file mode 100644 index 000000000..f8c9aade9 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/SecretProtectionMigrationService.cs @@ -0,0 +1,378 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using TypeWhisper.Core; +using TypeWhisper.Core.Services; + +namespace TypeWhisper.Linux.Services; + +internal sealed record SecretProtectionMigrationResult( + int MigratedFileCount, + int UnresolvedSecretCount, + bool RootSettingsChanged, + IReadOnlyList Errors +) +{ + public bool HasUnresolvedSecrets => UnresolvedSecretCount > 0; +} + +internal sealed class SecretProtectionMigrationService +{ + private const string SecretPrefix = "secret:"; + + private static readonly string[] s_rootSecretProperties = + [ + "groqApiKey", + "openAiApiKey", + "apiServerBearerToken", + ]; + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = true, + }; + + private readonly string _basePath; + private readonly string _keyFilePath; + + public SecretProtectionMigrationService() + : this( + TypeWhisperEnvironment.BasePath, + TypeWhisperEnvironment.SecretProtectionKeyFilePath + ) + { + } + + internal SecretProtectionMigrationService(string basePath, string? keyFilePath = null) + { + _basePath = Path.GetFullPath(basePath); + _keyFilePath = Path.GetFullPath( + keyFilePath ?? Path.Join(_basePath, "secret-protection.key") + ); + } + + public SecretProtectionMigrationResult MigrateAll() + { + try + { + return MigrateAllCore(); + } + catch (Exception ex) + { + // Startup runs this before the UI exists, so an unexpected failure has to + // fail closed (secrets stay unresolved, export stays blocked) rather than + // take the launch down with it. + Trace.WriteLine( + $"[SecretProtectionMigration] Migration failed: {ex.Message}" + ); + return new SecretProtectionMigrationResult(0, 1, false, [ex.Message]); + } + } + + private SecretProtectionMigrationResult MigrateAllCore() + { + try + { + var key = ApiKeyProtection.EnsureKeyFile(_keyFilePath); + CryptographicOperations.ZeroMemory(key); + } + catch (Exception ex) + { + var unresolved = CountProtectedValues(); + Trace.WriteLine( + $"[SecretProtectionMigration] Key validation failed: {ex.Message}" + ); + return new SecretProtectionMigrationResult( + 0, + unresolved, + false, + unresolved == 0 ? [] : [ex.Message] + ); + } + + var migratedFiles = 0; + var unresolvedSecrets = 0; + var rootSettingsChanged = false; + var errors = new List(); + + foreach ( + var path in new[] + { + Path.Join(_basePath, "settings.json"), + Path.Join(_basePath, "settings.json.bak"), + } + ) + { + var outcome = MigrateFileSafely(path, isRootSettings: true); + migratedFiles += outcome.Migrated ? 1 : 0; + unresolvedSecrets += outcome.UnresolvedSecretCount; + if (outcome.Migrated && string.Equals( + path, + Path.Join(_basePath, "settings.json"), + StringComparison.Ordinal + )) + { + rootSettingsChanged = true; + } + + if (outcome.Error is not null) + { + errors.Add(outcome.Error); + } + } + + var pluginDataPath = Path.Join(_basePath, "PluginData"); + // ReSharper disable once InvertIf -- inverting would duplicate the result construction below. + if (Directory.Exists(pluginDataPath)) + { + foreach (var pluginDirectory in Directory.EnumerateDirectories(pluginDataPath)) + { + var outcome = MigrateFileSafely( + Path.Join(pluginDirectory, "settings.json"), + isRootSettings: false + ); + migratedFiles += outcome.Migrated ? 1 : 0; + unresolvedSecrets += outcome.UnresolvedSecretCount; + if (outcome.Error is not null) + { + errors.Add(outcome.Error); + } + } + } + + return new SecretProtectionMigrationResult( + migratedFiles, + unresolvedSecrets, + rootSettingsChanged, + errors + ); + } + + private FileMigrationOutcome MigrateFileSafely( + string path, + bool isRootSettings + ) + { + try + { + return MigrateFile(path, isRootSettings); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[SecretProtectionMigration] Could not migrate '{path}': {ex.Message}" + ); + return new FileMigrationOutcome( + false, + Math.Max(1, CountProtectedValues(path, isRootSettings)), + $"Could not migrate protected settings in '{path}': {ex.Message}" + ); + } + } + + private FileMigrationOutcome MigrateFile(string path, bool isRootSettings) + { + if (!File.Exists(path)) + { + return FileMigrationOutcome.None; + } + + JsonObject settings; + try + { + settings = JsonNode.Parse(File.ReadAllText(path)) as JsonObject + ?? throw new JsonException("The settings root must be a JSON object."); + } + catch (Exception ex) when ( + ex is IOException or JsonException or UnauthorizedAccessException + ) + { + Trace.WriteLine( + $"[SecretProtectionMigration] Could not inspect '{path}': {ex.Message}" + ); + return new FileMigrationOutcome( + false, + 1, + $"Could not inspect protected settings in '{path}': {ex.Message}" + ); + } + + var protectedProperties = isRootSettings + ? s_rootSecretProperties.Where(settings.ContainsKey) + : settings + .Select(property => property.Key) + .Where(key => key.StartsWith(SecretPrefix, StringComparison.Ordinal)); + + var replacements = new Dictionary(StringComparer.Ordinal); + var unresolved = 0; + foreach (var propertyName in protectedProperties) + { + if ( + !settings.TryGetPropertyValue(propertyName, out var node) + || node is null + ) + { + continue; + } + + if (node is not JsonValue value || !value.TryGetValue(out var stored)) + { + unresolved++; + continue; + } + + if (string.IsNullOrEmpty(stored)) + { + continue; + } + + var result = ApiKeyProtection.Decrypt(stored, _keyFilePath); + if (result.Format == SecretProtectionFormat.Current) + { + continue; + } + + if (result is { Succeeded: true, PlainText: not null }) + { + replacements[propertyName] = ApiKeyProtection.Encrypt( + result.PlainText, + _keyFilePath + ); + continue; + } + + if ( + isRootSettings + && string.Equals( + propertyName, + "apiServerBearerToken", + StringComparison.Ordinal + ) + ) + { + replacements[propertyName] = ApiKeyProtection.Encrypt( + CreateBearerToken(), + _keyFilePath + ); + continue; + } + + unresolved++; + } + + if (unresolved > 0) + { + return new FileMigrationOutcome(false, unresolved, null); + } + + if (replacements.Count == 0) + { + return FileMigrationOutcome.None; + } + + foreach (var replacement in replacements) + { + settings[replacement.Key] = replacement.Value; + } + + try + { + AtomicFileWrite.WriteAllText(path, settings.ToJsonString(s_jsonOptions)); + return new FileMigrationOutcome(true, 0, null); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Trace.WriteLine( + $"[SecretProtectionMigration] Could not replace '{path}': {ex.Message}" + ); + return new FileMigrationOutcome( + false, + replacements.Count, + $"Could not migrate protected settings in '{path}': {ex.Message}" + ); + } + } + + private int CountProtectedValues() + { + var count = CountProtectedValues( + Path.Join(_basePath, "settings.json"), + isRootSettings: true + ) + + CountProtectedValues( + Path.Join(_basePath, "settings.json.bak"), + isRootSettings: true + ); + + var pluginDataPath = Path.Join(_basePath, "PluginData"); + if (!Directory.Exists(pluginDataPath)) + { + return count; + } + + count += Directory + .EnumerateDirectories(pluginDataPath) + .Sum(pluginDirectory => + CountProtectedValues( + Path.Join(pluginDirectory, "settings.json"), + isRootSettings: false + ) + ); + + return count; + } + + private static int CountProtectedValues(string path, bool isRootSettings) + { + if (!File.Exists(path)) + { + return 0; + } + + try + { + if (JsonNode.Parse(File.ReadAllText(path)) is not JsonObject settings) + { + return 1; + } + + return isRootSettings + ? s_rootSecretProperties.Count(property => + HasNonEmptyValue(settings, property) + ) + : settings.Count(property => + property.Key.StartsWith(SecretPrefix, StringComparison.Ordinal) + && HasNonEmptyValue(settings, property.Key) + ); + } + catch (Exception ex) when ( + ex is IOException or JsonException or UnauthorizedAccessException + ) + { + return 1; + } + } + + private static bool HasNonEmptyValue(JsonObject settings, string propertyName) + { + return settings.TryGetPropertyValue(propertyName, out var node) + && node is JsonValue value + && value.TryGetValue(out var stored) + && !string.IsNullOrEmpty(stored); + } + + private static string CreateBearerToken() + { + return Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + } + + private readonly record struct FileMigrationOutcome( + bool Migrated, + int UnresolvedSecretCount, + string? Error + ) + { + public static FileMigrationOutcome None => new(false, 0, null); + } +} diff --git a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs index e872c6db7..9f891823c 100644 --- a/src/TypeWhisper.Linux/Services/SettingsBackupService.cs +++ b/src/TypeWhisper.Linux/Services/SettingsBackupService.cs @@ -1,7 +1,6 @@ using System.IO.Compression; using System.Text.Json; using System.Text.Json.Serialization; -using TypeWhisper.Core; using TypeWhisper.Linux.Services.Localization; namespace TypeWhisper.Linux.Services; @@ -9,14 +8,50 @@ 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. 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"; @@ -24,7 +59,7 @@ public sealed class SettingsBackupService [ "settings.json", "settings.json.bak", - "linux-preferences.json" + "linux-preferences.json", ]; private static readonly string[] s_backupDirectoryRoots = ["Data", "PluginData"]; @@ -39,18 +74,33 @@ public sealed class SettingsBackupService private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; - private readonly string _basePath; - - public SettingsBackupService() - : this(TypeWhisperEnvironment.BasePath) + private static readonly JsonSerializerOptions s_transactionJsonOptions = new() { - } + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, + }; - internal SettingsBackupService(string basePath) + private readonly string _basePath; + private readonly RestoreCommitObserver? _commitObserver; + private readonly Action? _cleanupObserver; + private readonly SecretProtectionMigrationService _secretMigration; + + internal SettingsBackupService( + string basePath, + RestoreCommitObserver? commitObserver = null, + Action? cleanupObserver = null, + SecretProtectionMigrationService? secretMigration = null + ) { _basePath = Path.GetFullPath(basePath); + _commitObserver = commitObserver; + _cleanupObserver = cleanupObserver; + _secretMigration = + secretMigration ?? new SecretProtectionMigrationService(_basePath); } + internal string PendingDirectoryPath => Path.Join(_basePath, PendingDirectoryName); + public SettingsBackupResult CreateBackup(string destinationZipPath) { if (string.IsNullOrWhiteSpace(destinationZipPath)) @@ -58,6 +108,17 @@ public SettingsBackupResult CreateBackup(string destinationZipPath) throw new ArgumentException("Backup path is required.", nameof(destinationZipPath)); } + var migration = _secretMigration.MigrateAll(); + if (migration.HasUnresolvedSecrets) + { + throw new InvalidOperationException( + Loc.Instance.GetString( + "Security.BackupBlockedByUnresolvedSecrets", + migration.UnresolvedSecretCount + ) + ); + } + var destinationDirectory = Path.GetDirectoryName(destinationZipPath); if (!string.IsNullOrWhiteSpace(destinationDirectory)) { @@ -81,7 +142,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())) @@ -130,17 +191,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 +244,514 @@ public SettingsBackupResult RestoreBackup(string sourceZipPath) continue; } - var targetPath = GetSafeDestinationPath(tempDir, entry.FullName); + 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; } - 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 restoredRoot = Path.Join(tempDir, root); - if (!Directory.Exists(restoredRoot)) + 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 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( @@ -262,6 +773,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 @@ -337,14 +889,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 +918,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)) @@ -443,6 +1022,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/Services/Setup/GlobalHotkeySetupTask.cs b/src/TypeWhisper.Linux/Services/Setup/GlobalHotkeySetupTask.cs index 7418e17e8..85510ba71 100644 --- a/src/TypeWhisper.Linux/Services/Setup/GlobalHotkeySetupTask.cs +++ b/src/TypeWhisper.Linux/Services/Setup/GlobalHotkeySetupTask.cs @@ -1,3 +1,4 @@ +using TypeWhisper.Core.Interfaces; using TypeWhisper.Linux.Services.Hotkey.DeSetup; using TypeWhisper.Linux.Services.Hotkey.Evdev; using TypeWhisper.Linux.Services.Localization; @@ -35,12 +36,15 @@ public sealed class GlobalHotkeySetupTask : ISetupTask private readonly Func _userListedInInputGroupFile; private readonly Func _ruleInstalled; private readonly Func _onAccessGranted; + private readonly Func _evdevOptedIn; + // ReSharper disable once UnusedMember.Global -- resolved by the DI container (AddSingleton), which SWEA cannot see. public GlobalHotkeySetupTask( SystemCommandAvailabilityService commands, IProcessRunner runner, InputAccessSetupHelper accessHelper, - HotkeyService hotkey + HotkeyService hotkey, + ISettingsService settings ) : this( () => commands.GetSnapshot().SessionType == "Wayland", @@ -50,7 +54,8 @@ HotkeyService hotkey InputAccessSetupHelper.IsSeatManagerPresent, UserListedInInputGroupFile, InputAccessSetupHelper.IsRuleInstalled, - hotkey.SwitchBackendAsync + hotkey.SwitchBackendAsync, + () => settings.Current.WaylandEvdevHotkeysEnabled ) { } @@ -63,7 +68,8 @@ internal GlobalHotkeySetupTask( Func isSeatManagerPresent, Func userListedInInputGroupFile, Func ruleInstalled, - Func onAccessGranted + Func onAccessGranted, + Func evdevOptedIn ) { _isWayland = isWayland; @@ -74,6 +80,7 @@ Func onAccessGranted _userListedInInputGroupFile = userListedInInputGroupFile; _ruleInstalled = ruleInstalled; _onAccessGranted = onAccessGranted; + _evdevOptedIn = evdevOptedIn; } private bool IsWayland => _isWayland(); @@ -97,6 +104,26 @@ public Task EvaluateAsync(CancellationToken ct) return Satisfied(Loc.Instance["Setup.GlobalHotkeyActiveX11"]); } + // Opting out is a valid focused-only choice and must never block setup or + // prompt for privileged keyboard access. If a rule remains from an earlier + // opt-in, keep the task satisfied while offering an explicit revoke action. + if (!_evdevOptedIn()) + { + if (_ruleInstalled()) + { + return Task.FromResult( + new SetupTaskState( + SetupTaskStatusKind.Satisfied, + Loc.Instance["Setup.GlobalHotkeyOptedOutRuleInstalled"], + Loc.Instance["Setup.GlobalHotkeyOptedOutRuleInstalledDetail"], + Loc.Instance["Setup.GlobalHotkeyRevokeButton"] + ) + ); + } + + return Satisfied(Loc.Instance["Setup.GlobalHotkeyOptedOut"]); + } + // Wayland: gate on actual openability of a keyboard node, NOT input-group // membership. With the uaccess rule the user is granted access via a // session ACL without ever joining the group, so a group check would @@ -135,6 +162,19 @@ public Task EvaluateAsync(CancellationToken ct) public async Task RunActionAsync(CancellationToken ct) { + // Re-check the setting here so direct callers cannot bypass the opt-out + // and fall through into either privileged installation path. + if (IsWayland && !_evdevOptedIn()) + { + if (!_ruleInstalled()) + { + return new SetupActionOutcome(true, Loc.Instance["Setup.GlobalHotkeyOptedOut"]); + } + + var removal = await _accessHelper.RemoveAsync(ct).ConfigureAwait(false); + return new SetupActionOutcome(removal.Success, removal.Message, removal.Detail); + } + if (!IsWayland || _hasKeyboardAccess()) { return new SetupActionOutcome(true, Loc.Instance["Setup.GlobalHotkeyAlreadyActive"]); 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/Setup/PackageInstaller.cs b/src/TypeWhisper.Linux/Services/Setup/PackageInstaller.cs index 644c4755a..037bfdf50 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; @@ -28,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; @@ -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) { @@ -188,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/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 47cb3c236..a1e6e8d51 100644 --- a/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs +++ b/src/TypeWhisper.Linux/Services/SpeechFeedbackService.cs @@ -15,36 +15,94 @@ 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 Action? _playbackVersionAllocated; private readonly PluginManager _pluginManager; private readonly ISettingsService _settings; 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, PluginManager pluginManager, - SystemCommandAvailabilityService commands + SystemCommandAvailabilityService commands, + IProcessRunner processRunner ) - : this(settings, pluginManager, new LinuxSystemTtsProvider(settings, commands)) + : this( + settings, + pluginManager, + new LinuxSystemTtsProvider(settings, commands, processRunner) + ) { } internal SpeechFeedbackService( ISettingsService settings, PluginManager pluginManager, - ITtsProviderPlugin systemProvider + ITtsProviderPlugin systemProvider, + Func? delay = null, + Action? playbackVersionAllocated = null ) { _settings = settings; _pluginManager = pluginManager; _systemProvider = systemProvider; + _delay = delay ?? Task.Delay; + _playbackVersionAllocated = playbackVersionAllocated; _pluginManager.PluginStateChanged += OnPluginStateChanged; } @@ -168,57 +226,87 @@ 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 + catch (Exception ex) { - // Best-effort cancellation; the source is disposed in the finally below. + Debug.WriteLine($"SpeechFeedback stop wait error: {ex.Message}"); } - finally + } + + internal async Task AnnounceRecordingStartedAsync(bool spokenFeedbackEnabled) + { + if (!spokenFeedbackEnabled) + { + return; + } + + 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(); + ReleasePlaybackOwnership(request); + _ = await WaitForCompletionAsync(request, s_stopPlaybackTimeout) + .ConfigureAwait(false); + request.Complete(); } - 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(); + ReleasePlaybackOwnership(request); + request.Complete(); + 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( @@ -226,15 +314,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 @@ -245,18 +342,29 @@ private void SpeakCore( request = ApplyConfiguredLanguageFallback(request); } - Stop(); - - var cts = new CancellationTokenSource(); - var version = Interlocked.Increment(ref _playbackVersion); + PlaybackRequest? supersededRequest; + PlaybackRequest playbackRequest; lock (_lock) { - _speakCts = cts; + supersededRequest = _playbackRequest; + var version = AllocatePlaybackVersion(); + playbackRequest = new PlaybackRequest(version); + _playbackRequest = playbackRequest; + _playbackSession = null; _isPlaybackPending = true; } - _ = SpeakAsync(request, cts, version); + 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 @@ -291,21 +399,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 @@ -313,7 +417,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; @@ -323,7 +431,8 @@ long version if (!accepted) { - session.Stop(); + playbackRequest.CancelAndStop(); + ClearPending(playbackRequest); return; } @@ -331,73 +440,108 @@ long version completedHandler = (_, _) => { session.Completed -= completedHandler; - OnPlaybackCompleted(session, cts, version); + OnPlaybackCompleted(session, playbackRequest); }; session.Completed += completedHandler; 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(PlaybackRequest playbackRequest) + { + ReleasePlaybackOwnership(playbackRequest); + playbackRequest.Complete(); } - private void ClearPending(CancellationTokenSource cts, long version) + private void ReleasePlaybackOwnership(PlaybackRequest playbackRequest) { - var disposeCts = false; lock (_lock) { - if (_speakCts == cts && version == Volatile.Read(ref _playbackVersion)) + // 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) + ) { - _speakCts = null; + _playbackRequest = null; + _playbackSession = null; _isPlaybackPending = false; - disposeCts = true; } } + } + + private PlaybackRequest? StopPlayback() + { + PlaybackRequest? playbackRequest; + lock (_lock) + { + playbackRequest = _playbackRequest; + _playbackRequest = null; + _playbackSession = null; + _isPlaybackPending = false; + } - if (disposeCts) + 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() @@ -443,4 +587,4 @@ private void OnPluginStateChanged(object? sender, EventArgs e) { ProvidersChanged?.Invoke(this, EventArgs.Empty); } -} \ No newline at end of file +} 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/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/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/Services/StreamingTranscriptState.cs b/src/TypeWhisper.Linux/Services/StreamingTranscriptState.cs index 3fdf83ae2..483979ad5 100644 --- a/src/TypeWhisper.Linux/Services/StreamingTranscriptState.cs +++ b/src/TypeWhisper.Linux/Services/StreamingTranscriptState.cs @@ -9,38 +9,42 @@ namespace TypeWhisper.Linux.Services; /// internal sealed class StreamingTranscriptState { + // Keep corrector work outside this lock: correctors can take their own locks and do + // non-trivial list/regex work, so a full-method lock would make StartSession and + // StopSession wait behind them and introduce nested-lock ordering. Instead, snapshot + // under this lock and compare-and-commit under it after correction/stabilization. + private readonly Lock _lock = new(); 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() { - // Bump version first so any in-flight writer with the old version fails its re-check. - var newVersion = Interlocked.Increment(ref _sessionVersion); - _confirmedText = ""; - _lastDisplayedText = ""; - return newVersion; + lock (_lock) + { + _sessionVersion++; + _confirmedText = ""; + _lastDisplayedText = ""; + return _sessionVersion; + } } public string StopSession() { - var finalText = !string.IsNullOrWhiteSpace(_lastDisplayedText) - ? _lastDisplayedText - : _confirmedText; - InvalidateSession(); - _confirmedText = ""; - _lastDisplayedText = ""; - return finalText; - } - - private bool IsCurrentSession(int sessionVersion) - { - return sessionVersion == Volatile.Read(ref _sessionVersion); - } - - private void InvalidateSession() - { - Interlocked.Increment(ref _sessionVersion); + lock (_lock) + { + var finalText = !string.IsNullOrWhiteSpace(_lastDisplayedText) + ? _lastDisplayedText + : _confirmedText; + _sessionVersion++; + _confirmedText = ""; + _lastDisplayedText = ""; + return finalText; + } } public bool TryApplyPolling( @@ -51,9 +55,17 @@ out string displayText ) { displayText = ""; - if (!IsCurrentSession(sessionVersion)) + string confirmedSnapshot; + int revisionSnapshot; + lock (_lock) { - return false; + if (sessionVersion != _sessionVersion) + { + return false; + } + + confirmedSnapshot = _confirmedText; + revisionSnapshot = _commitRevision; } var text = rawText.Trim(); @@ -62,25 +74,30 @@ out string displayText return false; } + // Deliberately outside the lock; see the trade-off note on _lock. text = corrector(text); if (string.IsNullOrEmpty(text)) { return false; } - var stable = StabilizeText(_confirmedText, text); + var stable = StabilizeText(confirmedSnapshot, text); - // Re-check before writing: StartSession/InvalidateSession may have bumped - // the version while we were correcting and stabilizing. - if (!IsCurrentSession(sessionVersion)) + lock (_lock) { - return false; - } + // A version check alone cannot detect another poll committing within this same + // session; the revision compare discards this stale result instead of clobbering it. + if (sessionVersion != _sessionVersion || _commitRevision != revisionSnapshot) + { + return false; + } - _confirmedText = stable; - _lastDisplayedText = stable; - displayText = stable; - return true; + _commitRevision++; + _confirmedText = stable; + _lastDisplayedText = stable; + displayText = stable; + return true; + } } /// @@ -154,4 +171,4 @@ internal static string StabilizeText(string confirmed, string newText) return newText; } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs b/src/TypeWhisper.Linux/Services/StreamingTranscriptionCoordinator.cs index 4fa46061a..451188d54 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, @@ -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; @@ -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/SystemCommandAvailabilityService.cs b/src/TypeWhisper.Linux/Services/SystemCommandAvailabilityService.cs index c1c7d3627..ae0d51065 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 = [ @@ -32,11 +37,18 @@ 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", ]; 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 +250,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 ) @@ -292,9 +338,10 @@ public async Task RunCudaBenchmarkAsync( } var stopwatch = Stopwatch.StartNew(); + Process? process = null; try { - using var process = Process.Start( + process = Process.Start( new ProcessStartInfo( "nvidia-smi", "--query-gpu=name,memory.total,driver_version --format=csv,noheader,nounits" @@ -303,7 +350,7 @@ public async Task RunCudaBenchmarkAsync( RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); @@ -321,15 +368,6 @@ public async Task RunCudaBenchmarkAsync( if (!ReferenceEquals(completed, waitTask) && !process.HasExited) { - try - { - process.Kill(true); - } - catch - { - /* best effort */ - } - return new CudaBenchmarkResult( false, "nvidia-smi did not respond within 3 seconds.", @@ -375,6 +413,31 @@ public async Task RunCudaBenchmarkAsync( stopwatch.Elapsed ); } + finally + { + if (process is not null) + { + // Disposing a Process does not stop the child, so every early exit — + // cancellation, the 3 s timeout, an I/O failure — would orphan nvidia-smi. + TryKillProcessTree(process); + process.Dispose(); + } + } + } + + private static void TryKillProcessTree(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(true); + } + } + catch + { + /* best effort */ + } } public static bool IsCommandAvailable(string commandName) @@ -395,7 +458,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; } @@ -428,8 +496,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() { @@ -449,6 +516,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) { @@ -459,14 +531,24 @@ 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) + .AsTask() + .GetAwaiter() + .GetResult(); + return candidate; } catch { - // Inaccessible socket path — skip it. + // Missing, stale, inaccessible, or non-datagram endpoint — skip it. } } @@ -482,7 +564,7 @@ internal void RaiseSnapshotChangedForTests(LinuxCapabilitySnapshot snapshot) private static LinuxCapabilitySnapshot BuildSnapshot() { - var isWayland = Environment.GetEnvironmentVariable("WAYLAND_DISPLAY") is { Length: > 0 }; + var isWayland = WaylandSessionDetector.IsWaylandSession(); var isX11 = Environment.GetEnvironmentVariable("DISPLAY") is { Length: > 0 }; var hasXclip = IsCommandAvailable("xclip"); var hasWlClipboard = IsCommandAvailable("wl-copy") && IsCommandAvailable("wl-paste"); @@ -533,7 +615,7 @@ private static LinuxCapabilitySnapshot BuildSnapshot() RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); if (p is null) @@ -656,7 +738,7 @@ private static bool FindInLdCache(string libraryName) RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, } ); @@ -775,4 +857,4 @@ public sealed record LinuxCapabilitySnapshot( CanUseCuda ? Localization.Loc.Instance["Dictation.CudaStatusAvailable"] : HasCudaGpu ? Localization.Loc.Instance["Dictation.CudaStatusRuntimeMissing"] : Localization.Loc.Instance["Dictation.CudaStatusNoGpu"]; -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs index 0cbcaee6c..84643fbf1 100644 --- a/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs +++ b/src/TypeWhisper.Linux/Services/TargetAppCorrectionLearningService.cs @@ -231,6 +231,7 @@ public async Task ArmAsync(string insertedText) if (!await _client.EnsureStartedAsync().ConfigureAwait(false)) { LogSkipOnce("AT-SPI unavailable; target-app correction learning inactive."); + DisarmIfCurrent(armSequence); return; } @@ -286,6 +287,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/Services/TextInsertionService.cs b/src/TypeWhisper.Linux/Services/TextInsertionService.cs index 4e2d34f5a..ad90e8f77 100644 --- a/src/TypeWhisper.Linux/Services/TextInsertionService.cs +++ b/src/TypeWhisper.Linux/Services/TextInsertionService.cs @@ -15,7 +15,8 @@ public enum InsertionResult ActionFailed, MissingClipboardTool, MissingPasteTool, - Failed + Failed, + ActionUnavailable, } /// @@ -30,7 +31,8 @@ public enum InsertionFailureReason YdotoolSocketUnreachable, NoWaylandTypingTool, FocusFailed, - PasteRetriesExhausted + PasteRetriesExhausted, + PartialTypingFailure, } public sealed record TextInsertionRequest( @@ -55,6 +57,16 @@ public sealed record TextInsertionRequest( 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 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 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); // After the paste chord we hold our text on the clipboard this long before restoring the user's @@ -126,6 +138,14 @@ internal TextInsertionService( public InsertionFailureReason LastFailureReason { get; private set; } = InsertionFailureReason.None; + // Whether the last direct-typing attempt aborted mid-sequence after already + // delivering part of the text. Both the clipboard-fallback suppression and the + // orchestrator's completion message key on this fact rather than on + // LastFailureReason: a structural reason (e.g. ydotool socket unreachable) can be + // recorded before the partial-delivery abort, so the reason value alone can't tell + // whether a prefix already landed. Reset per request. + public bool LastTypingDeliveredPartialText { get; private set; } + public async Task InsertTextAsync( string text, bool autoPaste = true, @@ -152,6 +172,7 @@ public async Task InsertTextAsync( public async Task InsertTextAsync(TextInsertionRequest request) { LastFailureReason = InsertionFailureReason.None; + LastTypingDeliveredPartialText = false; var text = request.Text; var autoPaste = request.AutoPaste; @@ -203,7 +224,7 @@ public async Task InsertTextAsync(TextInsertionRequest request) && string.IsNullOrEmpty(targetWindowTitle) && _platform.PrefersDirectTypingForUnknownTarget && IsAsciiSafe(text) - ) + ), }; if (shouldTypeDirectly) @@ -212,6 +233,11 @@ public async Task InsertTextAsync(TextInsertionRequest request) if ( strategy is TextInsertionStrategy.DirectTyping || directResult is not InsertionResult.Failed + // Partial delivery already happened under the failed backend; falling + // through would clipboard-paste the complete text again and duplicate + // the prefix that's already in the target app. (Keyed on the delivery + // fact, not LastFailureReason — see the property comment.) + || LastTypingDeliveredPartialText ) { return directResult; @@ -224,6 +250,8 @@ strategy is TextInsertionStrategy.DirectTyping } var previousClipboard = await _platform.TryGetClipboardTextAsync(); + var previousClipboardHasNonTextFormats = + await _platform.ClipboardHasNonTextFormatsAsync(); if (!await _platform.SetClipboardTextAsync(text)) { return InsertionResult.Failed; @@ -241,7 +269,11 @@ strategy is TextInsertionStrategy.DirectTyping "Auto paste fell back to clipboard: target window could not be focused." ); return requiresSafeTerminalPaste - ? await FailTerminalMultilineAsync(text, previousClipboard) + ? await FailTerminalMultilineAsync( + text, + previousClipboard, + previousClipboardHasNonTextFormats + ) : InsertionResult.CopiedToClipboard; } @@ -252,7 +284,11 @@ strategy is TextInsertionStrategy.DirectTyping + $"so {pasteShortcut} was not sent (it would have pasted nothing or stale content)." ); return requiresSafeTerminalPaste - ? await FailTerminalMultilineAsync(text, previousClipboard) + ? await FailTerminalMultilineAsync( + text, + previousClipboard, + previousClipboardHasNonTextFormats + ) : InsertionResult.CopiedToClipboard; } @@ -267,7 +303,7 @@ strategy is TextInsertionStrategy.DirectTyping // Arm the confirmation watch BEFORE the keystroke: the target's text-changed // fires while the paste chord 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(); + var pasteWatch = _pasteConfirmation?.BeginWatch(text); // Until the watch is handed to RestorePreviousClipboardAsync (which owns its disposal), // any throw from the paste/enter path must still release the AT-SPI subscription. @@ -287,7 +323,11 @@ strategy is TextInsertionStrategy.DirectTyping $"Auto paste fell back to clipboard: {pasteShortcut} could not be sent after retries." ); return requiresSafeTerminalPaste - ? await FailTerminalMultilineAsync(text, previousClipboard) + ? await FailTerminalMultilineAsync( + text, + previousClipboard, + previousClipboardHasNonTextFormats + ) : InsertionResult.CopiedToClipboard; } @@ -317,6 +357,7 @@ strategy is TextInsertionStrategy.DirectTyping await RestorePreviousClipboardAsync( text, previousClipboard, + previousClipboardHasNonTextFormats, pasteWatch, deliveryConfirmed ); @@ -388,6 +429,8 @@ public async Task CaptureSelectedTextAsync(bool targetIsTerminal = false private async Task ProbeSelectionViaCopyAsync(bool targetIsTerminal) { var previousClipboard = await _platform.TryGetClipboardTextAsync(); + var previousClipboardHasNonTextFormats = + await _platform.ClipboardHasNonTextFormatsAsync(); // Only prime a sentinel when the clipboard already holds text we can detect against and // restore: a null read means it's empty or non-text (an image / file list) we must not @@ -426,9 +469,19 @@ private async Task ProbeSelectionViaCopyAsync(bool targetIsTerminal) if (!useSentinel) { + if (previousClipboardHasNonTextFormats) + { + LogInsertionFallback(ClipboardNonTextCouldNotRestoreMessage); + } + return afterCopy; } + if (previousClipboardHasNonTextFormats) + { + LogInsertionFallback(ClipboardRichRestoreLossyMessage); + } + await _platform.SetClipboardTextAsync(previousClipboard!); return string.Equals(afterCopy, sentinel, StringComparison.Ordinal) ? "" : afterCopy; } @@ -499,14 +552,15 @@ read is null /// /// Fail-closed exit for terminal multiline auto-paste. The clipboard already holds our /// staged text but no keystroke was sent, so — unlike the paste path — there is no - /// in-flight transfer to protect. Back the staged text out so the Failed result stays - /// honest ("could not be copied or pasted"). Ownership-checked like the post-paste - /// restore: if the user copied something newer while the insert was failing, leave - /// their copy alone rather than clobbering it with the stale snapshot. + /// in-flight transfer to protect. Restore a faithfully captured plain-text predecessor; + /// otherwise retain the staged text as a manual-paste fallback. Ownership-checked like + /// the post-paste restore: if the user copied something newer while the insert was + /// failing, leave their copy alone rather than clobbering it with the stale snapshot. /// private async Task FailTerminalMultilineAsync( string stagedText, - string? previousClipboard + string? previousClipboard, + bool previousClipboardHasNonTextFormats ) { var current = await _platform.TryGetClipboardTextAsync(); @@ -523,9 +577,23 @@ current is not null return InsertionResult.Failed; } + if (previousClipboard is null || previousClipboardHasNonTextFormats) + { + if (previousClipboardHasNonTextFormats) + { + LogInsertionFallback( + previousClipboard is null + ? ClipboardNonTextCouldNotRestoreMessage + : ClipboardRichRestoreSkippedMessage + ); + } + + return InsertionResult.Failed; + } + try { - await _platform.SetClipboardTextAsync(previousClipboard ?? string.Empty); + await _platform.SetClipboardTextAsync(previousClipboard); } catch { @@ -538,12 +606,18 @@ current is not null private async Task RestorePreviousClipboardAsync( string pastedText, string? previousClipboard, + bool previousClipboardHasNonTextFormats, IPasteWatch? watch, bool? deliveryConfirmed ) { if (previousClipboard is null) { + if (previousClipboardHasNonTextFormats) + { + LogInsertionFallback(ClipboardNonTextCouldNotRestoreMessage); + } + // 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 the paste chord: its event subscription must not outlive the insertion. @@ -580,9 +654,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 @@ -592,6 +674,12 @@ current is not null return; } + if (previousClipboardHasNonTextFormats) + { + LogInsertionFallback(ClipboardRichRestoreSkippedMessage); + return; + } + try { await _platform.SetClipboardTextAsync(previousClipboard); @@ -641,7 +729,15 @@ or InsertionFailureReason.YdotoolSocketUnreachable or InsertionFailureReason.NoWaylandTypingTool ) { - LastFailureReason = platformReason; + // First-failing structural reason within this request wins — a later, + // more generic reason from this fallback's own chain walk must not + // downgrade an earlier specific one (e.g. "ydotool socket unreachable" + // is a more useful hint than the generic "no typing tool"). + if (LastFailureReason == InsertionFailureReason.None) + { + LastFailureReason = platformReason; + } + return false; } @@ -674,6 +770,7 @@ bool autoEnter LastFailureReason = _platform.LastFailureReason; } + LastTypingDeliveredPartialText = _platform.LastTypingDeliveredPartialText; LogInsertionFallback("Direct typing failed."); return InsertionResult.Failed; } @@ -851,8 +948,26 @@ internal interface ITextInsertionPlatform bool PrefersDirectTypingForUnknownTarget { get; } InsertionFailureReason LastFailureReason { get; } + + /// + /// True when the most recent typing attempt aborted mid-sequence after at least + /// one segment had already reached the target. The caller must then suppress the + /// clipboard fallback — a full re-paste would duplicate the delivered prefix — + /// regardless of which failure reason was recorded. + /// + bool LastTypingDeliveredPartialText { get; } + Task TryGetClipboardTextAsync(); Task SetClipboardTextAsync(string text); + + /// + /// True when the clipboard currently offers a MIME type beyond ordinary plain text + /// (an image, a file list, HTML, etc.) — queried before an insertion overwrites the + /// clipboard, so a caller can tell whether the value it is about to destroy was + /// something a plain-text round trip cannot faithfully preserve or restore. + /// + Task ClipboardHasNonTextFormatsAsync(); + Task DelayAsync(TimeSpan delay); string? GetActiveWindowId(); Task ActivateWindowAsync(string windowId); @@ -882,9 +997,38 @@ internal interface ITextInsertionPlatform /// internal sealed class LinuxTextInsertionPlatform : ITextInsertionPlatform { + private static readonly TimeSpan s_clipboardOperationTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan s_injectorProcessTimeout = TimeSpan.FromSeconds(60); + + private static readonly HashSet s_waylandTextSafeTargets = + new(StringComparer.OrdinalIgnoreCase) { "STRING", "UTF8_STRING", "TEXT" }; + + // X11 TARGETS listings always include protocol/negotiation targets that carry no + // content of their own alongside the plain-text encodings. None count as non-text content. + private static readonly HashSet s_x11TextSafeTargets = new( + [ + "TARGETS", + "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", + "COMPOUND_TEXT", + ], + StringComparer.OrdinalIgnoreCase + ); + // kept injected as a DI/test seam; not consumed in-tree // ReSharper disable once NotAccessedField.Local private readonly SystemCommandAvailabilityService? _commands; + private readonly IProcessRunner _ioRunner; private readonly bool _isWayland; private readonly ProcessRunnerWithEnv _processRunner; @@ -896,12 +1040,19 @@ private readonly Func< private List _chain; private HashSet _disabled = []; + private bool _abortChainAfterAttempt; private LinuxCapabilitySnapshot _snapshot; - public LinuxTextInsertionPlatform(SystemCommandAvailabilityService commands) - : this(commands, DefaultProcessRunnerWithEnv, DefaultProcessRunnerWithStderr) + public LinuxTextInsertionPlatform( + SystemCommandAvailabilityService commands, + IProcessRunner? processRunner = null + ) + : this(commands.GetSnapshot(), processRunner ?? new ProcessRunner()) { + _commands = commands; + // Rebuild chain in place whenever the snapshot refreshes (e.g. after ydotool setup). + commands.SnapshotChanged += OnSnapshotChanged; } internal LinuxTextInsertionPlatform( @@ -948,6 +1099,7 @@ internal LinuxTextInsertionPlatform( >? processRunnerWithStderr = null ) { + _ioRunner = new ProcessRunner(); _snapshot = snapshot; _processRunner = processRunner; _processRunnerWithStderr = processRunnerWithStderr; @@ -955,6 +1107,19 @@ internal LinuxTextInsertionPlatform( _chain = BuildChain(snapshot); } + internal LinuxTextInsertionPlatform( + LinuxCapabilitySnapshot snapshot, + IProcessRunner processRunner + ) + { + _ioRunner = processRunner; + _snapshot = snapshot; + _processRunner = DefaultProcessRunnerWithEnv; + _processRunnerWithStderr = DefaultProcessRunnerWithStderr; + _isWayland = snapshot.SessionType == "Wayland"; + _chain = BuildChain(snapshot); + } + public bool IsClipboardSetAvailable => _isWayland ? IsCommandAvailable("wl-copy") : IsCommandAvailable("xclip"); @@ -975,26 +1140,32 @@ internal LinuxTextInsertionPlatform( public InsertionFailureReason LastFailureReason { get; private set; } = InsertionFailureReason.None; + public bool LastTypingDeliveredPartialText { get; private set; } + public async Task TryGetClipboardTextAsync() { - var psi = _isWayland - ? new ProcessStartInfo("wl-paste", "--no-newline") - : new ProcessStartInfo("xclip", "-selection clipboard -o"); - psi.RedirectStandardOutput = true; - psi.RedirectStandardError = true; - psi.UseShellExecute = false; + var fileName = _isWayland ? "wl-paste" : "xclip"; + IReadOnlyList args = _isWayland + ? ["--no-newline"] + : ["-selection", "clipboard", "-o"]; try { - using var p = Process.Start(psi); - if (p is null) + var result = await _ioRunner.RunAsync( + fileName, + args, + timeout: s_clipboardOperationTimeout + ).ConfigureAwait(false); + // ReSharper disable once InvertIf -- early-return guard clause; inverting would nest the happy path + if (result.TimedOut) { + Trace.WriteLine( + $"[TextInsertionService] clipboard read timed out after {s_clipboardOperationTimeout.TotalSeconds:0} seconds and was killed." + ); return null; } - var output = await p.StandardOutput.ReadToEndAsync(); - await p.WaitForExitAsync(); - return p.ExitCode == 0 ? output : null; + return result.Succeeded ? result.StandardOutput : null; } catch (Exception ex) { @@ -1003,27 +1174,79 @@ internal LinuxTextInsertionPlatform( } } + public async Task ClipboardHasNonTextFormatsAsync() + { + var fileName = _isWayland ? "wl-paste" : "xclip"; + IReadOnlyList args = _isWayland + ? ["--list-types"] + : ["-selection", "clipboard", "-o", "-t", "TARGETS"]; + + try + { + var result = await _ioRunner.RunAsync( + fileName, + args, + timeout: s_clipboardOperationTimeout + ).ConfigureAwait(false); + // ReSharper disable once InvertIf -- early-return guard clause; inverting would nest the happy path + if (result.TimedOut) + { + Trace.WriteLine( + $"[TextInsertionService] clipboard format listing timed out after {s_clipboardOperationTimeout.TotalSeconds:0} seconds and was killed." + ); + return false; + } + + return result.Succeeded + && ListingHasNonTextFormats(result.StandardOutput, _isWayland); + } + catch (Exception ex) + { + Trace.WriteLine( + $"[TextInsertionService] clipboard format listing failed: {ex.Message}" + ); + return false; + } + } + + internal static bool ListingHasNonTextFormats(string listing, bool isWayland) + { + var textSafe = isWayland ? s_waylandTextSafeTargets : s_x11TextSafeTargets; + return listing.Split('\n').Any(rawLine => + { + var target = rawLine.Trim(); + return target.Length != 0 + && !textSafe.Contains(target) + && !target.StartsWith("text/plain", StringComparison.OrdinalIgnoreCase); + }); + } + public async Task SetClipboardTextAsync(string text) { - var psi = _isWayland - ? new ProcessStartInfo("wl-copy") - : new ProcessStartInfo("xclip", "-selection clipboard"); - psi.RedirectStandardInput = true; - psi.RedirectStandardError = true; - psi.UseShellExecute = false; + var fileName = _isWayland ? "wl-copy" : "xclip"; + IReadOnlyList args = _isWayland ? [] : ["-selection", "clipboard"]; try { - using var p = Process.Start(psi); - if (p is null) + var result = await _ioRunner.RunAsync( + fileName, + args, + standardInput: text, + 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) { + Trace.WriteLine( + $"[TextInsertionService] clipboard write timed out after {s_clipboardOperationTimeout.TotalSeconds:0} seconds and was killed." + ); return false; } - await p.StandardInput.WriteAsync(text); - p.StandardInput.Close(); - await p.WaitForExitAsync(); - return p.ExitCode == 0; + return result.Succeeded; } catch (Exception ex) { @@ -1084,7 +1307,7 @@ public async Task SendPasteAsync(bool useTerminalShortcut = false) ? YdotoolBackend.TerminalPasteArgs() : YdotoolBackend.PasteArgs() ), - _ => false + _ => false, } ); } @@ -1110,26 +1333,60 @@ private async Task TypeWithNewlinesAsync(InputBackend backend, string text return await TypeSegmentAsync(backend, normalized); } - // A backend that fails mid-stream returns false and the chain retries - // the next backend from scratch — same all-or-nothing risk the single - // type() call already carried; partial duplication needs a rare - // mid-sequence failure (the first call fails fast on a dead backend). var segments = normalized.Split('\n'); + var delivered = false; for (var i = 0; i < segments.Length; i++) { - if (i > 0 && !await SendShiftEnterAsync(backend)) + if (i > 0) { - return false; + if (!await SendShiftEnterAsync(backend)) + { + return FailPartway(delivered); + } + + // A landed Shift+Enter is itself delivery: it puts a newline in the + // target even when every segment so far was empty (leading/blank + // lines). Count it so a later failure fails closed instead of letting + // the chain retype from scratch and duplicate the newline. + delivered = true; } var segment = segments[i]; - if (segment.Length > 0 && !await TypeSegmentAsync(backend, segment)) + if (segment.Length == 0) { - return false; + continue; } + + if (!await TypeSegmentAsync(backend, segment)) + { + return FailPartway(delivered); + } + + delivered = true; } return true; + + // A failure after at least one segment already reached the target means + // retrying — with this backend or the next — would retype from the start + // and duplicate what's already there (or resubmit a partial shell command + // in a terminal). Stop the chain instead of risking a silent duplicate. + bool FailPartway(bool hasDelivered) + { + if (!hasDelivered) + { + return false; + } + + _abortChainAfterAttempt = true; + LastTypingDeliveredPartialText = true; + if (LastFailureReason == InsertionFailureReason.None) + { + LastFailureReason = InsertionFailureReason.PartialTypingFailure; + } + + return false; + } } private async Task TypeSegmentAsync(InputBackend backend, string segment) @@ -1143,7 +1400,7 @@ private async Task TypeSegmentAsync(InputBackend backend, string segment) null ) == 0, InputBackend.Ydotool => await RunYdotoolAsync(YdotoolBackend.TypeArgs(segment)), - _ => false + _ => false, }; } @@ -1158,7 +1415,7 @@ private async Task SendShiftEnterAsync(InputBackend backend) null ) == 0, InputBackend.Ydotool => await RunYdotoolAsync(YdotoolBackend.ShiftEnterArgs()), - _ => false + _ => false, }; } @@ -1176,7 +1433,7 @@ public async Task SendCopyAsync(bool useTerminalShortcut) InputBackend.Ydotool => await RunYdotoolAsync( useTerminalShortcut ? YdotoolBackend.TerminalCopyArgs() : YdotoolBackend.CopyArgs() ), - _ => false + _ => false, } ); } @@ -1193,7 +1450,7 @@ public async Task SendEnterAsync() null ) == 0, InputBackend.Ydotool => await RunYdotoolAsync(YdotoolBackend.EnterArgs()), - _ => false + _ => false, } ); } @@ -1224,6 +1481,8 @@ private async Task WalkChainAsync(Func> attempt) var chain = _chain; var disabled = _disabled; LastFailureReason = InsertionFailureReason.None; + _abortChainAfterAttempt = false; + LastTypingDeliveredPartialText = false; if (chain.Count == 0) { LastFailureReason = InsertionFailureReason.NoWaylandTypingTool; @@ -1244,6 +1503,11 @@ private async Task WalkChainAsync(Func> attempt) { return true; } + + if (_abortChainAfterAttempt) + { + break; + } } if (!anyAttempted) @@ -1293,10 +1557,10 @@ private static List BuildChain(LinuxCapabilitySnapshot snapshot) } } - if (snapshot.HasXdotool) - { - chain.Add(InputBackend.Xdotool); - } + // xdotool is never added on Wayland: XTEST reaches only XWayland + // surfaces and can exit 0 even when the native-Wayland target received + // nothing — and nothing here can tell whether the focused surface + // is XWayland. } else if (snapshot.HasXdotool) { @@ -1362,7 +1626,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) @@ -1474,7 +1738,7 @@ private Task RunWithEnv( return _processRunner(fileName, args, env); } - private static async Task DefaultProcessRunnerWithEnv( + private async Task DefaultProcessRunnerWithEnv( string fileName, IReadOnlyList args, IReadOnlyDictionary? env @@ -1482,28 +1746,30 @@ private static async Task DefaultProcessRunnerWithEnv( { try { - var psi = new ProcessStartInfo(fileName) { RedirectStandardError = true, UseShellExecute = false }; - foreach (var arg in args) - { - psi.ArgumentList.Add(arg); - } - - if (env is not null) + var result = await _ioRunner.RunAsync( + fileName, + args, + environment: env, + timeout: s_injectorProcessTimeout + ).ConfigureAwait(false); + if (result.TimedOut) { - foreach (var (key, value) in env) - { - psi.Environment[key] = value; - } + Trace.WriteLine( + $"[TextInsertionService] {fileName} timed out after {s_injectorProcessTimeout.TotalSeconds:0} seconds and was killed." + ); + return -1; } - using var p = Process.Start(psi); - if (p is null) + // ReSharper disable once InvertIf -- early-return guard clause; inverting would nest the happy path + if (!result.Started) { + Trace.WriteLine( + $"[TextInsertionService] {fileName} failed: {result.StandardError}" + ); return -1; } - await p.WaitForExitAsync(); - return p.ExitCode; + return result.ExitCode; } catch (Exception ex) { @@ -1512,34 +1778,36 @@ private static async Task DefaultProcessRunnerWithEnv( } } - private static async Task<(int exitCode, string stderr)> DefaultProcessRunnerWithStderr( + private async Task<(int exitCode, string stderr)> DefaultProcessRunnerWithStderr( string fileName, IReadOnlyList args ) { try { - var psi = new ProcessStartInfo(fileName) - { - RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false - }; - foreach (var arg in args) + var result = await _ioRunner.RunAsync( + fileName, + args, + timeout: s_injectorProcessTimeout + ).ConfigureAwait(false); + if (result.TimedOut) { - psi.ArgumentList.Add(arg); + Trace.WriteLine( + $"[TextInsertionService] {fileName} timed out after {s_injectorProcessTimeout.TotalSeconds:0} seconds and was killed." + ); + return (-1, string.Empty); } - using var p = Process.Start(psi); - if (p is null) + // ReSharper disable once InvertIf -- early-return guard clause; inverting would nest the happy path + if (!result.Started) { + Trace.WriteLine( + $"[TextInsertionService] {fileName} failed: {result.StandardError}" + ); return (-1, string.Empty); } - var stderrTask = p.StandardError.ReadToEndAsync(); - var stdoutTask = p.StandardOutput.ReadToEndAsync(); - await p.WaitForExitAsync(); - var stderr = await stderrTask.ConfigureAwait(false); - await stdoutTask.ConfigureAwait(false); - return (p.ExitCode, stderr); + return (result.ExitCode, result.StandardError); } catch (Exception ex) { @@ -1554,7 +1822,7 @@ private enum InputBackend None, Xdotool, Wtype, - Ydotool + Ydotool, } internal delegate Task ProcessRunnerWithEnv( diff --git a/src/TypeWhisper.Linux/Services/TransformSelectionService.cs b/src/TypeWhisper.Linux/Services/TransformSelectionService.cs index 801bdafbf..f7879f508 100644 --- a/src/TypeWhisper.Linux/Services/TransformSelectionService.cs +++ b/src/TypeWhisper.Linux/Services/TransformSelectionService.cs @@ -91,6 +91,66 @@ internal static bool IsCancelCommand(string command) || normalized.Equals("stop", StringComparison.OrdinalIgnoreCase); } + // Test seam: lets the terminal-aware copy-shortcut decision be tested without constructing the full service. See audit §3 M5. + internal static Task CaptureSelectionForTransformAsync( + TextInsertionService textInsertion, + string? processName + ) + { + return textInsertion.CaptureSelectedTextAsync( + TextInsertionService.IsTerminalApp(processName) + ); + } + + // Test seam: decides whether it's still safe to replace the captured selection. See audit §3 M6. + // Cannot detect caret/selection drift within the same window — that would need an + // AT-SPI selection-range or document-revision token. + internal static bool HasSelectionTargetChanged( + string? capturedWindowId, + string? capturedProcessName, + string? currentWindowId, + string? currentProcessName + ) + { + // Window id is the strongest signal (X11 only) — if both sides have one, trust it + // even if process-name detection disagrees. + var capturedHasWindowId = !string.IsNullOrEmpty(capturedWindowId); + var currentHasWindowId = !string.IsNullOrEmpty(currentWindowId); + if (capturedHasWindowId && currentHasWindowId) + { + return !string.Equals(capturedWindowId, currentWindowId, StringComparison.Ordinal); + } + + // 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( + capturedProcessName, + currentProcessName, + StringComparison.OrdinalIgnoreCase + ); + } + + // No identity signal on either side — fail open rather than block a replacement we can't validate. + return false; + } + + // Test seam: delivers an aborted transform clipboard-only so it can never paste into the now-focused window. See audit §3 M6. + internal static Task DeliverAbortedTransformAsync( + TextInsertionService textInsertion, + string transformed + ) => textInsertion.InsertTextAsync(transformed, autoPaste: false); + public event EventHandler? OverlayStateChanged; private async Task StartAsync() @@ -106,17 +166,17 @@ await ShowWarningAsync( var windowId = _activeWindow.GetActiveWindowId(); var processName = _activeWindow.GetActiveWindowProcessName(); var windowTitle = _activeWindow.GetActiveWindowTitle(); - var selectedText = await _textInsertion.CaptureSelectedTextAsync(); + var selectedText = await CaptureSelectionForTransformAsync(_textInsertion, processName); if (string.IsNullOrWhiteSpace(selectedText)) { await ShowWarningAsync("Select text before using Transform Selection."); return; } - _audio.WhisperModeEnabled = _settings.Current.WhisperModeEnabled; + AudioRecordingService.AudioCaptureSession? captureSession; try { - _audio.StartRecording(); + captureSession = _audio.TryStartRecording(_settings.Current.WhisperModeEnabled); } catch (Exception ex) { @@ -125,13 +185,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 { @@ -143,7 +209,7 @@ state with StatusText = Localization.Loc.Instance["Overlay.TransformPrompt"], PartialText = selectedText, ActiveAppName = string.IsNullOrWhiteSpace(processName) ? windowTitle : processName, - SessionStartedAtUtc = DateTime.UtcNow + SessionStartedAtUtc = DateTime.UtcNow, } ); } @@ -169,14 +235,14 @@ state with ActiveAppName = string.IsNullOrWhiteSpace(session.ProcessName) ? session.WindowTitle : session.ProcessName, - SessionStartedAtUtc = null + SessionStartedAtUtc = null, } ); byte[] wav; try { - wav = await _audio.StopRecordingAsync(); + wav = await _audio.StopRecordingAsync(session.CaptureSession); } catch (Exception ex) { @@ -259,6 +325,21 @@ state with return; } + var currentWindowId = _activeWindow.GetActiveWindowId(); + var currentProcessName = _activeWindow.GetActiveWindowProcessName(); + if ( + HasSelectionTargetChanged( + session.WindowId, + session.ProcessName, + currentWindowId, + currentProcessName + ) + ) + { + await AbortReplacementAsync(transformed); + return; + } + PublishStatus("Replacing selected text..."); var insertion = await _textInsertion.InsertTextAsync( transformed, @@ -300,6 +381,22 @@ await ShowWarningAsync( } } + private async Task AbortReplacementAsync(string transformed) + { + var insertion = await DeliverAbortedTransformAsync(_textInsertion, transformed); + var message = insertion switch + { + InsertionResult.CopiedToClipboard => + "Focus changed while transforming — the original selection was left alone. " + + "Transformed text copied; paste manually to replace it.", + InsertionResult.MissingClipboardTool => ClipboardToolMissingMessage(), + _ => + "Focus changed while transforming, and the transformed text could not be copied. " + + "The original selection was left alone.", + }; + await ShowWarningAsync(message); + } + private async Task ShowWarningAsync(string message) { ShowFeedback(message, true); @@ -320,7 +417,7 @@ state with ShowFeedback = false, FeedbackText = null, IsRecording = false, - SessionStartedAtUtc = null + SessionStartedAtUtc = null, } ); } @@ -333,7 +430,7 @@ private void ShowFeedback(string message, bool isError) ShowFeedback = true, FeedbackIsError = isError, FeedbackText = message, - StatusText = message + StatusText = message, }); } @@ -354,6 +451,7 @@ private sealed record TransformSelectionSession( string SelectedText, string? WindowId, string? ProcessName, - string? WindowTitle + string? WindowTitle, + AudioRecordingService.AudioCaptureSession CaptureSession ); -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Services/TranslationService.cs b/src/TypeWhisper.Linux/Services/TranslationService.cs index c534cdf66..d97a3a0a5 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 ) @@ -101,8 +101,10 @@ string userPrompt } var providerId = provider.GetLlmSelectionId(); - var plugin = _pluginManager.GetPlugin(providerId); - var ranLocally = plugin is not null && PluginLocalityClassifier.IsLocal(plugin.Manifest); + // 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 { @@ -113,7 +115,7 @@ string userPrompt ProviderId = providerId, ModelId = modelId, RanLocally = ranLocally, - InjectedMemoryContext = null + InjectedMemoryContext = null, }; capture.Add(provenance); return provenance; @@ -263,7 +265,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 +301,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 +323,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 +385,7 @@ private static void RegisterOnnxRuntimeResolver() var rid = RuntimeInformation.ProcessArchitecture switch { Architecture.Arm64 => "linux-arm64", - _ => "linux-x64" + _ => "linux-x64", }; var candidate = Path.Join( @@ -404,4 +406,4 @@ internal sealed record LoadedTranslationModel( InferenceSession Decoder, MarianTokenizer Tokenizer, MarianConfig Config -); \ No newline at end of file +); diff --git a/src/TypeWhisper.Linux/Services/TrayIconService.cs b/src/TypeWhisper.Linux/Services/TrayIconService.cs index 5acab0070..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() @@ -58,7 +67,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 +119,7 @@ internal bool ProbeTrayAvailable() "--method", "org.freedesktop.DBus.Properties.Get", "org.kde.StatusNotifierWatcher", - "IsStatusNotifierHostRegistered" + "IsStatusNotifierHostRegistered", ], timeout: TimeSpan.FromSeconds(2) ) @@ -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/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/Services/UpdateCheckService.cs b/src/TypeWhisper.Linux/Services/UpdateCheckService.cs index f022328df..56b04dc0f 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, }; } @@ -174,14 +174,26 @@ 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 - } - ); + 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); @@ -201,7 +213,16 @@ public void DismissUpdate(string? version) return; } - _prefs.Save(_prefs.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); @@ -295,4 +316,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/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..bddfec55b 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; @@ -7,7 +8,7 @@ public enum WatchFolderOutputFormat Markdown, PlainText, Srt, - Vtt + Vtt, } public sealed record WatchFolderOptions( @@ -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 @@ -68,7 +80,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/Services/WatchFolderService.cs b/src/TypeWhisper.Linux/Services/WatchFolderService.cs index 4c93e9f13..56e5ba75c 100644 --- a/src/TypeWhisper.Linux/Services/WatchFolderService.cs +++ b/src/TypeWhisper.Linux/Services/WatchFolderService.cs @@ -6,44 +6,46 @@ namespace TypeWhisper.Linux.Services; -public sealed class WatchFolderService : IDisposable +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() { - WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true + WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, }; - private readonly ConcurrentDictionary _activeFiles = new( - StringComparer.OrdinalIgnoreCase + private readonly ConcurrentDictionary _activeFiles = new( + StringComparer.Ordinal ); - 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 Action _atomicWriteAllText; private readonly Lock _persistenceGate = new(); - private readonly HashSet _processedFingerprints = new(StringComparer.OrdinalIgnoreCase); - + private readonly Func _readinessRetryDelay; + private readonly HashSet _processedFingerprints = new(StringComparer.Ordinal); + private readonly string _processedFingerprintsBackupPath; 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,18 +53,108 @@ public WatchFolderService() } internal WatchFolderService(string dataPath) + : this( + dataPath, + static (workers, timeout) => workers.WaitAsync(timeout), + 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 + ) + { + } + + internal WatchFolderService( + string dataPath, + Func waitForWorkers + ) + : this( + dataPath, + waitForWorkers, + AtomicFileWrite.WriteAllText, + static (delay, ct) => Task.Delay(delay, ct) + ) + { + } + + 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"; _historyPath = Path.Join(dataPath, "watch-folder-history.json"); LoadProcessedFingerprints(); LoadHistory(); } // 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 + { + get + { + var run = _currentRun; + return run is not null + && run.WorkerFailure is null + && !run.CancellationSource.IsCancellationRequested; + } + } + + internal WatchFolderRun? CurrentRun => _currentRun; public IReadOnlyList History { @@ -77,13 +169,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 +186,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 +244,185 @@ 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; + } + + // 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 = ObserveWorkerAsync(run, queueWorker, "queue"); + // 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)); + var observedRescanWorker = ObserveWorkerAsync(run, rescanWorker, "rescan"); + run.SetWorkers(observedQueueWorker, observedRescanWorker); + + 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 + { + // 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) + { + 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 void OnFileRenamed(object sender, RenamedEventArgs e) + private static async Task ObserveRetiredRunAsync(WatchFolderRun run) { - TryScanEventFolder(e.FullPath); + try + { + await run.WorkerCompletion.ConfigureAwait(false); + } + catch (Exception ex) + { + Debug.WriteLine($"Retired WatchFolder worker stopped with an error: {ex}"); + } + finally + { + run.DisposeCancellationSource(); + } } - private void TryScanEventFolder(string filePath) + private async Task DisposeAsyncCore() { + await _lifecycleGate.WaitAsync().ConfigureAwait(false); + try + { + if (_disposed) + { + return; + } + + _disposed = true; + await StopCoreAsync().ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + } + + 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 +431,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 +447,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 +461,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 +475,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 (!run.QueuedFiles.TryAdd(fullPath, 0)) { return; } - if (!_queuedFiles.TryAdd(fullPath, 0)) + if (!IsRunCurrentAndLive(run)) { + run.QueuedFiles.TryRemove(fullPath, out _); return; } - _pendingFiles.Enqueue(fullPath); + 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 +513,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 +525,95 @@ private async Task ProcessQueueAsync(CancellationToken ct) } } - private async Task RescanLoopAsync(string folderPath, CancellationToken ct) + private async Task ObserveWorkerAsync(WatchFolderRun run, Task worker, string workerName) + { + try + { + await worker.ConfigureAwait(false); + } + catch (Exception ex) + { + Debug.WriteLine($"WatchFolder {workerName} 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 {workerName} 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; while (!ct.IsCancellationRequested) { try { await Task.Delay(TimeSpan.FromSeconds(5), ct); - ScanFolder(folderPath); + ScanFolder(run, run.Options.WatchPath); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -298,59 +626,83 @@ 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(); + string? committedOutputPath = null; + 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 TranscribeWithReadinessRetryAsync(run, 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), artifact, ct ); + committedOutputPath = outputPath; 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 +715,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(), @@ -372,11 +731,11 @@ private async Task ProcessFileAsync(string filePath, CancellationToken ct) ProcessedAtUtc = DateTime.UtcNow, OutputPath = outputPath, Success = true, - ErrorMessage = sourceDeletionError + ErrorMessage = sourceDeletionError, } ); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } @@ -387,28 +746,63 @@ 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(), FileName = fileName, ProcessedAtUtc = DateTime.UtcNow, - OutputPath = "", + // A failure after CommitExport still left the transcript on disk — record + // where, or the user can't find it once DeleteSource removed the original. + OutputPath = committedOutputPath ?? "", Success = false, - ErrorMessage = ex.Message + ErrorMessage = ex.Message, } ); } finally { - _activeFiles.TryRemove(filePath, out _); - CurrentlyProcessing = null; - OnStateChanged(); + _activeFiles.TryRemove(new KeyValuePair(filePath, run)); + ClearCurrentlyProcessing(run); + } + } + + 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(); + } } } @@ -469,37 +863,130 @@ private static string ResolveEngineName(WatchFolderTranscriptionResult result) return result.EngineId ?? result.ModelId ?? "Default"; } - private bool IsKnownFingerprint(string fingerprint) + private bool IsRunCurrentAndLive(WatchFolderRun run) { - lock (_persistenceGate) + 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)) { - return _processedFingerprints.Contains(fingerprint) - || _failedFingerprints.Contains(fingerprint); + OnStateChanged(); } } - private void AddProcessedFingerprint(string fingerprint) + 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) { - _failedFingerprints.Remove(fingerprint); - _processedFingerprints.Add(fingerprint); - SaveProcessedFingerprintsCore(); + if (_processedFingerprints.Contains(fingerprint)) + { + return true; + } + } + + lock (run.FailedFingerprintsGate) + { + return run.FailedFingerprints.Contains(fingerprint); } } - private void AddFailedFingerprint(string fingerprint) + private void AddProcessedFingerprint(WatchFolderRun run, string fingerprint) { + if (!IsRunCurrentAndLive(run)) + { + return; + } + lock (_persistenceGate) { - _failedFingerprints.Add(fingerprint); + if (!IsRunCurrentAndLive(run)) + { + return; + } + + 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); } } - private void AddHistory(WatchFolderHistoryItem item) + private static void AddFailedFingerprint(WatchFolderRun run, string fingerprint) + { + lock (run.FailedFingerprintsGate) + { + run.FailedFingerprints.Add(fingerprint); + } + } + + 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 +995,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) @@ -592,42 +1086,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; + } + + 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; + } - foreach (var fingerprint in loaded) + 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 + ); } } @@ -681,7 +1269,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 +1299,72 @@ private void ThrowIfDisposed() { ObjectDisposedException.ThrowIf(_disposed, this); } + + internal sealed class WatchFolderRun + { + private int _cancellationSourceDisposed; + private Exception? _workerFailure; + + 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.Ordinal + ); + + internal Lock FailedFingerprintsGate { get; } = new(); + 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; + + internal void SetWorkers(Task queueWorker, Task rescanWorker) + { + WorkerCompletion = Task.WhenAll(queueWorker, rescanWorker); + } + + 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) + { + CancellationSource.Dispose(); + } + } + } } diff --git a/src/TypeWhisper.Linux/Services/WaylandSessionDetector.cs b/src/TypeWhisper.Linux/Services/WaylandSessionDetector.cs new file mode 100644 index 000000000..f8aa0b9a7 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/WaylandSessionDetector.cs @@ -0,0 +1,16 @@ +namespace TypeWhisper.Linux.Services; + +/// +/// Single source of truth for "is this session Wayland?" (setup, +/// backend selection, and the Shortcuts UI used to disagree by reading different +/// env vars). The runtime signal is a nonempty WAYLAND_DISPLAY — the actual +/// Wayland display connection the process would use — not XDG_SESSION_TYPE, +/// which some manually launched or minimal compositors never set. +/// +public static class WaylandSessionDetector +{ + public static bool IsWaylandSession() + { + return Environment.GetEnvironmentVariable("WAYLAND_DISPLAY") is { Length: > 0 }; + } +} diff --git a/src/TypeWhisper.Linux/Services/XdgPaths.cs b/src/TypeWhisper.Linux/Services/XdgPaths.cs new file mode 100644 index 000000000..cb8134c44 --- /dev/null +++ b/src/TypeWhisper.Linux/Services/XdgPaths.cs @@ -0,0 +1,29 @@ +namespace TypeWhisper.Linux.Services; + +/// +/// Resolution of the XDG base directories the app writes user data into. +/// +internal static class XdgPaths +{ + /// + /// $XDG_DATA_HOME, falling back to ~/.local/share when it is unset + /// or relative. The spec treats a relative value as invalid, and honouring one + /// would resolve writes against the CWD, where the session never looks. + /// + internal static string ResolveDataHome() + { + var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + if (!string.IsNullOrEmpty(xdg) && Path.IsPathRooted(xdg)) + { + return xdg; + } + + // DoNotVerify: the default option returns an empty string for a HOME that is not + // on disk, which would make this fallback relative and defeat the check above. + var home = Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile, + Environment.SpecialFolderOption.DoNotVerify + ); + return Path.Join(home, ".local", "share"); + } +} diff --git a/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj b/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj index 118ef7845..df2c9c729 100644 --- a/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj +++ b/src/TypeWhisper.Linux/TypeWhisper.Linux.csproj @@ -18,6 +18,10 @@ + + + + @@ -48,7 +52,6 @@ All - @@ -59,7 +62,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/DictationOverlayViewModel.cs b/src/TypeWhisper.Linux/ViewModels/DictationOverlayViewModel.cs index 7acd1db95..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) @@ -323,11 +381,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 50f5bd9dc..581b23829 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AboutSectionViewModel.cs @@ -17,9 +17,8 @@ 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 TimeZoneInfo _timeZone; private readonly UpdateCheckService _updateCheck; [ObservableProperty] @@ -50,17 +49,22 @@ [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanCheckForUpdates))] public AboutSectionViewModel( IErrorLogService errorLog, - ISettingsService settings, - LinuxPreferencesService linuxPreferences, SettingsBackupService settingsBackup, UpdateCheckService updateCheck + ) + : this(errorLog, settingsBackup, updateCheck, TimeZoneInfo.Local) { } + + internal AboutSectionViewModel( + IErrorLogService errorLog, + SettingsBackupService settingsBackup, + UpdateCheckService updateCheck, + TimeZoneInfo timeZone ) { _errorLog = errorLog; - _settings = settings; - _linuxPreferences = linuxPreferences; _settingsBackup = settingsBackup; _updateCheck = updateCheck; + _timeZone = timeZone; RefreshErrors(); // EntriesChanged fires synchronously on whichever thread called AddEntry — // and producers now log from background threads (transcription, detection, @@ -104,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; } = []; @@ -156,13 +160,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 @@ -272,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(); @@ -292,7 +297,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)))); @@ -358,4 +363,18 @@ public override string ToString() return Display; } } -} \ No newline at end of file +} + +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/AdvancedSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs index 88ade66c4..8e8d4ddbd 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AdvancedSectionViewModel.cs @@ -1,3 +1,6 @@ +// 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; using TypeWhisper.Core.Interfaces; @@ -12,8 +15,17 @@ 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 bool _configuredMemoryEnabled; + private bool _configuredSpokenFeedbackEnabled; + private string _configuredSpokenFeedbackProviderId = + AppSettings.DefaultSpokenFeedbackProviderId; + private string? _configuredSpokenFeedbackVoiceId = + SpeechFeedbackService.DefaultVoiceOptionId; + private bool _isProgrammaticRefresh; + private long _refreshGeneration; [ObservableProperty] private bool _captureLlmProvenance; @@ -43,29 +55,65 @@ 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(); - Refresh(settings.Current); + _post = post; RefreshSpokenFeedbackProviders(); - _settings.SettingsChanged += Refresh; - _pluginManager.PluginStateChanged += (_, _) => + Refresh(settings.Current); + + // Subscribe only once hydration has run: RefreshSpokenFeedbackProviders falls back to the + // default provider when the selected one is absent, and firing that against an un-hydrated + // selection would persist the default over the user's saved provider. + _speechFeedback.ProvidersChanged += (_, _) => PostPluginStateRefresh(); + + // SettingsChanged can fire off the UI thread (HTTP API, model manager), and + // Refresh mutates the provider/voice collections. + _settings.SettingsChanged += changed => _post(() => Refresh(changed)); + _pluginManager.PluginStateChanged += (_, _) => PostPluginStateRefresh(); + Loc.Instance.LanguageChanged += OnInterfaceLanguageChanged; + } + + private void PostPluginStateRefresh() + { + var generation = Interlocked.Increment(ref _refreshGeneration); + _post(() => { - OnPropertyChanged(nameof(CanUseMemory)); - OnPropertyChanged(nameof(ShowMemoryUnavailableReason)); - OnPropertyChanged(nameof(MemoryHint)); - OnPropertyChanged(nameof(CanUseSpokenFeedback)); - OnPropertyChanged(nameof(ShowSpokenFeedbackUnavailableReason)); - OnPropertyChanged(nameof(SpokenFeedbackHint)); - RefreshSpokenFeedbackProviders(); - if (!CanUseMemory && MemoryEnabled) + if (generation != Interlocked.Read(ref _refreshGeneration)) { - MemoryEnabled = false; + return; } - }; + + RunProgrammaticRefresh(() => + { + 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; + }); + }); } public ObservableCollection SpokenFeedbackProviders { get; } = []; @@ -121,24 +169,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; @@ -170,46 +205,62 @@ 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 }); } 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; } @@ -219,56 +270,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) @@ -293,7 +365,7 @@ partial void OnCaptureLlmProvenanceChanged(bool value) partial void OnSelectedHistoryRetentionChanged(HistoryRetentionOption? value) { - if (value is null) + if (_isProgrammaticRefresh || value is null) { return; } @@ -314,11 +386,63 @@ _settings.Current with { HistoryRetentionMode = value.Mode, HistoryRetentionMinutes = - value.Minutes ?? _settings.Current.HistoryRetentionMinutes + value.Minutes ?? _settings.Current.HistoryRetentionMinutes, } ); } + 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. @@ -337,29 +461,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) @@ -384,4 +565,4 @@ public sealed record HistoryRetentionOption( HistoryRetentionMode Mode, int? Minutes, string DisplayName -); \ No newline at end of file +); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/AppearanceSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/AppearanceSectionViewModel.cs index c9d406c1c..feb2d12d1 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/AppearanceSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/AppearanceSectionViewModel.cs @@ -1,3 +1,4 @@ +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using TypeWhisper.Core.Interfaces; @@ -10,8 +11,14 @@ namespace TypeWhisper.Linux.ViewModels.Sections; public partial class AppearanceSectionViewModel : ObservableObject { + private readonly Action _post; private readonly ISettingsService _settings; + // Set while Refresh applies persisted state so the generated OnChanged hooks don't + // write it straight back: their equality guards compare against _settings.Current, which a + // queued refresh has already fallen behind, so a stale value would overwrite the newer commit. + private bool _hydratingFromSettings; + [ObservableProperty] private double _previewBubbleAutoHideSeconds = AppSettings.DefaultPreviewBubbleAutoHideMilliseconds / 1000d; @@ -33,11 +40,15 @@ public partial class AppearanceSectionViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(PreviewRightText))] private OverlayWidgetOption? _selectedRightWidget; - public AppearanceSectionViewModel(ISettingsService settings) + // post marshals refreshes onto the UI thread; it is injected rather than calling + // Dispatcher.UIThread directly because that dispatcher binds to whichever thread touches it + // first and nothing pumps it under the test runner, so tests pass a synchronous one. + public AppearanceSectionViewModel(ISettingsService settings, Action? post = null) { _settings = settings; + _post = post ?? PostToUiThread; Refresh(settings.Current); - _settings.SettingsChanged += Refresh; + _settings.SettingsChanged += OnSettingsChanged; // Option labels and the localized status/preview getters are resolved into // strings, so re-resolve them when the UI language changes at runtime. Loc.Instance.LanguageChanged += OnLanguageChanged; @@ -61,7 +72,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 +84,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 => @@ -133,7 +144,46 @@ private void OnLanguageChanged(object? sender, EventArgs e) OnPropertyChanged(nameof(PreviewRightText)); } + // Saves happen on whichever thread called them — the dictation path and the model-storage + // migration both save off the UI thread — and Refresh writes bound properties. + private void OnSettingsChanged(AppSettings settings) + { + // Read Current when the post runs rather than capturing the payload, so queued + // refreshes coalesce onto the newest commit instead of replaying superseded ones. + _post(() => Refresh(_settings.Current)); + } + + private static void PostToUiThread(Action action) + { + // Inline when already on the UI thread, so a save from the UI keeps refreshing + // synchronously rather than deferring to the next dispatcher turn. + if (Dispatcher.UIThread.CheckAccess()) + { + action(); + } + else + { + Dispatcher.UIThread.Post(action); + } + } + private void Refresh(AppSettings settings) + { + // Restore rather than clear: a nested Refresh must not un-guard the remainder + // of the outer one, which would let it write its older snapshot back. + var wasHydrating = _hydratingFromSettings; + _hydratingFromSettings = true; + try + { + ApplySettings(settings); + } + finally + { + _hydratingFromSettings = wasHydrating; + } + } + + private void ApplySettings(AppSettings settings) { SelectedOverlayPosition = OverlayPositions.FirstOrDefault(option => option.Value == settings.OverlayPosition) @@ -181,16 +231,16 @@ 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"], - _ => "" + _ => "", }; } partial void OnSelectedOverlayPositionChanged(OverlayPositionOption? value) { - if (value is null || _settings.Current.OverlayPosition == value.Value) + if (_hydratingFromSettings || value is null || _settings.Current.OverlayPosition == value.Value) { return; } @@ -200,7 +250,7 @@ partial void OnSelectedOverlayPositionChanged(OverlayPositionOption? value) partial void OnSelectedLeftWidgetChanged(OverlayWidgetOption? value) { - if (value is null || _settings.Current.OverlayLeftWidget == value.Value) + if (_hydratingFromSettings || value is null || _settings.Current.OverlayLeftWidget == value.Value) { return; } @@ -210,7 +260,7 @@ partial void OnSelectedLeftWidgetChanged(OverlayWidgetOption? value) partial void OnSelectedRightWidgetChanged(OverlayWidgetOption? value) { - if (value is null || _settings.Current.OverlayRightWidget == value.Value) + if (_hydratingFromSettings || value is null || _settings.Current.OverlayRightWidget == value.Value) { return; } @@ -225,7 +275,8 @@ partial void OnPreviewBubbleAutoHideSecondsChanged(double value) var milliseconds = AppSettings.NormalizePreviewBubbleAutoHideMilliseconds( (int)Math.Round(value * 1000, MidpointRounding.AwayFromZero)); - if (_settings.Current.PreviewBubbleAutoHideMilliseconds == milliseconds) + if (_hydratingFromSettings + || _settings.Current.PreviewBubbleAutoHideMilliseconds == milliseconds) { return; } diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DashboardSectionViewModel.cs index 9060fdc29..8ae98b949 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; @@ -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; @@ -168,7 +178,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(); @@ -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/DictationSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/DictationSectionViewModel.cs index 1c16dd8f4..1f8fdb872 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; @@ -21,8 +23,10 @@ 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 IErrorLogService? _errorLog; private readonly ModelManagerService _models; private readonly PluginManager _pluginManager; private readonly ISettingsService _settings; @@ -151,6 +155,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"]; @@ -170,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; @@ -212,12 +218,41 @@ 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, + models, + audio, + settings, + pluginManager, + commands, + a11yBus, + AudioRecordingService.GetInputDevices, + errorLog + ) + { + } + + 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, + IErrorLogService? errorLog = null ) { _dictation = dictation; _models = models; _audio = audio; + _errorLog = errorLog; + _getInputDevices = getInputDevices; _settings = settings; _pluginManager = pluginManager; _commands = commands; @@ -271,52 +306,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; } = []; @@ -402,7 +410,7 @@ IAccessibilityBusActivation a11yBus // 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 @@ -457,7 +465,7 @@ public string AccelerationStatusText : Loc.Instance["Dictation.AccelCudaNotVisible"], AppSettings.LocalModelAccelerationNvidiaCuda => Loc.Instance["Dictation.AccelCudaReady"], - _ => Loc.Instance["Dictation.AccelAutoStatus"] + _ => Loc.Instance["Dictation.AccelAutoStatus"], }; } @@ -646,11 +654,27 @@ private void RefreshDevices() Loc.Instance["Dictation.FollowSystemDefaultMic"] ) ); - foreach (var d in AudioRecordingService.GetInputDevices()) + foreach (var d in _getInputDevices()) { 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 @@ -768,6 +792,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 ) @@ -854,7 +980,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)); @@ -879,6 +1005,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) @@ -974,7 +1105,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) @@ -1367,7 +1498,7 @@ partial void OnSelectedDeviceChanged(AudioInputDevice? value) _settings.Current with { SelectedMicrophoneDevice = null, - SelectedMicrophoneDeviceId = AppSettings.FollowSystemDefaultMicrophoneId + SelectedMicrophoneDeviceId = AppSettings.FollowSystemDefaultMicrophoneId, } ); return; @@ -1378,13 +1509,18 @@ _settings.Current with _settings.Save( _settings.Current with { - SelectedMicrophoneDevice = value.Index, SelectedMicrophoneDeviceId = value.PersistentId + SelectedMicrophoneDevice = value.Index, SelectedMicrophoneDeviceId = value.PersistentId, } ); } partial void OnLanguageChanged(string value) { + if (_isLocalizedOptionRefresh) + { + return; + } + _settings.Save(_settings.Current with { Language = value }); OnPropertyChanged(nameof(SelectedLanguageOption)); } @@ -1397,6 +1533,11 @@ partial void OnTranslationTargetLanguageChanged(string? value) partial void OnCleanupLevelChanged(CleanupLevel value) { + if (_isLocalizedOptionRefresh) + { + return; + } + _settings.Save(_settings.Current with { CleanupLevel = value }); OnPropertyChanged(nameof(SelectedCleanupLevelOption)); } @@ -1496,6 +1637,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 @@ -1601,6 +1753,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)) @@ -1694,7 +1851,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)); @@ -1795,4 +1952,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/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..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; @@ -723,7 +760,7 @@ _settings.Current with FileTranscriptionEngineOverride = CleanSettingValue( FileTranscriptionEngineOverride ), - FileTranscriptionModelOverride = CleanSettingValue(FileTranscriptionModelOverride) + FileTranscriptionModelOverride = CleanSettingValue(FileTranscriptionModelOverride), } ); } @@ -747,7 +784,7 @@ _settings.Current with WatchFolderDeleteSource = WatchFolderDeleteSource, WatchFolderLanguage = string.IsNullOrWhiteSpace(WatchFolderLanguage) ? "auto" - : WatchFolderLanguage + : WatchFolderLanguage, } ); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/GeneralSectionViewModel.cs index f737d538e..efeabe59a 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; @@ -15,6 +16,13 @@ public partial class GeneralSectionViewModel : ObservableObject private readonly LinuxPreferencesService _linuxPrefs; private readonly ISettingsService _settings; private readonly TrayIconService _tray; + private bool _updatingStartWithSystem; + private bool _autostartStatusIsHint = true; + + // 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 = ""; @@ -46,6 +54,9 @@ public partial class GeneralSectionViewModel : ObservableObject [ObservableProperty] private bool _startWithSystem; + [ObservableProperty] + private string _autostartStatusText = Loc.Instance["General.AutostartHint"]; + [ObservableProperty] private string? _uiLanguage; @@ -63,10 +74,23 @@ TrayIconService tray _linuxPrefs = linuxPrefs; _tray = tray; Refresh(settings.Current); - StartWithSystem = StartupService.IsEnabled; + _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)); + Loc.Instance.LanguageChanged += (_, _) => + { + if (_autostartStatusIsHint) + { + AutostartStatusText = Loc.Instance["General.AutostartHint"]; + } + }; + _api.StateChanged += () => + Dispatcher.UIThread.Post(() => ApiStatusText = _api.StatusText); ApiStatusText = _api.StatusText; RefreshCliState(); } @@ -112,12 +136,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] @@ -127,14 +159,19 @@ private void RefreshCliState() } [RelayCommand] - private void InstallCli() + private async Task InstallCliAsync() { try { - ApplyCliState(_cliInstall.Install()); + // Install copies the ~17 MB CLI and runs it once to verify, on a 10-second + // deadline — far too long to hold the UI thread. + ApplyCliState(await Task.Run(_cliInstall.Install)); } catch (Exception ex) - when (ex is InvalidOperationException or IOException or UnauthorizedAccessException) + when (ex is InvalidOperationException + or IOException + or UnauthorizedAccessException + or TimeoutException) { CliStatusText = ex.Message; } @@ -171,31 +208,47 @@ 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)); } partial void OnStartWithSystemChanged(bool value) { - if (value == StartupService.IsEnabled) + if (_updatingStartWithSystem) { return; } - if (value) + _updatingStartWithSystem = true; + try { - StartupService.Enable(); + var result = value ? StartupService.Enable() : StartupService.Disable(); + AutostartStatusText = result.StatusText; + _autostartStatusIsHint = result.Success; + StartWithSystem = result.IsEnabled; } - else + catch (Exception ex) + when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { - StartupService.Disable(); + AutostartStatusText = ex.Message; + _autostartStatusIsHint = false; + StartWithSystem = StartupService.IsEnabled; + } + finally + { + _updatingStartWithSystem = false; } } partial void OnApiServerEnabledChanged(bool value) { - if (_settings.Current.ApiServerEnabled == value) + if (_hydratingFromSettings || _settings.Current.ApiServerEnabled == value) { return; } @@ -205,7 +258,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; } @@ -220,8 +276,24 @@ partial void OnCloseToTrayChanged(bool value) return; } - _linuxPrefs.Save(_linuxPrefs.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; + } } } -public sealed record CommandExample(string Command); \ No newline at end of file +public sealed record CommandExample(string Command); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/HistorySectionViewModel.cs index 413898cf3..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 += () => { @@ -93,7 +132,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 +232,7 @@ internal void AddTermFromHistory(HistoryRecordRow record) Id = Guid.NewGuid().ToString(), EntryType = DictionaryEntryType.Term, Original = term, - Source = DictionaryEntrySource.Manual + Source = DictionaryEntrySource.Manual, } ); } @@ -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)); } @@ -718,7 +768,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}"; @@ -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/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..8996fb8b9 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,56 +17,13 @@ namespace TypeWhisper.Linux.ViewModels.Sections; public partial class PluginsSectionViewModel : ObservableObject { - 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 static readonly TimeSpan s_defaultPluginBoundaryTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan s_defaultPluginValidationTimeout = TimeSpan.FromMinutes(10); private readonly IErrorLogService? _errorLog; private readonly Dictionary _pluginById = []; + private readonly TimeSpan _pluginBoundaryTimeout; + private readonly TimeSpan _pluginValidationTimeout; private readonly PluginManager _pluginManager; [ObservableProperty] @@ -75,11 +33,39 @@ 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(); + RebuildPluginRows(PluginListRefreshKind.Initial); } public ObservableCollection PluginGroups { get; } = []; @@ -96,48 +82,145 @@ 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; + .DistinctBy(plugin => plugin.Id) + .ToDictionary(plugin => plugin.Id, StringComparer.Ordinal); + var expandedPluginId = existingRows.Values.FirstOrDefault(plugin => plugin.IsExpanded)?.Id; 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; + + 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 { - _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) - ); - }) + plugin.Metadata, + hasExpandableSettings || settingsDefinitionFailed, + _pluginManager.IsEnabled(plugin.Manifest.Id) + ) { LoadedPlugin = plugin }; + + 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 ?? "", + plugin.Metadata, + plugin.Instance is IPluginSettingsProvider + or IPluginCollectionSettingsProvider, + _pluginManager.IsEnabled(plugin.Manifest.Id) + ) { LoadedPlugin = plugin }; + MarkSettingsLoadFailed(row); + plugins.Add(row); + } + } + + plugins = plugins .OrderBy(p => p.CategorySortOrder) .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)); } @@ -174,7 +257,12 @@ p.Instance is IPluginCollectionSettingsProvider cp } expandedPlugin.IsExpanded = true; - _ = LoadPluginSettingsAsync(expandedPlugin); + BeginObservedSettingsLoad( + expandedPlugin, + refreshKind == PluginListRefreshKind.Ambient + ? SettingsReloadKind.PreserveDraft + : SettingsReloadKind.ResetBaseline + ); } [RelayCommand] @@ -205,7 +293,7 @@ private async Task ToggleExpandedAsync(PluginRow row) } row.IsExpanded = true; - await LoadPluginSettingsAsync(row); + await LoadPluginSettingsAsync(row, SettingsReloadKind.ResetBaseline); } [RelayCommand] @@ -238,33 +326,41 @@ 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); + await ReloadCurrentVisibleRowAsync(row, loaded, true); return; } - if (result.IsSuccess) + if (setResult.Value.IsSuccess) { continue; } - row.Status = result.Message; + row.Status = setResult.Value.Message; return; } } row.Status = Loc.Instance["Plugins.SettingsSaved"]; + await ReloadCurrentVisibleRowAsync(row, loaded, true); } [RelayCommand] @@ -285,9 +381,21 @@ private async Task ValidateSettingsAsync(PluginRow row) return; } - var result = await provider.ValidateAsync(); - row.Status = result?.Message ?? Loc.Instance["Plugins.NoValidationAvailable"]; - await LoadPluginSettingsAsync(row, true); + 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 ReloadCurrentVisibleRowAsync(row, loaded, true); } private async Task TrySaveFlatSettingsAsync( @@ -298,18 +406,21 @@ 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; + } + ); + // ReSharper disable once InvertIf -- guard clause; inverting would bury the failure path. + 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); + await ReloadCurrentVisibleRowAsync(row, loaded, true); return false; } } @@ -317,8 +428,54 @@ IPluginSettingsProvider provider 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; @@ -327,6 +484,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; } @@ -336,35 +494,103 @@ private async Task LoadPluginSettingsAsync(PluginRow row, bool preserveStatus = if (flatProvider is null && collectionProvider is null) { row.Status = Loc.Instance["Plugins.NoHostNeutralSettings"]; + row.CaptureSettingsBaseline(); return; } 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; } } @@ -380,94 +606,204 @@ private async Task LoadPluginSettingsAsync(PluginRow row, bool preserveStatus = ? Loc.Instance["Plugins.EditValuesHint"] : Loc.Instance["Plugins.NoEditableFields"]; } + + row.CaptureSettingsBaseline(); } - // 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 - // entry (third-party plugins, or keys not yet translated). PluginLocalization - // returns the key itself on a miss, so an unchanged key signals "no entry". - private static string LocalizeManifest(PluginLocalization loc, string key, string fallback) + private void BeginObservedSettingsLoad(PluginRow row, SettingsReloadKind reloadKind) { - var localized = loc.GetString(key); - return string.Equals(localized, key, StringComparison.Ordinal) ? fallback : localized; + var loadTask = ObserveSettingsLoadAsync(row, reloadKind); + _ = 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 + ); } - // 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) + private async Task ObserveSettingsLoadAsync( + PluginRow row, + SettingsReloadKind reloadKind + ) { - if (!string.IsNullOrWhiteSpace(manifest.Category)) + try { - return manifest.Category; + await LoadPluginSettingsAsync(row, reloadKind); } - - var id = manifest.Id.Trim().ToLowerInvariant(); - if (s_transcriptionPluginIds.Contains(id)) + catch (Exception ex) { - return "transcription"; - } + 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}" + ); + } - if (s_llmPluginIds.Contains(id)) - { - return "llm"; + MarkSettingsLoadFailed(row); } + } - if (s_actionPluginIds.Contains(id)) - { - return "action"; - } + private static void MarkSettingsLoadFailed(PluginRow row, bool preserveStatus = false) + { + row.SettingFields.Clear(); + row.Collections.Clear(); + row.CanEditSettings = false; + row.CanValidateSettings = false; + row.CaptureSettingsBaseline(); - if (s_memoryPluginIds.Contains(id)) + if (!preserveStatus) { - return "memory"; + row.Status = Loc.Instance["Plugins.UnableToLoadSettings"]; } + } - if (s_utilityPluginIds.Contains(id)) - { - return "utility"; - } + private PluginBoundaryResult TryInvokePluginBoundary( + LoadedPlugin plugin, + string operation, + Func boundary + ) + { + return TryInvokePluginBoundaryAsync( + plugin, + operation, + _ => Task.FromResult(boundary()) + ) + .GetAwaiter() + .GetResult(); + } - 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"; - } + 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 ( - combined.Contains("llm") - || combined.Contains("prompt") - || combined.Contains("inference") - || combined.Contains("multi-model") - ) + if (completedTask != boundaryTask) { - return "llm"; + 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; } - if (combined.Contains("memory")) + try { - return "memory"; + var value = await boundaryTask.ConfigureAwait(false); + return new PluginBoundaryResult(true, value); } - - if ( - combined.Contains("issue") - || combined.Contains("obsidian") - || combined.Contains("webhook") - || combined.Contains("script") - ) + catch (Exception ex) { - return "action"; + 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}"); + } - return "utility"; + 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); + } + + 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 + // entry (third-party plugins, or keys not yet translated). PluginLocalization + // returns the key itself on a miss, so an unchanged key signals "no entry". + private static string LocalizeManifest(PluginLocalization loc, string key, string fallback) + { + var localized = loc.GetString(key); + return string.Equals(localized, key, StringComparison.Ordinal) ? fallback : localized; } } @@ -485,6 +821,8 @@ public PluginCategoryGroup(string title, IEnumerable plugins) public partial class PluginRow : ObservableObject { + private PluginSettingsDraftEntry[]? _settingsBaseline; + [ObservableProperty] private bool _canEditSettings; @@ -506,8 +844,7 @@ public PluginRow( string name, string version, string description, - string? category, - bool isLocal, + PluginMetadataDescriptor metadata, bool hasExpandableSettings, bool isEnabled ) @@ -517,11 +854,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; @@ -534,14 +875,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"; @@ -561,6 +928,75 @@ 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(); + // ReSharper disable once LoopCanBeConvertedToQuery -- the loop index is captured into each entry; a query would need Select((_, i) => ...) and read worse. + 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]; + // ReSharper disable once LoopCanBeConvertedToQuery -- the loop index is captured into each entry; a query would need Select((_, i) => ...) and read worse. + 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) { @@ -574,21 +1010,47 @@ 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); 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; @@ -607,10 +1069,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; @@ -624,8 +1100,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; @@ -657,17 +1132,50 @@ bool isSecret partial void OnSelectedOptionChanged(PluginSettingOption? value) { - if (value is not null && _value != value.Value) + if (_syncingOptionValue) + { + 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 { - Value = value.Value; + _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; @@ -695,44 +1203,111 @@ 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); 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/Sections/ProfilesSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ProfilesSectionViewModel.cs index 63f30f4c0..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; @@ -29,6 +31,8 @@ public partial class ProfilesSectionViewModel : ObservableObject private readonly PluginManager _pluginManager; 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; @@ -70,6 +74,9 @@ public partial class ProfilesSectionViewModel : ObservableObject [ObservableProperty] private string? _editHotkeyData; + [ObservableProperty] + private string? _hotkeyValidationMessage; + [ObservableProperty] private bool _editIsEnabled = true; @@ -130,18 +137,22 @@ public ProfilesSectionViewModel( IActiveWindowService activeWindow, PluginManager pluginManager, IPromptActionService promptActions, + HotkeyService hotkeys, IDetectionFailureTracker failureTracker, GnomeWindowCallsSetupHelper gnomeSetup, - BrowserAccessibilitySetupHelper browserSetup + BrowserAccessibilitySetupHelper browserSetup, + UiOperationGuard uiOperations ) { _profiles = profiles; _activeWindow = activeWindow; _pluginManager = pluginManager; _promptActions = promptActions; + _hotkeys = hotkeys; _failureTracker = failureTracker; _gnomeSetup = gnomeSetup; _browserSetup = browserSetup; + _uiOperations = uiOperations; RefreshBrowserAccessibilityStatus(); _profiles.ProfilesChanged += () => Dispatcher.UIThread.Post(RefreshProfiles); @@ -181,6 +192,7 @@ BrowserAccessibilitySetupHelper browserSetup _windowTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _windowTimer.Tick += (_, _) => StartCurrentWindowUpdate(); + Loc.Instance.LanguageChanged += OnInterfaceLanguageChanged; } public ObservableCollection Profiles { get; } = []; @@ -192,31 +204,13 @@ BrowserAccessibilitySetupHelper browserSetup 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; } = []; @@ -317,12 +311,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 { @@ -474,6 +464,7 @@ public Task RefreshProviderModelsAsync() partial void OnSelectedProfileChanged(Profile? value) { + HotkeyValidationMessage = null; ProcessNameChips.Clear(); UrlPatternChips.Clear(); ProcessNameInput = ""; @@ -540,6 +531,7 @@ partial void OnEditModelIdChanged(string? value) partial void OnEditPromptActionIdChanged(string? value) { + HotkeyValidationMessage = null; OnPropertyChanged(nameof(SelectedPromptActionOption)); } @@ -550,11 +542,13 @@ partial void OnEditStylePresetChanged(ProfileStylePreset value) partial void OnEditHotkeyBehaviorChanged(ProfileHotkeyBehavior value) { + HotkeyValidationMessage = null; OnPropertyChanged(nameof(SelectedHotkeyBehaviorOption)); } partial void OnEditHotkeyDataChanged(string? value) { + HotkeyValidationMessage = null; // A hotkey turns an empty-matcher profile into a hotkey-only profile, // which is no longer the global fallback — refresh the editor hint. OnPropertyChanged(nameof(IsGlobalFallbackProfile)); @@ -583,19 +577,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] @@ -606,6 +609,33 @@ private void SaveProfile() return; } + var promptActionId = string.IsNullOrWhiteSpace(EditPromptActionId) + ? null + : EditPromptActionId.Trim(); + var hotkeyValidation = _hotkeys.ValidateProfileHotkeyCandidate( + EditHotkeyData, + EditHotkeyBehavior, + promptActionId, + SelectedProfile.Id, + _promptActions.Actions, + _profiles.Profiles + ); + if (!hotkeyValidation.IsValid) + { + HotkeyValidationMessage = hotkeyValidation.Status switch + { + HotkeyCandidateValidationStatus.Malformed => + Loc.Instance["Profiles.HotkeyMalformed"], + HotkeyCandidateValidationStatus.MissingEnabledPromptAction => + Loc.Instance["Profiles.HotkeyPromptActionRequired"], + _ => Loc.Instance["Profiles.HotkeyCollision"], + }; + return; + } + + EditHotkeyData = hotkeyValidation.NormalizedHotkey; + HotkeyValidationMessage = null; + var updated = SelectedProfile with { Name = EditName.Trim(), @@ -618,22 +648,29 @@ private void SaveProfile() TranscriptionModelOverride = string.IsNullOrWhiteSpace(EditModelId) ? null : EditModelId, - PromptActionId = string.IsNullOrWhiteSpace(EditPromptActionId) - ? null - : EditPromptActionId, - HotkeyData = string.IsNullOrWhiteSpace(EditHotkeyData) ? null : EditHotkeyData.Trim(), + PromptActionId = promptActionId, + HotkeyData = hotkeyValidation.NormalizedHotkey, HotkeyBehavior = EditHotkeyBehavior, StylePreset = EditStylePreset, CleanupLevelOverride = EditCleanupLevelOverride, DeveloperFormattingOverride = EditDeveloperFormattingOverride, Priority = EditPriority, - IsEnabled = EditIsEnabled + IsEnabled = EditIsEnabled, }; 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] @@ -652,12 +689,21 @@ 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); - RefreshProfiles(); - SelectById(duplicate.Id); + _uiOperations.Run( + "duplicate profile", + Loc.Instance["Common.Copy"], + UiFailureKind.FileSystem, + () => + { + _profiles.AddProfile(duplicate); + RefreshProfiles(); + SelectById(duplicate.Id); + }, + ResyncProfilesAfterFailure + ); } [RelayCommand] @@ -668,9 +714,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] @@ -681,8 +737,17 @@ private void ToggleProfileEnabled(Profile? profile) return; } - _profiles.UpdateProfile(profile with { IsEnabled = !profile.IsEnabled }); - RefreshProfiles(); + _uiOperations.Run( + "toggle profile", + Loc.Instance["Common.Enabled"], + UiFailureKind.FileSystem, + () => + { + _profiles.ToggleProfileEnabled(profile.Id); + RefreshProfiles(); + }, + ResyncProfilesAfterFailure + ); } [RelayCommand] @@ -884,6 +949,124 @@ 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 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/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs index 9c09168dd..eeda28a9f 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/PromptsSectionViewModel.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using TypeWhisper.Core.Interfaces; using TypeWhisper.Core.Models; +using TypeWhisper.Linux.Services; using TypeWhisper.Linux.Services.Localization; using TypeWhisper.Linux.Services.Plugins; using TypeWhisper.PluginSDK; @@ -16,9 +17,15 @@ namespace TypeWhisper.Linux.ViewModels.Sections; // ReSharper disable UnusedParameterInPartialMethod public partial class PromptsSectionViewModel : ObservableObject { + private readonly IErrorLogService? _errorLog; private readonly PluginManager _pluginManager; + private readonly IProfileService _profiles; private readonly IPromptActionService _prompts; private readonly ISettingsService _settings; + private readonly HotkeyService _hotkeys; + + [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. @@ -33,6 +40,9 @@ public partial class PromptsSectionViewModel : ObservableObject [ObservableProperty] private string? _editHotkeyKey; + [ObservableProperty] + private string? _hotkeyValidationMessage; + [ObservableProperty] private string _editIcon = "\u2728"; @@ -69,13 +79,19 @@ public partial class PromptsSectionViewModel : ObservableObject public PromptsSectionViewModel( IPromptActionService prompts, + IProfileService profiles, + HotkeyService hotkeys, PluginManager pluginManager, - ISettingsService settings + ISettingsService settings, + IErrorLogService? errorLog = null ) { _prompts = prompts; + _profiles = profiles; + _hotkeys = hotkeys; _pluginManager = pluginManager; _settings = settings; + _errorLog = errorLog; _prompts.ActionsChanged += () => Dispatcher.UIThread.Post(RefreshActions); _pluginManager.PluginStateChanged += (_, _) => @@ -107,6 +123,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")] @@ -249,6 +267,7 @@ partial void OnCommandKeyphraseChanged(string value) partial void OnSelectedActionChanged(PromptAction? value) { + HotkeyValidationMessage = null; if (value is null) { if (!IsCreatingNew) @@ -273,9 +292,15 @@ partial void OnSelectedActionChanged(PromptAction? value) NotifyStateChanged(); } + partial void OnEditHotkeyKeyChanged(string? value) + { + HotkeyValidationMessage = null; + } + [RelayCommand] private void StartCreate() { + HotkeyValidationMessage = null; IsCreatingNew = true; ShowEditor = true; SelectedAction = null; @@ -298,6 +323,26 @@ private void SaveAction() return; } + var hotkeyValidation = _hotkeys.ValidatePromptActionHotkeyCandidate( + EditHotkeyKey, + _editingActionId, + _prompts.Actions, + _profiles.Profiles + ); + if (!hotkeyValidation.IsValid) + { + HotkeyValidationMessage = hotkeyValidation.Status switch + { + HotkeyCandidateValidationStatus.Malformed => + Loc.Instance["Prompts.HotkeyMalformed"], + _ => Loc.Instance["Prompts.HotkeyCollision"], + }; + return; + } + + EditHotkeyKey = hotkeyValidation.NormalizedHotkey; + HotkeyValidationMessage = null; + if (IsCreatingNew) { var action = new PromptAction @@ -308,10 +353,10 @@ private void SaveAction() Icon = EditIcon, ProviderOverride = EditProviderOverride, TargetActionPluginId = EditTargetActionPluginId, - HotkeyKey = NormalizeOptionalString(EditHotkeyKey), + HotkeyKey = hotkeyValidation.NormalizedHotkey, IsManualOnly = EditIsManualOnly, IsEnabled = true, - SortOrder = _prompts.Actions.Count + SortOrder = _prompts.Actions.Count, }; if (!TryMutate(() => _prompts.AddAction(action), "add a prompt action")) @@ -346,8 +391,8 @@ existing with Icon = EditIcon, ProviderOverride = EditProviderOverride, TargetActionPluginId = EditTargetActionPluginId, - HotkeyKey = NormalizeOptionalString(EditHotkeyKey), - IsManualOnly = EditIsManualOnly + HotkeyKey = hotkeyValidation.NormalizedHotkey, + IsManualOnly = EditIsManualOnly, } ), "update a prompt action" @@ -509,16 +554,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; @@ -635,6 +688,7 @@ private void SelectById(string id) private void ClearEditor() { + HotkeyValidationMessage = null; _editingActionId = null; EditName = ""; EditSystemPrompt = ""; @@ -645,11 +699,6 @@ private void ClearEditor() EditIsManualOnly = false; } - private static string? NormalizeOptionalString(string? value) - { - return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - } - private void NotifyStateChanged() { OnPropertyChanged(nameof(HasSelectedAction)); diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/RecorderSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/RecorderSectionViewModel.cs index 5960e1f65..fca39da5e 100644 --- a/src/TypeWhisper.Linux/ViewModels/Sections/RecorderSectionViewModel.cs +++ b/src/TypeWhisper.Linux/ViewModels/Sections/RecorderSectionViewModel.cs @@ -22,11 +22,10 @@ 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; - - // Command execution and continuations that access this flag run on the UI thread. - private bool _stopSaveInProgress; + private readonly Func> _transcribeAsync; + private AudioRecordingService.AudioCaptureSession? _captureSession; [ObservableProperty] private double _audioLevel; @@ -51,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(); @@ -67,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 { @@ -86,31 +100,16 @@ private void ToggleRecording() } } - private bool CanToggleRecording() - { - return !_stopSaveInProgress; - } - - private void SetStopSaveInProgress(bool value) - { - if (_stopSaveInProgress == value) - { - return; - } - - _stopSaveInProgress = value; - ToggleRecordingCommand.NotifyCanExecuteChanged(); - } - 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 +133,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"]; @@ -149,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 @@ -166,7 +168,6 @@ private async Task StopRecordingAsync() IsRecording = false; OnPropertyChanged(nameof(RecordButtonText)); AudioLevel = 0; - SetStopSaveInProgress(false); } var fileName = Path.GetFileName(filePath); @@ -177,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 { @@ -207,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 { @@ -229,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) { @@ -269,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/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/ShortcutsSectionViewModel.cs index 085188f5a..fed8a2d38 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 @@ -271,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 @@ -281,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 @@ -291,7 +316,7 @@ private async Task RefreshKeyboardAccessAsync() { "Hyprland" => Loc.Instance["Shortcuts.PushToTalkSnippetHintHyprland"], "Sway" => Loc.Instance["Shortcuts.PushToTalkSnippetHintSway"], - _ => "" + _ => "", }; // DesktopDetector normalizes edge cases like "ubuntu:GNOME". @@ -319,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 @@ -356,38 +381,257 @@ 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 { get { var w = ActiveWriter; - return w is null ? string.Empty : w.PreviewLines(BuildSpec(w)); + if (w is null) + { + return string.Empty; + } + + var spec = BuildSpec(w); + return spec is null ? GetUnsupportedModeMessage(w) : w.PreviewLines(spec); } } // Uses the shared factory so this panel and the onboarding checklist register // identical shortcuts — otherwise one could install a bind the other wouldn't recognize. - private DeShortcutSpec BuildSpec(IDeShortcutWriter writer) + private DeShortcutSpec? BuildSpec(IDeShortcutWriter writer) { 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) + { + // 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) + { + // 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 + { + var writer = ActiveWriter; + var spec = writer is null ? null : BuildSpec(writer); + if (writer is null || spec is null) + { + _hotkey.SetNativeDictationBindingActive(false); + return; + } + + var isInstalled = await writer.IsInstalledAsync(spec, ct).ConfigureAwait(false); + _hotkey.SetNativeDictationBindingActive(isInstalled); + } + catch (OperationCanceledException) + { + _hotkey.SetNativeDictationBindingActive(false); + throw; + } + catch (Exception ex) + { + System.Diagnostics.Trace.WriteLine( + $"[Shortcuts] Native dictation binding probe failed: {ex.Message}" + ); + _hotkey.SetNativeDictationBindingActive(false); + } + } + + private string GetUnsupportedModeMessage(IDeShortcutWriter writer) + { + 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"], + _ => "", + }; + } + // VMs don't have direct clipboard access in Avalonia — the view // subscribes and writes via TopLevel.Clipboard. public event EventHandler? CopyCustomShortcutRequested; private static bool IsWaylandSession() { - return string.Equals( - Environment.GetEnvironmentVariable("XDG_SESSION_TYPE"), - "wayland", - StringComparison.OrdinalIgnoreCase - ); + return WaylandSessionDetector.IsWaylandSession(); } [RelayCommand] @@ -423,7 +667,7 @@ private void ApplyCopyLastTranscriptionHotkey() _settings.Save( _settings.Current with { - CopyLastTranscriptionHotkey = _hotkey.CurrentCopyLastTranscriptionHotkeyString + CopyLastTranscriptionHotkey = _hotkey.CurrentCopyLastTranscriptionHotkeyString, } ); StatusMessage = string.IsNullOrWhiteSpace( @@ -476,17 +720,53 @@ private async Task SetupAutomaticallyAsync() return; } + var spec = BuildSpec(writer); + if (spec is null) + { + IntegrationStatusMessage = GetUnsupportedModeMessage(writer); + return; + } + IntegrationStatusMessage = Loc.Instance.GetString("Shortcuts.InstallingShortcut", writer.DisplayName); + InvalidateDesktopIntegrationProbes(); try { var result = await writer - .WriteAsync(BuildSpec(writer), CancellationToken.None) + .WriteAsync(spec, CancellationToken.None) .ConfigureAwait(true); IntegrationStatusMessage = FormatResultMessage(result); + + if (result.Success) + { + var appliesImmediately = + !writer.RequiresSessionRestartToApply && result.Warning is null; + if (appliesImmediately) + { + _hotkey.SetNativeDictationBindingActive(true); + IntegrationStatusMessage = + $"{IntegrationStatusMessage} " + + Loc.Instance["Shortcuts.NativeDictationOwnershipActive"]; + } + else + { + IntegrationStatusMessage = + $"{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); } } @@ -503,15 +783,45 @@ private async Task RemoveIntegrationAsync() IntegrationStatusMessage = Loc.Instance.GetString("Shortcuts.RemovingShortcut", writer.DisplayName); + InvalidateDesktopIntegrationProbes(); try { var result = await writer .RemoveAsync(DictationShortcutId, CancellationToken.None) .ConfigureAwait(true); IntegrationStatusMessage = FormatResultMessage(result); + + if (result.Success) + { + var appliesImmediately = + !writer.RequiresSessionRestartToApply && result.Warning is null; + if (appliesImmediately) + { + _hotkey.SetNativeDictationBindingActive(false); + IntegrationStatusMessage = + $"{IntegrationStatusMessage} " + + Loc.Instance["Shortcuts.NativeDictationRemovalActive"]; + } + else + { + IntegrationStatusMessage = + $"{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); } } @@ -586,13 +896,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 { @@ -637,9 +949,14 @@ 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)); + OnPropertyChanged(nameof(CanWriteDesktopIntegration)); + OnPropertyChanged(nameof(CanRefreshDesktopIntegration)); + OnPropertyChanged(nameof(StaleIntegrationMessage)); + _ = ScheduleDesktopIntegrationRefresh(); } partial void OnWaylandEvdevHotkeysEnabledChanged(bool value) @@ -701,4 +1018,4 @@ private async Task SwitchBackendAndNotifyAsync() // which must clear the banner. RefreshKeyboardAccessAsync raises the change. await RefreshKeyboardAccessAsync(); } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs b/src/TypeWhisper.Linux/ViewModels/Sections/SnippetsSectionViewModel.cs index bb08f4d79..ff6990d0a 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); @@ -85,7 +96,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() @@ -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)); @@ -173,7 +189,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) @@ -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; } @@ -327,7 +346,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 +356,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 a9ae0a81d..f505db70c 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,8 @@ 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] private string _cudaBenchmarkStatus = Loc.Instance["Wizard.CudaBenchmarkIdle"]; @@ -123,20 +127,64 @@ public WelcomeWizardViewModel( IEnumerable setupTasks, 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; @@ -187,6 +235,11 @@ ISettingsService settings public async Task RunPasteSmokeTestAsync() { + if (IsAbandoned) + { + return false; + } + PasteTestPassed = false; PasteSmokeText = ""; PasteTestStatus = Loc.Instance["Wizard.PasteTestRunning"]; @@ -201,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) @@ -237,6 +300,11 @@ public async Task RunPasteSmokeTestAsync() public void CompletePasteSmokeTest(string? actualText) { + if (IsAbandoned) + { + return; + } + PasteSmokeText = actualText ?? ""; PasteTestPassed = PasteSmokeText.Contains( PasteSmokeExpectedText, @@ -250,24 +318,41 @@ 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) { _audio.StopPreview(); } - if (IsFirstDictationRecording) + var firstDictationCaptureSession = _firstDictationCaptureSession; + _firstDictationCaptureSession = null; + if (firstDictationCaptureSession is not null) { - FireAndLog(() => _audio.StopRecordingAsync(), "welcome wizard stop recording"); + 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" + ); } IsMicTestRunning = false; @@ -336,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) ) @@ -348,7 +432,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); } @@ -361,6 +445,11 @@ private void LoadMics() private void RefreshPluginState() { + if (IsAbandoned) + { + return; + } + foreach (var existing in ExtensionPlugins) { var isEnabled = _pluginManager.IsEnabled(existing.Id); @@ -380,6 +469,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) @@ -446,6 +540,11 @@ partial void OnSelectedModelChanged(WizardModelRow? value) partial void OnStepIndexChanged(int value) { + if (IsAbandoned) + { + return; + } + OnPropertyChanged(nameof(IsFirstStep)); OnPropertyChanged(nameof(IsLastStep)); OnPropertyChanged(nameof(NextLabel)); @@ -484,6 +583,11 @@ partial void OnHotkeyTextChanged(string value) [RelayCommand] private void Back() { + if (IsAbandoned) + { + return; + } + if (StepIndex > 0) { StepIndex--; @@ -493,6 +597,11 @@ private void Back() [RelayCommand] private async Task NextAsync() { + if (IsAbandoned) + { + return; + } + // Step 0: pick model — download/load before advancing if (StepIndex == 0) { @@ -517,14 +626,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; @@ -551,7 +674,7 @@ private async Task NextAsync() _settings.Current with { SelectedMicrophoneDevice = SelectedMic.Index, - SelectedMicrophoneDeviceId = SelectedMic.PersistentId + SelectedMicrophoneDeviceId = SelectedMic.PersistentId, } ); } @@ -577,6 +700,11 @@ _settings.Current with [RelayCommand] private void Skip() { + if (IsAbandoned) + { + return; + } + FinishOnboardingWithIndustryPreset(); RequestClose?.Invoke(this, EventArgs.Empty); } @@ -592,7 +720,7 @@ _settings.Current with EnabledPackIds = IndustryPreset.MergeIntoEnabledPackIds( _settings.Current.EnabledPackIds, SelectedIndustryPresetId - ) + ), } ); } @@ -600,6 +728,11 @@ _settings.Current with [RelayCommand] private async Task TogglePluginEnabledAsync(PluginRow row) { + if (IsAbandoned) + { + return; + } + if (row.IsEnabled) { await _pluginManager.DisablePluginAsync(row.Id); @@ -616,6 +749,11 @@ private async Task TogglePluginEnabledAsync(PluginRow row) /// private async Task RefreshSetupAsync() { + if (IsAbandoned) + { + return; + } + if (SetupItems.Count == 0) { foreach (var task in _setupTasks) @@ -634,20 +772,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(); } @@ -680,7 +840,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; } @@ -691,17 +851,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). @@ -711,12 +888,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(); @@ -747,6 +934,11 @@ private void ToggleMicTest() [RelayCommand] private async Task ToggleFirstDictationAsync() { + if (IsAbandoned) + { + return; + } + if (!IsFirstDictationRecording) { if (IsMicTestRunning) @@ -761,9 +953,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 +970,7 @@ private async Task ToggleFirstDictationAsync() return; } - if (!_audio.IsRecording) + if (_firstDictationCaptureSession is null) { FirstDictationStatus = Loc.Instance["Wizard.FirstDictationStartFailedGeneric"]; IsFirstDictationRecording = false; @@ -788,17 +983,32 @@ 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 + ? [] + // 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"]; @@ -810,10 +1020,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; } @@ -821,6 +1043,11 @@ private async Task ToggleFirstDictationAsync() string transcript; await using (lease) { + if (IsAbandoned) + { + return; + } + var plugin = lease.Plugin; FirstDictationStatus = Loc.Instance.GetString( "Wizard.Transcribing", @@ -831,19 +1058,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); } } @@ -851,7 +1097,7 @@ private async Task ToggleFirstDictationAsync() [RelayCommand] private async Task RunCudaBenchmarkAsync() { - if (IsCudaBenchmarkRunning) + if (IsAbandoned || IsCudaBenchmarkRunning) { return; } @@ -866,24 +1112,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) @@ -917,6 +1180,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) @@ -1005,7 +1271,7 @@ public SetupTaskRow(ISetupTask source) SetupTaskStatusKind.Satisfied => "ok", SetupTaskStatusKind.Failed => "error", SetupTaskStatusKind.Working => "busy", - _ => "missing" + _ => "missing", }; public string StatusGlyph => Kind switch @@ -1013,7 +1279,7 @@ public SetupTaskRow(ISetupTask source) SetupTaskStatusKind.Satisfied => "✓", SetupTaskStatusKind.Failed => "!", SetupTaskStatusKind.Working => "…", - _ => "•" + _ => "•", }; public void Apply(SetupTaskState state) @@ -1053,4 +1319,4 @@ private void NotifyDerived() OnPropertyChanged(nameof(StatusTone)); OnPropertyChanged(nameof(StatusGlyph)); } -} \ No newline at end of file +} diff --git a/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs b/src/TypeWhisper.Linux/Views/DictationOverlayWindow.axaml.cs index 41e3a61fd..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; @@ -340,7 +460,73 @@ private void OnDragSaveTimerTick(object? sender, EventArgs e) _settings.Save(_settings.Current with { OverlayCustomLeft = (double)pos.X, - OverlayCustomTop = (double)pos.Y + OverlayCustomTop = (double)pos.Y, }); } } + +/// +/// 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/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/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/AboutSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/AboutSection.axaml.cs index 492236aa8..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"] }], } ); @@ -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/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/DictationSection.axaml b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml index d5a93625c..56ad17173 100644 --- a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml @@ -311,7 +311,7 @@ - + @@ -631,10 +631,10 @@ - - diff --git a/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/DictationSection.axaml.cs index be4cbcef3..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/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 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/FileTranscriptionSection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/FileTranscriptionSection.axaml.cs index ca6462b9d..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( @@ -137,7 +185,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"] }], } ); @@ -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) @@ -189,7 +259,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}"] }], } ); @@ -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/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/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml b/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml index 68f46bf3b..680a627bf 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:d} {0:HH:mm}'}" /> @@ -510,4 +510,4 @@ - \ No newline at end of file + diff --git a/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml.cs b/src/TypeWhisper.Linux/Views/Sections/HistorySection.axaml.cs index 84de77810..722b30a62 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; @@ -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/ProfilesSection.axaml b/src/TypeWhisper.Linux/Views/Sections/ProfilesSection.axaml index e880f8e5f..3dbfc58d9 100644 --- a/src/TypeWhisper.Linux/Views/Sections/ProfilesSection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/ProfilesSection.axaml @@ -538,6 +538,11 @@ + @@ -627,4 +632,4 @@ - \ No newline at end of file + 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/PromptsSection.axaml b/src/TypeWhisper.Linux/Views/Sections/PromptsSection.axaml index 7cebc9ab6..754a80ff4 100644 --- a/src/TypeWhisper.Linux/Views/Sections/PromptsSection.axaml +++ b/src/TypeWhisper.Linux/Views/Sections/PromptsSection.axaml @@ -39,6 +39,19 @@ FontSize="12" Foreground="#8A9AAE" TextWrapping="Wrap" /> + + + +