diff --git a/.github/workflows/studio.yml b/.github/workflows/studio.yml index 71f3a28ef6..ec59a68365 100644 --- a/.github/workflows/studio.yml +++ b/.github/workflows/studio.yml @@ -121,6 +121,45 @@ jobs: CGO_ENABLED=0 GOOS=$os GOARCH=amd64 go build -o /dev/null ./cmd/reasonix-studio-host done + # The ARM64 leg queues on a scarcer runner pool, so a pull request runs it + # only when it touches what the leg verifies; a push to studio always does. + scope: + name: package matrix + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.pick.outputs.matrix }} + arm64: ${{ steps.pick.outputs.arm64 }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - id: pick + env: + EVENT: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + arm64=true + if [ "$EVENT" = pull_request ]; then + arm64=false + if git diff --name-only "$BASE_SHA" HEAD | + grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|\.github/workflows/studio\.yml$)'; then + arm64=true + fi + fi + legs='[ + {"os":"macos-latest","arch":"arm64","platform":"darwin"}, + {"os":"ubuntu-22.04","arch":"amd64","platform":"linux"}, + {"os":"windows-latest","arch":"amd64","platform":"windows","ebarch":"x64"} + ]' + if [ "$arm64" = true ]; then + legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")" + else + legs="$(jq -c . <<<"$legs")" + fi + echo "arm64=$arm64" >> "$GITHUB_OUTPUT" + echo "matrix={\"include\":$legs}" >> "$GITHUB_OUTPUT" + # The packaging contract, on real artifacts rather than on the config that # describes them. Unsigned on purpose: signing and notarization are a # distribution concern and belong to the release workflow, and treating their @@ -128,20 +167,12 @@ jobs: # cross-compile while only Windows had ever produced a package. package: name: package (${{ matrix.os }}) + needs: scope runs-on: ${{ matrix.os }} + timeout-minutes: 45 strategy: fail-fast: false - matrix: - include: - - os: macos-latest - arch: arm64 - platform: darwin - - os: ubuntu-22.04 - arch: amd64 - platform: linux - - os: windows-latest - arch: amd64 - platform: windows + matrix: ${{ fromJSON(needs.scope.outputs.matrix) }} defaults: run: shell: bash @@ -174,8 +205,13 @@ jobs: working-directory: desktop/frontend-next run: pnpm install --frozen-lockfile && pnpm build + # The Windows legs name their architecture instead of taking the + # toolchain's, so a runner whose Go or Node is the other architecture + # fails here rather than shipping a mislabelled package. - name: Build the kernel the shell speaks to working-directory: desktop/electron + env: + REASONIX_GOARCH: ${{ matrix.platform == 'windows' && matrix.arch || '' }} run: pnpm install --frozen-lockfile && pnpm run build:host # The .deb's absolute-path payload. A no-op off Linux, so it is not @@ -191,7 +227,8 @@ jobs: working-directory: desktop/electron env: CSC_IDENTITY_AUTO_DISCOVERY: "false" - run: pnpm run build:dist + ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.platform == 'windows' && matrix.arch == 'arm64' && 'BCJ' || '' }} + run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }} # The release vocabulary is GOARCH. electron-builder's own is not, and the # rename that reconciles them runs here rather than in the manifest. @@ -207,6 +244,78 @@ jobs: exit 1 fi + # An emulated x64 program runs on ARM64 Windows, so a package that merely + # starts proves nothing about its architecture: every PE image in the + # bundle has to be built for the one the package is named for. + - name: Every PE image is built for the package's architecture + if: matrix.platform == 'windows' + run: | + dir=$(find dist -maxdepth 1 -type d -name 'win*-unpacked' | head -1) + test -n "$dir" + node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}" + + # Run on the machine the package is for: the shell's own binary as a + # node, the kernel's identity flag, then the installer end to end, per-user + # and silent, into a folder of its own. + - name: The package starts, installs and uninstalls + if: matrix.platform == 'windows' + shell: pwsh + env: + ARCH: ${{ matrix.arch }} + REASONIX_HOME: ${{ runner.temp }}/reasonix-home + run: | + $ErrorActionPreference = 'Stop' + $unpacked = Get-ChildItem dist -Directory -Filter 'win*-unpacked' | Select-Object -First 1 + # A windowed executable returns to the shell at once with no stdout, so + # it is started and waited for with its output redirected to a file. + $env:ELECTRON_RUN_AS_NODE = '1' + $out = Join-Path $env:RUNNER_TEMP 'shell-arch.txt' + $node = Start-Process -FilePath "$($unpacked.FullName)/Reasonix Studio.exe" -ArgumentList '-p', 'process.arch' -RedirectStandardOutput $out -Wait -PassThru + Remove-Item Env:ELECTRON_RUN_AS_NODE + $arch = (Get-Content -Raw $out).Trim() + $want = if ($env:ARCH -eq 'amd64') { 'x64' } else { $env:ARCH } + if ($node.ExitCode -ne 0 -or $arch -ne $want) { throw "the shell reports arch '$arch' (exit $($node.ExitCode)), want $want" } + & "$($unpacked.FullName)/resources/bin/reasonix-studio-host.exe" -instance-id + if ($LASTEXITCODE -ne 0) { throw "the kernel exited $LASTEXITCODE" } + + $installer = (Get-ChildItem dist -Filter "ReasonixStudio-windows-$env:ARCH-installer.exe").FullName + $target = Join-Path $env:RUNNER_TEMP 'rx-install' + $run = Start-Process -FilePath $installer -ArgumentList '/S', "/D=$target" -Wait -PassThru + if ($run.ExitCode -ne 0) { throw "the installer exited $($run.ExitCode)" } + $clock = [Diagnostics.Stopwatch]::StartNew() + $name = [IO.Path]::GetFileNameWithoutExtension($installer) + while (-not (Test-Path "$target/Reasonix Studio.exe") -and $clock.Elapsed.TotalSeconds -lt 120) { Start-Sleep -Seconds 2 } + Write-Host "application appeared after $([int]$clock.Elapsed.TotalSeconds)s past the installer's exit; installer processes still alive: $(@(Get-Process -Name $name -ErrorAction SilentlyContinue).Count)" + if (-not (Test-Path "$target/Reasonix Studio.exe")) { + Write-Host "target holds: $((Get-ChildItem -Force $target -ErrorAction SilentlyContinue | ForEach-Object Name) -join ', ')" + Write-Host "files under the target: $((Get-ChildItem -Recurse -File $target -ErrorAction SilentlyContinue).Count); resources holds: $((Get-ChildItem -Force "$target/resources" -ErrorAction SilentlyContinue | ForEach-Object Name) -join ', ')" + Write-Host "Defender detections: $((Get-MpThreatDetection -ErrorAction SilentlyContinue | ForEach-Object { $_.Resources }) -join '; ')" + Write-Host "real-time protection: $((Get-MpComputerStatus -ErrorAction SilentlyContinue).RealTimeProtectionEnabled)" + Write-Host "PE files present: $((Get-ChildItem -Recurse -File $target -Include *.exe,*.dll,*.node | ForEach-Object { $_.FullName.Substring($target.Length) }) -join ', ')" + Write-Host "Defender events: $((Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Windows Defender/Operational'; Id=1116,1117,1121,1122} -MaxEvents 5 -ErrorAction SilentlyContinue | ForEach-Object { $_.Message -replace '\s+',' ' }) -join ' || ')" + Set-MpPreference -DisableRealtimeMonitoring $true + $again = Join-Path $env:RUNNER_TEMP 'rx-install-nodefender' + Start-Process -FilePath $installer -ArgumentList '/S', "/D=$again" -Wait | Out-Null + Start-Sleep -Seconds 20 + Write-Host "without real-time scanning the application is present: $(Test-Path "$again/Reasonix Studio.exe")" + throw 'the installer reported success and installed nothing' + } + node desktop/electron/packaging/pe.js $target $env:ARCH + if ($LASTEXITCODE -ne 0) { throw 'the installed tree is not the package architecture' } + + $run = Start-Process -FilePath "$target/Uninstall Reasonix Studio.exe" -ArgumentList '/S', "_?=$target" -Wait -PassThru + if ($run.ExitCode -ne 0) { throw "the uninstaller exited $($run.ExitCode)" } + if (Test-Path "$target/Reasonix Studio.exe") { throw 'the uninstaller left the application behind' } + + - name: Keep the arm64 installer for the x64 refusal check + if: matrix.platform == 'windows' && matrix.arch == 'arm64' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: studio-arm64-installer-ci + path: dist/ReasonixStudio-windows-arm64-installer.exe + if-no-files-found: error + retention-days: 1 + # The update channel is the signed manifest. A second answer to which # version is current is not a convenience. The .blockmap is the same # channel's differential index and carries the release prefix, so it also @@ -301,3 +410,27 @@ jobs: echo "::error::the manifest has no entry for ${{ matrix.platform }}-${{ matrix.arch }}" exit 1 fi + + # The arm64 installer carries one application and no other. Run anywhere but + # ARM64 Windows it has to say so and stop, instead of exiting 0 with nothing + # installed. + arm64-installer-refuses-x64: + name: arm64 installer refuses an x64 machine + needs: [scope, package] + if: needs.scope.outputs.arm64 == 'true' + runs-on: windows-latest + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: studio-arm64-installer-ci + path: installer + + - name: Run it silently + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $target = Join-Path $env:RUNNER_TEMP 'rx-refused' + $run = Start-Process -FilePath installer/ReasonixStudio-windows-arm64-installer.exe -ArgumentList '/S', "/D=$target" -Wait -PassThru + Write-Host "exit code $($run.ExitCode)" + if ($run.ExitCode -eq 0) { throw 'the arm64 installer exited 0 on an x64 machine' } + if (@(Get-ChildItem -Recurse -Force $target -ErrorAction SilentlyContinue).Count -ne 0) { throw 'the arm64 installer wrote into the target on an x64 machine' } diff --git a/desktop/electron/assets/installer.nsh b/desktop/electron/assets/installer.nsh index 57f76531d5..3e91fef848 100644 --- a/desktop/electron/assets/installer.nsh +++ b/desktop/electron/assets/installer.nsh @@ -99,6 +99,17 @@ # full of someone's work gets named in the first place. Refuse with an exit # code, because that is all a script driving this can read. !macro customInit + # The arm64 installer carries only an arm64 application: on any other + # machine electron-builder's extraction picks no archive and installs nothing + # while reporting success. + !ifdef APP_ARM64 + ${IfNot} ${IsNativeARM64} + DetailPrint "This installer is for Windows on ARM64 and cannot run on this machine" + MessageBox MB_ICONSTOP "This Reasonix Studio installer is built for Windows on ARM64.$\r$\n$\r$\nDownload the x64 installer for this machine." /SD IDOK + SetErrorLevel 3 + Abort + ${EndIf} + !endif Push $R9 !insertmacro instDirTakesNothingElse $R9 ${If} $R9 == 0 diff --git a/desktop/electron/packaging/build-host.js b/desktop/electron/packaging/build-host.js index dc90e10b25..5c70a67291 100644 --- a/desktop/electron/packaging/build-host.js +++ b/desktop/electron/packaging/build-host.js @@ -45,22 +45,35 @@ function go(args, env) { // statically so the helper needs no redistributable on the person's machine. const VS_INSTALLER = path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Microsoft Visual Studio", "Installer"); -function msvcVars() { +// The helper is built for the architecture the kernel is, with that +// architecture's own tools: the x64 toolset on an x64 machine, the ARM64 one on +// an ARM64 machine (vcvarsarm64 is the native environment there). +const WINDOWS_TOOLCHAIN = { + amd64: { component: "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", vars: "vcvars64.bat", label: "x64" }, + arm64: { component: "Microsoft.VisualStudio.Component.VC.Tools.ARM64", vars: "vcvarsarm64.bat", label: "ARM64" }, +}; + +function msvcVars(toolchain) { const vswhere = path.join(VS_INSTALLER, "vswhere.exe"); if (!fs.existsSync(vswhere)) return ""; - const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"], { encoding: "utf8" }); + const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", toolchain.component, "-property", "installationPath"], { encoding: "utf8" }); const root = (found.stdout || "").trim().split(/\r?\n/)[0]; - const vars = root ? path.join(root, "VC", "Auxiliary", "Build", "vcvars64.bat") : ""; + const vars = root ? path.join(root, "VC", "Auxiliary", "Build", toolchain.vars) : ""; return vars && fs.existsSync(vars) ? vars : ""; } -function buildWindowsHelper() { - const vars = msvcVars(); +function buildWindowsHelper(goarch) { + const toolchain = WINDOWS_TOOLCHAIN[goarch]; + if (!toolchain) { + console.error(`no MSVC toolchain is declared for windows/${goarch}`); + process.exit(1); + } + const vars = msvcVars(toolchain); if (!vars) { // A release without the helper would ship computer use switched off with // nothing saying why, so CI refuses; a dev build only loses the feature. if (process.env.CI) { - console.error("MSVC with the x64 C++ tools is required to build reasonix-computer-helper.exe"); + console.error(`MSVC with the ${toolchain.label} C++ tools is required to build reasonix-computer-helper.exe`); process.exit(1); } console.warn("MSVC not found: building without reasonix-computer-helper.exe, so computer use is unavailable in this build"); @@ -91,9 +104,12 @@ function buildWindowsHelper() { if (process.platform !== "darwin") { // go build names the binary after the package and adds .exe where it belongs. // Without cgo, because with it the Linux kernel inherits the runner's glibc. - go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], { CGO_ENABLED: "0" }); + // REASONIX_GOARCH names the architecture to build for; unset, Go's own is + // used, which is the runner's. + const goarch = (process.env.REASONIX_GOARCH || "").trim(); + go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], goarch ? { CGO_ENABLED: "0", GOARCH: goarch } : { CGO_ENABLED: "0" }); console.log("built reasonix-studio-host"); - if (process.platform === "win32") buildWindowsHelper(); + if (process.platform === "win32") buildWindowsHelper(goarch || (process.arch === "arm64" ? "arm64" : "amd64")); process.exit(0); } diff --git a/desktop/electron/packaging/pe.js b/desktop/electron/packaging/pe.js new file mode 100644 index 0000000000..6c82d11408 --- /dev/null +++ b/desktop/electron/packaging/pe.js @@ -0,0 +1,77 @@ +"use strict"; +const fs = require("node:fs"); +const path = require("node:path"); + +const MACHINE = { amd64: 0x8664, arm64: 0xaa64 }; +// Written by the NSIS installer rather than built by us, and 32-bit on every +// architecture; the release pins it by content (windows-signing-lib.ps1). +const INSTALLER_OWNED = { "resources/elevate.exe": 0x14c, "Uninstall Reasonix Studio.exe": 0x14c }; +const EXTENSIONS = new Set([".exe", ".dll", ".node"]); + +// The machine field of a PE image's COFF header. An emulated x64 binary and a +// native one are both valid programs on ARM64 Windows, so the architecture a +// package claims has to be read from the files themselves. +function peMachine(bytes) { + if (bytes.length < 0x40 || bytes.readUInt16LE(0) !== 0x5a4d) throw new Error("not a PE image"); + const header = bytes.readUInt32LE(0x3c); + if (header + 6 > bytes.length || bytes.readUInt32LE(header) !== 0x00004550) throw new Error("no PE signature"); + return bytes.readUInt16LE(header + 4); +} + +function isPeCandidate(file) { + if (EXTENSIONS.has(path.extname(file).toLowerCase())) return true; + const fd = fs.openSync(file, "r"); + try { + const head = Buffer.alloc(2); + return fs.readSync(fd, head, 0, 2, 0) === 2 && head.readUInt16LE(0) === 0x5a4d; + } finally { + fs.closeSync(fd); + } +} + +function walk(dir, into) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full, into); + else if (entry.isFile() && isPeCandidate(full)) into.push(full); + } + return into; +} + +// Every PE image under root with the machine it was built for. +function machines(root) { + return walk(root, []).sort().map((file) => { + const name = path.relative(root, file).split(path.sep).join("/"); + try { + return { file: name, machine: peMachine(fs.readFileSync(file)) }; + } catch (err) { + return { file: name, machine: null, error: err.message }; + } + }); +} + +// The images under root that are not built for arch; none means the tree is +// uniformly that architecture. An empty tree is refused, not passed. +function foreign(root, arch) { + const want = MACHINE[arch]; + if (want === undefined) throw new Error(`unknown architecture ${arch}`); + const found = machines(root); + if (found.length === 0) throw new Error(`no PE images under ${root}`); + return found.filter((f) => f.machine !== (INSTALLER_OWNED[f.file] ?? want)); +} + +module.exports = { peMachine, machines, foreign, MACHINE }; + +if (require.main === module) { + const [root, arch] = process.argv.slice(2); + if (!root || !arch) { + console.error("usage: node packaging/pe.js