#!/usr/bin/env bash
#
# Local signed (and optionally notarized) macOS build.
#
# jpackage cannot sign the app itself: it ad-hoc signs the embedded runtime and
# then re-runs codesign on the same files without --force, which codesign
# rejects with "is already signed". So this builds an unsigned app-image, signs
# it from the inside out ourselves, and only then wraps it into a DMG.
#
# Required:
#   SIGNING_IDENTITY   The name part of the Developer ID Application certificate,
#                      without the "Developer ID Application: " prefix. Example:
#                        SIGNING_IDENTITY="Philipp Wagner (ABCDE12345)"
#                      List available ones with:
#                        security find-identity -v -p codesigning
#
# Optional:
#   Notarization, either as three separate values (what CI uses)...
#     NOTARY_KEY       Path to the App Store Connect .p8 private key
#     NOTARY_KEY_ID    The key's ID, also part of the .p8 filename
#     NOTARY_ISSUER    The issuer UUID, shown above the key list in the portal
#   ...or as a keychain profile previously created with
#     NOTARY_PROFILE     xcrun notarytool store-credentials <name>
#
#   With neither, the build is signed but not notarized -- enough to test
#   locally, not enough to distribute.
#
set -euo pipefail

cd "$(dirname "$0")/../.."
REPO_ROOT="$PWD"

BUNDLE_ID="de.x08.KST4Contest"
ENTITLEMENTS="packaging/macos/kst4contest.entitlements"

if [ -z "${SIGNING_IDENTITY:-}" ]; then
    echo "SIGNING_IDENTITY is not set. Available signing identities:" >&2
    security find-identity -v -p codesigning >&2 || true
    exit 1
fi
FULL_IDENTITY="Developer ID Application: $SIGNING_IDENTITY"

# notarytool takes either an API key triple or a stored keychain profile. The
# triple needs no keychain at all, which is why CI uses it.
NOTARY_ARGS=()
if [ -n "${NOTARY_KEY:-}" ] && [ -n "${NOTARY_KEY_ID:-}" ] && [ -n "${NOTARY_ISSUER:-}" ]; then
    NOTARY_ARGS=(--key "$NOTARY_KEY" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER")
elif [ -n "${NOTARY_PROFILE:-}" ]; then
    NOTARY_ARGS=(--keychain-profile "$NOTARY_PROFILE")
fi

echo "==> Building JAR and collecting runtime dependencies"
chmod +x mvnw
./mvnw -B -DskipTests package \
    dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
JAR="$(ls -t target/praktiKST-*.jar | head -n 1)"
cp "$JAR" target/dist-libs/app.jar

# jpackage only accepts a numeric major[.minor[.patch]] as the macOS bundle
# version, so a Maven qualifier like "-nightly" has to be trimmed off.
POM_VERSION="${JAR##*/praktiKST-}"
POM_VERSION="${POM_VERSION%.jar}"
APP_VERSION="$(printf '%s' "$POM_VERSION" | sed -e 's/[^0-9.].*$//' -e 's/\.*$//')"
[ -n "$APP_VERSION" ] || { echo "Could not derive app version from $JAR" >&2; exit 1; }
echo "==> Version: $POM_VERSION -> bundle version $APP_VERSION"

echo "==> Step 1/4: jpackage app-image (unsigned)"
rm -rf dist
mkdir -p dist
ADD_MODULES="$(java packaging/AddModules.java)"

MACOSX_DEPLOYMENT_TARGET="13.0" jpackage \
    --type app-image \
    --name KST4Contest \
    --app-version "$APP_VERSION" \
    --icon packaging/icons/kst4contest.icns \
    --input target/dist-libs \
    --main-jar app.jar \
    --main-class kst4contest.view.Kst4ContestApplication \
    --module-path target/dist-libs \
    --add-modules "$ADD_MODULES" \
    --mac-package-identifier "$BUNDLE_ID" \
    --mac-package-name KST4Contest \
    --dest dist/appimage

APP="dist/appimage/KST4Contest.app"
[ -d "$APP" ] || { echo "jpackage produced no app image" >&2; exit 1; }

echo "==> Step 2/4: signing bundle contents (this takes a few minutes)"

# Apple's notary service unpacks JARs and checks the native libraries inside
# them. sqlite-jdbc ships libsqlitejdbc.dylib for both architectures that way,
# and an unsigned binary in there fails the whole submission. So sign those
# first: the app bundle's seal covers Contents/app, and rewriting a JAR
# afterwards would invalidate it.
echo "    scanning jars for native libraries"
find "$APP/Contents/app" -name '*.jar' -type f | while read -r JARPATH; do
    # Only unpack jars that can plausibly hold a native library. Note the
    # plain grep: "grep -q" exits at the first match, which hands unzip a
    # SIGPIPE, and under "set -o pipefail" that failure becomes the pipeline's
    # status -- inverting this very test.
    if ! unzip -l "$JARPATH" | grep -E '\.(dylib|jnilib|so)$' >/dev/null; then
        continue
    fi

    JARABS="$(cd "$(dirname "$JARPATH")" && pwd)/$(basename "$JARPATH")"
    JARTMP="$(mktemp -d)"
    unzip -q "$JARABS" -d "$JARTMP"

    NATIVES="$(mktemp)"
    ( cd "$JARTMP" && find . -type f \( -name '*.dylib' -o -name '*.jnilib' -o -name '*.so' \) \
        | while read -r n; do
            if [ "$(file --mime-type -b "$n")" = "application/x-mach-binary" ]; then
                printf '%s\n' "${n#./}"
            fi
          done ) > "$NATIVES"

    if [ -s "$NATIVES" ]; then
        echo "    $(basename "$JARPATH"): $(wc -l < "$NATIVES" | tr -d ' ') native lib(s)"
        ( cd "$JARTMP" && xargs -I {} codesign --force --timestamp --options runtime \
            --sign "$FULL_IDENTITY" {} < "$NATIVES" )
        # Update in place rather than repacking, so the rest of the jar --
        # manifest, module descriptor, entry order -- stays byte for byte.
        ( cd "$JARTMP" && xargs jar --update --file "$JARABS" < "$NATIVES" )
    fi

    rm -rf "$JARTMP" "$NATIVES"
done

# Every Mach-O file has to carry its own signature before the enclosing bundle
# can be sealed, so collect them first. jpackage leaves them ad-hoc signed,
# hence --force on every call.
# file(1) pads its output into columns when given several arguments at once,
# so ask it one file at a time with -b and get an unambiguous answer.
MACHO_LIST="$(mktemp)"
find "$APP" -type f -print0 | while IFS= read -r -d '' f; do
    case "$(file --mime-type -b "$f")" in
        application/x-mach-binary) printf '%s\n' "$f" ;;
    esac
done > "$MACHO_LIST"

COUNT="$(wc -l < "$MACHO_LIST" | tr -d ' ')"
echo "    $COUNT Mach-O files to sign"

# Serially, deliberately. Running codesign concurrently over several files of
# the same bundle fails intermittently -- a CI run died with "replacing existing
# signature" immediately followed by "No such file or directory" for that same
# path, while the identical script passed locally. Each call contacts Apple's
# timestamp server, so this costs about a minute for a runtime this size.
xargs -I {} codesign --force --timestamp --options runtime \
    --sign "$FULL_IDENTITY" {} < "$MACHO_LIST"
rm -f "$MACHO_LIST"

# The embedded JDK is a bundle in its own right and must be sealed before the
# app that contains it.
echo "    sealing embedded runtime"
codesign --force --timestamp --options runtime \
    --sign "$FULL_IDENTITY" "$APP/Contents/runtime"

# Entitlements go on the outermost bundle: the hardened runtime derives the
# process's entitlements from the main executable's signature.
echo "    sealing app bundle"
codesign --force --timestamp --options runtime \
    --entitlements "$ENTITLEMENTS" \
    --sign "$FULL_IDENTITY" "$APP"

# Apple rejects the whole submission over a single unsigned native library, and
# a round trip to the notary service costs minutes. Check its two criteria --
# a Developer ID authority and a secure timestamp -- locally first.
echo "    preflight: verifying native libraries inside jars"
PREFLIGHT_ERRORS="$(mktemp)"
find "$APP/Contents/app" -name '*.jar' -type f | while read -r JARPATH; do
    if ! unzip -l "$JARPATH" | grep -E '\.(dylib|jnilib|so)$' >/dev/null; then
        continue
    fi
    CHECKTMP="$(mktemp -d)"
    unzip -q "$JARPATH" -d "$CHECKTMP"
    find "$CHECKTMP" -type f \( -name '*.dylib' -o -name '*.jnilib' -o -name '*.so' \) \
        | while read -r NATIVE; do
            [ "$(file --mime-type -b "$NATIVE")" = "application/x-mach-binary" ] || continue
            INFO="$(codesign -dv --verbose=2 "$NATIVE" 2>&1 || true)"
            LABEL="$(basename "$JARPATH")/${NATIVE#"$CHECKTMP"/}"
            printf '%s' "$INFO" | grep -q "Authority=Developer ID Application" \
                || echo "$LABEL: not signed with a Developer ID certificate" >> "$PREFLIGHT_ERRORS"
            printf '%s' "$INFO" | grep -q "Timestamp=" \
                || echo "$LABEL: signature has no secure timestamp" >> "$PREFLIGHT_ERRORS"
          done
    rm -rf "$CHECKTMP"
done
if [ -s "$PREFLIGHT_ERRORS" ]; then
    echo "ERROR: these would fail notarization:" >&2
    sed 's/^/    /' "$PREFLIGHT_ERRORS" >&2
    rm -f "$PREFLIGHT_ERRORS"
    exit 1
fi
rm -f "$PREFLIGHT_ERRORS"
echo "    preflight ok"

echo "==> Step 3/4: building the dmg"

# Not with jpackage: "jpackage --type dmg --app-image" re-signs the app it is
# handed, replacing our Developer ID signature with an ad-hoc one and dropping
# the hardened runtime flag. hdiutil copies the bundle verbatim instead.
DMG="dist/KST4Contest-${APP_VERSION}.dmg"
STAGE="$(mktemp -d)"
# ditto rather than cp -R: it preserves the extended attributes the code
# signature depends on.
ditto "$APP" "$STAGE/KST4Contest.app"
ln -s /Applications "$STAGE/Applications"

hdiutil create -volname "KST4Contest" -srcfolder "$STAGE" \
    -ov -format UDZO -quiet "$DMG"
rm -rf "$STAGE"
[ -f "$DMG" ] || { echo "hdiutil produced no DMG" >&2; exit 1; }

# Signing the DMG itself is not what Gatekeeper judges -- that is the .app
# inside -- but Apple expects the container to be signed too.
codesign --force --timestamp --sign "$FULL_IDENTITY" "$DMG"
echo "==> Built $DMG"

echo "==> Step 4/4: verification"

if [ ${#NOTARY_ARGS[@]} -gt 0 ]; then
    echo "    submitting for notarization (waits for Apple's verdict)"
    # Without a timeout a stalled submission would hang a CI job forever.
    xcrun notarytool submit "$DMG" "${NOTARY_ARGS[@]}" --wait --timeout 30m
    echo "    stapling ticket"
    xcrun stapler staple "$DMG"
else
    echo "    no notarization credentials set, skipping notarization"
fi

# Everything below inspects the app as it actually ships, mounted from the DMG,
# rather than the staging copy on disk.
MOUNT_POINT="$(mktemp -d)"
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint "$MOUNT_POINT"
trap 'hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null || hdiutil detach "$MOUNT_POINT" -force -quiet 2>/dev/null || true' EXIT
SHIPPED_APP="$MOUNT_POINT/KST4Contest.app"

echo "--- codesign --verify on the app inside the DMG ---"
codesign --verify --deep --strict --verbose=2 "$SHIPPED_APP"

echo "--- app identity ---"
codesign -dv --verbose=2 "$SHIPPED_APP" 2>&1 | grep -iE "identifier|authority|teamidentifier|flags"

# An ad-hoc signature here means something along the way re-signed the bundle.
if codesign -dv "$SHIPPED_APP" 2>&1 | grep -q "adhoc"; then
    echo "ERROR: the app inside the DMG is ad-hoc signed, not Developer ID signed" >&2
    exit 1
fi

echo "--- entitlements as signed ---"
codesign -d --entitlements - --xml "$SHIPPED_APP" 2>/dev/null | plutil -convert xml1 -o - - | grep -E "key|true|false"

echo "--- dmg identity ---"
codesign -dv --verbose=2 "$DMG" 2>&1 | grep -iE "authority|teamidentifier" | head -2

echo "--- spctl assessment ---"
# Without notarization this reports "rejected"; that is expected.
spctl --assess --type execute --verbose=4 "$SHIPPED_APP" || true

if [ ${#NOTARY_ARGS[@]} -gt 0 ]; then
    echo "--- stapler validate ---"
    xcrun stapler validate "$DMG"
fi

echo
echo "Done: $REPO_ROOT/$DMG"
