#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUILD_DIR="$ROOT_DIR/build" ARTIFACTS_DIR="$ROOT_DIR/dist" SCHEME="iKeyMon" PROJECT="iKeyMon.xcodeproj" CREDENTIALS_FILE="$ROOT_DIR/.signing.env" VERSION_FILE="$ROOT_DIR/version.json" DERIVED_DATA_ROOT="${DERIVED_DATA_ROOT:-$HOME/Library/Developer/Xcode/DerivedData}" find_generate_appcast() { if [[ -n "${SPARKLE_GENERATE_APPCAST:-}" && -x "${SPARKLE_GENERATE_APPCAST}" ]]; then echo "$SPARKLE_GENERATE_APPCAST" return fi if [[ -d "$DERIVED_DATA_ROOT" ]]; then local candidate candidate="$(find "$DERIVED_DATA_ROOT" -path "*/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_appcast" -type f 2>/dev/null | head -n 1 || true)" if [[ -n "$candidate" ]]; then echo "$candidate" return fi fi } generate_appcast() { local generator generator="$(find_generate_appcast)" local download_prefix="${SPARKLE_DOWNLOAD_BASE_URL:-}" local version_prefix="${SPARKLE_DOWNLOAD_VERSION_PREFIX:-}" if [[ -z "$generator" || -z "${SPARKLE_EDDSA_KEY_FILE:-}" || -z "$download_prefix" ]]; then echo "â„šī¸ Skipping Sparkle appcast generation (generator/key/download prefix not configured)." return fi local output="$SPARKLE_APPCAST_OUTPUT" mkdir -p "$(dirname "$output")" local staging_dir staging_dir="$(mktemp -d)" cp "$ARTIFACTS_DIR"/*.zip "$staging_dir"/ 2>/dev/null || true if ! ls "$staging_dir"/*.zip >/dev/null 2>&1; then echo "â„šī¸ Skipping Sparkle appcast generation (no ZIP archives found)." rm -rf "$staging_dir" return fi echo "🧾 Generating Sparkle appcast at $output" if ! "$generator" \ --download-url-prefix "$download_prefix" \ --ed-key-file "$SPARKLE_EDDSA_KEY_FILE" \ -o "$output" \ "$staging_dir"; then echo "âš ī¸ Sparkle appcast generation failed." fi rewrite_appcast_urls "$output" "$version_prefix" rm -rf "$staging_dir" } sign_update_artifacts() { local signer signer="$(find "$DERIVED_DATA_ROOT" -path "*/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update" -type f 2>/dev/null | head -n 1 || true)" if [[ -z "$signer" || -z "${SPARKLE_EDDSA_KEY_FILE:-}" ]]; then echo "â„šī¸ Skipping Sparkle signing (sign_update or SPARKLE_EDDSA_KEY_FILE missing)." return fi echo "🔑 Signing ${ZIP_NAME} for Sparkle feed" if ! "$signer" "${ARTIFACTS_DIR}/${ZIP_NAME}" --ed-key-file "${SPARKLE_EDDSA_KEY_FILE}"; then echo "âš ī¸ sign_update failed (continuing without signature)" fi } rewrite_appcast_urls() { local appcast="$1" local version_prefix="$2" local marker="${SPARKLE_DOWNLOAD_VERSION_MARKER:-/releases/download/}" if [[ -z "$version_prefix" || -z "$marker" ]]; then return fi python3 - "$appcast" "$marker" "$version_prefix" <<'PY' import sys import xml.etree.ElementTree as ET path, marker, prefix = sys.argv[1:] tree = ET.parse(path) root = tree.getroot() ns = {'sparkle': 'http://www.andymatuschak.org/xml-namespaces/sparkle'} changed = False for item in root.findall('.//item'): short = item.find('sparkle:shortVersionString', ns) enclosure = item.find('enclosure') if short is None or enclosure is None: continue version = (short.text or '').strip() url = enclosure.get('url') if not version or not url or marker not in url: continue desired = f"{marker}{prefix}{version}/" if desired in url: continue base, rest = url.split(marker, 1) if rest.startswith(f"{prefix}{version}/"): continue new_rest = f"{prefix}{version}/{rest.lstrip('/')}" enclosure.set('url', f"{base}{marker}{new_rest}") changed = True if changed: ET.indent(tree, space=" ", level=0) tree.write(path, encoding='utf-8', xml_declaration=True) PY } submit_for_notarization() { local target="$1" local label="$2" echo "📝 Submitting ${label} for notarization..." xcrun notarytool submit "$target" \ --apple-id "$NOTARY_APPLE_ID" \ --team-id "$NOTARY_TEAM_ID" \ --password "$NOTARY_PASSWORD" \ --wait } notarize_app_bundle() { local bundle="$1" local label="$2" if [[ -z "${NOTARY_APPLE_ID:-}" || -z "${NOTARY_TEAM_ID:-}" || -z "${NOTARY_PASSWORD:-}" ]]; then echo "â„šī¸ Skipping notarization for ${label} (NOTARY_* variables not set)." return 1 fi local tmp_dir tmp_dir="$(mktemp -d)" local archive="$tmp_dir/$(basename "$bundle").zip" ditto -c -k --keepParent "$bundle" "$archive" submit_for_notarization "$archive" "$label" xcrun stapler staple "$bundle" rm -rf "$tmp_dir" } notarize_artifact() { local artifact="$1" local label="$2" if [[ -z "${NOTARY_APPLE_ID:-}" || -z "${NOTARY_TEAM_ID:-}" || -z "${NOTARY_PASSWORD:-}" ]]; then echo "â„šī¸ Skipping notarization for ${label} (NOTARY_* variables not set)." return 1 fi submit_for_notarization "$artifact" "$label" xcrun stapler staple "$artifact" } if [[ -f "$CREDENTIALS_FILE" ]]; then set -a # shellcheck disable=SC1090 source "$CREDENTIALS_FILE" set +a fi : "${SPARKLE_APPCAST_OUTPUT:=$ROOT_DIR/Sparkle/appcast.xml}" export SPARKLE_APPCAST_OUTPUT "$ROOT_DIR/scripts/sync_version.sh" rm -rf "$BUILD_DIR" "$ARTIFACTS_DIR" mkdir -p "$ARTIFACTS_DIR" xcodebuild \ -project "$ROOT_DIR/$PROJECT" \ -scheme "$SCHEME" \ -configuration Release \ -derivedDataPath "$BUILD_DIR" \ CODE_SIGNING_ALLOWED=NO \ clean build APP_PATH="$BUILD_DIR/Build/Products/Release/iKeyMon.app" if [[ ! -d "$APP_PATH" ]]; then echo "❌ Failed to find built app at $APP_PATH" exit 1 fi if [[ -n "${CODESIGN_IDENTITY:-}" ]]; then echo "🔏 Codesigning app with identity: $CODESIGN_IDENTITY" codesign \ --deep \ --force \ --options runtime \ --timestamp \ --entitlements "$ROOT_DIR/iKeyMon.entitlements" \ --sign "$CODESIGN_IDENTITY" \ "$APP_PATH" else echo "âš ī¸ Skipping codesign (CODESIGN_IDENTITY not set)." fi notarize_app_bundle "$APP_PATH" "iKeyMon.app" STAGING_DIR=$(mktemp -d) mkdir -p "$STAGING_DIR" cp -R "$APP_PATH" "$STAGING_DIR/" ln -s /Applications "$STAGING_DIR/Applications" mkdir -p "$STAGING_DIR/.background" cp "$ROOT_DIR/Assets/dmg_background.png" "$STAGING_DIR/.background/background.png" VERSION="$(python3 - <<'PY' "$VERSION_FILE" import json, sys with open(sys.argv[1], "r", encoding="utf-8") as handle: data = json.load(handle) print(data.get("marketing_version", "dev")) PY )" ZIP_NAME="iKeyMon-${VERSION}.zip" pushd "$(dirname "$APP_PATH")" >/dev/null zip -r "$ARTIFACTS_DIR/$ZIP_NAME" "$(basename "$APP_PATH")" popd >/dev/null DMG_NAME="iKeyMon-${VERSION}.dmg" hdiutil create -volname "iKeyMon" -srcfolder "$STAGING_DIR" -ov -format UDZO "$ARTIFACTS_DIR/$DMG_NAME" sign_update_artifacts if [[ -n "${NOTARY_APPLE_ID:-}" && -n "${NOTARY_TEAM_ID:-}" && -n "${NOTARY_PASSWORD:-}" ]]; then notarize_artifact "$ARTIFACTS_DIR/$DMG_NAME" "$DMG_NAME" else echo "âš ī¸ Skipping DMG notarization (NOTARY_* variables not set)." fi rm -rf "$STAGING_DIR" generate_appcast if [[ -n "${GITEA_TOKEN:-}" && -n "${GITEA_OWNER:-}" && -n "${GITEA_REPO:-}" ]]; then "$ROOT_DIR/scripts/publish_release.sh" "$VERSION" "$ARTIFACTS_DIR/$ZIP_NAME" "$ARTIFACTS_DIR/$DMG_NAME" else echo "â„šī¸ Skipping Gitea release publishing (GITEA_* variables not fully set)." fi echo "✅ Build complete. Artifacts:" echo " - $ARTIFACTS_DIR/$ZIP_NAME" echo " - $ARTIFACTS_DIR/$DMG_NAME"