diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index e68ce4f4..2177e9b7 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -243,8 +243,53 @@ jobs: run: pnpm install --frozen-lockfile working-directory: apps/x + - name: Setup Azure Trusted Signing + shell: pwsh + run: | + # Stage everything under a path WITHOUT spaces: @electron/windows-sign + # splits signWithParams on spaces, so the /dlib and /dmdf paths must be + # space-free. + $dir = "C:\azsign" + New-Item -ItemType Directory -Force -Path $dir | Out-Null + + # The signtool plugin that delegates signing to Azure Trusted Signing + # (renamed "Artifact Signing" — the old Microsoft.Trusted.Signing.Client + # package is gone from NuGet, but the dlib inside kept its filename). + nuget install Microsoft.ArtifactSigning.Client -x -OutputDirectory "$dir\nuget" + $dlib = Get-ChildItem "$dir\nuget" -Recurse -Filter "Azure.CodeSigning.Dlib.dll" | + Where-Object { $_.FullName -match '\\x64\\' } | + Select-Object -First 1 -ExpandProperty FullName + if (-not $dlib) { throw "x64 Azure.CodeSigning.Dlib.dll not found under $dir\nuget" } + echo "Using dlib: $dlib" + + # Tells the dlib which account/profile to sign with. + @{ + Endpoint = "${{ secrets.AZURE_ENDPOINT }}" + CodeSigningAccountName = "${{ secrets.AZURE_CODE_SIGNING_NAME }}" + CertificateProfileName = "${{ secrets.AZURE_CERT_PROFILE_NAME }}" + } | ConvertTo-Json | Out-File "$dir\metadata.json" -Encoding utf8 + + # Newest Windows SDK signtool (the one vendored by @electron/windows-sign + # is too old to load the dlib). Copied into the space-free dir. + $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | + Where-Object { $_.Directory.Parent.Name -match '^\d+(\.\d+)+$' } | + Sort-Object { [version]$_.Directory.Parent.Name } | Select-Object -Last 1 + if (-not $signtool) { throw "signtool.exe not found in Windows SDK" } + Copy-Item $signtool.FullName "$dir\signtool.exe" + # NOTE: don't run signtool bare to "print usage" — it exits 1 and the + # Actions pwsh wrapper turns the last native exit code into a step + # failure. Read the version from file metadata instead. + echo "Using signtool $((Get-Item "$dir\signtool.exe").VersionInfo.ProductVersion) from $($signtool.FullName)" + + echo "AZURE_CODE_SIGNING_DLIB=$dlib" >> $env:GITHUB_ENV + echo "AZURE_METADATA_JSON=$dir\metadata.json" >> $env:GITHUB_ENV + echo "SIGNTOOL_PATH=$dir\signtool.exe" >> $env:GITHUB_ENV + - name: Build electron app env: + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index cca7fdc4..f4c0357d 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -59,10 +59,20 @@ const ptySrc = fs.realpathSync(path.join(here, 'node_modules', 'node-pty')); const ptyDest = path.join(here, '.package', 'node_modules', 'node-pty'); fs.rmSync(ptyDest, { recursive: true, force: true }); fs.mkdirSync(ptyDest, { recursive: true }); -for (const item of ['package.json', 'lib', 'prebuilds']) { +for (const item of ['package.json', 'lib']) { fs.cpSync(path.join(ptySrc, item), path.join(ptyDest, item), { recursive: true, dereference: true }); } +// Stage only the CURRENT platform's prebuilds. Each OS packages natively in CI, +// so other platforms' binaries are dead weight — and worse: Windows code signing +// walks every .node file in the app and signtool hard-fails on the Mach-O darwin +// pty.node ("file format cannot be signed"). +const prebuildsSrc = path.join(ptySrc, 'prebuilds'); const prebuildsDir = path.join(ptyDest, 'prebuilds'); +fs.mkdirSync(prebuildsDir, { recursive: true }); +for (const dir of fs.readdirSync(prebuildsSrc)) { + if (!dir.startsWith(`${process.platform}-`)) continue; + fs.cpSync(path.join(prebuildsSrc, dir), path.join(prebuildsDir, dir), { recursive: true, dereference: true }); +} for (const dir of fs.readdirSync(prebuildsDir)) { const helper = path.join(prebuildsDir, dir, 'spawn-helper'); if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755); diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 581206a8..cc6c4dd6 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -12,6 +12,28 @@ const pkg = require('./package.json'); const SKIP_PACMAN = process.env.ROWBOAT_SKIP_PACMAN === '1'; const SKIP_CODE_SIGNING = process.env.ROWBOAT_SKIP_CODE_SIGNING === '1'; +// Windows code signing via Azure Trusted Signing — CI-only. The GitHub workflow +// downloads the Azure dlib, writes metadata.json, and exports these env vars; +// when they're absent (local builds, mac/linux jobs) Windows signing is skipped. +// signtool loads Azure.CodeSigning.Dlib.dll (/dlib), which reads metadata.json +// (/dmdf) for the endpoint/account/profile and authenticates via +// AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET. +// NOTE: @electron/windows-sign splits signWithParams on spaces, so the dlib and +// metadata paths must not contain spaces (the workflow stages them in C:\azsign). +const WINDOWS_SIGN = + !SKIP_CODE_SIGNING && + process.env.AZURE_CODE_SIGNING_DLIB && + process.env.AZURE_METADATA_JSON + ? { + // The signtool vendored by @electron/windows-sign is too old for the + // Azure dlib; the workflow points this at the Windows SDK's signtool. + ...(process.env.SIGNTOOL_PATH ? { signToolPath: process.env.SIGNTOOL_PATH } : {}), + signWithParams: `/v /debug /dlib ${process.env.AZURE_CODE_SIGNING_DLIB} /dmdf ${process.env.AZURE_METADATA_JSON}`, + timestampServer: 'http://timestamp.acs.microsoft.com', + hashes: ['sha256'], + } + : undefined; + // Stage the ACP coding-adapters (@agentclientprotocol/*-acp) and their full // production dependency closure into the packaged app. // @@ -202,6 +224,9 @@ module.exports = { NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)', NSCameraUsageDescription: 'Rowboat uses your camera in video chat mode so the assistant can see you and give feedback (e.g. pitch practice).', }, + // Signs the packaged app's executables (rowboat.exe etc.); the Squirrel + // maker below separately signs the installer it produces. + ...(WINDOWS_SIGN ? { windowsSign: WINDOWS_SIGN } : {}), ...(SKIP_CODE_SIGNING ? {} : { osxSign: { batchCodesignCalls: true, @@ -260,6 +285,9 @@ module.exports = { // GitHub release page next to setup.exe and users grab the // wrong one (it neither launches the app nor auto-updates). noMsi: true, + // Sign the Squirrel installer (setup.exe) and the binaries it + // repackages. No-op unless the CI signing env vars are set. + ...(WINDOWS_SIGN ? { windowsSign: WINDOWS_SIGN } : {}), }) }, {