name: Build macOS App Release

on:
  push:
    tags:
      - "v[0-9]+.[0-9]+.[0-9]+"
  workflow_dispatch:

permissions:
  contents: write

concurrency:
  group: cicy-desktop-release-macos
  cancel-in-progress: true

jobs:
  build-mac-app:
    runs-on: macos-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      # cicy-code is NOT built-from-source here anymore. (2026-06): the bundled
      # sidecar = the per-platform optionalDependency (cicy-code-<plat>, prebuilt +
      # arm64 linker-adhoc-signed), seeded at runtime by localbin.fromBundle. The old
      # .cicy-code-ref source-build → vendor/cicy-code path was stale + never packaged
      # (vendor/ not in package.json files), so it's removed. Sync just pins the
      # optionalDeps to the latest published cicy-code/cicy-mihomo.
      - name: Sync runtime deps to latest (cicy-code + cicy-mihomo; drop msys2)
        run: node scripts/sync-runtime-deps.cjs

      - name: Install project dependencies
        run: npm install --no-audit

      - name: Install Electron build dependencies in project directory
        run: npm install --save-dev --no-audit electron@41.0.2 electron-builder@26.7.0

      # (2026-06 关键修复): macos-latest runner 是 arm64,`npm install` 的 os/cpu
      # 过滤只装 arm64 的 cicy-code/mihomo 子包 → x64(Intel)的 .pkg 里塞的也是 arm64
      # 二进制 → Intel fresh 装没 :8008(实测 josephs)。这里强制把 BOTH darwin 架构都
      # 装进 node_modules(--force 绕过 EBADPLATFORM),让 electron-builder 两架构都打包,
      # localbin 按用户机器选对应那份。必须在所有 npm install 之后(否则被剪枝)。
      - name: Bundle BOTH macOS arches of cicy-code/mihomo (runner is single-arch)
        shell: bash
        run: |
          set -e
          # `npm install --force` 在已有 lockfile 的项目里会判 "up to date" 跳过非本机架构
          # → 改用 `npm pack`(纯按名下 tarball,不看 os/cpu/lockfile)+ 解压进 node_modules。
          for pkg in cicy-code-darwin-x64 cicy-code-darwin-arm64 cicy-mihomo-darwin-x64 cicy-mihomo-darwin-arm64; do
            ver=$(node -e "const o=require('./package.json').optionalDependencies||{}; process.stdout.write(o['$pkg']||'')")
            [ -z "$ver" ] && { echo "skip $pkg (not pinned)"; continue; }
            bin=cicy-code; case "$pkg" in cicy-mihomo*) bin=mihomo;; esac
            if [ -f "node_modules/$pkg/$bin" ]; then echo "$pkg present (native arch)"; continue; fi
            echo "npm pack $pkg@$ver → node_modules/$pkg"
            tmp=$(mktemp -d); ( cd "$tmp" && npm pack "$pkg@$ver" --silent ); tar -xzf "$tmp"/*.tgz -C "$tmp"
            rm -rf "node_modules/$pkg"; mkdir -p "node_modules/$pkg"; cp -R "$tmp/package/." "node_modules/$pkg/"; rm -rf "$tmp"
            # npm pack 的 package.json 解出来是 600 → .pkg 以 root 装进 /Applications 后,
            # 非 root 的 App(用户 cicy)读不了它 → localbin.bundledVersion EACCES → fromBundle
            # 返回 null → seed 失败、:8008 起不来(实测 josephs)。必须放成所有人可读。
            chmod -R a+rX "node_modules/$pkg"
          done
          echo "--- bundled darwin subpackages (perms) ---"; ls -l node_modules/cicy-code-darwin-*/package.json 2>/dev/null || true
          for a in x64 arm64; do
            test -f "node_modules/cicy-code-darwin-$a/cicy-code" || { echo "::error::cicy-code-darwin-$a binary missing"; exit 1; }
            test -r "node_modules/cicy-code-darwin-$a/package.json" || { echo "::error::cicy-code-darwin-$a/package.json not world-readable"; exit 1; }
          done

      # NOTE: the homepage SPA is rebuilt automatically by the prebuild:mac npm
      # hook (scripts/build-homepage.cjs) right before `npm run build:mac` below,
      # so the dmg can never ship a stale homepage. No explicit step needed.

      - name: Prepare macOS icon
        shell: bash
        # cicy-ai.com sometimes returns 522 (origin unreachable). Don't let an
        # icon-asset hiccup kill the whole release — we fall back to the
        # default Electron icon if the fetch + convert chain fails. Build
        # step downstream checks `build/logo.icns` and conditionally passes
        # --config.mac.icon.
        continue-on-error: true
        run: |
          set +e
          mkdir -p build/icon.iconset
          if ! curl -fsSL --max-time 12 "https://cicy-ai.com/logo.svg" -o build/logo.svg; then
            echo "::warning::logo.svg fetch failed (cicy-ai.com unreachable); building with default Electron icon"
            exit 0
          fi
          magick -background none -density 1024 build/logo.svg \
            -alpha on -resize 1024x1024 PNG32:build/logo-1024.png || { echo "::warning::magick failed"; exit 0; }
          for size in 16 32 128 256 512; do
            sips -z $size $size build/logo-1024.png \
              --out "build/icon.iconset/icon_${size}x${size}.png" >/dev/null
            sips -z $((size*2)) $((size*2)) build/logo-1024.png \
              --out "build/icon.iconset/icon_${size}x${size}@2x.png" >/dev/null
          done
          iconutil -c icns build/icon.iconset -o build/logo.icns || { echo "::warning::iconutil failed"; exit 0; }

      - name: Build macOS app directories (unsigned)
        # CSC_IDENTITY_AUTO_DISCOVERY=false stops electron-builder from probing the
        # runner keychain for a cert it does not have; combined with
        # build.mac.identity=null in package.json this produces a clean unsigned
        # build. The cicy-code sidecar arrives via the optionalDependency
        # (arm64 is linker-adhoc-signed already), seeded by localbin at runtime.
        shell: bash
        env:
          CSC_IDENTITY_AUTO_DISCOVERY: "false"
          GH_TOKEN: ${{ github.token }}
          CICY_OBFUSCATE: "1"   # 发版混淆 JS(prebuild 主进程 + Vite 渲染层),仅 release CI
        run: |
          set -euo pipefail
          rm -rf dist
          # Build BOTH native app directories, then package only the PKGs consumed by
          # the updater/download page. Avoiding dmg/zip saves macOS runner time and
          # duplicate release storage.
          if [ -f build/logo.icns ]; then
            npm run build:mac -- --dir --arm64 --x64 --config.mac.icon=build/logo.icns --publish never
          else
            echo "::warning::build/logo.icns missing — using default Electron icon"
            npm run build:mac -- --dir --arm64 --x64 --publish never
          fi

      - name: Resolve release version
        id: meta
        shell: bash
        run: |
          version="$(node -p "require('./package.json').version")"
          echo "version=${version}" >> "$GITHUB_OUTPUT"
          echo "tag=v${version}" >> "$GITHUB_OUTPUT"

      - name: Wait for Windows workflow to create the GitHub release
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          if [ "$GITHUB_REF_TYPE" != "tag" ]; then
            gh release create "${{ steps.meta.outputs.tag }}" --repo "${{ github.repository }}" --title "CiCy Desktop v${{ steps.meta.outputs.version }}" --generate-notes --draft 2>/dev/null || true
            exit 0
          fi
          for attempt in $(seq 1 60); do
            gh release view "${{ steps.meta.outputs.tag }}" --repo "${{ github.repository }}" >/dev/null 2>&1 && exit 0
            sleep 5
          done
          echo "::error::release was not created by the Windows workflow within 5 minutes"
          exit 1

      - name: Build and publish PKG installers
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          VER="${{ steps.meta.outputs.version }}"
          for ARCH in x64 arm64; do
            WANT="$([ "$ARCH" = x64 ] && echo x86_64 || echo arm64)"
            APP=""
            # NUL-delimited: the .app dir name has a space ("CiCy Desktop.app"), so a
            # `for d in $(find …)` word-splits the path and breaks. read -d '' is safe.
            while IFS= read -r -d '' d; do
              bin="$(find "$d/Contents/MacOS" -maxdepth 1 -type f | head -1)"
              if lipo -archs "$bin" 2>/dev/null | grep -qw "$WANT"; then APP="$d"; break; fi
            done < <(find dist -maxdepth 2 -type d -name "*.app" -print0)
            [ -n "$APP" ] || { echo "::error::no $ARCH .app found"; exit 1; }
            echo "pkg $ARCH ← $APP"

            # Native .pkg installer (right-click Open once; the postinstall ad-hoc-signs
            # + de-quarantines the installed app so it opens with no Apple cert).
            ROOT="pkgroot-$ARCH"; rm -rf "$ROOT"; mkdir -p "$ROOT/Applications"
            cp -R "$APP" "$ROOT/Applications/CiCy Desktop.app"
            SCR="pkgscripts-$ARCH"; rm -rf "$SCR"; mkdir -p "$SCR"
            cp scripts/mac-allinone/postinstall "$SCR/postinstall"; chmod +x "$SCR/postinstall"
            PKG="cicy-desktop-$VER-$ARCH.pkg"
            # Disable bundle relocation. Without this the macOS Installer "relocates" the
            # install to wherever an existing com.cicy.desktop bundle already lives (e.g. a
            # leftover copy in ~/Downloads from a tarball test) instead of /Applications —
            # so the app vanishes from /Applications. BundleIsRelocatable=false pins it to
            # the payload path (/Applications) no matter what's already on disk.
            PLIST="component-$ARCH.plist"
            pkgbuild --analyze --root "$ROOT" "$PLIST"
            /usr/libexec/PlistBuddy -c "Set :0:BundleIsRelocatable false" "$PLIST" 2>/dev/null \
              || plutil -replace 0.BundleIsRelocatable -bool NO "$PLIST" || true
            pkgbuild --root "$ROOT" --component-plist "$PLIST" --scripts "$SCR" \
              --identifier com.cicy.desktop --version "$VER" --install-location / "$PKG"
            gh release upload "${{ steps.meta.outputs.tag }}" "$PKG" \
              --repo "${{ github.repository }}" --clobber
            echo "uploaded $PKG ($(du -h "$PKG" | cut -f1))"
          done

      # Publish the .pkg installers to our R2 bucket. GitHub release downloads stall from
      # mainland China, so users in CN pull from the CDN instead. Each arch gets a versioned key
      # plus a stable cicy-desktop-mac-<arch>-latest.pkg alias a download page can hard-link.
      # Same bucket/creds as the Windows latest.exe step.
      - name: Upload .pkg to R2 (versioned + per-arch latest alias)
        continue-on-error: true   # the CDN mirror is a convenience — never fail the release
        shell: bash
        env:
          R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
          R2_API_TOKEN:  ${{ secrets.R2_API_TOKEN }}
        run: |
          set -uo pipefail
          VER="${{ steps.meta.outputs.version }}"
          if [ -z "${R2_ACCOUNT_ID:-}" ] || [ -z "${R2_API_TOKEN:-}" ]; then
            echo "::warning::R2 creds missing — skipping CDN mirror"; exit 0
          fi
          BASE="releases"
          HOST="https://r2.deepfetch.de5.net/releases"
          for ARCH in x64 arm64; do
            PKG="cicy-desktop-$VER-$ARCH.pkg"
            [ -f "$PKG" ] || { echo "::warning::$PKG not built — skipping R2 for $ARCH"; continue; }
            node scripts/r2.mjs put "$BASE/$PKG" "$PKG" || { echo "::warning::R2 versioned upload failed $ARCH"; continue; }
            node scripts/r2.mjs put "$BASE/cicy-desktop-mac-$ARCH-latest.pkg" "$PKG" || echo "::warning::R2 latest upload failed $ARCH"
            echo "R2 $ARCH: $HOST/cicy-desktop-mac-$ARCH-latest.pkg"
          done
          printf '%s' "$VER" > mac-latest-version.txt
          node scripts/r2.mjs put "$BASE/mac-latest-version.txt" mac-latest-version.txt || true

          # Prune —— 版本化 pkg 只保留最新 2 个版本(每版含 x64+arm64),更旧的删掉(省存储)。
          # 只匹配 cicy-desktop-<semver>-<arch>.pkg;mac-<arch>-latest.pkg 指针不含 semver、永远保留。非致命。
          allvers=$(node scripts/r2.mjs list "$BASE/cicy-desktop-" 2>/dev/null | grep -oE 'cicy-desktop-[0-9]+\.[0-9]+\.[0-9]+-(x64|arm64)\.pkg' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -uV || true)
          keep=$(echo "$allvers" | tail -2)
          for v in $allvers; do
            echo "$keep" | grep -qx "$v" && continue
            for ARCH in x64 arm64; do
              node scripts/r2.mjs delete "$BASE/cicy-desktop-$v-$ARCH.pkg" 2>/dev/null || true
            done
            echo "Pruned old mac pkg version: $v"
          done
