diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1574c835..5285c9b7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,24 +9,27 @@ jobs: runs-on: windows-latest steps: - name: Checkout code - uses: actions/checkout@v3 - + uses: actions/checkout@v4 + - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '22' - + - name: Install dependencies - run: npm ci - + run: npm ci --ignore-scripts + - name: Install app dependencies run: npx electron-builder install-app-deps - + - name: Build Windows app - run: npm run build:win - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + shell: pwsh + run: | + npm run build:platform-native-helpers + npx tsc + npx vite build + npx electron-builder --win nsis --x64 --publish never + - name: Upload Windows build uses: actions/upload-artifact@v4 with: @@ -38,63 +41,72 @@ jobs: runs-on: macos-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '22' - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Install app dependencies run: npx electron-builder install-app-deps - name: Build macOS app - run: npm run build:mac - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm run build:native-helpers + npx tsc + npx vite build + npx electron-builder --mac dmg zip --publish never - name: Upload macOS build uses: actions/upload-artifact@v4 with: name: macos-installer - path: release/**/*.dmg + path: | + release/**/*.dmg + release/**/*.zip + release/latest-mac.yml retention-days: 30 build-linux: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '22' - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Install app dependencies run: npx electron-builder install-app-deps - name: Build Linux app - run: npm run build:linux - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm run build:platform-native-helpers + npx tsc + npx vite build + npx electron-builder --linux AppImage --x64 --publish never - name: Upload Linux build uses: actions/upload-artifact@v4 with: name: linux-installer - path: release/**/*.AppImage + path: | + release/**/*.AppImage + release/latest-linux.yml retention-days: 30 diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml index 08df4aea..5f8549ec 100644 --- a/.github/workflows/homebrew-tap.yml +++ b/.github/workflows/homebrew-tap.yml @@ -1,8 +1,6 @@ name: Update Homebrew Tap on: - release: - types: [published] workflow_dispatch: inputs: tag: @@ -37,11 +35,7 @@ jobs: id: tag shell: bash run: | - if [ "${{ github.event_name }}" = "release" ]; then - TAG="${{ github.event.release.tag_name }}" - else - TAG="${{ github.event.inputs.tag }}" - fi + TAG="${{ github.event.inputs.tag }}" if [ -z "$TAG" ]; then echo "Release tag is empty" @@ -55,21 +49,33 @@ jobs: - name: Download release artifacts shell: bash run: | + set -euo pipefail mkdir -p /tmp/recordly-release gh release download "${{ steps.tag.outputs.tag }}" \ --repo "${{ github.repository }}" \ --dir /tmp/recordly-release \ - --pattern "Recordly-arm64.dmg" \ - --pattern "Recordly-x64.dmg" + --pattern "Recordly-*-arm64.dmg" \ + --pattern "Recordly-*-x64.dmg" - name: Compute checksums id: sha shell: bash run: | - SHA_ARM=$(sha256sum /tmp/recordly-release/Recordly-arm64.dmg | awk '{print $1}') - SHA_INTEL=$(sha256sum /tmp/recordly-release/Recordly-x64.dmg | awk '{print $1}') + set -euo pipefail + ARM_FILE=$(find /tmp/recordly-release -maxdepth 1 -name 'Recordly-*-arm64.dmg' | head -n 1) + INTEL_FILE=$(find /tmp/recordly-release -maxdepth 1 -name 'Recordly-*-x64.dmg' | head -n 1) + + if [ -z "$ARM_FILE" ] || [ -z "$INTEL_FILE" ]; then + echo "Unable to locate macOS release artifacts for ${{ steps.tag.outputs.tag }}" + exit 1 + fi + + SHA_ARM=$(sha256sum "$ARM_FILE" | awk '{print $1}') + SHA_INTEL=$(sha256sum "$INTEL_FILE" | awk '{print $1}') echo "arm=$SHA_ARM" >> "$GITHUB_OUTPUT" echo "intel=$SHA_INTEL" >> "$GITHUB_OUTPUT" + echo "arm_file=$(basename "$ARM_FILE")" >> "$GITHUB_OUTPUT" + echo "intel_file=$(basename "$INTEL_FILE")" >> "$GITHUB_OUTPUT" - name: Clone tap repository shell: bash @@ -94,12 +100,12 @@ jobs: on_arm do sha256 "__SHA_ARM__" - url "https://github.com/__REPO__/releases/download/__TAG__/Recordly-arm64.dmg" + url "https://github.com/__REPO__/releases/download/__TAG__/__ARM_FILE__" end on_intel do sha256 "__SHA_INTEL__" - url "https://github.com/__REPO__/releases/download/__TAG__/Recordly-x64.dmg" + url "https://github.com/__REPO__/releases/download/__TAG__/__INTEL_FILE__" end name "Recordly" @@ -115,6 +121,8 @@ jobs: sed -i "s/__SHA_INTEL__/${{ steps.sha.outputs.intel }}/g" Casks/recordly.rb sed -i "s#__REPO__#${{ github.repository }}#g" Casks/recordly.rb sed -i "s#__TAG__#${{ steps.tag.outputs.tag }}#g" Casks/recordly.rb + sed -i "s#__ARM_FILE__#${{ steps.sha.outputs.arm_file }}#g" Casks/recordly.rb + sed -i "s#__INTEL_FILE__#${{ steps.sha.outputs.intel_file }}#g" Casks/recordly.rb if git diff --quiet; then echo "changed=false" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec802e49..8f154120 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,93 +1,91 @@ -name: Release Builds +name: Publish Release on: + release: + types: [published, prereleased] workflow_dispatch: inputs: - source_ref: - description: Branch, tag, or commit to build from - required: true - default: main - type: string tag_name: - description: Release tag to create or update + description: Existing release tag to rebuild and publish required: true type: string - release_name: - description: Release title - required: true - type: string - release_notes: - description: Optional release notes - required: false - type: string - make_latest: - description: Mark this release as the latest release - required: true - default: true - type: boolean permissions: contents: write + actions: write + +concurrency: + group: release-${{ github.event.release.tag_name || github.event.inputs.tag_name || github.run_id }} + cancel-in-progress: false jobs: - validate-release: - name: Prepare release input + prepare-release: + name: Prepare release runs-on: ubuntu-latest outputs: + tag_name: ${{ steps.prepare.outputs.tag_name }} package_version: ${{ steps.prepare.outputs.package_version }} source_sha: ${{ steps.prepare.outputs.source_sha }} + prerelease: ${{ steps.prepare.outputs.prerelease }} steps: - - name: Checkout source ref + - name: Checkout release source uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.source_ref }} + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.tag_name }} fetch-depth: 0 - - name: Sync version with release tag + - name: Validate release tag id: prepare shell: bash run: | set -euo pipefail - INPUT_REF="${{ github.event.inputs.source_ref }}" - TAG_VERSION="${{ github.event.inputs.tag_name }}" - TAG_VERSION="${TAG_VERSION#v}" - SOURCE_BRANCH="" - - if git ls-remote --exit-code --heads origin "$INPUT_REF" >/dev/null 2>&1; then - SOURCE_BRANCH="$INPUT_REF" + if [ "${{ github.event_name }}" = "release" ]; then + TAG_NAME="${{ github.event.release.tag_name }}" + IS_PRERELEASE="${{ github.event.release.prerelease }}" + else + TAG_NAME="${{ github.event.inputs.tag_name }}" + IS_PRERELEASE="false" fi + if [ -z "$TAG_NAME" ]; then + echo "Release tag is required." + exit 1 + fi + + TAG_VERSION="${TAG_NAME#v}" PACKAGE_VERSION=$(node -p "require('./package.json').version") if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then - if [ -z "$SOURCE_BRANCH" ]; then - echo "Release tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION), and source_ref '$INPUT_REF' is not a branch that can be updated automatically." - exit 1 - fi - - npm version "$TAG_VERSION" --no-git-tag-version - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add package.json package-lock.json - git commit -m "chore(release): bump version to v$TAG_VERSION" - git push origin "HEAD:$SOURCE_BRANCH" - - PACKAGE_VERSION=$(node -p "require('./package.json').version") + echo "Release tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)." + exit 1 fi + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + gh release view "$TAG_NAME" --repo "${{ github.repository }}" >/dev/null 2>&1 || \ + gh release create "$TAG_NAME" --repo "${{ github.repository }}" --verify-tag --draft --title "$TAG_NAME" + fi + + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT" echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} build-macos-x64: name: Build macOS x64 - needs: validate-release + needs: prepare-release runs-on: macos-15-intel + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} steps: - name: Checkout code uses: actions/checkout@v4 with: - ref: ${{ needs.validate-release.outputs.source_sha }} + ref: ${{ needs.prepare-release.outputs.source_sha }} - name: Setup Node.js uses: actions/setup-node@v4 @@ -100,8 +98,36 @@ jobs: with: python-version: '3.11' + - name: Validate macOS signing secrets + shell: bash + env: + APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }} + APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + for name in APPLE_SIGNING_CERTIFICATE_P12_BASE64 APPLE_SIGNING_CERTIFICATE_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!name:-}" ]; then + echo "Missing required macOS release secret: $name" + exit 1 + fi + done + + - name: Prepare macOS signing certificate + shell: bash + env: + APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }} + APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + CERT_PATH="$RUNNER_TEMP/apple-signing-cert.p12" + echo "$APPLE_SIGNING_CERTIFICATE_P12_BASE64" | base64 --decode > "$CERT_PATH" + { + echo "CSC_LINK=$CERT_PATH" + echo "CSC_KEY_PASSWORD=$APPLE_SIGNING_CERTIFICATE_PASSWORD" + } >> "$GITHUB_ENV" + - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Install app dependencies run: npx electron-builder install-app-deps @@ -113,25 +139,255 @@ jobs: run: | npx tsc npx vite build - npx electron-builder --mac dmg --x64 --publish never - mv release/Recordly.dmg release/Recordly-x64.dmg + npx electron-builder --mac dmg zip --x64 --publish always - - name: Upload macOS x64 artifact + - name: Upload macOS x64 artifacts uses: actions/upload-artifact@v4 with: - name: macos-x64 - path: release/Recordly-x64.dmg + name: macos-x64-release + path: | + release/*.dmg + release/*.zip + release/latest-mac.yml if-no-files-found: error build-macos-arm64: name: Build macOS arm64 - needs: validate-release + needs: prepare-release runs-on: macos-14 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} steps: - name: Checkout code uses: actions/checkout@v4 with: - ref: ${{ needs.validate-release.outputs.source_sha }} + ref: ${{ needs.prepare-release.outputs.source_sha }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Validate macOS signing secrets + shell: bash + env: + APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }} + APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + for name in APPLE_SIGNING_CERTIFICATE_P12_BASE64 APPLE_SIGNING_CERTIFICATE_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!name:-}" ]; then + echo "Missing required macOS release secret: $name" + exit 1 + fi + done + + - name: Prepare macOS signing certificate + shell: bash + env: + APPLE_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_P12_BASE64 }} + APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + CERT_PATH="$RUNNER_TEMP/apple-signing-cert.p12" + echo "$APPLE_SIGNING_CERTIFICATE_P12_BASE64" | base64 --decode > "$CERT_PATH" + { + echo "CSC_LINK=$CERT_PATH" + echo "CSC_KEY_PASSWORD=$APPLE_SIGNING_CERTIFICATE_PASSWORD" + } >> "$GITHUB_ENV" + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Install app dependencies + run: npx electron-builder install-app-deps + + - name: Build native macOS helpers + run: npm run build:native-helpers + + - name: Build macOS arm64 + run: | + npx tsc + npx vite build + npx electron-builder --mac dmg zip --arm64 --publish always + + - name: Upload macOS arm64 artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-arm64-release + path: | + release/*.dmg + release/*.zip + release/latest-mac.yml + if-no-files-found: error + + merge-macos-update-metadata: + name: Merge macOS update metadata + needs: + - prepare-release + - build-macos-x64 + - build-macos-arm64 + runs-on: ubuntu-latest + steps: + - name: Download macOS x64 artifacts + uses: actions/download-artifact@v4 + with: + name: macos-x64-release + path: release-assets/macos-x64 + + - name: Download macOS arm64 artifacts + uses: actions/download-artifact@v4 + with: + name: macos-arm64-release + path: release-assets/macos-arm64 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install PyYAML + run: python -m pip install pyyaml + + - name: Merge latest-mac.yml + shell: bash + run: | + set -euo pipefail + mkdir -p release-assets/merged + python <<'PY' + from pathlib import Path + import yaml + + x64_info = yaml.safe_load(Path('release-assets/macos-x64/latest-mac.yml').read_text()) + arm64_info = yaml.safe_load(Path('release-assets/macos-arm64/latest-mac.yml').read_text()) + + merged_files = [] + seen_urls = set() + for info in (x64_info, arm64_info): + for file_info in info.get('files', []): + url = file_info.get('url') + if url and url not in seen_urls: + seen_urls.add(url) + merged_files.append(file_info) + + if not merged_files: + raise SystemExit('No macOS update files found to merge.') + + preferred = next((item for item in merged_files if 'x64' in item.get('url', '')), merged_files[0]) + merged = dict(x64_info) + merged['files'] = merged_files + merged['path'] = preferred.get('url', merged.get('path')) + merged['sha512'] = preferred.get('sha512', merged.get('sha512')) + if not merged.get('releaseDate') and arm64_info.get('releaseDate'): + merged['releaseDate'] = arm64_info['releaseDate'] + + Path('release-assets/merged/latest-mac.yml').write_text( + yaml.safe_dump(merged, sort_keys=False) + ) + PY + + - name: Upload merged latest-mac.yml to release + shell: bash + run: | + gh release upload "${{ needs.prepare-release.outputs.tag_name }}" \ + release-assets/merged/latest-mac.yml \ + --repo "${{ github.repository }}" \ + --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-windows-x64: + name: Build Windows x64 + needs: prepare-release + runs-on: windows-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare-release.outputs.source_sha }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Validate Windows signing secrets + shell: pwsh + env: + WINDOWS_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_P12_BASE64 }} + WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} + run: | + foreach ($name in @('WINDOWS_SIGNING_CERTIFICATE_P12_BASE64', 'WINDOWS_SIGNING_CERTIFICATE_PASSWORD')) { + $value = [Environment]::GetEnvironmentVariable($name) + if ([string]::IsNullOrWhiteSpace($value)) { + Write-Error "Missing required Windows release secret: $name" + exit 1 + } + } + + - name: Prepare Windows signing certificate + shell: pwsh + env: + WINDOWS_SIGNING_CERTIFICATE_P12_BASE64: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_P12_BASE64 }} + WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} + run: | + $certPath = Join-Path $env:RUNNER_TEMP 'windows-signing-cert.p12' + [IO.File]::WriteAllBytes($certPath, [Convert]::FromBase64String($env:WINDOWS_SIGNING_CERTIFICATE_P12_BASE64)) + Add-Content -Path $env:GITHUB_ENV -Value "WIN_CSC_LINK=$certPath" + Add-Content -Path $env:GITHUB_ENV -Value "WIN_CSC_KEY_PASSWORD=$env:WINDOWS_SIGNING_CERTIFICATE_PASSWORD" + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Install app dependencies + run: npx electron-builder install-app-deps + + - name: Build Windows x64 + shell: pwsh + run: | + npm run build:platform-native-helpers + npx tsc + npx vite build + npx electron-builder --win nsis --x64 --publish always + + - name: Upload Windows x64 artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-x64-release + path: | + release/*.exe + release/*.blockmap + release/latest*.yml + if-no-files-found: error + + build-linux-x64: + name: Build Linux x64 + needs: prepare-release + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare-release.outputs.source_sha }} - name: Setup Node.js uses: actions/setup-node@v4 @@ -150,154 +406,41 @@ jobs: - name: Install app dependencies run: npx electron-builder install-app-deps - - name: Build native macOS helpers - run: npm run build:native-helpers - - - name: Build macOS arm64 - run: | - npx tsc - npx vite build - npx electron-builder --mac dmg --arm64 --publish never - mv release/Recordly.dmg release/Recordly-arm64.dmg - - - name: Upload macOS arm64 artifact - uses: actions/upload-artifact@v4 - with: - name: macos-arm64 - path: release/Recordly-arm64.dmg - if-no-files-found: error - - build-windows-x64: - name: Build Windows x64 - needs: validate-release - runs-on: windows-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ needs.validate-release.outputs.source_sha }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install dependencies - run: npm ci - - - name: Install app dependencies - run: npx electron-builder install-app-deps - - - name: Build Windows x64 - shell: pwsh - run: | - npm run build:cursor-monitor - npx tsc - npx vite build - npx electron-builder --win nsis --x64 --publish never - Rename-Item -Path release/Recordly.exe -NewName Recordly-windows-x64.exe - - - name: Upload Windows x64 artifact - uses: actions/upload-artifact@v4 - with: - name: windows-x64 - path: release/Recordly-windows-x64.exe - if-no-files-found: error - - build-linux-x64: - name: Build Linux x64 - needs: validate-release - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ needs.validate-release.outputs.source_sha }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install dependencies - run: npm ci - - - name: Install app dependencies - run: npx electron-builder install-app-deps - - name: Build Linux x64 run: | + export CSC_IDENTITY_AUTO_DISCOVERY=false + npm run build:platform-native-helpers npx tsc npx vite build - npx electron-builder --linux AppImage --x64 --publish never - mv release/Recordly.AppImage release/Recordly-linux-x64.AppImage + npx electron-builder --linux AppImage --x64 --publish always - - name: Upload Linux x64 artifact + - name: Upload Linux x64 artifacts uses: actions/upload-artifact@v4 with: - name: linux-x64 - path: release/Recordly-linux-x64.AppImage + name: linux-x64-release + path: | + release/*.AppImage + release/*.blockmap + release/latest-linux.yml if-no-files-found: error - publish-release: - name: Publish release + trigger-homebrew-update: + name: Trigger Homebrew tap update needs: - - validate-release + - prepare-release - build-macos-x64 - build-macos-arm64 + - merge-macos-update-metadata - build-windows-x64 - build-linux-x64 runs-on: ubuntu-latest + if: needs.prepare-release.outputs.prerelease != 'true' steps: - - name: Download macOS x64 artifact - uses: actions/download-artifact@v4 - with: - name: macos-x64 - path: release-assets - - - name: Download macOS arm64 artifact - uses: actions/download-artifact@v4 - with: - name: macos-arm64 - path: release-assets - - - name: Download Windows x64 artifact - uses: actions/download-artifact@v4 - with: - name: windows-x64 - path: release-assets - - - name: Download Linux x64 artifact - uses: actions/download-artifact@v4 - with: - name: linux-x64 - path: release-assets - - - name: Create or update release - uses: ncipollo/release-action@v1 - with: - tag: ${{ github.event.inputs.tag_name }} - commit: ${{ needs.validate-release.outputs.source_sha }} - name: ${{ github.event.inputs.release_name }} - body: ${{ github.event.inputs.release_notes }} - makeLatest: ${{ github.event.inputs.make_latest }} - allowUpdates: true - replacesArtifacts: true - artifactErrorsFailBuild: true - artifacts: | - release-assets/Recordly-arm64.dmg - release-assets/Recordly-x64.dmg - release-assets/Recordly-windows-x64.exe - release-assets/Recordly-linux-x64.AppImage + - name: Dispatch Homebrew tap workflow + shell: bash + run: | + gh workflow run homebrew-tap.yml \ + --repo "${{ github.repository }}" \ + -f tag="${{ needs.prepare-release.outputs.tag_name }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index c5b8380b..bc0d1015 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Language: EN | [简中](README.zh-CN.md) ### Create polished, pro-grade screen recordings. [Recordly](https://www.recordly.dev) is an open-source screen recorder and editor for walkthroughs, demos, tutorials, product videos, and social clips. Record a screen or window, jump straight into the editor, and export a polished result with cursor effects, zooms, backgrounds, annotations, webcam overlays, and more. +Release automation, signing, notarization, and cross-platform auto-update publishing are documented in [RELEASING.md](RELEASING.md). +

Recordly demo video

diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..7af64644 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,90 @@ +# Releasing Recordly + +This repository now uses `electron-builder` + `electron-updater` for macOS, Windows, and Linux auto-updates. + +For this Electron app, that is the right path instead of wiring Sparkle.framework directly. On macOS, `electron-updater` handles the release metadata and update flow that Sparkle would otherwise cover in a native app, while still sharing the same GitHub Releases pipeline used by Windows and Linux. + +## What the release workflow does + +When you publish a GitHub release tagged like `v1.2.3`, `.github/workflows/release.yml` will: + +- validate that `package.json` is also `1.2.3` +- build signed macOS x64 and arm64 artifacts +- notarize the macOS builds +- merge the dual-architecture `latest-mac.yml` metadata into one release asset +- build and sign the Windows NSIS installer +- build the Linux AppImage +- publish installer artifacts and auto-update metadata files to the GitHub release +- dispatch the Homebrew tap workflow after the release assets are available + +The packaged app then checks GitHub Releases for: + +- `latest-mac.yml` +- `latest.yml` +- `latest-linux.yml` + +## Required GitHub secrets + +### macOS signing and notarization + +Set these repository secrets: + +- `APPLE_SIGNING_CERTIFICATE_P12_BASE64` +- `APPLE_SIGNING_CERTIFICATE_PASSWORD` +- `APPLE_ID` +- `APPLE_APP_SPECIFIC_PASSWORD` +- `APPLE_TEAM_ID` + +`APPLE_SIGNING_CERTIFICATE_P12_BASE64` must be a base64-encoded `.p12` export of a `Developer ID Application` certificate. + +If the certificate you currently have is only `Apple Development`, that is not enough for public notarized releases and auto-update distribution. You need `Developer ID Application`. + +To export and encode the certificate: + +```bash +security export -k ~/Library/Keychains/login.keychain-db -t identities -f pkcs12 -P "YOUR_P12_PASSWORD" -o recordly-mac-signing.p12 +base64 < recordly-mac-signing.p12 | pbcopy +``` + +Paste the copied base64 into `APPLE_SIGNING_CERTIFICATE_P12_BASE64` and the export password into `APPLE_SIGNING_CERTIFICATE_PASSWORD`. + +### Windows signing + +Set these repository secrets: + +- `WINDOWS_SIGNING_CERTIFICATE_P12_BASE64` +- `WINDOWS_SIGNING_CERTIFICATE_PASSWORD` + +These should point to an Authenticode code-signing certificate exported as `.p12` and then base64-encoded. + +### Homebrew tap automation + +Set this repository secret if you want the cask PR to open automatically: + +- `HOMEBREW_TAP_TOKEN` + +Optional repository variables: + +- `HOMEBREW_TAP_REPO` +- `HOMEBREW_TAP_AUTO_MERGE` + +## Release flow + +1. Bump `package.json` to the version you want to ship. +2. Commit and push that version. +3. Create a Git tag in the form `vX.Y.Z`. +4. In GitHub, create and publish a release for that tag. +5. The `Publish Release` workflow builds, signs, notarizes, uploads, and publishes update metadata. + +That is the normal path if you want “click new release and let CI do the rest.” + +## Rebuilding an existing release + +If you need to rerun publishing for an existing tag, use the manual dispatch for `.github/workflows/release.yml` and provide the existing tag. + +## Notes + +- macOS auto-updates require the `zip` target in addition to `dmg`, because `latest-mac.yml` is generated from the zipped build. +- macOS arm64 and x64 builds both publish updater zips, and the release workflow merges them into one `latest-mac.yml` so `electron-updater` can choose the correct architecture automatically. +- The release workflow publishes versioned artifact names so the generated update metadata matches the uploaded files. +- `build.yml` is intentionally forced to `--publish never` so ad hoc CI builds do not accidentally upload to a draft release. \ No newline at end of file diff --git a/electron-builder.json5 b/electron-builder.json5 index 0fd0b26e..b66ada09 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -2,6 +2,7 @@ { "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", "appId": "dev.recordly.app", + "electronUpdaterCompatibility": ">=2.16", "asar": true, "asarUnpack": [ "node_modules/ffmpeg-static/**", @@ -32,17 +33,32 @@ "to": "assets/wallpapers" } ], + "publish": [ + { + "provider": "github", + "owner": "webadderall", + "repo": "Recordly", + "tagNamePrefix": "v", + "publishAutoUpdate": true + } + ], "mac": { - "hardenedRuntime": false, + "hardenedRuntime": true, + "entitlements": "build/entitlements.mac.plist", + "entitlementsInherit": "build/entitlements.mac.inherit.plist", "target": [ { "target": "dmg", "arch": ["x64", "arm64"] + }, + { + "target": "zip", + "arch": ["x64", "arm64"] } ], "icon": "icons/icons/mac/icon.icns", - "artifactName": "Recordly.dmg", + "artifactName": "${productName}-${version}-${arch}.${ext}", "extendInfo": { "NSAudioCaptureUsageDescription": "Recordly needs audio capture permission to record system audio.", "NSMicrophoneUsageDescription": "Recordly needs microphone access to record voice audio.", @@ -55,7 +71,7 @@ "AppImage" ], "icon": "icons/icons/png", - "artifactName": "Recordly.AppImage", + "artifactName": "${productName}-${version}-${arch}.${ext}", "category": "AudioVideo" }, "win": { @@ -64,7 +80,8 @@ ], "icon": "icons/icons/win/icon.ico" , - "artifactName": "Recordly.exe" + "executableName": "Recordly", + "artifactName": "${productName}-Setup-${version}-${arch}.${ext}" } } diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index c6bbddcf..4cab9719 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -279,6 +279,18 @@ interface Window { message?: string; error?: string; }>; + installDownloadedUpdate: () => Promise<{ success: boolean }>; + deferDownloadedUpdate: (delayMs?: number) => Promise<{ + success: boolean; + message?: string; + }>; + previewUpdateToast: () => Promise<{ success: boolean }>; + onUpdateReadyToast: (callback: (payload: { + version: string; + detail: string; + delayMs: number; + isPreview?: boolean; + }) => void) => () => void; onMenuLoadProject: (callback: () => void) => () => void; onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; diff --git a/electron/main.ts b/electron/main.ts index 2a0e75e3..905e8d63 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -19,8 +19,19 @@ import { killWindowsCaptureProcess, registerIpcHandlers, } from "./ipc/handlers"; -import { checkForAppUpdates, setupAutoUpdates } from "./updater"; -import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows"; +import { + checkForAppUpdates, + deferDownloadedUpdateReminder, + installDownloadedUpdateNow, + previewUpdateToast, + setupAutoUpdates, +} from "./updater"; +import { + createEditorWindow, + createHudOverlayWindow, + createSourceSelectorWindow, + getHudOverlayWindow, +} from "./windows"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -231,9 +242,20 @@ function setupApplicationMenu() { { label: "Check for Updates…", click: () => { - void checkForAppUpdates(() => mainWindow, { manual: true }); + void checkForAppUpdates(getUpdateDialogWindow, { manual: true }); }, }, + ...(!app.isPackaged + ? [ + { type: "separator" as const }, + { + label: "Preview Update Toast", + click: () => { + previewUpdateToast(sendUpdateToastToWindows); + }, + }, + ] + : []), ], }, ); @@ -274,6 +296,60 @@ function syncDockIcon() { } } +function getUpdateWindowTargets() { + const targets: BrowserWindow[] = []; + const hudOverlayWindow = getHudOverlayWindow(); + + if (hudOverlayWindow) { + targets.push(hudOverlayWindow); + } + + if (mainWindow && !mainWindow.isDestroyed() && mainWindow !== hudOverlayWindow) { + targets.push(mainWindow); + } + + return targets; +} + +function sendUpdateToastToWindows(channel: "update-ready-toast", payload: unknown) { + const targets = getUpdateWindowTargets(); + if (targets.length === 0) { + return false; + } + + for (const target of targets) { + target.webContents.send(channel, payload); + } + + return true; +} + +function getUpdateDialogWindow() { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (focusedWindow && !focusedWindow.isDestroyed()) { + return focusedWindow; + } + + if (mainWindow && !mainWindow.isDestroyed()) { + return mainWindow; + } + + return getHudOverlayWindow(); +} + +ipcMain.handle("install-downloaded-update", () => { + installDownloadedUpdateNow(); + return { success: true }; +}); + +ipcMain.handle("defer-downloaded-update", (_event, delayMs?: number) => { + return deferDownloadedUpdateReminder(getUpdateDialogWindow, sendUpdateToastToWindows, delayMs); +}); + +ipcMain.handle("preview-update-toast", () => { + return { success: previewUpdateToast(sendUpdateToastToWindows) }; +}); + function updateTrayMenu(recording: boolean = false) { if (!tray) return; const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon; @@ -436,7 +512,7 @@ app.whenReady().then(async () => { }, ); - setupAutoUpdates(() => mainWindow); + setupAutoUpdates(getUpdateDialogWindow, sendUpdateToastToWindows); // Register the display media handler so that renderer's getDisplayMedia() // calls land on the pre-selected source without showing a system picker. diff --git a/electron/preload.ts b/electron/preload.ts index e25dde55..edb85fa8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -244,6 +244,30 @@ contextBridge.exposeInMainWorld("electronAPI", { openProjectsDirectory: () => { return ipcRenderer.invoke("open-projects-directory"); }, + installDownloadedUpdate: () => { + return ipcRenderer.invoke("install-downloaded-update"); + }, + deferDownloadedUpdate: (delayMs?: number) => { + return ipcRenderer.invoke("defer-downloaded-update", delayMs); + }, + previewUpdateToast: () => { + return ipcRenderer.invoke("preview-update-toast"); + }, + onUpdateReadyToast: ( + callback: (payload: { + version: string; + detail: string; + delayMs: number; + isPreview?: boolean; + }) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { version: string; detail: string; delayMs: number; isPreview?: boolean }, + ) => callback(payload); + ipcRenderer.on("update-ready-toast", listener); + return () => ipcRenderer.removeListener("update-ready-toast", listener); + }, onMenuLoadProject: (callback: () => void) => { const listener = () => callback(); ipcRenderer.on("menu-load-project", listener); diff --git a/electron/updater.ts b/electron/updater.ts index 9d2c2dc6..5387d46c 100644 --- a/electron/updater.ts +++ b/electron/updater.ts @@ -3,19 +3,38 @@ import { autoUpdater } from "electron-updater"; import type { MessageBoxOptions, MessageBoxReturnValue } from "electron"; const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; -const AUTO_UPDATES_ENABLED = process.env.RECORDLY_ENABLE_AUTO_UPDATES === "1"; +export const UPDATE_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000; +const AUTO_UPDATES_DISABLED = process.env.RECORDLY_DISABLE_AUTO_UPDATES === "1"; +const DEV_UPDATE_PREVIEW_INTERVAL_MS = 10 * 1000; + +export interface UpdateToastPayload { + version: string; + detail: string; + delayMs: number; + isPreview?: boolean; +} + +type UpdateToastSender = (channel: "update-ready-toast", payload: UpdateToastPayload) => boolean; let updaterInitialized = false; let updateCheckInProgress = false; let manualCheckRequested = false; let periodicCheckTimer: NodeJS.Timeout | null = null; +let deferredReminderTimer: NodeJS.Timeout | null = null; +let devPreviewTimer: NodeJS.Timeout | null = null; +let pendingDownloadedVersion: string | null = null; +let skippedVersion: string | null = null; function canUseAutoUpdates() { - return AUTO_UPDATES_ENABLED && app.isPackaged && !process.mas; + return !AUTO_UPDATES_DISABLED && app.isPackaged && !process.mas; +} + +function canUseDevUpdatePreview() { + return !app.isPackaged && Boolean(process.env.VITE_DEV_SERVER_URL); } export function isAutoUpdateFeatureEnabled() { - return AUTO_UPDATES_ENABLED; + return !AUTO_UPDATES_DISABLED; } function getDialogWindow(getMainWindow: () => BrowserWindow | null) { @@ -31,6 +50,32 @@ function showMessageBox( return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options); } +function clearDeferredReminderTimer() { + if (deferredReminderTimer) { + clearTimeout(deferredReminderTimer); + deferredReminderTimer = null; + } +} + +function clearDevPreviewTimer() { + if (devPreviewTimer) { + clearTimeout(devPreviewTimer); + devPreviewTimer = null; + } +} + +function sendUpdateToast(sendToRenderer: UpdateToastSender, payload: UpdateToastPayload) { + return sendToRenderer("update-ready-toast", payload); +} + +function createDownloadedUpdateToastPayload(version: string): UpdateToastPayload { + return { + version, + detail: "Restart now to install the update, or wait and we will remind you again in 3 hours.", + delayMs: UPDATE_REMINDER_DELAY_MS, + }; +} + async function showNoUpdatesDialog(getMainWindow: () => BrowserWindow | null) { await showMessageBox(getMainWindow, { type: "info", @@ -49,6 +94,112 @@ async function showUpdateErrorDialog(getMainWindow: () => BrowserWindow | null, }); } +function scheduleDevUpdatePreview(sendToRenderer: UpdateToastSender) { + clearDevPreviewTimer(); + devPreviewTimer = setTimeout(() => { + previewUpdateToast(sendToRenderer); + scheduleDevUpdatePreview(sendToRenderer); + }, DEV_UPDATE_PREVIEW_INTERVAL_MS); +} + +export function installDownloadedUpdateNow() { + clearDeferredReminderTimer(); + clearDevPreviewTimer(); + autoUpdater.quitAndInstall(); +} + +export function deferDownloadedUpdateReminder( + getMainWindow: () => BrowserWindow | null, + sendToRenderer?: UpdateToastSender, + delayMs = UPDATE_REMINDER_DELAY_MS, +) { + if (!pendingDownloadedVersion) { + return { success: false, message: "No downloaded update is ready yet." }; + } + + clearDeferredReminderTimer(); + deferredReminderTimer = setTimeout(() => { + if (pendingDownloadedVersion) { + if (sendToRenderer) { + const payload = createDownloadedUpdateToastPayload(pendingDownloadedVersion); + if (sendUpdateToast(sendToRenderer, payload)) { + return; + } + } + + void showDownloadedUpdateDialog(getMainWindow, pendingDownloadedVersion); + } + }, delayMs); + + return { success: true }; +} + +export function previewUpdateToast(sendToRenderer: UpdateToastSender) { + return sendUpdateToast(sendToRenderer, { + version: "9.9.9", + detail: "This is a development preview of the in-app update toast.", + delayMs: UPDATE_REMINDER_DELAY_MS, + isPreview: true, + }); +} + +async function showDownloadedUpdateDialog( + getMainWindow: () => BrowserWindow | null, + version: string, + options?: { isPreview?: boolean }, +) { + const isPreview = Boolean(options?.isPreview); + const result = await showMessageBox(getMainWindow, { + type: "info", + title: "Update Ready", + message: isPreview + ? `Recordly ${version} is ready to install.` + : `Recordly ${version} has been downloaded.`, + detail: isPreview + ? "Development preview of the native update prompt. No real update will be installed." + : "Install now, remind me in 3 hours, or skip this version.", + buttons: ["Update Now", "Remind Me in 3 Hours", "Skip This Version"], + defaultId: 0, + cancelId: 1, + noLink: true, + }); + + if (result.response === 0) { + if (isPreview) { + await showMessageBox(getMainWindow, { + type: "info", + title: "Preview Only", + message: "No real update was installed.", + detail: "The development preview will appear again in 10 seconds.", + }); + return; + } + + clearDeferredReminderTimer(); + setImmediate(() => { + installDownloadedUpdateNow(); + }); + return; + } + + if (result.response === 1) { + if (isPreview) { + return; + } + + deferDownloadedUpdateReminder(getMainWindow, undefined, UPDATE_REMINDER_DELAY_MS); + return; + } + + if (isPreview) { + return; + } + + skippedVersion = version; + pendingDownloadedVersion = null; + clearDeferredReminderTimer(); +} + export async function checkForAppUpdates( getMainWindow: () => BrowserWindow | null, options?: { manual?: boolean }, @@ -58,8 +209,10 @@ export async function checkForAppUpdates( await showMessageBox(getMainWindow, { type: "info", title: "Updates Not Enabled", - message: "Auto-updates are not enabled in this build.", - detail: "The updater infrastructure is present, but release publishing and signed distribution are not enabled yet.", + message: "Auto-updates are only available in packaged releases.", + detail: AUTO_UPDATES_DISABLED + ? "This build disabled auto-updates through RECORDLY_DISABLE_AUTO_UPDATES=1." + : "Development builds do not ship the packaged update metadata required by electron-updater.", }); } return; @@ -92,8 +245,30 @@ export async function checkForAppUpdates( } } -export function setupAutoUpdates(getMainWindow: () => BrowserWindow | null) { - if (updaterInitialized || !canUseAutoUpdates()) { +export function setupAutoUpdates( + getMainWindow: () => BrowserWindow | null, + sendToRenderer: UpdateToastSender, +) { + if (updaterInitialized) { + return; + } + + if (canUseDevUpdatePreview()) { + updaterInitialized = true; + scheduleDevUpdatePreview(sendToRenderer); + + app.on("before-quit", () => { + clearDeferredReminderTimer(); + clearDevPreviewTimer(); + if (periodicCheckTimer) { + clearInterval(periodicCheckTimer); + periodicCheckTimer = null; + } + }); + return; + } + + if (!canUseAutoUpdates()) { return; } @@ -111,7 +286,7 @@ export function setupAutoUpdates(getMainWindow: () => BrowserWindow | null) { type: "info", title: "Update Available", message: `Recordly ${info.version} is available.`, - detail: "The update is downloading in the background and you will be prompted to restart when it is ready.", + detail: "The update is downloading in the background and you will see a native update prompt when it is ready.", }); }); @@ -134,25 +309,20 @@ export function setupAutoUpdates(getMainWindow: () => BrowserWindow | null) { } }); - autoUpdater.on("update-downloaded", async (info) => { + autoUpdater.on("update-downloaded", (info) => { updateCheckInProgress = false; manualCheckRequested = false; - - const result = await showMessageBox(getMainWindow, { - type: "info", - title: "Update Ready", - message: `Recordly ${info.version} has been downloaded.`, - detail: "Restart the app now to install the update.", - buttons: ["Restart Now", "Later"], - defaultId: 0, - cancelId: 1, - }); - - if (result.response === 0) { - setImmediate(() => { - autoUpdater.quitAndInstall(); - }); + if (skippedVersion === info.version) { + return; } + pendingDownloadedVersion = info.version; + clearDeferredReminderTimer(); + + if (sendUpdateToast(sendToRenderer, createDownloadedUpdateToastPayload(info.version))) { + return; + } + + void showDownloadedUpdateDialog(getMainWindow, info.version); }); void checkForAppUpdates(getMainWindow); @@ -161,6 +331,8 @@ export function setupAutoUpdates(getMainWindow: () => BrowserWindow | null) { }, UPDATE_CHECK_INTERVAL_MS); app.on("before-quit", () => { + clearDeferredReminderTimer(); + clearDevPreviewTimer(); if (periodicCheckTimer) { clearInterval(periodicCheckTimer); periodicCheckTimer = null; diff --git a/electron/windows.ts b/electron/windows.ts index cb265237..ec390b93 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -273,6 +273,10 @@ export function createHudOverlayWindow(): BrowserWindow { return win; } +export function getHudOverlayWindow(): BrowserWindow | null { + return hudOverlayWindow && !hudOverlayWindow.isDestroyed() ? hudOverlayWindow : null; +} + export function createEditorWindow(): BrowserWindow { const isMac = process.platform === "darwin"; const { width, height } = getScreen().getPrimaryDisplay().workAreaSize; diff --git a/src/App.tsx b/src/App.tsx index bd5812b1..bcaf4fc2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,6 @@ +import { Gift } from "lucide-react"; import { useEffect, useState } from "react"; +import { toast } from "sonner"; import { CountdownOverlay } from "./components/countdown/CountdownOverlay"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; @@ -9,6 +11,13 @@ import { useI18n } from "./contexts/I18nContext"; import { ShortcutsProvider } from "./contexts/ShortcutsContext"; import { loadAllCustomFonts } from "./lib/customFonts"; +const UPDATE_TOAST_ID = "recordly-update-ready"; + +function formatDelayHours(delayMs: number) { + const hours = Math.max(1, Math.round(delayMs / (60 * 60 * 1000))); + return `${hours}h`; +} + export default function App() { const [windowType, setWindowType] = useState(""); const { locale, t } = useI18n(); @@ -40,6 +49,79 @@ export default function App() { windowType === "editor" ? t("app.editorTitle", "Recordly Editor") : t("app.name", "Recordly"); }, [windowType, locale, t]); + useEffect(() => { + if ( + windowType === "countdown" || + windowType === "source-selector" || + typeof window.electronAPI?.onUpdateReadyToast !== "function" + ) { + return; + } + + return window.electronAPI.onUpdateReadyToast((payload) => { + toast.custom( + (toastInstance) => ( +
+
+ +
+
+
+

+ {payload.isPreview ? "Update Toast Preview" : `Recordly ${payload.version} is ready`} +

+ {payload.isPreview ? ( + + Dev + + ) : null} +
+

{payload.detail}

+
+ + +
+
+
+ ), + { id: UPDATE_TOAST_ID, duration: Number.POSITIVE_INFINITY }, + ); + }); + }, [windowType]); + switch (windowType) { case "hud-overlay": return (