From f53a225fc291d50b9d71e8a9bbbfe2a290b53491 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Wed, 9 Sep 2026 11:10:08 +0200 Subject: [PATCH 01/11] feat: replace powershell bridge with native csharp --- .github/workflows/build.yml | 9 - CHANGELOG.md | 32 +- README.md | 98 --- packaging/Package.wxs | 4 - src/App/ThoriumVoiceBridge.ps1 | 756 ------------------ src/Launcher.Core/LaunchPlan.cs | 46 -- src/Launcher.Core/Launcher.Core.csproj | 1 + src/Launcher.Core/VoiceBridgeService.cs | 167 ++++ src/Launcher.Core/VoiceRegistry.cs | 38 + src/Launcher/Launcher.csproj | 4 +- src/Launcher/MainForm.cs | 278 +++++++ src/Launcher/Program.cs | 82 +- src/Launcher/WindowsVoiceRegistry.cs | 114 +++ src/Launcher/app.manifest | 6 - tests/Launcher.Core.Tests/LaunchPlanTests.cs | 60 -- .../VoiceBridgeServiceTests.cs | 90 +++ 16 files changed, 724 insertions(+), 1061 deletions(-) delete mode 100644 src/App/ThoriumVoiceBridge.ps1 delete mode 100644 src/Launcher.Core/LaunchPlan.cs create mode 100644 src/Launcher.Core/VoiceBridgeService.cs create mode 100644 src/Launcher.Core/VoiceRegistry.cs create mode 100644 src/Launcher/MainForm.cs create mode 100644 src/Launcher/WindowsVoiceRegistry.cs delete mode 100644 tests/Launcher.Core.Tests/LaunchPlanTests.cs create mode 100644 tests/Launcher.Core.Tests/VoiceBridgeServiceTests.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9f4a382..1433bab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,14 +47,6 @@ jobs: "version=$version" >> $env:GITHUB_OUTPUT Write-Host "Version: $version" - - name: Lint PowerShell - shell: pwsh - run: | - Install-Module PSScriptAnalyzer -Force -Scope CurrentUser -SkipPublisherCheck - $findings = Invoke-ScriptAnalyzer -Path src/App -Recurse -Severity Error - $findings | Format-Table -AutoSize - if ($findings) { throw "PSScriptAnalyzer reported $($findings.Count) error(s)." } - - name: Build launcher run: dotnet publish src/Launcher/Launcher.csproj --configuration Release --runtime win-x64 --self-contained true -p:Version=${{ steps.version.outputs.version }} @@ -64,7 +56,6 @@ jobs: $stage = "artifacts/stage" New-Item -ItemType Directory -Force -Path $stage | Out-Null Copy-Item src/Launcher/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $stage - Copy-Item src/App/ThoriumVoiceBridge.ps1 $stage Copy-Item src/App/KB_icon.ico $stage Copy-Item src/App/KB_logo.png $stage Get-ChildItem $stage | Format-Table Name, Length diff --git a/CHANGELOG.md b/CHANGELOG.md index 5715126..58e3eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,15 @@ -# Changelog - -All notable changes to this project are documented here. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - -## [Unreleased] - -### Changed - -- The app is now distributed as a signed MSI installer with a Start Menu shortcut, - replacing the manually copied script folder. -- Elevation now happens through a single UAC prompt on the shortcut instead of the - previous `.cmd` → `.vbs` → PowerShell chain. - -## [1.3.0] - -- Last version distributed as a loose script folder. +# Changelog + +All notable changes to this project are documented here. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Changed + +- The app is now a native .NET 10 WinForms executable with no PowerShell dependency. +- Voice bridge operations are covered by in-memory tests that can run on macOS. + +## [1.3.0] + +- Last version distributed as a loose script folder. diff --git a/README.md b/README.md index 5dfff8b..dadd3ef 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,3 @@ -<<<<<<< Updated upstream -# tts-windows-voice-fixer - -Windows-værktøj der kopierer installerede SAPI 5-oplæsningsstemmer over i Windows' -OneCore-stemmeregister, så de kan bruges i **Thorium Reader**. Stemmer der er tilføjet -af værktøjet kan også fjernes igen. - -Skrevet af Joshua Lindemann for Det Kgl. Biblioteks Nota-service. -Brugerfladen er på dansk; kode, filnavne og dokumentation er på engelsk. - -## How it works - -Thorium Reader reads voices from `HKLM\SOFTWARE\Microsoft\Speech_OneCore\Voices\Tokens`, -while most third-party voices register themselves under the older SAPI 5 hive at -`HKLM\SOFTWARE\Microsoft\Speech\Voices\Tokens`. The app mirrors selected SAPI tokens into -the OneCore hive and tags each copy with a `ThoriumBridge` marker value, so it only ever -removes entries it created itself. Microsoft's own voices are skipped. - -Because it writes to `HKLM`, the app requires administrator rights. - -## Repository layout - -| Path | Contents | -| --- | --- | -| `src/App/` | The PowerShell WinForms application and its image assets | -| `src/Launcher/` | Small .NET Framework 4.8 launcher that triggers UAC and starts the script hidden | -| `packaging/` | WiX v6 authoring for the MSI | -| `signing/` | Azure Artifact Signing (formerly Trusted Signing) account metadata | -| `.github/workflows/` | CI: lint, build, sign, package, release | - -## Why an MSI and not MSIX - -MSIX was considered and rejected. Packaged MSIX apps cannot declare -`requestedExecutionLevel=requireAdministrator`, and writes to `HKLM` from inside the -package container are blocked. Both are hard requirements for this app, so the MSI is -not a stopgap — it is the only viable option unless the app stops editing machine-wide -registry state. - -## Building locally - -Requires the .NET 8 SDK and the .NET Framework 4.8 targeting pack on Windows. - -```powershell -dotnet build src/Launcher/Launcher.csproj -c Release - -$stage = "artifacts/stage" -New-Item -ItemType Directory -Force -Path $stage | Out-Null -Copy-Item src/Launcher/bin/Release/net48/ThoriumVoiceBridge.exe $stage -Copy-Item src/App/ThoriumVoiceBridge.ps1, src/App/KB_icon.ico, src/App/KB_logo.png $stage - -dotnet tool install --global wix --version 6.* -wix extension add -g WixToolset.UI.wixext/6.* - -wix build packaging/Package.wxs -ext WixToolset.UI.wixext ` - -d Version=1.3.0 -d StageDir="$($PWD.Path)\artifacts\stage" ` - -arch x64 -out artifacts/ThoriumVoiceBridge-1.3.0.msi -``` - -To run the app without packaging it, launch `src/App/ThoriumVoiceBridge.ps1` from an -elevated PowerShell prompt. - -## Releasing - -Every push to `main` builds, signs and uploads an MSI as a workflow artifact. - -To publish a release, bump `` in -[src/Launcher/Launcher.csproj](src/Launcher/Launcher.csproj), update -[CHANGELOG.md](CHANGELOG.md), merge to `main`, then run the **Build** workflow manually -with **Create a GitHub Release** enabled. The version in the csproj is the single source -of truth — it drives the exe file version, the MSI `ProductVersion`, the artifact name and -the release tag. - -## Signing setup - -Signing uses [`azure/artifact-signing-action`](https://github.com/Azure/artifact-signing-action) -with the account details in [signing/metadata.json](signing/metadata.json). The launcher is -signed before the MSI is built so its signature is embedded in the package; the MSI is then -signed as a whole. - -Authentication is OpenID Connect, so no secret values are stored in the repository. The -following repository secrets must point at an app registration with a federated credential -for this repo and the **Certificate Profile Signer** role on the signing account: - -- `AZURE_CLIENT_ID` -- `AZURE_TENANT_ID` -- `AZURE_SUBSCRIPTION_ID` - -Builds triggered by pull requests from forks cannot reach the signing service and produce an -unsigned MSI, which is fine for smoke-testing but must never be distributed. - -## Known gaps - -- The `.ps1` itself is not Authenticode-signed; the launcher invokes it with - `-ExecutionPolicy Bypass`. This is acceptable because the script sits in `Program Files` - and is only writable by administrators, but it would fail under an `AllSigned` policy. -- The installer UI (`WixUI_Minimal`) is English while the app UI is Danish. -======= # tts-windows-voice-fixer Windows-værktøj der kopierer installerede SAPI 5-oplæsningsstemmer over i Windows' @@ -195,4 +98,3 @@ Microsoft-voice filtering, existing-token protection, and marker-protected remov The real `WindowsVoiceRegistry` uses the explicit 64-bit `HKLM` registry view and is validated by Windows CI and manual Windows smoke testing. The installer UI (`WixUI_Minimal`) remains English while the app UI is Danish. ->>>>>>> Stashed changes diff --git a/packaging/Package.wxs b/packaging/Package.wxs index 7c2810b..b7bb7e8 100644 --- a/packaging/Package.wxs +++ b/packaging/Package.wxs @@ -53,10 +53,6 @@ Advertise="no" /> - - - - diff --git a/src/App/ThoriumVoiceBridge.ps1 b/src/App/ThoriumVoiceBridge.ps1 deleted file mode 100644 index 0d12e44..0000000 --- a/src/App/ThoriumVoiceBridge.ps1 +++ /dev/null @@ -1,756 +0,0 @@ -# Tilføj stemmer til Thorium (v1.3) -# Skrevet af Joshua Lindemann for Det Kgl. Biblioteks Nota-service -# -# Windows PowerShell 5.1 compatible - -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing - -[System.Windows.Forms.Application]::EnableVisualStyles() - -$SapiRoot = "HKLM:\SOFTWARE\Microsoft\Speech\Voices\Tokens" -$OneCoreRoot = "HKLM:\SOFTWARE\Microsoft\Speech_OneCore\Voices\Tokens" -$BridgeMarkerName = "ThoriumBridge" -$BridgeMarkerValue = "1" - -# ---------------- Helpers ---------------- - -function Test-IsAdmin { - $id = [Security.Principal.WindowsIdentity]::GetCurrent() - $p = New-Object Security.Principal.WindowsPrincipal($id) - return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -} - -function Normalize-LangHex([string]$lang) { - if ([string]::IsNullOrWhiteSpace($lang)) { return $null } - - $t = $lang.Trim() - - # Hvis det ligner hex (1-4 tegn), så pad til 4 og upper-case - if ($t -match '^[0-9A-Fa-f]{1,4}$') { - return $t.PadLeft(4,'0').ToUpper() - } - - # Hvis det er decimalt (LCID), så konvertér til hex og pad til 4 - if ($t -match '^[0-9]{1,5}$') { - try { - return ('{0:X4}' -f [int]$t) - } catch { - return $t - } - } - - return $t -} - -function Get-LangLabel([string]$langHex) { - switch ($langHex.ToUpper()) { - - # Nordiske - "0406" { "Dansk (DA)" } - "041D" { "Svensk (SV)" } - "0414" { "Norsk Bokmål (NO)" } - "0814" { "Norsk Nynorsk (NO)" } - "040B" { "Finsk (FI)" } - "040F" { "Islandsk (IS)" } - - # Engelsk - "0409" { "Engelsk (US)" } - "0809" { "Engelsk (UK)" } - "0C09" { "Engelsk (AU)" } - "1009" { "Engelsk (CA)" } - "1409" { "Engelsk (NZ)" } - "1809" { "Engelsk (IE)" } - - # Tysk - "0407" { "Tysk (DE)" } - "0807" { "Tysk (CH)" } - "0C07" { "Tysk (AT)" } - - # Fransk - "040C" { "Fransk (FR)" } - "080C" { "Fransk (BE)" } - "0C0C" { "Fransk (CA)" } - "100C" { "Fransk (CH)" } - - # Spansk - "040A" { "Spansk (ES)" } - "080A" { "Spansk (MX)" } - "0C0A" { "Spansk (ES - modern)" } - "140A" { "Spansk (CR)" } - "180A" { "Spansk (PA)" } - "1C0A" { "Spansk (DO)" } - "200A" { "Spansk (VE)" } - "240A" { "Spansk (CO)" } - "280A" { "Spansk (PE)" } - "2C0A" { "Spansk (AR)" } - "300A" { "Spansk (EC)" } - "340A" { "Spansk (CL)" } - "380A" { "Spansk (UY)" } - "3C0A" { "Spansk (PY)" } - "400A" { "Spansk (BO)" } - - # Portugisisk - "0416" { "Portugisisk (BR)" } - "0816" { "Portugisisk (PT)" } - - # Italiensk - "0410" { "Italiensk (IT)" } - "0810" { "Italiensk (CH)" } - - # Hollandsk - "0413" { "Hollandsk (NL)" } - "0813" { "Hollandsk (BE)" } - - # Østeuropæisk - "0405" { "Tjekkisk (CS)" } - "0415" { "Polsk (PL)" } - "0418" { "Rumænsk (RO)" } - "0419" { "Russisk (RU)" } - "0424" { "Slovensk (SL)" } - "041B" { "Slovakisk (SK)" } - "040E" { "Ungarsk (HU)" } - "041A" { "Kroatisk (HR)" } - "0422" { "Ukrainsk (UK)" } - "0425" { "Estisk (ET)" } - "0426" { "Lettisk (LV)" } - "0427" { "Litauisk (LT)" } - - # Asiatisk - "0411" { "Japansk (JA)" } - "0412" { "Koreansk (KO)" } - "0804" { "Kinesisk (CN)" } - "0404" { "Kinesisk (TW)" } - "0C04" { "Kinesisk (HK)" } - "1004" { "Kinesisk (SG)" } - "041E" { "Thai (TH)" } - "042A" { "Vietnamesisk (VI)" } - - # Mellemøstlig - "0401" { "Arabisk (SA)" } - "040D" { "Hebræisk (HE)" } - "0429" { "Persisk (FA)" } - "041F" { "Tyrkisk (TR)" } - - default { - if ([string]::IsNullOrWhiteSpace($langHex)) { "" } - else { $langHex } - } - } -} - -function Is-MicrosoftVendor([string]$vendor) { - if ([string]::IsNullOrWhiteSpace($vendor)) { return $false } - return ($vendor -match "^\s*Microsoft\b") -} - -function Ensure-Key([string]$path) { - if (-not (Test-Path $path)) { New-Item -Path $path -Force | Out-Null } -} - -function Make-DisplayName([string]$name, [string]$vendor) { - $n = $name; if ($null -eq $n) { $n = "" } - $v = $vendor; if ($null -eq $v) { $v = "" } - $n = $n.Trim() - $v = $v.Trim() - if ([string]::IsNullOrWhiteSpace($n)) { $n = "Ukendt" } - if ([string]::IsNullOrWhiteSpace($v)) { return $n } - return "$n ($v)" -} - -function Log([string]$msg) { - if (-not $txtLog) { return } - $ts = (Get-Date).ToString("HH:mm:ss") - $txtLog.AppendText("[$ts] $msg`r`n") - $txtLog.SelectionStart = $txtLog.TextLength - $txtLog.ScrollToCaret() -} - -function Set-EqualColumnWidths { - param( - [Parameter(Mandatory=$true)] - [System.Windows.Forms.ListView]$ListView, - - # Hvor meget vi trækker fra til scrollbar/borders (kan justeres) - [int]$Padding = 16 - ) - - $colCount = $ListView.Columns.Count - if ($colCount -le 0) { return } - - $usable = $ListView.ClientSize.Width - $Padding - if ($usable -lt $colCount) { return } - - $w = [Math]::Floor($usable / $colCount) - - foreach ($col in $ListView.Columns) { - $col.Width = $w - } -} - -# ---------------- Read registry ---------------- - -function Get-SapiVoices { - $result = @() - if (-not (Test-Path $SapiRoot)) { return $result } - - foreach ($k in Get-ChildItem $SapiRoot) { - $tokenPath = $k.PSPath - $attrPath = Join-Path $tokenPath "Attributes" - - $token = Get-ItemProperty -Path $tokenPath -ErrorAction SilentlyContinue - $attr = Get-ItemProperty -Path $attrPath -ErrorAction SilentlyContinue - - $langHex = Normalize-LangHex $attr.Language - - $display = $null - if ($token) { $display = $token.'(default)' } - if ([string]::IsNullOrWhiteSpace($display)) { $display = $attr.Name } - if ([string]::IsNullOrWhiteSpace($display)) { $display = $k.PSChildName } - - $name = $attr.Name - if ([string]::IsNullOrWhiteSpace($name)) { $name = $display } - - $result += [PSCustomObject]@{ - TokenName = $k.PSChildName - Name = $name - Vendor = $attr.Vendor - LanguageHex = $langHex - CLSID = if ($token) { $token.CLSID } else { $null } - } - } - return $result -} - -function Get-OneCoreVoices { - $result = @() - if (-not (Test-Path $OneCoreRoot)) { return $result } - - foreach ($k in Get-ChildItem $OneCoreRoot) { - $tokenPath = $k.PSPath - $attrPath = Join-Path $tokenPath "Attributes" - - $token = Get-ItemProperty -Path $tokenPath -ErrorAction SilentlyContinue - $attr = Get-ItemProperty -Path $attrPath -ErrorAction SilentlyContinue - - $langHex = $null - if ($attr -and $attr.Language) { $langHex = Normalize-LangHex $attr.Language } - - $display = $null - if ($token) { $display = $token.'(default)' } - if ([string]::IsNullOrWhiteSpace($display)) { $display = $attr.Name } - if ([string]::IsNullOrWhiteSpace($display)) { $display = $k.PSChildName } - - $result += [PSCustomObject]@{ - TokenName = $k.PSChildName - Name = $attr.Name - Vendor = $attr.Vendor - LanguageHex = $langHex - CLSID = if ($token) { $token.CLSID } else { $null } - Display = $display - ThoriumBridge = if ($attr) { $attr.$BridgeMarkerName } else { $null } - } - } - return $result -} - -function Find-OneCoreByTokenName($oneCoreVoices, [string]$tokenName) { - return ($oneCoreVoices | Where-Object { $_.TokenName -eq $tokenName } | Select-Object -First 1) -} - -# ---------------- Write registry ---------------- - -function Install-VoiceToOneCore([string]$tokenName) { - $sapiTokenPath = Join-Path $SapiRoot $tokenName - $sapiAttrPath = Join-Path $sapiTokenPath "Attributes" - if (-not (Test-Path $sapiTokenPath)) { throw "SAPI token findes ikke: $tokenName" } - - $token = Get-ItemProperty -Path $sapiTokenPath -ErrorAction Stop - $attr = Get-ItemProperty -Path $sapiAttrPath -ErrorAction SilentlyContinue - - if ($attr -and (Is-MicrosoftVendor $attr.Vendor)) { - Log "Springer over Microsoft-stemme: $tokenName" - return - } - - $langHex = Normalize-LangHex $attr.Language - if (-not $langHex) { $langHex = "0000" } - - $oneCoreTokenPath = Join-Path $OneCoreRoot $tokenName - $oneCoreAttrPath = Join-Path $oneCoreTokenPath "Attributes" - - if (Test-Path $oneCoreTokenPath) { - Log "Findes allerede i Thorium: $tokenName" - return - } - - Ensure-Key $oneCoreTokenPath - Ensure-Key $oneCoreAttrPath - - $name = $attr.Name - if ([string]::IsNullOrWhiteSpace($name)) { $name = $token.'(default)' } - $vendor = $attr.Vendor - $display = Make-DisplayName -name $name -vendor $vendor - - New-ItemProperty -Path $oneCoreTokenPath -Name "(default)" -Value $display -PropertyType String -Force | Out-Null - New-ItemProperty -Path $oneCoreTokenPath -Name $langHex -Value $display -PropertyType String -Force | Out-Null - if ($token.CLSID) { - New-ItemProperty -Path $oneCoreTokenPath -Name "CLSID" -Value $token.CLSID -PropertyType String -Force | Out-Null - } - - if ($attr.Age) { New-ItemProperty -Path $oneCoreAttrPath -Name "Age" -Value $attr.Age -PropertyType String -Force | Out-Null } - if ($attr.Gender) { New-ItemProperty -Path $oneCoreAttrPath -Name "Gender" -Value $attr.Gender -PropertyType String -Force | Out-Null } - New-ItemProperty -Path $oneCoreAttrPath -Name "Language" -Value $langHex -PropertyType String -Force | Out-Null - if ($name) { New-ItemProperty -Path $oneCoreAttrPath -Name "Name" -Value $name -PropertyType String -Force | Out-Null } - if ($vendor) { New-ItemProperty -Path $oneCoreAttrPath -Name "Vendor" -Value $vendor -PropertyType String -Force | Out-Null } - - New-ItemProperty -Path $oneCoreAttrPath -Name $BridgeMarkerName -Value $BridgeMarkerValue -PropertyType String -Force | Out-Null - Log "Flyttet til Thorium: $tokenName -> '$display'" -} - -function Remove-OneCoreToken([string]$tokenName) { - $psPath = Join-Path $OneCoreRoot $tokenName - $regPath = "HKLM\SOFTWARE\Microsoft\Speech_OneCore\Voices\Tokens\$tokenName" - - if (-not (Test-Path $psPath)) { return $true } - - try { Remove-Item -Path $psPath -Recurse -Force -ErrorAction Stop } catch {} - Start-Sleep -Milliseconds 150 - if (-not (Test-Path $psPath)) { return $true } - - try { $null = & reg.exe delete $regPath /f 2>&1 } catch {} - Start-Sleep -Milliseconds 150 - return (-not (Test-Path $psPath)) -} - -function Remove-FromThorium([string]$tokenName) { - $attrPath = Join-Path (Join-Path $OneCoreRoot $tokenName) "Attributes" - if (-not (Test-Path $attrPath)) { - Log "Fjern: $tokenName mangler Attributes - springer over." - return $false - } - - $attr = Get-ItemProperty -Path $attrPath -ErrorAction SilentlyContinue - if ($attr -and $attr.$BridgeMarkerName -eq $BridgeMarkerValue) { - $ok = Remove-OneCoreToken $tokenName - if ($ok) { Log "Fjernet fra Thorium: $tokenName" } else { Log "Fjern fejlede: $tokenName findes stadig." } - return $ok - } - - Log "Fjern: $tokenName er ikke markeret (native/ukendt) - kan ikke fjernes." - return $false -} - -# ---------------- GUI ---------------- - -$form = New-Object System.Windows.Forms.Form -$form.Text = "Tilføj stemmer til Thorium" -$form.StartPosition = "CenterScreen" - -#-------------Program-ikon-------------- -$baseDir = if ($PSScriptRoot) { $PSScriptRoot } elseif ($PSCommandPath) { Split-Path -Parent $PSCommandPath } else { (Get-Location).Path } -$iconPath = Join-Path $baseDir "KB_icon.ico" - -$form.ShowIcon = $true -$form.ShowInTaskbar = $true - -if ((Test-Path $iconPath)) { - try { - $form.Icon = [System.Drawing.Icon]::new($iconPath) - } catch { - # Gør intet. - } -} - -# sizes (with/without log) -$SizeNoLog = New-Object System.Drawing.Size(988, 453) -$SizeWithLog = New-Object System.Drawing.Size(980, 580) -$form.Size = $SizeNoLog -$form.KeyPreview = $true - -# Lås vinduesstørrelsen -$form.FormBorderStyle = 'FixedDialog' -$form.MaximizeBox = $false -$form.MinimizeBox = $true - -$font = New-Object System.Drawing.Font("Segoe UI", 9) - -$lblTop = New-Object System.Windows.Forms.Label -$lblTop.AutoSize = $false -$lblTop.Location = New-Object System.Drawing.Point(12, 34) -$lblTop.Size = New-Object System.Drawing.Size(740, 36) -$lblTop.Font = New-Object System.Drawing.Font("Segoe UI", 9, [System.Drawing.FontStyle]::Regular) -$form.Controls.Add($lblTop) - -# Logo (øverst til højre) -$logoPath = Join-Path $baseDir "KB_logo.png" -$picLogo = New-Object System.Windows.Forms.PictureBox -$picLogo.Location = New-Object System.Drawing.Point(770, 15) -$picLogo.Size = New-Object System.Drawing.Size(190, 55) -$picLogo.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom -$picLogo.Anchor = [System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Right -if (Test-Path $logoPath) { $picLogo.Image = [System.Drawing.Image]::FromFile($logoPath) } -$form.Controls.Add($picLogo) - -# Toggles -$chkDanishOnly = New-Object System.Windows.Forms.CheckBox -$chkDanishOnly.Text = "Vis kun Dansk" -$chkDanishOnly.Location = New-Object System.Drawing.Point(12, 74) # <-- var 62 -$chkDanishOnly.Size = New-Object System.Drawing.Size(140, 20) -$chkDanishOnly.Font = $font -$form.Controls.Add($chkDanishOnly) - -# Bypass visning af MS-stemmer -#$chkShowMicrosoft = New-Object System.Windows.Forms.CheckBox -#$chkShowMicrosoft.Text = "Vis Microsoft-stemmer" -#$chkShowMicrosoft.Location = New-Object System.Drawing.Point(160, 74) # <-- var 62 -#$chkShowMicrosoft.Size = New-Object System.Drawing.Size(170, 20) -#$chkShowMicrosoft.Font = $font -#$form.Controls.Add($chkShowMicrosoft) - -# Bypass log -#$chkShowLog = New-Object System.Windows.Forms.CheckBox -#$chkShowLog.Text = "Vis log" -#$chkShowLog.Location = New-Object System.Drawing.Point(340, 74) # <-- var 62 -#$chkShowLog.Size = New-Object System.Drawing.Size(90, 20) -#$chkShowLog.Font = $font -#$form.Controls.Add($chkShowLog) - -$lblLeft = New-Object System.Windows.Forms.Label -$lblLeft.Text = "Ikke i Thorium" -$lblLeft.Location = New-Object System.Drawing.Point(12, 100) # <-- var 88 -$lblLeft.Size = New-Object System.Drawing.Size(200, 20) -$lblLeft.Font = $font -$form.Controls.Add($lblLeft) - -$lblRight = New-Object System.Windows.Forms.Label -$lblRight.Text = "I Thorium" -$lblRight.Location = New-Object System.Drawing.Point(520, 100) # <-- var 88 -$lblRight.Size = New-Object System.Drawing.Size(200, 20) -$lblRight.Font = $font -$form.Controls.Add($lblRight) - -# Left list (SAPI) -$listLeft = New-Object System.Windows.Forms.ListView -$listLeft.Location = New-Object System.Drawing.Point(12, 122) # <-- var 110 -$listLeft.Size = New-Object System.Drawing.Size(440, 280) -$listLeft.View = [System.Windows.Forms.View]::Details -$listLeft.FullRowSelect = $true -$listLeft.GridLines = $true -$listLeft.Font = $font -$listLeft.HideSelection = $false -$listLeft.MultiSelect = $true -$listLeft.CheckBoxes = $true -[void]$listLeft.Columns.Add("Navn", 0) -[void]$listLeft.Columns.Add("Leverandør", 0) -[void]$listLeft.Columns.Add("Sprog", 0) -$form.Controls.Add($listLeft) - -# Right list (OneCore) -$listRight = New-Object System.Windows.Forms.ListView -$listRight.Location = New-Object System.Drawing.Point(520, 122) # <-- var 110 -$listRight.Size = New-Object System.Drawing.Size(440, 280) -$listRight.View = [System.Windows.Forms.View]::Details -$listRight.FullRowSelect = $true -$listRight.GridLines = $true -$listRight.Font = $font -$listRight.HideSelection = $false -$listRight.MultiSelect = $true -$listRight.CheckBoxes = $true -[void]$listRight.Columns.Add("Navn", 0) -[void]$listRight.Columns.Add("Leverandør", 0) -[void]$listRight.Columns.Add("Sprog", 0) -$form.Controls.Add($listRight) - -# Dynamic action button -$btnAction = New-Object System.Windows.Forms.Button -$btnAction.Text = "—" -$btnAction.Location = New-Object System.Drawing.Point(458, 202) # <-- var 190 -$btnAction.Size = New-Object System.Drawing.Size(56, 120) -$btnAction.Font = New-Object System.Drawing.Font("Segoe UI", 8) -$btnAction.Enabled = $false -$form.Controls.Add($btnAction) - -# Log (hidden by default) -$txtLog = New-Object System.Windows.Forms.RichTextBox -$txtLog.Location = New-Object System.Drawing.Point(12, 417) # <-- var 405 -$txtLog.Size = New-Object System.Drawing.Size(948, 140) -$txtLog.ReadOnly = $true -$txtLog.Font = $font -$txtLog.Visible = $false -$form.Controls.Add($txtLog) - -function Set-LogVisibility([bool]$show) { - $txtLog.Visible = $show - if ($show) { $form.Size = $SizeWithLog } else { $form.Size = $SizeNoLog } -} - -function Set-TopStatus { - if (Test-IsAdmin) { - $lblTop.Text = "Tilføj eller fjern oplæsningsstemmer, så de kan bruges i Thorium Reader." - #$lblTop.ForeColor = [System.Drawing.Color]::DarkGreen - } else { - $lblTop.Text = "Status: Ikke administrator. Du kan se stemmer, men kan ikke flytte/fjerne). Start programmet som Administrator." - $lblTop.ForeColor = [System.Drawing.Color]::DarkRed - } -} - -$lblTitle = New-Object System.Windows.Forms.Label -$lblTitle.Text = "Tilføj stemmer til Thorium" -$lblTitle.Location = New-Object System.Drawing.Point(12, 8) -$lblTitle.Size = New-Object System.Drawing.Size(500, 24) -$lblTitle.Font = New-Object System.Drawing.Font("Segoe UI", 11, [System.Drawing.FontStyle]::Bold) -$form.Controls.Add($lblTitle) - -function Add-LeftRow($name, $vendor, $langLabel, $payload, [bool]$locked) { - $item = New-Object System.Windows.Forms.ListViewItem($name) - [void]$item.SubItems.Add($vendor) - [void]$item.SubItems.Add($langLabel) - $item.Tag = $payload - if ($locked) { $item.ForeColor = [System.Drawing.Color]::Gray; $item.Checked = $false } - [void]$listLeft.Items.Add($item) -} - -function Add-RightRow($name, $vendor, $langLabel, $payload, [bool]$locked) { - $item = New-Object System.Windows.Forms.ListViewItem($name) - [void]$item.SubItems.Add($vendor) - [void]$item.SubItems.Add($langLabel) - $item.Tag = $payload - if ($locked) { $item.ForeColor = [System.Drawing.Color]::Gray; $item.Checked = $false } - [void]$listRight.Items.Add($item) -} - -function Uncheck-All($lv) { - foreach ($it in $lv.Items) { $it.Checked = $false } -} - -function Get-CheckedCount($lv) { - $c = 0 - foreach ($it in $lv.Items) { if ($it.Checked) { $c++ } } - return $c -} - -function Update-ActionButton { - $leftChecked = Get-CheckedCount $listLeft - $rightChecked = Get-CheckedCount $listRight - - if ($leftChecked -gt 0 -and $rightChecked -gt 0) { - $btnAction.Text = "—" - $btnAction.Enabled = $false - return - } - - if ($leftChecked -gt 0) { - $btnAction.Text = "Flyt`r`n→" - $btnAction.Enabled = (Test-IsAdmin) - $btnAction.Tag = "MOVE_IN" - return - } - - if ($rightChecked -gt 0) { - $btnAction.Text = "←`r`nFjern" - $btnAction.Enabled = (Test-IsAdmin) - $btnAction.Tag = "MOVE_OUT" - return - } - - $btnAction.Text = "—" - $btnAction.Enabled = $false -} - -function Refresh-Lists { - $listLeft.Items.Clear() - $listRight.Items.Clear() - - $oneCore = Get-OneCoreVoices - $sapi = Get-SapiVoices - - if ($chkDanishOnly.Checked) { - $sapi = $sapi | Where-Object { $_.LanguageHex -eq "0406" } - $oneCore = $oneCore | Where-Object { $_.LanguageHex -eq "0406" } - - } - - $showMicrosoft = $chkShowMicrosoft.Checked - - # RIGHT: alle OneCore, men kun ThoriumBridge=1 kan markeres/fjernes - foreach ($oc in ($oneCore | Sort-Object Vendor, Name, TokenName)) { - if (-not $showMicrosoft -and (Is-MicrosoftVendor $oc.Vendor)) { continue } - - $isBridged = ($oc.ThoriumBridge -eq $BridgeMarkerValue) - $locked = (-not $isBridged) - - $name = $oc.Name - if ([string]::IsNullOrWhiteSpace($name)) { $name = $oc.Display } - $vendor = if ($oc.Vendor) { $oc.Vendor } else { "" } - $langLabel = Get-LangLabel $oc.LanguageHex - - Add-RightRow -name $name -vendor $vendor -langLabel $langLabel -payload ([PSCustomObject]@{ - TokenName = $oc.TokenName - CanRemove = $isBridged - }) -locked:$locked - } - - # LEFT: SAPI som ikke allerede findes som OneCore token - foreach ($v in ($sapi | Sort-Object Vendor, Name, TokenName)) { - $isMicrosoft = Is-MicrosoftVendor $v.Vendor - if (-not $showMicrosoft -and $isMicrosoft) { continue } - - $ocExact = Find-OneCoreByTokenName -oneCoreVoices $oneCore -tokenName $v.TokenName - if ($ocExact) { continue } - - $vendor = if ($v.Vendor) { $v.Vendor } else { "" } - $langLabel = Get-LangLabel $v.LanguageHex - - # Microsoft SAPI vises kun hvis toggle, men låses altid - $locked = $isMicrosoft - - Add-LeftRow -name $v.Name -vendor $vendor -langLabel $langLabel -payload ([PSCustomObject]@{ - TokenName = $v.TokenName - Vendor = $v.Vendor - Locked = $locked - }) -locked:$locked - } - - Log "Opdateret. Ikke i Thorium: $($listLeft.Items.Count) | I Thorium: $($listRight.Items.Count)" - Update-ActionButton - - Set-EqualColumnWidths -ListView $listLeft - Set-EqualColumnWidths -ListView $listRight -} - -function Move-CheckedToThorium { - if (-not (Test-IsAdmin)) { - [System.Windows.Forms.MessageBox]::Show( - "For at flytte/fjerne stemmer skal programmet køre som Administrator.", - "Mangler rettigheder", - "OK", - "Warning" - ) | Out-Null - return - } - - $targets = @() - foreach ($it in $listLeft.Items) { if ($it.Checked) { $targets += $it } } - if ($targets.Count -eq 0) { return } - - $count = 0 - foreach ($it in $targets) { - $p = $it.Tag - if (-not $p) { continue } - if ($p.Locked) { continue } - - try { Install-VoiceToOneCore -tokenName $p.TokenName; $count++ } - catch { Log "FEJL ved flyt til Thorium ($($p.TokenName)): $($_.Exception.Message)" } - } - - Refresh-Lists - if ($count -gt 0) { - [System.Windows.Forms.MessageBox]::Show( - "Stemmen er tilføjet til Thorium. Genstart Thorium for at bruge den nye stemme.", - "Info", - "OK", - "Information" - ) | Out-Null - } -} - -function Remove-CheckedFromThorium { - if (-not (Test-IsAdmin)) { - [System.Windows.Forms.MessageBox]::Show( - "For at flytte/fjerne stemmer skal programmet køre som Administrator.", - "Mangler rettigheder", - "OK", - "Warning" - ) | Out-Null - return - } - - $targets = @() - foreach ($it in $listRight.Items) { if ($it.Checked) { $targets += $it } } - if ($targets.Count -eq 0) { return } - - $count = 0 - foreach ($it in $targets) { - $p = $it.Tag - if (-not $p) { continue } - if (-not $p.CanRemove) { continue } - - try { if (Remove-FromThorium -tokenName $p.TokenName) { $count++ } } - catch { Log "FEJL ved fjern fra Thorium ($($p.TokenName)): $($_.Exception.Message)" } - } - - Refresh-Lists - if ($count -gt 0) { - [System.Windows.Forms.MessageBox]::Show( - "Stemmen er fjernet fra Thorium. Det er nødvendigt at genstarte Thorium før ændringen træder i kraft.", - "Info", - "OK", - "Information" - ) | Out-Null - } -} - -# --- Events / protections --- - -$listLeft.Add_ItemCheck({ - param($sender, $e) - $item = $sender.Items[$e.Index] - $p = $item.Tag - if ($p -and $p.Locked -and $e.NewValue -eq [System.Windows.Forms.CheckState]::Checked) { - $e.NewValue = [System.Windows.Forms.CheckState]::Unchecked - [System.Windows.Forms.SystemSounds]::Beep.Play() - return - } - if ($e.NewValue -eq [System.Windows.Forms.CheckState]::Checked) { - Uncheck-All $listRight - } -}) - -$listRight.Add_ItemCheck({ - param($sender, $e) - $item = $sender.Items[$e.Index] - $p = $item.Tag - if ($p -and (-not $p.CanRemove) -and $e.NewValue -eq [System.Windows.Forms.CheckState]::Checked) { - $e.NewValue = [System.Windows.Forms.CheckState]::Unchecked - [System.Windows.Forms.SystemSounds]::Beep.Play() - return - } - if ($e.NewValue -eq [System.Windows.Forms.CheckState]::Checked) { - Uncheck-All $listLeft - } -}) - -$listLeft.Add_ItemChecked({ Update-ActionButton }) -$listRight.Add_ItemChecked({ Update-ActionButton }) - -$btnAction.Add_Click({ - if ($btnAction.Tag -eq "MOVE_IN") { - Move-CheckedToThorium - } elseif ($btnAction.Tag -eq "MOVE_OUT") { - Remove-CheckedFromThorium - } -}) - -$chkDanishOnly.Add_CheckedChanged({ Refresh-Lists }) -$chkShowMicrosoft.Add_CheckedChanged({ Refresh-Lists }) -$chkShowLog.Add_CheckedChanged({ Set-LogVisibility $chkShowLog.Checked }) - -$form.Add_KeyDown({ - param($sender, $e) - if ($e.KeyCode -eq [System.Windows.Forms.Keys]::F5) { - Set-TopStatus - Refresh-Lists - $e.Handled = $true - } -}) - -$form.Add_Shown({ - Set-TopStatus - Set-LogVisibility $false - Refresh-Lists - Log "Log er skjult som standard. Slå 'Vis log' til for fejlfinding." - Log "Kun tokens markeret $BridgeMarkerName=1 kan fjernes." -}) - -[void]$form.ShowDialog() diff --git a/src/Launcher.Core/LaunchPlan.cs b/src/Launcher.Core/LaunchPlan.cs deleted file mode 100644 index 0224be0..0000000 --- a/src/Launcher.Core/LaunchPlan.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Diagnostics; - -namespace Nota.ThoriumVoiceBridge; - -/// -/// Works out how the PowerShell application should be invoked. Deliberately free of -/// Windows-only APIs so the logic can be unit tested on any platform. -/// -public sealed class LaunchPlan -{ - public const string ScriptFileName = "ThoriumVoiceBridge.ps1"; - - private LaunchPlan(string workingDirectory, string scriptPath) - { - WorkingDirectory = workingDirectory; - ScriptPath = scriptPath; - } - - public string WorkingDirectory { get; } - - public string ScriptPath { get; } - - /// - /// Arguments for powershell.exe, unquoted. Quoting is left to - /// , which applies the Windows rules - /// correctly for install paths containing spaces and non-ASCII characters. - /// - public IReadOnlyList PowerShellArguments => - [ - "-NoProfile", - "-NonInteractive", - // Bypass is safe here: the script sits next to the exe in Program Files and is only writable by administrators. - "-ExecutionPolicy", "Bypass", - "-WindowStyle", "Hidden", - "-File", ScriptPath - ]; - - public static LaunchPlan ForApplicationDirectory(string applicationDirectory) - { - ArgumentException.ThrowIfNullOrWhiteSpace(applicationDirectory); - - return new LaunchPlan( - applicationDirectory, - Path.Combine(applicationDirectory, ScriptFileName)); - } -} diff --git a/src/Launcher.Core/Launcher.Core.csproj b/src/Launcher.Core/Launcher.Core.csproj index bef190d..64236fa 100644 --- a/src/Launcher.Core/Launcher.Core.csproj +++ b/src/Launcher.Core/Launcher.Core.csproj @@ -7,6 +7,7 @@ enable enable true + latest true diff --git a/src/Launcher.Core/VoiceBridgeService.cs b/src/Launcher.Core/VoiceBridgeService.cs new file mode 100644 index 0000000..500d60a --- /dev/null +++ b/src/Launcher.Core/VoiceBridgeService.cs @@ -0,0 +1,167 @@ +namespace Nota.ThoriumVoiceBridge; + +public sealed class VoiceBridgeService +{ + public const string BridgeMarkerName = "ThoriumBridge"; + public const string BridgeMarkerValue = "1"; + + private readonly IVoiceRegistry registry; + + public VoiceBridgeService(IVoiceRegistry registry) + { + this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); + } + + public IReadOnlyList GetSapiVoices() => registry.GetVoices(VoiceRegistryHive.Sapi); + + public IReadOnlyList GetOneCoreVoices() => registry.GetVoices(VoiceRegistryHive.OneCore); + + public VoiceOperationResult InstallVoice(string tokenName) + { + var source = registry.GetVoice(VoiceRegistryHive.Sapi, tokenName) + ?? throw new InvalidOperationException($"SAPI token findes ikke: {tokenName}"); + + if (source.IsMicrosoft) + { + return VoiceOperationResult.Skipped("Microsoft-stemmer kan ikke flyttes."); + } + + if (registry.GetVoice(VoiceRegistryHive.OneCore, tokenName) is not null) + { + return VoiceOperationResult.Skipped("Stemmen findes allerede i Thorium."); + } + + var bridged = new VoiceToken( + TokenName: source.TokenName, + Name: source.Name, + Vendor: source.Vendor, + Language: source.LanguageHex, + Clsid: source.Clsid, + Display: source.DisplayName, + Age: source.Age, + Gender: source.Gender, + ThoriumBridge: BridgeMarkerValue); + + registry.SetVoice(VoiceRegistryHive.OneCore, bridged); + return VoiceOperationResult.Completed(bridged); + } + + public VoiceOperationResult RemoveVoice(string tokenName) + { + var voice = registry.GetVoice(VoiceRegistryHive.OneCore, tokenName) + ?? throw new InvalidOperationException($"OneCore-token findes ikke: {tokenName}"); + + if (!voice.IsBridged) + { + return VoiceOperationResult.Skipped("Native/ukendte OneCore-stemmer kan ikke fjernes."); + } + + return registry.DeleteVoice(VoiceRegistryHive.OneCore, tokenName) + ? VoiceOperationResult.Completed(voice) + : VoiceOperationResult.Failed("Stemmen kunne ikke fjernes fra Thorium."); + } + + public static string? NormalizeLanguage(string? language) + { + if (string.IsNullOrWhiteSpace(language)) + { + return null; + } + + var value = language.Trim(); + if (value.All(Uri.IsHexDigit) && value.Length is >= 1 and <= 4) + { + return value.PadLeft(4, '0').ToUpperInvariant(); + } + + if (value.All(char.IsDigit) && value.Length <= 5 && int.TryParse(value, out var lcid)) + { + return lcid.ToString("X4"); + } + + return value; + } + + public static string LanguageLabel(string? language) + { + var languageHex = NormalizeLanguage(language); + return languageHex?.ToUpperInvariant() switch + { + "0406" => "Dansk (DA)", + "041D" => "Svensk (SV)", + "0414" => "Norsk Bokmål (NO)", + "0814" => "Norsk Nynorsk (NO)", + "040B" => "Finsk (FI)", + "040F" => "Islandsk (IS)", + "0409" => "Engelsk (US)", + "0809" => "Engelsk (UK)", + "0C09" => "Engelsk (AU)", + "1009" => "Engelsk (CA)", + "1409" => "Engelsk (NZ)", + "1809" => "Engelsk (IE)", + "0407" => "Tysk (DE)", + "0807" => "Tysk (CH)", + "0C07" => "Tysk (AT)", + "040C" => "Fransk (FR)", + "080C" => "Fransk (BE)", + "0C0C" => "Fransk (CA)", + "100C" => "Fransk (CH)", + "040A" => "Spansk (ES)", + "080A" => "Spansk (MX)", + "0C0A" => "Spansk (ES - modern)", + "0416" => "Portugisisk (BR)", + "0816" => "Portugisisk (PT)", + "0410" => "Italiensk (IT)", + "0810" => "Italiensk (CH)", + "0413" => "Hollandsk (NL)", + "0813" => "Hollandsk (BE)", + "0405" => "Tjekkisk (CS)", + "0415" => "Polsk (PL)", + "0418" => "Rumænsk (RO)", + "0419" => "Russisk (RU)", + "0424" => "Slovensk (SL)", + "041B" => "Slovakisk (SK)", + "040E" => "Ungarsk (HU)", + "041A" => "Kroatisk (HR)", + "0422" => "Ukrainsk (UK)", + "0425" => "Estisk (ET)", + "0426" => "Lettisk (LV)", + "0427" => "Litauisk (LT)", + "0411" => "Japansk (JA)", + "0412" => "Koreansk (KO)", + "0804" => "Kinesisk (CN)", + "0404" => "Kinesisk (TW)", + "0C04" => "Kinesisk (HK)", + "1004" => "Kinesisk (SG)", + "041E" => "Thai (TH)", + "042A" => "Vietnamesisk (VI)", + "0401" => "Arabisk (SA)", + "040D" => "Hebræisk (HE)", + "0429" => "Persisk (FA)", + "041F" => "Tyrkisk (TR)", + _ => languageHex ?? string.Empty + }; + } + + public static bool IsMicrosoftVendor(string? vendor) => + !string.IsNullOrWhiteSpace(vendor) && vendor.TrimStart().StartsWith("Microsoft", StringComparison.OrdinalIgnoreCase); + + public static string MakeDisplayName(string? name, string? vendor) + { + var cleanName = string.IsNullOrWhiteSpace(name) ? "Ukendt" : name.Trim(); + var cleanVendor = vendor?.Trim(); + return string.IsNullOrWhiteSpace(cleanVendor) ? cleanName : $"{cleanName} ({cleanVendor})"; + } +} + +public sealed record VoiceOperationResult(bool Changed, string Message, VoiceToken? Voice = null) +{ + public static VoiceOperationResult Completed(VoiceToken voice) => + new(true, "OK", voice); + + public static VoiceOperationResult Skipped(string message) => + new(false, message); + + public static VoiceOperationResult Failed(string message) => + new(false, message); +} diff --git a/src/Launcher.Core/VoiceRegistry.cs b/src/Launcher.Core/VoiceRegistry.cs new file mode 100644 index 0000000..ca63e62 --- /dev/null +++ b/src/Launcher.Core/VoiceRegistry.cs @@ -0,0 +1,38 @@ +namespace Nota.ThoriumVoiceBridge; + +public enum VoiceRegistryHive +{ + Sapi, + OneCore +} + +public sealed record VoiceToken( + string TokenName, + string? Name, + string? Vendor, + string? Language, + string? Clsid, + string? Display, + string? Age = null, + string? Gender = null, + string? ThoriumBridge = null) +{ + public string LanguageHex => VoiceBridgeService.NormalizeLanguage(Language) ?? "0000"; + + public bool IsMicrosoft => VoiceBridgeService.IsMicrosoftVendor(Vendor); + + public bool IsBridged => ThoriumBridge == VoiceBridgeService.BridgeMarkerValue; + + public string DisplayName => VoiceBridgeService.MakeDisplayName(Name ?? Display, Vendor); +} + +public interface IVoiceRegistry +{ + IReadOnlyList GetVoices(VoiceRegistryHive hive); + + VoiceToken? GetVoice(VoiceRegistryHive hive, string tokenName); + + void SetVoice(VoiceRegistryHive hive, VoiceToken voice); + + bool DeleteVoice(VoiceRegistryHive hive, string tokenName); +} diff --git a/src/Launcher/Launcher.csproj b/src/Launcher/Launcher.csproj index 20369bd..5945790 100644 --- a/src/Launcher/Launcher.csproj +++ b/src/Launcher/Launcher.csproj @@ -3,11 +3,13 @@ WinExe net10.0-windows + true win-x64 true true - true + + false true en diff --git a/src/Launcher/MainForm.cs b/src/Launcher/MainForm.cs new file mode 100644 index 0000000..1b1728d --- /dev/null +++ b/src/Launcher/MainForm.cs @@ -0,0 +1,278 @@ +using System.Drawing; +using System.Windows.Forms; + +namespace Nota.ThoriumVoiceBridge; + +internal sealed class MainForm : Form +{ + private readonly VoiceBridgeService service; + private readonly ListView leftList = CreateListView(); + private readonly ListView rightList = CreateListView(); + private readonly CheckBox danishOnly = new() { Text = "Vis kun Dansk", AutoSize = true }; + private readonly Button actionButton = new() { Text = "—", Enabled = false }; + private readonly Label statusLabel = new() { AutoSize = false }; + private readonly RichTextBox logBox = new() { ReadOnly = true, Visible = false }; + private readonly Label leftLabel = new() { Text = "Ikke i Thorium", AutoSize = true }; + private readonly Label rightLabel = new() { Text = "I Thorium", AutoSize = true }; + private readonly Size normalSize = new(988, 453); + private readonly Size logSize = new(980, 580); + + public MainForm(VoiceBridgeService service, string baseDirectory) + { + this.service = service; + Text = "Tilføj stemmer til Thorium"; + StartPosition = FormStartPosition.CenterScreen; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + KeyPreview = true; + ClientSize = normalSize; + Font = new Font("Segoe UI", 9); + + var iconPath = Path.Combine(baseDirectory, "KB_icon.ico"); + if (File.Exists(iconPath)) + { + try { Icon = new Icon(iconPath); } catch (Exception) { } + } + + var logo = new PictureBox + { + Location = new Point(770, 15), + Size = new Size(190, 55), + SizeMode = PictureBoxSizeMode.Zoom, + Anchor = AnchorStyles.Top | AnchorStyles.Right + }; + var logoPath = Path.Combine(baseDirectory, "KB_logo.png"); + if (File.Exists(logoPath)) + { + try { logo.Image = Image.FromFile(logoPath); } catch (Exception) { } + } + + var title = new Label + { + Text = "Tilføj stemmer til Thorium", + Location = new Point(12, 8), + Size = new Size(500, 24), + Font = new Font("Segoe UI", 11, FontStyle.Bold) + }; + + statusLabel.Location = new Point(12, 34); + statusLabel.Size = new Size(740, 36); + danishOnly.Location = new Point(12, 74); + leftLabel.Location = new Point(12, 100); + rightLabel.Location = new Point(520, 100); + ConfigureList(leftList, new Point(12, 122)); + ConfigureList(rightList, new Point(520, 122)); + + actionButton.Location = new Point(458, 202); + actionButton.Size = new Size(56, 120); + actionButton.Font = new Font("Segoe UI", 8); + logBox.Location = new Point(12, 417); + logBox.Size = new Size(948, 140); + + Controls.AddRange([title, statusLabel, logo, danishOnly, leftLabel, rightLabel, leftList, rightList, actionButton, logBox]); + WireEvents(); + Shown += (_, _) => RefreshLists(); + KeyDown += OnKeyDown; + } + + private static ListView CreateListView() => new() + { + View = View.Details, + FullRowSelect = true, + GridLines = true, + HideSelection = false, + MultiSelect = true, + CheckBoxes = true, + Size = new Size(440, 280) + }; + + private static void ConfigureList(ListView list, Point location) + { + list.Location = location; + list.Columns.Add("Navn"); + list.Columns.Add("Leverandør"); + list.Columns.Add("Sprog"); + } + + private void WireEvents() + { + leftList.ItemCheck += (_, args) => OnItemCheck(leftList, args, canSelect: true); + rightList.ItemCheck += (_, args) => OnItemCheck(rightList, args, canSelect: false); + leftList.ItemChecked += (_, _) => UpdateActionButton(); + rightList.ItemChecked += (_, _) => UpdateActionButton(); + danishOnly.CheckedChanged += (_, _) => RefreshLists(); + actionButton.Click += (_, _) => RunAction(); + } + + private void OnItemCheck(ListView source, ItemCheckEventArgs args, bool canSelect) + { + if (args.Index < 0 || args.Index >= source.Items.Count) + { + return; + } + + var row = (VoiceRow)source.Items[args.Index].Tag!; + if (!row.CanSelect && args.NewValue == CheckState.Checked) + { + args.NewValue = CheckState.Unchecked; + System.Media.SystemSounds.Beep.Play(); + return; + } + + if (args.NewValue == CheckState.Checked) + { + UncheckAll(canSelect ? rightList : leftList); + } + } + + private void RefreshLists() + { + leftList.Items.Clear(); + rightList.Items.Clear(); + SetStatus(); + + var oneCore = service.GetOneCoreVoices() + .Where(voice => !voice.IsMicrosoft) + .Where(voice => !danishOnly.Checked || voice.LanguageHex == "0406") + .OrderBy(voice => voice.Vendor) + .ThenBy(voice => voice.Name) + .ThenBy(voice => voice.TokenName); + var oneCoreNames = oneCore.Select(voice => voice.TokenName).ToHashSet(StringComparer.Ordinal); + + foreach (var voice in oneCore) + { + AddRow(rightList, voice, voice.IsBridged); + } + + var sapi = service.GetSapiVoices() + .Where(voice => !voice.IsMicrosoft) + .Where(voice => !oneCoreNames.Contains(voice.TokenName)) + .Where(voice => !danishOnly.Checked || voice.LanguageHex == "0406") + .OrderBy(voice => voice.Vendor) + .ThenBy(voice => voice.Name) + .ThenBy(voice => voice.TokenName); + + foreach (var voice in sapi) + { + AddRow(leftList, voice, canSelect: true); + } + + SetEqualColumnWidths(leftList); + SetEqualColumnWidths(rightList); + UpdateActionButton(); + Log($"Opdateret. Ikke i Thorium: {leftList.Items.Count} | I Thorium: {rightList.Items.Count}"); + } + + private static void AddRow(ListView list, VoiceToken voice, bool canSelect) + { + var item = new ListViewItem(voice.Name ?? voice.DisplayName); + item.SubItems.Add(voice.Vendor ?? string.Empty); + item.SubItems.Add(VoiceBridgeService.LanguageLabel(voice.Language)); + item.Tag = new VoiceRow(voice, canSelect); + if (!canSelect) + { + item.ForeColor = Color.Gray; + } + list.Items.Add(item); + } + + private void RunAction() + { + var source = leftList.CheckedItems.Count > 0 ? leftList : rightList; + var rows = source.CheckedItems.Cast().Select(item => (VoiceRow)item.Tag!).ToArray(); + var changed = 0; + + foreach (var row in rows) + { + try + { + var result = source == leftList + ? service.InstallVoice(row.Voice.TokenName) + : service.RemoveVoice(row.Voice.TokenName); + if (result.Changed) changed++; + Log(result.Message); + } + catch (Exception error) + { + Log($"FEJL ({row.Voice.TokenName}): {error.Message}"); + } + } + + RefreshLists(); + if (changed > 0) + { + MessageBox.Show( + source == leftList + ? "Stemmen er tilføjet til Thorium. Genstart Thorium for at bruge den nye stemme." + : "Stemmen er fjernet fra Thorium. Genstart Thorium for at bruge ændringen.", + "Info", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + } + + private void UpdateActionButton() + { + var leftChecked = leftList.CheckedItems.Count; + var rightChecked = rightList.CheckedItems.Count; + if (leftChecked > 0 && rightChecked > 0) + { + actionButton.Text = "—"; + actionButton.Enabled = false; + } + else if (leftChecked > 0) + { + actionButton.Text = "Flyt\n→"; + actionButton.Enabled = true; + } + else if (rightChecked > 0) + { + actionButton.Text = "←\nFjern"; + actionButton.Enabled = true; + } + else + { + actionButton.Text = "—"; + actionButton.Enabled = false; + } + } + + private void SetStatus() + { + statusLabel.Text = "Tilføj eller fjern oplæsningsstemmer, så de kan bruges i Thorium Reader."; + statusLabel.ForeColor = SystemColors.ControlText; + } + + private void UncheckAll(ListView list) + { + foreach (ListViewItem item in list.Items) + { + item.Checked = false; + } + } + + private void SetEqualColumnWidths(ListView list) + { + var width = (list.ClientSize.Width - 16) / list.Columns.Count; + foreach (ColumnHeader column in list.Columns) + { + column.Width = width; + } + } + + private void Log(string message) + { + logBox.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}"); + logBox.SelectionStart = logBox.TextLength; + logBox.ScrollToCaret(); + } + + private void OnKeyDown(object? sender, KeyEventArgs args) + { + if (args.KeyCode == Keys.F5) + { + RefreshLists(); + args.Handled = true; + } + } + + private sealed record VoiceRow(VoiceToken Voice, bool CanSelect); +} diff --git a/src/Launcher/Program.cs b/src/Launcher/Program.cs index 46d3d3b..a2a59f8 100644 --- a/src/Launcher/Program.cs +++ b/src/Launcher/Program.cs @@ -1,77 +1,31 @@ -using System.Diagnostics; -using System.Runtime.InteropServices; +namespace Nota.ThoriumVoiceBridge; -namespace Nota.ThoriumVoiceBridge; - -/// -/// Elevated, windowless launcher for the PowerShell application. The app edits HKLM -/// speech tokens, so app.manifest forces a UAC prompt before this runs. -/// -internal static partial class Program +internal static class Program { - private const string Caption = "Tilføj stemmer til Thorium"; - - private const uint MB_OK = 0x00000000; - private const uint MB_ICONERROR = 0x00000010; - private const uint MB_SETFOREGROUND = 0x00010000; - private static int Main() { - // Correct under single-file publish, unlike Assembly.Location. - var plan = LaunchPlan.ForApplicationDirectory(AppContext.BaseDirectory); - - if (!File.Exists(plan.ScriptPath)) - { - ShowError($""" - Kunne ikke finde {LaunchPlan.ScriptFileName}. + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); - Forventet placering: - {plan.ScriptPath} - - Installationen ser ud til at være beskadiget. Installer programmet igen. - """); - return 2; - } - - var startInfo = new ProcessStartInfo - { - FileName = "powershell.exe", - WorkingDirectory = plan.WorkingDirectory, - UseShellExecute = false, - CreateNoWindow = true - }; - - foreach (var argument in plan.PowerShellArguments) + using var registry = new WindowsVoiceRegistry(); + var service = new VoiceBridgeService(registry); + try { - startInfo.ArgumentList.Add(argument); + Application.Run(new MainForm(service, AppContext.BaseDirectory)); + return 0; } - - try + catch (UnauthorizedAccessException) { - using var process = Process.Start(startInfo); - if (process is null) - { - ShowError("Windows PowerShell kunne ikke startes."); - return 3; - } - - process.WaitForExit(); - return process.ExitCode; + MessageBox.Show("Programmet skal køres som Administrator.", "Mangler rettigheder", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return 5; } - catch (Exception ex) + catch (Exception error) { - ShowError($""" - Windows PowerShell kunne ikke startes. - - {ex.Message} - """); - return 3; + MessageBox.Show(error.Message, "Tilføj stemmer til Thorium", + MessageBoxButtons.OK, MessageBoxIcon.Error); + return 1; } } - - private static void ShowError(string message) => - MessageBox(IntPtr.Zero, message, Caption, MB_OK | MB_ICONERROR | MB_SETFOREGROUND); - - [LibraryImport("user32.dll", EntryPoint = "MessageBoxW", StringMarshalling = StringMarshalling.Utf16)] - private static partial int MessageBox(IntPtr hWnd, string text, string caption, uint type); } diff --git a/src/Launcher/WindowsVoiceRegistry.cs b/src/Launcher/WindowsVoiceRegistry.cs new file mode 100644 index 0000000..89f90f9 --- /dev/null +++ b/src/Launcher/WindowsVoiceRegistry.cs @@ -0,0 +1,114 @@ +using Microsoft.Win32; +using Nota.ThoriumVoiceBridge; + +namespace Nota.ThoriumVoiceBridge; + +internal sealed class WindowsVoiceRegistry : IVoiceRegistry, IDisposable +{ + private const string SapiPath = @"SOFTWARE\Microsoft\Speech\Voices\Tokens"; + private const string OneCorePath = @"SOFTWARE\Microsoft\Speech_OneCore\Voices\Tokens"; + + private readonly RegistryKey baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + + public IReadOnlyList GetVoices(VoiceRegistryHive hive) + { + using var root = baseKey.OpenSubKey(PathFor(hive)); + if (root is null) + { + return []; + } + + return root.GetSubKeyNames() + .Select(tokenName => ReadVoice(root, tokenName)) + .Where(voice => voice is not null) + .Cast() + .ToArray(); + } + + public VoiceToken? GetVoice(VoiceRegistryHive hive, string tokenName) + { + using var root = baseKey.OpenSubKey(PathFor(hive)); + return root is null ? null : ReadVoice(root, tokenName); + } + + public void SetVoice(VoiceRegistryHive hive, VoiceToken voice) + { + using var root = baseKey.CreateSubKey(PathFor(hive), writable: true) + ?? throw new InvalidOperationException("Kunne ikke åbne stemmeregisteret."); + using var token = root.CreateSubKey(voice.TokenName, writable: true) + ?? throw new InvalidOperationException("Kunne ikke oprette stemmetokenet."); + using var attributes = token.CreateSubKey("Attributes", writable: true) + ?? throw new InvalidOperationException("Kunne ikke oprette stemmens attributter."); + + token.SetValue(string.Empty, voice.DisplayName, RegistryValueKind.String); + token.SetValue(voice.LanguageHex, voice.DisplayName, RegistryValueKind.String); + SetOptional(token, "CLSID", voice.Clsid); + SetOptional(attributes, "Age", voice.Age); + SetOptional(attributes, "Gender", voice.Gender); + attributes.SetValue("Language", voice.LanguageHex, RegistryValueKind.String); + SetOptional(attributes, "Name", voice.Name); + SetOptional(attributes, "Vendor", voice.Vendor); + attributes.SetValue(VoiceBridgeService.BridgeMarkerName, VoiceBridgeService.BridgeMarkerValue, RegistryValueKind.String); + } + + public bool DeleteVoice(VoiceRegistryHive hive, string tokenName) + { + using var root = baseKey.OpenSubKey(PathFor(hive), writable: true); + if (root is null) + { + return true; + } + + try + { + root.DeleteSubKeyTree(tokenName, throwOnMissingSubKey: false); + using var remaining = root.OpenSubKey(tokenName); + return remaining is null; + } + catch (ArgumentException) + { + return false; + } + } + + public void Dispose() => baseKey.Dispose(); + + private static VoiceToken? ReadVoice(RegistryKey root, string tokenName) + { + using var token = root.OpenSubKey(tokenName); + if (token is null) + { + return null; + } + + using var attributes = token.OpenSubKey("Attributes"); + return new VoiceToken( + TokenName: tokenName, + Name: GetString(attributes, "Name"), + Vendor: GetString(attributes, "Vendor"), + Language: GetString(attributes, "Language"), + Clsid: GetString(token, "CLSID"), + Display: GetString(token, string.Empty) ?? GetString(attributes, "Name") ?? tokenName, + Age: GetString(attributes, "Age"), + Gender: GetString(attributes, "Gender"), + ThoriumBridge: GetString(attributes, VoiceBridgeService.BridgeMarkerName)); + } + + private static string? GetString(RegistryKey? key, string name) => + key?.GetValue(name)?.ToString(); + + private static void SetOptional(RegistryKey key, string name, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + key.SetValue(name, value, RegistryValueKind.String); + } + } + + private static string PathFor(VoiceRegistryHive hive) => hive switch + { + VoiceRegistryHive.Sapi => SapiPath, + VoiceRegistryHive.OneCore => OneCorePath, + _ => throw new ArgumentOutOfRangeException(nameof(hive), hive, null) + }; +} diff --git a/src/Launcher/app.manifest b/src/Launcher/app.manifest index bde6304..5f0d6c8 100644 --- a/src/Launcher/app.manifest +++ b/src/Launcher/app.manifest @@ -17,10 +17,4 @@ - - - - true - - diff --git a/tests/Launcher.Core.Tests/LaunchPlanTests.cs b/tests/Launcher.Core.Tests/LaunchPlanTests.cs deleted file mode 100644 index ca7c065..0000000 --- a/tests/Launcher.Core.Tests/LaunchPlanTests.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Nota.ThoriumVoiceBridge; - -namespace Launcher.Core.Tests; - -public class LaunchPlanTests -{ - private static readonly string AppDirectory = - Path.Combine("C:", "Program Files", "Tilføj stemmer til Thorium"); - - [Fact] - public void ScriptPath_SitsNextToTheExecutable() - { - var plan = LaunchPlan.ForApplicationDirectory(AppDirectory); - - Assert.Equal(Path.Combine(AppDirectory, "ThoriumVoiceBridge.ps1"), plan.ScriptPath); - Assert.Equal(AppDirectory, plan.WorkingDirectory); - } - - [Fact] - public void PowerShellArguments_PassTheScriptAsASingleUnquotedArgument() - { - var plan = LaunchPlan.ForApplicationDirectory(AppDirectory); - var arguments = plan.PowerShellArguments; - - var fileIndex = arguments.ToList().IndexOf("-File"); - Assert.True(fileIndex >= 0, "-File switch is missing."); - - // ProcessStartInfo.ArgumentList quotes each entry, so the path must not be pre-quoted. - var scriptArgument = arguments[fileIndex + 1]; - Assert.Equal(plan.ScriptPath, scriptArgument); - Assert.DoesNotContain('"', scriptArgument); - } - - [Fact] - public void PowerShellArguments_SuppressProfileAndWindow() - { - var arguments = LaunchPlan.ForApplicationDirectory(AppDirectory).PowerShellArguments; - - Assert.Contains("-NoProfile", arguments); - Assert.Contains("-NonInteractive", arguments); - Assert.Equal("Hidden", ValueAfter(arguments, "-WindowStyle")); - Assert.Equal("Bypass", ValueAfter(arguments, "-ExecutionPolicy")); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - public void ForApplicationDirectory_RejectsAMissingDirectory(string? directory) - { - Assert.ThrowsAny(() => LaunchPlan.ForApplicationDirectory(directory!)); - } - - private static string ValueAfter(IReadOnlyList arguments, string name) - { - var index = arguments.ToList().IndexOf(name); - Assert.True(index >= 0, $"{name} switch is missing."); - return arguments[index + 1]; - } -} diff --git a/tests/Launcher.Core.Tests/VoiceBridgeServiceTests.cs b/tests/Launcher.Core.Tests/VoiceBridgeServiceTests.cs new file mode 100644 index 0000000..88ccc85 --- /dev/null +++ b/tests/Launcher.Core.Tests/VoiceBridgeServiceTests.cs @@ -0,0 +1,90 @@ +using Nota.ThoriumVoiceBridge; + +namespace Launcher.Core.Tests; + +public sealed class VoiceBridgeServiceTests +{ + [Fact] + public void InstallCopiesVoiceAndMarksItAsBridged() + { + var registry = new InMemoryVoiceRegistry(); + var source = new VoiceToken("AcmeVoice", "Acme", "Acme Ltd", "1033", "{clsid}", "Acme", "2", "Female"); + registry.SetVoice(VoiceRegistryHive.Sapi, source); + + var result = new VoiceBridgeService(registry).InstallVoice("AcmeVoice"); + var installed = registry.GetVoice(VoiceRegistryHive.OneCore, "AcmeVoice"); + + Assert.True(result.Changed); + Assert.NotNull(installed); + Assert.Equal("1033", installed.LanguageHex); + Assert.Equal("Acme (Acme Ltd)", installed.Display); + Assert.Equal(VoiceBridgeService.BridgeMarkerValue, installed.ThoriumBridge); + Assert.Equal("{clsid}", installed.Clsid); + } + + [Fact] + public void InstallDoesNotOverwriteExistingOneCoreVoice() + { + var registry = new InMemoryVoiceRegistry(); + registry.SetVoice(VoiceRegistryHive.Sapi, new VoiceToken("Voice", "SAPI", "Vendor", "0406", null, null)); + registry.SetVoice(VoiceRegistryHive.OneCore, new VoiceToken("Voice", "Native", "Vendor", "0406", null, null)); + + var result = new VoiceBridgeService(registry).InstallVoice("Voice"); + + Assert.False(result.Changed); + Assert.Equal("Native", registry.GetVoice(VoiceRegistryHive.OneCore, "Voice")!.Name); + } + + [Fact] + public void MicrosoftVoicesAreSkipped() + { + var registry = new InMemoryVoiceRegistry(); + registry.SetVoice(VoiceRegistryHive.Sapi, new VoiceToken("MicrosoftVoice", "David", "Microsoft Corporation", "0409", null, null)); + + var result = new VoiceBridgeService(registry).InstallVoice("MicrosoftVoice"); + + Assert.False(result.Changed); + Assert.Null(registry.GetVoice(VoiceRegistryHive.OneCore, "MicrosoftVoice")); + } + + [Fact] + public void RemoveOnlyDeletesOurMarkedVoice() + { + var registry = new InMemoryVoiceRegistry(); + registry.SetVoice(VoiceRegistryHive.OneCore, new VoiceToken("Bridged", "Acme", "Acme", "0406", null, null, ThoriumBridge: "1")); + registry.SetVoice(VoiceRegistryHive.OneCore, new VoiceToken("Native", "Windows", "Microsoft", "0409", null, null)); + var service = new VoiceBridgeService(registry); + + Assert.True(service.RemoveVoice("Bridged").Changed); + Assert.False(service.RemoveVoice("Native").Changed); + Assert.Null(registry.GetVoice(VoiceRegistryHive.OneCore, "Bridged")); + Assert.NotNull(registry.GetVoice(VoiceRegistryHive.OneCore, "Native")); + } + + [Theory] + [InlineData("406", "0406")] + [InlineData("0406", "0406")] + [InlineData("1033", "1033")] + [InlineData("", null)] + public void NormalizeLanguagePreservesTheScriptRules(string input, string? expected) + { + Assert.Equal(expected, VoiceBridgeService.NormalizeLanguage(input)); + } + + private sealed class InMemoryVoiceRegistry : IVoiceRegistry + { + private readonly Dictionary<(VoiceRegistryHive Hive, string TokenName), VoiceToken> voices = new(); + + public IReadOnlyList GetVoices(VoiceRegistryHive hive) => + voices.Where(item => item.Key.Hive == hive).Select(item => item.Value).ToArray(); + + public VoiceToken? GetVoice(VoiceRegistryHive hive, string tokenName) => + voices.TryGetValue((hive, tokenName), out var voice) ? voice : null; + + public void SetVoice(VoiceRegistryHive hive, VoiceToken voice) => + voices[(hive, voice.TokenName)] = voice; + + public bool DeleteVoice(VoiceRegistryHive hive, string tokenName) => + voices.Remove((hive, tokenName)); + } +} From d1c15157503f99f323edeb23e89228297325dd1b Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Wed, 9 Sep 2026 12:52:07 +0200 Subject: [PATCH 02/11] docs: guide for manual signing --- README.md | 6 + docs/manual-windows-signing.md | 204 +++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 docs/manual-windows-signing.md diff --git a/README.md b/README.md index dadd3ef..1294513 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,12 @@ with the account details in [signing/metadata.json](signing/metadata.json). The signed before the MSI is built so its signature is embedded in the package; the MSI is then signed as a whole. +### Manual Signing +For a manual Windows build using Azure CLI authentication, see +[docs/manual-windows-signing.md](docs/manual-windows-signing.md). + +### Automated Workflow + Authentication is OpenID Connect, so no secret values are stored in the repository. The following repository secrets must point at an app registration with a federated credential for this repo and the **Certificate Profile Signer** role on the signing account: diff --git a/docs/manual-windows-signing.md b/docs/manual-windows-signing.md new file mode 100644 index 0000000..1e2151f --- /dev/null +++ b/docs/manual-windows-signing.md @@ -0,0 +1,204 @@ +# Manual Windows installer build and signing + +This runbook builds and signs the MSI from a Windows machine. It is intended for +manual release or verification builds using Azure CLI authentication for the +`dfg@kb.dk` account. + +The repository stores the Artifact Signing account metadata in +[`signing/metadata.json`](../signing/metadata.json). Azure CLI provides the +authentication token, but local file signing is performed by Windows `signtool.exe` +with Microsoft's Artifact Signing DLib. + +## Prerequisites + +Install these on the Windows signing machine: + +- Git +- Azure CLI +- .NET 10 SDK +- WiX v6 .NET tool +- Windows SDK `signtool.exe`, version `10.0.2261.755` or newer +- Microsoft Artifact Signing Client Tools, which provide `Azure.CodeSigning.Dlib.dll` + +The signing user must have the **Certificate Profile Signer** role on the Artifact +Signing certificate profile. + +```powershell +winget install -e --id Microsoft.DotNet.SDK.10 +winget install -e --id Microsoft.AzureCLI +winget install -e --id Microsoft.Azure.ArtifactSigningClientTools + +dotnet tool install --global wix --version 6.* +$wixVersion = wix --version +wix extension add -g "WixToolset.UI.wixext/$wixVersion" +``` + +## Check out the repository + +```powershell +git clone https://github.com/Notalib/tts-windows-voice-fixer.git +cd tts-windows-voice-fixer +git pull --ff-only +``` + +## Sign in to Azure + +```powershell +az login +az account show --query "{user:user.name, tenant:tenantId, subscription:name}" --output table +``` + +If Azure CLI selects the wrong tenant or subscription, set the expected context +before signing: + +```powershell +az login --tenant +az account set --subscription +``` + +## Build the launcher payload + +The launcher project version is the source of truth for the executable version, +the MSI `ProductVersion`, and the output MSI filename. + +```powershell +$csproj = [xml](Get-Content src/Launcher/Launcher.csproj) +$version = $csproj.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 +if (-not $version) { throw "No found in src/Launcher/Launcher.csproj" } + +Write-Host "Building version $version" +``` + +Run tests, then publish the self-contained Windows executable: + +```powershell +dotnet test ThoriumVoiceBridge.slnx + +dotnet publish src/Launcher/Launcher.csproj ` + --configuration Release ` + --runtime win-x64 ` + --self-contained true ` + -p:Version=$version +``` + +Stage the files consumed by WiX: + +```powershell +$stage = "artifacts/stage" +New-Item -ItemType Directory -Force -Path $stage | Out-Null + +Copy-Item src/Launcher/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $stage +Copy-Item src/App/KB_icon.ico $stage +Copy-Item src/App/KB_logo.png $stage + +Get-ChildItem $stage | Format-Table Name, Length +``` + +## Locate signing tools + +```powershell +$signTool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" ` + -Recurse ` + -Filter signtool.exe ` + -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\' } | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + +$dlib = Get-ChildItem @($env:ProgramFiles, ${env:ProgramFiles(x86)}) ` + -Recurse ` + -Filter Azure.CodeSigning.Dlib.dll ` + -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\' } | + Select-Object -First 1 -ExpandProperty FullName + +if (-not $signTool) { throw "Could not find x64 signtool.exe. Install the Windows SDK or Build Tools." } +if (-not $dlib) { throw "Could not find Azure.CodeSigning.Dlib.dll. Install Microsoft.Azure.ArtifactSigningClientTools." } + +Write-Host "signtool: $signTool" +Write-Host "dlib: $dlib" +``` + +Use matching architectures for `signtool.exe` and `Azure.CodeSigning.Dlib.dll`. +The commands above intentionally pick the x64 versions of both. + +## Sign the launcher + +The launcher must be signed before the MSI is built, so the MSI embeds the signed +executable. + +```powershell +& $signTool sign ` + /v ` + /debug ` + /fd SHA256 ` + /tr "http://timestamp.acs.microsoft.com" ` + /td SHA256 ` + /dlib $dlib ` + /dmdf signing/metadata.json ` + artifacts/stage/ThoriumVoiceBridge.exe +``` + +## Build the MSI + +```powershell +$out = "artifacts/ThoriumVoiceBridge-$version.msi" + +wix build packaging/Package.wxs ` + -ext WixToolset.UI.wixext ` + -d Version=$version ` + -d StageDir="$($PWD.Path)\artifacts\stage" ` + -arch x64 ` + -out $out +``` + +## Sign the MSI + +```powershell +& $signTool sign ` + /v ` + /debug ` + /fd SHA256 ` + /tr "http://timestamp.acs.microsoft.com" ` + /td SHA256 ` + /dlib $dlib ` + /dmdf signing/metadata.json ` + $out +``` + +Timestamping is required because Artifact Signing certificates are short-lived. +Keep `/tr http://timestamp.acs.microsoft.com /td SHA256` on both signing commands. + +## Verify signatures + +```powershell +foreach ($file in @("artifacts/stage/ThoriumVoiceBridge.exe", $out)) { + $sig = Get-AuthenticodeSignature $file + Write-Host "$file -> $($sig.Status) / $($sig.SignerCertificate.Subject)" + if ($sig.Status -ne "Valid") { + throw "Invalid signature on $file" + } +} +``` + +The finished installer is written to: + +```text +artifacts/ThoriumVoiceBridge-.msi +``` + +## Troubleshooting + +- `SignerSign() failed` or `403 Forbidden`: confirm `az account show` is using + the tenant and subscription where the Artifact Signing account exists, and that + [`signing/metadata.json`](../signing/metadata.json) uses the matching regional + endpoint. +- `No certificates were found`: confirm the signing user has the + **Certificate Profile Signer** role on the certificate profile, not only on the + resource group or subscription. +- `signtool` cannot load the DLib: confirm the Windows SDK `signtool.exe` version + is `10.0.2261.755` or newer and that `signtool.exe` and + `Azure.CodeSigning.Dlib.dll` use the same architecture. +- The MSI installs an unsigned launcher: rebuild the MSI after signing + `artifacts/stage/ThoriumVoiceBridge.exe`; signing the MSI afterward does not + change the embedded executable. From abfea1a92f9515b5a0d8fbec61a046fd18c6244c Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Wed, 9 Sep 2026 14:33:59 +0200 Subject: [PATCH 03/11] feat: distribute standalone executable --- .github/workflows/build.yml | 80 +++++++++------------------------- CHANGELOG.md | 1 + README.md | 40 +++++------------ docs/manual-windows-signing.md | 79 ++++++++------------------------- 4 files changed, 50 insertions(+), 150 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1433bab..2c21059 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ concurrency: jobs: build: - # The Artifact Signing action and WiX both require Windows. + # Artifact Signing requires Windows. runs-on: windows-2025 outputs: @@ -47,18 +47,16 @@ jobs: "version=$version" >> $env:GITHUB_OUTPUT Write-Host "Version: $version" - - name: Build launcher + - name: Build application run: dotnet publish src/Launcher/Launcher.csproj --configuration Release --runtime win-x64 --self-contained true -p:Version=${{ steps.version.outputs.version }} - - name: Stage payload + - name: Stage executable shell: pwsh run: | - $stage = "artifacts/stage" - New-Item -ItemType Directory -Force -Path $stage | Out-Null - Copy-Item src/Launcher/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $stage - Copy-Item src/App/KB_icon.ico $stage - Copy-Item src/App/KB_logo.png $stage - Get-ChildItem $stage | Format-Table Name, Length + New-Item -ItemType Directory -Force -Path artifacts | Out-Null + $out = "artifacts/ThoriumVoiceBridge-${{ steps.version.outputs.version }}.exe" + Copy-Item src/Launcher/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $out + Get-Item $out | Format-Table Name, Length - name: Read signing metadata id: signing @@ -70,7 +68,7 @@ jobs: "profile=$($meta.CertificateProfileName)" >> $env:GITHUB_OUTPUT # Pull requests from forks have no access to the OIDC federated credential, - # so those builds produce an unsigned MSI for smoke-testing only. + # so those builds produce an unsigned executable for smoke-testing only. - name: Decide whether to sign id: gate shell: pwsh @@ -88,40 +86,7 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - # The launcher must be signed before the MSI is built, so its signature ends up inside the package. - - name: Sign launcher - if: steps.gate.outputs.sign == 'true' - uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 - with: - endpoint: ${{ steps.signing.outputs.endpoint }} - signing-account-name: ${{ steps.signing.outputs.account }} - certificate-profile-name: ${{ steps.signing.outputs.profile }} - files-folder: ${{ github.workspace }}\artifacts\stage - files-folder-filter: exe - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 - - - name: Install WiX - shell: pwsh - run: | - dotnet tool install --global wix --version 6.* - $wixVersion = (wix --version) - wix extension add -g "WixToolset.UI.wixext/$wixVersion" - - - name: Build MSI - shell: pwsh - run: | - $version = "${{ steps.version.outputs.version }}" - $out = "artifacts/ThoriumVoiceBridge-$version.msi" - wix build packaging/Package.wxs ` - -ext WixToolset.UI.wixext ` - -d Version=$version ` - -d StageDir="$($PWD.Path)\artifacts\stage" ` - -arch x64 ` - -out $out - - - name: Sign MSI + - name: Sign executable if: steps.gate.outputs.sign == 'true' uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 with: @@ -129,7 +94,7 @@ jobs: signing-account-name: ${{ steps.signing.outputs.account }} certificate-profile-name: ${{ steps.signing.outputs.profile }} files-folder: ${{ github.workspace }}\artifacts - files-folder-filter: msi + files-folder-filter: exe file-digest: SHA256 timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 @@ -138,19 +103,16 @@ jobs: if: steps.gate.outputs.sign == 'true' shell: pwsh run: | - $failed = $false - foreach ($file in @("artifacts/stage/ThoriumVoiceBridge.exe", "artifacts/ThoriumVoiceBridge-${{ steps.version.outputs.version }}.msi")) { - $sig = Get-AuthenticodeSignature $file - Write-Host "$file -> $($sig.Status) / $($sig.SignerCertificate.Subject)" - if ($sig.Status -ne 'Valid') { $failed = $true } - } - if ($failed) { throw "One or more artifacts are not validly signed." } - - - name: Upload MSI + $file = "artifacts/ThoriumVoiceBridge-${{ steps.version.outputs.version }}.exe" + $sig = Get-AuthenticodeSignature $file + Write-Host "$file -> $($sig.Status) / $($sig.SignerCertificate.Subject)" + if ($sig.Status -ne 'Valid') { throw "The executable is not validly signed." } + + - name: Upload executable uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: ThoriumVoiceBridge-${{ steps.version.outputs.version }}-msi - path: artifacts/*.msi + name: ThoriumVoiceBridge-${{ steps.version.outputs.version }}-exe + path: artifacts/*.exe if-no-files-found: error release: @@ -160,10 +122,10 @@ jobs: permissions: contents: write steps: - - name: Download MSI + - name: Download executable uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ThoriumVoiceBridge-${{ needs.build.outputs.version }}-msi + name: ThoriumVoiceBridge-${{ needs.build.outputs.version }}-exe path: artifacts - name: Create release @@ -172,4 +134,4 @@ jobs: tag_name: v${{ needs.build.outputs.version }} name: v${{ needs.build.outputs.version }} generate_release_notes: true - files: artifacts/*.msi + files: artifacts/*.exe diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e3eda..ef0c9dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - The app is now a native .NET 10 WinForms executable with no PowerShell dependency. +- Releases now distribute a single self-contained, signed executable instead of an MSI installer. - Voice bridge operations are covered by in-memory tests that can run on macOS. ## [1.3.0] diff --git a/README.md b/README.md index 1294513..818c18d 100644 --- a/README.md +++ b/README.md @@ -23,17 +23,14 @@ Because it writes to `HKLM`, the app requires administrator rights. | --- | --- | | `src/Launcher.Core/` | Platform-neutral voice bridge service and registry abstraction | | `src/Launcher/` | Native .NET 10 WinForms application, Windows registry adapter, and image assets | -| `packaging/` | WiX v6 authoring for the MSI | | `signing/` | Azure Artifact Signing (formerly Trusted Signing) account metadata | -| `.github/workflows/` | CI: build, sign, package, release | +| `.github/workflows/` | CI: build, sign, and release the executable | -## Why an MSI and not MSIX +## Distribution -MSIX was considered and rejected. Packaged MSIX apps cannot declare -`requestedExecutionLevel=requireAdministrator`, and writes to `HKLM` from inside the -package container are blocked. Both are hard requirements for this app, so the MSI is -not a stopgap — it is the only viable option unless the app stops editing machine-wide -registry state. +The release is a single self-contained, signed `ThoriumVoiceBridge.exe` for 64-bit +Windows. It has no .NET or PowerShell dependency. Windows asks for administrator +permission when it starts, because the app writes to `HKLM`. ## Building locally @@ -43,19 +40,6 @@ from macOS or Linux with `EnableWindowsTargeting`; the resulting app runs on Win ```powershell dotnet test ThoriumVoiceBridge.slnx dotnet publish src/Launcher/Launcher.csproj -c Release -r win-x64 --self-contained true - -$stage = "artifacts/stage" -New-Item -ItemType Directory -Force -Path $stage | Out-Null -Copy-Item src/Launcher/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $stage -Copy-Item src/App/KB_icon.ico, src/App/KB_logo.png $stage - -dotnet tool install --global wix --version 6.* -$wixVersion = (wix --version) -wix extension add -g "WixToolset.UI.wixext/$wixVersion" - -wix build packaging/Package.wxs -ext WixToolset.UI.wixext ` - -d Version=1.3.0 -d StageDir="$($PWD.Path)\artifacts\stage" ` - -arch x64 -out artifacts/ThoriumVoiceBridge-1.3.0.msi ``` The native app uses a manifest to request administrator rights before opening the @@ -63,21 +47,18 @@ machine-wide voice registry. ## Releasing -Every push to `main` builds, signs and uploads an MSI as a workflow artifact. +Every push to `main` builds, signs and uploads the executable as a workflow artifact. To publish a release, bump `` in [src/Launcher/Launcher.csproj](src/Launcher/Launcher.csproj), update [CHANGELOG.md](CHANGELOG.md), merge to `main`, then run the **Build** workflow manually with **Create a GitHub Release** enabled. The version in the csproj is the single source -of truth — it drives the exe file version, the MSI `ProductVersion`, the artifact name and -the release tag. +of truth — it drives the EXE file version, artifact name, and release tag. ## Signing setup Signing uses [`azure/artifact-signing-action`](https://github.com/Azure/artifact-signing-action) -with the account details in [signing/metadata.json](signing/metadata.json). The launcher is -signed before the MSI is built so its signature is embedded in the package; the MSI is then -signed as a whole. +with the account details in [signing/metadata.json](signing/metadata.json). ### Manual Signing For a manual Windows build using Azure CLI authentication, see @@ -94,7 +75,7 @@ for this repo and the **Certificate Profile Signer** role on the signing account - `AZURE_SUBSCRIPTION_ID` Builds triggered by pull requests from forks cannot reach the signing service and produce an -unsigned MSI, which is fine for smoke-testing but must never be distributed. +unsigned executable, which is fine for smoke-testing but must never be distributed. ## Testing architecture @@ -102,5 +83,4 @@ The service operates on `IVoiceRegistry`, so tests use an in-memory implementati run on macOS without touching Windows. They cover token copying, language normalization, Microsoft-voice filtering, existing-token protection, and marker-protected removal. The real `WindowsVoiceRegistry` uses the explicit 64-bit `HKLM` registry view and is -validated by Windows CI and manual Windows smoke testing. The installer UI -(`WixUI_Minimal`) remains English while the app UI is Danish. +validated by Windows CI and manual Windows smoke testing. diff --git a/docs/manual-windows-signing.md b/docs/manual-windows-signing.md index 1e2151f..7470280 100644 --- a/docs/manual-windows-signing.md +++ b/docs/manual-windows-signing.md @@ -1,6 +1,6 @@ -# Manual Windows installer build and signing +# Manual Windows executable build and signing -This runbook builds and signs the MSI from a Windows machine. It is intended for +This runbook builds and signs the standalone executable from a Windows machine. It is intended for manual release or verification builds using Azure CLI authentication for the `dfg@kb.dk` account. @@ -16,7 +16,6 @@ Install these on the Windows signing machine: - Git - Azure CLI - .NET 10 SDK -- WiX v6 .NET tool - Windows SDK `signtool.exe`, version `10.0.2261.755` or newer - Microsoft Artifact Signing Client Tools, which provide `Azure.CodeSigning.Dlib.dll` @@ -27,10 +26,6 @@ Signing certificate profile. winget install -e --id Microsoft.DotNet.SDK.10 winget install -e --id Microsoft.AzureCLI winget install -e --id Microsoft.Azure.ArtifactSigningClientTools - -dotnet tool install --global wix --version 6.* -$wixVersion = wix --version -wix extension add -g "WixToolset.UI.wixext/$wixVersion" ``` ## Check out the repository @@ -56,10 +51,10 @@ az login --tenant az account set --subscription ``` -## Build the launcher payload +## Build the executable -The launcher project version is the source of truth for the executable version, -the MSI `ProductVersion`, and the output MSI filename. +The launcher project version is the source of truth for the executable version +and output filename. ```powershell $csproj = [xml](Get-Content src/Launcher/Launcher.csproj) @@ -81,17 +76,14 @@ dotnet publish src/Launcher/Launcher.csproj ` -p:Version=$version ``` -Stage the files consumed by WiX: +Copy the executable to the release artifact path: ```powershell -$stage = "artifacts/stage" -New-Item -ItemType Directory -Force -Path $stage | Out-Null - -Copy-Item src/Launcher/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $stage -Copy-Item src/App/KB_icon.ico $stage -Copy-Item src/App/KB_logo.png $stage +New-Item -ItemType Directory -Force -Path artifacts | Out-Null +$out = "artifacts/ThoriumVoiceBridge-$version.exe" +Copy-Item src/Launcher/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $out -Get-ChildItem $stage | Format-Table Name, Length +Get-Item $out | Format-Table Name, Length ``` ## Locate signing tools @@ -122,37 +114,7 @@ Write-Host "dlib: $dlib" Use matching architectures for `signtool.exe` and `Azure.CodeSigning.Dlib.dll`. The commands above intentionally pick the x64 versions of both. -## Sign the launcher - -The launcher must be signed before the MSI is built, so the MSI embeds the signed -executable. - -```powershell -& $signTool sign ` - /v ` - /debug ` - /fd SHA256 ` - /tr "http://timestamp.acs.microsoft.com" ` - /td SHA256 ` - /dlib $dlib ` - /dmdf signing/metadata.json ` - artifacts/stage/ThoriumVoiceBridge.exe -``` - -## Build the MSI - -```powershell -$out = "artifacts/ThoriumVoiceBridge-$version.msi" - -wix build packaging/Package.wxs ` - -ext WixToolset.UI.wixext ` - -d Version=$version ` - -d StageDir="$($PWD.Path)\artifacts\stage" ` - -arch x64 ` - -out $out -``` - -## Sign the MSI +## Sign the executable ```powershell & $signTool sign ` @@ -167,24 +129,22 @@ wix build packaging/Package.wxs ` ``` Timestamping is required because Artifact Signing certificates are short-lived. -Keep `/tr http://timestamp.acs.microsoft.com /td SHA256` on both signing commands. +Keep `/tr http://timestamp.acs.microsoft.com /td SHA256` on the signing command. ## Verify signatures ```powershell -foreach ($file in @("artifacts/stage/ThoriumVoiceBridge.exe", $out)) { - $sig = Get-AuthenticodeSignature $file - Write-Host "$file -> $($sig.Status) / $($sig.SignerCertificate.Subject)" - if ($sig.Status -ne "Valid") { - throw "Invalid signature on $file" - } +$sig = Get-AuthenticodeSignature $out +Write-Host "$out -> $($sig.Status) / $($sig.SignerCertificate.Subject)" +if ($sig.Status -ne "Valid") { + throw "Invalid signature on $out" } ``` -The finished installer is written to: +The finished executable is written to: ```text -artifacts/ThoriumVoiceBridge-.msi +artifacts/ThoriumVoiceBridge-.exe ``` ## Troubleshooting @@ -199,6 +159,3 @@ artifacts/ThoriumVoiceBridge-.msi - `signtool` cannot load the DLib: confirm the Windows SDK `signtool.exe` version is `10.0.2261.755` or newer and that `signtool.exe` and `Azure.CodeSigning.Dlib.dll` use the same architecture. -- The MSI installs an unsigned launcher: rebuild the MSI after signing - `artifacts/stage/ThoriumVoiceBridge.exe`; signing the MSI afterward does not - change the embedded executable. From bacda3d32250a549d3ef62855e7c711be3b4754f Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Wed, 9 Sep 2026 15:47:07 +0200 Subject: [PATCH 04/11] refactor: move KB logo/icon into Assets folder --- src/{App => Launcher/Assets}/KB_icon.ico | Bin src/{App => Launcher/Assets}/KB_logo.png | Bin src/Launcher/Launcher.csproj | 4 +++- src/Launcher/MainForm.cs | 15 ++++++++------- src/Launcher/Program.cs | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) rename src/{App => Launcher/Assets}/KB_icon.ico (100%) rename src/{App => Launcher/Assets}/KB_logo.png (100%) diff --git a/src/App/KB_icon.ico b/src/Launcher/Assets/KB_icon.ico similarity index 100% rename from src/App/KB_icon.ico rename to src/Launcher/Assets/KB_icon.ico diff --git a/src/App/KB_logo.png b/src/Launcher/Assets/KB_logo.png similarity index 100% rename from src/App/KB_logo.png rename to src/Launcher/Assets/KB_logo.png diff --git a/src/Launcher/Launcher.csproj b/src/Launcher/Launcher.csproj index 5945790..37cc8f4 100644 --- a/src/Launcher/Launcher.csproj +++ b/src/Launcher/Launcher.csproj @@ -22,7 +22,7 @@ ThoriumVoiceBridge Nota.ThoriumVoiceBridge app.manifest - ..\App\KB_icon.ico + Assets\KB_icon.ico true 1.3.0 Tilføj stemmer til Thorium @@ -34,6 +34,8 @@ + + diff --git a/src/Launcher/MainForm.cs b/src/Launcher/MainForm.cs index 1b1728d..c2a7e74 100644 --- a/src/Launcher/MainForm.cs +++ b/src/Launcher/MainForm.cs @@ -17,7 +17,7 @@ internal sealed class MainForm : Form private readonly Size normalSize = new(988, 453); private readonly Size logSize = new(980, 580); - public MainForm(VoiceBridgeService service, string baseDirectory) + public MainForm(VoiceBridgeService service) { this.service = service; Text = "Tilføj stemmer til Thorium"; @@ -28,10 +28,10 @@ public MainForm(VoiceBridgeService service, string baseDirectory) ClientSize = normalSize; Font = new Font("Segoe UI", 9); - var iconPath = Path.Combine(baseDirectory, "KB_icon.ico"); - if (File.Exists(iconPath)) + using var iconStream = typeof(MainForm).Assembly.GetManifestResourceStream("KB_icon.ico"); + if (iconStream is not null) { - try { Icon = new Icon(iconPath); } catch (Exception) { } + Icon = new Icon(iconStream); } var logo = new PictureBox @@ -41,10 +41,11 @@ public MainForm(VoiceBridgeService service, string baseDirectory) SizeMode = PictureBoxSizeMode.Zoom, Anchor = AnchorStyles.Top | AnchorStyles.Right }; - var logoPath = Path.Combine(baseDirectory, "KB_logo.png"); - if (File.Exists(logoPath)) + using var logoStream = typeof(MainForm).Assembly.GetManifestResourceStream("KB_logo.png"); + if (logoStream is not null) { - try { logo.Image = Image.FromFile(logoPath); } catch (Exception) { } + using var image = Image.FromStream(logoStream); + logo.Image = new Bitmap(image); } var title = new Label diff --git a/src/Launcher/Program.cs b/src/Launcher/Program.cs index a2a59f8..0ebdd2a 100644 --- a/src/Launcher/Program.cs +++ b/src/Launcher/Program.cs @@ -12,7 +12,7 @@ private static int Main() var service = new VoiceBridgeService(registry); try { - Application.Run(new MainForm(service, AppContext.BaseDirectory)); + Application.Run(new MainForm(service)); return 0; } catch (UnauthorizedAccessException) From 95c2b75702b365cdedbdbecd5455ad0075bc22b5 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Wed, 9 Sep 2026 16:00:35 +0200 Subject: [PATCH 05/11] refactor: move Launcher -> ThoriumVoiceBridge --- .github/workflows/build.yml | 8 +-- .vscode/launch.json | 15 ++++++ .vscode/tasks.json | 16 ++++++ README.md | 8 +-- ThoriumVoiceBridge.slnx | 18 +++---- docs/manual-windows-signing.md | 8 +-- .../ThoriumVoiceBridge.Core.csproj} | 2 +- .../VoiceBridgeService.cs | 0 .../VoiceRegistry.cs | 0 .../Assets/KB_icon.ico | Bin .../Assets/KB_logo.png | Bin .../MainForm.cs | 0 .../Program.cs | 0 .../ThoriumVoiceBridge.csproj} | 2 +- .../WindowsVoiceRegistry.cs | 0 .../app.manifest | 0 .../ThoriumVoiceBridge.Core.Tests.csproj} | 50 +++++++++--------- .../VoiceBridgeServiceTests.cs | 2 +- 18 files changed, 80 insertions(+), 49 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json rename src/{Launcher.Core/Launcher.Core.csproj => ThoriumVoiceBridge.Core/ThoriumVoiceBridge.Core.csproj} (82%) rename src/{Launcher.Core => ThoriumVoiceBridge.Core}/VoiceBridgeService.cs (100%) rename src/{Launcher.Core => ThoriumVoiceBridge.Core}/VoiceRegistry.cs (100%) rename src/{Launcher => ThoriumVoiceBridge}/Assets/KB_icon.ico (100%) rename src/{Launcher => ThoriumVoiceBridge}/Assets/KB_logo.png (100%) rename src/{Launcher => ThoriumVoiceBridge}/MainForm.cs (100%) rename src/{Launcher => ThoriumVoiceBridge}/Program.cs (100%) rename src/{Launcher/Launcher.csproj => ThoriumVoiceBridge/ThoriumVoiceBridge.csproj} (93%) rename src/{Launcher => ThoriumVoiceBridge}/WindowsVoiceRegistry.cs (100%) rename src/{Launcher => ThoriumVoiceBridge}/app.manifest (100%) rename tests/{Launcher.Core.Tests/Launcher.Core.Tests.csproj => ThoriumVoiceBridge.Core.Tests/ThoriumVoiceBridge.Core.Tests.csproj} (82%) rename tests/{Launcher.Core.Tests => ThoriumVoiceBridge.Core.Tests}/VoiceBridgeServiceTests.cs (96%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2c21059..d189f9d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,21 +41,21 @@ jobs: id: version shell: pwsh run: | - $csproj = [xml](Get-Content src/Launcher/Launcher.csproj) + $csproj = [xml](Get-Content src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj) $version = $csproj.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 - if (-not $version) { throw "No found in src/Launcher/Launcher.csproj" } + if (-not $version) { throw "No found in src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj" } "version=$version" >> $env:GITHUB_OUTPUT Write-Host "Version: $version" - name: Build application - run: dotnet publish src/Launcher/Launcher.csproj --configuration Release --runtime win-x64 --self-contained true -p:Version=${{ steps.version.outputs.version }} + run: dotnet publish src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj --configuration Release --runtime win-x64 --self-contained true -p:Version=${{ steps.version.outputs.version }} - name: Stage executable shell: pwsh run: | New-Item -ItemType Directory -Force -Path artifacts | Out-Null $out = "artifacts/ThoriumVoiceBridge-${{ steps.version.outputs.version }}.exe" - Copy-Item src/Launcher/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $out + Copy-Item src/ThoriumVoiceBridge/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $out Get-Item $out | Format-Table Name, Length - name: Read signing metadata diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a1f3074 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "C#: Thorium Voice Bridge", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj", + "preLaunchTask": "build Thorium Voice Bridge" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..d612d99 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,16 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build Thorium Voice Bridge", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}/src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj" + ], + "isBackground": false, + "group": "build" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 818c18d..445af33 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ Because it writes to `HKLM`, the app requires administrator rights. | Path | Contents | | --- | --- | -| `src/Launcher.Core/` | Platform-neutral voice bridge service and registry abstraction | -| `src/Launcher/` | Native .NET 10 WinForms application, Windows registry adapter, and image assets | +| `src/ThoriumVoiceBridge.Core/` | Platform-neutral voice bridge service and registry abstraction | +| `src/ThoriumVoiceBridge/` | Native .NET 10 WinForms application, Windows registry adapter, and image assets | | `signing/` | Azure Artifact Signing (formerly Trusted Signing) account metadata | | `.github/workflows/` | CI: build, sign, and release the executable | @@ -39,7 +39,7 @@ from macOS or Linux with `EnableWindowsTargeting`; the resulting app runs on Win ```powershell dotnet test ThoriumVoiceBridge.slnx -dotnet publish src/Launcher/Launcher.csproj -c Release -r win-x64 --self-contained true +dotnet publish src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj -c Release -r win-x64 --self-contained true ``` The native app uses a manifest to request administrator rights before opening the @@ -50,7 +50,7 @@ machine-wide voice registry. Every push to `main` builds, signs and uploads the executable as a workflow artifact. To publish a release, bump `` in -[src/Launcher/Launcher.csproj](src/Launcher/Launcher.csproj), update +[src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj](src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj), update [CHANGELOG.md](CHANGELOG.md), merge to `main`, then run the **Build** workflow manually with **Create a GitHub Release** enabled. The version in the csproj is the single source of truth — it drives the EXE file version, artifact name, and release tag. diff --git a/ThoriumVoiceBridge.slnx b/ThoriumVoiceBridge.slnx index 4d98379..36e725d 100644 --- a/ThoriumVoiceBridge.slnx +++ b/ThoriumVoiceBridge.slnx @@ -1,9 +1,9 @@ - - - - - - - - - + + + + + + + + + diff --git a/docs/manual-windows-signing.md b/docs/manual-windows-signing.md index 7470280..b89ce8d 100644 --- a/docs/manual-windows-signing.md +++ b/docs/manual-windows-signing.md @@ -57,9 +57,9 @@ The launcher project version is the source of truth for the executable version and output filename. ```powershell -$csproj = [xml](Get-Content src/Launcher/Launcher.csproj) +$csproj = [xml](Get-Content src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj) $version = $csproj.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 -if (-not $version) { throw "No found in src/Launcher/Launcher.csproj" } +if (-not $version) { throw "No found in src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj" } Write-Host "Building version $version" ``` @@ -69,7 +69,7 @@ Run tests, then publish the self-contained Windows executable: ```powershell dotnet test ThoriumVoiceBridge.slnx -dotnet publish src/Launcher/Launcher.csproj ` +dotnet publish src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj ` --configuration Release ` --runtime win-x64 ` --self-contained true ` @@ -81,7 +81,7 @@ Copy the executable to the release artifact path: ```powershell New-Item -ItemType Directory -Force -Path artifacts | Out-Null $out = "artifacts/ThoriumVoiceBridge-$version.exe" -Copy-Item src/Launcher/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $out +Copy-Item src/ThoriumVoiceBridge/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $out Get-Item $out | Format-Table Name, Length ``` diff --git a/src/Launcher.Core/Launcher.Core.csproj b/src/ThoriumVoiceBridge.Core/ThoriumVoiceBridge.Core.csproj similarity index 82% rename from src/Launcher.Core/Launcher.Core.csproj rename to src/ThoriumVoiceBridge.Core/ThoriumVoiceBridge.Core.csproj index 64236fa..7582ff6 100644 --- a/src/Launcher.Core/Launcher.Core.csproj +++ b/src/ThoriumVoiceBridge.Core/ThoriumVoiceBridge.Core.csproj @@ -3,7 +3,7 @@ net10.0 Nota.ThoriumVoiceBridge - Nota.ThoriumVoiceBridge.Launcher.Core + Nota.ThoriumVoiceBridge.Core enable enable true diff --git a/src/Launcher.Core/VoiceBridgeService.cs b/src/ThoriumVoiceBridge.Core/VoiceBridgeService.cs similarity index 100% rename from src/Launcher.Core/VoiceBridgeService.cs rename to src/ThoriumVoiceBridge.Core/VoiceBridgeService.cs diff --git a/src/Launcher.Core/VoiceRegistry.cs b/src/ThoriumVoiceBridge.Core/VoiceRegistry.cs similarity index 100% rename from src/Launcher.Core/VoiceRegistry.cs rename to src/ThoriumVoiceBridge.Core/VoiceRegistry.cs diff --git a/src/Launcher/Assets/KB_icon.ico b/src/ThoriumVoiceBridge/Assets/KB_icon.ico similarity index 100% rename from src/Launcher/Assets/KB_icon.ico rename to src/ThoriumVoiceBridge/Assets/KB_icon.ico diff --git a/src/Launcher/Assets/KB_logo.png b/src/ThoriumVoiceBridge/Assets/KB_logo.png similarity index 100% rename from src/Launcher/Assets/KB_logo.png rename to src/ThoriumVoiceBridge/Assets/KB_logo.png diff --git a/src/Launcher/MainForm.cs b/src/ThoriumVoiceBridge/MainForm.cs similarity index 100% rename from src/Launcher/MainForm.cs rename to src/ThoriumVoiceBridge/MainForm.cs diff --git a/src/Launcher/Program.cs b/src/ThoriumVoiceBridge/Program.cs similarity index 100% rename from src/Launcher/Program.cs rename to src/ThoriumVoiceBridge/Program.cs diff --git a/src/Launcher/Launcher.csproj b/src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj similarity index 93% rename from src/Launcher/Launcher.csproj rename to src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj index 37cc8f4..c100ae6 100644 --- a/src/Launcher/Launcher.csproj +++ b/src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj @@ -33,7 +33,7 @@ - + diff --git a/src/Launcher/WindowsVoiceRegistry.cs b/src/ThoriumVoiceBridge/WindowsVoiceRegistry.cs similarity index 100% rename from src/Launcher/WindowsVoiceRegistry.cs rename to src/ThoriumVoiceBridge/WindowsVoiceRegistry.cs diff --git a/src/Launcher/app.manifest b/src/ThoriumVoiceBridge/app.manifest similarity index 100% rename from src/Launcher/app.manifest rename to src/ThoriumVoiceBridge/app.manifest diff --git a/tests/Launcher.Core.Tests/Launcher.Core.Tests.csproj b/tests/ThoriumVoiceBridge.Core.Tests/ThoriumVoiceBridge.Core.Tests.csproj similarity index 82% rename from tests/Launcher.Core.Tests/Launcher.Core.Tests.csproj rename to tests/ThoriumVoiceBridge.Core.Tests/ThoriumVoiceBridge.Core.Tests.csproj index 23e0b0c..819dbe5 100644 --- a/tests/Launcher.Core.Tests/Launcher.Core.Tests.csproj +++ b/tests/ThoriumVoiceBridge.Core.Tests/ThoriumVoiceBridge.Core.Tests.csproj @@ -1,25 +1,25 @@ - - - - net10.0 - enable - enable - false - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Launcher.Core.Tests/VoiceBridgeServiceTests.cs b/tests/ThoriumVoiceBridge.Core.Tests/VoiceBridgeServiceTests.cs similarity index 96% rename from tests/Launcher.Core.Tests/VoiceBridgeServiceTests.cs rename to tests/ThoriumVoiceBridge.Core.Tests/VoiceBridgeServiceTests.cs index 88ccc85..75717c6 100644 --- a/tests/Launcher.Core.Tests/VoiceBridgeServiceTests.cs +++ b/tests/ThoriumVoiceBridge.Core.Tests/VoiceBridgeServiceTests.cs @@ -1,6 +1,6 @@ using Nota.ThoriumVoiceBridge; -namespace Launcher.Core.Tests; +namespace ThoriumVoiceBridge.Core.Tests; public sealed class VoiceBridgeServiceTests { From d5352ceecf373f80180c2b01f9d1a18b04a9c443 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Wed, 9 Sep 2026 16:03:51 +0200 Subject: [PATCH 06/11] chore: format code --- src/ThoriumVoiceBridge.Core/VoiceBridgeService.cs | 2 +- src/ThoriumVoiceBridge.Core/VoiceRegistry.cs | 2 +- src/ThoriumVoiceBridge/MainForm.cs | 3 --- src/ThoriumVoiceBridge/Program.cs | 2 +- src/ThoriumVoiceBridge/WindowsVoiceRegistry.cs | 3 +-- tests/ThoriumVoiceBridge.Core.Tests/VoiceBridgeServiceTests.cs | 2 +- 6 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/ThoriumVoiceBridge.Core/VoiceBridgeService.cs b/src/ThoriumVoiceBridge.Core/VoiceBridgeService.cs index 500d60a..498ea21 100644 --- a/src/ThoriumVoiceBridge.Core/VoiceBridgeService.cs +++ b/src/ThoriumVoiceBridge.Core/VoiceBridgeService.cs @@ -1,4 +1,4 @@ -namespace Nota.ThoriumVoiceBridge; +namespace Nota.ThoriumVoiceBridge; public sealed class VoiceBridgeService { diff --git a/src/ThoriumVoiceBridge.Core/VoiceRegistry.cs b/src/ThoriumVoiceBridge.Core/VoiceRegistry.cs index ca63e62..14a5155 100644 --- a/src/ThoriumVoiceBridge.Core/VoiceRegistry.cs +++ b/src/ThoriumVoiceBridge.Core/VoiceRegistry.cs @@ -1,4 +1,4 @@ -namespace Nota.ThoriumVoiceBridge; +namespace Nota.ThoriumVoiceBridge; public enum VoiceRegistryHive { diff --git a/src/ThoriumVoiceBridge/MainForm.cs b/src/ThoriumVoiceBridge/MainForm.cs index c2a7e74..8be544e 100644 --- a/src/ThoriumVoiceBridge/MainForm.cs +++ b/src/ThoriumVoiceBridge/MainForm.cs @@ -1,6 +1,3 @@ -using System.Drawing; -using System.Windows.Forms; - namespace Nota.ThoriumVoiceBridge; internal sealed class MainForm : Form diff --git a/src/ThoriumVoiceBridge/Program.cs b/src/ThoriumVoiceBridge/Program.cs index 0ebdd2a..bfd1483 100644 --- a/src/ThoriumVoiceBridge/Program.cs +++ b/src/ThoriumVoiceBridge/Program.cs @@ -1,4 +1,4 @@ -namespace Nota.ThoriumVoiceBridge; +namespace Nota.ThoriumVoiceBridge; internal static class Program { diff --git a/src/ThoriumVoiceBridge/WindowsVoiceRegistry.cs b/src/ThoriumVoiceBridge/WindowsVoiceRegistry.cs index 89f90f9..e60fe92 100644 --- a/src/ThoriumVoiceBridge/WindowsVoiceRegistry.cs +++ b/src/ThoriumVoiceBridge/WindowsVoiceRegistry.cs @@ -1,5 +1,4 @@ -using Microsoft.Win32; -using Nota.ThoriumVoiceBridge; +using Microsoft.Win32; namespace Nota.ThoriumVoiceBridge; diff --git a/tests/ThoriumVoiceBridge.Core.Tests/VoiceBridgeServiceTests.cs b/tests/ThoriumVoiceBridge.Core.Tests/VoiceBridgeServiceTests.cs index 75717c6..4afb957 100644 --- a/tests/ThoriumVoiceBridge.Core.Tests/VoiceBridgeServiceTests.cs +++ b/tests/ThoriumVoiceBridge.Core.Tests/VoiceBridgeServiceTests.cs @@ -1,4 +1,4 @@ -using Nota.ThoriumVoiceBridge; +using Nota.ThoriumVoiceBridge; namespace ThoriumVoiceBridge.Core.Tests; From b01681d0f1bfafbb93ad1e0e20bcd3ef9bdf9920 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Thu, 10 Sep 2026 09:19:08 +0200 Subject: [PATCH 07/11] fix(ui): add missing icon and logo to --- CHANGELOG.md | 1 + README.md | 7 + src/ThoriumVoiceBridge/MainForm.cs | 36 ++++- tools/ui-preview/index.html | 242 +++++++++++++++++++++++++++++ 4 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 tools/ui-preview/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index ef0c9dc..eb7f154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- The native WinForms UI now shows the KB icon in the top-left header and the KB logo on the right, matching the previous PowerShell UI. - The app is now a native .NET 10 WinForms executable with no PowerShell dependency. - Releases now distribute a single self-contained, signed executable instead of an MSI installer. - Voice bridge operations are covered by in-memory tests that can run on macOS. diff --git a/README.md b/README.md index 445af33..3c38ee3 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,10 @@ run on macOS without touching Windows. They cover token copying, language normal Microsoft-voice filtering, existing-token protection, and marker-protected removal. The real `WindowsVoiceRegistry` uses the explicit 64-bit `HKLM` registry view and is validated by Windows CI and manual Windows smoke testing. + +## UI preview + +The WinForms app only runs on Windows, but the static preview at +[tools/ui-preview/index.html](tools/ui-preview/index.html) mirrors the fixed-size form layout and +loads the same KB icon and logo assets. Open it in a browser on macOS for quick visual checks +before doing the final Windows smoke test. diff --git a/src/ThoriumVoiceBridge/MainForm.cs b/src/ThoriumVoiceBridge/MainForm.cs index 8be544e..45ecf46 100644 --- a/src/ThoriumVoiceBridge/MainForm.cs +++ b/src/ThoriumVoiceBridge/MainForm.cs @@ -20,17 +20,31 @@ public MainForm(VoiceBridgeService service) Text = "Tilføj stemmer til Thorium"; StartPosition = FormStartPosition.CenterScreen; FormBorderStyle = FormBorderStyle.FixedDialog; + ShowIcon = true; MaximizeBox = false; KeyPreview = true; ClientSize = normalSize; Font = new Font("Segoe UI", 9); - using var iconStream = typeof(MainForm).Assembly.GetManifestResourceStream("KB_icon.ico"); + using var iconStream = OpenEmbeddedAsset("KB_icon.ico"); if (iconStream is not null) { Icon = new Icon(iconStream); } + var headerIcon = new PictureBox + { + Location = new Point(12, 12), + Size = new Size(48, 48), + SizeMode = PictureBoxSizeMode.Zoom + }; + using var headerIconStream = OpenEmbeddedAsset("KB_icon.ico"); + if (headerIconStream is not null) + { + using var image = new Icon(headerIconStream, headerIcon.Size).ToBitmap(); + headerIcon.Image = new Bitmap(image); + } + var logo = new PictureBox { Location = new Point(770, 15), @@ -38,7 +52,7 @@ public MainForm(VoiceBridgeService service) SizeMode = PictureBoxSizeMode.Zoom, Anchor = AnchorStyles.Top | AnchorStyles.Right }; - using var logoStream = typeof(MainForm).Assembly.GetManifestResourceStream("KB_logo.png"); + using var logoStream = OpenEmbeddedAsset("KB_logo.png"); if (logoStream is not null) { using var image = Image.FromStream(logoStream); @@ -48,13 +62,13 @@ public MainForm(VoiceBridgeService service) var title = new Label { Text = "Tilføj stemmer til Thorium", - Location = new Point(12, 8), + Location = new Point(72, 12), Size = new Size(500, 24), Font = new Font("Segoe UI", 11, FontStyle.Bold) }; - statusLabel.Location = new Point(12, 34); - statusLabel.Size = new Size(740, 36); + statusLabel.Location = new Point(72, 38); + statusLabel.Size = new Size(680, 32); danishOnly.Location = new Point(12, 74); leftLabel.Location = new Point(12, 100); rightLabel.Location = new Point(520, 100); @@ -67,7 +81,7 @@ public MainForm(VoiceBridgeService service) logBox.Location = new Point(12, 417); logBox.Size = new Size(948, 140); - Controls.AddRange([title, statusLabel, logo, danishOnly, leftLabel, rightLabel, leftList, rightList, actionButton, logBox]); + Controls.AddRange([headerIcon, title, statusLabel, logo, danishOnly, leftLabel, rightLabel, leftList, rightList, actionButton, logBox]); WireEvents(); Shown += (_, _) => RefreshLists(); KeyDown += OnKeyDown; @@ -161,6 +175,16 @@ private void RefreshLists() Log($"Opdateret. Ikke i Thorium: {leftList.Items.Count} | I Thorium: {rightList.Items.Count}"); } + private static Stream? OpenEmbeddedAsset(string fileName) + { + var assembly = typeof(MainForm).Assembly; + return assembly.GetManifestResourceStream(fileName) + ?? assembly.GetManifestResourceNames() + .Where(resourceName => resourceName.EndsWith($".{fileName}", StringComparison.Ordinal)) + .Select(assembly.GetManifestResourceStream) + .FirstOrDefault(stream => stream is not null); + } + private static void AddRow(ListView list, VoiceToken voice, bool canSelect) { var item = new ListViewItem(voice.Name ?? voice.DisplayName); diff --git a/tools/ui-preview/index.html b/tools/ui-preview/index.html new file mode 100644 index 0000000..c0ec932 --- /dev/null +++ b/tools/ui-preview/index.html @@ -0,0 +1,242 @@ + + + + + + Thorium Voice Bridge UI Preview + + + +
+ +

Tilføj stemmer til Thorium

+

Tilføj eller fjern oplæsningsstemmer, så de kan bruges i Thorium Reader.

+ + + + +

Ikke i Thorium

+

I Thorium

+ + + + + + + + + + +
NavnLeverandørSprog
IdaAcme VoiceDansk
MadsAcme VoiceDansk
SofieNordic SpeechDansk
+ + + + + + + + + + + +
NavnLeverandørSprog
AnnaMicrosoftDansk
JakobNordic SpeechDansk
+ + +
+ + From e87810caadefe4ea988cfc9b074cbd67a1e4c399 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Mon, 14 Sep 2026 13:12:25 +0200 Subject: [PATCH 08/11] fix: remove the extra icon requested by Joshua --- src/ThoriumVoiceBridge/MainForm.cs | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/ThoriumVoiceBridge/MainForm.cs b/src/ThoriumVoiceBridge/MainForm.cs index 45ecf46..17211e1 100644 --- a/src/ThoriumVoiceBridge/MainForm.cs +++ b/src/ThoriumVoiceBridge/MainForm.cs @@ -32,19 +32,6 @@ public MainForm(VoiceBridgeService service) Icon = new Icon(iconStream); } - var headerIcon = new PictureBox - { - Location = new Point(12, 12), - Size = new Size(48, 48), - SizeMode = PictureBoxSizeMode.Zoom - }; - using var headerIconStream = OpenEmbeddedAsset("KB_icon.ico"); - if (headerIconStream is not null) - { - using var image = new Icon(headerIconStream, headerIcon.Size).ToBitmap(); - headerIcon.Image = new Bitmap(image); - } - var logo = new PictureBox { Location = new Point(770, 15), @@ -62,12 +49,12 @@ public MainForm(VoiceBridgeService service) var title = new Label { Text = "Tilføj stemmer til Thorium", - Location = new Point(72, 12), + Location = new Point(12, 12), Size = new Size(500, 24), Font = new Font("Segoe UI", 11, FontStyle.Bold) }; - statusLabel.Location = new Point(72, 38); + statusLabel.Location = new Point(12, 38); statusLabel.Size = new Size(680, 32); danishOnly.Location = new Point(12, 74); leftLabel.Location = new Point(12, 100); @@ -81,7 +68,7 @@ public MainForm(VoiceBridgeService service) logBox.Location = new Point(12, 417); logBox.Size = new Size(948, 140); - Controls.AddRange([headerIcon, title, statusLabel, logo, danishOnly, leftLabel, rightLabel, leftList, rightList, actionButton, logBox]); + Controls.AddRange([title, statusLabel, logo, danishOnly, leftLabel, rightLabel, leftList, rightList, actionButton, logBox]); WireEvents(); Shown += (_, _) => RefreshLists(); KeyDown += OnKeyDown; From 028ad35c79a82b9808a62029ffed5cb746bb0448 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Mon, 14 Sep 2026 14:22:26 +0200 Subject: [PATCH 09/11] feat: Sing powershell script for manual local build+exe signing --- .vscode/launch.json | 5 +- README.md | 197 ++++++++++++++++++---------------- tools/Sign-WindowsRelease.ps1 | 152 ++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 95 deletions(-) create mode 100644 tools/Sign-WindowsRelease.ps1 diff --git a/.vscode/launch.json b/.vscode/launch.json index a1f3074..c36d7db 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,9 +6,10 @@ "configurations": [ { "name": "C#: Thorium Voice Bridge", - "type": "dotnet", + "type": "coreclr", "request": "launch", - "projectPath": "${workspaceFolder}/src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj", + "program": "${workspaceFolder}/src/ThoriumVoiceBridge/bin/Debug/net10.0-windows/win-x64/ThoriumVoiceBridge.exe", + "cwd": "${workspaceFolder}/src/ThoriumVoiceBridge/bin/Debug/net10.0-windows/win-x64", "preLaunchTask": "build Thorium Voice Bridge" } ] diff --git a/README.md b/README.md index 3c38ee3..72256da 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,104 @@ -# tts-windows-voice-fixer - -Windows-værktøj der kopierer installerede SAPI 5-oplæsningsstemmer over i Windows' -OneCore-stemmeregister, så de kan bruges i **Thorium Reader**. Stemmer der er tilføjet -af værktøjet kan også fjernes igen. - -Skrevet af Joshua Lindemann for Det Kgl. Biblioteks Nota-service. -Brugerfladen er på dansk; kode, filnavne og dokumentation er på engelsk. - -## How it works - -Thorium Reader reads voices from `HKLM\SOFTWARE\Microsoft\Speech_OneCore\Voices\Tokens`, -while most third-party voices register themselves under the older SAPI 5 hive at -`HKLM\SOFTWARE\Microsoft\Speech\Voices\Tokens`. The app mirrors selected SAPI tokens into -the OneCore hive and tags each copy with a `ThoriumBridge` marker value, so it only ever -removes entries it created itself. Microsoft's own voices are skipped. - -Because it writes to `HKLM`, the app requires administrator rights. - -## Repository layout - -| Path | Contents | -| --- | --- | -| `src/ThoriumVoiceBridge.Core/` | Platform-neutral voice bridge service and registry abstraction | -| `src/ThoriumVoiceBridge/` | Native .NET 10 WinForms application, Windows registry adapter, and image assets | -| `signing/` | Azure Artifact Signing (formerly Trusted Signing) account metadata | -| `.github/workflows/` | CI: build, sign, and release the executable | - -## Distribution - -The release is a single self-contained, signed `ThoriumVoiceBridge.exe` for 64-bit -Windows. It has no .NET or PowerShell dependency. Windows asks for administrator -permission when it starts, because the app writes to `HKLM`. - -## Building locally - -Requires the .NET 10 SDK. The Windows-targeted project can be compiled and published -from macOS or Linux with `EnableWindowsTargeting`; the resulting app runs on Windows. - -```powershell -dotnet test ThoriumVoiceBridge.slnx -dotnet publish src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj -c Release -r win-x64 --self-contained true -``` - -The native app uses a manifest to request administrator rights before opening the -machine-wide voice registry. - -## Releasing - -Every push to `main` builds, signs and uploads the executable as a workflow artifact. - -To publish a release, bump `` in -[src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj](src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj), update -[CHANGELOG.md](CHANGELOG.md), merge to `main`, then run the **Build** workflow manually -with **Create a GitHub Release** enabled. The version in the csproj is the single source -of truth — it drives the EXE file version, artifact name, and release tag. - -## Signing setup - -Signing uses [`azure/artifact-signing-action`](https://github.com/Azure/artifact-signing-action) -with the account details in [signing/metadata.json](signing/metadata.json). - -### Manual Signing -For a manual Windows build using Azure CLI authentication, see -[docs/manual-windows-signing.md](docs/manual-windows-signing.md). - -### Automated Workflow - -Authentication is OpenID Connect, so no secret values are stored in the repository. The -following repository secrets must point at an app registration with a federated credential -for this repo and the **Certificate Profile Signer** role on the signing account: - -- `AZURE_CLIENT_ID` -- `AZURE_TENANT_ID` -- `AZURE_SUBSCRIPTION_ID` - -Builds triggered by pull requests from forks cannot reach the signing service and produce an -unsigned executable, which is fine for smoke-testing but must never be distributed. - -## Testing architecture - -The service operates on `IVoiceRegistry`, so tests use an in-memory implementation and -run on macOS without touching Windows. They cover token copying, language normalization, -Microsoft-voice filtering, existing-token protection, and marker-protected removal. -The real `WindowsVoiceRegistry` uses the explicit 64-bit `HKLM` registry view and is -validated by Windows CI and manual Windows smoke testing. - -## UI preview - -The WinForms app only runs on Windows, but the static preview at -[tools/ui-preview/index.html](tools/ui-preview/index.html) mirrors the fixed-size form layout and -loads the same KB icon and logo assets. Open it in a browser on macOS for quick visual checks -before doing the final Windows smoke test. +# tts-windows-voice-fixer + +Windows-værktøj der kopierer installerede SAPI 5-oplæsningsstemmer over i Windows' +OneCore-stemmeregister, så de kan bruges i **Thorium Reader**. Stemmer der er tilføjet +af værktøjet kan også fjernes igen. + +Skrevet af Joshua Lindemann for Det Kgl. Biblioteks Nota-service. +Brugerfladen er på dansk; kode, filnavne og dokumentation er på engelsk. + +## How it works + +Thorium Reader reads voices from `HKLM\SOFTWARE\Microsoft\Speech_OneCore\Voices\Tokens`, +while most third-party voices register themselves under the older SAPI 5 hive at +`HKLM\SOFTWARE\Microsoft\Speech\Voices\Tokens`. The app mirrors selected SAPI tokens into +the OneCore hive and tags each copy with a `ThoriumBridge` marker value, so it only ever +removes entries it created itself. Microsoft's own voices are skipped. + +Because it writes to `HKLM`, the app requires administrator rights. + +## Repository layout + +| Path | Contents | +| --- | --- | +| `src/ThoriumVoiceBridge.Core/` | Platform-neutral voice bridge service and registry abstraction | +| `src/ThoriumVoiceBridge/` | Native .NET 10 WinForms application, Windows registry adapter, and image assets | +| `signing/` | Azure Artifact Signing (formerly Trusted Signing) account metadata | +| `.github/workflows/` | CI: build, sign, and release the executable | + +## Distribution + +The release is a single self-contained, signed `ThoriumVoiceBridge.exe` for 64-bit +Windows. It has no .NET or PowerShell dependency. Windows asks for administrator +permission when it starts, because the app writes to `HKLM`. + +## Building locally + +Requires the .NET 10 SDK. The Windows-targeted project can be compiled and published +from macOS or Linux with `EnableWindowsTargeting`; the resulting app runs on Windows. + +```powershell +dotnet test ThoriumVoiceBridge.slnx +dotnet publish src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj -c Release -r win-x64 --self-contained true +``` + +The native app uses a manifest to request administrator rights before opening the +machine-wide voice registry. + +## Releasing + +Every push to `main` builds, signs and uploads the executable as a workflow artifact. + +To publish a release, bump `` in +[src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj](src/ThoriumVoiceBridge/ThoriumVoiceBridge.csproj), update +[CHANGELOG.md](CHANGELOG.md), merge to `main`, then run the **Build** workflow manually +with **Create a GitHub Release** enabled. The version in the csproj is the single source +of truth — it drives the EXE file version, artifact name, and release tag. + +## Signing setup + +Signing uses [`azure/artifact-signing-action`](https://github.com/Azure/artifact-signing-action) +with the account details in [signing/metadata.json](signing/metadata.json). + +### Manual Signing +For a manual Windows build using Azure CLI authentication, see +[docs/manual-windows-signing.md](docs/manual-windows-signing.md). + +After the prerequisites are installed and `az login` has been completed, run the +release build, signing, and signature verification with: + +```powershell +.\tools\Sign-WindowsRelease.ps1 +``` + +Use `.\tools\Sign-WindowsRelease.ps1 -CheckOnly` to validate the local signing +tools without building or signing anything. The signed executable is written to +`artifacts/ThoriumVoiceBridge-.exe`. + +### Automated Workflow + +Authentication is OpenID Connect, so no secret values are stored in the repository. The +following repository secrets must point at an app registration with a federated credential +for this repo and the **Certificate Profile Signer** role on the signing account: + +- `AZURE_CLIENT_ID` +- `AZURE_TENANT_ID` +- `AZURE_SUBSCRIPTION_ID` + +Builds triggered by pull requests from forks cannot reach the signing service and produce an +unsigned executable, which is fine for smoke-testing but must never be distributed. + +## Testing architecture + +The service operates on `IVoiceRegistry`, so tests use an in-memory implementation and +run on macOS without touching Windows. They cover token copying, language normalization, +Microsoft-voice filtering, existing-token protection, and marker-protected removal. +The real `WindowsVoiceRegistry` uses the explicit 64-bit `HKLM` registry view and is +validated by Windows CI and manual Windows smoke testing. + +## UI preview + +The WinForms app only runs on Windows, but the static preview at +[tools/ui-preview/index.html](tools/ui-preview/index.html) mirrors the fixed-size form layout and +loads the same KB icon and logo assets. Open it in a browser on macOS for quick visual checks +before doing the final Windows smoke test. diff --git a/tools/Sign-WindowsRelease.ps1 b/tools/Sign-WindowsRelease.ps1 new file mode 100644 index 0000000..79eea78 --- /dev/null +++ b/tools/Sign-WindowsRelease.ps1 @@ -0,0 +1,152 @@ +[CmdletBinding()] +param( + [switch]$CheckOnly, + [switch]$SkipTests +) + +$ErrorActionPreference = 'Stop' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +Set-Location $repoRoot + +function Find-Executable { + param( + [Parameter(Mandatory)] [string]$CommandName, + [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Candidates + ) + + $command = Get-Command $CommandName -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + + foreach ($candidate in $Candidates) { + if (Test-Path $candidate) { return $candidate } + } + + return $null +} + +function Find-SignTool { + $roots = @( + (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'), + (Join-Path $env:ProgramFiles 'Windows Kits\10\bin') + ) | Where-Object { $_ -and (Test-Path $_) } + + $tool = $roots | + ForEach-Object { Get-ChildItem $_ -Recurse -Filter signtool.exe -File -ErrorAction SilentlyContinue } | + Where-Object { $_.FullName -match '\\x64\\' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + + if ($tool) { return $tool.FullName } + return $null +} + +function Find-Dlib { + $roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:LOCALAPPDATA) | + Where-Object { $_ -and (Test-Path $_) } | + Select-Object -Unique + + $candidates = $roots | + ForEach-Object { Get-ChildItem $_ -Recurse -Filter Azure.CodeSigning.Dlib.dll -File -ErrorAction SilentlyContinue } | + Sort-Object FullName + + $dlib = $candidates | + Where-Object { $_.FullName -match '\\x64\\' } | + Select-Object -First 1 + + if (-not $dlib) { $dlib = $candidates | Select-Object -First 1 } + + if ($dlib) { return $dlib.FullName } + return $null +} + +$az = Find-Executable 'az' @( + 'C:\Program Files\Microsoft SDKs\Azure\CLI2\wbin\az.cmd', + 'C:\Program Files (x86)\Microsoft SDKs\Azure\CLI2\wbin\az.cmd', + (Join-Path $env:LOCALAPPDATA 'Programs\Azure CLI\wbin\az.cmd') +) +$dotnet = Find-Executable 'dotnet' @() +$signTool = Find-SignTool +$dlib = Find-Dlib + +if (-not $az) { throw 'Azure CLI (az.cmd) was not found. Install it or add its wbin directory to PATH.' } +if (-not $dotnet) { throw '.NET SDK (dotnet.exe) was not found.' } +if (-not $signTool) { throw 'x64 signtool.exe was not found. Install the Windows SDK 10.0.2261.755 or newer.' } +if (-not $dlib) { throw 'x64 Azure.CodeSigning.Dlib.dll was not found. Install Microsoft Artifact Signing Client Tools.' } + +$project = Join-Path $repoRoot 'src\ThoriumVoiceBridge\ThoriumVoiceBridge.csproj' +$metadataPath = Join-Path $repoRoot 'signing\metadata.json' +$csproj = [xml](Get-Content $project -Raw) +$version = $csproj.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 +if (-not $version) { throw "No found in $project" } + +$metadata = Get-Content $metadataPath -Raw | ConvertFrom-Json +$artifact = Join-Path $repoRoot "artifacts\ThoriumVoiceBridge-$version.exe" + +Write-Host "Repository: $repoRoot" +Write-Host "Version: $version" +Write-Host "Azure CLI: $az" +Write-Host "dotnet: $dotnet" +Write-Host "signtool: $signTool" +Write-Host "DLib: $dlib" +Write-Host "Endpoint: $($metadata.Endpoint)" + +if ($CheckOnly) { + Write-Host 'Prerequisite check passed.' -ForegroundColor Green + exit 0 +} + +$accountJson = & $az account show --output json +if ($LASTEXITCODE -ne 0) { + Write-Host 'No active Azure CLI login found. Starting interactive Azure login.' + & $az login + if ($LASTEXITCODE -ne 0) { throw 'Azure CLI login failed.' } + $accountJson = & $az account show --output json + if ($LASTEXITCODE -ne 0) { throw 'Could not read the Azure CLI account after login.' } +} + +$account = $accountJson | ConvertFrom-Json +Write-Host "Azure user: $($account.user.name)" +Write-Host "Azure tenant: $($account.tenantId)" +Write-Host "Azure subscription: $($account.name)" + +$accessToken = & $az account get-access-token --resource 'https://codesigning.azure.net' --query accessToken --output tsv +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($accessToken)) { + throw 'Could not acquire an Artifact Signing access token from Azure CLI.' +} + +if (-not $SkipTests) { + & $dotnet test (Join-Path $repoRoot 'ThoriumVoiceBridge.slnx') + if ($LASTEXITCODE -ne 0) { throw 'Tests failed; release was not published.' } +} + +& $dotnet publish $project --configuration Release --runtime win-x64 --self-contained true "-p:Version=$version" +if ($LASTEXITCODE -ne 0) { throw 'Publish failed; release was not signed.' } + +$publishOutput = Join-Path $repoRoot 'src\ThoriumVoiceBridge\bin\Release\net10.0-windows\win-x64\publish\ThoriumVoiceBridge.exe' +if (-not (Test-Path $publishOutput)) { throw "Published executable was not found at $publishOutput" } + +New-Item -ItemType Directory -Force -Path (Split-Path $artifact) | Out-Null +Copy-Item $publishOutput $artifact -Force + +$temporaryMetadataPath = Join-Path ([IO.Path]::GetTempPath()) "ThoriumVoiceBridge-signing-$([guid]::NewGuid().ToString('N')).json" +try { + $temporaryMetadata = [ordered]@{ + Endpoint = $metadata.Endpoint + CodeSigningAccountName = $metadata.CodeSigningAccountName + CertificateProfileName = $metadata.CertificateProfileName + AccessToken = $accessToken + } | ConvertTo-Json + [IO.File]::WriteAllText($temporaryMetadataPath, $temporaryMetadata, [Text.UTF8Encoding]::new($false)) + + & $signTool sign /v /debug /fd SHA256 /tr 'http://timestamp.acs.microsoft.com' /td SHA256 /dlib $dlib /dmdf $temporaryMetadataPath $artifact + if ($LASTEXITCODE -ne 0) { throw 'Artifact signing failed.' } +} +finally { + Remove-Item $temporaryMetadataPath -Force -ErrorAction SilentlyContinue +} + +$signature = Get-AuthenticodeSignature $artifact +Write-Host "$artifact -> $($signature.Status) / $($signature.SignerCertificate.Subject)" +if ($signature.Status -ne 'Valid') { throw 'The executable signature is not valid.' } + +Write-Host "Signed release: $artifact" -ForegroundColor Green \ No newline at end of file From 49810f5d0d53eca9012add4348c1095d6b9b2aa9 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Mon, 14 Sep 2026 15:21:23 +0200 Subject: [PATCH 10/11] =?UTF-8?q?chore:=20rename=20.exe=20to=20"Tilf=C3=B8?= =?UTF-8?q?j=20stemmer=20til=20Thorium"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 6 +- .vscode/launch.json | 2 +- README.md | 15 ++++- docs/manual-windows-signing.md | 25 +++++++- packaging/Package.wxs | 2 +- .../ThoriumVoiceBridge.csproj | 2 +- tools/Sign-WindowsRelease.ps1 | 58 +++++++++++++++++-- 7 files changed, 94 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d189f9d..bbd2f56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,8 +54,8 @@ jobs: shell: pwsh run: | New-Item -ItemType Directory -Force -Path artifacts | Out-Null - $out = "artifacts/ThoriumVoiceBridge-${{ steps.version.outputs.version }}.exe" - Copy-Item src/ThoriumVoiceBridge/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $out + $out = "artifacts/Tilføj stemmer til Thorium.exe" + Copy-Item "src/ThoriumVoiceBridge/bin/Release/net10.0-windows/win-x64/publish/Tilføj stemmer til Thorium.exe" $out Get-Item $out | Format-Table Name, Length - name: Read signing metadata @@ -103,7 +103,7 @@ jobs: if: steps.gate.outputs.sign == 'true' shell: pwsh run: | - $file = "artifacts/ThoriumVoiceBridge-${{ steps.version.outputs.version }}.exe" + $file = "artifacts/Tilføj stemmer til Thorium.exe" $sig = Get-AuthenticodeSignature $file Write-Host "$file -> $($sig.Status) / $($sig.SignerCertificate.Subject)" if ($sig.Status -ne 'Valid') { throw "The executable is not validly signed." } diff --git a/.vscode/launch.json b/.vscode/launch.json index c36d7db..ac24f4e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "name": "C#: Thorium Voice Bridge", "type": "coreclr", "request": "launch", - "program": "${workspaceFolder}/src/ThoriumVoiceBridge/bin/Debug/net10.0-windows/win-x64/ThoriumVoiceBridge.exe", + "program": "${workspaceFolder}/src/ThoriumVoiceBridge/bin/Debug/net10.0-windows/win-x64/Tilføj stemmer til Thorium.exe", "cwd": "${workspaceFolder}/src/ThoriumVoiceBridge/bin/Debug/net10.0-windows/win-x64", "preLaunchTask": "build Thorium Voice Bridge" } diff --git a/README.md b/README.md index 72256da..7744144 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Because it writes to `HKLM`, the app requires administrator rights. ## Distribution -The release is a single self-contained, signed `ThoriumVoiceBridge.exe` for 64-bit +The release is a single self-contained, signed `Tilføj stemmer til Thorium.exe` for 64-bit Windows. It has no .NET or PowerShell dependency. Windows asks for administrator permission when it starts, because the app writes to `HKLM`. @@ -73,7 +73,18 @@ release build, signing, and signature verification with: Use `.\tools\Sign-WindowsRelease.ps1 -CheckOnly` to validate the local signing tools without building or signing anything. The signed executable is written to -`artifacts/ThoriumVoiceBridge-.exe`. +`artifacts/Tilføj stemmer til Thorium.exe`. + +To create the matching `v` GitHub release and upload the signed executable, +install and authenticate GitHub CLI, commit and push the release changes, then run: + +```powershell +winget install --id GitHub.cli +gh auth login +.\tools\Sign-WindowsRelease.ps1 -PublishRelease +``` + +If the release already exists at the current commit, its executable asset is replaced. ### Automated Workflow diff --git a/docs/manual-windows-signing.md b/docs/manual-windows-signing.md index b89ce8d..16dd836 100644 --- a/docs/manual-windows-signing.md +++ b/docs/manual-windows-signing.md @@ -80,8 +80,8 @@ Copy the executable to the release artifact path: ```powershell New-Item -ItemType Directory -Force -Path artifacts | Out-Null -$out = "artifacts/ThoriumVoiceBridge-$version.exe" -Copy-Item src/ThoriumVoiceBridge/bin/Release/net10.0-windows/win-x64/publish/ThoriumVoiceBridge.exe $out +$out = "artifacts/Tilføj stemmer til Thorium.exe" +Copy-Item "src/ThoriumVoiceBridge/bin/Release/net10.0-windows/win-x64/publish/Tilføj stemmer til Thorium.exe" $out Get-Item $out | Format-Table Name, Length ``` @@ -144,9 +144,28 @@ if ($sig.Status -ne "Valid") { The finished executable is written to: ```text -artifacts/ThoriumVoiceBridge-.exe +artifacts/Tilføj stemmer til Thorium.exe ``` +## Publish a GitHub release + +The signing script can create the matching `v` GitHub release and upload the +verified executable. Install GitHub CLI and authenticate it first: + +```powershell +winget install --id GitHub.cli +gh auth login +``` + +Commit and push the release changes, then build, sign, verify, and publish with: + +```powershell +.\tools\Sign-WindowsRelease.ps1 -PublishRelease +``` + +The script refuses to publish with tracked worktree changes. If the release already +exists, its tag must point to the current commit and the executable asset is replaced. + ## Troubleshooting - `SignerSign() failed` or `403 Forbidden`: confirm `az account show` is using diff --git a/packaging/Package.wxs b/packaging/Package.wxs index f2a8ea3..3316791 100644 --- a/packaging/Package.wxs +++ b/packaging/Package.wxs @@ -46,7 +46,7 @@ - + enable enable latest - ThoriumVoiceBridge + Tilføj stemmer til Thorium Nota.ThoriumVoiceBridge app.manifest Assets\KB_icon.ico diff --git a/tools/Sign-WindowsRelease.ps1 b/tools/Sign-WindowsRelease.ps1 index 79eea78..b7e3b5b 100644 --- a/tools/Sign-WindowsRelease.ps1 +++ b/tools/Sign-WindowsRelease.ps1 @@ -1,7 +1,8 @@ [CmdletBinding()] param( [switch]$CheckOnly, - [switch]$SkipTests + [switch]$SkipTests, + [switch]$PublishRelease ) $ErrorActionPreference = 'Stop' @@ -65,27 +66,36 @@ $az = Find-Executable 'az' @( (Join-Path $env:LOCALAPPDATA 'Programs\Azure CLI\wbin\az.cmd') ) $dotnet = Find-Executable 'dotnet' @() +$gh = Find-Executable 'gh' @( + (Join-Path $env:ProgramFiles 'GitHub CLI\gh.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'GitHub CLI\gh.exe') +) $signTool = Find-SignTool $dlib = Find-Dlib if (-not $az) { throw 'Azure CLI (az.cmd) was not found. Install it or add its wbin directory to PATH.' } if (-not $dotnet) { throw '.NET SDK (dotnet.exe) was not found.' } +if ($PublishRelease -and -not $gh) { throw 'GitHub CLI (gh.exe) was not found. Install it with: winget install --id GitHub.cli' } if (-not $signTool) { throw 'x64 signtool.exe was not found. Install the Windows SDK 10.0.2261.755 or newer.' } if (-not $dlib) { throw 'x64 Azure.CodeSigning.Dlib.dll was not found. Install Microsoft Artifact Signing Client Tools.' } $project = Join-Path $repoRoot 'src\ThoriumVoiceBridge\ThoriumVoiceBridge.csproj' $metadataPath = Join-Path $repoRoot 'signing\metadata.json' -$csproj = [xml](Get-Content $project -Raw) +$csproj = [xml][IO.File]::ReadAllText($project) $version = $csproj.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 if (-not $version) { throw "No found in $project" } +$assemblyName = $csproj.Project.PropertyGroup.AssemblyName | Where-Object { $_ } | Select-Object -First 1 +if (-not $assemblyName) { throw "No found in $project" } $metadata = Get-Content $metadataPath -Raw | ConvertFrom-Json -$artifact = Join-Path $repoRoot "artifacts\ThoriumVoiceBridge-$version.exe" +$executableName = "$assemblyName.exe" +$artifact = Join-Path $repoRoot "artifacts\$executableName" Write-Host "Repository: $repoRoot" Write-Host "Version: $version" Write-Host "Azure CLI: $az" Write-Host "dotnet: $dotnet" +if ($PublishRelease) { Write-Host "GitHub CLI: $gh" } Write-Host "signtool: $signTool" Write-Host "DLib: $dlib" Write-Host "Endpoint: $($metadata.Endpoint)" @@ -95,6 +105,15 @@ if ($CheckOnly) { exit 0 } +if ($PublishRelease) { + & $gh auth status + if ($LASTEXITCODE -ne 0) { throw 'GitHub CLI is not authenticated. Run: gh auth login' } + + $trackedChanges = & git status --porcelain --untracked-files=no + if ($LASTEXITCODE -ne 0) { throw 'Could not read the Git worktree status.' } + if ($trackedChanges) { throw 'Commit tracked changes before publishing a GitHub release.' } +} + $accountJson = & $az account show --output json if ($LASTEXITCODE -ne 0) { Write-Host 'No active Azure CLI login found. Starting interactive Azure login.' @@ -122,7 +141,7 @@ if (-not $SkipTests) { & $dotnet publish $project --configuration Release --runtime win-x64 --self-contained true "-p:Version=$version" if ($LASTEXITCODE -ne 0) { throw 'Publish failed; release was not signed.' } -$publishOutput = Join-Path $repoRoot 'src\ThoriumVoiceBridge\bin\Release\net10.0-windows\win-x64\publish\ThoriumVoiceBridge.exe' +$publishOutput = Join-Path $repoRoot "src\ThoriumVoiceBridge\bin\Release\net10.0-windows\win-x64\publish\$executableName" if (-not (Test-Path $publishOutput)) { throw "Published executable was not found at $publishOutput" } New-Item -ItemType Directory -Force -Path (Split-Path $artifact) | Out-Null @@ -149,4 +168,33 @@ $signature = Get-AuthenticodeSignature $artifact Write-Host "$artifact -> $($signature.Status) / $($signature.SignerCertificate.Subject)" if ($signature.Status -ne 'Valid') { throw 'The executable signature is not valid.' } -Write-Host "Signed release: $artifact" -ForegroundColor Green \ No newline at end of file +Write-Host "Signed release: $artifact" -ForegroundColor Green + +if ($PublishRelease) { + $tag = "v$version" + $repository = & $gh repo view --json nameWithOwner --jq '.nameWithOwner' + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repository)) { + throw 'Could not determine the GitHub repository from the origin remote.' + } + + $headCommit = & git rev-parse HEAD + if ($LASTEXITCODE -ne 0) { throw 'Could not resolve the current Git commit.' } + + & $gh release view $tag --repo $repository *> $null + if ($LASTEXITCODE -eq 0) { + $releaseCommit = & $gh api "repos/$repository/commits/$tag" --jq '.sha' + if ($LASTEXITCODE -ne 0) { throw "Could not resolve release tag $tag." } + if ($releaseCommit -ne $headCommit) { + throw "Release $tag points to $releaseCommit, but the signed build is from $headCommit." + } + + & $gh release upload $tag $artifact --repo $repository --clobber + if ($LASTEXITCODE -ne 0) { throw "Could not upload $executableName to release $tag." } + } + else { + & $gh release create $tag $artifact --repo $repository --target $headCommit --title $tag --generate-notes + if ($LASTEXITCODE -ne 0) { throw "Could not create release $tag. Confirm the commit has been pushed." } + } + + Write-Host "Published GitHub release: https://github.com/$repository/releases/tag/$tag" -ForegroundColor Green +} \ No newline at end of file From 8403fdaf345cdb82b03f767181e383389871586d Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Mon, 14 Sep 2026 15:27:26 +0200 Subject: [PATCH 11/11] fix: gh use in publish script --- tools/Sign-WindowsRelease.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/Sign-WindowsRelease.ps1 b/tools/Sign-WindowsRelease.ps1 index b7e3b5b..801bfb3 100644 --- a/tools/Sign-WindowsRelease.ps1 +++ b/tools/Sign-WindowsRelease.ps1 @@ -180,7 +180,8 @@ if ($PublishRelease) { $headCommit = & git rev-parse HEAD if ($LASTEXITCODE -ne 0) { throw 'Could not resolve the current Git commit.' } - & $gh release view $tag --repo $repository *> $null + # gh writes "release not found" to stderr, which $ErrorActionPreference = 'Stop' would turn into a terminating error. + & { $ErrorActionPreference = 'Continue'; & $gh release view $tag --repo $repository 2>&1 | Out-Null } if ($LASTEXITCODE -eq 0) { $releaseCommit = & $gh api "repos/$repository/commits/$tag" --jq '.sha' if ($LASTEXITCODE -ne 0) { throw "Could not resolve release tag $tag." }