返回 DeepSeek-Reasonix
desktop-build.sh
根目录 / scripts / desktop-build.sh
1 #!/usr/bin/env bash
2 # Build and package the Wails desktop app for one platform. Wails cannot
3 # cross-compile a CGO+webview binary, so this runs on a native runner per target
4 # (see .github/workflows/release-desktop.yml) and is invoked once per matrix entry.
5 #
6 # Output lands in <repo>/dist/ with stable, platform-keyed names that
7 # desktop/cmd/sign's `manifest` subcommand maps back to update.PlatformKey:
8 # macOS: Reasonix-darwin-<arch>.zip (ditto archive; updater channel)
9 # Reasonix-darwin-universal.dmg (drag-to-install; human download)
10 # Windows: Reasonix-windows-<arch>-installer.exe (NSIS per-user installer; updater channel)
11 # Reasonix-windows-<arch>.zip (portable human download)
12 # Linux: Reasonix-linux-<arch>.tar.gz (desktop + guard + CLI; portable updater)
13 # Reasonix-linux-<arch>.deb (Debian/Ubuntu package; native updater)
14 #
15 # Usage: scripts/desktop-build.sh <os/arch> <version> [channel]
16 # e.g. scripts/desktop-build.sh darwin/arm64 v1.1.0
17 # scripts/desktop-build.sh darwin/arm64 v1.5.0-preview.42 preview
18 set -euo pipefail
19
20 PLATFORM="${1:?usage: desktop-build.sh <os/arch> <version> [channel]}"
21 VERSION="${2:?usage: desktop-build.sh <os/arch> <version> [channel]}"
22 CHANNEL="${3:-stable}"
23
24 os="${PLATFORM%/*}"
25 arch="${PLATFORM#*/}"
26
27 ROOT="$(cd "$(dirname "$0")/.." && pwd)"
28 APPNAME="Reasonix" # wails.json productName -> Reasonix.app
29 BINNAME="reasonix-desktop" # wails.json outputfilename -> linux binary name
30 CLINAME="reasonix" # bundled CLI sidecar used for remote serve upload
31 WINDOWS_CLINAME="reasonix-cli" # Windows cannot store Reasonix.exe and reasonix.exe separately
32 GUARDNAME="reasonix-guard"
33 LAUNCHERNAME="reasonix-launcher"
34 windows_resource_tool_dir=""
35
36 # desktop/ is a nested Go module, so the Go toolchain cannot discover the
37 # repository VCS revision for the Wails binary. Link the same source identity
38 # into both Desktop and its CLI sidecar before this script mutates packaging
39 # metadata such as wails.json.
40 SOURCE_REVISION="$(git -C "$ROOT" rev-parse --verify HEAD)"
41 if ! git -C "$ROOT" diff-index --quiet HEAD --; then
42 SOURCE_REVISION="$SOURCE_REVISION+dirty"
43 fi
44 # Short commit + real UTC build clock for CLI `version --verbose/--json`.
45 GIT_COMMIT="$(git -C "$ROOT" rev-parse --short=12 HEAD 2>/dev/null || echo unknown)"
46 BUILD_TIME_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
47 # The remote protocol source-revision ldflag was removed with Remote Workbench.
48 product_docs_ldflags="-X reasonix/internal/productdocs.linkedVersion=$VERSION -X reasonix/internal/productdocs.linkedRevision=$SOURCE_REVISION"
49 cli_identity_ldflags="-X main.version=$VERSION -X main.gitCommit=$GIT_COMMIT -X main.buildTimeUTC=$BUILD_TIME_UTC $product_docs_ldflags"
50
51 cleanup() {
52 if [ -n "$windows_resource_tool_dir" ]; then
53 rm -rf "$windows_resource_tool_dir"
54 fi
55 }
56 trap cleanup EXIT
57
58 cd "$ROOT/desktop"
59
60 # build_guard produces the one-shot legacy migrator still named reasonix-guard
61 # in compatibility payloads for 1.18–1.19.1 updaters. Source is intentionally
62 # separate from the removed Guard recovery product.
63 build_guard() {
64 echo "==> go build Reasonix legacy migrator (compat name reasonix-guard)"
65 mkdir -p "$(dirname "$guard_out")"
66 if [ "$arch" = universal ]; then
67 guard_tmp=$(mktemp -d)
68 (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)
69 (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)
70 lipo -create "$guard_tmp/amd64" "$guard_tmp/arm64" -output "$guard_out"
71 rm -rf "$guard_tmp"
72 else
73 (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)
74 fi
75 }
76
77 build_cli() {
78 echo "==> go build Reasonix CLI sidecar"
79 mkdir -p "$(dirname "$cli_out")"
80 if [ "$arch" = universal ]; then
81 cli_tmp=$(mktemp -d)
82 (cd "$ROOT" && GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w $cli_identity_ldflags" -o "$cli_tmp/amd64" ./cmd/reasonix)
83 (cd "$ROOT" && GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w $cli_identity_ldflags" -o "$cli_tmp/arm64" ./cmd/reasonix)
84 lipo -create "$cli_tmp/amd64" "$cli_tmp/arm64" -output "$cli_out"
85 rm -rf "$cli_tmp"
86 else
87 (cd "$ROOT" && GOOS="$os" GOARCH="$arch" CGO_ENABLED=0 go build -trimpath -ldflags="-s -w $cli_identity_ldflags" -o "$cli_out" ./cmd/reasonix)
88 fi
89 }
90
91 stamp_windows_executable() {
92 local target="$1"
93 local description="$2"
94 local internal_name="$3"
95 local original_filename="$4"
96 "$windows_resource_tool" \
97 -exe "$target" \
98 -icon "$ROOT/desktop/build/windows/icon.ico" \
99 -version "$numver" \
100 -description "$description" \
101 -internal-name "$internal_name" \
102 -original-filename "$original_filename"
103 }
104
105 # Stamp the version resource (Windows file properties, macOS CFBundleVersion) from
106 # the tag. Wails feeds info.productVersion into goversioninfo and NSIS's
107 # VIFileVersion, both of which demand a strictly numeric X.X.X, so strip the
108 # leading "v" AND any prerelease suffix (a `-rc1` tag would otherwise abort the
109 # installer build). The full tag still rides in ldflags for the in-app version.
110 numver="${VERSION#v}"; numver="${numver%%-*}"
111 node -e 'const fs=require("fs"),f="wails.json",j=JSON.parse(fs.readFileSync(f,"utf8"));j.info.productVersion=process.argv[1];fs.writeFileSync(f,JSON.stringify(j,null,2)+"\n")' "$numver"
112
113 # NSIS installer is Windows-only (Wails requires a single windows target for -nsis).
114 ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $product_docs_ldflags"
115 [ "$os" = "darwin" ] && [ "${HAS_APPLE_CERT:-}" = "true" ] && ldflags="$ldflags -X main.macSelfUpdate=true"
116 UPDATE_HELPER="reasonix-update-helper.exe"
117 if [ "$os" = windows ]; then
118 windows_resource_tool_dir=$(mktemp -d)
119 windows_resource_tool="$windows_resource_tool_dir/reasonix-windows-resource.exe"
120 echo "==> build Windows resource stamper"
121 go build -trimpath -o "$windows_resource_tool" ./cmd/windows-resource
122 guard_out="$ROOT/desktop/build/windows/installer/$GUARDNAME.exe"
123 build_guard
124 stamp_windows_executable "$guard_out" "Reasonix Legacy Migrator" "$GUARDNAME" "$GUARDNAME.exe"
125 launcher_out="$ROOT/desktop/build/windows/installer/$LAUNCHERNAME.exe"
126 echo "==> go build Windows GUI thin launcher"
127 (cd "$ROOT" && GOOS=windows GOARCH="$arch" CGO_ENABLED=0 go build -trimpath \
128 -ldflags="-s -w -H windowsgui -X main.version=$VERSION" -o "$launcher_out" ./cmd/reasonix-launcher)
129 stamp_windows_executable "$launcher_out" "Reasonix Launcher" "$LAUNCHERNAME" "$LAUNCHERNAME.exe"
130 echo "==> go build Windows update helper"
131 GOOS=windows GOARCH="$arch" go build -trimpath -ldflags="-s -w" \
132 -o "build/windows/installer/$UPDATE_HELPER" ./cmd/update-helper
133 stamp_windows_executable "build/windows/installer/$UPDATE_HELPER" "Reasonix Update Helper" "reasonix-update-helper" "$UPDATE_HELPER"
134 cli_out="$ROOT/desktop/build/windows/installer/$WINDOWS_CLINAME.exe"
135 build_cli
136 stamp_windows_executable "$cli_out" "Reasonix CLI" "$WINDOWS_CLINAME" "$WINDOWS_CLINAME.exe"
137 # The first NSIS pass must regenerate this release's uninstaller; a stale
138 # preserved file must never enter the signing payload.
139 rm -f "build/windows/installer/reasonix-uninstall.exe"
140 fi
141 build_args=()
142 [ "${DESKTOP_BUILD_CLEAN:-1}" != "0" ] && build_args+=(-clean)
143 build_args+=(-platform "$PLATFORM" -ldflags "$ldflags")
144 [ "$os" = windows ] && build_args+=(-nsis -webview2 embed)
145 # Link cgo against WebKitGTK 4.1: 4.0 (libwebkit2gtk-4.0.so.37) is gone on
146 # Ubuntu 24.04+/Fedora 40+, while 4.1 ships from Ubuntu 22.04 onward.
147 [ "$os" = linux ] && build_args+=(-tags webkit2_41)
148
149 echo "==> wails build ${build_args[*]}"
150 wails build "${build_args[@]}"
151 if [ "$os" != windows ]; then
152 # Linux still ships a one-shot migrator named reasonix-guard in the portable
153 # tarball so 1.18–1.19.1 updaters can hand off. macOS does not bundle Guard.
154 if [ "$os" = linux ]; then
155 guard_out="$ROOT/desktop/build/bin/$GUARDNAME"
156 build_guard
157 launcher_out="$ROOT/desktop/build/bin/$LAUNCHERNAME"
158 echo "==> go build Linux thin launcher"
159 (cd "$ROOT" && GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -trimpath \
160 -ldflags="-s -w -X main.version=$VERSION" -o "$launcher_out" ./cmd/reasonix-launcher)
161 fi
162 cli_out="$ROOT/desktop/build/bin/$CLINAME"
163 build_cli
164 fi
165
166 mkdir -p "$ROOT/dist"
167
168 case "$os" in
169 darwin)
170 # Wails names the bundle after outputfilename (reasonix-desktop.app); repackage
171 # it as Reasonix.app for a clean user-facing name.
172 staging=$(mktemp -d)
173 app="$staging/${APPNAME}.app"
174 cp -R "build/bin/reasonix-desktop.app" "$app"
175 # v1.20+: no Guard in the App bundle. CLI remains a sibling helper for
176 # terminal workflows; LaunchServices must own the Wails process directly.
177 cp "$cli_out" "$app/Contents/MacOS/$CLINAME"
178 bundle_executable=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$app/Contents/Info.plist")
179 [ "$bundle_executable" = "$BINNAME" ] || { echo "macOS bundle executable is $bundle_executable, want $BINNAME" >&2; exit 1; }
180 if [ -e "$app/Contents/MacOS/$GUARDNAME" ]; then
181 echo "macOS bundle must not include $GUARDNAME" >&2
182 exit 1
183 fi
184 bundle_icon=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIconFile" "$app/Contents/Info.plist")
185 case "$bundle_icon" in
186 *.icns) ;;
187 *) bundle_icon="$bundle_icon.icns" ;;
188 esac
189 darwin_icon="$ROOT/desktop/build/darwin/icon.icns"
190 [ -s "$darwin_icon" ] || { echo "macOS source icon is missing: $darwin_icon" >&2; exit 1; }
191 # Wails v2 always regenerates iconfile.icns from build/appicon.png. Replace it
192 # with the platform-specific asset before signing so the macOS safe area does
193 # not force the shared Windows/Linux artwork to shrink as well.
194 cp "$darwin_icon" "$app/Contents/Resources/$bundle_icon"
195 [ -s "$app/Contents/Resources/$bundle_icon" ] || { echo "macOS bundle icon is missing: $bundle_icon" >&2; exit 1; }
196 cmp -s "$darwin_icon" "$app/Contents/Resources/$bundle_icon" || { echo "macOS bundle icon replacement failed: $bundle_icon" >&2; exit 1; }
197
198 # Two signing paths, selected by HAS_APPLE_CERT (set by release-desktop.yml when
199 # the APPLE_* secrets are present). With a real Developer ID cert + notarization
200 # key we sign with a hardened runtime, notarize, and staple — a downloaded build
201 # then opens with no Gatekeeper prompt. Without it we ad-hoc sign as before (still
202 # un-notarized; users clear the quarantine attribute per desktop/README.md). The
203 # fallback keeps fork/local builds working with no secrets configured.
204 if [ "${HAS_APPLE_CERT:-}" = "true" ]; then
205 identity="$(security find-identity -v -p codesigning | awk -F'"' '/Developer ID Application/{print $2; exit}')"
206 [ -n "$identity" ] || { echo "HAS_APPLE_CERT=true but no 'Developer ID Application' identity found in the keychain" >&2; exit 1; }
207 echo "==> codesign (Developer ID): $identity"
208 codesign --force --deep --timestamp --options runtime \
209 --entitlements "$ROOT/desktop/build/darwin/entitlements.plist" \
210 -s "$identity" "$app"
211 # notarytool wants an archive, not a bare bundle: zip the .app, submit, wait,
212 # then staple the ticket back onto the bundle so it verifies offline.
213 ditto -c -k --keepParent "$app" "$staging/notarize.zip"
214 echo "==> notarytool submit (app)"
215 xcrun notarytool submit "$staging/notarize.zip" \
216 --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY_ID" \
217 --issuer "$APPLE_API_ISSUER_ID" --wait
218 xcrun stapler staple "$app"
219 else
220 # Ad-hoc cuts the "is damaged" error somewhat but is NOT notarized; users may
221 # still need `xattr -dr com.apple.quarantine` (see desktop/README.md).
222 codesign --force --deep -s - "$app"
223 fi
224
225 if [ "$arch" = universal ]; then
226 # One universal .app covers Intel + Apple Silicon; publish it under both
227 # manifest keys so the updater's darwin-arm64/darwin-amd64 lookup finds it
228 # (avoids a scarce macos-13 Intel runner).
229 ditto -c -k --keepParent "$app" "$ROOT/dist/${APPNAME}-darwin-arm64.zip"
230 ditto -c -k --keepParent "$app" "$ROOT/dist/${APPNAME}-darwin-amd64.zip"
231 else
232 ditto -c -k --keepParent "$app" "$ROOT/dist/${APPNAME}-darwin-${arch}.zip"
233 fi
234 if [ "${DESKTOP_BUILD_SKIP_DMG:-0}" = "1" ]; then
235 echo "==> skip DMG packaging (DESKTOP_BUILD_SKIP_DMG=1)"
236 else
237 # A drag-to-Applications .dmg for first-time human download. Named -universal so
238 # cmd/sign's substring match (darwin-arm64/darwin-amd64) skips it: the .zip stays
239 # the updater channel, the .dmg is release-page only. create-dmg can exit nonzero
240 # while still writing the image, so gate on the file existing, not the exit code.
241 dmgsrc=$(mktemp -d)
242 cp -R "$app" "$dmgsrc/${APPNAME}.app"
243 dmg="$ROOT/dist/${APPNAME}-darwin-universal.dmg"
244 create-dmg \
245 --volname "$APPNAME" \
246 --window-size 540 380 \
247 --icon-size 110 \
248 --icon "${APPNAME}.app" 150 190 \
249 --app-drop-link 390 190 \
250 --no-internet-enable \
251 "$dmg" "$dmgsrc" || true
252 [ -f "$dmg" ] || { echo "create-dmg did not produce $dmg" >&2; exit 1; }
253 # The .dmg is a separately-downloaded artifact, so sign + notarize + staple the
254 # disk image itself too — the stapled .app inside isn't enough for the image.
255 if [ "${HAS_APPLE_CERT:-}" = "true" ]; then
256 codesign --force --timestamp -s "$identity" "$dmg"
257 echo "==> notarytool submit (dmg)"
258 xcrun notarytool submit "$dmg" \
259 --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY_ID" \
260 --issuer "$APPLE_API_ISSUER_ID" --wait
261 xcrun stapler staple "$dmg"
262 fi
263 rm -rf "$dmgsrc"
264 fi
265 rm -rf "$staging"
266 ;;
267 windows)
268 # Keep one canonical flat payload for SignPath. The release workflow signs
269 # these files, then calls package-windows-desktop.sh again so both the
270 # portable archive and the files embedded by NSIS carry Authenticode.
271 payload_dir="$ROOT/desktop/build/windows/signing-payload"
272 rm -rf -- "$payload_dir"
273 mkdir -p "$payload_dir"
274 cp "build/bin/$BINNAME.exe" "$payload_dir/$BINNAME.exe"
275 cp "build/windows/installer/$UPDATE_HELPER" "$payload_dir/$UPDATE_HELPER"
276 cp "$launcher_out" "$payload_dir/$LAUNCHERNAME.exe"
277 cp "$guard_out" "$payload_dir/$GUARDNAME.exe"
278 cp "build/windows/installer/$WINDOWS_CLINAME.exe" "$payload_dir/$WINDOWS_CLINAME.exe"
279 cp "build/windows/installer/reasonix-uninstall.exe" "$payload_dir/reasonix-uninstall.exe"
280 "$ROOT/scripts/package-windows-desktop.sh" "$arch" "$payload_dir"
281 ;;
282 linux)
283 for desktop_contract in \
284 'Exec=reasonix-launcher' \
285 'Icon=reasonix-desktop' \
286 'StartupWMClass=reasonix-desktop'; do
287 grep -F -x -q "$desktop_contract" build/linux/reasonix.desktop || { echo "Linux desktop entry missing: $desktop_contract" >&2; exit 1; }
288 done
289 # Portable Linux tarball: desktop + thin launcher + one-shot migrator
290 # (compat name reasonix-guard) + CLI. After migrator runs, Guard self-deletes.
291 tar -czf "$ROOT/dist/${APPNAME}-linux-${arch}.tar.gz" -C build/bin \
292 "$BINNAME" "$LAUNCHERNAME" "$GUARDNAME" "$CLINAME"
293 # Build the privileged update helper shipped inside the .deb. Portable tarball
294 # installs do not need it; only the dpkg package installs helper + Polkit policy.
295 echo "==> go build reasonix-update-helper"
296 GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$VERSION" \
297 -o "build/bin/reasonix-update-helper" ./cmd/update-helper
298 # .deb for Debian/Ubuntu. Portable updater still uses the tarball under
299 # platforms[]; .deb is published under native_packages. Debian versions use
300 # "~" for prereleases so 1.18.0~rc.1 < 1.18.0 (policy version ordering).
301 # Extra "-" inside the prerelease label becomes "." (Debian policy).
302 ver_body="${VERSION#v}"
303 if [[ "$ver_body" == *-* ]]; then
304 deb_base="${ver_body%%-*}"
305 deb_pre="${ver_body#*-}"
306 deb_pre="${deb_pre//-/.}"
307 deb_version="${deb_base}~${deb_pre}"
308 else
309 deb_version="$ver_body"
310 fi
311 DEB_VERSION="$deb_version" DEB_ARCH="$arch" \
312 nfpm package --config build/linux/nfpm.yaml --packager deb \
313 --target "$ROOT/dist/${APPNAME}-linux-${arch}.deb"
314 # Contract smoke: helper, policy, package identity, and pkexec dependency.
315 deb_path="$ROOT/dist/${APPNAME}-linux-${arch}.deb"
316 dpkg-deb --field "$deb_path" Package | grep -x 'reasonix-desktop' >/dev/null
317 dpkg-deb --field "$deb_path" Version | grep -x "$deb_version" >/dev/null
318 dpkg-deb --field "$deb_path" Depends | grep -F 'pkexec' >/dev/null
319 dpkg-deb --contents "$deb_path" | grep -E 'usr/lib/reasonix/reasonix-update-helper' >/dev/null
320 dpkg-deb --contents "$deb_path" | grep -E 'usr/share/polkit-1/actions/io.reasonix.desktop.update.policy' >/dev/null
321 ;;
322 *)
323 echo "unsupported os: $os" >&2
324 exit 1
325 ;;
326 esac
327
328 echo "==> packaged into dist/:"
329 ls -la "$ROOT/dist"
330
330 lines BASH