diff --git a/.cargo/config.toml b/.cargo/config.toml index 35049cb..f22dc5d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,8 @@ [alias] xtask = "run --package xtask --" + +# Windows release binaries link the C runtime statically, so they run without the VC++ +# redistributable (packaging/windows/package.ps1 builds with --target, which keeps the flag +# off build scripts and proc-macros). +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index e5efce7..8cc7865 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -154,6 +154,7 @@ is committed and listed here with its exports. | assets/app-icon/icon.iconset/icon_256x256@2x.png | FieldCraft contributors | sips export (512 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 | | assets/app-icon/icon.iconset/icon_512x512.png | FieldCraft contributors | sips export (512 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 | | assets/app-icon/icon.iconset/icon_512x512@2x.png | FieldCraft contributors | sips export (1024 px) of assets/app-icon/icon.png | MIT OR Apache-2.0 | +| packaging/macos/README.txt, packaging/linux/io.github.jyviko.fieldcraft.desktop, packaging/macos/entitlements.plist | FieldCraft contributors | Original packaging text: the disk image's install note, the Linux desktop entry and the (empty) hardened-runtime entitlements | MIT OR Apache-2.0 | | crates/ui-egui/fonts/Inter-SemiBold.ttf | The Inter Project Authors | https://github.com/rsms/inter release v4.1 (Inter-4.1.zip, extras/ttf/Inter-SemiBold.ttf), unmodified; licence text in crates/ui-egui/fonts/OFL.txt; the interface's caps labels (theme::LABEL_FAMILY) | SIL OFL 1.1 | ## Adapted code @@ -171,6 +172,9 @@ is committed and listed here with its exports. | apps/fieldcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 | | crates/mcp/src/ (JSON-RPC framing, MCP lifecycle, tool schema checks, the control-channel backend, base64) | SolidCraft `crates/mcp/src/` | MIT OR Apache-2.0 | | crates/io/src/roots.rs (the rules of read and write roots) | VectorCraft's automation roots (docs/mcp.md there) | MIT OR Apache-2.0 | +| packaging/env.sh, packaging/macos/package.sh, packaging/macos/Info.plist.in, packaging/linux/package.sh, packaging/linux/AppRun, packaging/windows/package.ps1 | SolidCraft `packaging/` and VectorCraft `packaging/` (signing and notarization in macos/package.sh) | MIT OR Apache-2.0 | +| packaging/macos/import-cert.sh | VectorCraft `packaging/macos/import-cert.sh` | MIT OR Apache-2.0 | +| packaging/windows/check-exe.ps1 | SolidCraft `packaging/windows/check-icon.ps1` | MIT OR Apache-2.0 | ## Test oracles diff --git a/packaging/env.sh b/packaging/env.sh new file mode 100644 index 0000000..76c392c --- /dev/null +++ b/packaging/env.sh @@ -0,0 +1,58 @@ +# shellcheck shell=bash +# Shared setup for the packaging scripts. Source it: `. "$(dirname "$0")/../env.sh"`. +# Adapted from SolidCraft's and VectorCraft's packaging (MIT OR Apache-2.0; see ATTRIBUTION.md). +# +# Exports: +# ROOT workspace root +# VERSION [workspace.package] version from Cargo.toml (override: FIELDCRAFT_VERSION) +# DIST output directory for release artifacts (default: $ROOT/dist/release) +# FIELDCRAFT_BUILD_SHA the commit being packaged (recorded in the macOS Info.plist) +# CARGO_TARGET_DIR cargo's target dir (default: $ROOT/target) +# APP_ID the reverse-DNS id: macOS bundle id, Linux .desktop and icon name, and +# the window's Wayland app_id / X11 class (apps/fieldcraft/src/main.rs) + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export ROOT + +# The version lives in one place: `[workspace.package] version` in the root Cargo.toml. +workspace_version() { + awk ' + /^\[/ { in_pkg = ($0 == "[workspace.package]") ; next } + in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit } + ' "$ROOT/Cargo.toml" +} + +VERSION="${FIELDCRAFT_VERSION:-$(workspace_version)}" +if [ -z "$VERSION" ]; then + echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 + exit 1 +fi +export VERSION + +DIST="${DIST:-$ROOT/dist/release}" +mkdir -p "$DIST" +DIST="$(cd "$DIST" && pwd)" +export DIST + +FIELDCRAFT_BUILD_SHA="${FIELDCRAFT_BUILD_SHA:-$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo unknown)}" +export FIELDCRAFT_BUILD_SHA +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" +APP_ID=io.github.jyviko.fieldcraft +export APP_ID + +# Emit a GitHub Actions warning (plain stderr outside Actions). +warn() { + if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi +} + +# The readme, licence, notice and third-party licence files every package carries +# (packaging/windows/package.ps1 has the same list). +DOC_FILES=(README.md LICENSE-MIT LICENSE-APACHE NOTICE THIRD-PARTY-LICENSES.txt) + +# Copy DOC_FILES into a package directory. +copy_docs() { + local dest="$1" f + for f in "${DOC_FILES[@]}"; do + cp "$ROOT/$f" "$dest/" + done +} diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun new file mode 100755 index 0000000..0e3c197 --- /dev/null +++ b/packaging/linux/AppRun @@ -0,0 +1,6 @@ +#!/bin/sh +# FieldCraft AppImage entry point (packaging/linux/AppRun; installed by package.sh): run the app +# from the AppImage's own tree. +set -eu +APPDIR="$(dirname "$(readlink -f "$0")")" +exec "$APPDIR/usr/bin/fieldcraft" "$@" diff --git a/packaging/linux/io.github.jyviko.fieldcraft.desktop b/packaging/linux/io.github.jyviko.fieldcraft.desktop new file mode 100644 index 0000000..ef2c89d --- /dev/null +++ b/packaging/linux/io.github.jyviko.fieldcraft.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Type=Application +Name=FieldCraft +GenericName=Finite Element Modeler +Comment=Turn geometry into finite element models: mesh, set up, solve and export decks +Exec=fieldcraft %F +TryExec=fieldcraft +Icon=io.github.jyviko.fieldcraft +Terminal=false +StartupNotify=true +StartupWMClass=io.github.jyviko.fieldcraft +Categories=Science;Engineering; +Keywords=cae;fea;fem;finite element;mesh;hex;tet;solver;abaqus;nastran;ls-dyna; diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh new file mode 100755 index 0000000..c29b819 --- /dev/null +++ b/packaging/linux/package.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Build and package FieldCraft for Linux x86_64: +# +# $DIST/fieldcraft--linux-x86_64.tar.gz bin/fieldcraft, bin/fieldcraft-cli, the +# .desktop entry, icons and licences +# $DIST/fieldcraft--linux-x86_64.AppImage the app as one file (chmod +x, run) +# +# Usage: packaging/linux/package.sh [--skip-build] [--formats "tar appimage"] +# +# Build on an old distribution (CI: Ubuntu 22.04, glibc 2.35) so the binaries run on newer ones. +# The AppImage uses appimagetool and the type 2 runtime at pinned releases, checked against their +# SHA-256 (downloaded into $CARGO_TARGET_DIR). Optional: desktop-file-validate. +# Adapted from SolidCraft's packaging/linux/package.sh (MIT OR Apache-2.0; see ATTRIBUTION.md). +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" +ICONSET="$ROOT/assets/app-icon/icon.iconset" + +SKIP_BUILD=0 +FORMATS="tar appimage" +while [ $# -gt 0 ]; do + case "$1" in + --skip-build) SKIP_BUILD=1; shift ;; + --formats) FORMATS="$2"; shift 2 ;; + -h | --help) sed -n '2,13p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } + +ARCH="$(uname -m)" +# The AppImage tools, pinned (github.com/AppImage/appimagetool, github.com/AppImage/type2-runtime; +# MIT). A version bump changes the SHA-256 below too. +APPIMAGETOOL_VERSION=1.9.1 +RUNTIME_VERSION=20251108 +case "$ARCH" in + x86_64) + APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d + ;; + *) echo "unsupported architecture $ARCH (release builds are x86_64)" >&2; exit 2 ;; +esac +APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" +RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-$ARCH" +BASENAME="fieldcraft-$VERSION-linux-$ARCH" + +echo "==> FieldCraft $VERSION for Linux $ARCH ($FORMATS)" +if [ "$SKIP_BUILD" = 0 ]; then + (cd "$ROOT" && cargo build --release --locked -p fieldcraft -p fieldcraft-cli) +fi +BIN="$CARGO_TARGET_DIR/release" +WORK="$CARGO_TARGET_DIR/linux-package" +STAGE="$WORK/root/usr" +rm -rf "$WORK" + +# ---- an FHS tree, shared by both formats -------------------------------------------------------- +install -Dm755 "$BIN/fieldcraft" "$STAGE/bin/fieldcraft" +install -Dm755 "$BIN/fieldcraft-cli" "$STAGE/bin/fieldcraft-cli" +strip "$STAGE/bin/fieldcraft" "$STAGE/bin/fieldcraft-cli" +install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/share/applications/$APP_ID.desktop" +# hicolor icons: . +while read -r size file; do + install -Dm644 "$ICONSET/$file" "$STAGE/share/icons/hicolor/${size}x${size}/apps/$APP_ID.png" +done <<'ICONS' +16 icon_16x16.png +32 icon_32x32.png +64 icon_32x32@2x.png +128 icon_128x128.png +256 icon_256x256.png +512 icon_512x512.png +1024 icon_512x512@2x.png +ICONS +mkdir -p "$STAGE/share/doc/fieldcraft" +copy_docs "$STAGE/share/doc/fieldcraft" +if command -v desktop-file-validate >/dev/null; then + desktop-file-validate "$STAGE/share/applications/$APP_ID.desktop" +fi + +# ---- .tar.gz ------------------------------------------------------------------------------------ +if has tar; then + mkdir -p "$WORK/tar" + cp -R "$STAGE" "$WORK/tar/$BASENAME" + tar -C "$WORK/tar" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME" + echo "wrote $DIST/$BASENAME.tar.gz" +fi + +# ---- AppImage ----------------------------------------------------------------------------------- +# Usage: fetch ; downloads once and checks the hash every time. +fetch() { + if [ ! -s "$3" ]; then curl -fsSL -o "$3.part" "$1" && mv "$3.part" "$3"; fi + if ! echo "$2 $3" | sha256sum --check --status; then + echo "error: $3 does not match its pinned SHA-256 ($1)" >&2 + rm -f "$3" + exit 1 + fi +} +if has appimage; then + TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" + RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$RUNTIME_VERSION-$ARCH" + fetch "$APPIMAGETOOL_URL" "$APPIMAGETOOL_SHA256" "$TOOL" + fetch "$RUNTIME_URL" "$RUNTIME_SHA256" "$RUNTIME" + chmod +x "$TOOL" + + APPDIR="$WORK/FieldCraft.AppDir" + mkdir -p "$APPDIR" + cp -R "$STAGE" "$APPDIR/usr" + install -Dm755 "$HERE/AppRun" "$APPDIR/AppRun" + cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" + cp "$ICONSET/icon_256x256.png" "$APPDIR/$APP_ID.png" + ln -s "$APP_ID.png" "$APPDIR/.DirIcon" + OUT="$DIST/$BASENAME.AppImage" + rm -f "$OUT" + # Extract-and-run: appimagetool is itself an AppImage, and CI runners have no FUSE. + ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream --runtime-file "$RUNTIME" "$APPDIR" "$OUT" + echo "wrote $OUT" +fi + +"$STAGE/bin/fieldcraft-cli" --version +echo "==> done" +ls -lh "$DIST" diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in new file mode 100644 index 0000000..907c3a6 --- /dev/null +++ b/packaging/macos/Info.plist.in @@ -0,0 +1,50 @@ + + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + FieldCraft + CFBundleName + FieldCraft + CFBundleExecutable + FieldCraft + CFBundleIdentifier + @APP_ID@ + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleShortVersionString + @SHORT_VERSION@ + CFBundleVersion + @SHORT_VERSION@ + FieldCraftVersion + @VERSION@ + FieldCraftBuildCommit + @BUILD_SHA@ + + CFBundleIconFile + FieldCraft + CFBundleIconName + FieldCraft + LSApplicationCategoryType + public.app-category.graphics-design + LSMinimumSystemVersion + @MIN_MACOS@ + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + NSHumanReadableCopyright + Copyright © FieldCraft contributors. MIT OR Apache-2.0. + + diff --git a/packaging/macos/README.txt b/packaging/macos/README.txt new file mode 100644 index 0000000..f81ca8c --- /dev/null +++ b/packaging/macos/README.txt @@ -0,0 +1,13 @@ +Install FieldCraft + +Drag FieldCraft.app onto Applications. + +If this build is not notarized (the release notes say so), macOS blocks the first launch: +right-click FieldCraft.app in Applications and choose Open, then Open again. Or run + + xattr -dr com.apple.quarantine /Applications/FieldCraft.app + +Updating: quit FieldCraft, drag FieldCraft.app from this disk image onto Applications and +choose Replace. + +The command-line tool, fieldcraft-cli, is a separate download (fieldcraft-cli-*-macos-*.zip). diff --git a/packaging/macos/entitlements.plist b/packaging/macos/entitlements.plist new file mode 100644 index 0000000..816cfcb --- /dev/null +++ b/packaging/macos/entitlements.plist @@ -0,0 +1,9 @@ + + + + + + + diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh new file mode 100755 index 0000000..77e6237 --- /dev/null +++ b/packaging/macos/import-cert.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# CI only: import the Developer ID certificate into a temporary keychain so codesign can use it. +# +# Adapted from VectorCraft's packaging/macos/import-cert.sh (MIT OR Apache-2.0; see ATTRIBUTION.md). +# +# Inputs (env): APPLE_CERTIFICATE (base64 .p12), APPLE_CERTIFICATE_PASSWORD, KEYCHAIN_PASSWORD +# (optional; random if unset), RUNNER_TEMP (or TMPDIR). +# Outputs: MACOS_SIGN_IDENTITY (SHA-1 of the identity) and MACOS_KEYCHAIN, appended to +# $GITHUB_ENV when running in Actions, otherwise printed. +# +# Missing certificate: prints a warning and exits 0, so package.sh falls back to ad-hoc signing. +set -euo pipefail +umask 077 +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" + +if [ -z "${APPLE_CERTIFICATE:-}" ]; then + warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" + exit 0 +fi + +TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +KEYCHAIN="$TMP/fieldcraft-signing.keychain-db" +CERT="$TMP/fieldcraft-signing.p12" +KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" + +trap 'rm -f "$CERT"' EXIT +printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT" +security create-keychain -p "$KC_PASS" "$KEYCHAIN" +security set-keychain-settings -lut 21600 "$KEYCHAIN" +security unlock-keychain -p "$KC_PASS" "$KEYCHAIN" +security import "$CERT" -P "${APPLE_CERTIFICATE_PASSWORD:-}" -T /usr/bin/codesign -T /usr/bin/security -t cert -f pkcs12 -k "$KEYCHAIN" +rm -f "$CERT" +# Let codesign use the key without a UI prompt. +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$KEYCHAIN" >/dev/null +# Put it on the search list (keeping the login keychain) so codesign and notarytool find it. +# shellcheck disable=SC2046 +security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + +IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" +if [ -z "$IDENTITY" ]; then + security find-identity -v -p codesigning "$KEYCHAIN" >&2 + echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 + exit 1 +fi +echo "Imported signing identity $IDENTITY" +if [ -n "${GITHUB_ENV:-}" ]; then + { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" +else + echo "MACOS_SIGN_IDENTITY=$IDENTITY" + echo "MACOS_KEYCHAIN=$KEYCHAIN" +fi diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh new file mode 100755 index 0000000..384dde9 --- /dev/null +++ b/packaging/macos/package.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# Build, sign and (with credentials) notarize FieldCraft for macOS: +# +# $DIST/fieldcraft--macos-.dmg FieldCraft.app on a drag-to-Applications DMG +# $DIST/fieldcraft-cli--macos-.zip fieldcraft-cli with the licence files +# +# Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build] +# +# Signing (env): +# MACOS_SIGN_IDENTITY codesign identity (name or SHA-1). Default "-": ad-hoc, which Gatekeeper +# blocks on other Macs until the user allows it. In CI, import-cert.sh sets +# it from the APPLE_CERTIFICATE secret. +# MACOS_KEYCHAIN keychain holding the identity (optional) +# Notarization (env; all three, and a real identity): APPLE_ID, APPLE_PASSWORD (an app-specific +# password), APPLE_TEAM_ID. +# +# Writes `signing=notarized|signed|adhoc` to $GITHUB_OUTPUT when run in GitHub Actions. +# Adapted from SolidCraft's and VectorCraft's packaging/macos/package.sh (MIT OR Apache-2.0; see +# ATTRIBUTION.md). +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/macos" +ICONS="$ROOT/assets/app-icon" + +ARCH=universal +SKIP_BUILD=0 +while [ $# -gt 0 ]; do + case "$1" in + --arch) ARCH="$2"; shift 2 ;; + --skip-build) SKIP_BUILD=1; shift ;; + -h | --help) sed -n '2,17p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +case "$ARCH" in + universal) TARGETS=(aarch64-apple-darwin x86_64-apple-darwin); WANT=(arm64 x86_64) ;; + aarch64) TARGETS=(aarch64-apple-darwin); WANT=(arm64) ;; + x86_64) TARGETS=(x86_64-apple-darwin); WANT=(x86_64) ;; + *) echo "unknown --arch $ARCH" >&2; exit 2 ;; +esac + +# The oldest macOS the binaries run on; also the Info.plist's LSMinimumSystemVersion. +export MACOSX_DEPLOYMENT_TARGET=11.0 +IDENTITY="${MACOS_SIGN_IDENTITY:--}" +SHORT_VERSION="${VERSION%%-*}" +WORK="$CARGO_TARGET_DIR/macos-package" +APP="$WORK/FieldCraft.app" +DMG="$DIST/fieldcraft-$VERSION-macos-$ARCH.dmg" +CLI_ZIP="$DIST/fieldcraft-cli-$VERSION-macos-$ARCH.zip" + +NOTARIZE=0 +SIGNING=adhoc +if [ "$IDENTITY" = "-" ]; then + warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); the app is not notarized" +elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then + NOTARIZE=1 + SIGNING=notarized +else + SIGNING=signed + warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" +fi + +echo "==> FieldCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" + +# ---- build ------------------------------------------------------------------------------------- +if [ "$SKIP_BUILD" = 0 ]; then + args=() + for t in "${TARGETS[@]}"; do args+=(--target "$t"); done + (cd "$ROOT" && cargo build --release --locked -p fieldcraft -p fieldcraft-cli "${args[@]}") +fi + +rm -rf "$WORK" +mkdir -p "$WORK/bin" +for bin in fieldcraft fieldcraft-cli; do + inputs=() + for t in "${TARGETS[@]}"; do inputs+=("$CARGO_TARGET_DIR/$t/release/$bin"); done + lipo -create -output "$WORK/bin/$bin" "${inputs[@]}" + lipo -info "$WORK/bin/$bin" +done + +# ---- signing helpers --------------------------------------------------------------------------- +sign() { + # Hardened runtime and a secure timestamp for a real identity; ad-hoc can't be timestamped. + local ts=(--timestamp) + [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + local kc=() + [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" +} + +notarize() { + local file="$1" out id status + echo "==> notarizing $(basename "$file") (this can take a few minutes)" + out="$(xcrun notarytool submit "$file" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" --wait --timeout 1h --output-format json)" || true + echo "$out" + id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" + status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" + if [ "$status" != "Accepted" ]; then + if [ -n "$id" ]; then + xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true + fi + echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 + exit 1 + fi +} + +# hdiutil attaches a device while it works, which fails now and then on CI runners ("Resource +# busy"). Usage: hdiutil_retry ; five tries with growing pauses. +hdiutil_retry() { + local what="$1" attempt + for attempt in 1 2 3 4 5; do + if hdiutil "$@"; then return 0; fi + [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + echo "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" >&2 + sleep $((attempt * 10)) + done +} + +# ---- FieldCraft.app ---------------------------------------------------------------------------- +echo "==> assembling $APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources/Licenses" +# The executable and the icons carry the display name (CFBundleExecutable, CFBundleIconFile, +# CFBundleIconName). Assets.car holds the layered icon for macOS 26 and later, the .icns the +# icon for earlier versions (assets/app-icon/README.md). +cp "$WORK/bin/fieldcraft" "$APP/Contents/MacOS/FieldCraft" +cp "$ICONS/icon.icns" "$APP/Contents/Resources/FieldCraft.icns" +cp "$ICONS/Assets.car" "$APP/Contents/Resources/Assets.car" +copy_docs "$APP/Contents/Resources/Licenses" +sed -e "s/@APP_ID@/$APP_ID/g" -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ + -e "s/@BUILD_SHA@/$FIELDCRAFT_BUILD_SHA/g" -e "s/@MIN_MACOS@/$MACOSX_DEPLOYMENT_TARGET/g" "$HERE/Info.plist.in" >"$APP/Contents/Info.plist" +plutil -lint "$APP/Contents/Info.plist" +printf 'APPL????' >"$APP/Contents/PkgInfo" + +# Sign inside-out: the executable, then the bundle (no --deep on the bundle's own signature). +sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/FieldCraft" +sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" +codesign --verify --strict --deep --verbose=2 "$APP" +codesign -dv "$APP" + +if [ "$NOTARIZE" = 1 ]; then + ditto -c -k --keepParent "$APP" "$WORK/FieldCraft-notarize.zip" + notarize "$WORK/FieldCraft-notarize.zip" + xcrun stapler staple "$APP" + xcrun stapler validate "$APP" + spctl --assess --type execute -vvv "$APP" +fi + +# ---- DMG --------------------------------------------------------------------------------------- +echo "==> building $DMG" +STAGE="$WORK/dmg" +mkdir -p "$STAGE" +ditto "$APP" "$STAGE/FieldCraft.app" +ln -s /Applications "$STAGE/Applications" +cp "$HERE/README.txt" "$STAGE/README.txt" +rm -f "$DMG" +# `create -srcfolder` writes the files as they are; `makehybrid -hfs` gives every file a +# com.apple.FinderInfo, which `codesign --verify --strict` rejects (VectorCraft found this). +hdiutil_retry create -srcfolder "$STAGE" -volname "FieldCraft" -fs HFS+ -format UDZO \ + -imagekey zlib-level=9 -ov "$DMG" +sign "$DMG" +codesign --verify --strict --verbose=2 "$DMG" + +# Check the image as a user gets it: mount it and look inside. +MNT="$WORK/dmg-check" +rm -rf "$MNT" +mkdir -p "$MNT" +hdiutil_retry attach -nobrowse -readonly -mountpoint "$MNT" "$DMG" >/dev/null +# Detach on the way out, also when a check below fails and ends the script. +trap 'hdiutil detach "$MNT" >/dev/null 2>&1 || true' EXIT +M_APP="$MNT/FieldCraft.app" +M_PLIST="$M_APP/Contents/Info.plist" +test -L "$MNT/Applications" +test "$(readlink "$MNT/Applications")" = /Applications +cmp "$HERE/README.txt" "$MNT/README.txt" +plutil -lint "$M_PLIST" +for key in CFBundleName CFBundleDisplayName CFBundleExecutable CFBundleIconFile CFBundleIconName; do + test "$(/usr/libexec/PlistBuddy -c "Print :$key" "$M_PLIST")" = FieldCraft +done +test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$M_PLIST")" = "$APP_ID" +test "$(/usr/libexec/PlistBuddy -c 'Print :FieldCraftVersion' "$M_PLIST")" = "$VERSION" +cmp "$ICONS/icon.icns" "$M_APP/Contents/Resources/FieldCraft.icns" +cmp "$ICONS/Assets.car" "$M_APP/Contents/Resources/Assets.car" +for f in "${DOC_FILES[@]}"; do + test -s "$M_APP/Contents/Resources/Licenses/$f" +done +for arch in "${WANT[@]}"; do lipo "$M_APP/Contents/MacOS/FieldCraft" -verify_arch "$arch"; done +codesign --verify --strict --deep --verbose=2 "$M_APP" +"$M_APP/Contents/MacOS/FieldCraft" --version +hdiutil_retry detach "$MNT" >/dev/null +trap - EXIT +if [ "$NOTARIZE" = 1 ]; then + notarize "$DMG" + xcrun stapler staple "$DMG" + xcrun stapler validate "$DMG" + spctl --assess --type open --context context:primary-signature -vvv "$DMG" +fi + +# ---- CLI --------------------------------------------------------------------------------------- +echo "==> building $CLI_ZIP" +CLI_DIR="$WORK/fieldcraft-cli-$VERSION-macos-$ARCH" +mkdir -p "$CLI_DIR" +cp "$WORK/bin/fieldcraft-cli" "$CLI_DIR/" +copy_docs "$CLI_DIR" +sign --options runtime --identifier "$APP_ID.cli" "$CLI_DIR/fieldcraft-cli" +codesign --verify --strict --verbose=2 "$CLI_DIR/fieldcraft-cli" +rm -f "$CLI_ZIP" +ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" +# A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online. +if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi + +"$CLI_DIR/fieldcraft-cli" --version +if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "signing=$SIGNING" >>"$GITHUB_OUTPUT"; fi +echo "==> done (signing: $SIGNING)" +ls -lh "$DMG" "$CLI_ZIP" diff --git a/packaging/windows/check-exe.ps1 b/packaging/windows/check-exe.ps1 new file mode 100644 index 0000000..61447ec --- /dev/null +++ b/packaging/windows/check-exe.ps1 @@ -0,0 +1,76 @@ +# Check an x64 executable: its PE machine, its subsystem (2 = GUI, so the app opens no console +# window; 3 = console, so the CLI's output reaches the terminal) and, with -Icon, a well-formed +# embedded application icon (RT_GROUP_ICON referring to RT_ICON images). +# Adapted from SolidCraft's packaging/windows/check-icon.ps1 (MIT OR Apache-2.0; see ATTRIBUTION.md). +param([Parameter(Mandatory)][string] $Path, [Parameter(Mandatory)][int] $Subsystem, [switch] $Icon) +$ErrorActionPreference = 'Stop' +$bytes = [IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $Path)) +function U16([int] $At) { if ($At -lt 0 -or $At + 2 -gt $bytes.Length) { throw 'truncated PE' }; [BitConverter]::ToUInt16($bytes, $At) } +function U32([int] $At) { if ($At -lt 0 -or $At + 4 -gt $bytes.Length) { throw 'truncated PE' }; [BitConverter]::ToUInt32($bytes, $At) } +if ((U16 0) -ne 0x5A4D) { throw 'not an executable' } +$pe = U32 0x3C +if ((U32 $pe) -ne 0x4550) { throw 'missing PE signature' } +$optional = $pe + 24 +$magic = U16 $optional +if ((U16 ($pe + 4)) -ne 0x8664) { throw "$Path is for machine 0x$('{0:X}' -f (U16 ($pe + 4))), expected 0x8664 (x64)" } +if ((U16 ($optional + 68)) -ne $Subsystem) { throw "$Path has PE subsystem $(U16 ($optional + 68)), expected $Subsystem" } +Write-Output "ok x64, PE subsystem ${Subsystem}: $Path" +if (-not $Icon) { return } +$directories = switch ($magic) { 0x10B { $optional + 96 }; 0x20B { $optional + 112 }; default { throw 'unknown PE format' } } +$resourceRva = U32 ($directories + 16) +$resourceSize = U32 ($directories + 20) +if (!$resourceRva -or !$resourceSize) { throw 'executable has no resources' } +$sections = $optional + (U16 ($pe + 20)) +function RvaOffset([uint32] $Rva) { + for ($i = 0; $i -lt (U16 ($pe + 6)); $i++) { + $section = $sections + 40 * $i + $start = U32 ($section + 12) + $rawSize = U32 ($section + 16) + if ($Rva -ge $start -and [uint64]$Rva -lt [uint64]$start + $rawSize) { return (U32 ($section + 20)) + ($Rva - $start) } + } + throw 'resource RVA outside file sections' +} +$base = RvaOffset $resourceRva +function Entries([uint32] $Relative) { + if ([uint64]$Relative + 16 -gt $resourceSize) { throw 'resource directory outside bounds' } + $at = $base + $Relative + $count = (U16 ($at + 12)) + (U16 ($at + 14)) + if ([uint64]$Relative + 16 + 8 * $count -gt $resourceSize) { throw 'resource entries outside bounds' } + for ($i = 0; $i -lt $count; $i++) { + $entry = $at + 16 + 8 * $i + @{ Name = (U32 $entry); Offset = (U32 ($entry + 4)) } + } +} +function Payloads($Entry) { + if (!( $Entry.Offset -band 0x80000000L )) { throw 'expected resource directory' } + foreach ($name in @(Entries ($Entry.Offset -band 0x7FFFFFFFL))) { + if (!( $name.Offset -band 0x80000000L )) { throw 'expected resource language directory' } + foreach ($language in @(Entries ($name.Offset -band 0x7FFFFFFFL))) { + if ($language.Offset -band 0x80000000L) { throw 'unexpected nested resource directory' } + if ([uint64]$language.Offset + 16 -gt $resourceSize) { throw 'resource data outside bounds' } + $data = $base + $language.Offset + $offset = RvaOffset (U32 $data) + $length = U32 ($data + 4) + if (!$length -or [uint64]$offset + $length -gt $bytes.Length) { throw 'empty or truncated icon resource' } + @{ Id = $name.Name; At = $offset; Length = $length } + } + } +} +$root = @(Entries 0) +$iconType = @($root | Where-Object Name -eq 3) +$groupType = @($root | Where-Object Name -eq 14) +if ($iconType.Count -ne 1 -or $groupType.Count -ne 1) { throw 'missing RT_ICON or RT_GROUP_ICON' } +$icons = @(Payloads $iconType[0]) +$groups = @(Payloads $groupType[0]) +foreach ($group in $groups) { + if ((U16 $group.At) -ne 0 -or (U16 ($group.At + 2)) -ne 1) { throw 'invalid icon group' } + $count = U16 ($group.At + 4) + if (!$count -or 6 + 14 * $count -gt $group.Length) { throw 'empty or truncated icon group' } + for ($i = 0; $i -lt $count; $i++) { + $entry = $group.At + 6 + 14 * $i + $id = U16 ($entry + 12) + $length = U32 ($entry + 8) + if (!@($icons | Where-Object { $_.Id -eq $id -and $_.Length -eq $length }).Count) { throw 'icon group refers to missing image' } + } +} +Write-Output "ok embedded app icon: $($icons.Count) images, $($groups.Count) group(s): $Path" diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 new file mode 100644 index 0000000..62a8e5c --- /dev/null +++ b/packaging/windows/package.ps1 @@ -0,0 +1,71 @@ +<# +.SYNOPSIS + Build and package FieldCraft for Windows x64. + +.DESCRIPTION + Produces, in $env:DIST (default: dist/release): + fieldcraft--windows-x64.zip fieldcraft.exe, fieldcraft-cli.exe, README.md and the + licence files (LICENSE-MIT, LICENSE-APACHE, NOTICE, + THIRD-PARTY-LICENSES.txt) + + The binaries link the C runtime statically (+crt-static, .cargo/config.toml), so nothing else + needs installing. + fieldcraft.exe carries the application icon (assets/app-icon/icon.ico, embedded by + apps/fieldcraft/build.rs). Nothing is signed. + + Adapted from SolidCraft's packaging/windows/package.ps1 (MIT OR Apache-2.0; see ATTRIBUTION.md). + +.EXAMPLE + pwsh packaging/windows/package.ps1 +#> +param([switch] $SkipBuild) +$ErrorActionPreference = 'Stop' +$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + +function Invoke-Native([string] $What, [scriptblock] $Block) { + Write-Output "==> $What" + & $Block + if ($LASTEXITCODE -ne 0) { throw "$What failed with exit code $LASTEXITCODE" } +} + +# The version: FIELDCRAFT_VERSION (CI sets it), else [workspace.package] version in Cargo.toml. +$Version = $env:FIELDCRAFT_VERSION +if (-not $Version) { + $meta = cargo metadata --no-deps --format-version 1 --manifest-path (Join-Path $Root 'Cargo.toml') | ConvertFrom-Json + $Version = ($meta.packages | Where-Object name -eq 'fieldcraft').version +} +if (-not $Version) { throw 'could not read [workspace.package] version from Cargo.toml' } + +$Arch = 'x64' +$Target = 'x86_64-pc-windows-msvc' +$Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' } +$TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' } +New-Item -ItemType Directory -Force -Path $Dist | Out-Null +Write-Output "FieldCraft $Version for Windows $Arch ($Target)" + +if (-not $SkipBuild) { + Invoke-Native "cargo build ($Target)" { cargo build --release --locked -p fieldcraft -p fieldcraft-cli --target $Target } +} +$Bin = Join-Path $TargetDir "$Target\release" + +$Check = Join-Path $PSScriptRoot 'check-exe.ps1' +& $Check -Path (Join-Path $Bin 'fieldcraft.exe') -Subsystem 2 -Icon +& $Check -Path (Join-Path $Bin 'fieldcraft-cli.exe') -Subsystem 3 + +# ---- zip --------------------------------------------------------------------------------------- +$Name = "fieldcraft-$Version-windows-$Arch" +$Stage = Join-Path $TargetDir "windows-package\$Name" +Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $Stage | Out-Null +Copy-Item (Join-Path $Bin 'fieldcraft.exe'), (Join-Path $Bin 'fieldcraft-cli.exe') $Stage +# The files copy_docs (packaging/env.sh) puts in every package. +foreach ($f in 'README.md', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'THIRD-PARTY-LICENSES.txt') { Copy-Item (Join-Path $Root $f) $Stage } +$Zip = Join-Path $Dist "$Name.zip" +Remove-Item -Force $Zip -ErrorAction SilentlyContinue +# Windows' own bsdtar writes the zip (-a: the format from the name; Compress-Archive is slow). By +# full path, as Git for Windows puts a GNU tar without zip support on the PATH. +$Tar = Join-Path $env:SystemRoot 'System32\tar.exe' +Invoke-Native 'zip' { & $Tar -a -cf $Zip -C (Split-Path $Stage) $Name } + +Invoke-Native 'fieldcraft-cli --version' { & (Join-Path $Stage 'fieldcraft-cli.exe') --version } +Get-Item $Zip | Format-Table Name, Length