diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6c744c..b081ea0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,31 +17,24 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 25 - cache: npm - - name: Setup .NET uses: actions/setup-dotnet@v5 with: dotnet-version: 8.0.x - - name: Install dependencies - run: npm ci - - - name: Restore C# solution - run: npm run dotnet:restore + - name: Restore + run: dotnet restore src/SwitchifyPc.sln - - name: Build C# solution - run: npm run dotnet:build + - name: Build + run: dotnet build src/SwitchifyPc.sln -c Release --no-restore - - name: Test C# solution - run: npm run dotnet:test + - name: Test + run: dotnet test src/SwitchifyPc.sln -c Release --no-build - - name: Stage C# Windows package - run: npm run dotnet:package:win:stage + - name: Stage Windows package + shell: pwsh + run: pwsh scripts/Package-Windows.ps1 -StageOnly -SkipSign - - name: Verify staged C# Windows package - run: npm run dotnet:package:win:verify + - name: Verify staged Windows package + shell: pwsh + run: pwsh scripts/Verify-DotnetPackage.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9aced0c..44c6ac2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,20 +45,19 @@ jobs: with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} - - name: Setup Node.js - uses: actions/setup-node@v6 + - name: Setup .NET + uses: actions/setup-dotnet@v5 with: - node-version: 25 - cache: npm + dotnet-version: 8.0.x - - name: Install dependencies - run: npm ci + - name: Restore + run: dotnet restore src/SwitchifyPc.sln - - name: Typecheck - run: npm run typecheck + - name: Build + run: dotnet build src/SwitchifyPc.sln -c Release --no-restore - name: Test - run: npm test + run: dotnet test src/SwitchifyPc.sln -c Release --no-build - name: Verify Certum signing certificate shell: powershell @@ -73,34 +72,34 @@ jobs: Select-Object -First 1 if (-not $cert) { - throw "Certum code-signing certificate $thumbprint was not found in Cert:\CurrentUser\My. Make sure SimplySign Desktop is logged in before running the release." + throw "Certum code-signing certificate was not found in Cert:\CurrentUser\My. Make sure SimplySign Desktop is logged in before running the release." } if (-not $cert.HasPrivateKey) { - throw "Certum certificate $thumbprint is present but has no private key." + throw "Certum certificate is present but has no private key." } Write-Host "Using Certum signing certificate: $($cert.Subject)" - - name: Build native cursor overlay helper - run: npm run native:build-overlay - - - name: Smoke native cursor overlay helper - run: npm run native:smoke-overlay - - name: Package Windows installer - run: npm run package:win + shell: powershell + run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\Package-Windows.ps1 - name: Verify updater metadata - run: npm run package:win:verify-updater-metadata + shell: powershell + run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\Verify-UpdaterMetadata.ps1 - - name: Verify release tag matches package version + - name: Verify release tag matches app version shell: powershell run: | - $package = Get-Content package.json | ConvertFrom-Json - $expectedTag = "v$($package.version)" + [xml]$project = Get-Content src\SwitchifyPc.App\SwitchifyPc.App.csproj + $version = $project.Project.PropertyGroup.Version | Select-Object -First 1 + if (-not $version) { + throw 'Could not read C# app project version.' + } + $expectedTag = "v$version" if ($env:RELEASE_TAG -ne $expectedTag) { - throw "Release tag $env:RELEASE_TAG does not match package.json version $expectedTag." + throw "Release tag $env:RELEASE_TAG does not match app version $expectedTag." } - name: Publish GitHub release diff --git a/.gitignore b/.gitignore index 92a3666..69c74bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ -node_modules/ dist/ -dist-dotnet/ build/ !build/ build/* @@ -17,9 +15,5 @@ src/**/obj/ .env .env.* *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* .DS_Store Thumbs.db diff --git a/AGENTS.md b/AGENTS.md index f4b847b..334c960 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project overview -Switchify PC is the desktop companion app for Switchify Android. Its first target is a Windows-first Electron app that accepts authenticated local WebSocket commands from the Android app and turns them into PC mouse, keyboard, text, media, and status actions. +Switchify PC is the Windows-native C#/.NET WPF desktop companion app for Switchify Android. It accepts authenticated Bluetooth commands from the Android app and turns them into PC mouse, keyboard, text, media, window, and status actions. The app should be treated as a trusted local control agent. Be conservative with networking, authentication, logging, and desktop input behavior. @@ -10,21 +10,21 @@ The app should be treated as a trusted local control agent. Be conservative with - Every change needs a GitHub issue before implementation. - Every change needs its own branch from `main`. -- Branch names should be short and scoped, for example `chore/electron-scaffold-1`, `feat/pairing-auth-3`, or `fix/ws-reconnect`. +- Branch names should be short and scoped, for example `chore/dotnet-package-1`, `feat/pairing-auth-3`, or `fix/bluetooth-reconnect`. - Keep pull requests tightly scoped to one issue wherever possible. - Do not mix implementation work with broad refactors unless the issue explicitly calls for it. - Prefer existing project patterns once the app scaffold exists. - Before creating a milestone, verify whether the intended milestone already exists. - Reuse an existing open milestone instead of creating a duplicate. - Create a release milestone only when it is missing. -- Do not create future release milestones casually. The active release milestone should normally be the next version after `package.json`. -- Before release work starts, verify the target milestone name exactly matches the version being released, for example `Release 0.1.8` for `package.json` version `0.1.8`. +- Do not create future release milestones casually. The active release milestone should normally be the next version after the C# app project ``. +- Before release work starts, verify the target milestone name exactly matches the version being released, for example `Release 0.2.0` for app project version `0.2.0`. ## Development standards -- Use TypeScript for app code. -- Keep Electron main-process code, renderer code, and shared protocol code clearly separated. -- Keep OS input execution behind a narrow adapter interface so protocol handling is not tied directly to a native automation library. +- Use C#/.NET for app code. +- Keep WPF app composition, core protocol/control logic, and Windows-specific integrations clearly separated. +- Keep OS input execution behind a narrow adapter interface so protocol handling is not tied directly to Win32 APIs. - Validate all WebSocket messages at runtime before using them. - Prefer small, testable modules for protocol parsing, pairing, auth, command routing, and desktop input mapping. - Do not log pairing tokens, shared secrets, raw auth headers, or full typed text payloads. @@ -47,13 +47,12 @@ The app should be treated as a trusted local control agent. Be conservative with - Convert native automation failures into structured command errors. - Manual Windows smoke testing matters for input changes because unit tests should use fake adapters instead of controlling the real OS. -## Electron app guidance +## Windows app guidance -- The main process owns WebSocket server lifecycle, pairing/auth state, tray behavior, and desktop input execution. -- The renderer shows pairing, connection status, server status, and recent non-sensitive errors. -- Communicate between renderer and main process through explicit IPC channels. +- The app owns Bluetooth lifecycle, pairing/auth state, tray behavior, update checks, and desktop input execution. +- The UI shows pairing, connection status, Bluetooth status, update state, and recent non-sensitive errors. - Keep the app useful from the tray: show window, server status, disconnect, and quit. -- Avoid blocking the Electron main process with long-running work. +- Avoid blocking the UI thread with long-running work. ## Design language @@ -68,8 +67,9 @@ The app should be treated as a trusted local control agent. Be conservative with Before pushing implementation changes, run the most relevant checks available for the current scaffold. As the repo matures, this should normally include: ```powershell -npm test -npm run build +dotnet restore src/SwitchifyPc.sln +dotnet build src/SwitchifyPc.sln -c Release --no-restore +dotnet test src/SwitchifyPc.sln -c Release --no-build ``` For desktop input or packaging changes, also run the relevant manual smoke path: @@ -85,14 +85,14 @@ For desktop input or packaging changes, also run the relevant manual smoke path: Only the maintainer should publish releases. -Release builds are published from tags named `vX.Y.Z`, where `X.Y.Z` must match `package.json`. +Release builds are published from tags named `vX.Y.Z`, where `X.Y.Z` must match `src/SwitchifyPc.App/SwitchifyPc.App.csproj` ``. Before creating a release milestone, verify whether the intended milestone already exists. Reuse an existing open milestone. Create the milestone only if it is missing. If the milestone exists but is closed, reopen it only when the work truly belongs in that release. The release workflow is `.github/workflows/release.yml`. It runs on the self-hosted Windows signing runner with the `switchify-signing` label. The runner is expected to have: - Windows. -- Node and npm. +- .NET SDK. - GitHub CLI authentication available to the workflow. - Windows SDK signing tools available, including `signtool`. - The Certum SimplySign certificate available in `Cert:\CurrentUser\My`. @@ -113,8 +113,8 @@ $env:SWITCHIFY_CERTUM_TIMESTAMP_URL = "http://time.certum.pl" Before creating or pushing a release tag, the maintainer or agent must verify all of the following and stop if any check fails: - Local `main` is clean and up to date with `origin/main`. -- `package.json` version is the exact version being released. -- The release tag is exactly `vX.Y.Z`, where `X.Y.Z` equals `package.json`. +- The C# app project `` is the exact version being released. +- The release tag is exactly `vX.Y.Z`, where `X.Y.Z` equals the C# app project ``. - The release issue and release PR are assigned to milestone `Release X.Y.Z`. - The milestone `Release X.Y.Z` exists and has no unrelated open issues. - The self-hosted runner with label `switchify-signing` is online and not busy. @@ -124,9 +124,10 @@ Before creating or pushing a release tag, the maintainer or agent must verify al Use commands like these for preflight checks. Do not print, document, or commit the real certificate thumbprint. ```powershell -$packageVersion = node -p "require('./package.json').version" -$expectedTag = "v$packageVersion" -$expectedMilestone = "Release $packageVersion" +[xml]$project = Get-Content src/SwitchifyPc.App/SwitchifyPc.App.csproj +$projectVersion = $project.Project.PropertyGroup.Version | Select-Object -First 1 +$expectedTag = "v$projectVersion" +$expectedMilestone = "Release $projectVersion" git status --short --branch git pull --ff-only @@ -175,22 +176,23 @@ If the thumbprint is available only as a GitHub repository variable, the maintai The release workflow: -- installs dependencies with `npm ci` -- runs `npm run typecheck` -- runs `npm test` +- restores the C# solution +- builds the C# solution +- runs C# tests - verifies the Certum signing certificate -- builds native helpers -- packages the Windows x64 NSIS installer with `npm run package:win` -- verifies the tag matches `package.json` +- packages the Windows x64 NSIS installer with `pwsh scripts/Package-Windows.ps1` +- verifies updater metadata +- verifies the tag matches the C# app project `` - uploads all top-level `dist` release assets to GitHub Releases, including the signed installer and updater metadata -Local packaging with `npm run package:win` creates artifacts under `dist`. It does not publish a GitHub release. +Local packaging with `pwsh scripts/Package-Windows.ps1` creates artifacts under `dist`. It does not publish a GitHub release. Before publishing a release, the maintainer should run or confirm the Windows smoke path: - signed installer verifies with Authenticode - installer installs per-machine under `C:\Program Files\Switchify PC\` -- `npm run package:win:verify-uiaccess` passes +- `pwsh scripts/Verify-DotnetPackage.ps1` passes +- `pwsh scripts/Verify-UpdaterMetadata.ps1` passes - app launches and stays running - tray menu works - Bluetooth pairing works @@ -212,7 +214,7 @@ Do not push a release tag until the release PR with the matching version bump ha Do not tag if: -- `package.json` does not match the intended tag. +- The C# app project `` does not match the intended tag. - The release issue or PR milestone does not match `Release X.Y.Z`. - The signing runner is offline or busy. - The Certum certificate cannot be verified locally. diff --git a/README.md b/README.md index 7441e03..fdedc72 100644 --- a/README.md +++ b/README.md @@ -36,47 +36,41 @@ If the main window is closed, Switchify PC continues running from the tray. Use ## Development -Install dependencies: +Restore the C# solution: ```powershell -npm install +dotnet restore src/SwitchifyPc.sln ``` -Run the Electron app in development mode: +Build the app: ```powershell -npm run dev +dotnet build src/SwitchifyPc.sln -c Release --no-restore ``` -Build the compiled Electron output: +Run tests: ```powershell -npm run build +dotnet test src/SwitchifyPc.sln -c Release --no-build ``` -Build the native Windows cursor overlay helper: - -```powershell -npm run native:build-overlay -``` +## Windows packaging -Run checks before pushing implementation changes: +Stage a local Windows x64 package without signing: ```powershell -npm run typecheck -npm test +pwsh scripts/Package-Windows.ps1 -StageOnly -SkipSign +pwsh scripts/Verify-DotnetPackage.ps1 ``` -## Windows packaging - -Create a local Windows x64 package: +Create a local Windows x64 installer: ```powershell -npm run package:win +pwsh scripts/Package-Windows.ps1 +pwsh scripts/Verify-DotnetPackage.ps1 +pwsh scripts/Verify-UpdaterMetadata.ps1 ``` -The package script runs `npm run build` and `npm run native:build-overlay` first, then creates an unpacked Windows artifact in `dist/win-unpacked` and a per-machine NSIS installer in `dist`. - Local packaging builds artifacts under `dist`. It does not publish a GitHub release. ## Windows uiAccess packaging @@ -90,33 +84,16 @@ Windows only honors `uiAccess` when all of these are true: - The executable is installed in a secure location such as `C:\Program Files\Switchify PC\`. - Signing happens after icon and manifest resource embedding. -The packaged Windows app also disables the Chromium GPU child-process sandbox at startup. Without that switch, Electron's GPU child process can fail to launch under `uiAccess=true`, causing the app to exit even when the manifest, signature, and install location are valid. - -Packaged Windows builds include `SwitchifyCursorOverlay.exe`, a native self-contained cursor overlay helper under app resources. The Electron app controls it over stdin JSON lines so cursor feedback can render as a per-pixel-alpha ring over protected UI. If the helper cannot start, Switchify PC falls back to the Electron overlay instead of failing input commands. - -Development builds can use a local certificate chain trusted on the test machine. The script creates a local dev root CA, creates a code-signing leaf certificate, exports the leaf PFX, stores the leaf thumbprint for `signtool`, and imports trust material into the current user and local machine certificate stores. Accept the UAC prompt so Windows can trust the certificate machine-wide for `uiAccess`. - -```powershell -$env:SWITCHIFY_DEV_CERT_PASSWORD = "choose-a-local-password" -npm run signing:create-dev-cert -``` - -Then package and verify: - -```powershell -$env:SWITCHIFY_DEV_CERT_PASSWORD = "same-password" -npm run package:win -npm run package:win:verify-uiaccess -``` - -Run the generated installer from `dist` and install per-machine. To verify the installed Program Files copy launches and stays running, use: +Development builds can use a local code-signing certificate through the signing environment variables supported by `scripts/WinSigningTools.psm1`. Then package and verify: ```powershell -$env:SWITCHIFY_VERIFY_INSTALLED_LAUNCH = "1" -npm run package:win:verify-uiaccess +$env:SWITCHIFY_DEV_CERT_PASSWORD = "" +pwsh scripts/Package-Windows.ps1 +pwsh scripts/Verify-DotnetPackage.ps1 +pwsh scripts/Verify-UpdaterMetadata.ps1 ``` -Running from `npm run dev`, `dist/win-unpacked`, AppData, Downloads, or the repo does not prove that `uiAccess` is active. +Run the generated installer from `dist` and install per-machine. Running from the repo, `dist/win-unpacked`, AppData, or Downloads does not prove that `uiAccess` is active. Self-signed certificates are for development and testing only. Production users should not be asked to trust a self-signed certificate manually. @@ -138,17 +115,17 @@ The release workflow expects the Certum certificate to be available in `Cert:\Cu Only the maintainer should publish releases. -Release builds are published from tags named `vX.Y.Z`, where `X.Y.Z` matches `package.json`. +Release builds are published from tags named `vX.Y.Z`, where `X.Y.Z` matches the C# app project ``. The release workflow runs on the self-hosted Windows signing runner with the `switchify-signing` label. It: -- installs dependencies with `npm ci` -- runs `npm run typecheck` -- runs `npm test` +- restores the C# solution +- builds the C# solution +- runs C# tests - verifies the Certum signing certificate -- builds native helpers - packages the Windows x64 NSIS installer -- verifies the tag matches `package.json` +- verifies updater metadata +- verifies the tag matches the C# app project version - uploads the installer and update metadata to GitHub Releases The maintainer can publish a release by pushing an annotated tag: @@ -182,7 +159,7 @@ Use this checklist after packaging changes and before publishing any installer: - Tray menu opens and can show the main window. - Main window shows Android download QR/link before a device is connected. - Android download link opens externally in the browser. -- Bluetooth helper starts and reports a safe status. +- Bluetooth starts and reports a safe status. - No QR/manual local-network connection UI appears for pairing or control. - No local IP address or WebSocket address appears in Settings or troubleshooting. - Pairing approval requests appear and can be accepted or rejected. @@ -202,7 +179,7 @@ Use this checklist after packaging changes and before publishing any installer: - Settings > Updates can check for updates in packaged builds. - Downloaded updates show an `Install update` action. - Disconnect all removes active Bluetooth sessions. -- Quit exits the app, removes the tray icon, and exits the native cursor overlay helper. +- Quit exits the app and removes the tray icon. ## License diff --git a/installer/SwitchifyPc.DotNet.nsi b/installer/SwitchifyPc.DotNet.nsi index 182f375..6b33ba1 100644 --- a/installer/SwitchifyPc.DotNet.nsi +++ b/installer/SwitchifyPc.DotNet.nsi @@ -6,11 +6,11 @@ !endif !ifndef SOURCE_DIR -!define SOURCE_DIR "..\dist-dotnet\win-unpacked" +!define SOURCE_DIR "..\dist\win-unpacked" !endif !ifndef OUTPUT_EXE -!define OUTPUT_EXE "..\dist-dotnet\Switchify-PC-Setup-${VERSION}-x64.exe" +!define OUTPUT_EXE "..\dist\Switchify-PC-Setup-${VERSION}-x64.exe" !endif Name "Switchify PC" diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 3eee9a0..0000000 --- a/package-lock.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "switchify-pc", - "version": "0.2.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "switchify-pc", - "version": "0.2.0", - "license": "AGPL-3.0-or-later" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 2d96b5d..0000000 --- a/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "switchify-pc", - "version": "0.2.0", - "description": "Desktop companion app for Switchify Android.", - "scripts": { - "dotnet:restore": "dotnet restore src/SwitchifyPc.sln", - "dotnet:build": "dotnet build src/SwitchifyPc.sln -c Release --no-restore", - "dotnet:test": "dotnet test src/SwitchifyPc.sln -c Release --no-build", - "dotnet:publish": "dotnet publish src/SwitchifyPc.App/SwitchifyPc.App.csproj -c Release -r win-x64 --self-contained true", - "dotnet:package:win": "node scripts/package-dotnet-win.cjs", - "dotnet:package:win:stage": "node scripts/package-dotnet-win.cjs --stage-only --skip-sign", - "dotnet:package:win:verify": "node scripts/verify-dotnet-package.cjs", - "package:win:verify-updater-metadata": "node scripts/verify-latest-yml-admin-rights.cjs" - }, - "keywords": [ - "switchify", - "accessibility", - "desktop-control" - ], - "author": "Owen McGirr", - "license": "AGPL-3.0-or-later", - "type": "commonjs", - "homepage": "https://github.com/switchifyapp/switchify-pc#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/switchifyapp/switchify-pc.git" - }, - "bugs": { - "url": "https://github.com/switchifyapp/switchify-pc/issues" - } -} diff --git a/scripts/Package-Windows.ps1 b/scripts/Package-Windows.ps1 new file mode 100644 index 0000000..1779907 --- /dev/null +++ b/scripts/Package-Windows.ps1 @@ -0,0 +1,178 @@ +param( + [switch]$StageOnly, + [switch]$SkipSign, + [string]$Configuration = 'Release', + [string]$RuntimeIdentifier = 'win-x64' +) + +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'WinSigningTools.psm1') -Force -DisableNameChecking + +$framework = 'net8.0-windows10.0.19041.0' +$appProjectPath = Resolve-ProjectPath 'src' 'SwitchifyPc.App' 'SwitchifyPc.App.csproj' +$version = Get-ProjectVersion -ProjectPath $appProjectPath +$publishDir = Resolve-ProjectPath 'src' 'SwitchifyPc.App' 'bin' $Configuration $framework $RuntimeIdentifier 'publish' +$distDir = Resolve-ProjectPath 'dist' +$stageDir = Join-Path $distDir 'win-unpacked' +$installerName = "Switchify-PC-Setup-$version-x64.exe" +$installerPath = Join-Path $distDir $installerName + +function Reset-Directory { + param([Parameter(Mandatory = $true)][string]$Path) + + if (Test-Path -LiteralPath $Path) { + Remove-Item -LiteralPath $Path -Recurse -Force + } + New-Item -ItemType Directory -Path $Path -Force | Out-Null +} + +function Reset-PackageArtifacts { + New-Item -ItemType Directory -Path $distDir -Force | Out-Null + + $latest = Join-Path $distDir 'latest.yml' + if (Test-Path -LiteralPath $latest) { + Remove-Item -LiteralPath $latest -Force + } + + Get-ChildItem -LiteralPath $distDir -File -Filter 'Switchify-PC-Setup-*-x64.exe' | + Remove-Item -Force +} + +function Copy-Directory { + param( + [Parameter(Mandatory = $true)][string]$From, + [Parameter(Mandatory = $true)][string]$To + ) + + if (-not (Test-Path -LiteralPath $From -PathType Container)) { + throw "Publish directory was not found: $From" + } + + Copy-Item -Path (Join-Path $From '*') -Destination $To -Recurse -Force +} + +function Assert-StagedApp { + $executable = Join-Path $stageDir 'Switchify PC.exe' + if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { + throw "Staged C# app executable is missing: $executable" + } + + $markers = @( + (Join-Path $stageDir 'resources'), + (Join-Path $stageDir 'chrome_100_percent.pak'), + (Join-Path $stageDir 'ffmpeg.dll') + ) + + foreach ($marker in $markers) { + if (Test-Path -LiteralPath $marker) { + throw "Staged C# app unexpectedly contains legacy runtime marker: $marker" + } + } +} + +function Build-Installer { + $makensis = Get-MakeNsisTool + if (Test-Path -LiteralPath $installerPath) { + Remove-Item -LiteralPath $installerPath -Force + } + + Invoke-ExternalTool -FilePath $makensis -Arguments @( + "/DVERSION=$version", + "/DSOURCE_DIR=$stageDir", + "/DOUTPUT_EXE=$installerPath", + (Resolve-ProjectPath 'installer' 'SwitchifyPc.DotNet.nsi') + ) + + if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { + throw "NSIS did not create expected installer: $installerPath" + } +} + +function Write-LatestYml { + $sha512 = Get-Sha512Base64 -Path $installerPath + $size = (Get-Item -LiteralPath $installerPath).Length + $releaseDate = [DateTimeOffset]::UtcNow.ToString('o') + $latestPath = Join-Path $distDir 'latest.yml' + $lines = @( + "version: $version", + 'files:', + " - url: $installerName", + " sha512: $sha512", + " size: $size", + ' isAdminRightsRequired: true', + "path: $installerName", + "sha512: $sha512", + 'isAdminRightsRequired: true', + "releaseDate: '$releaseDate'", + '' + ) + + Set-Content -LiteralPath $latestPath -Value $lines -Encoding UTF8 +} + +function Write-BuilderDebug { + $builderDebugPath = Join-Path $distDir 'builder-debug.yml' + $lines = @( + "version: $version", + 'packager: dotnet-wpf-nsis', + "stageDir: $(ConvertTo-YamlString $stageDir)", + "publishDir: $(ConvertTo-YamlString $publishDir)", + '' + ) + + Set-Content -LiteralPath $builderDebugPath -Value $lines -Encoding UTF8 +} + +function Get-Sha512Base64 { + param([Parameter(Mandatory = $true)][string]$Path) + + $sha = [System.Security.Cryptography.SHA512]::Create() + try { + $stream = [System.IO.File]::OpenRead($Path) + try { + return [Convert]::ToBase64String($sha.ComputeHash($stream)) + } finally { + $stream.Dispose() + } + } finally { + $sha.Dispose() + } +} + +function ConvertTo-YamlString { + param([Parameter(Mandatory = $true)][string]$Value) + + return '"' + ($Value.Replace('\', '/').Replace('"', '\"')) + '"' +} + +dotnet publish $appProjectPath -c $Configuration -r $RuntimeIdentifier --self-contained true +if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed with exit code $LASTEXITCODE." +} + +Reset-PackageArtifacts +Reset-Directory -Path $stageDir +Copy-Directory -From $publishDir -To $stageDir +Assert-StagedApp +Write-BuilderDebug + +$appExe = Join-Path $stageDir 'Switchify PC.exe' +if (-not $SkipSign) { + Invoke-EmbedUiAccessManifest -ExecutablePath $appExe + Invoke-SignFile -FilePath $appExe -RequireSigning $true +} + +if (-not $StageOnly) { + Build-Installer + if (-not $SkipSign) { + Invoke-SignFile -FilePath $installerPath -RequireSigning $true + } + Write-LatestYml +} + +if ($StageOnly) { + Write-Host "Staged C# app in $stageDir" +} else { + Write-Host "Packaged C# installer at $installerPath" +} diff --git a/scripts/Verify-DotnetPackage.ps1 b/scripts/Verify-DotnetPackage.ps1 new file mode 100644 index 0000000..8ffad52 --- /dev/null +++ b/scripts/Verify-DotnetPackage.ps1 @@ -0,0 +1,92 @@ +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'WinSigningTools.psm1') -Force -DisableNameChecking + +$distDir = Resolve-ProjectPath 'dist' +$stageDir = Join-Path $distDir 'win-unpacked' +$appExe = Join-Path $stageDir 'Switchify PC.exe' +$builderDebug = Join-Path $distDir 'builder-debug.yml' +$latestYml = Join-Path $distDir 'latest.yml' +$appProjectPath = Resolve-ProjectPath 'src' 'SwitchifyPc.App' 'SwitchifyPc.App.csproj' +$appVersion = Get-ProjectVersion -ProjectPath $appProjectPath +$expectedInstaller = Join-Path $distDir "Switchify-PC-Setup-$appVersion-x64.exe" +$installerScript = Resolve-ProjectPath 'installer' 'SwitchifyPc.DotNet.nsi' + +function Assert-Exists { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Label + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "$Label was not found: $Path" + } +} + +function Assert-NoLegacyRuntime { + $markers = @( + (Join-Path $stageDir 'resources/app.asar'), + (Join-Path $stageDir 'chrome_100_percent.pak'), + (Join-Path $stageDir 'ffmpeg.dll'), + (Join-Path $stageDir ('resources/' + 'elect' + 'ron.asar')) + ) + + foreach ($marker in $markers) { + if (Test-Path -LiteralPath $marker) { + throw "C# package contains legacy runtime marker: $marker" + } + } +} + +function Assert-InstallerScript { + Assert-Exists -Path $installerScript -Label 'C# NSIS installer script' + $content = Get-Content -LiteralPath $installerScript -Raw + Assert-Includes -Content $content -Expected 'RequestExecutionLevel admin' -Label 'installer elevation requirement' + Assert-Includes -Content $content -Expected 'InstallDir "$PROGRAMFILES64\Switchify PC"' -Label 'installer Program Files target' + Assert-Includes -Content $content -Expected '!define MUI_FINISHPAGE_RUN "$INSTDIR\Switchify PC.exe"' -Label 'installer run-after-finish behavior' + Assert-Includes -Content $content -Expected 'Call PromptForRunningApp' -Label 'installer running-app prompt' + Assert-Includes -Content $content -Expected 'Call un.PromptForRunningApp' -Label 'uninstaller running-app prompt' + Assert-Includes -Content $content -Expected 'find /I "Switchify PC.exe"' -Label 'installer process detection' + Assert-Includes -Content $content -Expected 'Software\Microsoft\Windows\CurrentVersion\Uninstall\Switchify PC' -Label 'installer uninstall registry key' +} + +function Assert-Includes { + param( + [Parameter(Mandatory = $true)][string]$Content, + [Parameter(Mandatory = $true)][string]$Expected, + [Parameter(Mandatory = $true)][string]$Label + ) + + if (-not $Content.Contains($Expected)) { + throw "$Label is missing $Expected." + } +} + +function Assert-Regex { + param( + [Parameter(Mandatory = $true)][string]$Content, + [Parameter(Mandatory = $true)][string]$Regex, + [Parameter(Mandatory = $true)][string]$Label + ) + + if ($Content -notmatch $Regex) { + throw "$Label is missing." + } +} + +Assert-Exists -Path $appExe -Label 'staged C# app executable' +Assert-Exists -Path $builderDebug -Label 'C# builder debug metadata' +Assert-InstallerScript +Assert-NoLegacyRuntime + +if ((Test-Path -LiteralPath $expectedInstaller) -or (Test-Path -LiteralPath $latestYml)) { + Assert-Exists -Path $expectedInstaller -Label 'C# NSIS installer' + Assert-Exists -Path $latestYml -Label 'C# updater metadata' + $latest = Get-Content -LiteralPath $latestYml -Raw + Assert-Includes -Content $latest -Expected "version: $appVersion" -Label 'latest.yml version' + Assert-Includes -Content $latest -Expected "path: $(Split-Path -Leaf $expectedInstaller)" -Label 'latest.yml installer path' + Assert-Regex -Content $latest -Regex '(?m)^isAdminRightsRequired:\s*true\s*$' -Label 'top-level admin metadata' + Assert-Regex -Content $latest -Regex '(?m)^ isAdminRightsRequired:\s*true\s*$' -Label 'file-entry admin metadata' +} + +Write-Host 'C# package verification passed.' diff --git a/scripts/Verify-UpdaterMetadata.ps1 b/scripts/Verify-UpdaterMetadata.ps1 new file mode 100644 index 0000000..7f76a29 --- /dev/null +++ b/scripts/Verify-UpdaterMetadata.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'WinSigningTools.psm1') -Force -DisableNameChecking + +$latestPath = Resolve-ProjectPath 'dist' 'latest.yml' +if (-not (Test-Path -LiteralPath $latestPath -PathType Leaf)) { + throw "latest.yml was not found: $latestPath" +} + +$content = Get-Content -LiteralPath $latestPath -Raw +if ($content -notmatch '(?m)^isAdminRightsRequired:\s*true\s*$') { + throw 'latest.yml is missing top-level isAdminRightsRequired: true.' +} + +if ($content -notmatch '(?m)^ isAdminRightsRequired:\s*true\s*$') { + throw 'latest.yml is missing installer file entry isAdminRightsRequired: true.' +} + +Write-Host 'latest.yml admin-rights metadata: present' diff --git a/scripts/WinSigningTools.psm1 b/scripts/WinSigningTools.psm1 new file mode 100644 index 0000000..32a3ecc --- /dev/null +++ b/scripts/WinSigningTools.psm1 @@ -0,0 +1,317 @@ +$ErrorActionPreference = 'Stop' + +function Resolve-ProjectPath { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Segments) + + $root = Resolve-Path (Join-Path $PSScriptRoot '..') + $path = $root.Path + if (-not $Segments -or $Segments.Count -eq 0) { + return $path + } + + foreach ($segment in $Segments) { + $path = Join-Path $path $segment + } + + return $path +} + +function Get-WindowsSdkTool { + param([Parameter(Mandatory = $true)][string]$ToolName) + + $overrideName = if ($ToolName.ToLowerInvariant() -eq 'mt.exe') { 'SWITCHIFY_MT_EXE' } else { 'SWITCHIFY_SIGNTOOL_EXE' } + $override = [Environment]::GetEnvironmentVariable($overrideName) + if ($override) { + if (-not (Test-Path -LiteralPath $override -PathType Leaf)) { + throw "$overrideName was configured but does not exist." + } + return $override + } + + $sdkBinRoot = 'C:\Program Files (x86)\Windows Kits\10\bin' + if (Test-Path -LiteralPath $sdkBinRoot -PathType Container) { + $candidate = Get-ChildItem -LiteralPath $sdkBinRoot -Directory | + Sort-Object Name -Descending | + ForEach-Object { + $toolPath = Join-Path $_.FullName "x64\$ToolName" + if (Test-Path -LiteralPath $toolPath -PathType Leaf) { $toolPath } + } | + Select-Object -First 1 + + if ($candidate) { + return $candidate + } + } + + $whereResult = & cmd.exe /c "where $ToolName 2>NUL" + if ($LASTEXITCODE -eq 0) { + $first = $whereResult | Where-Object { $_ } | Select-Object -First 1 + if ($first) { + return $first + } + } + + throw "Unable to find $ToolName. Install the Windows SDK or set $overrideName." +} + +function Get-MakeNsisTool { + $override = [Environment]::GetEnvironmentVariable('SWITCHIFY_MAKENSIS_EXE') + if ($override) { + if (-not (Test-Path -LiteralPath $override -PathType Leaf)) { + throw 'SWITCHIFY_MAKENSIS_EXE does not exist.' + } + return $override + } + + $candidates = @( + 'C:\Program Files (x86)\NSIS\makensis.exe', + 'C:\Program Files\NSIS\makensis.exe' + ) + + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return $candidate + } + } + + $whereResult = & cmd.exe /c 'where makensis.exe 2>NUL' + if ($LASTEXITCODE -eq 0) { + $first = $whereResult | Where-Object { $_ } | Select-Object -First 1 + if ($first) { + return $first + } + } + + throw 'Unable to find makensis.exe. Install NSIS or set SWITCHIFY_MAKENSIS_EXE.' +} + +function Invoke-ExternalTool { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [Parameter(Mandatory = $true)][string[]]$Arguments + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$FilePath failed with exit code $LASTEXITCODE." + } +} + +function Get-ProjectVersion { + param([Parameter(Mandatory = $true)][string]$ProjectPath) + + [xml]$project = Get-Content -LiteralPath $ProjectPath + $version = $project.Project.PropertyGroup | + ForEach-Object { $_.Version } | + Where-Object { $_ } | + Select-Object -First 1 + + if (-not $version) { + throw "Could not read C# app from $ProjectPath." + } + + return [string]$version +} + +function Invoke-EmbedUiAccessManifest { + param([Parameter(Mandatory = $true)][string]$ExecutablePath) + + $mt = Get-WindowsSdkTool -ToolName 'mt.exe' + $manifestPath = Resolve-ProjectPath 'build' 'win-uiaccess.manifest' + Invoke-ExternalTool -FilePath $mt -Arguments @('-nologo', '-manifest', $manifestPath, "-outputresource:$ExecutablePath;#1") +} + +function Invoke-SignFile { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [bool]$RequireSigning = $true + ) + + $args = Get-SigningArguments -FilePath $FilePath -RequireSigning:$RequireSigning + if (-not $args) { + return + } + + $signtool = Get-WindowsSdkTool -ToolName 'signtool.exe' + Invoke-ExternalTool -FilePath $signtool -Arguments $args +} + +function Get-SigningArguments { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [bool]$RequireSigning = $true + ) + + if (-not (Test-IsWindows)) { + return $null + } + + $mode = [Environment]::GetEnvironmentVariable('SWITCHIFY_SIGNING_MODE') + if ($mode -eq 'certum-simplysign') { + return Get-CertumSigningArguments -FilePath $FilePath -RequireSigning:$RequireSigning + } + + if ($mode -eq 'azure') { + return Get-AzureSigningArguments -FilePath $FilePath -RequireSigning:$RequireSigning + } + + $devPfx = Get-DevPfxSigningArguments -FilePath $FilePath + if ($devPfx) { + return $devPfx + } + + $devStore = Get-DevStoreSigningArguments -FilePath $FilePath + if ($devStore) { + return $devStore + } + + $azure = Get-AzureSigningArguments -FilePath $FilePath -RequireSigning:$false + if ($azure) { + return $azure + } + + if ([Environment]::GetEnvironmentVariable('SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE') -eq '1') { + if ($RequireSigning) { + Write-Warning 'Producing an unsigned uiAccess package because SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE=1.' + } + return $null + } + + throw 'Signing is required for uiAccess packaging. Configure Certum SimplySign, a dev certificate, or Azure Artifact Signing.' +} + +function Test-IsWindows { + return [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( + [System.Runtime.InteropServices.OSPlatform]::Windows + ) +} + +function Get-CertumSigningArguments { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [bool]$RequireSigning + ) + + $thumbprint = Normalize-Thumbprint ([Environment]::GetEnvironmentVariable('SWITCHIFY_CERTUM_CERT_THUMBPRINT')) + if (-not $thumbprint) { + if ($RequireSigning) { + throw 'Certum SimplySign signing requires SWITCHIFY_CERTUM_CERT_THUMBPRINT.' + } + return $null + } + + $timestamp = [Environment]::GetEnvironmentVariable('SWITCHIFY_CERTUM_TIMESTAMP_URL') + if (-not $timestamp) { + $timestamp = 'http://time.certum.pl' + } + + return @('sign', '/v', '/fd', 'SHA256', '/sha1', $thumbprint, '/tr', $timestamp, '/td', 'SHA256', $FilePath) +} + +function Get-DevPfxSigningArguments { + param([Parameter(Mandatory = $true)][string]$FilePath) + + $password = [Environment]::GetEnvironmentVariable('SWITCHIFY_DEV_CERT_PASSWORD') + $pfxPath = [Environment]::GetEnvironmentVariable('SWITCHIFY_DEV_CERT_PFX') + if (-not $pfxPath) { + $pfxPath = Resolve-ProjectPath '.certs' 'switchify-dev-code-signing.pfx' + } + $thumbprint = Resolve-DevCertificateThumbprint -PfxPath $pfxPath + + if (-not $password -or -not (Test-Path -LiteralPath $pfxPath -PathType Leaf)) { + return $null + } + + $args = @('sign', '/fd', 'SHA256', '/f', $pfxPath, '/p', $password) + if ($thumbprint) { + $args += @('/sha1', $thumbprint) + } + if ([Environment]::GetEnvironmentVariable('SWITCHIFY_SIGN_SKIP_TIMESTAMP') -ne '1') { + $args += @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256') + } + $args += $FilePath + return $args +} + +function Get-DevStoreSigningArguments { + param([Parameter(Mandatory = $true)][string]$FilePath) + + $pfxPath = [Environment]::GetEnvironmentVariable('SWITCHIFY_DEV_CERT_PFX') + if (-not $pfxPath) { + $pfxPath = Resolve-ProjectPath '.certs' 'switchify-dev-code-signing.pfx' + } + $thumbprint = Resolve-DevCertificateThumbprint -PfxPath $pfxPath + if (-not $thumbprint) { + return $null + } + + $args = @('sign', '/fd', 'SHA256', '/sha1', $thumbprint) + if ([Environment]::GetEnvironmentVariable('SWITCHIFY_SIGN_SKIP_TIMESTAMP') -ne '1') { + $args += @('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256') + } + $args += $FilePath + return $args +} + +function Resolve-DevCertificateThumbprint { + param([Parameter(Mandatory = $true)][string]$PfxPath) + + $explicit = Normalize-Thumbprint ([Environment]::GetEnvironmentVariable('SWITCHIFY_DEV_CERT_THUMBPRINT')) + if ($explicit) { + return $explicit + } + + $thumbprintPath = Join-Path (Split-Path -Parent $PfxPath) 'switchify-dev-code-signing.thumbprint' + if (-not (Test-Path -LiteralPath $thumbprintPath -PathType Leaf)) { + return $null + } + + return Normalize-Thumbprint (Get-Content -LiteralPath $thumbprintPath -Raw) +} + +function Normalize-Thumbprint { + param([AllowNull()][string]$Value) + + if (-not $Value) { + return $null + } + + $thumbprint = ($Value -replace '[^a-fA-F0-9]', '').ToUpperInvariant() + if ($thumbprint.Length -eq 0) { + return $null + } + return $thumbprint +} + +function Get-AzureSigningArguments { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [bool]$RequireSigning + ) + + $dlib = [Environment]::GetEnvironmentVariable('SWITCHIFY_AZURE_SIGNING_DLIB') + $metadata = [Environment]::GetEnvironmentVariable('SWITCHIFY_AZURE_SIGNING_METADATA') + if (-not $dlib -or -not $metadata) { + if ($RequireSigning) { + throw 'Azure Artifact Signing requires SWITCHIFY_AZURE_SIGNING_DLIB and SWITCHIFY_AZURE_SIGNING_METADATA.' + } + return $null + } + + return @( + 'sign', + '/v', + '/debug', + '/fd', + 'SHA256', + '/tr', + 'http://timestamp.acs.microsoft.com', + '/td', + 'SHA256', + '/dlib', + $dlib, + '/dmdf', + $metadata, + $FilePath + ) +} diff --git a/scripts/create-dev-signing-cert.cjs b/scripts/create-dev-signing-cert.cjs deleted file mode 100644 index 62afc71..0000000 --- a/scripts/create-dev-signing-cert.cjs +++ /dev/null @@ -1,47 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const { runTool, resolveProjectPath } = require('./win-signing-tools.cjs'); - -const password = process.env.SWITCHIFY_DEV_CERT_PASSWORD; -if (!password) { - throw new Error('SWITCHIFY_DEV_CERT_PASSWORD is required to create the dev signing certificate.'); -} - -const certDir = resolveProjectPath('.certs'); -const pfxPath = process.env.SWITCHIFY_DEV_CERT_PFX || path.join(certDir, 'switchify-dev-code-signing.pfx'); -const cerPath = path.join(path.dirname(pfxPath), 'switchify-dev-code-signing.cer'); -const rootCerPath = path.join(path.dirname(pfxPath), 'switchify-dev-code-signing-root.cer'); -const thumbprintPath = path.join(path.dirname(pfxPath), 'switchify-dev-code-signing.thumbprint'); - -fs.mkdirSync(path.dirname(pfxPath), { recursive: true }); - -const escapedPfxPath = escapePowerShellString(pfxPath); -const escapedCerPath = escapePowerShellString(cerPath); -const escapedRootCerPath = escapePowerShellString(rootCerPath); -const escapedThumbprintPath = escapePowerShellString(thumbprintPath); -const escapedPassword = escapePowerShellString(password); - -const script = ` -$password = ConvertTo-SecureString '${escapedPassword}' -AsPlainText -Force -$root = New-SelfSignedCertificate -Subject 'CN=Switchify PC Dev Code Signing Root' -CertStoreLocation 'Cert:\\CurrentUser\\My' -KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 -KeyExportPolicy Exportable -KeyUsage CertSign,CRLSign,DigitalSignature -TextExtension @('2.5.29.19={critical}{text}ca=1&pathlength=1','2.5.29.37={text}1.3.6.1.5.5.7.3.3') -NotAfter (Get-Date).AddYears(5) -$cert = New-SelfSignedCertificate -Subject 'CN=Switchify PC Dev Code Signing' -CertStoreLocation 'Cert:\\CurrentUser\\My' -Signer $root -KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 -KeyExportPolicy Exportable -KeyUsage DigitalSignature -TextExtension @('2.5.29.19={critical}{text}ca=0','2.5.29.37={text}1.3.6.1.5.5.7.3.3') -NotAfter (Get-Date).AddYears(1) -Export-PfxCertificate -Cert $cert -FilePath '${escapedPfxPath}' -Password $password | Out-Null -Export-Certificate -Cert $cert -FilePath '${escapedCerPath}' | Out-Null -Export-Certificate -Cert $root -FilePath '${escapedRootCerPath}' | Out-Null -Set-Content -Path '${escapedThumbprintPath}' -Value $cert.Thumbprint -Encoding ascii -Import-Certificate -FilePath '${escapedRootCerPath}' -CertStoreLocation 'Cert:\\CurrentUser\\Root' | Out-Null -Import-Certificate -FilePath '${escapedCerPath}' -CertStoreLocation 'Cert:\\CurrentUser\\TrustedPublisher' | Out-Null -$machineTrustScript = "Import-Certificate -FilePath '${escapedRootCerPath}' -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null; Import-Certificate -FilePath '${escapedCerPath}' -CertStoreLocation Cert:\\LocalMachine\\TrustedPublisher | Out-Null" -Start-Process -FilePath powershell.exe -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-Command',$machineTrustScript) -Verb RunAs -Wait -Write-Host 'Created dev code-signing certificate.' -Write-Host 'PFX:' '${escapedPfxPath}' -Write-Host 'Leaf CER:' '${escapedCerPath}' -Write-Host 'Root CER:' '${escapedRootCerPath}' -Write-Host 'Leaf thumbprint:' $cert.Thumbprint -`.trim(); - -runTool('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script]); - -function escapePowerShellString(value) { - return value.replace(/'/g, "''"); -} diff --git a/scripts/package-dotnet-win.cjs b/scripts/package-dotnet-win.cjs deleted file mode 100644 index b4571b9..0000000 --- a/scripts/package-dotnet-win.cjs +++ /dev/null @@ -1,222 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const crypto = require('node:crypto'); -const { spawnSync } = require('node:child_process'); -const { - createSigningArgs, - findWindowsSdkTool, - isWindows, - resolveProjectPath, - runTool -} = require('./win-signing-tools.cjs'); - -const stageOnly = process.argv.includes('--stage-only'); -const skipSign = process.argv.includes('--skip-sign'); -const appProjectPath = resolveProjectPath('src', 'SwitchifyPc.App', 'SwitchifyPc.App.csproj'); -const version = readDotnetAppVersion(appProjectPath); -const publishDir = resolveProjectPath( - 'src', - 'SwitchifyPc.App', - 'bin', - 'Release', - 'net8.0-windows10.0.19041.0', - 'win-x64', - 'publish' -); -const distDir = resolveProjectPath('dist-dotnet'); -const stageDir = path.join(distDir, 'win-unpacked'); -const installerName = `Switchify-PC-Setup-${version}-x64.exe`; -const installerPath = path.join(distDir, installerName); - -runDotnetPublish(); -resetPackageArtifacts(); -resetDirectory(stageDir); -copyDirectory(publishDir, stageDir); -verifyStagedApp(); -writeBuilderDebug(); - -const appExe = path.join(stageDir, 'Switchify PC.exe'); -if (isWindows() && !skipSign) { - embedUiAccessManifest(appExe); - signFile(appExe, { requireSigning: true }); -} - -if (!stageOnly) { - buildInstaller(); - if (isWindows() && !skipSign) { - signFile(installerPath, { requireSigning: true }); - } - writeLatestYml(); -} - -console.log(stageOnly ? `Staged C# app in ${stageDir}` : `Packaged C# installer at ${installerPath}`); - -function runDotnetPublish() { - const result = spawnSync( - 'dotnet', - [ - 'publish', - appProjectPath, - '-c', - 'Release', - '-r', - 'win-x64', - '--self-contained', - 'true' - ], - { stdio: 'inherit' } - ); - if (result.status !== 0) { - throw new Error(`dotnet publish failed with exit code ${result.status ?? 'unknown'}.`); - } -} - -function resetDirectory(directory) { - fs.rmSync(directory, { recursive: true, force: true }); - fs.mkdirSync(directory, { recursive: true }); -} - -function resetPackageArtifacts() { - fs.mkdirSync(distDir, { recursive: true }); - fs.rmSync(path.join(distDir, 'latest.yml'), { force: true }); - for (const entry of fs.readdirSync(distDir)) { - if (/^Switchify-PC-Setup-.+-x64\.exe$/i.test(entry)) { - fs.rmSync(path.join(distDir, entry), { force: true }); - } - } -} - -function copyDirectory(from, to) { - if (!fs.existsSync(from)) { - throw new Error(`Publish directory was not found: ${from}`); - } - - for (const entry of fs.readdirSync(from, { withFileTypes: true })) { - const source = path.join(from, entry.name); - const target = path.join(to, entry.name); - if (entry.isDirectory()) { - fs.mkdirSync(target, { recursive: true }); - copyDirectory(source, target); - } else { - fs.copyFileSync(source, target); - } - } -} - -function verifyStagedApp() { - const executable = path.join(stageDir, 'Switchify PC.exe'); - if (!fs.existsSync(executable)) { - throw new Error(`Staged C# app executable is missing: ${executable}`); - } - - const electronMarkers = ['resources', 'chrome_100_percent.pak', 'ffmpeg.dll']; - for (const marker of electronMarkers) { - if (fs.existsSync(path.join(stageDir, marker))) { - throw new Error(`Staged C# app unexpectedly contains Electron marker: ${marker}`); - } - } -} - -function embedUiAccessManifest(executablePath) { - const mtExe = findWindowsSdkTool('mt.exe'); - const manifestPath = resolveProjectPath('build', 'win-uiaccess.manifest'); - runTool(mtExe, ['-nologo', '-manifest', manifestPath, `-outputresource:${executablePath};#1`]); -} - -function signFile(filePath, { requireSigning }) { - const signingArgs = createSigningArgs(filePath, { requireSigning }); - if (!signingArgs) return; - - const signtoolExe = findWindowsSdkTool('signtool.exe'); - runTool(signtoolExe, signingArgs); -} - -function buildInstaller() { - const makensis = findMakensis(); - fs.rmSync(installerPath, { force: true }); - runTool(makensis, [ - `/DVERSION=${version}`, - `/DSOURCE_DIR=${stageDir}`, - `/DOUTPUT_EXE=${installerPath}`, - resolveProjectPath('installer', 'SwitchifyPc.DotNet.nsi') - ]); - if (!fs.existsSync(installerPath)) { - throw new Error(`NSIS did not create expected installer: ${installerPath}`); - } -} - -function findMakensis() { - const override = process.env.SWITCHIFY_MAKENSIS_EXE; - if (override) { - if (!fs.existsSync(override)) { - throw new Error(`SWITCHIFY_MAKENSIS_EXE does not exist: ${override}`); - } - return override; - } - - const candidates = [ - 'C:\\Program Files (x86)\\NSIS\\makensis.exe', - 'C:\\Program Files\\NSIS\\makensis.exe' - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) return candidate; - } - - const result = spawnSync('where.exe', ['makensis.exe'], { encoding: 'utf8' }); - if (result.status === 0) { - const [first] = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - if (first) return first; - } - - throw new Error('Unable to find makensis.exe. Install NSIS or set SWITCHIFY_MAKENSIS_EXE.'); -} - -function writeLatestYml() { - const sha512 = createSha512Base64(installerPath); - const size = fs.statSync(installerPath).size; - const releaseDate = new Date().toISOString(); - const latest = [ - `version: ${version}`, - 'files:', - ` - url: ${installerName}`, - ` sha512: ${sha512}`, - ` size: ${size}`, - ' isAdminRightsRequired: true', - `path: ${installerName}`, - `sha512: ${sha512}`, - 'isAdminRightsRequired: true', - `releaseDate: '${releaseDate}'`, - '' - ].join('\n'); - - fs.writeFileSync(path.join(distDir, 'latest.yml'), latest); -} - -function writeBuilderDebug() { - const content = [ - `version: ${version}`, - 'packager: dotnet-wpf-nsis', - `stageDir: ${toYamlPath(stageDir)}`, - `publishDir: ${toYamlPath(publishDir)}`, - '' - ].join('\n'); - fs.writeFileSync(path.join(distDir, 'builder-debug.yml'), content); -} - -function createSha512Base64(filePath) { - return crypto.createHash('sha512').update(fs.readFileSync(filePath)).digest('base64'); -} - -function toYamlPath(value) { - return JSON.stringify(value.replace(/\\/g, '/')); -} - -function readDotnetAppVersion(projectPath) { - const content = fs.readFileSync(projectPath, 'utf8'); - const match = content.match(/([^<]+)<\/Version>/); - if (!match || !match[1].trim()) { - throw new Error(`Could not read C# app from ${projectPath}.`); - } - - return match[1].trim(); -} diff --git a/scripts/verify-dotnet-package.cjs b/scripts/verify-dotnet-package.cjs deleted file mode 100644 index 07029e0..0000000 --- a/scripts/verify-dotnet-package.cjs +++ /dev/null @@ -1,89 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const { resolveProjectPath } = require('./win-signing-tools.cjs'); - -const distDir = resolveProjectPath('dist-dotnet'); -const stageDir = path.join(distDir, 'win-unpacked'); -const appExe = path.join(stageDir, 'Switchify PC.exe'); -const builderDebug = path.join(distDir, 'builder-debug.yml'); -const latestYml = path.join(distDir, 'latest.yml'); -const appProjectPath = resolveProjectPath('src', 'SwitchifyPc.App', 'SwitchifyPc.App.csproj'); -const appVersion = readDotnetAppVersion(appProjectPath); -const expectedInstaller = path.join(distDir, `Switchify-PC-Setup-${appVersion}-x64.exe`); -const installerScript = resolveProjectPath('installer', 'SwitchifyPc.DotNet.nsi'); - -assertExists(appExe, 'staged C# app executable'); -assertExists(builderDebug, 'C# builder debug metadata'); -assertInstallerScript(); -assertNoElectronRuntime(); - -if (fs.existsSync(expectedInstaller) || fs.existsSync(latestYml)) { - assertExists(expectedInstaller, 'C# NSIS installer'); - assertExists(latestYml, 'C# updater metadata'); - const latest = fs.readFileSync(latestYml, 'utf8'); - assertIncludes(latest, `version: ${appVersion}`, 'latest.yml version'); - assertIncludes(latest, `path: ${path.basename(expectedInstaller)}`, 'latest.yml installer path'); - assertRegex(latest, /^isAdminRightsRequired:\s*true\s*$/m, 'top-level admin metadata'); - assertRegex(latest, /^ isAdminRightsRequired:\s*true\s*$/m, 'file-entry admin metadata'); -} - -console.log('C# package verification passed.'); - -function assertExists(filePath, label) { - if (!fs.existsSync(filePath)) { - throw new Error(`${label} was not found: ${filePath}`); - } -} - -function assertNoElectronRuntime() { - const electronMarkers = [ - path.join(stageDir, 'resources', 'app.asar'), - path.join(stageDir, 'chrome_100_percent.pak'), - path.join(stageDir, 'ffmpeg.dll'), - path.join(stageDir, 'resources', 'electron.asar') - ]; - - for (const marker of electronMarkers) { - if (fs.existsSync(marker)) { - throw new Error(`C# package contains Electron runtime marker: ${marker}`); - } - } -} - -function assertInstallerScript() { - assertExists(installerScript, 'C# NSIS installer script'); - const content = fs.readFileSync(installerScript, 'utf8'); - assertIncludes(content, 'RequestExecutionLevel admin', 'installer elevation requirement'); - assertIncludes(content, 'InstallDir "$PROGRAMFILES64\\Switchify PC"', 'installer Program Files target'); - assertIncludes(content, '!define MUI_FINISHPAGE_RUN "$INSTDIR\\Switchify PC.exe"', 'installer run-after-finish behavior'); - assertIncludes(content, 'Call PromptForRunningApp', 'installer running-app prompt'); - assertIncludes(content, 'Call un.PromptForRunningApp', 'uninstaller running-app prompt'); - assertIncludes(content, 'find /I "Switchify PC.exe"', 'installer process detection'); - assertIncludes( - content, - 'Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Switchify PC', - 'installer uninstall registry key' - ); -} - -function assertIncludes(content, expected, label) { - if (!content.includes(expected)) { - throw new Error(`${label} is missing ${expected}.`); - } -} - -function assertRegex(content, regex, label) { - if (!regex.test(content)) { - throw new Error(`${label} is missing.`); - } -} - -function readDotnetAppVersion(projectPath) { - const content = fs.readFileSync(projectPath, 'utf8'); - const match = content.match(/([^<]+)<\/Version>/); - if (!match || !match[1].trim()) { - throw new Error(`Could not read C# app from ${projectPath}.`); - } - - return match[1].trim(); -} diff --git a/scripts/verify-latest-yml-admin-rights.cjs b/scripts/verify-latest-yml-admin-rights.cjs deleted file mode 100644 index be82efb..0000000 --- a/scripts/verify-latest-yml-admin-rights.cjs +++ /dev/null @@ -1,18 +0,0 @@ -const fs = require('node:fs'); -const { resolveProjectPath } = require('./win-signing-tools.cjs'); - -const latestPath = resolveProjectPath('dist', 'latest.yml'); -if (!fs.existsSync(latestPath)) { - throw new Error(`latest.yml was not found: ${latestPath}`); -} - -const content = fs.readFileSync(latestPath, 'utf8'); -if (!/^isAdminRightsRequired:\s*true\s*$/m.test(content)) { - throw new Error('latest.yml is missing top-level isAdminRightsRequired: true.'); -} - -if (!/^ isAdminRightsRequired:\s*true\s*$/m.test(content)) { - throw new Error('latest.yml is missing installer file entry isAdminRightsRequired: true.'); -} - -console.log('latest.yml admin-rights metadata: present'); diff --git a/scripts/win-signing-tools.cjs b/scripts/win-signing-tools.cjs deleted file mode 100644 index c44b998..0000000 --- a/scripts/win-signing-tools.cjs +++ /dev/null @@ -1,227 +0,0 @@ -const fs = require('node:fs'); -const path = require('node:path'); -const { spawnSync } = require('node:child_process'); - -function isWindows() { - return process.platform === 'win32'; -} - -function resolveProjectPath(...segments) { - return path.resolve(__dirname, '..', ...segments); -} - -function findWindowsSdkTool(toolName) { - const envOverride = toolName.toLowerCase() === 'mt.exe' ? process.env.SWITCHIFY_MT_EXE : process.env.SWITCHIFY_SIGNTOOL_EXE; - if (envOverride) { - if (!fs.existsSync(envOverride)) { - throw new Error(`${path.basename(envOverride)} was configured but does not exist: ${envOverride}`); - } - return envOverride; - } - - const sdkBinRoot = 'C:\\Program Files (x86)\\Windows Kits\\10\\bin'; - const sdkTool = findNewestSdkTool(sdkBinRoot, toolName); - if (sdkTool) return sdkTool; - - const whereResult = spawnSync('where.exe', [toolName], { encoding: 'utf8' }); - if (whereResult.status === 0) { - const [firstMatch] = whereResult.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - if (firstMatch) return firstMatch; - } - - throw new Error(`Unable to find ${toolName}. Install the Windows SDK or set ${toolName.toLowerCase() === 'mt.exe' ? 'SWITCHIFY_MT_EXE' : 'SWITCHIFY_SIGNTOOL_EXE'}.`); -} - -function runTool(command, args, options = {}) { - const displayCommand = [command, ...redactSensitiveArgs(args)].join(' '); - const result = spawnSync(command, args, { - stdio: options.stdio ?? 'inherit', - encoding: 'utf8', - ...options - }); - - if (result.status !== 0) { - throw new Error(`${displayCommand} failed with exit code ${result.status ?? 'unknown'}.`); - } - - return result; -} - -function findNewestSdkTool(sdkBinRoot, toolName) { - if (!fs.existsSync(sdkBinRoot)) return null; - - const versions = fs.readdirSync(sdkBinRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(compareVersionDescending); - - for (const version of versions) { - const candidate = path.join(sdkBinRoot, version, 'x64', toolName); - if (fs.existsSync(candidate)) return candidate; - } - - return null; -} - -function compareVersionDescending(left, right) { - const leftParts = left.split('.').map((part) => Number.parseInt(part, 10)); - const rightParts = right.split('.').map((part) => Number.parseInt(part, 10)); - const length = Math.max(leftParts.length, rightParts.length); - - for (let index = 0; index < length; index += 1) { - const delta = (rightParts[index] || 0) - (leftParts[index] || 0); - if (delta !== 0) return delta; - } - - return right.localeCompare(left); -} - -function redactSensitiveArgs(args) { - return args.map((arg, index) => { - const previous = args[index - 1]?.toLowerCase(); - if (previous === '/p' || previous === '-p') return ''; - return String(arg); - }); -} - -function createSigningArgs(filePath, { requireSigning }) { - if (!isWindows()) return null; - - if (process.env.SWITCHIFY_SIGNING_MODE === 'certum-simplysign') { - return createCertumSimplySignArgs(filePath, requireSigning); - } - - if (process.env.SWITCHIFY_SIGNING_MODE === 'azure') { - return createAzureSigningArgs(filePath, requireSigning); - } - - const devArgs = createDevPfxSigningArgs(filePath); - if (devArgs) return devArgs; - - const devStoreArgs = createDevStoreSigningArgs(filePath); - if (devStoreArgs) return devStoreArgs; - - const azureArgs = createAzureSigningArgs(filePath, false); - if (azureArgs) return azureArgs; - - if (process.env.SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE === '1' && !requireSigning) return null; - if (process.env.SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE === '1') { - console.warn('WARNING: Producing an unsigned uiAccess package because SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE=1.'); - return null; - } - - throw new Error( - 'Signing is required for uiAccess packaging. Set SWITCHIFY_DEV_CERT_PASSWORD with a dev PFX, run the dev certificate script, or configure Azure Artifact Signing.' - ); -} - -function createCertumSimplySignArgs(filePath, requireSigning) { - const thumbprint = normalizeThumbprint(process.env.SWITCHIFY_CERTUM_CERT_THUMBPRINT || ''); - - if (!thumbprint) { - if (requireSigning && process.env.SWITCHIFY_SIGNING_MODE === 'certum-simplysign') { - throw new Error('Certum SimplySign signing requires SWITCHIFY_CERTUM_CERT_THUMBPRINT.'); - } - return null; - } - - return [ - 'sign', - '/v', - '/fd', - 'SHA256', - '/sha1', - thumbprint, - '/tr', - process.env.SWITCHIFY_CERTUM_TIMESTAMP_URL || 'http://time.certum.pl', - '/td', - 'SHA256', - filePath - ]; -} - -function createDevPfxSigningArgs(filePath) { - const password = process.env.SWITCHIFY_DEV_CERT_PASSWORD; - const pfxPath = process.env.SWITCHIFY_DEV_CERT_PFX || resolveProjectPath('.certs', 'switchify-dev-code-signing.pfx'); - const thumbprint = resolveDevCertificateThumbprint(pfxPath); - - if (!password || !fs.existsSync(pfxPath)) return null; - - const args = ['sign', '/fd', 'SHA256', '/f', pfxPath, '/p', password]; - if (thumbprint) { - args.push('/sha1', thumbprint); - } - if (process.env.SWITCHIFY_SIGN_SKIP_TIMESTAMP !== '1') { - args.push('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256'); - } - args.push(filePath); - return args; -} - -function createDevStoreSigningArgs(filePath) { - const thumbprint = resolveDevCertificateThumbprint( - process.env.SWITCHIFY_DEV_CERT_PFX || resolveProjectPath('.certs', 'switchify-dev-code-signing.pfx') - ); - - if (!thumbprint) return null; - - const args = ['sign', '/fd', 'SHA256', '/sha1', thumbprint]; - if (process.env.SWITCHIFY_SIGN_SKIP_TIMESTAMP !== '1') { - args.push('/tr', 'http://timestamp.digicert.com', '/td', 'SHA256'); - } - args.push(filePath); - return args; -} - -function resolveDevCertificateThumbprint(pfxPath) { - if (process.env.SWITCHIFY_DEV_CERT_THUMBPRINT) { - return normalizeThumbprint(process.env.SWITCHIFY_DEV_CERT_THUMBPRINT); - } - - const thumbprintPath = path.join(path.dirname(pfxPath), 'switchify-dev-code-signing.thumbprint'); - if (!fs.existsSync(thumbprintPath)) return null; - - return normalizeThumbprint(fs.readFileSync(thumbprintPath, 'utf8')); -} - -function normalizeThumbprint(value) { - const thumbprint = value.replace(/[^a-fA-F0-9]/g, '').toUpperCase(); - return thumbprint.length > 0 ? thumbprint : null; -} - -function createAzureSigningArgs(filePath, requireSigning) { - const dlibPath = process.env.SWITCHIFY_AZURE_SIGNING_DLIB; - const metadataPath = process.env.SWITCHIFY_AZURE_SIGNING_METADATA; - - if (!dlibPath || !metadataPath) { - if (requireSigning && process.env.SWITCHIFY_SIGNING_MODE === 'azure') { - throw new Error('Azure Artifact Signing requires SWITCHIFY_AZURE_SIGNING_DLIB and SWITCHIFY_AZURE_SIGNING_METADATA.'); - } - return null; - } - - return [ - 'sign', - '/v', - '/debug', - '/fd', - 'SHA256', - '/tr', - 'http://timestamp.acs.microsoft.com', - '/td', - 'SHA256', - '/dlib', - dlibPath, - '/dmdf', - metadataPath, - filePath - ]; -} - -module.exports = { - createSigningArgs, - findWindowsSdkTool, - isWindows, - resolveProjectPath, - runTool -};