diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93705b1..22b1b37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,7 +81,7 @@ jobs: - name: Install dependencies run: | - brew install gtk4 pkg-config dylibbundler + brew install gtk4 pkg-config - name: Build .app zip env: diff --git a/Makefile b/Makefile index 028f689..8ed138f 100644 --- a/Makefile +++ b/Makefile @@ -79,6 +79,14 @@ bundle: $(TARGET) @cp -R share dist/ @ldd $(TARGET) | grep -Ei '/(ucrt64|mingw64)/bin/' | awk '{print $$3}' \ | xargs -r -I{} cp -f {} dist/ + @# GTK runtime data (schemas / pixbuf loaders) next to the exe. + @GTK_PREFIX="$$($(PKG_CONFIG) --variable=prefix gtk4)"; \ + if [ -d "$$GTK_PREFIX/share/glib-2.0" ]; then \ + mkdir -p dist/share && cp -R "$$GTK_PREFIX/share/glib-2.0" dist/share/; \ + fi; \ + if [ -d "$$GTK_PREFIX/lib/gdk-pixbuf-2.0" ]; then \ + mkdir -p dist/lib && cp -R "$$GTK_PREFIX/lib/gdk-pixbuf-2.0" dist/lib/; \ + fi @echo "Bundled into dist/ - run dist/$(TARGET)" else ifeq ($(UNAME_S),Darwin) bundle: diff --git a/scripts/bundle-macos.sh b/scripts/bundle-macos.sh index 4c68164..0269111 100755 --- a/scripts/bundle-macos.sh +++ b/scripts/bundle-macos.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Build a self-contained macOS .app with GTK dylibs relocated into the bundle. -# Requires: make, pkg-config, GTK4 (Homebrew), dylibbundler (brew install dylibbundler). +# Requires: make, pkg-config, GTK4 (Homebrew). set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -21,13 +21,9 @@ APP="$ROOT/dist/$APP_NAME" MACOS="$APP/Contents/MacOS" RES="$APP/Contents/Resources" FW="$APP/Contents/Frameworks" -ICON_DST="assets/icons/hicolor/512x512/apps/maturita.png" +ICON_512="$ROOT/share/icons/hicolor/512x512/apps/maturita.png" OUT_ZIP="maturita-macos-${ARCH_TAG}.zip" - -if ! command -v dylibbundler >/dev/null 2>&1; then - echo "dylibbundler not found. Install with: brew install dylibbundler" >&2 - exit 1 -fi +BREW_PREFIX="$(brew --prefix 2>/dev/null || echo /opt/homebrew)" echo "==> Building maturita" make -C "$ROOT" clean @@ -39,34 +35,144 @@ mkdir -p "$MACOS" "$RES/icons/hicolor/512x512/apps" "$RES/share" "$FW" cp -f "$ROOT/maturita" "$MACOS/maturita-bin" cp -f "$ROOT/style.css" "$RES/style.css" -cp -f "$ROOT/$ICON_DST" "$RES/icons/hicolor/512x512/apps/maturita.png" +cp -f "$ICON_512" "$RES/icons/hicolor/512x512/apps/maturita.png" cp -R "$ROOT/share/." "$RES/share/" -# Bundle Homebrew / MacPorts GTK stack next to the binary. -echo "==> Relocating dylibs with dylibbundler" -BREW_PREFIX="$(brew --prefix 2>/dev/null || true)" -DYLIB_SEARCH=() -if [[ -n "$BREW_PREFIX" ]]; then - DYLIB_SEARCH+=(-s "$BREW_PREFIX/lib") - # Common keg-only / versioned lib dirs dylibbundler otherwise prompts for. - for sub in librsvg glib gettext; do - [[ -d "$BREW_PREFIX/opt/$sub/lib" ]] && DYLIB_SEARCH+=(-s "$BREW_PREFIX/opt/$sub/lib") +# Resolve an absolute Homebrew-style dependency to a real file on disk. +resolve_lib() { + local dep="$1" + local candidate + + if [[ -f "$dep" ]]; then + realpath "$dep" + return 0 + fi + + # Try common Homebrew layouts for the basename. + local base + base="$(basename "$dep")" + for candidate in \ + "$BREW_PREFIX/lib/$base" \ + "$BREW_PREFIX/opt"/*/lib/"$base" \ + "$BREW_PREFIX/Cellar"/*/*/lib/"$base"; do + if [[ -f "$candidate" ]]; then + realpath "$candidate" + return 0 + fi done + return 1 +} + +# Recursively copy non-system dylibs into Frameworks and rewrite load paths. +# Unlike dylibbundler, this never prompts interactively and fails loudly. +bundle_deps() { + local target="$1" + local line dep name dest real + + while IFS= read -r line; do + dep="$(echo "$line" | awk '{print $1}')" + case "$dep" in + /System/*|/usr/lib/*|@executable_path/*|@loader_path/*) + continue + ;; + @rpath/*) + name="$(basename "$dep")" + if ! real="$(resolve_lib "$BREW_PREFIX/lib/$name")"; then + echo "error: cannot resolve @rpath dependency '$dep' needed by $target" >&2 + exit 1 + fi + ;; + /*) + name="$(basename "$dep")" + case "$name" in + libjpeg*.dylib) name="libjpeg.8.dylib" ;; + esac + if [[ -f "$dep" ]]; then + real="$(realpath "$dep")" + elif ! real="$(resolve_lib "$dep")"; then + echo "error: cannot resolve dependency '$dep' needed by $target" >&2 + exit 1 + fi + ;; + *) + continue + ;; + esac + + dest="$FW/$name" + + if [[ ! -f "$dest" ]]; then + echo " + $name (from $real)" + cp -f "$real" "$dest" + chmod u+w "$dest" + install_name_tool -id "@executable_path/../Frameworks/$name" "$dest" 2>/dev/null || true + codesign --force --sign - "$dest" >/dev/null 2>&1 || true + bundle_deps "$dest" + fi + + # Rewrite matching absolute / @rpath references on the target. + while IFS= read -r changed; do + changed="$(echo "$changed" | awk '{print $1}')" + case "$changed" in + /*|@rpath/*) + if [[ "$(basename "$changed")" == "$name" ]] || \ + [[ "$(basename "$changed")" == "$(basename "$real")" ]] || \ + { [[ "$name" == "libjpeg.8.dylib" ]] && [[ "$(basename "$changed")" == libjpeg* ]]; }; then + install_name_tool -change "$changed" \ + "@executable_path/../Frameworks/$name" "$target" 2>/dev/null || true + fi + ;; + esac + done < <(otool -L "$target" | tail -n +2) + done < <(otool -L "$target" | tail -n +2) +} + +echo "==> Bundling GTK / GLib dylibs into Frameworks" +bundle_deps "$MACOS/maturita-bin" +codesign --force --sign - "$MACOS/maturita-bin" >/dev/null 2>&1 || true + +echo "==> Verifying Frameworks completeness" +MISSING=0 +check_file_deps() { + local file="$1" + local line dep name + while IFS= read -r line; do + dep="$(echo "$line" | awk '{print $1}')" + case "$dep" in + @executable_path/../Frameworks/*) + name="$(basename "$dep")" + if [[ ! -f "$FW/$name" ]]; then + echo "MISSING: $name (required by $(basename "$file"))" >&2 + MISSING=1 + fi + ;; + @rpath/*) + echo "UNRESOLVED @rpath: $dep (in $(basename "$file"))" >&2 + MISSING=1 + ;; + /opt/homebrew/*|/usr/local/*) + echo "UNBUNDLED absolute dep still present: $dep (in $(basename "$file"))" >&2 + MISSING=1 + ;; + esac + done < <(otool -L "$file" | tail -n +2) +} +check_file_deps "$MACOS/maturita-bin" +for f in "$FW"/*.dylib; do + check_file_deps "$f" +done +if [[ "$MISSING" -ne 0 ]]; then + echo "error: macOS bundle is incomplete" >&2 + exit 1 fi +echo " Frameworks: $(ls "$FW" | wc -l | tr -d ' ') dylibs" -dylibbundler -od -b \ - -x "$MACOS/maturita-bin" \ - -d "$FW" \ - -p "@executable_path/../Frameworks" \ - "${DYLIB_SEARCH[@]+"${DYLIB_SEARCH[@]}"}" /dev/null || true)" [[ -n "$prefix" && -d "$prefix/share" ]] || return 0 mkdir -p "$RES/share" - # Copy only the subtrees GTK needs at runtime. for sub in glib-2.0 gtk-4.0 icons themes locale; do if [[ -d "$prefix/share/$sub" ]]; then rsync -a "$prefix/share/$sub" "$RES/share/" 2>/dev/null \ @@ -78,39 +184,6 @@ copy_pkg_share() { copy_pkg_share gtk4 copy_pkg_share glib-2.0 -# gdk-pixbuf loaders (PNG may be built-in; copy others for GIF/ICO/etc.). -PIXBUF_DIR="$(pkg-config --variable=gdk_pixbuf_moduledir gdk-pixbuf-2.0 2>/dev/null || true)" -LOADERS_DST="$RES/lib/gdk-pixbuf-2.0/2.10.0/loaders" -if [[ -n "$PIXBUF_DIR" && -d "$PIXBUF_DIR" ]]; then - mkdir -p "$LOADERS_DST" - # Homebrew keeps loaders as relative symlinks into Cellar — dereference. - if command -v rsync >/dev/null 2>&1; then - rsync -aL "$PIXBUF_DIR"/ "$LOADERS_DST"/ - else - cp -R -L "$PIXBUF_DIR"/. "$LOADERS_DST"/ - fi - # SVG loader pulls librsvg; not needed (app assets are PNG). Drop it so - # dylibbundler never blocks on an interactive "locate this library" prompt. - rm -f "$LOADERS_DST"/libpixbufloader_svg.* "$LOADERS_DST"/libpixbufloader-svg.* - # Drop broken leftovers and relocate real loader modules. - find "$LOADERS_DST" -type l ! -exec test -e {} \; -delete 2>/dev/null || true - shopt -s nullglob - for loader in "$LOADERS_DST"/*; do - [[ -f "$loader" ]] || continue - # stdin closed → if a dep is still missing, fail instead of hanging CI. - dylibbundler -od -b -x "$loader" -d "$FW" \ - -p "@executable_path/../Frameworks" \ - "${DYLIB_SEARCH[@]+"${DYLIB_SEARCH[@]}"}" &2 - done - shopt -u nullglob - if command -v gdk-pixbuf-query-loaders >/dev/null 2>&1; then - export GDK_PIXBUF_MODULEDIR="$LOADERS_DST" - gdk-pixbuf-query-loaders > "$RES/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache" || true - fi -fi - -# Compile GSettings schemas if glib-compile-schemas is available. if [[ -d "$RES/share/glib-2.0/schemas" ]] && command -v glib-compile-schemas >/dev/null 2>&1; then glib-compile-schemas "$RES/share/glib-2.0/schemas" || true fi @@ -122,19 +195,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" export DYLD_LIBRARY_PATH="$ROOT/Frameworks${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" export XDG_DATA_DIRS="$ROOT/Resources/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" export GSETTINGS_SCHEMA_DIR="$ROOT/Resources/share/glib-2.0/schemas" -export GDK_PIXBUF_MODULEDIR="$ROOT/Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders" -if [[ -f "$ROOT/Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache" ]]; then - export GDK_PIXBUF_MODULE_FILE="$ROOT/Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache" -fi export GTK_DATA_PREFIX="$ROOT/Resources" export GTK_EXE_PREFIX="$ROOT" -# Adwaita / hicolor icons for GTK widgets. export XDG_DATA_HOME="$ROOT/Resources" exec "$ROOT/MacOS/maturita-bin" "$@" EOF chmod +x "$MACOS/maturita" -# Info.plist cat > "$APP/Contents/Info.plist" < "$APP/Contents/Info.plist" < EOF -# Optional .icns from PNG if iconutil / sips available. if command -v sips >/dev/null 2>&1 && command -v iconutil >/dev/null 2>&1; then ICONSET="$RES/AppIcon.iconset" mkdir -p "$ICONSET" for sz in 16 32 64 128 256 512; do - sips -z "$sz" "$sz" "$ROOT/$ICON_DST" --out "$ICONSET/icon_${sz}x${sz}.png" >/dev/null - sips -z $((sz * 2)) $((sz * 2)) "$ROOT/$ICON_DST" \ + sips -z "$sz" "$sz" "$ICON_512" --out "$ICONSET/icon_${sz}x${sz}.png" >/dev/null + sips -z $((sz * 2)) $((sz * 2)) "$ICON_512" \ --out "$ICONSET/icon_${sz}x${sz}@2x.png" >/dev/null done iconutil -c icns -o "$RES/AppIcon.icns" "$ICONSET" && rm -rf "$ICONSET" || true fi +# Ad-hoc sign the .app so Gatekeeper is less likely to kill dyld loads. +codesign --force --deep --sign - "$APP" >/dev/null 2>&1 || true + +echo "==> Smoke-testing launch" +if [[ -n "${GITHUB_ACTIONS:-}${CI:-}" ]]; then + echo " (CI: skipping GUI smoke test; Frameworks verification already passed)" +else + SMOKE_LOG="$(mktemp)" + "$MACOS/maturita" >"$SMOKE_LOG" 2>&1 & + SMOKE_PID=$! + sleep 3 + if ! kill -0 "$SMOKE_PID" 2>/dev/null; then + if grep -q "Library not loaded" "$SMOKE_LOG"; then + echo "error: Maturita.app missing bundled libraries" >&2 + cat "$SMOKE_LOG" >&2 || true + exit 1 + fi + echo "error: Maturita.app exited during smoke test" >&2 + cat "$SMOKE_LOG" >&2 || true + exit 1 + fi + kill "$SMOKE_PID" 2>/dev/null || true + wait "$SMOKE_PID" 2>/dev/null || true + rm -f "$SMOKE_LOG" + echo " smoke test OK (process stayed up)" +fi + echo "==> Zipping" rm -f "$ROOT/dist/$OUT_ZIP" (