返回 DeepSeek-Reasonix
desktop-build.sh
根目录 / scripts / desktop-build.sh
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 elif [ "$os" = linux ]; then
172 # cgo would bind the service to the runner's glibc; nothing on Linux needs it
173 # and verify.mjs refuses a dynamically linked Go member.
174 GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -trimpath -ldflags="-s -w $service_ldflags" -o "$service_out" .
175 else
176 GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w $service_ldflags" -o "$service_out" .
177 fi
178 }
179
180 # package_shell runs @electron/packager via the packaging script and leaves the
181 # bundle at desktop/build/electron/<os>-<arch>/ (Reasonix.app on macOS, app/
182 # elsewhere). It also builds the frontend (build:electron) with the channel
183 # threaded through REASONIX_CHANNEL.
184 package_shell() {
185 echo "==> package Electron shell ($PLATFORM)"
186 REASONIX_COMMIT="$SOURCE_SHA" REASONIX_BUILD_TIME="$BUILD_TIME_UTC" \
187 node "$ROOT/desktop/packaging/package.mjs" "$PLATFORM" "$VERSION" "$CHANNEL"
188 }
189
190 mkdir -p "$ROOT/dist"
191
192 case "$os" in
193 darwin)
194 service_out="$ROOT/desktop/build/bin/$BINNAME"
195 build_service
196 cli_out="$ROOT/desktop/build/bin/$CLINAME"
197 build_cli
198 package_shell
199
200 staging=$(mktemp -d)
201 app="$staging/${APPNAME}.app"
202 cp -R "build/electron/${os}-${arch}/${APPNAME}.app" "$app"
203 # The bundle's main executable is Electron. Keep one Go service payload under
204 # Resources and a relative compatibility symlink in MacOS for older launchers.
205 bundle_executable=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$app/Contents/Info.plist")
206 [ "$bundle_executable" = "$APPNAME" ] || { echo "macOS bundle executable is $bundle_executable, want $APPNAME" >&2; exit 1; }
207 mkdir -p "$app/Contents/Resources/service"
208 cp "$service_out" "$app/Contents/Resources/service/$BINNAME"
209 rm -f "$app/Contents/MacOS/$BINNAME"
210 ln -s "../Resources/service/$BINNAME" "$app/Contents/MacOS/$BINNAME"
211 [ "$(readlink "$app/Contents/MacOS/$BINNAME")" = "../Resources/service/$BINNAME" ] || { echo "macOS service compatibility link is invalid" >&2; exit 1; }
212 [ -x "$app/Contents/MacOS/$BINNAME" ] || { echo "macOS service compatibility link is broken" >&2; exit 1; }
213 # Contents/MacOS already holds the Electron executable "Reasonix"; on
214 # case-insensitive APFS a "reasonix" sibling would overwrite it, so the
215 # CLI sidecar ships next to the service copy the shell actually launches
216 # (desktopCLIBinaryPath resolves it beside the running service).
217 cp "$cli_out" "$app/Contents/Resources/service/$CLINAME"
218 if [ -e "$app/Contents/MacOS/$GUARDNAME" ]; then
219 echo "macOS bundle must not include $GUARDNAME" >&2
220 exit 1
221 fi
222 darwin_icon="$ROOT/desktop/build/darwin/icon.icns"
223 bundle_icon=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIconFile" "$app/Contents/Info.plist" 2>/dev/null || true)
224 case "$bundle_icon" in
225 *.icns) ;;
226 *) bundle_icon="$bundle_icon.icns" ;;
227 esac
228 [ -s "$app/Contents/Resources/$bundle_icon" ] || { echo "macOS bundle icon is missing: $bundle_icon" >&2; exit 1; }
229
230 # Two signing paths, selected by HAS_APPLE_CERT (set by release-desktop.yml when
231 # the APPLE_* secrets are present). With a real Developer ID cert + notarization
232 # key we sign with a hardened runtime, notarize, and staple — a downloaded build
233 # then opens with no Gatekeeper prompt. Without it we ad-hoc sign as before (still
234 # un-notarized; users clear the quarantine attribute per desktop/README.md). The
235 # fallback keeps fork/local builds working with no secrets configured.
236 if [ "${HAS_APPLE_CERT:-}" = "true" ]; then
237 identity="$(security find-identity -v -p codesigning | awk -F'"' '/Developer ID Application/{print $2; exit}')"
238 [ -n "$identity" ] || { echo "HAS_APPLE_CERT=true but no 'Developer ID Application' identity found in the keychain" >&2; exit 1; }
239 echo "==> codesign (Developer ID): $identity"
240 node "$ROOT/desktop/packaging/sign-macos.mjs" "$app" "$identity"
241 # notarytool wants an archive, not a bare bundle: zip the .app, submit, wait,
242 # then staple the ticket back onto the bundle so it verifies offline.
243 ditto -c -k --keepParent "$app" "$staging/notarize.zip"
244 notary_diagnostics="${APPLE_NOTARIZATION_LOG_DIR:-$ROOT/desktop/build/notarization}"
245 node "$ROOT/scripts/notarize-desktop.mjs" "$staging/notarize.zip" "$app" app "$notary_diagnostics"
246 else
247 # Ad-hoc cuts the "is damaged" error somewhat but is NOT notarized; users may
248 # still need `xattr -dr com.apple.quarantine` (see desktop/README.md).
249 node "$ROOT/desktop/packaging/sign-macos.mjs" "$app" -
250 fi
251
252 # Updaters receive a native-architecture app. Universal remains a human
253 # download only, so a fat bundle is never copied under architecture names.
254 if [ "$arch" != universal ]; then
255 ditto -c -k --zlibCompressionLevel 9 --keepParent "$app" "$ROOT/dist/${APPNAME}-darwin-${arch}.zip"
256 fi
257 candidate_dir="$ROOT/desktop/build/candidate/darwin-${arch}"
258 rm -rf "$candidate_dir"
259 mkdir -p "$candidate_dir"
260 cp -R "$app" "$candidate_dir/${APPNAME}.app"
261 node "$ROOT/desktop/packaging/verify.mjs" "$candidate_dir/${APPNAME}.app" --kind darwin-app-dir
262 if [ "${DESKTOP_BUILD_SKIP_DMG:-0}" = "1" ]; then
263 echo "==> skip DMG packaging (DESKTOP_BUILD_SKIP_DMG=1)"
264 else
265 # A drag-to-Applications .dmg for first-time human download. cmd/sign uses an
266 # exact filename table, so the .zip stays the updater channel and the .dmg is
267 # release-page only. Validate a fresh image before replacing old output.
268 dmg="$ROOT/dist/${APPNAME}-darwin-${arch}.dmg"
269 bash "$ROOT/scripts/package-desktop-dmg.sh" "$app" "$dmg" "$APPNAME"
270 # The .dmg is a separately-downloaded artifact, so sign + notarize + staple the
271 # disk image itself too — the stapled .app inside isn't enough for the image.
272 if [ "${HAS_APPLE_CERT:-}" = "true" ]; then
273 codesign --force --timestamp -s "$identity" "$dmg"
274 node "$ROOT/scripts/notarize-desktop.mjs" "$dmg" "$dmg" dmg "$notary_diagnostics"
275 fi
276 fi
277 rm -rf "$staging"
278 ;;
279 windows)
280 windows_resource_tool_dir=$(mktemp -d)
281 windows_host_include="$ROOT/desktop/build/windows/installer/reasonix_host.nsh"
282 case "$(uname -s 2>/dev/null || printf '%s' unknown)" in
283 Darwin* | Linux* | FreeBSD*)
284 printf '%s\n' '!define REASONIX_UNINST_FINALIZE '\''/bin/cp -f "%1" "reasonix-uninstall.exe"'\''' >"$windows_host_include"
285 ;;
286 *)
287 printf '%s\n' '!define REASONIX_UNINST_FINALIZE '\''cmd.exe /C copy /Y "%1" "reasonix-uninstall.exe" >NUL'\''' >"$windows_host_include"
288 ;;
289 esac
290 windows_resource_tool="$windows_resource_tool_dir/reasonix-windows-resource.exe"
291 echo "==> build Windows resource stamper"
292 go build -trimpath -o "$windows_resource_tool" ./cmd/windows-resource
293
294 installer_dir="$ROOT/desktop/build/windows/installer"
295 guard_out="$installer_dir/$GUARDNAME.exe"
296 build_guard
297 stamp_windows_executable "$guard_out" "Reasonix Legacy Migrator" "$GUARDNAME" "$GUARDNAME.exe"
298 launcher_out="$installer_dir/$LAUNCHERNAME.exe"
299 echo "==> go build Windows GUI thin launcher"
300 (cd "$ROOT" && GOOS=windows GOARCH="$arch" CGO_ENABLED=0 go build -trimpath \
301 -ldflags="-s -w -H windowsgui -X main.version=$VERSION" -o "$launcher_out" ./cmd/reasonix-launcher)
302 stamp_windows_executable "$launcher_out" "Reasonix Launcher" "$LAUNCHERNAME" "$LAUNCHERNAME.exe"
303 UPDATE_HELPER="reasonix-update-helper.exe"
304 echo "==> go build Windows update helper"
305 GOOS=windows GOARCH="$arch" go build -trimpath -ldflags="-s -w" \
306 -o "$installer_dir/$UPDATE_HELPER" ./cmd/update-helper
307 stamp_windows_executable "$installer_dir/$UPDATE_HELPER" "Reasonix Update Helper" "reasonix-update-helper" "$UPDATE_HELPER"
308 cli_out="$installer_dir/$WINDOWS_CLINAME.exe"
309 build_cli
310 stamp_windows_executable "$cli_out" "Reasonix CLI" "$WINDOWS_CLINAME" "$WINDOWS_CLINAME.exe"
311 cli_entry_out="$ROOT/desktop/build/bin/$WINDOWS_CLI_ENTRY"
312 echo "==> go build Windows CLI entry"
313 (cd "$ROOT" && GOOS=windows GOARCH="$arch" CGO_ENABLED=0 go build -trimpath \
314 -ldflags="-s -w" -o "$cli_entry_out" ./cmd/reasonix-cli-launcher)
315 stamp_windows_executable "$cli_entry_out" "Reasonix CLI Launcher" "reasonix-cli-launcher" "$WINDOWS_CLI_ENTRY"
316
317 service_out="$ROOT/desktop/build/bin/$BINNAME.exe"
318 build_service
319 stamp_windows_executable "$service_out" "Reasonix Desktop" "$BINNAME" "$BINNAME.exe"
320 # NSIS File sources live next to project.nsi; the service joins the flat
321 # payload files there (package-windows-desktop.sh overwrites them with the
322 # signed copies before the second pass).
323 cp "$service_out" "$installer_dir/$BINNAME.exe"
324
325 package_shell
326 mkdir -p "build/electron/${os}-${arch}/app/resources/bin"
327 cp "$cli_entry_out" "build/electron/${os}-${arch}/app/resources/bin/$WINDOWS_CLI_ENTRY"
328 # The Electron bundle becomes versions/v<ver>/app/ at install time; NSIS
329 # consumes it as the "app" directory next to project.nsi.
330 rm -rf "$installer_dir/app"
331 cp -R "build/electron/${os}-${arch}/app" "$installer_dir/app"
332
333 # First NSIS pass: regenerate this release's uninstaller. A stale preserved
334 # uninstaller must never enter the signing payload.
335 # Compile only the shared uninstall section here; compressing the entire
336 # Electron payload just to discard this installer costs another five minutes.
337 rm -f "$installer_dir/reasonix-uninstall.exe"
338 find "$ROOT/desktop/build/bin" -maxdepth 1 -type f -name '*installer*.exe' -delete
339 arch_binary_define="ARG_REASONIX_AMD64_BINARY"
340 [ "$arch" = arm64 ] && arch_binary_define="ARG_REASONIX_ARM64_BINARY"
341 (
342 cd "$installer_dir"
343 makensis -DARG_REASONIX_UNINSTALLER_ONLY "-D${arch_binary_define}=$installer_dir/$BINNAME.exe" project.nsi
344 )
345 [ -s "$installer_dir/reasonix-uninstall.exe" ] || { echo "first NSIS pass did not produce reasonix-uninstall.exe" >&2; exit 1; }
346
347 # Keep one canonical payload for SignPath: the flat Go executables plus the
348 # Electron app/ tree. The release workflow signs these files, then calls
349 # package-windows-desktop.sh again so both the portable archive and the
350 # files embedded by NSIS carry Authenticode.
351 payload_dir="$ROOT/desktop/build/windows/signing-payload"
352 rm -rf -- "$payload_dir"
353 mkdir -p "$payload_dir"
354 for name in "$BINNAME.exe" "$GUARDNAME.exe" "$LAUNCHERNAME.exe" "$UPDATE_HELPER" "$WINDOWS_CLINAME.exe" "reasonix-uninstall.exe"; do
355 cp "$installer_dir/$name" "$payload_dir/$name"
356 done
357 cp -R "$installer_dir/app" "$payload_dir/app"
358 # signing-files.txt enumerates every PE file (flat payload + app tree); the
359 # SignPath artifact configuration and the Authenticode verifier consume it.
360 node "$ROOT/desktop/packaging/signing-files.mjs" "$payload_dir"
361 # A signed release rebuilds the installer and portable archive from the
362 # signed payload; compressing the unsigned pair here would be discarded.
363 if [ "${REASONIX_WINDOWS_PAYLOAD_ONLY:-0}" = "1" ]; then
364 mkdir -p "$ROOT/dist"
365 else
366 VERSION="$VERSION" "$ROOT/scripts/package-windows-desktop.sh" "$arch" "$payload_dir"
367 node "$ROOT/desktop/packaging/verify.mjs" "$ROOT/dist/${APPNAME}-windows-${arch}.zip" --kind windows-portable-zip
368 fi
369 ;;
370 linux)
371 service_out="$ROOT/desktop/build/bin/$BINNAME"
372 build_service
373 # Linux still ships a one-shot migrator named reasonix-guard in the portable
374 # tarball so 1.18–1.19.1 updaters can hand off.
375 guard_out="$ROOT/desktop/build/bin/$GUARDNAME"
376 build_guard
377 launcher_out="$ROOT/desktop/build/bin/$LAUNCHERNAME"
378 echo "==> go build Linux thin launcher"
379 (cd "$ROOT" && GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -trimpath \
380 -ldflags="-s -w -X main.version=$VERSION" -o "$launcher_out" ./cmd/reasonix-launcher)
381 cli_out="$ROOT/desktop/build/bin/$CLINAME"
382 build_cli
383 package_shell
384 # Stage the Electron tree next to the Go binaries so the tarball and the
385 # nfpm config share one source root (build/bin).
386 rm -rf "build/bin/app"
387 cp -R "build/electron/${os}-${arch}/app" "build/bin/app"
388
389 for desktop_contract in \
390 'Exec=reasonix-launcher' \
391 'Icon=reasonix-desktop' \
392 'StartupWMClass=Reasonix'; do
393 grep -F -x -q "$desktop_contract" build/linux/reasonix.desktop || { echo "Linux desktop entry missing: $desktop_contract" >&2; exit 1; }
394 done
395 # Portable Linux tarball: service + thin launcher + one-shot migrator
396 # (compat name reasonix-guard) + CLI + the Electron app/ tree. After the
397 # migrator runs, Guard self-deletes.
398 tar -cf - -C build/bin "$BINNAME" "$LAUNCHERNAME" "$GUARDNAME" "$CLINAME" app | \
399 gzip -9 >"$ROOT/dist/${APPNAME}-linux-${arch}.tar.gz"
400 # Build the privileged update helper shipped inside the .deb. Portable tarball
401 # installs do not need it; only the dpkg package installs helper + Polkit policy.
402 echo "==> go build reasonix-update-helper"
403 GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$VERSION" \
404 -o "build/bin/reasonix-update-helper" ./cmd/update-helper
405 # .deb for Debian/Ubuntu. Portable updater still uses the tarball under
406 # platforms[]; .deb is published under native_packages. Debian versions use
407 # "~" for prereleases so 1.18.0~rc.1 < 1.18.0 (policy version ordering).
408 # Extra "-" inside the prerelease label becomes "." (Debian policy).
409 ver_body="${VERSION#v}"
410 if [[ "$ver_body" == *-* ]]; then
411 deb_base="${ver_body%%-*}"
412 deb_pre="${ver_body#*-}"
413 deb_pre="${deb_pre//-/.}"
414 deb_version="${deb_base}~${deb_pre}"
415 else
416 deb_version="$ver_body"
417 fi
418 DEB_VERSION="$deb_version" DEB_ARCH="$arch" \
419 nfpm package --config build/linux/nfpm.yaml --packager deb \
420 --target "$ROOT/dist/${APPNAME}-linux-${arch}.deb"
421 # Contract smoke: helper, policy, package identity, Electron tree, sandbox.
422 deb_path="$ROOT/dist/${APPNAME}-linux-${arch}.deb"
423 dpkg-deb --field "$deb_path" Package | grep -x 'reasonix-desktop' >/dev/null
424 dpkg-deb --field "$deb_path" Version | grep -x "$deb_version" >/dev/null
425 dpkg-deb --field "$deb_path" Depends | grep -F 'pkexec' >/dev/null
426 dpkg-deb --contents "$deb_path" | grep -E 'usr/lib/reasonix/reasonix-update-helper' >/dev/null
427 dpkg-deb --contents "$deb_path" | grep -E 'usr/share/polkit-1/actions/io.reasonix.desktop.update.policy' >/dev/null
428 dpkg-deb --contents "$deb_path" | grep -E "usr/lib/reasonix/app/${APPNAME}" >/dev/null
429 dpkg-deb --contents "$deb_path" | grep -E 'usr/lib/reasonix/app/chrome-sandbox' >/dev/null
430 node "$ROOT/desktop/packaging/verify.mjs" "$ROOT/dist/${APPNAME}-linux-${arch}.tar.gz" --kind linux-tar
431 node "$ROOT/desktop/packaging/verify.mjs" "$deb_path" --kind linux-deb
432 ;;
433 *)
434 echo "unsupported os: $os" >&2
435 exit 1
436 ;;
437 esac
438
439 case "$os" in
440 # The staging directory is intentionally removed after the signed app is
441 # copied into build/candidate. Reports must inspect that published candidate,
442 # otherwise every successful macOS package build fails after artifact
443 # verification with ENOENT.
444 darwin) report_bundle="$ROOT/desktop/build/candidate/darwin-${arch}/${APPNAME}.app" ;;
445 windows) report_bundle="$ROOT/desktop/build/windows/signing-payload" ;;
446 linux) report_bundle="$ROOT/desktop/build/bin" ;;
447 esac
448 REASONIX_COMMIT="$SOURCE_SHA" REASONIX_BUILD_SECONDS="$((SECONDS - build_started_seconds))" \
449 node "$ROOT/desktop/packaging/size-report.mjs" \
450 --platform "$PLATFORM" \
451 --version "$VERSION" \
452 --bundle "$report_bundle" \
453 --dist "$ROOT/dist" \
454 --output "$ROOT/desktop/build/reports/${os}-${arch}"
455
456 echo "==> packaged into dist/:"
457 ls -la "$ROOT/dist"
458
458 lines BASH