Compare commits

...
Author SHA1 Message Date
enricobuehler 2d15548e38 ci(windows): provision the signing toolchain — no .NET runtime meant signtool exited 3 in silence
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m59s
android / android (pull_request) Failing after 2m16s
ci / web (pull_request) Successful in 2m29s
ci / rust-arm64 (pull_request) Successful in 2m35s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m24s
apple / swift (pull_request) Successful in 2m7s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 5m25s
ci / rust (pull_request) Successful in 7m16s
ci / docs-site (pull_request) Successful in 7m48s
Verified the whole Azure signing path on the runner (.133) today and it failed twice, for two
reasons that neither error message named. Both are now provisioned here so a rebuild from the
unom/infra Packer template cannot silently un-fix them.

Azure.CodeSigning.Dlib.dll is a mixed-mode C++/CLI assembly: it ships Ijwhost.dll and a
runtimeconfig.json pinning Microsoft.NETCore.App 8.0.0. The runner had NO .NET runtime at all —
pwsh 7 is a self-contained install and brings no shared runtime — so signtool exited 3 having
printed absolutely nothing. Installing the .NET 8 runtime turned that into a clean sign.

The client itself installs machine-wide under C:\trusted-signing rather than a user's .nuget,
because act_runner runs as SYSTEM, whose USERPROFILE is C:\Windows\System32\config\systemprofile.
A per-user install under Administrator is invisible to every job that actually builds. Confirmed by
resolving Find-AzureDlib from a SYSTEM scheduled task, which is also how the earlier SSH-only
attempts misled: over a network logon New-SelfSignedCertificate hits NTE_PERM, so a control test
that "fails" there proves nothing about how CI will behave.

Both downloads are SHA-256 pinned against version-immutable URLs (nuget.org flat-container and the
dotnet builds CDN), so they fail closed on tampering rather than on every Microsoft patch release —
unlike the BtbN `latest` pin above, which re-rolls. The .NET install uses Start-Process -Wait
because the bundle is a GUI PE that returns instantly under `&`, leaving $LASTEXITCODE unset and
racing the completion check (cost one false failure here).

End-to-end result on .133, as SYSTEM: sign rc=0, verify rc=0, chain Microsoft Identity Verification
Root CA 2020 -> ID Verified CS EOC CA 04 -> "unom - Enrico Buhler", leaf thumbprint
DD6A610F242CB5B2078C2A5D628699B6AB0CAC07 (matches the profile Azure reports), timestamped, leaf
expires in 3 days as expected. Signing an unsigned binary and reading the subject back reproduces
pack-msix.ps1's Publisher assertion exactly (match=True) — checked against a NON-catalog-signed
binary on purpose, because Get-AuthenticodeSignature on a catalog-signed system exe returns the
catalog signer and would have read as a false mismatch.
2026-08-14 19:05:21 +02:00
enricobuehler b79ff45bd1 feat(windows): sign via Azure Artifact Signing — a 3-day leaf makes timestamping mandatory
Releases move from the self-signed CN=unom cert to Azure Artifact Signing (formerly Trusted
Signing): account `unomsigning`, profile `unom-io`, signed by the `punktfunk-ci-signing` service
principal, which holds only the Artifact Signing Certificate Profile Signer role scoped to that one
profile. Both pack scripts gain the backend ahead of the existing .pfx and ephemeral fallbacks, so
canary and fork builds are unaffected.

Three things that are easy to get wrong, and are handled here rather than discovered in the field:

Azure mints a leaf certificate per signing request that expires in about three days. Both scripts
previously retried WITHOUT a timestamp when a timestamped sign failed — under Azure that ships an
artifact which verifies on the runner and goes untrusted days later, on every user's machine at
once. The retry is now gated on the mode: still lenient for a .pfx whose cert outlives the release,
a hard failure for Azure.

The MSIX manifest Publisher must equal the signer subject byte-for-byte, because package identity is
Name + Publisher. The default is now the profile's verified subject, written with `[char]0xFC`
escapes rather than literal umlauts so this UTF-8-without-BOM file cannot silently mojibake the DN
into one that no longer matches. pack-msix.ps1 now also reads the signature back off the packed
.msix and fails on drift — asymmetric on purpose: a subject that disagrees is fatal, a subject that
cannot be read is only a warning, since Get-AuthenticodeSignature's .msix support varies by Windows
version and signtool has already reported success by then. NOTE this changes package identity, so
existing installs need an uninstall, not an upgrade.

The updater's leaf-pinning note was wrong and is corrected: update/windows.rs claimed the
AUTHENTICODE_SHA256 field made Trusted Signing "a manifest edit", but a per-request leaf is exactly
what a leaf pin cannot track — a pin would go stale within days and reject every release after it.

Drivers are deliberately untouched: their catalogs keep the DRIVER_CERT_* cert and the installer
still plants it as a machine root. The two signatures were always independent (SmartScreen/UAC vs
PnP), which is why the installer could move without them. Whether a publicly-trusted catalog would
let us drop that root plant is recorded as an unverified follow-up, not assumed.

Verified: both scripts parse under the PowerShell 7 AST parser, both workflows are valid YAML, the
evaluated Publisher default matches the subject Azure reports for the profile (86 chars, ordinal),
rustfmt clean. NOT verified on Windows — the sign path itself needs an on-glass run on .133.
2026-08-14 18:24:27 +02:00
enricobuehler d0a3eca7b8 Merge pull request 'A per-user Playnite install is invisible to a SYSTEM host, and one tile killed the whole library' (#225) from fix/playnite-launcher-resolve into main
arch / build-publish (push) Successful in 9m22s
deb / build-publish-host (push) Successful in 5m46s
ci / docs-site (push) Successful in 3m54s
deb / build-publish-gamescope (push) Successful in 33s
ci / rust-arm64 (push) Successful in 4m33s
ci / rust (push) Successful in 5m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / deploy-docs (push) Successful in 52s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
windows-host / package (push) Successful in 16m20s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m34s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 4m0s
windows-host / canary-manifest (push) Successful in 38s
deb / smoke-install (push) Successful in 6m25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m3s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
android / android (push) Successful in 7m28s
deb / build-publish-client-arm64 (push) Successful in 3m31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m2s
ci / web (push) Successful in 1m4s
ci / bun-nix (push) Successful in 1m30s
docker / builders-arm64cross (push) Successful in 50s
2026-08-14 16:07:37 +00:00
enricobuehler bf741f8693 fix(library): a per-user Playnite install is invisible to a SYSTEM host, and one tile killed the library
ci / web (pull_request) Successful in 1m13s
ci / bun-nix (pull_request) Successful in 1m25s
ci / rust (pull_request) Successful in 4m18s
ci / docs-site (pull_request) Successful in 4m27s
ci / rust-arm64 (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 7m42s
Syncing the Playnite plugin failed outright:

  PUT /library/provider/playnite failed: entries[9]: launch.value for kind
  launcher_ui names a launcher this host cannot open (playnite)

Two defects, and the second is why it cost every game rather than one tile.

1. The host looked for Playnite in the wrong registry hive and the wrong
   profile. `playnite_fullscreen_exe()` read HKEY_CURRENT_USER, then fell back
   to %LOCALAPPDATA% — but the Windows host is a LocalSystem service, so its
   HKCU is the SYSTEM hive (S-1-5-18) and its %LOCALAPPDATA% is
   C:\Windows\System32\config\systemprofile\AppData\Local. Playnite installs
   per-user by default, so both lookups miss on a default install. The doc
   comment reasoned correctly that Playnite is per-user and then read the one
   HKCU that cannot see it.

   It also hardcoded `…\Uninstall\Playnite`. Playnite ships an Inno Setup
   installer, and Inno registers `<AppId>_is1` — measured on a Windows box
   where Git and Inno itself appear as `Git_is1` and `Inno Setup 6_is1` — so
   that key matched nothing anywhere.

   Now: every loaded hive under HKEY_USERS plus both HKLM views, matched on
   DisplayName rather than key name, then `C:\Users\*\AppData\Local\Playnite`
   for the conventional install (and for a user whose hive is not loaded).

2. One unopenable tile 400'd the whole reconcile. The Playnite plugin appends
   a single launcher tile beside its games, so refusing the payload cost the
   operator the entire library — the same shape as the unservable-cover bug
   that sanitize_art_paths was introduced to fix, on the launch side this time.

   `valid_launcher_ui` conflated two different failures. Split into
   `known_launcher_ui` (vocabulary — a plugin bug, still a hard 400, because
   the author has no other way to find out) and `resolvable_launcher_ui`
   (environment — the launcher just is not installed here, which is a fact
   about the box). `sanitize_launcher_entries` drops only the latter, with one
   warn, and the games sync.
2026-08-14 15:28:41 +02:00
14 changed files with 716 additions and 138 deletions
+13
View File
@@ -257,6 +257,19 @@ jobs:
if: github.event_name != 'pull_request'
shell: pwsh
env:
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
# when all three are set. Not secret: an account/profile name and a regional endpoint,
# inert without the credentials below. The profile's verified subject is also the MSIX
# manifest Publisher; pack-msix.ps1 reads the signature back and fails on a mismatch.
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
AZURE_CODESIGNING_ACCOUNT: unomsigning
AZURE_CODESIGNING_PROFILE: unom-io
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
# Legacy self-signed path, kept as the fallback for builds without Azure access.
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
+23 -3
View File
@@ -20,12 +20,18 @@
# main push / dispatch -> <next-minor>.<run_number> (canary; `canary/` alias; base one minor
# ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
#
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
# (import once to LocalMachine\TrustedPublisher). That fallback is for canary/CI ONLY — on a v* tag
# Signing goes through Azure Artifact Signing (account `unomsigning`, profile `unom-io`) — a publicly
# trusted CA, so there is no .cer for users to import and no SmartScreen "unknown publisher" prompt.
# It falls back to the old MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD self-signed cert, and then to an
# ephemeral one, for builds without Azure access. Those fallbacks are for canary/CI ONLY — on a v* tag
# the pack script FAILS CLOSED rather than ship a release signed by a per-build throwaway cert.
# See packaging/windows/pack-host-installer.ps1.
#
# The bundled DRIVERS are NOT signed by Azure — they keep their own DRIVER_CERT_* cert and are still
# trusted by planting that cert in the machine Root store at install time. Independent by design:
# Windows checks the installer's signature via SmartScreen/UAC and driver catalogs via PnP, and never
# requires a common signer. See packaging/windows/README.md for why that root-plant is still there.
#
# GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer.
# - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
# RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
@@ -415,12 +421,26 @@ jobs:
- name: Pack + sign installer
shell: pwsh
env:
# Azure Artifact Signing (formerly Trusted Signing) — takes precedence over MSIX_CERT_*
# when all three of these are set. Not secret: an account/profile name and a regional
# endpoint, all inert without the credentials below, so they live here where a reviewer
# can see which profile a release was signed by.
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
AZURE_CODESIGNING_ACCOUNT: unomsigning
AZURE_CODESIGNING_PROFILE: unom-io
# Service principal 'punktfunk-ci-signing', holding ONLY the Artifact Signing Certificate
# Profile Signer role, scoped to the unom-io profile — it can sign and nothing else.
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
# Legacy self-signed path, kept as the fallback for builds without Azure access.
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
# The DRIVER cert is separate from the host/MSIX one and reaches the two driver build
# scripts through the environment (pack-host-installer.ps1 invokes them, they read
# $env:DRIVER_CERT_PFX_B64 themselves). Without it they sign with a per-build throwaway,
# which the installer then trusts as a machine root — see packaging/windows/README.md.
# NOT moved to Azure: driver catalogs are a separate track, see that README.
DRIVER_CERT_PFX_B64: ${{ secrets.DRIVER_CERT_PFX_B64 }}
DRIVER_CERT_PASSWORD: ${{ secrets.DRIVER_CERT_PASSWORD }}
run: |
+8 -2
View File
@@ -3,8 +3,14 @@
MSIX package manifest for the punktfunk Windows client (WinUI 3 via windows-reactor).
This is a TEMPLATE: packaging/pack-msix.ps1 substitutes {VERSION} (4-part numeric, e.g.
0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — default
`CN=unom` for the self-signed CI cert; a real code-signing cert just passes its own subject).
0.2.137.0) and {PUBLISHER} (must EXACTLY equal the signing cert's subject DN — the default is
the verified subject of the Azure `unom-io` certificate profile; the self-signed fallback mints
a throwaway cert with that same subject so canary and release share a package identity).
Package identity is Name + Publisher, so changing {PUBLISHER} makes this a DIFFERENT package:
installs of the older publisher cannot be upgraded in place and must be uninstalled first. That
is a user-visible migration, not a packaging detail — mention it in the release notes. pack-msix.ps1
reads the signature back off the packed .msix and fails the build if the two ever drift.
Why this packages cleanly even though the app was built "unpackaged": windows-reactor calls
MddBootstrapInitialize2 with OnPackageIdentity_NOOP (crates/libs/reactor/src/app.rs), so under
+29 -18
View File
@@ -56,34 +56,45 @@ MSIX requires a strictly 4-part numeric version. The workflow computes:
## Signing & install
CI signs every build with a **stable self-signed code-signing cert** (`CN=unom`, SHA-1
`CD1EFDEEEC9743AFC38F56C5AF30C5A3009BE941`, valid to 2036). Its public half is checked in as
[`punktfunk-codesign.cer`](punktfunk-codesign.cer); the private `.pfx` + password live in the
`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD` Actions secrets. Because it's the *same* cert every build,
trusting it is **one-time, per machine** — once imported, every future build and in-place upgrade is
trusted with no further prompt:
CI signs every build with **Azure Artifact Signing** (formerly Trusted Signing) — account
`unomsigning`, certificate profile `unom-io`, endpoint `https://neu.codesigning.azure.net/`. That
chain is publicly trusted, so **there is nothing to import**:
```powershell
# once per machine (elevated): trust the publisher
Import-Certificate -FilePath .\punktfunk-codesign.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople
# then install the package for your CPU (and re-run for each upgrade — no re-trust needed)
# install the package for your CPU (and re-run for each upgrade)
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_x64.msix # Intel/AMD
Add-AppxPackage -Path .\punktfunk-client-windows_<ver>_arm64.msix # ARM64 (Snapdragon, etc.)
```
The matching `.cer` is also published next to each `.msix` in the registry, so it's always at hand.
The MSIX declares a dependency on the Windows App SDK 2.x runtime; install
[the App SDK runtime](https://aka.ms/windowsappsdk) if `Add-AppxPackage` reports a missing
`Microsoft.WindowsAppRuntime.2` framework.
`pack-msix.ps1` signing precedence: it uses the **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** secrets
when present (the stable cert above), else generates an *ephemeral* self-signed cert (forks / local
builds without the secrets). Either way it exports the signing cert's public `.cer` for the import.
**To move to a publicly-trusted (no-import) cert** — Azure Artifact Signing or a public OV cert —
replace the two secrets with the new `.pfx`; the cert's subject DN must equal the manifest
`Publisher`, so pass a matching `-Publisher` (it's stamped into the package `Identity`, and changing
it changes the package identity → a one-time reinstall).
### How signing resolves
`pack-msix.ps1` picks a backend in this order:
1. **Azure Artifact Signing** when `AZURE_CODESIGNING_ENDPOINT` / `_ACCOUNT` / `_PROFILE` are all
set (the workflow sets them; they aren't secret). Credentials come from `AZURE_TENANT_ID` /
`AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` — the `punktfunk-ci-signing` service principal, which
holds only the *Artifact Signing Certificate Profile Signer* role scoped to the `unom-io` profile.
Keys are HSM-backed and never leave Azure, so there is no `.pfx` and no `.cer` is emitted.
2. **`MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD`** — the older stable self-signed cert (`CN=unom`,
public half checked in as [`punktfunk-codesign.cer`](punktfunk-codesign.cer)), kept as a fallback.
3. An **ephemeral** self-signed cert (forks / local builds with no secrets at all).
Modes 2 and 3 still export a `.cer` to import into `Cert:\LocalMachine\TrustedPeople` first. On a
`v*` tag, a build with no real signing backend **fails closed** rather than shipping a throwaway.
Two things about Azure mode that are easy to get wrong:
- **Timestamping is mandatory, not best-effort.** Azure mints a leaf cert per request that expires in
about three days. An untimestamped signature therefore stops verifying within days of release, so
the script refuses to retry without one (modes 2 and 3 keep the old best-effort retry).
- **The manifest `Publisher` must equal the signer's subject exactly**, because MSIX package identity
is Name + Publisher. The default `-Publisher` is the `unom-io` profile's verified subject; after
signing, the script reads the signature back off the `.msix` and fails the build on any drift.
Changing it makes a *different* package — existing installs must be uninstalled, not upgraded.
## Building locally
+130 -23
View File
@@ -13,15 +13,22 @@
packaging/windows/pack-host-installer.ps1 still ships them for its amf-qsv encode path.
Signing cert precedence:
0. Azure Artifact Signing (formerly Trusted Signing) when AZURE_CODESIGNING_ENDPOINT/_ACCOUNT/
_PROFILE are all set. HSM-backed, so there is no .pfx and nothing to export: the chain is
publicly trusted, so no .cer is produced and MSIX_CER_PATH stays unset.
1. -PfxBase64 / -PfxPassword (a real or shared code-signing cert, e.g. from CI secrets) — the
cert's subject DN MUST match -Publisher (which is stamped into the manifest Identity).
2. otherwise an EPHEMERAL self-signed code-signing cert with subject = -Publisher is generated
in-process. The package installs only where that cert is trusted, so the matching public
.cer is exported next to the .msix for the user to import (Trusted People) before install.
Swap in a real cert later with zero manifest changes — just pass -PfxBase64/-Publisher.
This fallback is for canary/CI/dev ONLY: on a v* tag build a missing cert is a hard failure
(-RequireSignedCert), never a silent downgrade to a throwaway cert.
WHICHEVER mode runs, the signed .msix is read back and its signer subject compared to -Publisher;
a mismatch fails the build. MSIX package identity is Name + Publisher, so a publisher that does
not match the signer is not a cosmetic problem — Add-AppxPackage rejects the package outright,
and it would only be discovered by a user trying to install the release.
Run on the Windows runner (or the dev VM) with the MSVC/Windows SDK present.
.EXAMPLE
@@ -36,9 +43,21 @@ param(
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release output dir (has the exe)
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64', # package ProcessorArchitecture + artifact suffix
[string]$OutDir = (Join-Path $TargetDir 'msix'),
[string]$Publisher = 'CN=unom', # MUST equal the signing cert subject DN
# MUST equal the signing cert subject DN — this is the verified subject the Azure 'unom-io'
# certificate profile issues. The 'ü' is written as an escape, not a literal: this file is UTF-8
# with no BOM, and read by anything other than pwsh 7 a literal would silently mojibake into a
# publisher that no longer matches the signer, which surfaces only as an Add-AppxPackage refusal
# on a user's machine. Verified against the real signer after signing below.
[string]$Publisher = "CN=unom - Enrico B$([char]0xFC)hler, O=unom - Enrico B$([char]0xFC)hler, L=Rottweil, S=Baden-W$([char]0xFC)rttemberg, C=DE",
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # optional: base64 of a code-signing .pfx
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
# Azure Artifact Signing. All three select it, ahead of any .pfx. Credentials arrive through the
# environment via DefaultAzureCredential (AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET)
# rather than as arguments, so they cannot leak into a process listing or a transcript.
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT, # e.g. https://neu.codesigning.azure.net/
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT, # signing account name
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE, # certificate profile name
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB, # path to Azure.CodeSigning.Dlib.dll
# 'auto' (default) = required iff this is a v* tag build; 'true'/'false' to force. See below.
[ValidateSet('auto', 'true', 'false')][string]$RequireSignedCert = 'auto'
)
@@ -64,6 +83,28 @@ function Find-SdkTool([string]$name) {
if (-not $hit) { throw "$name not found under $root — install the Windows 10/11 SDK." }
$hit.FullName
}
# Azure.CodeSigning.Dlib.dll ships in the Microsoft.Trusted.Signing.Client NuGet package, which has
# no installer and no fixed location — hence an explicit override first, then the paths the runner
# setup uses (packaging/windows/README.md). Newest wins, so a package update needs no edit here.
function Find-AzureDlib([string]$Explicit) {
if ($Explicit) {
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
return (Resolve-Path $Explicit).Path
}
$roots = @(
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
'C:\trusted-signing\microsoft.trusted.signing.client'
) | Where-Object { $_ -and (Test-Path $_) }
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
Sort-Object LastWriteTime | Select-Object -Last 1
if (-not $hit) {
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
}
$hit.FullName
}
$makeappx = Find-SdkTool 'makeappx.exe'
$signtool = Find-SdkTool 'signtool.exe'
Write-Host "makeappx: $makeappx"
@@ -159,13 +200,34 @@ $requireCert = if ($RequireSignedCert -eq 'auto') { $env:GITHUB_REF -like 'refs/
else { [Convert]::ToBoolean($RequireSignedCert) }
$pfxPath = Join-Path $OutDir 'signing.pfx'
$cerPath = Join-Path $OutDir "punktfunk-client-windows_${Version}_${Arch}.cer"
if ($PfxBase64) {
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
$signMode = 'selfsigned'
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
$signMode = 'azure'
$AzureDlib = Find-AzureDlib $AzureDlib
# signtool takes the account/profile from this file (/dmdf), not the command line.
@{
Endpoint = $AzureEndpoint
CodeSigningAccountName = $AzureAccount
CertificateProfileName = $AzureProfile
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
Write-Host " dlib: $AzureDlib"
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
if (-not [Environment]::GetEnvironmentVariable($v)) {
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
"DefaultAzureCredential; without the service-principal trio it falls through to an " +
"interactive login that cannot complete on a runner and hangs the build.")
}
}
} elseif ($PfxBase64) {
$signMode = 'pfx'
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
} elseif ($requireCert) {
throw ("release build ($env:GITHUB_REF) with no MSIX_CERT_PFX_B64 — refusing to fall back to an " +
"ephemeral self-signed cert. Restore the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD repo " +
"secrets, or pass -RequireSignedCert false if this really is a test build.")
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 — " +
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
} else {
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
@@ -178,35 +240,80 @@ if ($PfxBase64) {
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
}
# Always export the public .cer from the pfx. For a self-signed / private-trust cert it's the file
# users import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that
# import is a one-time, per-machine step that keeps working across upgrades. For a public-CA cert
# it's just an unused extra (harmless). The manifest Publisher must equal the cert's subject DN.
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
if ($pubCert.Subject -ne $Publisher) {
Write-Warning "cert subject '$($pubCert.Subject)' != manifest Publisher '$Publisher' — Add-AppxPackage will reject the mismatch. Pass -Publisher '$($pubCert.Subject)'."
# Export the public .cer from the pfx. For a self-signed / private-trust cert it's the file users
# import once (Trusted People) — a STABLE cert (same pfx every build via the secret) means that
# import is a one-time, per-machine step that keeps working across upgrades. Azure signing is
# HSM-backed: there is no pfx to read and its chain is publicly trusted, so no .cer is produced.
if ($signMode -ne 'azure') {
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
}
# --- sign (timestamp best-effort) ---
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
& $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $msix))
# --- sign ---
# The timestamp is best-effort for a .pfx whose cert outlives the release, but MANDATORY under Azure
# signing: those leaf certs are minted per request and expire in ~3 days, so an untimestamped
# signature stops verifying within days of shipping. Retrying without one there would produce a
# package that installs on the runner and fails for every user that weekend — so the fallback is
# gated on the mode rather than applied blindly.
if ($signMode -eq 'azure') {
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
$ts = 'http://timestamp.acs.microsoft.com'
} else {
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
$ts = 'http://timestamp.digicert.com'
}
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $msix))
if ($LASTEXITCODE -ne 0) {
if ($signMode -eq 'azure') {
throw ("timestamped sign failed ($LASTEXITCODE) — NOT retrying without a timestamp. An Azure " +
"signing cert is valid for ~3 days; an untimestamped signature would go untrusted " +
"within days of release.")
}
Write-Warning "timestamped sign failed — retrying without a timestamp"
& $signtool ($signArgs + @($msix))
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed ($LASTEXITCODE)" }
}
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
# Read the signature back off the packed .msix and hold it against the manifest Publisher. MSIX
# package identity is Name + Publisher, so a publisher that doesn't match the signer isn't cosmetic:
# Add-AppxPackage refuses the package outright. Checking the ACTUAL signer (rather than a pfx we
# happen to hold) is the only form of this check that works in every signing mode, and failing the
# build here is the difference between a red pipeline and a release nobody can install.
# Deliberately asymmetric: a subject we CAN read and that DISAGREES is a hard failure, but a subject
# we cannot read at all is only a warning. Get-AuthenticodeSignature's support for the .msix/.appx
# subject interface varies by Windows version, and signtool has already reported success by this
# point — turning "the check could not run" into a build break would trade a real defect we catch for
# an imaginary one we invent.
$signerSubject = $null
try { $signerSubject = (Get-AuthenticodeSignature $msix).SignerCertificate.Subject } catch { }
if (-not $signerSubject) {
Write-Warning ("could not read a signer subject back from $msix, so Publisher/signer agreement is " +
"UNVERIFIED on this box. If the package is rejected at Add-AppxPackage time, compare " +
"`signtool verify /pa /v` against the manifest Publisher '$Publisher' by hand.")
} elseif ($signerSubject -ne $Publisher) {
throw ("signer subject does not match the manifest Publisher, so this package cannot install:`n" +
" signer : '$signerSubject'`n" +
" Publisher : '$Publisher'`n" +
"Pass -Publisher '$signerSubject' (or fix the certificate profile) and repack.")
} else {
Write-Host "verified signer subject matches manifest Publisher: $signerSubject"
}
Write-Host ""
Write-Host "==> MSIX: $msix"
Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):"
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
if ($signMode -eq 'azure') {
Write-Host "==> signed by a publicly trusted CA — nothing for users to import."
} else {
Write-Host "==> trust the cert once per machine (then it stays trusted across all future builds):"
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople"
}
# emit paths for the workflow to publish (only under CI, where GITHUB_ENV is set)
if ($env:GITHUB_ENV) {
"MSIX_PATH=$msix" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
if ($signMode -ne 'azure') { "MSIX_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 }
}
+79 -5
View File
@@ -442,6 +442,35 @@ pub fn validate_store_claim(store: &str) -> Result<(), String> {
}
}
/// Drop every `launcher_ui` entry naming a launcher this host cannot actually open, returning the
/// `(title, value)` pairs removed.
///
/// The launch-side counterpart to [`sanitize_art_paths`], and it exists for the same reason: a
/// plugin reconciles its **whole** entry set at once, so anything that fails the payload costs the
/// operator every game in it. The Playnite plugin appends one launcher tile beside the games, so a
/// host that could not resolve `Playnite.FullscreenApp.exe` refused the lot — the operator saw an
/// empty grid and a `HostRequestError` naming `entries[9]`, with nothing to say the other entries
/// were fine.
///
/// Only the *unresolvable* case is dropped. A value outside the platform's vocabulary is still a
/// hard 400 in [`validate_provider_payload`]: that one is a bug in the plugin, and silently
/// swallowing it would leave the author with a tile that never appears and no reason why.
///
/// Dropping the whole entry rather than clearing its `launch` is deliberate — a launcher tile with
/// no launch is a dead tile, which is strictly worse than no tile.
pub fn sanitize_launcher_entries(inputs: &mut Vec<ProviderEntryInput>) -> Vec<(String, String)> {
let mut dropped = Vec::new();
inputs.retain(|e| {
let Some(launch) = &e.launch else { return true };
if launch.kind != "launcher_ui" || resolvable_launcher_ui(&launch.value) {
return true;
}
dropped.push((e.title.clone(), launch.value.clone()));
false
});
dropped
}
/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the
/// diff key — a duplicate would make ownership of the surviving entry ambiguous).
pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> {
@@ -467,12 +496,13 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
"entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`"
));
}
// Refused rather than silently accepted, because the failure is otherwise invisible
// until a user clicks the tile: an unresolvable value yields no command at launch time.
if launch.kind == "launcher_ui" && !valid_launcher_ui(&launch.value) {
// Only the VOCABULARY is refused here. Whether the launcher is actually installed on
// this box is not the payload's fault, and 400ing over it threw away every game in the
// reconcile — see `sanitize_launcher_entries`, which drops just the tile instead.
if launch.kind == "launcher_ui" && !known_launcher_ui(&launch.value) {
return Err(format!(
"entries[{i}]: `launch.value` for kind `launcher_ui` names a launcher this host \
cannot open (`{}`)",
"entries[{i}]: `launch.value` for kind `launcher_ui` is not a launcher this \
host's platform supports (`{}`)",
launch.value
));
}
@@ -1065,6 +1095,14 @@ mod tests {
// Other kinds are unconstrained here (the host validates them per-kind at launch).
assert!(validate_provider_payload(&[with_launch("command", "anything")]).is_ok());
// `launcher_ui` is checked for VOCABULARY only. A launcher that is merely not installed
// must pass here and be dropped later — see `an_unopenable_launcher_tile_costs_only_itself`.
assert!(validate_provider_payload(&[with_launch("launcher_ui", "nonesuch")]).is_err());
#[cfg(windows)]
assert!(validate_provider_payload(&[with_launch("launcher_ui", "playnite")]).is_ok());
#[cfg(target_os = "linux")]
assert!(validate_provider_payload(&[with_launch("launcher_ui", "lutris")]).is_ok());
let with_env = |key: &str, value: Option<&str>| {
let mut i = input("a", "A");
i.detect.env_marker = Some(EnvMarker {
@@ -1129,4 +1167,40 @@ mod tests {
"duplicate external_id"
);
}
/// The regression `sanitize_launcher_entries` exists for: a launcher tile this host cannot open
/// must cost that tile, not the games reconciled beside it.
///
/// Field shape — the Playnite plugin appends exactly one `launcher_ui` tile after its games, so
/// `entries[N]` failing validation used to refuse the entire payload and leave the operator with
/// an empty grid and a `HostRequestError` that named only the index.
#[test]
fn an_unopenable_launcher_tile_costs_only_itself() {
let mut tile = input("launcher", "Playnite");
tile.role = GameRole::Launcher;
tile.launch = Some(LaunchSpec {
kind: "launcher_ui".into(),
value: "playnite".into(),
});
let mut inputs = vec![input("a", "A"), tile, input("b", "B")];
let dropped = sanitize_launcher_entries(&mut inputs);
if resolvable_launcher_ui("playnite") {
// A Windows box with Playnite actually installed keeps all three.
assert!(dropped.is_empty());
assert_eq!(inputs.len(), 3);
} else {
// Everywhere else the tile goes and both games survive — the whole point of the split.
assert_eq!(dropped.len(), 1);
assert_eq!(dropped[0].1, "playnite");
assert_eq!(inputs.len(), 2);
assert!(inputs.iter().all(|e| e.external_id != "launcher"));
}
// A payload of nothing but games is untouched on every OS.
let mut only_games = vec![input("a", "A"), input("b", "B")];
assert!(sanitize_launcher_entries(&mut only_games).is_empty());
assert_eq!(only_games.len(), 2);
}
}
+170 -42
View File
@@ -478,13 +478,31 @@ fn launcher_ui_stores() -> &'static [&'static str] {
}
}
/// Is this a `launcher_ui` value this host can resolve?
/// Is `value` a launcher this host's platform knows about at all?
///
/// On Windows, Playnite is validated by *resolution* rather than by being on the list: a host
/// without Playnite installed refuses the entry (a 400 the plugin author can act on) instead of
/// publishing a tile that does nothing when a user clicks it.
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
if !launcher_ui_stores().contains(&value) {
/// The *vocabulary* half of the old `valid_launcher_ui`. A value outside this set is a plugin
/// author's mistake — a typo, or a launcher this OS has no support for — and no amount of
/// installing things on the box will make it resolve, so the reconcile refuses the payload.
pub(crate) fn known_launcher_ui(value: &str) -> bool {
launcher_ui_stores().contains(&value)
}
/// Can this host open `value`'s launcher **right now**?
///
/// The *environment* half. Deliberately separate from [`known_launcher_ui`], because the two
/// failures are not the same kind of thing and must not get the same answer:
///
/// - an unknown value is a bug in the plugin, and a 400 is the only way its author finds out;
/// - a known value that will not resolve means the launcher simply is not installed here, which is
/// an ordinary fact about the box, not a defect in the payload.
///
/// Conflating them cost a real library: the Playnite plugin publishes one launcher tile alongside
/// every game, so a host that could not resolve Playnite 400'd the whole reconcile and the operator
/// got **no games at all** — the same shape as the unservable-cover bug that
/// [`super::sanitize_art_paths`] was introduced to fix. The tile is dropped now (see
/// [`super::sanitize_launcher_entries`]) and the games sync.
pub(crate) fn resolvable_launcher_ui(value: &str) -> bool {
if !known_launcher_ui(value) {
return false;
}
#[cfg(windows)]
@@ -502,36 +520,141 @@ pub(crate) fn valid_launcher_ui(value: &str) -> bool {
/// directly, which is also why nothing here is interpolated from the entry: the whole value is the
/// literal `"playnite"`.
///
/// Playnite installs per-user by default, so the install directory comes from its own uninstall
/// entry (HKCU first, then HKLM for a machine-wide install), falling back to the default
/// `%LOCALAPPDATA%\Playnite`. `None` when nothing resolves, which is what refuses the tile.
/// `None` when nothing resolves, which is what drops the tile.
#[cfg(windows)]
fn playnite_fullscreen_exe() -> Option<std::path::PathBuf> {
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
use winreg::RegKey;
const KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Playnite";
const EXE: &str = "Playnite.FullscreenApp.exe";
let from_registry = [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE]
playnite_install_dirs()
.into_iter()
.find_map(|root| {
RegKey::predef(root)
.open_subkey(KEY)
.ok()?
.get_value::<String, _>("InstallLocation")
.ok()
})
.map(std::path::PathBuf::from);
from_registry
.into_iter()
.chain(
std::env::var_os("LOCALAPPDATA").map(|l| std::path::PathBuf::from(l).join("Playnite")),
)
.map(|dir| dir.join(EXE))
.find(|p| p.is_file())
}
/// Windows: every directory that might hold a Playnite install, best candidates first.
///
/// **Playnite installs per-user by default, and this host is a LocalSystem service** — which
/// invalidates all three of the obvious lookups, and is why this is not a two-liner:
///
/// - `HKEY_CURRENT_USER` is *SYSTEM's own* hive (`S-1-5-18`), never the person's, so a per-user
/// install is invisible there. Every **loaded** hive under `HKEY_USERS` is read instead: only
/// logged-on users' hives are loaded, which is exactly the set that can be streaming, and it
/// avoids a `WTSQueryUserToken` dance for what is a best-effort probe. Same trade-off
/// [`crate::procscan::steam_running_hint`] makes, for the same reason.
/// - The uninstall subkey is matched by its **`DisplayName`**, not by key name. Playnite ships an
/// Inno Setup installer and Inno registers `<AppId>_is1` — measured on a Windows box where Git
/// and Inno itself appear as `Git_is1` and `Inno Setup 6_is1`. The hardcoded
/// `…\Uninstall\Playnite` this replaced matched nothing on any box.
/// - `%LOCALAPPDATA%` for a SYSTEM service is `C:\Windows\System32\config\systemprofile\AppData\
/// Local`, so the default-install fallback cannot trust the variable — it enumerates the profiles
/// under the users base instead, the same breadth [`super::art::art_roots`] already allows.
///
/// Order matters only as a preference: a registry `InstallLocation` is what the installer actually
/// did, so it is consulted before the conventional path. Every candidate is probed for the exe, so
/// a stale entry costs one `is_file` and nothing else.
#[cfg(windows)]
fn playnite_install_dirs() -> Vec<std::path::PathBuf> {
use winreg::enums::{HKEY_LOCAL_MACHINE, HKEY_USERS, KEY_READ};
use winreg::RegKey;
// 64-bit and 32-bit views. HKCU/HKU `Software` is not redirected (only `Software\Classes` is),
// so the WOW view is a machine-hive concern only.
const UNINSTALL: &str = r"Software\Microsoft\Windows\CurrentVersion\Uninstall";
const UNINSTALL_WOW: &str = r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
playnite_dirs_from_uninstall(&hklm, UNINSTALL, &mut dirs);
playnite_dirs_from_uninstall(&hklm, UNINSTALL_WOW, &mut dirs);
let users = RegKey::predef(HKEY_USERS);
for sid in users.enum_keys().flatten() {
// The `…_Classes` companion hives carry file associations, never uninstall entries.
if sid.ends_with("_Classes") {
continue;
}
if let Ok(hive) = users.open_subkey_with_flags(&sid, KEY_READ) {
playnite_dirs_from_uninstall(&hive, UNINSTALL, &mut dirs);
}
}
// The conventional per-user location, for every profile on the box — this is where Playnite's
// own default install lands, and it covers a user whose hive is not currently loaded.
for profile in windows_user_profiles() {
push_unique(&mut dirs, profile.join(r"AppData\Local\Playnite"));
}
dirs
}
/// Collect `InstallLocation` from every Playnite-looking uninstall entry under `root\path`.
///
/// Matched on `DisplayName` because the key name is the installer's `AppId` (see
/// [`playnite_install_dirs`]). `starts_with` rather than equality so a versioned or suffixed display
/// name still counts; the value is only ever used as a directory to probe for the exe, so a false
/// positive costs one failed `is_file`.
#[cfg(windows)]
fn playnite_dirs_from_uninstall(
root: &winreg::RegKey,
path: &str,
out: &mut Vec<std::path::PathBuf>,
) {
use winreg::enums::KEY_READ;
let Ok(uninstall) = root.open_subkey_with_flags(path, KEY_READ) else {
return;
};
for name in uninstall.enum_keys().flatten() {
let Ok(entry) = uninstall.open_subkey_with_flags(&name, KEY_READ) else {
continue;
};
let display: String = entry.get_value("DisplayName").unwrap_or_default();
if !display.starts_with("Playnite") {
continue;
}
if let Ok(location) = entry.get_value::<String, _>("InstallLocation") {
let location = location.trim();
if !location.is_empty() {
push_unique(out, std::path::PathBuf::from(location));
}
}
}
}
/// Every user profile directory on the box (`C:\Users\*`), minus the shared `Public` pseudo-profile.
///
/// `%PUBLIC%`'s parent is the users base on every supported Windows — the same derivation
/// [`super::art::art_roots`] uses — with `%SystemDrive%\Users` as the fallback when the variable is
/// missing from a service's environment.
#[cfg(windows)]
fn windows_user_profiles() -> Vec<std::path::PathBuf> {
let base = std::env::var_os("PUBLIC")
.map(std::path::PathBuf::from)
.and_then(|p| p.parent().map(std::path::Path::to_path_buf))
.or_else(|| {
std::env::var_os("SystemDrive").map(|d| std::path::PathBuf::from(d).join("Users"))
});
let Some(base) = base else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(&base) else {
return Vec::new();
};
entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && !p.ends_with("Public"))
.collect()
}
/// Push `path` unless an equal one is already there — the candidate lists are a handful of entries,
/// so a linear check beats carrying a set around.
#[cfg(windows)]
fn push_unique(out: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
if !out.contains(&path) {
out.push(path);
}
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
@@ -800,33 +923,38 @@ mod tests {
fn launcher_ui_accepts_only_launchers_this_host_can_open() {
#[cfg(target_os = "linux")]
{
assert!(valid_launcher_ui("heroic"));
assert!(valid_launcher_ui("lutris"));
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
assert!(!valid_launcher_ui("gog"));
assert!(known_launcher_ui("heroic"));
assert!(known_launcher_ui("lutris"));
// Not wired on this OS — outside the vocabulary, so it is refused inbound rather than
// becoming a tile that does nothing.
assert!(!known_launcher_ui("gog"));
}
#[cfg(windows)]
{
// Playnite is accepted only when this host can actually FIND its Fullscreen app:
// validation is resolution, so a box without Playnite refuses the entry rather than
// publishing a tile that does nothing when clicked.
// Playnite is in the vocabulary unconditionally — whether this particular box has it
// installed is a separate question, answered by `resolvable_launcher_ui` below. Keeping
// them separate is the fix for the reconcile that 400'd a whole library over one tile.
assert!(known_launcher_ui("playnite"));
assert_eq!(
valid_launcher_ui("playnite"),
resolvable_launcher_ui("playnite"),
playnite_fullscreen_exe().is_some()
);
// The Linux launchers, and the Windows ones whose activation is still unverified
// (Epic, GOG Galaxy, the Xbox app), stay refused.
assert!(!valid_launcher_ui("heroic"));
assert!(!valid_launcher_ui("gog"));
assert!(!known_launcher_ui("heroic"));
assert!(!known_launcher_ui("gog"));
}
#[cfg(not(any(target_os = "linux", windows)))]
{
// No launcher UIs are wired on this OS, so every value is refused.
assert!(!valid_launcher_ui("heroic"));
assert!(!valid_launcher_ui("gog"));
assert!(!known_launcher_ui("heroic"));
assert!(!known_launcher_ui("gog"));
}
assert!(!valid_launcher_ui(""));
assert!(!valid_launcher_ui("lutris; rm -rf ~"));
// Junk is outside the vocabulary on every OS, so it never reaches a resolver.
assert!(!known_launcher_ui(""));
assert!(!known_launcher_ui("lutris; rm -rf ~"));
assert!(!resolvable_launcher_ui(""));
assert!(!resolvable_launcher_ui("lutris; rm -rf ~"));
}
/// The `xbox` kind is what a library PLUGIN can publish: the runner's principal cannot read
+12
View File
@@ -524,6 +524,18 @@ pub(crate) async fn reconcile_provider_entries(
return denied;
}
}
// A launcher this box cannot open is a fact about the box, not a defect in the payload, so it
// costs its own tile and nothing else. Before this, the Playnite plugin's single launcher entry
// 400'd every game it shipped alongside.
for (title, value) in crate::library::sanitize_launcher_entries(&mut inputs) {
tracing::warn!(
provider,
launcher = %value,
title = %title,
"library reconcile: dropped a launcher tile this host cannot open — the rest of the \
payload still syncs. Install the launcher, or turn the tile off in the plugin's config"
);
}
// One aggregated line, not one per entry: a root mismatch misses EVERY cover in the payload, and
// a per-entry warn would bury the rest of the log under a thousand copies of one fact.
let mut dropped_art = 0usize;
+15 -5
View File
@@ -7,12 +7,20 @@
//! 1. **SHA-256 == the signed manifest's** — the primary integrity gate (the manifest is the
//! Ed25519-verified document; this check makes the downloaded bytes those exact bytes).
//! 2. **Authenticode**: the embedded signature must be cryptographically valid, tolerating
//! `CERT_E_UNTRUSTEDROOT` while the shipping cert is self-signed (`CN=unom`); when the
//! `CERT_E_UNTRUSTEDROOT` (canary and local builds still sign with a self-signed cert, and
//! releases moved to Azure Artifact Signing without needing this to tighten); when the
//! manifest carries leaf pins, the signing leaf's SHA-256 must match one. The leaf is taken
//! from the SAME `WinVerifyTrust` state (`WTHelperGetProvSignerFromChain`), never a second
//! parse — no verify-vs-inspect gap. An empty pin list skips only the pin comparison (the
//! manifest hash already binds content; pins arrive via `AUTHENTICODE_SHA256` in CI once
//! the cert story settles — the field exists so Trusted Signing is a manifest edit).
//! manifest hash already binds content).
//!
//! **Leaf pinning cannot be used with Azure Artifact Signing.** That service mints a fresh leaf
//! per signing request, valid ~3 days, so an `AUTHENTICODE_SHA256` pin would go stale within days
//! of publishing and reject every subsequent release. (An earlier note here assumed the opposite —
//! that the pin field made Trusted Signing "a manifest edit". It does not.) If pinning is wanted
//! against the Azure-signed artifacts, pin something stable instead: the issuing intermediate, or
//! the certificate subject. Leave the list empty until then; the Ed25519-signed manifest hash is
//! what actually binds the downloaded bytes.
//!
//! The spawn uses `CREATE_BREAKAWAY_FROM_JOB`: the service worker's job object is kill-on-close
//! (a stopping service would otherwise take the installer down with it) and was created
@@ -284,8 +292,10 @@ fn preflight_disk(at: &Path, needed: u64) -> Result<(), String> {
Ok(())
}
/// Authenticode: valid embedded signature (untrusted root tolerated — self-signed `CN=unom`),
/// signing-leaf SHA-256 ∈ `pins` when pins are present. The leaf comes out of the same
/// Authenticode: valid embedded signature (untrusted root tolerated — canary/local builds are still
/// self-signed), signing-leaf SHA-256 ∈ `pins` when pins are present — but see the module docs: a
/// leaf pin is unusable against Azure-signed releases, whose leaf rotates every few days. The leaf
/// comes out of the same
/// `WinVerifyTrust` state via `WTHelperGetProvSignerFromChain`. (`pub(crate)`: the service
/// supervisor's boot-loop rollback re-checks the cached previous installer with it.)
pub(crate) fn verify_authenticode(path: &Path, pins: &[String]) -> Result<(), String> {
+55 -1
View File
@@ -140,8 +140,61 @@ fresh install uses the generated random console password — read it from
> punktfunk-planning: `windows-build-and-packaging.md` (internal planning repo) for the toolchain
> + signing details.
## Installer signing (Azure Artifact Signing)
`setup.exe`, `punktfunk-host.exe`, `punktfunk-tray.exe` and the Vulkan HDR layer are signed with
**Azure Artifact Signing** (formerly Trusted Signing): account `unomsigning`, certificate profile
`unom-io`, endpoint `https://neu.codesigning.azure.net/`. It is a publicly trusted CA, so users get
a named publisher in the UAC prompt and there is no `.cer` to import — `HOST_CER_PATH` is simply not
emitted in this mode (every consumer already guards on `Test-Path`).
`pack-host-installer.ps1` resolves a backend in this order, first match wins:
| order | backend | selected by |
| --- | --- | --- |
| 1 | Azure Artifact Signing | `AZURE_CODESIGNING_ENDPOINT` + `_ACCOUNT` + `_PROFILE` all set |
| 2 | stable self-signed `.pfx` | `MSIX_CERT_PFX_B64` / `MSIX_CERT_PASSWORD` |
| 3 | ephemeral self-signed | nothing set (canary / local only; a `v*` tag **fails closed**) |
Credentials for mode 1 come from the environment via `DefaultAzureCredential``AZURE_TENANT_ID`,
`AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, the `punktfunk-ci-signing` service principal. It holds
exactly one role, *Artifact Signing Certificate Profile Signer*, scoped to the `unom-io` profile: it
can sign and can do nothing else with the subscription. The script hard-fails if the trio is missing
rather than letting `DefaultAzureCredential` fall through to an interactive login that would hang a
runner forever.
> **Timestamping is mandatory here, not best-effort.** Azure mints a leaf certificate per request,
> valid for about three days. An untimestamped signature therefore goes untrusted within days of
> release — it would verify fine on the runner and fail on users' machines that weekend. `Sign-File`
> refuses to retry without a timestamp in Azure mode; modes 2 and 3 keep the old lenient retry, where
> the cert outlives the release anyway.
### Runner setup
`signtool` reaches Azure through `Azure.CodeSigning.Dlib.dll`, which ships in the
`Microsoft.Trusted.Signing.Client` NuGet package — no installer, no fixed path. On the Windows runner:
```powershell
nuget install Microsoft.Trusted.Signing.Client -OutputDirectory $env:USERPROFILE\.nuget\packages
```
`Find-AzureDlib` searches that path and `C:\trusted-signing\`, newest first, so a package update needs
no script edit. Set `AZURE_CODESIGNING_DLIB` to override with an explicit path.
## Driver signing (`DRIVER_CERT_PFX_B64`)
> **The drivers are deliberately NOT on Azure.** Their catalogs keep the self-signed
> `CN=punktfunk-driver` cert below, which the installer still plants in the machine `Root` store.
> The two signatures are independent by design — Windows verifies the installer via SmartScreen/UAC
> and driver catalogs via PnP, and never requires a common signer, which is why the installer could
> move to a public CA without touching the driver track at all.
>
> Worth revisiting: these are **user-mode** (UMDF) drivers and we already clear `FORCE_INTEGRITY`, so
> a catalog signed by the publicly trusted Azure cert would likely chain to a root every Windows box
> already has — which would let us drop the `Root` plant entirely and keep only the `TrustedPublisher`
> entry that suppresses the device-software prompt. That is a real reduction in what we ask of a
> user's machine, but it is **unverified**: test it on the Windows box before believing it.
Our three UMDF drivers are signed with a **stable self-signed code-signing cert**, subject
`CN=punktfunk-driver`, supplied to `build-pf-vdisplay.ps1` / `build-gamepad-drivers.ps1` as the
`DRIVER_CERT_PFX_B64` + `DRIVER_CERT_PASSWORD` Actions secrets. On a `v*` tag build a missing cert
@@ -241,7 +294,8 @@ the recovery. From a Linux box drive either over SSH, e.g.
# statically links the vendored VPL dispatcher — needs cmake + a libclang, no FFmpeg)
cargo build --release -p punktfunk-host --features nvenc,qsv
# 2. pack (self-signed unless MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD are set; -NoDriver to skip pf-vdisplay)
# 2. pack (self-signed unless the AZURE_CODESIGNING_* trio or MSIX_CERT_PFX_B64/MSIX_CERT_PASSWORD
# are set — see "Installer signing" above; -NoDriver to skip pf-vdisplay)
pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetDir C:\t\release -OutDir C:\t\out
```
+115 -26
View File
@@ -4,15 +4,24 @@
.DESCRIPTION
From a release `cargo build -p punktfunk-host --features nvenc` output (the exe), this:
1. resolves a code-signing cert (supplied stable .pfx from CI secrets OR an ephemeral self-signed
CN=unom - same scheme as the client's pack-msix.ps1) and exports the public .cer. The
ephemeral fallback is for canary/CI/dev ONLY: on a v* tag build a missing cert (or -NoSign)
is a hard failure, never a silent downgrade to a throwaway cert - see -RequireSignedCert,
1. resolves a signing backend - Azure Artifact Signing (formerly Trusted Signing) when the
AZURE_CODESIGNING_* trio is set, else a supplied stable .pfx from CI secrets, else an
ephemeral self-signed CN=unom - same scheme as the client's pack-msix.ps1. The .pfx paths
also export the public .cer; Azure does not (see below). The ephemeral fallback is for
canary/CI/dev ONLY: on a v* tag build a missing cert (or -NoSign) is a hard failure, never
a silent downgrade to a throwaway cert - see -RequireSignedCert,
2. signs the inner punktfunk-host.exe,
3. stages the pf-vdisplay virtual-display driver bundle (unless -NoDriver),
4. runs ISCC to build punktfunk-host-setup-<ver>.exe,
5. signs the setup.exe (timestamp best-effort),
6. emits HOST_SETUP_PATH / HOST_CER_PATH to GITHUB_ENV for the publish step.
5. signs the setup.exe (timestamped - MANDATORY under Azure signing, see Sign-File),
6. emits HOST_SETUP_PATH / HOST_CER_PATH to GITHUB_ENV for the publish step. Azure signing
emits no .cer: the chain is publicly trusted, so there is nothing for a user to import.
Every consumer of HOST_CER_PATH already guards on Test-Path, so it is simply absent.
NOTE the drivers are signed separately, by build-pf-vdisplay.ps1 / build-gamepad-drivers.ps1 with
the DRIVER_CERT_* secret, and are NOT re-signed here (that would invalidate their catalogs). The
installer's signature and the driver catalogs' signatures are independent by design - Windows
verifies the first via SmartScreen/UAC and the second via PnP, and never requires a common signer.
Idempotent; safe to re-run. Run on the Windows runner / dev box (MSVC + Windows SDK + Inno Setup).
@@ -24,9 +33,20 @@ param(
[Parameter(Mandatory = $true)][string]$Version, # e.g. 0.2.137 or 1.4.0 (free-form)
[Parameter(Mandatory = $true)][string]$TargetDir, # cargo --release dir (has punktfunk-host.exe)
[string]$OutDir = (Join-Path $TargetDir 'installer'),
# Subject for the EPHEMERAL self-signed fallback only. Azure signing carries its own subject
# (the profile's verified CN/O), and nothing downstream of setup.exe compares the two - unlike
# the MSIX, whose manifest Identity/@Publisher must match byte-for-byte. See pack-msix.ps1.
[string]$Publisher = 'CN=unom',
[string]$PfxBase64 = $env:MSIX_CERT_PFX_B64, # reuse the client's signing secret
[string]$PfxPassword = $env:MSIX_CERT_PASSWORD,
# Azure Artifact Signing (formerly Trusted Signing). All three must be set to select it; it then
# takes precedence over any .pfx. Credentials come from the environment via DefaultAzureCredential
# (AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET) - never passed as arguments, so they
# cannot leak into a process listing or a transcript.
[string]$AzureEndpoint = $env:AZURE_CODESIGNING_ENDPOINT, # e.g. https://neu.codesigning.azure.net/
[string]$AzureAccount = $env:AZURE_CODESIGNING_ACCOUNT, # signing account name
[string]$AzureProfile = $env:AZURE_CODESIGNING_PROFILE, # certificate profile name
[string]$AzureDlib = $env:AZURE_CODESIGNING_DLIB, # path to Azure.CodeSigning.Dlib.dll
[string]$FfmpegDir = $env:FFMPEG_DIR, # bundle its bin\*.dll (amf-qsv build)
[string]$WebDir = $env:WEB_OUTPUT_DIR, # built web .output tree -> bundle the mgmt console
[string]$ScriptingBundle = $env:SCRIPTING_BUNDLE, # built runner-cli.js -> bundle the plugin/script runner
@@ -70,6 +90,29 @@ function Find-SdkTool([string]$name) {
if (-not $hit) { throw "$name not found under $root - install the Windows 10/11 SDK." }
$hit.FullName
}
# Azure.CodeSigning.Dlib.dll ships in the Microsoft.Trusted.Signing.Client NuGet package, which has no
# installer and no fixed location - hence an explicit override first, then the two paths the runner
# setup uses (see packaging/windows/README.md). Newest version wins so a package update is picked up
# without editing this script.
function Find-AzureDlib([string]$Explicit) {
if ($Explicit) {
if (-not (Test-Path $Explicit)) { throw "AZURE_CODESIGNING_DLIB points at a missing file: $Explicit" }
return (Resolve-Path $Explicit).Path
}
$roots = @(
(Join-Path $env:USERPROFILE '.nuget\packages\microsoft.trusted.signing.client'),
'C:\trusted-signing\microsoft.trusted.signing.client'
) | Where-Object { $_ -and (Test-Path $_) }
$hit = $roots | ForEach-Object { Get-ChildItem -Path $_ -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' -ErrorAction SilentlyContinue } |
Where-Object { $_.FullName -match '\\bin\\x64\\' } |
Sort-Object LastWriteTime | Select-Object -Last 1
if (-not $hit) {
throw ("Azure.CodeSigning.Dlib.dll not found. Install the signing client on this box, e.g. " +
"``nuget install Microsoft.Trusted.Signing.Client -OutputDirectory " +
"`$env:USERPROFILE\.nuget\packages``, or set AZURE_CODESIGNING_DLIB to its full path.")
}
$hit.FullName
}
$iscc = Find-Iscc
Write-Host "ISCC: $iscc"
@@ -87,20 +130,43 @@ if ($NoSign -and $requireCert) {
}
$pfxPath = Join-Path $OutDir 'signing.pfx'
$cerPath = Join-Path $OutDir "punktfunk-host-windows_${Version}.cer"
$azureMetadata = Join-Path $OutDir 'azure-codesigning.json'
$signMode = 'none'
$signtool = $null
if (-not $NoSign) {
$signtool = Find-SdkTool 'signtool.exe'
Write-Host "signtool: $signtool"
if ($PfxBase64) {
if ($AzureEndpoint -and $AzureAccount -and $AzureProfile) {
$signMode = 'azure'
$AzureDlib = Find-AzureDlib $AzureDlib
# signtool reads the account/profile from this file (/dmdf) rather than the command line.
@{
Endpoint = $AzureEndpoint
CodeSigningAccountName = $AzureAccount
CertificateProfileName = $AzureProfile
} | ConvertTo-Json | Set-Content -Path $azureMetadata -Encoding utf8
Write-Host "signing via Azure Artifact Signing: $AzureAccount/$AzureProfile at $AzureEndpoint"
Write-Host " dlib: $AzureDlib"
foreach ($v in 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET') {
if (-not [Environment]::GetEnvironmentVariable($v)) {
throw ("Azure signing selected but $v is not set. The dlib authenticates with " +
"DefaultAzureCredential; without the service-principal trio it falls through to " +
"an interactive login that cannot complete on a runner and hangs the build.")
}
}
}
elseif ($PfxBase64) {
$signMode = 'pfx'
Write-Host "signing with supplied code-signing cert (MSIX_CERT_PFX_B64)"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($PfxBase64))
}
elseif ($requireCert) {
throw ("release build ($env:GITHUB_REF) with no MSIX_CERT_PFX_B64 - refusing to fall back to " +
"an ephemeral self-signed cert. Restore the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD " +
"repo secrets, or pass -RequireSignedCert false if this really is a test build.")
throw ("release build ($env:GITHUB_REF) with neither AZURE_CODESIGNING_* nor MSIX_CERT_PFX_B64 - " +
"refusing to fall back to an ephemeral self-signed cert. Restore the signing secrets " +
"(packaging/windows/README.md), or pass -RequireSignedCert false if this really is a test build.")
}
else {
$signMode = 'selfsigned'
Write-Host "no MSIX_CERT_PFX_B64 -> generating an ephemeral self-signed cert (subject $Publisher)"
if (-not $PfxPassword) { $PfxPassword = 'punktfunk' }
$tmp = New-SelfSignedCertificate -Type Custom -Subject $Publisher `
@@ -111,25 +177,44 @@ if (-not $NoSign) {
Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -FilePath $pfxPath -Password $sec | Out-Null
Remove-Item "Cert:\CurrentUser\My\$($tmp.Thumbprint)" -Force
}
# Always export the public .cer. For a self-signed cert it's the file users import once
# (LocalMachine\TrustedPublisher) so SmartScreen/UAC trusts the signed setup.exe; for a real CA
# cert it's a harmless extra.
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
# Export the public .cer for the .pfx-backed modes. For a self-signed cert it's the file users
# import once (LocalMachine\TrustedPublisher) so SmartScreen/UAC trusts the signed setup.exe.
# Azure signing has no .pfx to read and needs no import - the chain is publicly trusted - so it
# deliberately produces no .cer and HOST_CER_PATH stays unset.
if ($signMode -ne 'azure') {
$pwsec = if ($PfxPassword) { ConvertTo-SecureString -String $PfxPassword -Force -AsPlainText } else { $null }
$pubCert = if ($pwsec) { Get-PfxCertificate -FilePath $pfxPath -Password $pwsec } else { Get-PfxCertificate -FilePath $pfxPath }
Export-Certificate -Cert $pubCert -FilePath $cerPath | Out-Null
Write-Host "signing cert subject=$($pubCert.Subject) thumbprint=$($pubCert.Thumbprint)"
}
}
# A timestamp is best-effort for a .pfx whose cert outlives the release, but MANDATORY under Azure
# signing: those leaf certs are minted per request and expire in ~3 days, so an untimestamped
# signature stops verifying within days of shipping. Retrying without one there would produce an
# artifact that passes on the runner and fails on every user's machine that weekend - so the
# fallback is gated on the mode rather than applied blindly.
function Sign-File([string]$Path) {
if ($NoSign) { return }
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
& $signtool ($signArgs + @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256', $Path))
if ($LASTEXITCODE -ne 0) {
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
& $signtool ($signArgs + @($Path))
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
if ($signMode -eq 'azure') {
$signArgs = @('sign', '/fd', 'SHA256', '/dlib', $AzureDlib, '/dmdf', $azureMetadata)
$ts = 'http://timestamp.acs.microsoft.com'
}
else {
$signArgs = @('sign', '/fd', 'SHA256', '/f', $pfxPath)
if ($PfxPassword) { $signArgs += @('/p', $PfxPassword) }
$ts = 'http://timestamp.digicert.com'
}
& $signtool ($signArgs + @('/tr', $ts, '/td', 'SHA256', $Path))
if ($LASTEXITCODE -eq 0) { return }
if ($signMode -eq 'azure') {
throw ("timestamped sign failed for $Path ($LASTEXITCODE) - NOT retrying without a timestamp. " +
"An Azure signing cert is valid for ~3 days; an untimestamped signature would go " +
"untrusted within days of release.")
}
Write-Warning "timestamped sign failed for $Path - retrying without a timestamp"
& $signtool ($signArgs + @($Path))
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed for $Path ($LASTEXITCODE)" }
}
# --- sign the inner exes before they're packed -------------------------------------------------
@@ -340,14 +425,18 @@ if (-not (Test-Path $setup)) { throw "expected installer not produced: $setup" }
# --- sign the setup.exe + clean up ------------------------------------------------------------
Sign-File $setup
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
Remove-Item $azureMetadata -Force -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "==> installer: $setup"
if (-not $NoSign) {
if ($signMode -eq 'azure') {
Write-Host "==> signed by a publicly trusted CA - nothing for users to import."
}
elseif (-not $NoSign) {
Write-Host "==> trust the cert once per machine (self-signed builds), then the signed setup.exe is trusted:"
Write-Host " Import-Certificate -FilePath '$cerPath' -CertStoreLocation Cert:\LocalMachine\TrustedPublisher"
}
if ($env:GITHUB_ENV) {
"HOST_SETUP_PATH=$setup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
if (-not $NoSign) { "HOST_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 }
if (-not $NoSign -and $signMode -ne 'azure') { "HOST_CER_PATH=$cerPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 }
}
+7 -7
View File
@@ -107,10 +107,10 @@ These manifests stay in winget-pkgs' own format rather than a bespoke one, so su
later is a copy, not a rewrite. Two things would need attention on that path: the signing note
below, and `Agreements` being verified-developers-only in the community repo.
> **Signing.** The installer is currently signed with a self-signed cert (`CN=unom`, subject ==
> issuer) and ships a `.cer` users import manually. winget does not sign anything; it downloads and
> runs the same binary, so SmartScreen behaves exactly as it does for a browser download. That is a
> pre-existing condition rather than something winget introduces — but the community repo
> (`microsoft/winget-pkgs`) gates on it via its `Binary-Validation-Error` /
> `Validation-Defender-Error` checks, so a submission there needs a publicly-trusted cert (Azure
> Trusted Signing is the cheap path). A self-hosted source has no such gate.
> **Signing.** The installer is signed with **Azure Artifact Signing** (account `unomsigning`,
> profile `unom-io`) — a publicly trusted CA, so there is no `.cer` for users to import. This
> removed the blocker on submitting to the community repo (`microsoft/winget-pkgs`), whose
> `Binary-Validation-Error` / `Validation-Defender-Error` checks require a publicly trusted cert;
> the remaining upstream obstacle is `Agreements` being verified-developers-only. Note that a
> trusted cert is not an instant SmartScreen bypass: reputation still accrues per publisher over
> downloads, it just now accrues to a named identity instead of being permanently unknown.
+4 -6
View File
@@ -16,12 +16,10 @@ winget upgrade unom.PunktfunkHost
## Why self-hosted rather than the community repo
`microsoft/winget-pkgs` gates submissions on its `Binary-Validation-Error` /
`Validation-Defender-Error` checks, and the host installer is currently signed with a self-signed
cert (`CN=unom`). That is a pre-existing condition — winget does not sign anything, so SmartScreen
behaves identically whether the installer arrives by browser or by `winget` — but it does block that
route until a publicly trusted cert is in place. A self-hosted source has no such gate, can carry
`Agreements` (verified-developers-only upstream), and can serve channels the community repo would
never accept.
`Validation-Defender-Error` checks, which need a publicly trusted signing cert. That blocker is
gone — the host installer is now signed with Azure Artifact Signing (see `packaging/windows/README.md`)
— but a self-hosted source is still the right call: it has no such gate, can carry `Agreements`
(verified-developers-only upstream), and can serve channels the community repo would never accept.
## What it implements
@@ -113,4 +113,60 @@ $env:PATH = "C:\Users\Public\ffmpeg\bin;" + $env:PATH
'@ | Set-Content -Encoding UTF8 $projectEnv
info "wrote $projectEnv (FFMPEG_DIR) - restart the gitea-act-runner scheduled task to pick it up"
# --- Azure Artifact Signing (formerly Trusted Signing) toolchain, for the signing step in
# windows-host.yml + windows-client.yml. Two pieces, neither of which the generic unom/infra image
# carries, and both of which fail in ways that do not name themselves:
#
# 1. The .NET 8 runtime. Azure.CodeSigning.Dlib.dll is a mixed-mode (C++/CLI) assembly - it ships
# Ijwhost.dll and a runtimeconfig.json pinning Microsoft.NETCore.App 8.0.0 - so on a box with
# no .NET runtime, signtool exits 3 having printed NOTHING AT ALL. Verified on .133 2026-08-14:
# the box had pwsh 7 (self-contained, brings no shared runtime) and no dotnet whatsoever.
# 2. The signing client, installed MACHINE-WIDE under C:\trusted-signing rather than into a user's
# .nuget. The act_runner daemon runs as SYSTEM, whose USERPROFILE is
# C:\Windows\System32\config\systemprofile - so a per-user install under Administrator is
# invisible to every job that actually builds. Find-AzureDlib in both pack scripts searches
# this exact path for that reason; verified by resolving it from a SYSTEM scheduled task.
#
# Both are SHA-256 pinned against version-immutable URLs (a nuget.org flat-container package and the
# dotnet builds CDN are both immutable per version), so these fail closed on tampering rather than
# every time Microsoft ships a patch release. Bump version + hash together to move either. ---
$dotnetVer = '8.0.30'
$dotnetSha = 'E40F199C6D5584AFF0554C01163C3C8D9CCF6BEC3A577E4D967E41070772A1C1'
$tscVer = '1.0.95'
$tscSha = '3BFCF1E0A3CB42AF1692F0A8ED45C15DE070C2DE86F28A59B2795D904D8A920F'
if (Test-Path 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App') {
info "shared .NET runtime already present ($((Get-ChildItem 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App' | ForEach-Object Name) -join ', '))"
} else {
info "installing .NET $dotnetVer runtime (required by Azure.CodeSigning.Dlib.dll)"
$dn = "$env:TEMP\dotnet-runtime-$dotnetVer-win-x64.exe"
Invoke-WebRequest -Uri "https://builds.dotnet.microsoft.com/dotnet/Runtime/$dotnetVer/dotnet-runtime-$dotnetVer-win-x64.exe" -OutFile $dn -UseBasicParsing
$got = (Get-FileHash $dn -Algorithm SHA256).Hash
if ($got -ne $dotnetSha) { Remove-Item $dn -Force; throw ".NET runtime download hash mismatch (got $got, pinned $dotnetSha)." }
# -Wait is load-bearing: the bundle is a GUI PE that returns immediately when invoked with &,
# leaving $LASTEXITCODE unset and racing any completion check against the install.
$p = Start-Process -FilePath $dn -ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru
Remove-Item $dn -Force -ErrorAction SilentlyContinue
if ($p.ExitCode -ne 0) { throw ".NET runtime installer exited $($p.ExitCode)." }
if (-not (Test-Path 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App')) { throw ".NET runtime installer reported success but installed no shared runtime." }
}
$tscDir = "C:\trusted-signing\microsoft.trusted.signing.client\$tscVer"
if (Test-Path (Join-Path $tscDir 'bin\x64\Azure.CodeSigning.Dlib.dll')) {
info "Trusted Signing client $tscVer already present at $tscDir"
} else {
info "installing Microsoft.Trusted.Signing.Client $tscVer (machine-wide, for SYSTEM)"
$nupkg = "$env:TEMP\microsoft.trusted.signing.client.$tscVer.nupkg"
Invoke-WebRequest -Uri "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$tscVer/microsoft.trusted.signing.client.$tscVer.nupkg" -OutFile $nupkg -UseBasicParsing
$got = (Get-FileHash $nupkg -Algorithm SHA256).Hash
if ($got -ne $tscSha) { Remove-Item $nupkg -Force; throw "Trusted Signing client download hash mismatch (got $got, pinned $tscSha)." }
if (Test-Path $tscDir) { Remove-Item -Recurse -Force $tscDir }
New-Item -ItemType Directory -Force -Path $tscDir | Out-Null
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($nupkg, $tscDir)
Remove-Item $nupkg -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $tscDir -Recurse -File | Unblock-File -ErrorAction SilentlyContinue
if (-not (Test-Path (Join-Path $tscDir 'bin\x64\Azure.CodeSigning.Dlib.dll'))) { throw "extracted $tscVer but bin\x64\Azure.CodeSigning.Dlib.dll is absent." }
}
info "punktfunk extras provisioned OK."