name: release-rust

# Trigger: any "v*" tag push.
on:
  push:
    tags:
      - "v*"

jobs:
  # Create the release first, then all builds upload to it
  create-release:
    name: Create GitHub Release
    runs-on: ubuntu-latest
    if: github.repository == 'snolab/CapsLockX'
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.ref }}

      - name: Create or update release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          # Build flag sets separately: `gh release edit` rejects the
          # `--generate-notes` flag that `gh release create` accepts. When
          # we re-tag (force-push) to retrigger a failed build, the
          # release already exists, so the fallback path runs.
          CREATE_FLAGS="--generate-notes"
          EDIT_FLAGS=""
          if [[ "${{ github.ref_name }}" == *"-"* ]]; then
            CREATE_FLAGS="$CREATE_FLAGS --prerelease"
            EDIT_FLAGS="$EDIT_FLAGS --prerelease"
          fi
          gh release create "${{ github.ref_name }}" $CREATE_FLAGS || \
          gh release edit "${{ github.ref_name }}" $EDIT_FLAGS

  build-windows:
    name: Build Windows (x86_64-pc-windows-msvc)
    needs: create-release
    runs-on: windows-latest
    if: github.repository == 'snolab/CapsLockX'
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.ref }}

      - name: Install Rust stable
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: x86_64-pc-windows-msvc

      - name: Cache Rust build artefacts
        uses: Swatinem/rust-cache@v2
        with:
          workspaces: rs -> rs/target

      - name: Build all Windows binaries --release
        working-directory: rs
        # capslockx-windows currently has no `full` feature; it builds
        # without stt by default (whisper-rs doesn't compile on Windows).
        # Both portable and setup ship the same binary on Windows for now —
        # the variants differ only in packaging (zip vs NSIS installer).
        run: cargo build -p capslockx-windows -p clx-screen-reader -p clx-prefs-slint --release

      - name: Package Windows zip
        shell: pwsh
        run: |
          $STAGE = "CapsLockX"
          New-Item -ItemType Directory -Force -Path $STAGE
          # cargo builds the windows adapter as `clx.exe` (the bin name
          # in capslockx-windows/Cargo.toml).
          Copy-Item rs\target\release\clx.exe "$STAGE\clx.exe"
          Copy-Item rs\target\release\clx-screen-reader.exe "$STAGE\clx-screen-reader.exe"
          # Native (Slint) preferences window — opens instantly (no WebView2).
          Copy-Item rs\target\release\clx-prefs-slint.exe "$STAGE\clx-prefs-slint.exe"
          # Runtime DLLs (sherpa-onnx-c-api.dll, onnxruntime.dll, etc.)
          # are dropped by sherpa-rs's build.rs into target/release WHEN
          # the `stt` feature is enabled. capslockx-windows currently
          # builds without stt (whisper-rs doesn't compile on Windows),
          # so no DLLs are produced and clx.exe runs as a stt-stub
          # (Space+V no-op). Bundle whatever DLLs exist; don't fail if
          # there are none.
          $dlls = Get-ChildItem rs\target\release -Filter *.dll -ErrorAction SilentlyContinue
          if ($dlls) {
            foreach ($d in $dlls) { Copy-Item $d.FullName "$STAGE\" }
            Write-Host "Bundled DLLs: $($dlls.Name -join ', ')"
          } else {
            Write-Host "No runtime DLLs to bundle (stt feature off)"
          }
          Copy-Item scripts\install.ps1 "$STAGE\install.ps1"
          @"
          `$InstallDir = "`$env:LOCALAPPDATA\CapsLockX"
          `$StartMenu = [Environment]::GetFolderPath("StartMenu")
          Remove-Item -Recurse -Force `$InstallDir -ErrorAction SilentlyContinue
          Remove-Item (Join-Path `$StartMenu "CapsLockX.lnk") -ErrorAction SilentlyContinue
          `$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
          `$NewPath = (`$UserPath -split ";" | Where-Object { `$_ -ne `$InstallDir }) -join ";"
          [Environment]::SetEnvironmentVariable("Path", `$NewPath, "User")
          Write-Host "CapsLockX uninstalled."
          "@ | Out-File -Encoding utf8 "$STAGE\uninstall.ps1"
          Compress-Archive -Path "$STAGE\*" -DestinationPath "CapsLockX-portable-windows-x86_64.zip"

      - name: Build NSIS setup.exe
        shell: pwsh
        run: |
          # Stage binaries + runtime DLLs next to .nsi (NSIS File resolves
          # relative to script dir)
          Copy-Item rs\target\release\clx.exe scripts\clx.exe
          Copy-Item rs\target\release\clx-screen-reader.exe scripts\clx-screen-reader.exe
          Copy-Item rs\target\release\clx-prefs-slint.exe scripts\clx-prefs-slint.exe
          Get-ChildItem rs\target\release -Filter *.dll -ErrorAction SilentlyContinue | ForEach-Object {
            Copy-Item $_.FullName "scripts\"
          }
          # Install NSIS + EnVar plugin
          choco install nsis -y --no-progress
          $NSIS_DIR = "C:\Program Files (x86)\NSIS"
          # Download EnVar plugin for PATH manipulation
          Invoke-WebRequest -Uri "https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip" `
            -OutFile envar.zip
          Expand-Archive envar.zip -DestinationPath envar -Force
          Copy-Item envar\Plugins\x86-unicode\EnVar.dll "$NSIS_DIR\Plugins\x86-unicode\" -Force
          # Build installer
          $VERSION = "${{ github.ref_name }}".TrimStart("v")
          & "$NSIS_DIR\makensis.exe" /DVERSION=$VERSION scripts\installer.nsi
          Move-Item scripts\CapsLockX-setup-x86_64.exe CapsLockX-setup-windows-x86_64.exe

      # Upload BEFORE the commit-back step: that step pushes to a protected
      # branch and can fail (it did for v2.1.0, leaving the stable release
      # with no Windows assets at all).
      - name: Upload Windows packages
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        shell: bash
        run: |
          gh release upload "${{ github.ref_name }}" \
            CapsLockX-portable-windows-x86_64.zip \
            CapsLockX-setup-windows-x86_64.exe \
            --clobber

      # Commit binaries back to main only for stable releases. Best effort:
      # `main` requires pull requests, so a direct push is rejected (GH006)
      # and must never fail the release itself.
      - name: Commit binaries to main
        if: "!contains(github.ref_name, '-')"
        continue-on-error: true
        shell: pwsh
        run: |
          git fetch origin main
          git checkout main
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          Copy-Item CapsLockX\clx.exe clx.exe
          Copy-Item CapsLockX\clx-screen-reader.exe clx-screen-reader.exe
          git add clx.exe clx-screen-reader.exe
          git diff --cached --quiet || git commit -m "chore: update prebuilt binaries for ${{ github.ref_name }}"
          git push origin main

  build-linux:
    name: Build Linux (x86_64-unknown-linux-gnu)
    needs: create-release
    runs-on: ubuntu-latest
    if: github.repository == 'snolab/CapsLockX'
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.ref }}

      - name: Install system dependencies
        run: sudo apt-get update && sudo apt-get install -y libasound2-dev pkg-config

      - name: Install Rust stable
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust build artefacts
        uses: Swatinem/rust-cache@v2
        with:
          workspaces: rs -> rs/target

      - name: Build Linux binary --release
        working-directory: rs
        # Linux adapter has no `full` feature yet — same binary serves both
        # variants. Difference is packaging only (tar.gz vs .deb).
        run: cargo build -p capslockx-linux --release

      - name: Package tar.gz
        run: |
          VERSION="${{ github.ref_name }}"
          STAGE="CapsLockX-${VERSION}-linux-x86_64"
          mkdir -p "$STAGE"
          cp rs/target/release/capslockx "$STAGE/clx"
          cp scripts/install.sh "$STAGE/install.sh"
          chmod +x "$STAGE/clx" "$STAGE/install.sh"
          tar czf "CapsLockX-portable-linux-x86_64.tar.gz" "$STAGE"

      - name: Package .deb
        run: |
          VERSION="${{ github.ref_name }}"
          DEB_VERSION="${VERSION#v}"
          PKG="capslockx_${DEB_VERSION}_amd64"
          mkdir -p "$PKG/DEBIAN"
          mkdir -p "$PKG/usr/local/bin"
          cp rs/target/release/capslockx "$PKG/usr/local/bin/clx"
          chmod 755 "$PKG/usr/local/bin/clx"
          cat > "$PKG/DEBIAN/control" << 'CTRL'
          Package: capslockx
          Version: DEB_VERSION_PLACEHOLDER
          Section: utils
          Priority: optional
          Architecture: amd64
          Maintainer: snomiao <snomiao@gmail.com>
          Description: CapsLockX — keyboard productivity tool + LLM agent
           Uses CapsLock and Space as trigger keys for hotkey combos.
           Vim-style cursor, mouse control, window management, voice input, and AI agent.
          Homepage: https://github.com/snolab/CapsLockX
          CTRL
          sed -i 's/^          //' "$PKG/DEBIAN/control"
          sed -i "s/DEB_VERSION_PLACEHOLDER/${DEB_VERSION}/" "$PKG/DEBIAN/control"
          dpkg-deb --build --root-owner-group "$PKG"
          mv "${PKG}.deb" "CapsLockX-setup-linux-x86_64.deb"

      - name: Upload Linux packages
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release upload "${{ github.ref_name }}" \
            CapsLockX-portable-linux-x86_64.tar.gz \
            CapsLockX-setup-linux-x86_64.deb \
            --clobber

  build-macos:
    name: Build macOS (aarch64-apple-darwin)
    needs: create-release
    runs-on: macos-latest
    if: github.repository == 'snolab/CapsLockX'
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.ref }}

      - name: Install Rust stable
        uses: dtolnay/rust-toolchain@stable

      - name: Cache Rust build artefacts
        uses: Swatinem/rust-cache@v2
        with:
          workspaces: rs -> rs/target

      # ── Portable: core only, no stt/ai. Tiny binary (~5 MB). ──
      - name: Build portable variant
        working-directory: rs
        run: cargo build -p capslockx-macos --release --bin capslockx

      - name: Sign + stage portable
        run: |
          codesign -s - --force --identifier "com.snomiao.capslockx" rs/target/release/capslockx
          STAGE="CapsLockX-portable-macos-arm64"
          mkdir -p "$STAGE"
          cp rs/target/release/capslockx "$STAGE/clx"
          cp scripts/install.sh "$STAGE/install.sh"
          chmod +x "$STAGE/clx" "$STAGE/install.sh"
          tar czf "CapsLockX-portable-macos-arm64.tar.gz" "$STAGE"

      # ── Full / setup: stt + ai features, larger binary. Same packaging
      # for now (.dmg). otoji bundling lands in a follow-up commit when the
      # otoji submodule's release artifacts are wired in.
      - name: Build full variant
        working-directory: rs
        run: cargo build -p capslockx-macos --release --bin capslockx --features full

      - name: Bundle dylibs + sign full
        # The full binary links @rpath/libonnxruntime.*.dylib and
        # @rpath/libsherpa-onnx-c-api.dylib (dropped by sherpa-rs's build.rs
        # into target/release). Without bundling them and adding an rpath,
        # the .dmg binary dies with "Library not loaded: @rpath/..." on
        # first launch. Set rpath BEFORE codesign — install_name_tool
        # invalidates any prior signature.
        run: |
          BIN=rs/target/release/capslockx
          install_name_tool -add_rpath "@executable_path/." "$BIN"
          codesign -s - --force --identifier "com.snomiao.capslockx" "$BIN"

      - name: Compile clx-ocr (Vision OCR helper)
        # voice_ptt::fetch_frontmost_ocr() looks for a sibling clx-ocr next
        # to the main binary to enrich PTT context with on-screen text.
        # Without bundling it, packaged builds silently lose OCR enrichment
        # that local build.sh users get for free.
        run: |
          swiftc -O -o clx-ocr rs/adapters/macos/src/bin/clx-ocr.swift
          codesign -s - --force --identifier "com.snomiao.capslockx.ocr" clx-ocr

      - name: Create .dmg (setup variant)
        run: |
          brew install create-dmg
          VERSION="${{ github.ref_name }}"
          APP_DIR="CapsLockX"
          mkdir -p "$APP_DIR"
          cp rs/target/release/capslockx "$APP_DIR/clx"
          cp clx-ocr "$APP_DIR/clx-ocr"
          # Bundle the @rpath dylibs next to the binary so the rpath
          # @executable_path/. resolves at launch.
          cp rs/target/release/libonnxruntime.*.dylib "$APP_DIR/" 2>/dev/null || true
          cp rs/target/release/libsherpa-onnx-c-api.dylib "$APP_DIR/" 2>/dev/null || true
          cp rs/target/release/libsherpa-onnx-cxx-api.dylib "$APP_DIR/" 2>/dev/null || true
          cp scripts/install.sh "$APP_DIR/install.sh"
          chmod +x "$APP_DIR/clx" "$APP_DIR/clx-ocr" "$APP_DIR/install.sh"
          create-dmg \
            --volname "CapsLockX ${VERSION}" \
            --window-pos 200 120 \
            --window-size 600 400 \
            --icon-size 80 \
            --hide-extension "clx" \
            --no-internet-enable \
            "CapsLockX-setup-macos-arm64.dmg" \
            "$APP_DIR/" \
          || true
          test -f CapsLockX-setup-macos-arm64.dmg

      # Upload BEFORE the commit-back step (see the Windows job).
      - name: Upload macOS packages
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release upload "${{ github.ref_name }}" \
            CapsLockX-portable-macos-arm64.tar.gz \
            CapsLockX-setup-macos-arm64.dmg \
            --clobber

      # Commit binary to main only for stable releases. Best effort: `main`
      # requires pull requests, so a direct push is rejected (GH006).
      - name: Commit macOS binary to main
        if: "!contains(github.ref_name, '-')"
        continue-on-error: true
        run: |
          git fetch origin main
          git checkout main
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git pull origin main --rebase
          cp rs/target/release/capslockx capslockx-macos-arm64
          git add capslockx-macos-arm64
          git diff --cached --quiet || git commit -m "chore: update macOS binary for ${{ github.ref_name }}"
          git push origin main
