| 1 | #!/usr/bin/env bash |
| 2 | # Build and package the Electron desktop app for one platform. Electron's |
| 3 | # Chromium shell cannot cross-compile the native targets from one host, so this |
| 4 | # runs on a native runner per target (see .github/workflows/release-desktop.yml) |
| 5 | # and is invoked once per matrix entry. |
| 6 | # |
| 7 | # Output lands in <repo>/dist/ with stable, platform-keyed names that |
| 8 | # desktop/cmd/sign's `manifest` subcommand maps back to update.PlatformKey: |
| 9 | # macOS: Reasonix-darwin-<arm64|amd64>.zip (ditto archive; updater channel) |
| 10 | # Reasonix-darwin-<arch>.dmg (drag-to-install; human download) |
| 11 | # Windows: Reasonix-windows-<arch>-installer.exe (NSIS per-user installer; updater channel) |
| 12 | # Reasonix-windows-<arch>.zip (portable human download) |
| 13 | # Linux: Reasonix-linux-<arch>.tar.gz (desktop + guard + CLI + app/ tree; portable updater) |
| 14 | # Reasonix-linux-<arch>.deb (Debian/Ubuntu package; native updater) |
| 15 | # |
| 16 | # Usage: scripts/desktop-build.sh <os/arch> <version> [channel] |
| 17 | # e.g. scripts/desktop-build.sh darwin/arm64 v1.1.0 |
| 18 | # scripts/desktop-build.sh darwin/arm64 v1.5.0-preview.42 preview |
| 19 | # |
| 20 | # Requirements: |
| 21 | # - Go toolchain matching desktop/go.mod (>= 1.25; the `toolchain` directive |
| 22 | # auto-downloads when GOTOOLCHAIN=auto) |
| 23 | # - Node >= 24 and pnpm 10 (the same major versions used by CI and releases) |
| 24 | # - A pnpm-installed desktop workspace (this script runs |
| 25 | # `pnpm --dir desktop install --frozen-lockfile` when node_modules is absent) |
| 26 | set -euo pipefail |
| 27 | |
| 28 | build_started_seconds=$SECONDS |
| 29 | |
| 30 | PLATFORM="${1:?usage: desktop-build.sh <os/arch> <version> [channel]}" |
| 31 | VERSION="${2:?usage: desktop-build.sh <os/arch> <version> [channel]}" |
| 32 | CHANNEL="${3:-stable}" |
| 33 | |
| 34 | os="${PLATFORM%/*}" |
| 35 | arch="${PLATFORM#*/}" |
| 36 | |
| 37 | ROOT="$(cd "$(dirname "$0")/.." && pwd)" |
| 38 | APPNAME="Reasonix" # Electron productName -> Reasonix.app / Reasonix.exe |
| 39 | BINNAME="reasonix-desktop" # Go desktop service (and the active version entry the launcher starts) |
| 40 | CLINAME="reasonix" # bundled CLI sidecar used for remote serve upload |
| 41 | WINDOWS_CLINAME="reasonix-cli" # Windows cannot store Reasonix.exe and reasonix.exe separately |
| 42 | WINDOWS_CLI_ENTRY="reasonix-cli-launcher.exe" |
| 43 | GUARDNAME="reasonix-guard" |
| 44 | LAUNCHERNAME="reasonix-launcher" |
| 45 | windows_resource_tool_dir="" |
| 46 | windows_host_include="" |
| 47 | |
| 48 | # desktop/ is a nested Go module, so the Go toolchain cannot discover the |
| 49 | # repository VCS revision for the service binary. Link the same source identity |
| 50 | # into both Desktop and its CLI sidecar. |
| 51 | SOURCE_REVISION="$(git -C "$ROOT" rev-parse --verify HEAD)" |
| 52 | SOURCE_SHA="$SOURCE_REVISION" |
| 53 | if ! git -C "$ROOT" diff-index --quiet HEAD --; then |
| 54 | SOURCE_REVISION="$SOURCE_REVISION+dirty" |
| 55 | fi |
| 56 | # Short commit + real UTC build clock for CLI `version --verbose/--json`. |
| 57 | GIT_COMMIT="$(git -C "$ROOT" rev-parse --short=12 HEAD 2>/dev/null || echo unknown)" |
| 58 | BUILD_TIME_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)" |
| 59 | product_docs_ldflags="-X reasonix/internal/productdocs.linkedVersion=$VERSION -X reasonix/internal/productdocs.linkedRevision=$SOURCE_REVISION" |
| 60 | cli_identity_ldflags="-X main.version=$VERSION -X main.gitCommit=$GIT_COMMIT -X main.buildTimeUTC=$BUILD_TIME_UTC $product_docs_ldflags" |
| 61 | |
| 62 | cleanup() { |
| 63 | if [ -n "$windows_resource_tool_dir" ]; then |
| 64 | rm -rf "$windows_resource_tool_dir" |
| 65 | fi |
| 66 | if [ -n "$windows_host_include" ]; then |
| 67 | rm -f "$windows_host_include" |
| 68 | fi |
| 69 | } |
| 70 | trap cleanup EXIT |
| 71 | |
| 72 | cd "$ROOT/desktop" |
| 73 | |
| 74 | # build_guard produces the one-shot legacy migrator still named reasonix-guard |
| 75 | # in compatibility payloads for 1.18–1.19.1 updaters. Source is intentionally |
| 76 | # separate from the removed Guard recovery product. |
| 77 | build_guard() { |
| 78 | echo "==> go build Reasonix legacy migrator (compat name reasonix-guard)" |
| 79 | mkdir -p "$(dirname "$guard_out")" |
| 80 | if [ "$arch" = universal ]; then |
| 81 | guard_tmp=$(mktemp -d) |
| 82 | (cd "$ROOT" && GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$VERSION" -o "$guard_tmp/amd64" ./cmd/reasonix-legacy-migrator) |
| 83 | (cd "$ROOT" && GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$VERSION" -o "$guard_tmp/arm64" ./cmd/reasonix-legacy-migrator) |
| 84 | lipo -create "$guard_tmp/amd64" "$guard_tmp/arm64" -output "$guard_out" |
| 85 | rm -rf "$guard_tmp" |
| 86 | else |
| 87 | (cd "$ROOT" && GOOS="$os" GOARCH="$arch" CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$VERSION" -o "$guard_out" ./cmd/reasonix-legacy-migrator) |
| 88 | fi |
| 89 | } |
| 90 | |
| 91 | build_cli() { |
| 92 | echo "==> go build Reasonix CLI sidecar" |
| 93 | mkdir -p "$(dirname "$cli_out")" |
| 94 | if [ "$arch" = universal ]; then |
| 95 | cli_tmp=$(mktemp -d) |
| 96 | (cd "$ROOT" && GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w $cli_identity_ldflags" -o "$cli_tmp/amd64" ./cmd/reasonix) |
| 97 | (cd "$ROOT" && GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w $cli_identity_ldflags" -o "$cli_tmp/arm64" ./cmd/reasonix) |
| 98 | lipo -create "$cli_tmp/amd64" "$cli_tmp/arm64" -output "$cli_out" |
| 99 | rm -rf "$cli_tmp" |
| 100 | else |
| 101 | (cd "$ROOT" && GOOS="$os" GOARCH="$arch" CGO_ENABLED=0 go build -trimpath -ldflags="-s -w $cli_identity_ldflags" -o "$cli_out" ./cmd/reasonix) |
| 102 | fi |
| 103 | } |
| 104 | |
| 105 | stamp_windows_executable() { |
| 106 | local target="$1" |
| 107 | local description="$2" |
| 108 | local internal_name="$3" |
| 109 | local original_filename="$4" |
| 110 | "$windows_resource_tool" \ |
| 111 | -exe "$target" \ |
| 112 | -icon "$ROOT/desktop/build/windows/icon.ico" \ |
| 113 | -version "$numver" \ |
| 114 | -description "$description" \ |
| 115 | -internal-name "$internal_name" \ |
| 116 | -original-filename "$original_filename" |
| 117 | } |
| 118 | |
| 119 | # Stamp the Windows version resource from the tag. goversioninfo demands a |
| 120 | # strictly numeric X.X.X, so strip the leading "v" AND any prerelease suffix (a |
| 121 | # `-rc1` tag would otherwise abort the resource stamping). The full tag still |
| 122 | # rides in ldflags for the in-app version. |
| 123 | numver="${VERSION#v}"; numver="${numver%%-*}" |
| 124 | |
| 125 | # Regenerate the desktop host contract and fail on drift: the packaged shell |
| 126 | # embeds desktopContract.json, so a stale frontend/src/generated would ship a |
| 127 | # shell/service protocol mismatch. CI's desktop-prepare job runs the same check. |
| 128 | echo "==> desktop host contract drift check" |
| 129 | contract_snapshot=$(mktemp -d) |
| 130 | cp -R frontend/src/generated "$contract_snapshot/generated" |
| 131 | go run . -emit-contract frontend/src/generated |
| 132 | if ! diff -qr "$contract_snapshot/generated" frontend/src/generated; then |
| 133 | rm -rf "$contract_snapshot" |
| 134 | echo "desktop contract is stale - review the regenerated frontend/src/generated files" >&2 |
| 135 | exit 1 |
| 136 | fi |
| 137 | rm -rf "$contract_snapshot" |
| 138 | |
| 139 | # The packaging script drives the frontend (build:electron) and shell builds |
| 140 | # through pnpm; make sure the workspace dependencies (Electron, the packager) |
| 141 | # are installed first. A warm store makes this a no-op. |
| 142 | if [ ! -d "$ROOT/desktop/node_modules/@electron/packager" ] || [ ! -d "$ROOT/desktop/electron/node_modules/electron" ]; then |
| 143 | echo "==> pnpm install desktop workspace" |
| 144 | pnpm --dir "$ROOT/desktop" install --frozen-lockfile |
| 145 | fi |
| 146 | |
| 147 | # Service ldflags carry version + channel; the shell reads the same identity |
| 148 | # from resources/build.json written by package.mjs. macOS Developer ID builds |
| 149 | # enable the in-app self-update path. |
| 150 | service_ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $product_docs_ldflags" |
| 151 | [ "$os" = "darwin" ] && [ "${HAS_APPLE_CERT:-}" = "true" ] && service_ldflags="$service_ldflags -X main.macSelfUpdate=true" |
| 152 | # The Windows service must be a GUI-subsystem image: the shell spawns it with |
| 153 | # --host-rpc over inherited pipes, so it never needs a console, and a CONSOLE |
| 154 | # image makes Windows allocate a conhost window on every launch (the startup |
| 155 | # "flash of black box" in #10148). -H is a Windows-only linker flag. |
| 156 | [ "$os" = "windows" ] && service_ldflags="$service_ldflags -H windowsgui" |
| 157 | |
| 158 | # build_service compiles the Go desktop service (reasonix-desktop). It stays |
| 159 | # the active version entry the thin launcher starts: without --host-rpc it |
| 160 | # bootstraps the Electron shell from app/ and exits; the shell then spawns it |
| 161 | # with --host-rpc as the service (see docs/DESKTOP_SHELL_MIGRATION.md phase E). |
| 162 | build_service() { |
| 163 | echo "==> go build Reasonix desktop service" |
| 164 | mkdir -p "$(dirname "$service_out")" |
| 165 | if [ "$arch" = universal ]; then |
| 166 | service_tmp=$(mktemp -d) |
| 167 | GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags="-s -w $service_ldflags" -o "$service_tmp/amd64" . |
| 168 | GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w $service_ldflags" -o "$service_tmp/arm64" . |
| 169 | lipo -create "$service_tmp/amd64" "$service_tmp/arm64" -output "$service_out" |
| 170 | rm -rf "$service_tmp" |
| 171 | else |
| 172 | GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w $service_ldflags" -o "$service_out" . |
| 173 | fi |
| 174 | } |
| 175 | |
| 176 | # package_shell runs @electron/packager via the packaging script and leaves the |
| 177 | # bundle at desktop/build/electron/<os>-<arch>/ (Reasonix.app on macOS, app/ |
| 178 | # elsewhere). It also builds the frontend (build:electron) with the channel |
| 179 | # threaded through REASONIX_CHANNEL. |
| 180 | package_shell() { |
| 181 | echo "==> package Electron shell ($PLATFORM)" |
| 182 | REASONIX_COMMIT="$SOURCE_SHA" REASONIX_BUILD_TIME="$BUILD_TIME_UTC" \ |
| 183 | node "$ROOT/desktop/packaging/package.mjs" "$PLATFORM" "$VERSION" "$CHANNEL" |
| 184 | } |
| 185 | |
| 186 | mkdir -p "$ROOT/dist" |
| 187 | |
| 188 | case "$os" in |
| 189 | darwin) |
| 190 | service_out="$ROOT/desktop/build/bin/$BINNAME" |
| 191 | build_service |
| 192 | cli_out="$ROOT/desktop/build/bin/$CLINAME" |
| 193 | build_cli |
| 194 | package_shell |
| 195 | |
| 196 | staging=$(mktemp -d) |
| 197 | app="$staging/${APPNAME}.app" |
| 198 | cp -R "build/electron/${os}-${arch}/${APPNAME}.app" "$app" |
| 199 | # The bundle's main executable is Electron. Keep one Go service payload under |
| 200 | # Resources and a relative compatibility symlink in MacOS for older launchers. |
| 201 | bundle_executable=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$app/Contents/Info.plist") |
| 202 | [ "$bundle_executable" = "$APPNAME" ] || { echo "macOS bundle executable is $bundle_executable, want $APPNAME" >&2; exit 1; } |
| 203 | mkdir -p "$app/Contents/Resources/service" |
| 204 | cp "$service_out" "$app/Contents/Resources/service/$BINNAME" |
| 205 | rm -f "$app/Contents/MacOS/$BINNAME" |
| 206 | ln -s "../Resources/service/$BINNAME" "$app/Contents/MacOS/$BINNAME" |
| 207 | [ "$(readlink "$app/Contents/MacOS/$BINNAME")" = "../Resources/service/$BINNAME" ] || { echo "macOS service compatibility link is invalid" >&2; exit 1; } |
| 208 | [ -x "$app/Contents/MacOS/$BINNAME" ] || { echo "macOS service compatibility link is broken" >&2; exit 1; } |
| 209 | # Contents/MacOS already holds the Electron executable "Reasonix"; on |
| 210 | # case-insensitive APFS a "reasonix" sibling would overwrite it, so the |
| 211 | # CLI sidecar ships next to the service copy the shell actually launches |
| 212 | # (desktopCLIBinaryPath resolves it beside the running service). |
| 213 | cp "$cli_out" "$app/Contents/Resources/service/$CLINAME" |
| 214 | if [ -e "$app/Contents/MacOS/$GUARDNAME" ]; then |
| 215 | echo "macOS bundle must not include $GUARDNAME" >&2 |
| 216 | exit 1 |
| 217 | fi |
| 218 | darwin_icon="$ROOT/desktop/build/darwin/icon.icns" |
| 219 | bundle_icon=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIconFile" "$app/Contents/Info.plist" 2>/dev/null || true) |
| 220 | case "$bundle_icon" in |
| 221 | *.icns) ;; |
| 222 | *) bundle_icon="$bundle_icon.icns" ;; |
| 223 | esac |
| 224 | [ -s "$app/Contents/Resources/$bundle_icon" ] || { echo "macOS bundle icon is missing: $bundle_icon" >&2; exit 1; } |
| 225 | |
| 226 | # Two signing paths, selected by HAS_APPLE_CERT (set by release-desktop.yml when |
| 227 | # the APPLE_* secrets are present). With a real Developer ID cert + notarization |
| 228 | # key we sign with a hardened runtime, notarize, and staple — a downloaded build |
| 229 | # then opens with no Gatekeeper prompt. Without it we ad-hoc sign as before (still |
| 230 | # un-notarized; users clear the quarantine attribute per desktop/README.md). The |
| 231 | # fallback keeps fork/local builds working with no secrets configured. |
| 232 | if [ "${HAS_APPLE_CERT:-}" = "true" ]; then |
| 233 | identity="$(security find-identity -v -p codesigning | awk -F'"' '/Developer ID Application/{print $2; exit}')" |
| 234 | [ -n "$identity" ] || { echo "HAS_APPLE_CERT=true but no 'Developer ID Application' identity found in the keychain" >&2; exit 1; } |
| 235 | echo "==> codesign (Developer ID): $identity" |
| 236 | node "$ROOT/desktop/packaging/sign-macos.mjs" "$app" "$identity" |
| 237 | # notarytool wants an archive, not a bare bundle: zip the .app, submit, wait, |
| 238 | # then staple the ticket back onto the bundle so it verifies offline. |
| 239 | ditto -c -k --keepParent "$app" "$staging/notarize.zip" |
| 240 | notary_diagnostics="${APPLE_NOTARIZATION_LOG_DIR:-$ROOT/desktop/build/notarization}" |
| 241 | node "$ROOT/scripts/notarize-desktop.mjs" "$staging/notarize.zip" "$app" app "$notary_diagnostics" |
| 242 | else |
| 243 | # Ad-hoc cuts the "is damaged" error somewhat but is NOT notarized; users may |
| 244 | # still need `xattr -dr com.apple.quarantine` (see desktop/README.md). |
| 245 | node "$ROOT/desktop/packaging/sign-macos.mjs" "$app" - |
| 246 | fi |
| 247 | |
| 248 | # Updaters receive a native-architecture app. Universal remains a human |
| 249 | # download only, so a fat bundle is never copied under architecture names. |
| 250 | if [ "$arch" != universal ]; then |
| 251 | ditto -c -k --zlibCompressionLevel 9 --keepParent "$app" "$ROOT/dist/${APPNAME}-darwin-${arch}.zip" |
| 252 | fi |
| 253 | candidate_dir="$ROOT/desktop/build/candidate/darwin-${arch}" |
| 254 | rm -rf "$candidate_dir" |
| 255 | mkdir -p "$candidate_dir" |
| 256 | cp -R "$app" "$candidate_dir/${APPNAME}.app" |
| 257 | node "$ROOT/desktop/packaging/verify.mjs" "$candidate_dir/${APPNAME}.app" --kind darwin-app-dir |
| 258 | if [ "${DESKTOP_BUILD_SKIP_DMG:-0}" = "1" ]; then |
| 259 | echo "==> skip DMG packaging (DESKTOP_BUILD_SKIP_DMG=1)" |
| 260 | else |
| 261 | # A drag-to-Applications .dmg for first-time human download. cmd/sign uses an |
| 262 | # exact filename table, so the .zip stays the updater channel and the .dmg is |
| 263 | # release-page only. create-dmg can exit nonzero |
| 264 | # while still writing the image, so gate on the file existing, not the exit code. |
| 265 | dmgsrc=$(mktemp -d) |
| 266 | cp -R "$app" "$dmgsrc/${APPNAME}.app" |
| 267 | dmg="$ROOT/dist/${APPNAME}-darwin-${arch}.dmg" |
| 268 | create-dmg \ |
| 269 | --volname "$APPNAME" \ |
| 270 | --window-size 540 380 \ |
| 271 | --icon-size 110 \ |
| 272 | --icon "${APPNAME}.app" 150 190 \ |
| 273 | --app-drop-link 390 190 \ |
| 274 | --no-internet-enable \ |
| 275 | "$dmg" "$dmgsrc" || true |
| 276 | [ -f "$dmg" ] || { echo "create-dmg did not produce $dmg" >&2; exit 1; } |
| 277 | # The .dmg is a separately-downloaded artifact, so sign + notarize + staple the |
| 278 | # disk image itself too — the stapled .app inside isn't enough for the image. |
| 279 | if [ "${HAS_APPLE_CERT:-}" = "true" ]; then |
| 280 | codesign --force --timestamp -s "$identity" "$dmg" |
| 281 | node "$ROOT/scripts/notarize-desktop.mjs" "$dmg" "$dmg" dmg "$notary_diagnostics" |
| 282 | fi |
| 283 | rm -rf "$dmgsrc" |
| 284 | fi |
| 285 | rm -rf "$staging" |
| 286 | ;; |
| 287 | windows) |
| 288 | windows_resource_tool_dir=$(mktemp -d) |
| 289 | windows_host_include="$ROOT/desktop/build/windows/installer/reasonix_host.nsh" |
| 290 | case "$(uname -s 2>/dev/null || printf '%s' unknown)" in |
| 291 | Darwin* | Linux* | FreeBSD*) |
| 292 | printf '%s\n' '!define REASONIX_UNINST_FINALIZE '\''/bin/cp -f "%1" "reasonix-uninstall.exe"'\''' >"$windows_host_include" |
| 293 | ;; |
| 294 | *) |
| 295 | printf '%s\n' '!define REASONIX_UNINST_FINALIZE '\''cmd.exe /C copy /Y "%1" "reasonix-uninstall.exe" >NUL'\''' >"$windows_host_include" |
| 296 | ;; |
| 297 | esac |
| 298 | windows_resource_tool="$windows_resource_tool_dir/reasonix-windows-resource.exe" |
| 299 | echo "==> build Windows resource stamper" |
| 300 | go build -trimpath -o "$windows_resource_tool" ./cmd/windows-resource |
| 301 | |
| 302 | installer_dir="$ROOT/desktop/build/windows/installer" |
| 303 | guard_out="$installer_dir/$GUARDNAME.exe" |
| 304 | build_guard |
| 305 | stamp_windows_executable "$guard_out" "Reasonix Legacy Migrator" "$GUARDNAME" "$GUARDNAME.exe" |
| 306 | launcher_out="$installer_dir/$LAUNCHERNAME.exe" |
| 307 | echo "==> go build Windows GUI thin launcher" |
| 308 | (cd "$ROOT" && GOOS=windows GOARCH="$arch" CGO_ENABLED=0 go build -trimpath \ |
| 309 | -ldflags="-s -w -H windowsgui -X main.version=$VERSION" -o "$launcher_out" ./cmd/reasonix-launcher) |
| 310 | stamp_windows_executable "$launcher_out" "Reasonix Launcher" "$LAUNCHERNAME" "$LAUNCHERNAME.exe" |
| 311 | UPDATE_HELPER="reasonix-update-helper.exe" |
| 312 | echo "==> go build Windows update helper" |
| 313 | GOOS=windows GOARCH="$arch" go build -trimpath -ldflags="-s -w" \ |
| 314 | -o "$installer_dir/$UPDATE_HELPER" ./cmd/update-helper |
| 315 | stamp_windows_executable "$installer_dir/$UPDATE_HELPER" "Reasonix Update Helper" "reasonix-update-helper" "$UPDATE_HELPER" |
| 316 | cli_out="$installer_dir/$WINDOWS_CLINAME.exe" |
| 317 | build_cli |
| 318 | stamp_windows_executable "$cli_out" "Reasonix CLI" "$WINDOWS_CLINAME" "$WINDOWS_CLINAME.exe" |
| 319 | cli_entry_out="$ROOT/desktop/build/bin/$WINDOWS_CLI_ENTRY" |
| 320 | echo "==> go build Windows CLI entry" |
| 321 | (cd "$ROOT" && GOOS=windows GOARCH="$arch" CGO_ENABLED=0 go build -trimpath \ |
| 322 | -ldflags="-s -w" -o "$cli_entry_out" ./cmd/reasonix-cli-launcher) |
| 323 | stamp_windows_executable "$cli_entry_out" "Reasonix CLI Launcher" "reasonix-cli-launcher" "$WINDOWS_CLI_ENTRY" |
| 324 | |
| 325 | service_out="$ROOT/desktop/build/bin/$BINNAME.exe" |
| 326 | build_service |
| 327 | stamp_windows_executable "$service_out" "Reasonix Desktop" "$BINNAME" "$BINNAME.exe" |
| 328 | # NSIS File sources live next to project.nsi; the service joins the flat |
| 329 | # payload files there (package-windows-desktop.sh overwrites them with the |
| 330 | # signed copies before the second pass). |
| 331 | cp "$service_out" "$installer_dir/$BINNAME.exe" |
| 332 | |
| 333 | package_shell |
| 334 | mkdir -p "build/electron/${os}-${arch}/app/resources/bin" |
| 335 | cp "$cli_entry_out" "build/electron/${os}-${arch}/app/resources/bin/$WINDOWS_CLI_ENTRY" |
| 336 | # The Electron bundle becomes versions/v<ver>/app/ at install time; NSIS |
| 337 | # consumes it as the "app" directory next to project.nsi. |
| 338 | rm -rf "$installer_dir/app" |
| 339 | cp -R "build/electron/${os}-${arch}/app" "$installer_dir/app" |
| 340 | |
| 341 | # First NSIS pass: regenerate this release's uninstaller. A stale preserved |
| 342 | # uninstaller must never enter the signing payload. |
| 343 | # Compile only the shared uninstall section here; compressing the entire |
| 344 | # Electron payload just to discard this installer costs another five minutes. |
| 345 | rm -f "$installer_dir/reasonix-uninstall.exe" |
| 346 | find "$ROOT/desktop/build/bin" -maxdepth 1 -type f -name '*installer*.exe' -delete |
| 347 | arch_binary_define="ARG_REASONIX_AMD64_BINARY" |
| 348 | [ "$arch" = arm64 ] && arch_binary_define="ARG_REASONIX_ARM64_BINARY" |
| 349 | ( |
| 350 | cd "$installer_dir" |
| 351 | makensis -DARG_REASONIX_UNINSTALLER_ONLY "-D${arch_binary_define}=$installer_dir/$BINNAME.exe" project.nsi |
| 352 | ) |
| 353 | [ -s "$installer_dir/reasonix-uninstall.exe" ] || { echo "first NSIS pass did not produce reasonix-uninstall.exe" >&2; exit 1; } |
| 354 | |
| 355 | # Keep one canonical payload for SignPath: the flat Go executables plus the |
| 356 | # Electron app/ tree. The release workflow signs these files, then calls |
| 357 | # package-windows-desktop.sh again so both the portable archive and the |
| 358 | # files embedded by NSIS carry Authenticode. |
| 359 | payload_dir="$ROOT/desktop/build/windows/signing-payload" |
| 360 | rm -rf -- "$payload_dir" |
| 361 | mkdir -p "$payload_dir" |
| 362 | for name in "$BINNAME.exe" "$GUARDNAME.exe" "$LAUNCHERNAME.exe" "$UPDATE_HELPER" "$WINDOWS_CLINAME.exe" "reasonix-uninstall.exe"; do |
| 363 | cp "$installer_dir/$name" "$payload_dir/$name" |
| 364 | done |
| 365 | cp -R "$installer_dir/app" "$payload_dir/app" |
| 366 | # signing-files.txt enumerates every PE file (flat payload + app tree); the |
| 367 | # SignPath artifact configuration and the Authenticode verifier consume it. |
| 368 | node "$ROOT/desktop/packaging/signing-files.mjs" "$payload_dir" |
| 369 | VERSION="$VERSION" "$ROOT/scripts/package-windows-desktop.sh" "$arch" "$payload_dir" |
| 370 | node "$ROOT/desktop/packaging/verify.mjs" "$ROOT/dist/${APPNAME}-windows-${arch}.zip" --kind windows-portable-zip |
| 371 | ;; |
| 372 | linux) |
| 373 | service_out="$ROOT/desktop/build/bin/$BINNAME" |
| 374 | build_service |
| 375 | # Linux still ships a one-shot migrator named reasonix-guard in the portable |
| 376 | # tarball so 1.18–1.19.1 updaters can hand off. |
| 377 | guard_out="$ROOT/desktop/build/bin/$GUARDNAME" |
| 378 | build_guard |
| 379 | launcher_out="$ROOT/desktop/build/bin/$LAUNCHERNAME" |
| 380 | echo "==> go build Linux thin launcher" |
| 381 | (cd "$ROOT" && GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -trimpath \ |
| 382 | -ldflags="-s -w -X main.version=$VERSION" -o "$launcher_out" ./cmd/reasonix-launcher) |
| 383 | cli_out="$ROOT/desktop/build/bin/$CLINAME" |
| 384 | build_cli |
| 385 | package_shell |
| 386 | # Stage the Electron tree next to the Go binaries so the tarball and the |
| 387 | # nfpm config share one source root (build/bin). |
| 388 | rm -rf "build/bin/app" |
| 389 | cp -R "build/electron/${os}-${arch}/app" "build/bin/app" |
| 390 | |
| 391 | for desktop_contract in \ |
| 392 | 'Exec=reasonix-launcher' \ |
| 393 | 'Icon=reasonix-desktop' \ |
| 394 | 'StartupWMClass=Reasonix'; do |
| 395 | grep -F -x -q "$desktop_contract" build/linux/reasonix.desktop || { echo "Linux desktop entry missing: $desktop_contract" >&2; exit 1; } |
| 396 | done |
| 397 | # Portable Linux tarball: service + thin launcher + one-shot migrator |
| 398 | # (compat name reasonix-guard) + CLI + the Electron app/ tree. After the |
| 399 | # migrator runs, Guard self-deletes. |
| 400 | tar -cf - -C build/bin "$BINNAME" "$LAUNCHERNAME" "$GUARDNAME" "$CLINAME" app | \ |
| 401 | gzip -9 >"$ROOT/dist/${APPNAME}-linux-${arch}.tar.gz" |
| 402 | # Build the privileged update helper shipped inside the .deb. Portable tarball |
| 403 | # installs do not need it; only the dpkg package installs helper + Polkit policy. |
| 404 | echo "==> go build reasonix-update-helper" |
| 405 | GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$VERSION" \ |
| 406 | -o "build/bin/reasonix-update-helper" ./cmd/update-helper |
| 407 | # .deb for Debian/Ubuntu. Portable updater still uses the tarball under |
| 408 | # platforms[]; .deb is published under native_packages. Debian versions use |
| 409 | # "~" for prereleases so 1.18.0~rc.1 < 1.18.0 (policy version ordering). |
| 410 | # Extra "-" inside the prerelease label becomes "." (Debian policy). |
| 411 | ver_body="${VERSION#v}" |
| 412 | if [[ "$ver_body" == *-* ]]; then |
| 413 | deb_base="${ver_body%%-*}" |
| 414 | deb_pre="${ver_body#*-}" |
| 415 | deb_pre="${deb_pre//-/.}" |
| 416 | deb_version="${deb_base}~${deb_pre}" |
| 417 | else |
| 418 | deb_version="$ver_body" |
| 419 | fi |
| 420 | DEB_VERSION="$deb_version" DEB_ARCH="$arch" \ |
| 421 | nfpm package --config build/linux/nfpm.yaml --packager deb \ |
| 422 | --target "$ROOT/dist/${APPNAME}-linux-${arch}.deb" |
| 423 | # Contract smoke: helper, policy, package identity, Electron tree, sandbox. |
| 424 | deb_path="$ROOT/dist/${APPNAME}-linux-${arch}.deb" |
| 425 | dpkg-deb --field "$deb_path" Package | grep -x 'reasonix-desktop' >/dev/null |
| 426 | dpkg-deb --field "$deb_path" Version | grep -x "$deb_version" >/dev/null |
| 427 | dpkg-deb --field "$deb_path" Depends | grep -F 'pkexec' >/dev/null |
| 428 | dpkg-deb --contents "$deb_path" | grep -E 'usr/lib/reasonix/reasonix-update-helper' >/dev/null |
| 429 | dpkg-deb --contents "$deb_path" | grep -E 'usr/share/polkit-1/actions/io.reasonix.desktop.update.policy' >/dev/null |
| 430 | dpkg-deb --contents "$deb_path" | grep -E "usr/lib/reasonix/app/${APPNAME}" >/dev/null |
| 431 | dpkg-deb --contents "$deb_path" | grep -E 'usr/lib/reasonix/app/chrome-sandbox' >/dev/null |
| 432 | node "$ROOT/desktop/packaging/verify.mjs" "$ROOT/dist/${APPNAME}-linux-${arch}.tar.gz" --kind linux-tar |
| 433 | node "$ROOT/desktop/packaging/verify.mjs" "$deb_path" --kind linux-deb |
| 434 | ;; |
| 435 | *) |
| 436 | echo "unsupported os: $os" >&2 |
| 437 | exit 1 |
| 438 | ;; |
| 439 | esac |
| 440 | |
| 441 | case "$os" in |
| 442 | # The staging directory is intentionally removed after the signed app is |
| 443 | # copied into build/candidate. Reports must inspect that published candidate, |
| 444 | # otherwise every successful macOS package build fails after artifact |
| 445 | # verification with ENOENT. |
| 446 | darwin) report_bundle="$ROOT/desktop/build/candidate/darwin-${arch}/${APPNAME}.app" ;; |
| 447 | windows) report_bundle="$ROOT/desktop/build/windows/signing-payload" ;; |
| 448 | linux) report_bundle="$ROOT/desktop/build/bin" ;; |
| 449 | esac |
| 450 | REASONIX_COMMIT="$SOURCE_SHA" REASONIX_BUILD_SECONDS="$((SECONDS - build_started_seconds))" \ |
| 451 | node "$ROOT/desktop/packaging/size-report.mjs" \ |
| 452 | --platform "$PLATFORM" \ |
| 453 | --version "$VERSION" \ |
| 454 | --bundle "$report_bundle" \ |
| 455 | --dist "$ROOT/dist" \ |
| 456 | --output "$ROOT/desktop/build/reports/${os}-${arch}" |
| 457 | |
| 458 | echo "==> packaged into dist/:" |
| 459 | ls -la "$ROOT/dist" |
| 460 |