diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5de815..7b02336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} @@ -67,12 +67,25 @@ jobs: echo "OK: no dependabot commits authored by non-bot users." + tauri-devtools-guard: + name: Guard tauri devtools feature + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Guard against `devtools` Cargo feature on tauri + run: | + if grep -E 'tauri\s*=\s*\{[^}]*features\s*=\s*\[[^]]*devtools[^]]*\]' app/src-tauri/Cargo.toml; then + echo "::error::tauri crate has 'devtools' feature enabled — release builds would ship devtools." + exit 1 + fi + echo "OK: devtools feature not enabled." + typecheck: name: Typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -83,8 +96,8 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -95,8 +108,8 @@ jobs: name: Prettier runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -107,8 +120,8 @@ jobs: name: Unit tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -117,11 +130,15 @@ jobs: e2e: name: Playwright E2E (optional) - runs-on: ubuntu-22.04 + # Ubuntu 24.04 is required: Playwright 1.59 ships a WebKit build that + # links against libgtk-4, libavif.so.13, libmanette, libhyphen — none of + # which are available on the 22.04 image, so `browserType.launch` dies + # with "Host system is missing dependencies" before a single test runs. + runs-on: ubuntu-24.04 continue-on-error: true steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -138,12 +155,19 @@ jobs: VITE_IMPRINT_PHONE="+49 30 0000000" VITE_IMPRINT_RESPONSIBLE_PERSON="Max Mustermann" EOF + # Split install-deps from browser install: in Playwright 1.59 the + # `--with-deps` flag silently skips the apt-get step when the workspace + # is invoked via `yarn workspace`, leaving WebKit without its GTK-4 / + # flite / hyphen / manette bundle. Running install-deps directly under + # sudo fixes it for real. + - name: Install Playwright system deps + run: sudo yarn workspace @recrest/tests exec playwright install-deps - name: Install Playwright browsers - run: yarn workspace @recrest/tests exec playwright install --with-deps + run: yarn workspace @recrest/tests exec playwright install - run: yarn test:e2e - name: Upload Playwright report on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-report path: tests/playwright-report @@ -153,8 +177,8 @@ jobs: name: Storybook build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -165,8 +189,8 @@ jobs: name: Web production build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -178,8 +202,8 @@ jobs: name: Landingpage production build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -195,6 +219,7 @@ jobs: [ branch-name, dependabot-type, + tauri-devtools-guard, typecheck, lint, format, diff --git a/.github/workflows/deploy-landingpage.yml b/.github/workflows/deploy-landingpage.yml index 983eeb1..3adb4d4 100644 --- a/.github/workflows/deploy-landingpage.yml +++ b/.github/workflows/deploy-landingpage.yml @@ -23,8 +23,8 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -41,8 +41,8 @@ jobs: VITE_IMPRINT_EMAIL: ${{ secrets.VITE_IMPRINT_EMAIL }} VITE_IMPRINT_PHONE: ${{ secrets.VITE_IMPRINT_PHONE }} VITE_IMPRINT_RESPONSIBLE_PERSON: ${{ secrets.VITE_IMPRINT_RESPONSIBLE_PERSON }} - - uses: actions/configure-pages@v5 - - uses: actions/upload-pages-artifact@v3 + - uses: actions/configure-pages@v6 + - uses: actions/upload-pages-artifact@v5 with: path: landingpage/dist @@ -55,4 +55,4 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release-tauri-beta.yml b/.github/workflows/release-tauri-beta.yml index 939d2d8..ae372c0 100644 --- a/.github/workflows/release-tauri-beta.yml +++ b/.github/workflows/release-tauri-beta.yml @@ -24,16 +24,16 @@ jobs: name: Generate installer assets runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ inputs.ref }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn - uses: ./.github/actions/install-deps - run: yarn workspace @recrest/app gen:installer-assets - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: installer-assets-build path: app/src-tauri/installer-assets/build/ @@ -58,11 +58,11 @@ jobs: runs-on: ${{ matrix.platform }} continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: ${{ inputs.ref }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -89,36 +89,63 @@ jobs: - uses: ./.github/actions/install-deps - name: Download installer-asset bitmaps - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: installer-assets-build path: app/src-tauri/installer-assets/build/ - - name: Build Tauri app (unsigned) - if: ${{ env.TAURI_SIGNING_PRIVATE_KEY == '' }} - uses: tauri-apps/tauri-action@v0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - with: - args: ${{ matrix.args }} - projectPath: ./app - - - name: Build Tauri app (signed) - if: ${{ env.TAURI_SIGNING_PRIVATE_KEY != '' }} - uses: tauri-apps/tauri-action@v0 + # Forward signing secrets only when populated. Tauri's bundler reads the + # signing env vars via `std::env::var`, which returns `Ok("")` for set- + # but-empty — not `Err(NotPresent)`. Piping `${{ secrets.FOO }}` directly + # into `env:` therefore triggers sign/notarize paths even when FOO is + # unset in the repo, failing e.g. `security import` with an empty .p12. + # This step writes each secret to $GITHUB_ENV only if non-empty, so + # missing secrets stay genuinely unset and Tauri skips the feature. + - name: Forward signing secrets (when configured) + shell: bash env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -eu + forward() { + local name="$1" + local val="${!name-}" + if [ -n "$val" ]; then + { + printf '%s<<__RECREST_EOF__\n' "$name" + printf '%s\n' "$val" + printf '__RECREST_EOF__\n' + } >> "$GITHUB_ENV" + echo "forwarded: $name" + else + echo "skipped (empty): $name" + fi + } + forward APPLE_CERTIFICATE + forward APPLE_CERTIFICATE_PASSWORD + forward APPLE_SIGNING_IDENTITY + forward APPLE_ID + forward APPLE_PASSWORD + forward APPLE_TEAM_ID + forward WINDOWS_CERTIFICATE + forward WINDOWS_CERTIFICATE_PASSWORD + forward TAURI_SIGNING_PRIVATE_KEY + forward TAURI_SIGNING_PRIVATE_KEY_PASSWORD + + - name: Build Tauri app + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} with: args: ${{ matrix.args }} projectPath: ./app @@ -140,7 +167,7 @@ jobs: ls -la "stage/${{ matrix.os }}" || true - name: Upload staging artifact (${{ matrix.os }}) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: recrest-beta-staging-${{ matrix.os }} path: stage/ @@ -154,7 +181,7 @@ jobs: if: ${{ always() && needs.build.result != 'cancelled' }} steps: - name: Download all staging artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: recrest-beta-staging-* path: merged/ @@ -164,7 +191,7 @@ jobs: run: ls -R merged || true - name: Upload consolidated artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: recrest-beta-${{ github.sha }} path: merged/ diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 3a7765d..29440ea 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -1,36 +1,108 @@ -# Triggered by the tag that release-please creates when its Release PR is merged. +# Triggered automatically by the tag that release-please creates when its +# Release PR is merged. Can also be re-run manually against an existing tag +# (defaults to the latest release) — the manual path deletes the current +# release but keeps the git tag, so assets get rebuilt from scratch without +# having to bump the version or rewrite the tag. +# # Builds Tauri installers for Linux / Windows / macOS, bündelt sie pro OS in # ein ZIP (`recrest--windows.zip` / `-macos.zip` / `-linux.zip`) und lädt -# diese als GitHub-Release-Assets hoch. Runs unsigned until -# TAURI_SIGNING_PRIVATE_KEY is populated — signing secrets are passed through -# only when they exist. +# diese als GitHub-Release-Assets hoch. Signing/notarization features activate +# automatically when the matching secrets are populated. name: Release (Tauri) on: push: tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to rebuild (e.g. v0.7.0). Leave empty to rebuild the latest release." + required: false + default: "" permissions: contents: write jobs: + # Resolve the target tag once and share it with every downstream job. For a + # `push` trigger this is `github.ref_name`; for `workflow_dispatch` it's the + # input (when set) or the most recent release tag (draft or published). + # Downstream jobs must use `needs.resolve.outputs.tag` instead of + # `github.ref_name`, because on manual dispatch from a branch the ref is the + # branch (e.g. `main`), not the release tag. + resolve: + name: Resolve target tag + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.r.outputs.tag }} + steps: + - id: r + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + INPUT_TAG: ${{ inputs.tag }} + REPO: ${{ github.repository }} + shell: bash + run: | + set -eu + if [ "$EVENT" = "push" ]; then + resolved="$REF_NAME" + echo "Resolved from push tag: $resolved" + elif [ -n "${INPUT_TAG:-}" ]; then + resolved="$INPUT_TAG" + echo "Resolved from dispatch input: $resolved" + else + resolved="$(gh release list --repo "$REPO" --limit 1 --json tagName --jq '.[0].tagName' || true)" + if [ -z "$resolved" ]; then + echo "::error::No existing release found and no tag input provided." + exit 1 + fi + echo "Resolved from latest release: $resolved" + fi + echo "tag=$resolved" >> "$GITHUB_OUTPUT" + # Generates the NSIS / DMG branding bitmaps from the SVG sources once on # Linux and passes them to every platform build via an artifact. The # `tauri.conf.json` references these files directly; without them, the # Windows NSIS bundler fails with `os error 3` and the macOS DMG bundler # aborts in `bundle_dmg.sh`. + # + # For a manual re-run this job also resets the existing release so + # `tauri-action` can recreate it cleanly (otherwise it would collide on + # asset names and leave stale files behind). The git tag is preserved. prepare-assets: name: Generate installer assets + needs: resolve runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + with: + ref: ${{ needs.resolve.outputs.tag }} + + - name: Reset existing release (manual dispatch) + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.resolve.outputs.tag }} + REPO: ${{ github.repository }} + shell: bash + run: | + set -eu + if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + echo "Deleting existing release $TAG (git tag is preserved via --cleanup-tag=false)" + gh release delete "$TAG" --repo "$REPO" --yes --cleanup-tag=false + else + echo "No existing release for $TAG — nothing to reset" + fi + + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn - uses: ./.github/actions/install-deps - run: yarn workspace @recrest/app gen:installer-assets - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: installer-assets-build path: app/src-tauri/installer-assets/build/ @@ -38,7 +110,7 @@ jobs: if-no-files-found: error build: - needs: prepare-assets + needs: [resolve, prepare-assets] strategy: fail-fast: false matrix: @@ -55,9 +127,11 @@ jobs: # manually (gh release upload) or by re-running the failed leg once fixed. continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + with: + ref: ${{ needs.resolve.outputs.tag }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version-file: .nvmrc cache: yarn @@ -84,11 +158,19 @@ jobs: - uses: ./.github/actions/install-deps - name: Download installer-asset bitmaps - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: installer-assets-build path: app/src-tauri/installer-assets/build/ + - name: Guard against unpopulated updater pubkey + shell: bash + run: | + if grep -q 'REPLACE_WITH_MINISIGN_PUBKEY' app/src-tauri/tauri.conf.json; then + echo "::error::tauri.conf.json still contains the updater pubkey placeholder." + exit 1 + fi + - name: Load release notes id: release_notes shell: bash @@ -99,39 +181,61 @@ jobs: echo 'EOF' } >> "$GITHUB_OUTPUT" - - name: Build Tauri app (unsigned) - if: ${{ env.TAURI_SIGNING_PRIVATE_KEY == '' }} - uses: tauri-apps/tauri-action@v0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - with: - tagName: ${{ github.ref_name }} - releaseName: "Recrest ${{ github.ref_name }}" - releaseBody: ${{ steps.release_notes.outputs.body }} - releaseDraft: true - prerelease: false - args: ${{ matrix.args }} - projectPath: ./app - - - name: Build Tauri app (signed) - if: ${{ env.TAURI_SIGNING_PRIVATE_KEY != '' }} - uses: tauri-apps/tauri-action@v0 + # Forward signing secrets only when populated. Tauri's bundler reads the + # signing env vars via `std::env::var`, which returns `Ok("")` for set- + # but-empty — not `Err(NotPresent)`. Piping `${{ secrets.FOO }}` directly + # into `env:` therefore triggers sign/notarize paths even when FOO is + # unset in the repo, failing e.g. `security import` with an empty .p12. + # This step writes each secret to $GITHUB_ENV only if non-empty, so + # missing secrets stay genuinely unset and Tauri skips the feature. + - name: Forward signing secrets (when configured) + shell: bash env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -eu + forward() { + local name="$1" + local val="${!name-}" + if [ -n "$val" ]; then + { + printf '%s<<__RECREST_EOF__\n' "$name" + printf '%s\n' "$val" + printf '__RECREST_EOF__\n' + } >> "$GITHUB_ENV" + echo "forwarded: $name" + else + echo "skipped (empty): $name" + fi + } + forward APPLE_CERTIFICATE + forward APPLE_CERTIFICATE_PASSWORD + forward APPLE_SIGNING_IDENTITY + forward APPLE_ID + forward APPLE_PASSWORD + forward APPLE_TEAM_ID + forward WINDOWS_CERTIFICATE + forward WINDOWS_CERTIFICATE_PASSWORD + forward TAURI_SIGNING_PRIVATE_KEY + forward TAURI_SIGNING_PRIVATE_KEY_PASSWORD + + - name: Build Tauri app + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} with: - tagName: ${{ github.ref_name }} - releaseName: "Recrest ${{ github.ref_name }}" + tagName: ${{ needs.resolve.outputs.tag }} + releaseName: "Recrest ${{ needs.resolve.outputs.tag }}" releaseBody: ${{ steps.release_notes.outputs.body }} releaseDraft: true prerelease: false @@ -144,15 +248,17 @@ jobs: # Download-Pakete statt einer langen Dateiliste zeigt. repackage: name: Repackage into per-OS ZIPs - needs: build + needs: [resolve, build] runs-on: ubuntu-latest + env: + TAG: ${{ needs.resolve.outputs.tag }} steps: - name: Download release assets env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | mkdir -p dl - gh release download "${{ github.ref_name }}" \ + gh release download "$TAG" \ --repo "${{ github.repository }}" \ --dir dl \ --pattern '*.dmg' \ @@ -162,6 +268,8 @@ jobs: --pattern '*.deb' \ --pattern '*.rpm' \ --pattern '*.app.tar.gz' \ + --pattern '*.sig' \ + --pattern 'latest.json' \ || true ls -la dl || true @@ -182,12 +290,11 @@ jobs: mv "$f" stage/linux/ done - tag="${{ github.ref_name }}" mkdir -p out produced=() for os in windows macos linux; do if [ -n "$(ls -A "stage/$os" 2>/dev/null)" ]; then - zip_name="recrest-${tag}-${os}.zip" + zip_name="recrest-${TAG}-${os}.zip" (cd stage && zip -r "../out/$zip_name" "$os") produced+=("out/$zip_name") echo "built: $zip_name" @@ -210,36 +317,73 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - xargs -a zips.list gh release upload "${{ github.ref_name }}" \ - --repo "${{ github.repository }}" \ - --clobber + set -euo pipefail + # Explicit per-file loop with visible logging. An earlier `xargs -a + # zips.list gh release upload ...` version occasionally completed + # silently without actually publishing the zips, leaving 404s on + # the release page. This loop makes each upload observable and fails + # loudly on any individual upload error. + while IFS= read -r zipfile; do + [ -n "$zipfile" ] || continue + echo "uploading: $zipfile" + gh release upload "$TAG" "$zipfile" \ + --repo "${{ github.repository }}" \ + --clobber + done < zips.list - - name: Remove original single-installer assets + - name: Prune single-installer assets (keep updater + zip + checksums) if: steps.zip.outputs.uploaded == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - for f in dl/*; do - [ -f "$f" ] || continue - asset="$(basename "$f")" - echo "deleting old asset: $asset" - gh release delete-asset "${{ github.ref_name }}" "$asset" \ + # Iterate over the actual release assets (not dl/, which was drained + # by the zip step) and keep only what the updater / download flow + # still needs: + # *.msi — Windows updater payload (tauri-action updater bundle) + # *.app.tar.gz — macOS updater payload + # *.AppImage — Linux updater payload + # *.sig — Minisign signatures for updater payloads + # latest.json — updater manifest + # *.zip — per-OS packages users actually download + # SHA256SUMS.txt — checksums file added by the `checksums` job + # Everything else (.dmg / .deb / .rpm / .exe) is bundled inside the + # per-OS ZIP and duplicates the download list — remove to keep the + # release page focused. + keep_patterns=("*.msi" "*.app.tar.gz" "*.AppImage" "*.sig" "latest.json" "*.zip" "SHA256SUMS.txt") + assets="$(gh release view "$TAG" \ + --repo "${{ github.repository }}" \ + --json assets \ + --jq '.assets[].name')" + while IFS= read -r asset; do + [ -n "$asset" ] || continue + keep=0 + for p in "${keep_patterns[@]}"; do + case "$asset" in $p) keep=1;; esac + done + if [ "$keep" = 1 ]; then + echo "keep: $asset" + continue + fi + echo "deleting: $asset" + gh release delete-asset "$TAG" "$asset" \ --repo "${{ github.repository }}" \ - --yes || true - done + --yes + done <<< "$assets" checksums: name: Checksums - needs: repackage + needs: [resolve, repackage] runs-on: ubuntu-latest + env: + TAG: ${{ needs.resolve.outputs.tag }} steps: - name: Download per-OS zips env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | mkdir -p assets - gh release download "${{ github.ref_name }}" \ + gh release download "$TAG" \ --repo "${{ github.repository }}" \ --dir assets \ --pattern '*.zip' \ @@ -259,7 +403,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release upload "${{ github.ref_name }}" \ + gh release upload "$TAG" \ assets/SHA256SUMS.txt \ --repo "${{ github.repository }}" \ --clobber diff --git a/.gitignore b/.gitignore index 8e7314c..8768d7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# Tauri updater signing keys — private key MUST never be committed; pubkey +# lives in app/src-tauri/tauri.conf.json so the standalone .pub file is also +# just a local artefact. +.tauri-key +.tauri-key.pub + # Logs logs *.log diff --git a/.release-please-manifest.json b/.release-please-manifest.json index bcd0522..e7ca613 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.6.0" + ".": "0.7.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b9599f2..7a276e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ All notable changes to Recrest are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [SemVer](https://semver.org/spec/v2.0.0.html). +## [0.7.0] — 2026-04-22 + +Third beta. Headline additions are the in-app auto-updater, the Developer tab, native OS notifications, and a page-transition animation pass. The stylesheet layer also migrated from flat CSS to SCSS. + +### Added + +- In-app auto-update system — background check against GitHub Releases with an `UpdaterBanner` prompt, manual "check for updates" action in Settings, version comparison that handles pre-release tags (`0.7.0-beta.1` > `0.6.9`), and `useLastSeenVersion` for what's-new indicators after an update. +- `Developer` tab in Settings — feature-flag toggles, in-app state inspectors, diagnostics dumps, and a dev-only Redux slice (`uiDevFlagsSlice`) persisted separately from user settings. Gated by `useDevFlag`. +- Native OS notifications (`commands/notifications.rs`) with per-trigger preferences in the new `NotificationSettings` tab. Triggers cover PR events, update availability, and scan completion; full suite of `useNotificationTriggers` tests. +- Page mount/transition animations across Dashboard, Repos, Branches, MergeRequests, and RepoDetail. Full plan in `docs/plans/page-mount-animations.md`. +- `Mascot` atom (animated brand character) with Storybook coverage; used on onboarding and empty-state screens. +- `TruncatedTooltip` compound molecule — shows the full value on hover only when content is actually truncated. +- Distinct dev-build app icon (white chevrons + orange `` badge) so `yarn dev` is visually distinguishable from the installed app in taskbar/dock. `tauri:dev` loads `tauri.dev.conf.json` to swap `bundle.icon` to `icons-dev/`; `tauri:build` keeps the production icon. +- `README-signing.md` in `src-tauri/` documenting the code-signing approach (and why installers currently ship unsigned). +- Installer-asset CI pipeline — regenerated installer assets land on `main` through a dedicated workflow. + +### Changed + +- Stylesheet layer migrated from plain CSS to SCSS (`tokens`, `layout`, `page-anim`, `views`) in both `app/` and `landingpage/`. No new build-step dependencies — Vite's built-in SCSS handling covers both. +- `ImportFromProviderDialog` rewritten — clearer provider/org/repo selection flow, inline validation, and expanded keyboard navigation. +- `DetailPane`, `Sidebar`, `Titlebar` (Win11 + GNOME), `RepoRow`, and `RepoList` refactored for faster initial render and smaller re-render surfaces. +- `UpdaterBanner` redesigned around the new updater command surface; dismiss/install states persist across sessions. +- `notify` bumped to 8.2 and `notify-debouncer-full` to 0.7 for more reliable filesystem event coalescing under Windows. +- Provider API surface (`providers/api.rs`, `github.rs`, `gitlab.rs`, `bitbucket.rs`) aligned around a shared typed error path to prepare for the GitLab/Bitbucket rollout. +- Dependabot sweeps: `@types/node` → 25.6.0, actions-all group (7 updates), npm-all group across 3 workspaces (6 updates). + +### Fixed + +- Playwright E2E stabilised on `ubuntu-24.04` — WebKit system libs reinstated (the older 22.04 runner no longer packages GTK 4 / libavif 13 / libmanette / libhyphen). Download-button spec realigned with the current DOM. +- Subpage navigation edge cases (blank transitions, scroll position loss) on Branches, MergeRequests, and RepoDetail. +- Loading-time regressions on Dashboard and RepoDetail — async work now runs in parallel instead of sequencing through the store. +- `RepoWatcher` documentation updated to reflect that it is already wired into `lib.rs::run()`. + +### Known gaps + +- GitLab and Bitbucket providers still return "not yet implemented". +- OAuth remains scaffolded; PAT-only auth for now. +- Installers are unsigned (Apple Developer ID / Windows EV certs pending). + ## [0.6.0] — 2026-04-21 Second beta. Headline additions are the Activity dashboard, native window chrome per OS, a guided onboarding flow, and IDE integration. @@ -64,5 +103,6 @@ First public beta. - Installers are unsigned — macOS Gatekeeper / Windows SmartScreen will warn on first launch. - `RepoWatcher` is not yet instantiated in `lib.rs::run()`, so status refreshes on explicit reload. +[0.7.0]: https://github.com/SoftVentures/Recrest/releases/tag/v0.7.0 [0.6.0]: https://github.com/SoftVentures/Recrest/releases/tag/v0.6.0 [0.5.1]: https://github.com/SoftVentures/Recrest/releases/tag/v0.5.1 diff --git a/CLAUDE.md b/CLAUDE.md index 6ace0e9..0198957 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,5 @@ Rust commands are registered in `app/src-tauri/src/lib.rs::run()`. DTOs use `#[s ## Known scope gaps (not bugs) -- `RepoWatcher` is implemented but not yet instantiated in `lib.rs::run()`. - OAuth is scaffolded; MVP ships PAT-only auth. -- Tauri icon PNGs are not in `app/src-tauri/icons/` yet — `yarn build` will fail until they're added. `yarn dev:web` works without them. - GitLab/Bitbucket providers return `not yet implemented` errors from `list_pull_requests`. diff --git a/RELEASE.md b/RELEASE.md index 537aa95..1f3e05e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,43 +1,60 @@ -# Recrest 0.6.0 — Activity insights, atomic UI, native window chrome +# Recrest 0.7.0 — Auto-updater, Developer tab, native notifications -Second beta of Recrest. The big story is the new **Activity** dashboard and a full UI refactor into atomic-design layers (atoms / molecules / organisms) with Storybook coverage across the board. Under the hood, the shell gained OS-native titlebars, a guided onboarding flow, and a lot of CI / platform polish. +Third beta of Recrest. The headline additions are a working **in-app auto-updater**, a new **Developer** tab for power users, **native OS notifications**, and a page-transition animation pass that makes the whole shell feel less static. Under the hood, stylesheets migrated from flat CSS to SCSS and the dev build now carries its own icon so you can tell `yarn dev` apart from the installed app. Still a beta — treat it as "use it, tell us what's broken" rather than "rely on it in your daily loop". ## What's new -### Activity dashboard +### In-app auto-updater -A new route that turns your local repos into insight cards — no cloud, everything computed from your own git data: +Recrest now checks GitHub Releases on startup (after a short delay) and again every four hours: -- **Heroes**: commits, authors, open PRs, CI health. -- **Contributor cards**: leaderboard, author-clock, streak. -- **Code cards**: churn, language donut, heatmap, stacked activity chart. -- **Pull-request cards**: PR velocity, time-to-merge, review queue. -- **CI cards**: pass rate, flaky repos, quietest repos, busiest peak. +- An `UpdaterBanner` appears when a newer tag is available, with install / dismiss / remind-me states persisted across sessions. +- Manual "check for updates" action lives in Settings → Updates. +- Version comparison handles pre-release tags correctly (`0.7.0-beta.1` > `0.6.9`), so shipping betas alongside stable doesn't confuse users running either channel. +- `useLastSeenVersion` remembers the version the user last opened, so "what's new" cues can appear after an update. -Each card is independent — filter by repo or time window and the dashboard reshapes. +### Developer tab -### Native window chrome +A new Settings tab for people who want to poke at the app: -The titlebar adapts to the host OS instead of forcing one look everywhere: +- Feature-flag toggles persisted in a dedicated Redux slice (`uiDevFlagsSlice`) — separate from user settings so toggling doesn't pollute your real preferences. +- In-app inspectors for Redux state, IPC traffic, and environment details. +- Diagnostics dump for bug reports. -- **Windows 11** — custom titlebar with snap-layouts affordance. -- **GNOME / Linux** — CSD-style titlebar matching Adwaita conventions. -- **macOS** — transparent overlay respecting traffic-light spacing. +The tab is gated by `useDevFlag`, so the surface area is zero for regular users. -### Onboarding wizard +### Native OS notifications -First-run flow walks new users through: welcome → basics → pick folder → connect provider (optional) → initial scan → done. Skippable per step. +System-level notifications for the events that matter: -### IDE integration +- PR events, update availability, scan completion. +- Per-trigger toggles in the new `NotificationSettings` tab — nothing is on by default that you didn't ask for. +- Backed by `commands/notifications.rs` on the Rust side with a full test suite on `useNotificationTriggers`. -- **Open in IDE** button on repo and PR rows, with live detection of installed IDEs (VS Code, JetBrains family, Zed, Sublime, Xcode, Android Studio). -- Branded icons for each IDE in the picker. +### Page transitions -### UI refactor +Dashboard, Repositories, Branches, Merge Requests, and Repo Detail now animate on mount and on route change. Timing and easing are documented in `docs/plans/page-mount-animations.md`. -Every component was moved into an atomic-design hierarchy (`atoms/ molecules/ organisms/`) with colocated Storybook stories and Vitest tests. The impact on day-to-day usage is small, but the codebase is now consistent from top to bottom and much easier to contribute to. +### Mascot & empty states + +New `Mascot` atom (animated brand character) appears on onboarding and empty-state screens. `EmptyState` itself got a friendlier layout. + +### Dev-build icon + +`yarn dev` and the installed app no longer share a taskbar icon. The dev build renders with a white-chevron + orange `` badge variant. `tauri:dev` loads a minimal `tauri.dev.conf.json` overlay that swaps `bundle.icon` to `icons-dev/`; `tauri:build` ignores the overlay. + +### Stylesheets moved to SCSS + +`tokens`, `layout`, `page-anim`, and `views` now live as `.scss` in both `app/` and `landingpage/`. No new dependencies — Vite handles SCSS natively. Day-to-day usage is identical; nesting and mixins are now available to contributors. + +### Under-the-hood polish + +- `ImportFromProviderDialog` rewritten — clearer provider/org/repo flow, inline validation, full keyboard navigation. +- `DetailPane`, `Sidebar`, `Titlebar`, `RepoRow`, and `RepoList` refactored for faster initial render. +- `TruncatedTooltip` shows full text on hover only when the content is actually truncated. +- `notify` → 8.2 and `notify-debouncer-full` → 0.7 for more reliable filesystem event coalescing on Windows. ## Install @@ -55,25 +72,19 @@ shasum -a 256 -c SHA256SUMS.txt # macOS Get-FileHash -Algorithm SHA256 # Windows PowerShell ``` -## Platform & build improvements +## Upgrading from 0.6.0 -- **Beta build workflow** — `release-tauri-beta.yml` produces unsigned installers from any ref without creating a release, useful for dogfooding before cutting a tag. -- **Linux build fix** — webkit / gtk / appindicator dependencies pinned; AppImage / deb / rpm now build cleanly on `ubuntu-22.04`. -- **macOS config split** — `tauri.macos.conf.json` isolates mac-specific entitlements so base config stays lean. -- **Refined installer assets** — DMG background, NSIS header / sidebar regenerated from SVG sources. -- **Pre-push hook** — husky gates every push with typecheck + lint + format before the network leaves your machine. +If you already run 0.6.0, the new updater will pick up this release automatically on next launch. No manual migration needed — settings and the keychain-stored tokens are preserved. ## Known limitations - GitLab and Bitbucket providers still return "not yet implemented" — arriving in a later release. - Auth is PAT-only; OAuth is scaffolded but not user-facing yet. - Installers remain **unsigned** — macOS Gatekeeper / Windows SmartScreen will warn on first launch. Verify via the `SHA256SUMS.txt` above. -- `RepoWatcher` is wired on the Rust side but not yet hooked into the runtime — repo status refreshes on explicit reload. -- Activity cards currently read from local git only; PR / CI metrics populate once a provider token is connected. ## Why unsigned? -Recrest is an open-source project without a paid code-signing cert. Apple Developer ID runs at $99/year, Windows EV certs start around $300/year. Installers are built straight from this tag by GitHub Actions — the build log is public, and the checksums above let you verify what you ran matches what was built. +Recrest is an open-source project without a paid code-signing cert. Apple Developer ID runs at $99/year, Windows EV certs start around $300/year. Installers are built straight from this tag by GitHub Actions — the build log is public, and the checksums above let you verify what you ran matches what was built. See `app/src-tauri/README-signing.md` for the full rationale. ## Feedback diff --git a/app/CLAUDE.md b/app/CLAUDE.md index d31c4e2..77c3778 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -43,10 +43,19 @@ Strict flags that bite: `noUncheckedIndexedAccess` (array/object index access re - `Cargo.toml` uses `git2` with `vendored-libgit2` (no system libgit2 needed) and `keyring` with native backends. - `git/scanner.rs` calls `skip_current_dir` on discovery so nested repos aren't re-scanned. -- `git/watcher.rs` is **built but not wired into `lib.rs::run()` yet** — when you wire it, hold its state in `AppState` and start it after the first scan. +- `git/watcher.rs` is instantiated in `lib.rs::run()` and held in `AppState.watcher`; it auto-subscribes existing repos on startup and is kept in sync by the `commands/repos.rs` add/remove paths and `commands/clone.rs`. Any new command that creates or removes a repo must update the watcher too. - `providers/r#trait.rs` is the shared async-trait surface. Tokens are accessed exclusively through `auth::token::TokenStore` (keyring); never serialize them into `settings.json`. - Add a crate: `cargo add ` inside `src-tauri/`. Watch that it works under `vendored-libgit2` linking; avoid crates that pull in a second libgit2. +## App icons (production vs dev) + +Two icon sets live under `src-tauri/`: + +- `icons/` — production build icon (dark chevrons on a white square). Sources aren't regenerated routinely; if you need to refresh them, feed `src/assets/recrest-icon-light.svg` to `tauri icon`. +- `icons-dev/` — dev build icon (white chevrons with an orange `` badge bottom-right), so `yarn dev` is visually distinct from the installed app in the taskbar/dock. Regenerate with `yarn workspace @recrest/app gen:dev-icons` whenever you edit `src/assets/recrest-icon-dev.svg`. + +`tauri:dev` passes `--config src-tauri/tauri.dev.conf.json`, a minimal overlay that swaps `bundle.icon` to point at `icons-dev/`. Only `tauri dev` picks it up; `tauri build` ignores the overlay and keeps the production icon. Do not duplicate other fields in the overlay — keep it strictly about the icon swap so production config stays the single source of truth. + ## Redux + i18n - Five slices in `src/store/slices/`. Async thunks inside each slice own the `invoke` calls — components dispatch, they don't call IPC directly. diff --git a/app/package.json b/app/package.json index a787590..cf9abf6 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@recrest/app", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "scripts": { @@ -8,9 +8,10 @@ "build": "tsc -b && vite build", "preview": "vite preview", "tauri": "tauri", - "tauri:dev": "tauri dev", + "tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json", "tauri:build": "tauri build", "gen:installer-assets": "node src-tauri/installer-assets/generate.mjs", + "gen:dev-icons": "tauri icon src/assets/recrest-icon-dev.svg -o src-tauri/icons-dev", "preformat": "node src/scripts/fix-imports.js", "format": "prettier --write 'src/**/*.{ts,tsx,json,css}'", "format:check": "prettier --check 'src/**/*.{ts,tsx,json,md,css}'", @@ -82,7 +83,7 @@ "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", - "@types/node": "^22.7.4", + "@types/node": "^25.6.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@typescript-eslint/eslint-plugin": "^8.8.0", @@ -91,10 +92,11 @@ "eslint": "^9.12.0", "eslint-plugin-react": "^7.37.1", "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.12", + "eslint-plugin-react-refresh": "^0.5.2", "jimp": "^1.6.0", "jsdom": "^25.0.1", "madge": "^8.0.0", + "sass": "^1.83.0", "storybook": "^10.3.5", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.2.5", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index fb36b9e..c488587 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -2299,11 +2299,11 @@ dependencies = [ [[package]] name = "inotify" -version = "0.10.2" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.1", "inotify-sys", "libc", ] @@ -2317,15 +2317,6 @@ dependencies = [ "libc", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -2917,12 +2908,11 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" [[package]] name = "notify" -version = "7.0.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ "bitflags 2.11.1", - "filetime", "fsevent-sys", "inotify", "kqueue", @@ -2931,14 +2921,14 @@ dependencies = [ "mio", "notify-types", "walkdir", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] name = "notify-debouncer-full" -version = "0.4.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcf855483228259b2353f89e99df35fc639b2b2510d1166e4858e3f67ec1afb" +checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302" dependencies = [ "file-id", "log", @@ -2963,11 +2953,11 @@ dependencies = [ [[package]] name = "notify-types" -version = "1.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "instant", + "bitflags 2.11.1", ] [[package]] @@ -4038,7 +4028,7 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "recrest" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "async-trait", @@ -4077,6 +4067,7 @@ dependencies = [ "uuid", "walkdir", "which", + "windows-sys 0.59.0", ] [[package]] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 8fad858..81d1281 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "recrest" -version = "0.6.0" # x-release-please-version +version = "0.7.0" # x-release-please-version description = "Recrest — A lightweight developer dashboard" authors = ["SoftVentures"] edition = "2021" @@ -33,8 +33,8 @@ serde_json = "1.0" thiserror = "2.0" tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "process", "io-util"] } git2 = { version = "0.19", default-features = false, features = ["vendored-libgit2", "vendored-openssl", "https", "ssh", "ssh_key_from_memory"] } -notify = "7.0" -notify-debouncer-full = "0.4" +notify = "8.2" +notify-debouncer-full = "0.7" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } keyring = { version = "3.5", features = ["apple-native", "windows-native", "linux-native"] } url = "2.5" @@ -51,6 +51,13 @@ base64 = "0.22.1" which = "8" fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" } +[target.'cfg(windows)'.dependencies] +# Only needed for setting the AppUserModelID so Windows Toast notifications +# attribute to "Recrest" instead of the parent process (e.g. powershell.exe +# in dev). `windows-sys` is ~150kb and compiles in ~2s, much lighter than +# the full `windows` crate. +windows-sys = { version = "0.59", features = ["Win32_UI_Shell", "Win32_Foundation"] } + [features] custom-protocol = ["tauri/custom-protocol"] diff --git a/app/src-tauri/README-signing.md b/app/src-tauri/README-signing.md new file mode 100644 index 0000000..0445361 --- /dev/null +++ b/app/src-tauri/README-signing.md @@ -0,0 +1,41 @@ +# Updater Signing Keys + +The `plugins.updater.pubkey` value in `tauri.conf.json` is a **DEV** minisign +public key generated with a placeholder password. It exists so the updater +plugin can initialize and CI release builds don't crash on missing config, but +it is **not suitable for production use**. + +## Before the first real signed release + +1. Regenerate the keypair with a strong password: + ```bash + yarn workspace @recrest/app tauri signer generate -p "" -f -w /tmp/recrest-prod.key + ``` +2. Store both artefacts in 1Password (SoftVentures vault, item "Recrest updater signing"): + - the private key file contents (`recrest-prod.key`), base64-encoded + - the password used in step 1 +3. Add GitHub Actions secrets on `SoftVentures/Recrest`: + - `TAURI_SIGNING_PRIVATE_KEY` — contents of `recrest-prod.key` (base64, single line) + - `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — the strong password +4. Replace the `pubkey` value in `app/src-tauri/tauri.conf.json` with the + contents of the matching `recrest-prod.key.pub` (the whole base64 blob, + comment line and key line are both included — Tauri accepts the file + content verbatim). +5. Delete `/tmp/recrest-prod.key` from disk once secrets are populated. +6. Commit the pubkey change, tag a release, verify the resulting `latest.json` + and `.sig` files on the Release page. + +## Why a placeholder pubkey is committed now + +The `tauri-plugin-updater` crate validates the pubkey format at startup. A +literal `REPLACE_WITH_MINISIGN_PUBKEY` string would fail schema checks and +prevent the app from launching in release mode, so we ship a real (but +throwaway) keypair. The private key is **not** in the repo; it was discarded +right after the pubkey was extracted. Any release built against this key will +carry matching signatures but anyone with the placeholder password can forge +update payloads — hence this must be rotated before the first production +release. + +The release workflow (`.github/workflows/release-tauri.yml`) contains a grep +guard that refuses to build if the pubkey is still the literal placeholder +sentinel `REPLACE_WITH_MINISIGN_PUBKEY`. diff --git a/app/src-tauri/icons-dev/128x128.png b/app/src-tauri/icons-dev/128x128.png new file mode 100644 index 0000000..bf72f33 Binary files /dev/null and b/app/src-tauri/icons-dev/128x128.png differ diff --git a/app/src-tauri/icons-dev/128x128@2x.png b/app/src-tauri/icons-dev/128x128@2x.png new file mode 100644 index 0000000..f56333a Binary files /dev/null and b/app/src-tauri/icons-dev/128x128@2x.png differ diff --git a/app/src-tauri/icons-dev/32x32.png b/app/src-tauri/icons-dev/32x32.png new file mode 100644 index 0000000..a225269 Binary files /dev/null and b/app/src-tauri/icons-dev/32x32.png differ diff --git a/app/src-tauri/icons-dev/64x64.png b/app/src-tauri/icons-dev/64x64.png new file mode 100644 index 0000000..e7b9395 Binary files /dev/null and b/app/src-tauri/icons-dev/64x64.png differ diff --git a/app/src-tauri/icons-dev/Square107x107Logo.png b/app/src-tauri/icons-dev/Square107x107Logo.png new file mode 100644 index 0000000..6ec6c31 Binary files /dev/null and b/app/src-tauri/icons-dev/Square107x107Logo.png differ diff --git a/app/src-tauri/icons-dev/Square142x142Logo.png b/app/src-tauri/icons-dev/Square142x142Logo.png new file mode 100644 index 0000000..56525cf Binary files /dev/null and b/app/src-tauri/icons-dev/Square142x142Logo.png differ diff --git a/app/src-tauri/icons-dev/Square150x150Logo.png b/app/src-tauri/icons-dev/Square150x150Logo.png new file mode 100644 index 0000000..d4e6f09 Binary files /dev/null and b/app/src-tauri/icons-dev/Square150x150Logo.png differ diff --git a/app/src-tauri/icons-dev/Square284x284Logo.png b/app/src-tauri/icons-dev/Square284x284Logo.png new file mode 100644 index 0000000..54ca4bb Binary files /dev/null and b/app/src-tauri/icons-dev/Square284x284Logo.png differ diff --git a/app/src-tauri/icons-dev/Square30x30Logo.png b/app/src-tauri/icons-dev/Square30x30Logo.png new file mode 100644 index 0000000..921233e Binary files /dev/null and b/app/src-tauri/icons-dev/Square30x30Logo.png differ diff --git a/app/src-tauri/icons-dev/Square310x310Logo.png b/app/src-tauri/icons-dev/Square310x310Logo.png new file mode 100644 index 0000000..d656d3b Binary files /dev/null and b/app/src-tauri/icons-dev/Square310x310Logo.png differ diff --git a/app/src-tauri/icons-dev/Square44x44Logo.png b/app/src-tauri/icons-dev/Square44x44Logo.png new file mode 100644 index 0000000..54db7bc Binary files /dev/null and b/app/src-tauri/icons-dev/Square44x44Logo.png differ diff --git a/app/src-tauri/icons-dev/Square71x71Logo.png b/app/src-tauri/icons-dev/Square71x71Logo.png new file mode 100644 index 0000000..b86a27d Binary files /dev/null and b/app/src-tauri/icons-dev/Square71x71Logo.png differ diff --git a/app/src-tauri/icons-dev/Square89x89Logo.png b/app/src-tauri/icons-dev/Square89x89Logo.png new file mode 100644 index 0000000..703b3a3 Binary files /dev/null and b/app/src-tauri/icons-dev/Square89x89Logo.png differ diff --git a/app/src-tauri/icons-dev/StoreLogo.png b/app/src-tauri/icons-dev/StoreLogo.png new file mode 100644 index 0000000..efb679a Binary files /dev/null and b/app/src-tauri/icons-dev/StoreLogo.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-anydpi-v26/ic_launcher.xml b/app/src-tauri/icons-dev/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/app/src-tauri/icons-dev/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src-tauri/icons-dev/android/mipmap-hdpi/ic_launcher.png b/app/src-tauri/icons-dev/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..2c0c593 Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-hdpi/ic_launcher_foreground.png b/app/src-tauri/icons-dev/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..179be32 Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-hdpi/ic_launcher_round.png b/app/src-tauri/icons-dev/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..0a545e4 Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-mdpi/ic_launcher.png b/app/src-tauri/icons-dev/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..9f437ca Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-mdpi/ic_launcher_foreground.png b/app/src-tauri/icons-dev/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ef78f87 Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-mdpi/ic_launcher_round.png b/app/src-tauri/icons-dev/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..16c2d55 Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-xhdpi/ic_launcher.png b/app/src-tauri/icons-dev/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..379afdc Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-xhdpi/ic_launcher_foreground.png b/app/src-tauri/icons-dev/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..98e1b11 Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-xhdpi/ic_launcher_round.png b/app/src-tauri/icons-dev/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..da6a0bc Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-xxhdpi/ic_launcher.png b/app/src-tauri/icons-dev/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..cfc877e Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src-tauri/icons-dev/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e5b528d Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-xxhdpi/ic_launcher_round.png b/app/src-tauri/icons-dev/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d174720 Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-xxxhdpi/ic_launcher.png b/app/src-tauri/icons-dev/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..6a2eb3b Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src-tauri/icons-dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..442da09 Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src-tauri/icons-dev/android/mipmap-xxxhdpi/ic_launcher_round.png b/app/src-tauri/icons-dev/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..2fe856b Binary files /dev/null and b/app/src-tauri/icons-dev/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src-tauri/icons-dev/android/values/ic_launcher_background.xml b/app/src-tauri/icons-dev/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/app/src-tauri/icons-dev/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/app/src-tauri/icons-dev/icon.icns b/app/src-tauri/icons-dev/icon.icns new file mode 100644 index 0000000..7a99518 Binary files /dev/null and b/app/src-tauri/icons-dev/icon.icns differ diff --git a/app/src-tauri/icons-dev/icon.ico b/app/src-tauri/icons-dev/icon.ico new file mode 100644 index 0000000..a4bd56b Binary files /dev/null and b/app/src-tauri/icons-dev/icon.ico differ diff --git a/app/src-tauri/icons-dev/icon.png b/app/src-tauri/icons-dev/icon.png new file mode 100644 index 0000000..92c96eb Binary files /dev/null and b/app/src-tauri/icons-dev/icon.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-20x20@1x.png b/app/src-tauri/icons-dev/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..9b5b024 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-20x20@1x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-20x20@2x-1.png b/app/src-tauri/icons-dev/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..298e8a3 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-20x20@2x-1.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-20x20@2x.png b/app/src-tauri/icons-dev/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..298e8a3 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-20x20@2x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-20x20@3x.png b/app/src-tauri/icons-dev/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..98d606b Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-20x20@3x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-29x29@1x.png b/app/src-tauri/icons-dev/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..c47fb8b Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-29x29@1x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-29x29@2x-1.png b/app/src-tauri/icons-dev/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..068496e Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-29x29@2x-1.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-29x29@2x.png b/app/src-tauri/icons-dev/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..068496e Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-29x29@2x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-29x29@3x.png b/app/src-tauri/icons-dev/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..e5a4b90 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-29x29@3x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-40x40@1x.png b/app/src-tauri/icons-dev/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..298e8a3 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-40x40@1x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-40x40@2x-1.png b/app/src-tauri/icons-dev/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..46a1be0 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-40x40@2x-1.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-40x40@2x.png b/app/src-tauri/icons-dev/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..46a1be0 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-40x40@2x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-40x40@3x.png b/app/src-tauri/icons-dev/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..889a5b4 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-40x40@3x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-512@2x.png b/app/src-tauri/icons-dev/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..4f2cd84 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-512@2x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-60x60@2x.png b/app/src-tauri/icons-dev/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..889a5b4 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-60x60@2x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-60x60@3x.png b/app/src-tauri/icons-dev/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..1e17b78 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-60x60@3x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-76x76@1x.png b/app/src-tauri/icons-dev/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..98f3314 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-76x76@1x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-76x76@2x.png b/app/src-tauri/icons-dev/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..6103640 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-76x76@2x.png differ diff --git a/app/src-tauri/icons-dev/ios/AppIcon-83.5x83.5@2x.png b/app/src-tauri/icons-dev/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..b6147a3 Binary files /dev/null and b/app/src-tauri/icons-dev/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/app/src-tauri/src/commands/dev.rs b/app/src-tauri/src/commands/dev.rs new file mode 100644 index 0000000..8cb7873 --- /dev/null +++ b/app/src-tauri/src/commands/dev.rs @@ -0,0 +1,78 @@ +// Debug-build-only developer helpers. Registration in lib.rs is gated on +// `#[cfg(debug_assertions)]` so release builds never expose these. +#![cfg(debug_assertions)] + +use std::path::PathBuf; + +use serde::Serialize; +use tauri::{AppHandle, Manager}; + +use super::error::CommandError; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DevPaths { + pub config_dir: Option, + pub data_dir: Option, + pub cache_dir: Option, + pub log_dir: Option, + /// Directory containing the currently-running binary (`target/debug` or + /// `target/release`). Resolved via `current_exe().parent()` so it survives + /// out-of-tree `cargo` target directories too. + pub binary_dir: Option, + /// Workspace root inferred by walking up from `binary_dir` until a + /// sibling `Cargo.toml` or `app/src-tauri` is visible. Falls back to the + /// binary's parent if nothing matches (useful when running an installed + /// build outside the source tree). + pub workspace_root: Option, +} + +#[tauri::command] +pub async fn get_dev_paths(app: AppHandle) -> Result { + let p = app.path(); + let binary_dir = std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|p| p.to_path_buf())); + let workspace_root = binary_dir.as_deref().and_then(infer_workspace_root); + Ok(DevPaths { + config_dir: p.app_config_dir().ok(), + data_dir: p.app_data_dir().ok(), + cache_dir: p.app_cache_dir().ok(), + log_dir: p.app_log_dir().ok(), + binary_dir, + workspace_root, + }) +} + +/// Walk up from `start` looking for a `package.json` next to a `yarn.lock` — +/// our monorepo root shape. Returns the first ancestor that matches, or None +/// if we run out of parents (installed builds outside the source tree). +fn infer_workspace_root(start: &std::path::Path) -> Option { + let mut cur = start; + for _ in 0..8 { + let has_pkg = cur.join("package.json").is_file(); + let has_yarn = cur.join("yarn.lock").is_file(); + if has_pkg && has_yarn { + return Some(cur.to_path_buf()); + } + let Some(parent) = cur.parent() else { break }; + cur = parent; + } + None +} + +#[tauri::command] +pub async fn get_build_triple() -> Result { + // Constructed from std::env::consts — TARGET isn't a real env var in Cargo + // at runtime, but the OS/arch combo is enough signal for devs. + Ok(format!( + "{os}-{arch}", + os = std::env::consts::OS, + arch = std::env::consts::ARCH + )) +} + +#[tauri::command] +pub async fn dev_panic() -> Result<(), CommandError> { + panic!("dev_panic triggered by Developer tab"); +} diff --git a/app/src-tauri/src/commands/git_ops.rs b/app/src-tauri/src/commands/git_ops.rs index b91f259..b59ab93 100644 --- a/app/src-tauri/src/commands/git_ops.rs +++ b/app/src-tauri/src/commands/git_ops.rs @@ -115,8 +115,7 @@ fn install_credentials(callbacks: &mut RemoteCallbacks<'_>, provider_id: Option< if !has_recrest_token && !tried_helper { tried_helper = true; if let Ok(config) = git2::Config::open_default() { - if let Ok(cred) = - git2::Cred::credential_helper(&config, url, username_from_url) + if let Ok(cred) = git2::Cred::credential_helper(&config, url, username_from_url) { return Ok(cred); } @@ -231,10 +230,9 @@ pub async fn git_fetch_all(state: State<'_, AppState>) -> Result ok += 1, Ok(Err(e)) => tracing::debug!("fetch_all: one repo skipped: {e:?}"), @@ -385,9 +383,11 @@ pub async fn git_branch_create( } let path = resolve_repo_path(&state, &repo_id).await?; let name_clone = name.clone(); - tokio::task::spawn_blocking(move || branch_create_blocking(&path, &name_clone, from.as_deref(), checkout)) - .await - .map_err(|e| CommandError::internal(format!("branch_create task failed: {e}")))??; + tokio::task::spawn_blocking(move || { + branch_create_blocking(&path, &name_clone, from.as_deref(), checkout) + }) + .await + .map_err(|e| CommandError::internal(format!("branch_create task failed: {e}")))??; let path2 = resolve_repo_path(&state, &repo_id).await?; Ok(status::read_status(&path2)?) } @@ -443,6 +443,10 @@ fn fetch_blocking(path: &Path, provider_id: Option<&str>) -> Result<(), CommandE install_credentials(&mut callbacks, effective); let mut opts = FetchOptions::new(); opts.remote_callbacks(callbacks); + // Prune refs/remotes/origin/* for branches that were deleted upstream. + // Without this, merged Dependabot / feature branches keep showing up in + // the Branches view long after they were removed on the host. + opts.prune(git2::FetchPrune::On); remote .fetch(&[] as &[&str], Some(&mut opts), None) .map_err(|e| CommandError::internal(format!("fetch failed: {e}")))?; @@ -473,14 +477,14 @@ fn merge_blocking( } if source == head_branch { - return Err(CommandError::bad_request("source and target are the same branch")); + return Err(CommandError::bad_request( + "source and target are the same branch", + )); } // Refuse to merge with a dirty working tree — mirrors git's own safety rail. let mut status_opts = git2::StatusOptions::new(); - status_opts - .include_untracked(false) - .include_ignored(false); + status_opts.include_untracked(false).include_ignored(false); let dirty = repo .statuses(Some(&mut status_opts)) .map(|s| s.iter().any(|e| e.status().bits() != 0)) @@ -595,7 +599,8 @@ fn is_valid_branch_name(name: &str) -> bool { if name.is_empty() || name.len() > 240 { return false; } - if name.starts_with('-') || name.starts_with('/') || name.ends_with('/') || name.ends_with('.') { + if name.starts_with('-') || name.starts_with('/') || name.ends_with('/') || name.ends_with('.') + { return false; } if name.contains("..") || name.contains("//") || name.contains("@{") { @@ -655,11 +660,7 @@ fn branch_create_blocking( Ok(()) } -fn checkout_remote_blocking( - path: &Path, - remote: &str, - branch: &str, -) -> Result<(), CommandError> { +fn checkout_remote_blocking(path: &Path, remote: &str, branch: &str) -> Result<(), CommandError> { let repo = Repository::open(path) .map_err(|e| CommandError::internal(format!("open repo failed: {e}")))?; @@ -669,9 +670,9 @@ fn checkout_remote_blocking( } let remote_ref = format!("refs/remotes/{remote}/{branch}"); - let reference = repo - .find_reference(&remote_ref) - .map_err(|_| CommandError::bad_request(format!("remote branch '{remote}/{branch}' not found")))?; + let reference = repo.find_reference(&remote_ref).map_err(|_| { + CommandError::bad_request(format!("remote branch '{remote}/{branch}' not found")) + })?; let commit = reference .peel_to_commit() .map_err(|e| CommandError::internal(format!("peel remote ref failed: {e}")))?; @@ -770,18 +771,18 @@ fn pull_blocking(path: &Path, provider_id: Option<&str>) -> Result<(), CommandEr .to_string(); let upstream_ref = format!("refs/remotes/origin/{branch_shorthand}"); - let upstream = repo - .find_reference(&upstream_ref) - .map_err(|e| CommandError::bad_request(format!("no upstream for {branch_shorthand}: {e}")))?; + let upstream = repo.find_reference(&upstream_ref).map_err(|e| { + CommandError::bad_request(format!("no upstream for {branch_shorthand}: {e}")) + })?; let upstream_oid = upstream .target() .ok_or_else(|| CommandError::internal("upstream ref has no target"))?; // Fast-forward only. If the merge-base isn't HEAD, we refuse. let (analysis, _) = repo - .merge_analysis(&[&repo.find_annotated_commit(upstream_oid).map_err(|e| { - CommandError::internal(format!("annotated commit failed: {e}")) - })?]) + .merge_analysis(&[&repo + .find_annotated_commit(upstream_oid) + .map_err(|e| CommandError::internal(format!("annotated commit failed: {e}")))?]) .map_err(|e| CommandError::internal(format!("merge analysis failed: {e}")))?; if analysis.is_up_to_date() { diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index aaea673..3bf8003 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -15,4 +15,7 @@ pub mod settings; pub mod system; pub mod terminal; pub mod tray; +pub mod update; +#[cfg(debug_assertions)] +pub mod dev; pub mod window; diff --git a/app/src-tauri/src/commands/notifications.rs b/app/src-tauri/src/commands/notifications.rs index 08fada4..a09eabf 100644 --- a/app/src-tauri/src/commands/notifications.rs +++ b/app/src-tauri/src/commands/notifications.rs @@ -17,10 +17,32 @@ pub enum NotificationKind { Generic, } +impl NotificationKind { + fn as_str(&self) -> &'static str { + match self { + NotificationKind::NewPr => "new_pr", + NotificationKind::CiFailed => "ci_failed", + NotificationKind::MergeReady => "merge_ready", + NotificationKind::Generic => "generic", + } + } +} + /// Shows a desktop notification if both the master toggle and the per-kind /// toggle are enabled. A `Generic` kind is only gated on the master toggle — /// callers can use it for one-off user-driven notifications (e.g. "clone /// finished") without adding new settings. +/// +/// The `url` field is accepted for forward compatibility: we'd like clicking +/// the toast to open the related PR in the user's browser, but +/// `tauri-plugin-notification` v2.3 only exposes click-through action +/// handling (`Action` / `register_action_types`) on *mobile* targets +/// (`src/mobile.rs`). The desktop impl in that crate calls through to +/// `notify_rust::Notification::show()` with no callback and throws away the +/// returned handle, so there's nowhere to attach an on-click today. We still +/// ship `url` in the IPC payload (and re-emit it on the debug dev event +/// below) so the dev preview UI and the eventual WebKit/Linux specific +/// click plumbing have a stable contract to build on. #[tauri::command] pub async fn notify( app: AppHandle, @@ -28,6 +50,11 @@ pub async fn notify( kind: NotificationKind, title: String, body: String, + // `url` is only consumed by the debug `dev://last-notification` emit below; + // in release builds the plugin has no desktop click hook, so it's genuinely + // unused. Silence the release-only warning here until the plugin grows an + // on-click API (see TODO(notification-click) below). + #[allow(unused_variables)] url: Option, ) -> Result<(), CommandError> { let allowed = { let config = state.config.lock().await; @@ -53,5 +80,33 @@ pub async fn notify( .body(&body) .show() .map_err(|e| CommandError::internal(format!("notification failed: {e}")))?; + + // TODO(notification-click): once tauri-plugin-notification exposes + // desktop click/action callbacks, re-route them through + // `tauri_plugin_opener::OpenerExt::open_url(&url, None::<&str>)` so the + // toast jumps straight to the PR. Until then the URL lives in the + // payload for UI previews and the dev event below only. + + // Debug-only: re-emit the full payload so `tests/` and the + // DevNotificationPreview panel can observe what *would* have surfaced + // natively, independent of OS-level notification permission state. + #[cfg(debug_assertions)] + { + use tauri::Emitter; + let _ = app.emit( + "dev://last-notification", + serde_json::json!({ + "kind": kind.as_str(), + "title": title, + "body": body, + "url": url, + }), + ); + } + #[cfg(not(debug_assertions))] + { + let _ = kind.as_str(); + } + Ok(()) } diff --git a/app/src-tauri/src/commands/repos.rs b/app/src-tauri/src/commands/repos.rs index e7f4c46..32d52d8 100644 --- a/app/src-tauri/src/commands/repos.rs +++ b/app/src-tauri/src/commands/repos.rs @@ -82,10 +82,33 @@ pub async fn scan_repos( #[tauri::command] pub async fn list_repos(state: State<'_, AppState>) -> Result, CommandError> { - let config = state.config.lock().await; - let mut out = Vec::new(); - for record in config.settings().repos.values() { - let status = status::read_status(&record.path).unwrap_or_else(|_| status::RepoStatusDto::unknown()); + // Snapshot the repo records and drop the config lock before hitting + // git2 — read_status is I/O-heavy and serializing here would keep every + // other command waiting on the same mutex. + let records: Vec<_> = { + let config = state.config.lock().await; + config.settings().repos.values().cloned().collect() + }; + + // git2 is synchronous, so each read_status used to run serially on the + // async executor thread — 8 repos × ~2s dominated app boot time. Fan + // out to the blocking pool so statuses are computed concurrently; we + // still preserve the original order when zipping the results back. + let handles: Vec<_> = records + .iter() + .map(|r| { + let path = r.path.clone(); + tokio::task::spawn_blocking(move || { + status::read_status(&path).unwrap_or_else(|_| status::RepoStatusDto::unknown()) + }) + }) + .collect(); + + let mut out = Vec::with_capacity(records.len()); + for (record, handle) in records.iter().zip(handles) { + let status = handle + .await + .unwrap_or_else(|_| status::RepoStatusDto::unknown()); out.push(RepoDto::from_record(record, status)); } Ok(out) @@ -116,7 +139,10 @@ pub async fn add_repo( let mut config = state.config.lock().await; let mut record = config.upsert_scanned_repo(std::path::Path::new(&path))?; record.group_id = group_id.clone(); - config.settings_mut().repos.insert(record.id.clone(), record.clone()); + config + .settings_mut() + .repos + .insert(record.id.clone(), record.clone()); config.save(&app)?; drop(config); let status = status::read_status(&record.path)?; @@ -192,7 +218,9 @@ fn collect_recent_commits( Ok(h) => h, Err(_) => return Ok(()), }; - let Some(head_oid) = head.target() else { return Ok(()) }; + let Some(head_oid) = head.target() else { + return Ok(()); + }; let mut revwalk = repo.revwalk()?; revwalk.set_sorting(git2::Sort::TIME)?; @@ -200,13 +228,19 @@ fn collect_recent_commits( for oid in revwalk { let Ok(oid) = oid else { continue }; - let Ok(commit) = repo.find_commit(oid) else { continue }; + let Ok(commit) = repo.find_commit(oid) else { + continue; + }; let ts = commit.time().seconds(); - let Some(local_dt) = Local.timestamp_opt(ts, 0).single() else { continue }; + let Some(local_dt) = Local.timestamp_opt(ts, 0).single() else { + continue; + }; if local_dt.date_naive() < cutoff_date { break; // TIME-sorted: the rest is older } - let Some(utc_ts) = Utc.timestamp_opt(ts, 0).single() else { continue }; + let Some(utc_ts) = Utc.timestamp_opt(ts, 0).single() else { + continue; + }; let author = commit.author(); let email = author .email() @@ -255,7 +289,9 @@ pub async fn load_logo_bytes( }); drop(config); if !allowed { - return Err(CommandError::bad_request("logo path outside any registered repo")); + return Err(CommandError::bad_request( + "logo path outside any registered repo", + )); } let meta = std::fs::metadata(&canonical) diff --git a/app/src-tauri/src/commands/system.rs b/app/src-tauri/src/commands/system.rs index c06b91a..cfe7b12 100644 --- a/app/src-tauri/src/commands/system.rs +++ b/app/src-tauri/src/commands/system.rs @@ -9,6 +9,7 @@ pub struct PlatformInfo { pub arch: String, pub version: String, pub family: String, + pub debug_assertions: bool, } #[tauri::command] @@ -18,5 +19,6 @@ pub async fn get_platform_info() -> Result { arch: std::env::consts::ARCH.to_string(), version: os_info::get().version().to_string(), family: std::env::consts::FAMILY.to_string(), + debug_assertions: cfg!(debug_assertions), }) } diff --git a/app/src-tauri/src/commands/update.rs b/app/src-tauri/src/commands/update.rs new file mode 100644 index 0000000..be19fae --- /dev/null +++ b/app/src-tauri/src/commands/update.rs @@ -0,0 +1,88 @@ +//! Updater-facing IPC commands. +//! +//! `check_for_update` is fire-and-forget from the renderer's perspective; +//! any result (available/nothing/error) is delivered out-of-band via the +//! `updater://available` / `updater://progress` events. +//! +//! `install_update` only makes sense on the signed plugin path — in debug +//! builds the plugin isn't registered and the call will fail with an +//! `updater init` error that the UI can translate into "please download +//! from GitHub manually". + +use serde::Deserialize; +use tauri::AppHandle; + +use super::error::CommandError; + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CheckForUpdateArgs { + #[serde(default)] + pub auto_install: bool, + #[serde(default)] + pub force_fallback: bool, + #[serde(default)] + pub endpoint_override: Option, +} + +#[tauri::command] +pub async fn check_for_update( + app: AppHandle, + args: Option, +) -> Result<(), CommandError> { + let args = args.unwrap_or_default(); + crate::update::run_update_check( + app, + args.auto_install, + args.force_fallback, + args.endpoint_override, + ) + .await; + Ok(()) +} + +#[cfg(not(debug_assertions))] +#[tauri::command] +pub async fn install_update(app: AppHandle) -> Result<(), CommandError> { + use tauri::Emitter; + use tauri_plugin_updater::UpdaterExt; + + let updater = app + .updater() + .map_err(|e| CommandError::internal(format!("updater init: {e}")))?; + let Some(update) = updater + .check() + .await + .map_err(|e| CommandError::internal(format!("check: {e}")))? + else { + return Ok(()); // nothing to do + }; + let app_for_progress = app.clone(); + update + .download_and_install( + move |chunk, total| { + let _ = app_for_progress.emit( + "updater://progress", + serde_json::json!({ + "chunk": chunk, + "total": total, + }), + ); + }, + || {}, + ) + .await + .map_err(|e| CommandError::internal(format!("install: {e}")))?; + app.restart(); +} + +/// Debug-build stub — the signed plugin isn't registered, so there's nothing +/// to install. The UI falls back to "Download on GitHub" via the `downloadUrl` +/// delivered on the `updater://available` event. +#[cfg(debug_assertions)] +#[tauri::command] +pub async fn install_update(_app: AppHandle) -> Result<(), CommandError> { + Err(CommandError::internal( + "install_update is unavailable in debug builds; use the GitHub download link instead", + )) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index af1cf7c..225830d 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod commands; mod config; mod git; mod providers; +mod update; use std::sync::Arc; @@ -29,6 +30,24 @@ pub struct AppState { pub oauth_pending: Arc>>, } +#[cfg(windows)] +fn set_app_user_model_id() { + // Must match `tauri.conf.json::identifier` so future Start-Menu entries + // (installer-written shortcuts) and this runtime setting address the + // same notification channel. + use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; + let aumid: Vec = "eu.softventures.recrest" + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + // `SetCurrentProcessExplicitAppUserModelID` returns an HRESULT; failure + // is non-fatal (notifications still work, just with the parent-process + // name). Silently swallow so a weird Windows build doesn't crash boot. + unsafe { + let _ = SetCurrentProcessExplicitAppUserModelID(aumid.as_ptr()); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tracing_subscriber::fmt() @@ -41,7 +60,19 @@ pub fn run() { // fix-path-env repariert den PATH einmalig beim Start. let _ = fix_path_env::fix(); - tauri::Builder::default() + // Windows-specific: register an explicit AppUserModelID so Toast + // notifications attribute to "Recrest" instead of the parent process + // (e.g. powershell.exe in `yarn dev`). Installed MSI builds already get + // this via the Start Menu shortcut the installer writes, but the dev + // binary has no registry entry, so Windows falls back to the launching + // process name on every toast. Setting it here fixes both dev and + // portable launches without touching the registry. + #[cfg(windows)] + { + set_app_user_model_id(); + } + + let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { if let Some(w) = app.get_webview_window("main") { let _ = w.show(); @@ -86,36 +117,55 @@ pub fn run() { } } - // Auto-updater plugin (release only). We register it unconditionally - // and kick off a silent background check on startup when the user - // has opted in via `auto_update`. Failures are swallowed — a missing - // endpoint or network error must not block app startup. + // Auto-updater plugin (release only). Registration is gated behind + // `not(debug_assertions)` because the plugin requires a valid + // pubkey + signed `latest.json` endpoint to initialize, which we + // don't want to depend on during development. #[cfg(not(debug_assertions))] { let _ = handle.plugin(tauri_plugin_updater::Builder::new().build()); - let auto_update = config.settings().auto_update.clone(); - if auto_update != "never" && auto_update != "manual" { - let updater_handle = handle.clone(); - tauri::async_runtime::spawn(async move { - use tauri_plugin_updater::UpdaterExt; - let Ok(updater) = updater_handle.updater() else { return }; - match updater.check().await { - Ok(Some(update)) => { - let _ = tauri::Emitter::emit( - &updater_handle, - "updater://available", - serde_json::json!({ - "version": update.version, - "currentVersion": update.current_version, - "body": update.body, - }), - ); - } - Ok(None) => {} - Err(err) => tracing::debug!("updater check failed: {err}"), - } - }); - } + } + + // Schedule startup + periodic update checks when the user has + // opted in. `"auto"` and `"manual"` both schedule a background + // probe — the only difference is that `auto_install` is set for + // `"auto"`, which triggers the plugin's download-and-restart + // flow. `"off"` skips scheduling entirely. In debug builds the + // helper falls through to the GitHub fallback automatically. + let auto_update = config.settings().auto_update.clone(); + if auto_update != "off" { + let auto_install = auto_update == "auto"; + let check_handle = handle.clone(); + tauri::async_runtime::spawn(async move { + // One-shot startup check after a ~10s delay so it doesn't + // compete with the initial paint + provider hydration. + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + crate::update::run_update_check( + check_handle.clone(), + auto_install, + false, + None, + ) + .await; + + // Then every 4h for the rest of the app's lifetime. + let mut interval = tokio::time::interval(std::time::Duration::from_secs( + 4 * 60 * 60, + )); + // The first tick fires immediately — skip it since we + // just ran the check above. + interval.tick().await; + loop { + interval.tick().await; + crate::update::run_update_check( + check_handle.clone(), + auto_install, + false, + None, + ) + .await; + } + }); } // Build a watcher and subscribe to every known repo. Failures here @@ -294,54 +344,122 @@ pub fn run() { // app exits because the webview was the only window. } }) - .invoke_handler(tauri::generate_handler![ - commands::repos::scan_repos, - commands::repos::list_repos, - commands::repos::repo_status, - commands::repos::add_repo, - commands::repos::remove_repo, - commands::repos::list_recent_commits, - commands::repos::load_logo_bytes, - commands::repos::open_in_ide, - commands::ide::detect_ides, - commands::repos::open_terminal, - commands::git_ops::open_in_explorer, - commands::git_ops::git_fetch, - commands::git_ops::git_fetch_all, - commands::git_ops::git_pull, - commands::git_ops::git_push, - commands::git_ops::git_checkout, - commands::git_ops::git_checkout_remote, - commands::git_ops::git_list_branches, - commands::git_ops::git_branch_create, - commands::git_ops::git_merge, - commands::clone::git_clone, - commands::search::find_across_repos, - commands::remote_import::list_remote_repositories, - commands::remote_import::list_remote_organizations, - commands::remote_import::clone_remote_repository, - commands::remote_import::clone_remote_repositories_bulk, - commands::remote_import::create_and_open_workspace, - commands::providers::list_providers, - commands::providers::set_provider_token, - commands::providers::set_provider_base_url, - commands::providers::clear_provider_token, - commands::providers::fetch_pull_requests, - commands::providers::get_pr_detail, - commands::activity::list_pr_events, - commands::activity::list_check_runs, - commands::notifications::notify, - commands::oauth::begin_oauth, - commands::oauth::complete_oauth, - commands::settings::get_settings, - commands::settings::update_settings, - commands::window::save_window_state, - commands::window::load_window_state, - commands::window::validate_window_position, - commands::system::get_platform_info, - commands::git_info::check_git, - commands::tray::update_tray_badge, - ]) + ; + + // `tauri::generate_handler!` cannot accept `#[cfg]` attrs on individual + // arms, so we duplicate the handler registration — release builds get the + // production command list, debug builds additionally expose the three + // `commands::dev::*` helpers used by the Developer settings tab. The + // `dev` module itself is `#![cfg(debug_assertions)]` so release builds + // don't even link it. + #[cfg(not(debug_assertions))] + let builder = builder.invoke_handler(tauri::generate_handler![ + commands::repos::scan_repos, + commands::repos::list_repos, + commands::repos::repo_status, + commands::repos::add_repo, + commands::repos::remove_repo, + commands::repos::list_recent_commits, + commands::repos::load_logo_bytes, + commands::repos::open_in_ide, + commands::ide::detect_ides, + commands::repos::open_terminal, + commands::git_ops::open_in_explorer, + commands::git_ops::git_fetch, + commands::git_ops::git_fetch_all, + commands::git_ops::git_pull, + commands::git_ops::git_push, + commands::git_ops::git_checkout, + commands::git_ops::git_checkout_remote, + commands::git_ops::git_list_branches, + commands::git_ops::git_branch_create, + commands::git_ops::git_merge, + commands::clone::git_clone, + commands::search::find_across_repos, + commands::remote_import::list_remote_repositories, + commands::remote_import::list_remote_organizations, + commands::remote_import::clone_remote_repository, + commands::remote_import::clone_remote_repositories_bulk, + commands::remote_import::create_and_open_workspace, + commands::providers::list_providers, + commands::providers::set_provider_token, + commands::providers::set_provider_base_url, + commands::providers::clear_provider_token, + commands::providers::fetch_pull_requests, + commands::providers::get_pr_detail, + commands::activity::list_pr_events, + commands::activity::list_check_runs, + commands::notifications::notify, + commands::oauth::begin_oauth, + commands::oauth::complete_oauth, + commands::settings::get_settings, + commands::settings::update_settings, + commands::window::save_window_state, + commands::window::load_window_state, + commands::window::validate_window_position, + commands::system::get_platform_info, + commands::git_info::check_git, + commands::tray::update_tray_badge, + commands::update::check_for_update, + commands::update::install_update, + ]); + + #[cfg(debug_assertions)] + let builder = builder.invoke_handler(tauri::generate_handler![ + commands::repos::scan_repos, + commands::repos::list_repos, + commands::repos::repo_status, + commands::repos::add_repo, + commands::repos::remove_repo, + commands::repos::list_recent_commits, + commands::repos::load_logo_bytes, + commands::repos::open_in_ide, + commands::ide::detect_ides, + commands::repos::open_terminal, + commands::git_ops::open_in_explorer, + commands::git_ops::git_fetch, + commands::git_ops::git_fetch_all, + commands::git_ops::git_pull, + commands::git_ops::git_push, + commands::git_ops::git_checkout, + commands::git_ops::git_checkout_remote, + commands::git_ops::git_list_branches, + commands::git_ops::git_branch_create, + commands::git_ops::git_merge, + commands::clone::git_clone, + commands::search::find_across_repos, + commands::remote_import::list_remote_repositories, + commands::remote_import::list_remote_organizations, + commands::remote_import::clone_remote_repository, + commands::remote_import::clone_remote_repositories_bulk, + commands::remote_import::create_and_open_workspace, + commands::providers::list_providers, + commands::providers::set_provider_token, + commands::providers::set_provider_base_url, + commands::providers::clear_provider_token, + commands::providers::fetch_pull_requests, + commands::providers::get_pr_detail, + commands::activity::list_pr_events, + commands::activity::list_check_runs, + commands::notifications::notify, + commands::oauth::begin_oauth, + commands::oauth::complete_oauth, + commands::settings::get_settings, + commands::settings::update_settings, + commands::window::save_window_state, + commands::window::load_window_state, + commands::window::validate_window_position, + commands::system::get_platform_info, + commands::git_info::check_git, + commands::tray::update_tray_badge, + commands::update::check_for_update, + commands::update::install_update, + commands::dev::get_dev_paths, + commands::dev::get_build_triple, + commands::dev::dev_panic, + ]); + + builder .run(tauri::generate_context!()) .expect("error while running recrest application"); } diff --git a/app/src-tauri/src/providers/api.rs b/app/src-tauri/src/providers/api.rs index 6212f7e..33ba541 100644 --- a/app/src-tauri/src/providers/api.rs +++ b/app/src-tauri/src/providers/api.rs @@ -9,6 +9,7 @@ pub struct PullRequestDto { pub title: String, pub url: String, pub author: String, + pub author_avatar_url: Option, pub state: PrState, pub draft: bool, pub source_branch: String, @@ -176,12 +177,18 @@ pub struct CheckRunSummaryDto { pub fn parse_owner_repo(remote_url: &str) -> Option<(String, String)> { let url = remote_url.trim(); // SSH: git@host:owner/repo(.git) - if let Some(rest) = url.strip_prefix("git@").and_then(|s| s.split_once(':').map(|(_, r)| r)) { + if let Some(rest) = url + .strip_prefix("git@") + .and_then(|s| s.split_once(':').map(|(_, r)| r)) + { return split_owner_repo(rest); } // https://host/owner/repo(.git) let after_scheme = url.split("://").nth(1).unwrap_or(url); - let without_host = after_scheme.split_once('/').map(|(_, r)| r).unwrap_or(after_scheme); + let without_host = after_scheme + .split_once('/') + .map(|(_, r)| r) + .unwrap_or(after_scheme); split_owner_repo(without_host) } diff --git a/app/src-tauri/src/providers/bitbucket.rs b/app/src-tauri/src/providers/bitbucket.rs index 2a11da0..dc0d0aa 100644 --- a/app/src-tauri/src/providers/bitbucket.rs +++ b/app/src-tauri/src/providers/bitbucket.rs @@ -55,15 +55,19 @@ impl BitbucketProvider { } async fn credentials(&self) -> Result, CommandError> { - let Some(token) = self.tokens.read(PROVIDER_ID)? else { return Ok(None) }; - let Some(username) = self.tokens.read(USERNAME_KEY)? else { return Ok(None) }; + let Some(token) = self.tokens.read(PROVIDER_ID)? else { + return Ok(None); + }; + let Some(username) = self.tokens.read(USERNAME_KEY)? else { + return Ok(None); + }; Ok(Some((username, token))) } async fn require_credentials(&self) -> Result<(String, String), CommandError> { - self.credentials() - .await? - .ok_or_else(|| CommandError::Unauthorized("bitbucket credentials not configured".into())) + self.credentials().await?.ok_or_else(|| { + CommandError::Unauthorized("bitbucket credentials not configured".into()) + }) } } @@ -129,7 +133,9 @@ impl GitProvider for BitbucketProvider { let res = self .http - .get(format!("{base}/repositories/{workspace}/{repo}/pullrequests")) + .get(format!( + "{base}/repositories/{workspace}/{repo}/pullrequests" + )) .basic_auth(&username, Some(&password)) .query(&[("state", "OPEN"), ("pagelen", "50")]) .send() @@ -153,13 +159,7 @@ impl GitProvider for BitbucketProvider { let ci = match sha { Some(sha) => Some( fetch_bb_ci_status( - &self.http, - &username, - &password, - &base, - &workspace, - &repo, - &sha, + &self.http, &username, &password, &base, &workspace, &repo, &sha, ) .await, ), @@ -237,7 +237,10 @@ impl GitProvider for BitbucketProvider { .unwrap_or_default() .into_iter() .map(|u| ReviewerDto { - login: u.nickname.clone().unwrap_or_else(|| u.display_name.clone().unwrap_or_default()), + login: u + .nickname + .clone() + .unwrap_or_else(|| u.display_name.clone().unwrap_or_default()), name: u.display_name.clone(), avatar_url: u.links.and_then(|l| l.avatar).map(|a| a.href), state: ReviewState::Pending, @@ -296,9 +299,7 @@ impl GitProvider for BitbucketProvider { let (username, password) = self.require_credentials().await?; let base = self.api_base(); let mut out = Vec::new(); - let mut url = format!( - "{base}/repositories?role=member&pagelen={PAGELEN}&sort=-updated_on" - ); + let mut url = format!("{base}/repositories?role=member&pagelen={PAGELEN}&sort=-updated_on"); for _ in 0..MAX_PAGES { let page: BbPage = bb_json(&self.http, &username, &password, &url).await?; for r in page.values { @@ -339,8 +340,9 @@ impl GitProvider for BitbucketProvider { _redirect_uri: &str, state: &str, ) -> Result { - let client_id = OAUTH_CLIENT_ID - .ok_or_else(|| CommandError::bad_request("bitbucket: OAuth client ID not configured"))?; + let client_id = OAUTH_CLIENT_ID.ok_or_else(|| { + CommandError::bad_request("bitbucket: OAuth client ID not configured") + })?; let state_enc = urlencoding::encode(state); // Bitbucket ignores `redirect_uri` in the request — the callback must // be configured on the OAuth consumer. Scopes come from the consumer @@ -407,9 +409,7 @@ impl GitProvider for BitbucketProvider { let (username, password) = self.require_credentials().await?; let base = self.api_base(); let mut out = Vec::new(); - let mut url = format!( - "{base}/repositories/{org_slug}?pagelen={PAGELEN}&sort=-updated_on" - ); + let mut url = format!("{base}/repositories/{org_slug}?pagelen={PAGELEN}&sort=-updated_on"); for _ in 0..MAX_PAGES { let page: BbPage = bb_json(&self.http, &username, &password, &url).await?; for r in page.values { @@ -425,16 +425,28 @@ impl GitProvider for BitbucketProvider { } fn map_pr(pr: BbPr, ci: Option) -> PullRequestDto { + let author_avatar_url = pr + .author + .as_ref() + .and_then(|a| a.links.as_ref()) + .and_then(|l| l.avatar.as_ref()) + .map(|h| h.href.clone()); PullRequestDto { id: pr.id.to_string(), number: pr.id, title: pr.title, - url: pr.links.as_ref().and_then(|l| l.html.as_ref()).map(|h| h.href.clone()).unwrap_or_default(), + url: pr + .links + .as_ref() + .and_then(|l| l.html.as_ref()) + .map(|h| h.href.clone()) + .unwrap_or_default(), author: pr .author .as_ref() .and_then(|a| a.display_name.clone()) .unwrap_or_default(), + author_avatar_url, state: match pr.state.as_str() { "MERGED" => PrState::Merged, "DECLINED" | "SUPERSEDED" => PrState::Closed, @@ -484,7 +496,9 @@ async fn fetch_bb_ci_status( if !res.status().is_success() { return CiStatus::None; } - let Ok(body) = res.json::>().await else { return CiStatus::None }; + let Ok(body) = res.json::>().await else { + return CiStatus::None; + }; aggregate_bb_statuses(&body.values) } @@ -534,7 +548,11 @@ fn map_repo(r: BbRepo) -> RemoteRepositoryDto { .and_then(|l| l.html.as_ref()) .map(|h| h.href.clone()) .unwrap_or_default(); - let owner_login = r.workspace.as_ref().map(|w| w.slug.clone()).unwrap_or_default(); + let owner_login = r + .workspace + .as_ref() + .map(|w| w.slug.clone()) + .unwrap_or_default(); let owner_avatar = r .workspace .as_ref() @@ -589,7 +607,10 @@ async fn bb_json( fn parse_workspace_repo(remote_url: &str) -> Option<(String, String)> { let url = remote_url.trim(); - let path = if let Some(rest) = url.strip_prefix("git@").and_then(|s| s.split_once(':').map(|(_, r)| r)) { + let path = if let Some(rest) = url + .strip_prefix("git@") + .and_then(|s| s.split_once(':').map(|(_, r)| r)) + { rest.to_string() } else { let after_scheme = url.split("://").nth(1).unwrap_or(url); diff --git a/app/src-tauri/src/providers/github.rs b/app/src-tauri/src/providers/github.rs index 508958c..5f34f88 100644 --- a/app/src-tauri/src/providers/github.rs +++ b/app/src-tauri/src/providers/github.rs @@ -90,7 +90,9 @@ impl GitProvider for GithubProvider { } async fn username(&self) -> Result, CommandError> { - let Some(token) = self.token().await? else { return Ok(None) }; + let Some(token) = self.token().await? else { + return Ok(None); + }; let base = self.api_base(); let res = self .http @@ -127,7 +129,10 @@ impl GitProvider for GithubProvider { Some(self.api_base()) } - async fn list_pull_requests(&self, remote_url: &str) -> Result, CommandError> { + async fn list_pull_requests( + &self, + remote_url: &str, + ) -> Result, CommandError> { let token = self.require_token().await?; let (owner, repo) = parse_owner_repo(remote_url) .ok_or_else(|| CommandError::bad_request("could not parse owner/repo from remote"))?; @@ -143,17 +148,14 @@ impl GitProvider for GithubProvider { .await?; if !res.status().is_success() { - return Err(CommandError::internal(format!( - "github: {}", - res.status() - ))); + return Err(CommandError::internal(format!("github: {}", res.status()))); } let items: Vec = res.json().await?; let mut out = Vec::with_capacity(items.len()); for pr in items { - let ci = fetch_combined_status(&self.http, &token, &base, &owner, &repo, &pr.head.sha) - .await; + let ci = + fetch_combined_status(&self.http, &token, &base, &owner, &repo, &pr.head.sha).await; out.push(map_pr(pr, Some(ci))); } Ok(out) @@ -298,7 +300,11 @@ impl GitProvider for GithubProvider { continue; } any_in_window = true; - let author = pr.user.as_ref().map(|u| u.login.clone()).unwrap_or_default(); + let author = pr + .user + .as_ref() + .map(|u| u.login.clone()) + .unwrap_or_default(); let url = pr.html_url.clone(); if pr.created_at >= cutoff { out.push(PrEventDto { @@ -367,9 +373,8 @@ impl GitProvider for GithubProvider { let (owner, repo) = parse_owner_repo(remote_url) .ok_or_else(|| CommandError::bad_request("could not parse owner/repo from remote"))?; let base = self.api_base(); - let tz = FixedOffset::east_opt(local_tz_offset_minutes * 60).unwrap_or_else(|| { - FixedOffset::east_opt(0).expect("zero offset is always valid") - }); + let tz = FixedOffset::east_opt(local_tz_offset_minutes * 60) + .unwrap_or_else(|| FixedOffset::east_opt(0).expect("zero offset is always valid")); // Bucket per local YYYY-MM-DD → (total, passed, failed, failing-shas). let mut buckets: std::collections::HashMap)> = @@ -380,9 +385,8 @@ impl GitProvider for GithubProvider { for chunk in shas.chunks(chunk_size) { let mut tasks = Vec::with_capacity(chunk.len()); for sha in chunk { - let url = format!( - "{base}/repos/{owner}/{repo}/commits/{sha}/check-runs?per_page=50" - ); + let url = + format!("{base}/repos/{owner}/{repo}/commits/{sha}/check-runs?per_page=50"); let http = self.http.clone(); let token = token.clone(); let sha = sha.clone(); @@ -409,7 +413,9 @@ impl GitProvider for GithubProvider { entry.0 += 1; match run.conclusion.as_deref() { Some("success") => entry.1 += 1, - Some("failure") | Some("timed_out") | Some("action_required") + Some("failure") + | Some("timed_out") + | Some("action_required") | Some("startup_failure") => { entry.2 += 1; if entry.3.len() < 3 && !entry.3.contains(&sha) { @@ -424,15 +430,17 @@ impl GitProvider for GithubProvider { let mut out: Vec = buckets .into_iter() - .map(|(day, (total, passed, failed, sha_samples))| CheckRunSummaryDto { - repo_id: repo_id.to_string(), - repo_name: repo_name.to_string(), - day, - total, - passed, - failed, - sha_samples, - }) + .map( + |(day, (total, passed, failed, sha_samples))| CheckRunSummaryDto { + repo_id: repo_id.to_string(), + repo_name: repo_name.to_string(), + day, + total, + passed, + failed, + sha_samples, + }, + ) .collect(); out.sort_by(|a, b| a.day.cmp(&b.day)); Ok(out) @@ -482,11 +490,7 @@ impl GitProvider for GithubProvider { OAUTH_CLIENT_ID.is_some() && OAUTH_CLIENT_SECRET.is_some() } - async fn authorize_url( - &self, - redirect_uri: &str, - state: &str, - ) -> Result { + async fn authorize_url(&self, redirect_uri: &str, state: &str) -> Result { let client_id = OAUTH_CLIENT_ID .ok_or_else(|| CommandError::bad_request("github: OAuth client ID not configured"))?; let scopes = urlencoding::encode(OAUTH_SCOPES); @@ -555,12 +559,17 @@ impl GitProvider for GithubProvider { } fn map_pr(pr: GhPull, ci: Option) -> PullRequestDto { + let (author, author_avatar_url) = match pr.user { + Some(u) => (u.login, u.avatar_url), + None => (String::new(), None), + }; PullRequestDto { id: pr.id.to_string(), number: pr.number, title: pr.title, url: pr.html_url, - author: pr.user.map(|u| u.login).unwrap_or_default(), + author, + author_avatar_url, state: if pr.merged_at.is_some() { PrState::Merged } else if pr.state == "closed" { @@ -597,7 +606,11 @@ fn map_repo(r: GhRepo) -> RemoteRepositoryDto { pushed_at: r.pushed_at, size_kb: r.size, language: r.language, - owner_login: r.owner.as_ref().map(|o| o.login.clone()).unwrap_or_default(), + owner_login: r + .owner + .as_ref() + .map(|o| o.login.clone()) + .unwrap_or_default(), owner_avatar_url: r.owner.and_then(|o| o.avatar_url), } } @@ -643,7 +656,9 @@ async fn fetch_combined_status( if !res.status().is_success() { return CiStatus::None; } - let Ok(body) = res.json::().await else { return CiStatus::None }; + let Ok(body) = res.json::().await else { + return CiStatus::None; + }; match body.state.as_str() { "success" => CiStatus::Success, "failure" | "error" => CiStatus::Failure, diff --git a/app/src-tauri/src/providers/gitlab.rs b/app/src-tauri/src/providers/gitlab.rs index 6c01942..260238f 100644 --- a/app/src-tauri/src/providers/gitlab.rs +++ b/app/src-tauri/src/providers/gitlab.rs @@ -82,7 +82,9 @@ impl GitProvider for GitlabProvider { } async fn username(&self) -> Result, CommandError> { - let Some(token) = self.token().await? else { return Ok(None) }; + let Some(token) = self.token().await? else { + return Ok(None); + }; let base = self.api_base(); let res = self .http @@ -123,8 +125,9 @@ impl GitProvider for GitlabProvider { remote_url: &str, ) -> Result, CommandError> { let token = self.require_token().await?; - let project_path = parse_project_path(remote_url) - .ok_or_else(|| CommandError::bad_request("could not parse GitLab project from remote"))?; + let project_path = parse_project_path(remote_url).ok_or_else(|| { + CommandError::bad_request("could not parse GitLab project from remote") + })?; let encoded = urlencoding::encode(&project_path); let base = self.api_base(); @@ -150,18 +153,16 @@ impl GitProvider for GitlabProvider { pr_number: u64, ) -> Result { let token = self.require_token().await?; - let project_path = parse_project_path(remote_url) - .ok_or_else(|| CommandError::bad_request("could not parse GitLab project from remote"))?; + let project_path = parse_project_path(remote_url).ok_or_else(|| { + CommandError::bad_request("could not parse GitLab project from remote") + })?; let encoded = urlencoding::encode(&project_path); let base = self.api_base(); let mr_url = format!("{base}/projects/{encoded}/merge_requests/{pr_number}"); - let changes_url = format!( - "{base}/projects/{encoded}/merge_requests/{pr_number}/changes" - ); - let notes_url = format!( - "{base}/projects/{encoded}/merge_requests/{pr_number}/notes?sort=asc" - ); + let changes_url = format!("{base}/projects/{encoded}/merge_requests/{pr_number}/changes"); + let notes_url = + format!("{base}/projects/{encoded}/merge_requests/{pr_number}/notes?sort=asc"); let (mr_res, changes_res, notes_res) = tokio::try_join!( gl_json::(&self.http, &token, &mr_url), @@ -175,7 +176,10 @@ impl GitProvider for GitlabProvider { .changes .into_iter() .map(|c| FileChangeDto { - path: c.new_path.clone().unwrap_or_else(|| c.old_path.unwrap_or_default()), + path: c + .new_path + .clone() + .unwrap_or_else(|| c.old_path.unwrap_or_default()), additions: 0, deletions: 0, status: if c.new_file.unwrap_or(false) { @@ -221,7 +225,11 @@ impl GitProvider for GitlabProvider { Ok(PullRequestDetailDto { pr: base_pr, body: mr_res.base_mr.description, - mergeable: mr_res.base_mr.merge_status.as_deref().map(|s| s == "can_be_merged"), + mergeable: mr_res + .base_mr + .merge_status + .as_deref() + .map(|s| s == "can_be_merged"), reviewers, files, timeline, @@ -272,11 +280,7 @@ impl GitProvider for GitlabProvider { OAUTH_CLIENT_ID.is_some() && OAUTH_CLIENT_SECRET.is_some() } - async fn authorize_url( - &self, - redirect_uri: &str, - state: &str, - ) -> Result { + async fn authorize_url(&self, redirect_uri: &str, state: &str) -> Result { let client_id = OAUTH_CLIENT_ID .ok_or_else(|| CommandError::bad_request("gitlab: OAuth client ID not configured"))?; let redirect = urlencoding::encode(redirect_uri); @@ -363,18 +367,25 @@ fn map_mr(mr: GlMr) -> PullRequestDto { _ => Some(CiStatus::None), }; + let (author, author_avatar_url) = match mr.author { + Some(a) => (a.username, a.avatar_url), + None => (String::new(), None), + }; PullRequestDto { id: mr.id.to_string(), number: mr.iid, title: mr.title, url: mr.web_url, - author: mr.author.map(|a| a.username).unwrap_or_default(), + author, + author_avatar_url, state: match mr.state.as_str() { "merged" => PrState::Merged, "closed" => PrState::Closed, _ => PrState::Open, }, - draft: mr.draft.unwrap_or_else(|| mr.work_in_progress.unwrap_or(false)), + draft: mr + .draft + .unwrap_or_else(|| mr.work_in_progress.unwrap_or(false)), source_branch: mr.source_branch, target_branch: mr.target_branch, created_at: mr.created_at, @@ -403,7 +414,11 @@ fn map_project(p: GlProject) -> RemoteRepositoryDto { pushed_at: p.last_activity_at, size_kb: None, language: None, - owner_login: p.namespace.as_ref().map(|n| n.path.clone()).unwrap_or_default(), + owner_login: p + .namespace + .as_ref() + .map(|n| n.path.clone()) + .unwrap_or_default(), owner_avatar_url: p.namespace.and_then(|n| n.avatar_url), } } @@ -413,11 +428,7 @@ async fn gl_json( token: &str, url: &str, ) -> Result { - let res = http - .get(url) - .header("PRIVATE-TOKEN", token) - .send() - .await?; + let res = http.get(url).header("PRIVATE-TOKEN", token).send().await?; if !res.status().is_success() { return Err(CommandError::internal(format!( "gitlab {}: {}", @@ -432,7 +443,10 @@ async fn gl_json( /// groups (`group/subgroup/project`) for both HTTPS and SSH forms. fn parse_project_path(remote_url: &str) -> Option { let url = remote_url.trim(); - let path = if let Some(rest) = url.strip_prefix("git@").and_then(|s| s.split_once(':').map(|(_, r)| r)) { + let path = if let Some(rest) = url + .strip_prefix("git@") + .and_then(|s| s.split_once(':').map(|(_, r)| r)) + { rest } else { let after_scheme = url.split("://").nth(1).unwrap_or(url); diff --git a/app/src-tauri/src/update/github.rs b/app/src-tauri/src/update/github.rs new file mode 100644 index 0000000..f67ce41 --- /dev/null +++ b/app/src-tauri/src/update/github.rs @@ -0,0 +1,272 @@ +//! GitHub Releases fallback for the updater. +//! +//! Runs when `tauri-plugin-updater` is either disabled (debug builds) or +//! fails at runtime (e.g. missing `latest.json`, signature mismatch, network +//! hiccup). We just surface a notification — the user clicks through to the +//! platform asset and installs manually. No signature verification on this +//! path; that's why `canAutoInstall` is always `false`. + +use std::sync::OnceLock; + +use tauri::{AppHandle, Emitter}; +use tokio::sync::Mutex; + +const DEFAULT_URL: &str = "https://api.github.com/repos/SoftVentures/Recrest/releases/latest"; + +/// Session-only cache of the last `ETag` header seen on a successful +/// `releases/latest` response. Sent back as `If-None-Match` on the next +/// request so GitHub can answer `304 Not Modified` and skip re-delivering +/// the release payload. Not persisted to disk — process-scoped only. +fn last_etag() -> &'static Mutex> { + static CELL: OnceLock>> = OnceLock::new(); + CELL.get_or_init(|| Mutex::new(None)) +} + +pub async fn check_latest(app: AppHandle, override_url: Option) { + let current = env!("CARGO_PKG_VERSION"); + let url = override_url.unwrap_or_else(|| DEFAULT_URL.to_string()); + let client = match reqwest::Client::builder() + .user_agent(format!("Recrest/{current}")) + .build() + { + Ok(c) => c, + Err(err) => { + tracing::debug!("updater fallback: reqwest build failed: {err}"); + return; + } + }; + + let mut req = client.get(&url); + let cached_etag = last_etag().lock().await.clone(); + if let Some(etag) = cached_etag.as_ref() { + req = req.header("If-None-Match", etag); + } + + let resp = match req.send().await { + Ok(r) => r, + Err(err) => { + tracing::debug!("updater fallback: request failed: {err}"); + return; + } + }; + + // 304 → body is unchanged since last check, no emit needed. + if resp.status() == reqwest::StatusCode::NOT_MODIFIED { + tracing::debug!("updater fallback: 304 Not Modified — skipping"); + return; + } + + // Capture ETag before consuming the response body. + let new_etag = resp + .headers() + .get(reqwest::header::ETAG) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let json = match resp.json::().await { + Ok(j) => j, + Err(err) => { + tracing::debug!("updater fallback: decode failed: {err}"); + return; + } + }; + + // Only persist the ETag once we've confirmed the payload decoded cleanly, + // so a malformed 200 doesn't poison the cache and silence future checks. + if let Some(etag) = new_etag { + *last_etag().lock().await = Some(etag); + } + + let Some(tag) = json["tag_name"].as_str() else { + tracing::debug!("updater fallback: no tag_name in response"); + return; + }; + let latest = tag.strip_prefix('v').unwrap_or(tag); + if !is_newer(latest, current) { + return; + } + let body_text = json["body"].as_str().unwrap_or("").to_string(); + let download_url = pick_platform_asset(&json, std::env::consts::OS); + + let _ = app.emit( + "updater://available", + serde_json::json!({ + "version": latest, + "currentVersion": current, + "body": body_text, + "canAutoInstall": false, + "downloadUrl": download_url, + }), + ); +} + +/// Compares two dotted-numeric version strings, tolerating a leading `v` and +/// a trailing pre-release suffix (`-beta.1`, `-rc.2`, etc.). +/// +/// Simplification: pre-release identifiers are **stripped**, not compared. +/// That means `0.7.0-beta.1` and `0.7.0` compare as equal here, so neither is +/// "newer" than the other. This is deliberate — a proper SemVer pre-release +/// ordering (pre-release < release at same numeric) would need a full parser, +/// and we'd rather not promote `-beta.1` over a stable `0.7.0` through the +/// fallback path. The upside is that `0.7.0-beta.1` > `0.6.9`, which is what +/// users actually want when running a beta build. +pub(crate) fn is_newer(latest: &str, current: &str) -> bool { + fn parts(s: &str) -> Option<(u32, u32, u32)> { + let cleaned = s.split('-').next().unwrap_or(s); + let mut it = cleaned + .split('.') + .map(|p| p.trim_start_matches(['v', 'V']).parse::().ok()); + Some((it.next()??, it.next()??, it.next()??)) + } + match (parts(latest), parts(current)) { + (Some(a), Some(b)) => a > b, + _ => false, + } +} + +pub(crate) fn pick_platform_asset(json: &serde_json::Value, os: &str) -> Option { + let assets = json["assets"].as_array()?; + let wants: &[&str] = match os { + "windows" => &[".msi", ".exe"], + "macos" => &[".dmg"], + "linux" => &[".AppImage", ".deb", ".rpm"], + _ => return None, + }; + for needle in wants { + for a in assets { + if let Some(name) = a["name"].as_str() { + if name.ends_with(needle) { + return a["browser_download_url"] + .as_str() + .map(|s| s.to_string()); + } + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn is_newer_detects_upgrades() { + assert!(is_newer("0.7.0", "0.6.9")); + assert!(is_newer("1.0.0", "0.9.9")); + assert!(is_newer("0.6.10", "0.6.9")); + } + + #[test] + fn is_newer_rejects_equal_or_older() { + assert!(!is_newer("0.6.0", "0.6.0")); + assert!(!is_newer("0.6.0", "0.7.0")); + assert!(!is_newer("0.5.9", "0.6.0")); + } + + #[test] + fn is_newer_handles_v_prefix() { + assert!(is_newer("v0.7.0", "0.6.9")); + assert!(is_newer("0.7.0", "v0.6.9")); + } + + #[test] + fn is_newer_tolerates_prerelease_suffix() { + // A pre-release of a *newer* version still counts as an upgrade from + // an older stable. + assert!(is_newer("0.7.0-beta.1", "0.6.9")); + assert!(is_newer("v1.0.0-rc.1", "0.9.0")); + // Same numeric core with a suffix on either side → treated as equal, + // so neither is newer. See the comment on `is_newer` for the rationale. + assert!(!is_newer("0.7.0-beta.1", "0.7.0")); + assert!(!is_newer("0.7.0", "0.7.0-beta.1")); + assert!(!is_newer("0.7.0-beta.1", "0.7.0-beta.2")); + } + + #[test] + fn is_newer_rejects_malformed() { + assert!(!is_newer("not.a.version", "0.6.0")); + assert!(!is_newer("0.7", "0.6.0")); + assert!(!is_newer("", "0.6.0")); + } + + fn synthetic_release() -> serde_json::Value { + json!({ + "tag_name": "v0.7.0", + "body": "release notes", + "assets": [ + { "name": "Recrest_0.7.0_x64_en-US.msi", "browser_download_url": "https://example.test/Recrest.msi" }, + { "name": "Recrest_0.7.0_x64-setup.exe", "browser_download_url": "https://example.test/Recrest.exe" }, + { "name": "Recrest_0.7.0_amd64.AppImage", "browser_download_url": "https://example.test/Recrest.AppImage" }, + { "name": "Recrest_0.7.0_amd64.deb", "browser_download_url": "https://example.test/Recrest.deb" }, + { "name": "Recrest_0.7.0_x64.dmg", "browser_download_url": "https://example.test/Recrest.dmg" } + ] + }) + } + + #[test] + fn pick_platform_asset_windows_prefers_msi() { + let got = pick_platform_asset(&synthetic_release(), "windows"); + assert_eq!(got.as_deref(), Some("https://example.test/Recrest.msi")); + } + + #[test] + fn pick_platform_asset_windows_falls_back_to_exe() { + let json = json!({ + "assets": [ + { "name": "Recrest_0.7.0_x64-setup.exe", "browser_download_url": "https://example.test/Recrest.exe" } + ] + }); + let got = pick_platform_asset(&json, "windows"); + assert_eq!(got.as_deref(), Some("https://example.test/Recrest.exe")); + } + + #[test] + fn pick_platform_asset_macos_picks_dmg() { + let got = pick_platform_asset(&synthetic_release(), "macos"); + assert_eq!(got.as_deref(), Some("https://example.test/Recrest.dmg")); + } + + #[test] + fn pick_platform_asset_linux_prefers_appimage() { + let got = pick_platform_asset(&synthetic_release(), "linux"); + assert_eq!(got.as_deref(), Some("https://example.test/Recrest.AppImage")); + } + + #[test] + fn pick_platform_asset_unknown_os_returns_none() { + assert!(pick_platform_asset(&synthetic_release(), "freebsd").is_none()); + } + + #[test] + fn pick_platform_asset_missing_assets_returns_none() { + let json = json!({ "tag_name": "v0.7.0" }); + assert!(pick_platform_asset(&json, "windows").is_none()); + } + + #[tokio::test] + async fn etag_cache_round_trips_values() { + // We can't easily mock the reqwest client without pulling a new dep, + // so exercise just the cache cell directly: write → read → clear. + // This at least pins the API we rely on (tokio::sync::Mutex>). + let cell = last_etag(); + + // Snapshot prior state so parallel tests in this module don't + // clobber each other (cargo test runs #[tokio::test] on a shared + // static). We restore it at the end. + let prior = cell.lock().await.clone(); + + *cell.lock().await = Some("\"abc123\"".to_string()); + assert_eq!( + cell.lock().await.as_deref(), + Some("\"abc123\""), + "cache should retain the value we just wrote" + ); + + *cell.lock().await = None; + assert!(cell.lock().await.is_none(), "cache should be clearable"); + + *cell.lock().await = prior; + } +} diff --git a/app/src-tauri/src/update/mod.rs b/app/src-tauri/src/update/mod.rs new file mode 100644 index 0000000..7392604 --- /dev/null +++ b/app/src-tauri/src/update/mod.rs @@ -0,0 +1,91 @@ +//! Hybrid updater: Tauri plugin first, GitHub Releases as a fallback. +//! +//! The plugin path gives us signed, auto-installed updates when a signed +//! `latest.json` endpoint is reachable. When that's missing (debug builds, +//! offline CI, unsigned dev releases), we degrade to a "there's a newer +//! tag on GitHub" notice with a platform-picked download URL. + +pub mod github; + +use tauri::AppHandle; +#[cfg(not(debug_assertions))] +use tauri::Emitter; + +/// Probe for an update and notify the renderer. +/// +/// * `auto_install` — only honored on the plugin path. When `true` and an +/// update is found, the plugin downloads and installs in the background, then +/// restarts the app. +/// * `force_fallback` — skip the plugin and go straight to the GitHub Releases +/// API. Useful for the explicit "Check for updates" button in debug builds +/// where the plugin isn't registered. +/// * `endpoint_override` — test hook for the GitHub fallback URL. +pub async fn run_update_check( + app: AppHandle, + #[cfg_attr(debug_assertions, allow(unused_variables))] auto_install: bool, + #[cfg_attr(debug_assertions, allow(unused_variables))] force_fallback: bool, + endpoint_override: Option, +) { + #[cfg(not(debug_assertions))] + if !force_fallback { + use tauri_plugin_updater::UpdaterExt; + match app.updater() { + Ok(updater) => match updater.check().await { + Ok(Some(update)) => { + let _ = app.emit( + "updater://available", + serde_json::json!({ + "version": update.version, + "currentVersion": update.current_version, + "body": update.body, + "canAutoInstall": true, + "downloadUrl": serde_json::Value::Null, + }), + ); + if auto_install { + let progress_app = app.clone(); + let download_result = update + .download_and_install( + move |chunk, total| { + let _ = progress_app.emit( + "updater://progress", + serde_json::json!({ + "chunk": chunk, + "total": total, + }), + ); + }, + || {}, + ) + .await; + match download_result { + Ok(()) => { + tracing::info!("updater: install complete, restarting"); + app.restart(); + } + Err(err) => { + tracing::warn!("updater: download_and_install failed: {err}"); + } + } + } + return; + } + Ok(None) => { + // Up to date — don't fall through to GitHub. + return; + } + Err(err) => { + tracing::debug!("updater: plugin check failed, trying GitHub fallback: {err}"); + // Fall through below. + } + }, + Err(err) => { + tracing::debug!("updater: plugin init failed, trying GitHub fallback: {err}"); + // Fall through below. + } + } + } + + // Debug builds, `force_fallback`, or plugin errors — use the GitHub API. + github::check_latest(app, endpoint_override).await; +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index a810e2d..539c0e4 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Recrest", - "version": "0.6.0", + "version": "0.7.0", "identifier": "eu.softventures.recrest", "build": { "beforeDevCommand": "yarn dev", @@ -35,6 +35,12 @@ "desktop": { "schemes": ["recrest"] } + }, + "updater": { + "active": true, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRCQjM4QkRDQUNBQzc3N0IKUldSN2Q2eXMzSXV6UzA5dVFUZDhaWWl3UGR4TTh6YzFMWEltcXdPbDlsMllLL3dUcXlMMFo1NVoK", + "endpoints": ["https://github.com/SoftVentures/Recrest/releases/latest/download/latest.json"], + "dialog": false } }, "bundle": { @@ -80,6 +86,6 @@ "bundleMediaFramework": true } }, - "createUpdaterArtifacts": false + "createUpdaterArtifacts": true } } diff --git a/app/src-tauri/tauri.dev.conf.json b/app/src-tauri/tauri.dev.conf.json new file mode 100644 index 0000000..6c99531 --- /dev/null +++ b/app/src-tauri/tauri.dev.conf.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "security": { + "capabilities": [ + "default", + { + "identifier": "dev", + "description": "Debug-only capability granting the main window access to devtools-related APIs. Tauri only compiles devtools support behind the `devtools` Cargo feature (or a debug build), so this capability is effectively inert in release builds where that feature is not enabled. Inlined here (instead of living in `capabilities/dev.json`) so it is NOT auto-discovered and therefore NEVER loaded for production builds — `tauri.dev.conf.json` is only merged via `tauri dev --config ...` and ignored by `tauri build`.", + "windows": ["main"], + "permissions": ["core:webview:allow-internal-toggle-devtools"] + } + ] + } + }, + "bundle": { + "icon": [ + "icons-dev/32x32.png", + "icons-dev/128x128.png", + "icons-dev/128x128@2x.png", + "icons-dev/icon.icns", + "icons-dev/icon.ico" + ] + } +} diff --git a/app/src/assets/recrest-icon-dev.svg b/app/src/assets/recrest-icon-dev.svg new file mode 100644 index 0000000..1183b44 --- /dev/null +++ b/app/src/assets/recrest-icon-dev.svg @@ -0,0 +1,16 @@ + + Recrest — Dev build + Recrest icon, development variant: white chevrons with an orange </> badge in the lower-right corner. + + + + + + + + + + + + + diff --git a/app/src/components/atoms/BrandIcon/index.tsx b/app/src/components/atoms/BrandIcon/index.tsx index 0082940..ecf149a 100644 --- a/app/src/components/atoms/BrandIcon/index.tsx +++ b/app/src/components/atoms/BrandIcon/index.tsx @@ -2,6 +2,8 @@ import { type SVGProps } from "react"; import { type SimpleIcon, siBitbucket, siGithub, siGitlab } from "simple-icons"; +import { cn } from "@/lib/utils"; + export type BrandSlug = "github" | "gitlab" | "bitbucket"; const BRAND_ICONS: Record = { @@ -24,6 +26,7 @@ export function BrandIcon({ size = 16, color = "currentColor", title, + className, ...rest }: BrandIconProps) { const icon = BRAND_ICONS[slug]; @@ -37,7 +40,7 @@ export function BrandIcon({ fill={fill} role="img" aria-label={title ?? icon.title} - style={{ flexShrink: 0 }} + className={cn("shrink-0", className)} {...rest} > diff --git a/app/src/components/atoms/DiffStat/index.tsx b/app/src/components/atoms/DiffStat/index.tsx index 9f315d7..fe959af 100644 --- a/app/src/components/atoms/DiffStat/index.tsx +++ b/app/src/components/atoms/DiffStat/index.tsx @@ -1,13 +1,3 @@ -import type { CSSProperties } from "react"; - -const DIFF_STYLE: CSSProperties = { - fontFamily: "var(--font-mono)", - fontSize: 11, - fontVariantNumeric: "tabular-nums", - display: "inline-flex", - gap: 6, -}; - interface DiffStatProps { added: number; removed: number; @@ -17,9 +7,9 @@ interface DiffStatProps { export function DiffStat({ added, removed }: DiffStatProps) { if (!added && !removed) return null; return ( - - {added > 0 && +{added}} - {removed > 0 && −{removed}} + + {added > 0 && +{added}} + {removed > 0 && −{removed}} ); } diff --git a/app/src/components/atoms/Icon/index.tsx b/app/src/components/atoms/Icon/index.tsx index 7980032..e493309 100644 --- a/app/src/components/atoms/Icon/index.tsx +++ b/app/src/components/atoms/Icon/index.tsx @@ -1,5 +1,7 @@ import type { ReactElement, SVGProps } from "react"; +import { cn } from "@/lib/utils"; + export type IconName = | "search" | "plus" @@ -33,6 +35,7 @@ export type IconName = | "settings" | "collapse" | "expand" + | "maximize" | "camera" | "inbox" | "star" @@ -43,7 +46,8 @@ export type IconName = | "license" | "scale" | "repo" - | "edit"; + | "edit" + | "wrench"; interface IconProps extends Omit, "name"> { name: IconName; @@ -186,6 +190,16 @@ const PATHS: Record = { ), + /** Classic "enter fullscreen" glyph: four corner brackets pointing + * outward. Used by the DetailPane's Open-full-view CTA. */ + maximize: ( + <> + + + + + + ), camera: ( <> @@ -247,6 +261,10 @@ const PATHS: Record = { ), + /** Wrench — developer / tooling affordance. Matches lucide's `wrench`. */ + wrench: ( + + ), /** Scales of justice — matches Octicon `law`, GitHub's license glyph. */ scale: ( <> @@ -259,7 +277,7 @@ const PATHS: Record = { ), }; -export function Icon({ name, size = 16, color = "currentColor", ...rest }: IconProps) { +export function Icon({ name, size = 16, color = "currentColor", className, ...rest }: IconProps) { return ( {PATHS[name]} diff --git a/app/src/components/atoms/IdeIcon/index.tsx b/app/src/components/atoms/IdeIcon/index.tsx index b699912..632d48c 100644 --- a/app/src/components/atoms/IdeIcon/index.tsx +++ b/app/src/components/atoms/IdeIcon/index.tsx @@ -8,6 +8,7 @@ import IntellijIdeaLogo from "@/components/atoms/IdeIcon/logos/intellij-idea.svg import JetbrainsLogo from "@/components/atoms/IdeIcon/logos/jetbrains.svg?react"; import VSCodeLogo from "@/components/atoms/IdeIcon/logos/visual-studio-code.svg?react"; import WebstormLogo from "@/components/atoms/IdeIcon/logos/webstorm.svg?react"; +import { cn } from "@/lib/utils"; /** * Official IDE logos inlined from the Iconify `logos` set (committed as @@ -94,8 +95,8 @@ function CursorGlyph({ viewBox="0 0 24 24" role="img" aria-label={title ?? siCursor.title} - className={className} - style={{ flexShrink: 0, opacity: mono ? 0.55 : 1, ...style }} + className={cn("shrink-0", mono ? "opacity-[0.55]" : "opacity-100", className)} + style={style} > diff --git a/app/src/components/atoms/LangDot/LangDot.test.tsx b/app/src/components/atoms/LangDot/LangDot.test.tsx index eabeadc..6385567 100644 --- a/app/src/components/atoms/LangDot/LangDot.test.tsx +++ b/app/src/components/atoms/LangDot/LangDot.test.tsx @@ -2,30 +2,39 @@ import { render } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { LangDot } from "@/components/atoms/LangDot"; +import { TooltipProvider } from "@/components/molecules/compounds/Tooltip"; + +function renderDot(lang: string | null | undefined) { + return render( + + + , + ); +} describe("LangDot", () => { it("rendert mit lang-dot Klasse", () => { - const { container } = render(); + const { container } = renderDot("rs"); expect(container.querySelector(".lang-dot")).not.toBeNull(); }); it("rendert bei null-lang (Fallback)", () => { - const { container } = render(); + const { container } = renderDot(null); expect(container.querySelector(".lang-dot")).not.toBeNull(); }); it("rendert bei undefined-lang (Fallback)", () => { - const { container } = render(); + const { container } = renderDot(undefined); expect(container.querySelector(".lang-dot")).not.toBeNull(); }); - it("hat ein title-Attribut mit Sprachenbezeichnung", () => { - const { container } = render(); - expect(container.querySelector(".lang-dot")?.getAttribute("title")).toBeTruthy(); + it("hat ein aria-label mit Sprachenbezeichnung", () => { + const { container } = renderDot("Rust"); + expect(container.querySelector(".lang-dot")?.getAttribute("aria-label")).toBeTruthy(); }); it("setzt background-style aus langMeta", () => { - const { container } = render(); + const { container } = renderDot("Rust"); const dot = container.querySelector(".lang-dot"); expect(dot?.style.background).not.toBe(""); }); diff --git a/app/src/components/atoms/LangDot/index.tsx b/app/src/components/atoms/LangDot/index.tsx index 127fbbd..75a873f 100644 --- a/app/src/components/atoms/LangDot/index.tsx +++ b/app/src/components/atoms/LangDot/index.tsx @@ -1,3 +1,4 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip"; import { langMeta } from "@/lib/languages"; interface LangDotProps { @@ -8,5 +9,17 @@ interface LangDotProps { * extension ("rs") or a canonical linguist name ("Rust"). */ export function LangDot({ lang }: LangDotProps) { const meta = langMeta(lang); - return ; + return ( + + + + + {meta.label} + + ); } diff --git a/app/src/components/atoms/Mascot/Mascot.stories.tsx b/app/src/components/atoms/Mascot/Mascot.stories.tsx new file mode 100644 index 0000000..415995f --- /dev/null +++ b/app/src/components/atoms/Mascot/Mascot.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Mascot, type MascotVariant } from "@/components/atoms/Mascot"; + +const meta: Meta = { + title: "Atoms/Mascot", + component: Mascot, + args: { + variant: "snoozing", + size: 128, + }, + argTypes: { + variant: { + control: { type: "select" }, + options: [ + "snoozing", + "celebrating", + "searching", + "waving", + "shrugging", + ] satisfies MascotVariant[], + }, + size: { control: { type: "number", min: 48, max: 256, step: 8 } }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Snoozing: Story = { args: { variant: "snoozing" } }; +export const Celebrating: Story = { args: { variant: "celebrating" } }; +export const Searching: Story = { args: { variant: "searching" } }; +export const Waving: Story = { args: { variant: "waving" } }; +export const Shrugging: Story = { args: { variant: "shrugging" } }; + +export const AllPoses: Story = { + render: () => ( +
+ {(["snoozing", "celebrating", "searching", "waving", "shrugging"] as MascotVariant[]).map( + (v) => ( +
+ + {v} +
+ ), + )} +
+ ), +}; diff --git a/app/src/components/atoms/Mascot/index.tsx b/app/src/components/atoms/Mascot/index.tsx new file mode 100644 index 0000000..5b89f3f --- /dev/null +++ b/app/src/components/atoms/Mascot/index.tsx @@ -0,0 +1,321 @@ +import { cn } from "@/lib/utils"; + +/** + * Recrest's empty-state mascot. A rounded-square character echoing the app + * icon, with the double-chevron logo as a head-crest and a handful of poses + * that match the semantic of each empty state (snoozing = nothing to do, + * celebrating = success, searching = filter has no hits, waving = onboarding, + * shrugging = generic empty). + * + * Stroke inherits `currentColor` so callers control the ink via CSS `color`. + * The crest uses `--accent` directly for a stable brand touch across themes. + */ +export type MascotVariant = "snoozing" | "celebrating" | "searching" | "waving" | "shrugging"; + +interface MascotProps { + variant?: MascotVariant; + size?: number; + className?: string; + title?: string; +} + +export function Mascot({ variant = "shrugging", size = 112, className, title }: MascotProps) { + return ( + + + + + + + + ); +} + +/* ───────── Body: rounded-square torso that also houses the head ───────── */ + +function MascotBody() { + return ( + <> + {/* Soft shadow puddle */} + + {/* Torso/head combo */} + + {/* Subtle belly hairline to give depth */} + + + ); +} + +/* ───────── Crest: the Recrest double-chevron as a forehead mark ───────── */ + +function MascotCrest() { + return ( + + + + + ); +} + +/* ───────── Face: eyes + mouth swap per mood ───────── + * Default eye centers: left (50, 54), right (78, 54). Mouth around (64, 72). + */ + +function MascotFace({ variant }: { variant: MascotVariant }) { + switch (variant) { + case "snoozing": + return ( + + + + + + ); + case "celebrating": + return ( + + + + + + ); + case "searching": + return ( + + + + + + ); + case "waving": + return ( + + + + {/* open, cheerful mouth */} + + {/* blush */} + + + + ); + case "shrugging": + default: + return ( + + + + + + ); + } +} + +/* ───────── Arms: different gestures per mood ───────── */ + +function MascotArms({ variant }: { variant: MascotVariant }) { + const base = { + stroke: "currentColor", + strokeWidth: 4, + fill: "var(--accent-weak)", + } as const; + + switch (variant) { + case "snoozing": + // Arms crossed, resting on the belly + return ( + + + + + ); + case "celebrating": + // Arms up in the air, little "hand" circles at the tips + return ( + + + + + + + ); + case "searching": + // One arm holds a magnifying glass out to the side + return ( + + {/* Left arm resting */} + + {/* Right arm holds the magnifier */} + + + + {/* Glass shine */} + + + ); + case "waving": + // One arm waving overhead, other at the side + return ( + + + + + + ); + case "shrugging": + default: + // Arms out to the sides, palms up + return ( + + + + + + + ); + } +} + +/* ───────── Decor: the little floaty extras (z's, sparks, dots) ───────── */ + +function MascotDecor({ variant }: { variant: MascotVariant }) { + switch (variant) { + case "snoozing": + return ( + + + + + ); + case "celebrating": + // Spark bursts either side + return ( + + + + + + + ); + case "searching": + // Tiny question mark / dotted trail above + return ( + + + + + ); + case "waving": + // Little motion lines near the raised hand + return ( + + + + + ); + case "shrugging": + default: + return null; + } +} diff --git a/app/src/components/molecules/AuthorAvatar/index.tsx b/app/src/components/molecules/AuthorAvatar/index.tsx index 18550ef..5e21681 100644 --- a/app/src/components/molecules/AuthorAvatar/index.tsx +++ b/app/src/components/molecules/AuthorAvatar/index.tsx @@ -25,7 +25,13 @@ interface AuthorAvatarProps { * leaves a blank hole and a 404 falls back cleanly via `onError`. */ export function AuthorAvatar({ name, size = 24, src, email, className }: AuthorAvatarProps) { const label = initialsFromName(name) || "?"; - const resolvedSrc = src ?? (email ? gravatarUrl(email, size) : null); + // Bot accounts (Dependabot, Renovate, GitHub Actions, etc.) rarely have a + // real Gravatar; their commits and MRs all share one canonical GitHub + // avatar. Match by substring on the author name or email so a commit + // signed as "dependabot[bot]" or a PR fetched before `authorAvatarUrl` + // existed still picks up the right face. + const botSrc = src ?? resolveBotAvatar(name, email, size); + const resolvedSrc = botSrc ?? (email ? gravatarUrl(email, size) : null); const [imgFailed, setImgFailed] = useState(false); useEffect(() => { // Reset the failure flag whenever the underlying URL changes — otherwise @@ -101,6 +107,34 @@ export function AuthorAvatar({ name, size = 24, src, email, className }: AuthorA ); } +/** Map a display name or commit-email to the canonical GitHub avatar URL + * for well-known bots. Returns null for anything we don't recognise so the + * caller can fall back to Gravatar-by-email. We query GitHub's + * `/u/:login.png?size=N` endpoint because it handles both App accounts + * (dependabot, renovate) and regular bot users uniformly, and it serves an + * appropriately-sized asset without needing the GitHub API. */ +function resolveBotAvatar( + name: string | null | undefined, + email: string | null | undefined, + size: number, +): string | null { + const sniff = ((name ?? "") + " " + (email ?? "")).toLowerCase(); + // Retina boost: pull a 2x bitmap so the circular mask (object-fit: cover) + // stays crisp on HiDPI displays, mirroring what AuthorAvatar does for + // Gravatar URLs. + const px = Math.max(32, size * 2); + if (sniff.includes("dependabot")) { + return `https://github.com/dependabot.png?size=${px}`; + } + if (sniff.includes("renovate")) { + return `https://github.com/renovate-bot.png?size=${px}`; + } + if (sniff.includes("github-actions") || sniff.includes("github actions")) { + return `https://github.com/github-actions.png?size=${px}`; + } + return null; +} + const GRADIENTS = [ "linear-gradient(135deg,#ff7a59,#d6336c)", "linear-gradient(135deg,#4f8cff,#7b2ff7)", diff --git a/app/src/components/molecules/EmptyState/EmptyState.stories.tsx b/app/src/components/molecules/EmptyState/EmptyState.stories.tsx index 2a2d7de..c2f2e1f 100644 --- a/app/src/components/molecules/EmptyState/EmptyState.stories.tsx +++ b/app/src/components/molecules/EmptyState/EmptyState.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { GitPullRequest } from "lucide-react"; import { EmptyState } from "@/components/molecules/EmptyState"; @@ -9,9 +10,59 @@ const meta: Meta = { export default meta; -export const Default: StoryObj = { +type Story = StoryObj; + +export const Default: Story = { args: { title: "Nothing here", description: "Add something to get started.", }, }; + +export const WithLucideIcon: Story = { + args: { + icon: GitPullRequest, + title: "No open merge requests", + description: "When something comes in, it'll show up here.", + }, +}; + +export const Snoozing: Story = { + args: { + mascot: "snoozing", + title: "No open merge requests", + description: "Everything's quiet — enjoy it while it lasts.", + }, +}; + +export const Celebrating: Story = { + args: { + mascot: "celebrating", + title: "Everything clean and in sync", + description: "Nothing needs your attention right now.", + }, +}; + +export const Searching: Story = { + args: { + mascot: "searching", + title: "No branches match your filter", + description: "Try a broader filter or clear it.", + }, +}; + +export const Waving: Story = { + args: { + mascot: "waving", + title: "Nothing here yet", + description: "Add your first repository to get started.", + }, +}; + +export const Shrugging: Story = { + args: { + mascot: "shrugging", + title: "Nothing to show", + description: "There's just nothing here — yet.", + }, +}; diff --git a/app/src/components/molecules/EmptyState/index.tsx b/app/src/components/molecules/EmptyState/index.tsx index 92ec7c6..cf3476d 100644 --- a/app/src/components/molecules/EmptyState/index.tsx +++ b/app/src/components/molecules/EmptyState/index.tsx @@ -1,27 +1,45 @@ import { type ComponentType, type ReactNode } from "react"; +import { Mascot, type MascotVariant } from "@/components/atoms/Mascot"; import { cn } from "@/lib/utils"; interface EmptyStateProps { + /** Optional Lucide-style icon. Ignored when `mascot` is set. */ icon?: ComponentType<{ className?: string; "aria-hidden"?: boolean }>; + /** Friendly Recrest character to show above the text. Takes precedence over `icon`. */ + mascot?: MascotVariant; + /** Pixel size of the mascot SVG. Default 112; use ~88 in compact cards. */ + mascotSize?: number; title: string; description?: ReactNode; action?: ReactNode; className?: string; } -export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) { +export function EmptyState({ + icon: Icon, + mascot, + mascotSize, + title, + description, + action, + className, +}: EmptyStateProps) { return (
- {Icon && ( -
- -
+ {mascot ? ( + + ) : ( + Icon && ( +
+ +
+ ) )}

{title}

diff --git a/app/src/components/molecules/ExternalLinkButton/ExternalLinkButton.test.tsx b/app/src/components/molecules/ExternalLinkButton/ExternalLinkButton.test.tsx index f10a2ce..7283368 100644 --- a/app/src/components/molecules/ExternalLinkButton/ExternalLinkButton.test.tsx +++ b/app/src/components/molecules/ExternalLinkButton/ExternalLinkButton.test.tsx @@ -1,27 +1,34 @@ +import type { ReactElement } from "react"; + import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { ExternalLinkButton } from "@/components/molecules/ExternalLinkButton"; +import { TooltipProvider } from "@/components/molecules/compounds/Tooltip"; + +function renderWithTooltip(ui: ReactElement) { + return render({ui}); +} describe("ExternalLinkButton", () => { - it("rendert Label und nutzt es als title-Fallback", () => { - render(); - const btn = screen.getByRole("button", { name: /Docs/ }); - expect(btn).toHaveAttribute("title", "Docs"); + it("rendert Label und nutzt es als aria-label-Fallback", () => { + renderWithTooltip(); + const btn = screen.getByTestId("external-link-button"); + expect(btn).toHaveAttribute("aria-label", "Docs"); }); it("ruft window.open außerhalb von Tauri auf", async () => { const user = userEvent.setup(); const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); - render(); - await user.click(screen.getByRole("button")); + renderWithTooltip(); + await user.click(screen.getByTestId("external-link-button")); expect(openSpy).toHaveBeenCalledWith("https://example.com", "_blank", "noopener,noreferrer"); openSpy.mockRestore(); }); it("rendert im iconOnly-Modus kein Label", () => { - render(); + renderWithTooltip(); expect(screen.queryByText("Docs")).toBeNull(); }); }); diff --git a/app/src/components/molecules/ExternalLinkButton/index.tsx b/app/src/components/molecules/ExternalLinkButton/index.tsx index 9ea8b5b..bc2a0b4 100644 --- a/app/src/components/molecules/ExternalLinkButton/index.tsx +++ b/app/src/components/molecules/ExternalLinkButton/index.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { Icon } from "@/components/atoms/Icon"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip"; import { openExternal } from "@/lib/tauri"; interface ExternalLinkButtonProps { @@ -30,15 +31,22 @@ export function ExternalLinkButton({ className, }: ExternalLinkButtonProps) { const classes = ["r-btn", size === "sm" ? "sm" : "", className ?? ""].filter(Boolean).join(" "); + const tooltipText = title ?? (typeof label === "string" ? label : url); return ( - + + + + + {tooltipText} + ); } diff --git a/app/src/components/molecules/OpenInIdeButton/OpenInIdeButton.test.tsx b/app/src/components/molecules/OpenInIdeButton/OpenInIdeButton.test.tsx index f2d41e7..5b9899c 100644 --- a/app/src/components/molecules/OpenInIdeButton/OpenInIdeButton.test.tsx +++ b/app/src/components/molecules/OpenInIdeButton/OpenInIdeButton.test.tsx @@ -20,10 +20,12 @@ describe("OpenInIdeButton", () => { seedDetected([]); render( - + + + , ); - const btn = screen.getByRole("button"); + const btn = screen.getByTestId("open-in-ide-button"); expect(btn).toBeDisabled(); expect(btn).toHaveTextContent(/open in ide/i); }); @@ -32,10 +34,12 @@ describe("OpenInIdeButton", () => { seedDetected(["cursor"]); render( - + + + , ); - const btn = screen.getByRole("button"); + const btn = screen.getByTestId("open-in-ide-button"); expect(btn).toBeEnabled(); expect(btn).toHaveTextContent(/open in cursor/i); }); diff --git a/app/src/components/molecules/OpenInIdeButton/index.tsx b/app/src/components/molecules/OpenInIdeButton/index.tsx index 087f025..cba81e4 100644 --- a/app/src/components/molecules/OpenInIdeButton/index.tsx +++ b/app/src/components/molecules/OpenInIdeButton/index.tsx @@ -5,6 +5,7 @@ import { TauriCommand } from "@recrest/shared"; import { Icon } from "@/components/atoms/Icon"; import { IdeIcon } from "@/components/atoms/IdeIcon"; import { IconButton } from "@/components/molecules/IconButton"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip"; import { useActiveIde } from "@/hooks/useActiveIde"; import { invoke } from "@/lib/tauri"; import { toast } from "@/lib/toast"; @@ -105,17 +106,26 @@ export function OpenInIdeButton({ .filter(Boolean) .join(" "); - return ( + const button = ( ); + + if (!disabledTitle) return button; + return ( + + {button} + {disabledTitle} + + ); } diff --git a/app/src/components/molecules/RepoAvatar/RepoAvatar.test.tsx b/app/src/components/molecules/RepoAvatar/RepoAvatar.test.tsx index 108ec7e..196a2f2 100644 --- a/app/src/components/molecules/RepoAvatar/RepoAvatar.test.tsx +++ b/app/src/components/molecules/RepoAvatar/RepoAvatar.test.tsx @@ -6,11 +6,16 @@ import { Provider } from "react-redux"; import { describe, expect, it } from "vitest"; import { RepoAvatar } from "@/components/molecules/RepoAvatar"; +import { TooltipProvider } from "@/components/molecules/compounds/Tooltip"; import { settingsReducer } from "@/store/slices/settingsSlice"; function renderWithStore(ui: ReactElement) { const store = configureStore({ reducer: { settings: settingsReducer } }); - return render({ui}); + return render( + + {ui} + , + ); } describe("RepoAvatar", () => { @@ -19,9 +24,9 @@ describe("RepoAvatar", () => { expect(screen.getByText("R")).toBeInTheDocument(); }); - it("nutzt den Namen als title-Attribut", () => { - const { container } = renderWithStore(); - expect(container.querySelector('[title="MyRepo"]')).not.toBeNull(); + it("nutzt den Namen als aria-label-Attribut", () => { + renderWithStore(); + expect(screen.getByTestId("repo-avatar")).toHaveAttribute("aria-label", "MyRepo"); }); it("respektiert die size-Prop", () => { diff --git a/app/src/components/molecules/RepoAvatar/index.tsx b/app/src/components/molecules/RepoAvatar/index.tsx index ded3ccb..729e839 100644 --- a/app/src/components/molecules/RepoAvatar/index.tsx +++ b/app/src/components/molecules/RepoAvatar/index.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { WindowEvent, storageKeyForLogo } from "@recrest/shared"; import { BrandIcon, type BrandSlug } from "@/components/atoms/BrandIcon"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip"; import { useRepoLogo } from "@/hooks/useRepoLogo"; /** Curated, hand-picked two-stop gradients for repo avatars. Each pair is @@ -117,36 +118,42 @@ export function RepoAvatar({ repo, size = 24, radius = 6 }: RepoAvatarProps) { const src = custom ?? autoLogo; if (src) { return ( -
- -
+ + +
+ +
+
+ {repo.name} +
); } @@ -156,24 +163,30 @@ export function RepoAvatar({ repo, size = 24, radius = 6 }: RepoAvatarProps) { const specialIcon = detectSpecialIcon(repo.name); if (specialIcon) { return ( -
- -
+ + +
+ +
+
+ {repo.name} +
); } @@ -181,29 +194,35 @@ export function RepoAvatar({ repo, size = 24, radius = 6 }: RepoAvatarProps) { const letter = cleaned.charAt(0).toUpperCase(); return ( -
- {letter} -
+ + +
+ {letter} +
+
+ {repo.name} +
); } diff --git a/app/src/components/molecules/compounds/TruncatedTooltip/TruncatedTooltip.test.tsx b/app/src/components/molecules/compounds/TruncatedTooltip/TruncatedTooltip.test.tsx new file mode 100644 index 0000000..18c34b1 --- /dev/null +++ b/app/src/components/molecules/compounds/TruncatedTooltip/TruncatedTooltip.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TooltipProvider } from "@/components/molecules/compounds/Tooltip"; +import { TruncatedTooltip } from "@/components/molecules/compounds/TruncatedTooltip"; + +/** + * jsdom does not perform layout, so `scrollWidth` and `clientWidth` are both + * `0` by default. We stub them on `HTMLElement.prototype` to simulate the + * two relevant states. + */ +function stubWidths(scroll: number, client: number) { + Object.defineProperty(HTMLElement.prototype, "scrollWidth", { + configurable: true, + get() { + return scroll; + }, + }); + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get() { + return client; + }, + }); +} + +beforeEach(() => { + // Minimal ResizeObserver stub — our hook only uses observe/disconnect. + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + observe() {} + disconnect() {} + unobserve() {} + }; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("TruncatedTooltip", () => { + it("renders the child as-is when text is not truncated", () => { + stubWidths(50, 100); // scroll <= client → not truncated + render( + + + short + + , + ); + const child = screen.getByTestId("tt-child"); + expect(child).toBeInTheDocument(); + // Radix adds `data-state` to its Trigger. A bare child has none. + expect(child.getAttribute("data-state")).toBeNull(); + }); + + it("wraps the child in a tooltip trigger when text overflows", () => { + stubWidths(200, 100); // scroll > client → truncated + render( + + + overflowing + + , + ); + const child = screen.getByTestId("tt-child"); + // Radix Tooltip.Trigger sets `data-state="closed"` on the trigger element + // until hovered/focused. Its presence confirms the wrapping kicked in. + expect(child.getAttribute("data-state")).toBe("closed"); + }); + + it("renders children as-is when content is empty", () => { + stubWidths(200, 100); // would normally trigger tooltip + render( + + + whatever + + , + ); + const child = screen.getByTestId("tt-child"); + expect(child.getAttribute("data-state")).toBeNull(); + }); +}); diff --git a/app/src/components/molecules/compounds/TruncatedTooltip/index.tsx b/app/src/components/molecules/compounds/TruncatedTooltip/index.tsx new file mode 100644 index 0000000..0e3db2a --- /dev/null +++ b/app/src/components/molecules/compounds/TruncatedTooltip/index.tsx @@ -0,0 +1,71 @@ +import { + type ReactElement, + type Ref, + cloneElement, + useCallback, + useLayoutEffect, + useRef, + useState, +} from "react"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip"; + +interface Props { + /** Tooltip text. If undefined/empty, renders children as-is. */ + content: string | undefined | null; + /** Single child whose DOM node is measured for truncation. + * Must be a DOM element (e.g. ``, `

`, `

`, ``) — not a + * function component, because a ref is attached to decide whether the + * content is actually clipped. */ + children: ReactElement; +} + +/** + * Wraps children in a Tooltip only when the child's text is actually + * horizontally truncated (`scrollWidth > clientWidth`). Keeps a11y and + * hover noise down on labels that fit their container. + * + * Assumes `TooltipProvider` is mounted higher up (done once in `AppShell`). + */ +export function TruncatedTooltip({ content, children }: Props) { + const innerRef = useRef(null); + const [truncated, setTruncated] = useState(false); + + const measure = useCallback(() => { + const el = innerRef.current; + if (!el) return; + setTruncated(el.scrollWidth > el.clientWidth); + }, []); + + useLayoutEffect(() => { + measure(); + const el = innerRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [measure, content]); + + const originalRef = (children as unknown as { ref?: Ref }).ref; + const mergedRef = (node: HTMLElement | null) => { + innerRef.current = node; + if (typeof originalRef === "function") { + originalRef(node); + } else if (originalRef && typeof originalRef === "object") { + (originalRef as { current: HTMLElement | null }).current = node; + } + }; + + // `cloneElement` does not carry the child's exact props signature, so the + // ref key must be injected via a loosely-typed props object. DOM elements + // accept the `ref` prop at runtime regardless. + const childWithRef = cloneElement(children, { ref: mergedRef } as unknown as Partial); + + if (!content || !truncated) return childWithRef; + return ( + + {childWithRef} + {content} + + ); +} diff --git a/app/src/components/organisms/activity/Timeline/Timeline.test.tsx b/app/src/components/organisms/activity/Timeline/Timeline.test.tsx index 321c282..346ae7d 100644 --- a/app/src/components/organisms/activity/Timeline/Timeline.test.tsx +++ b/app/src/components/organisms/activity/Timeline/Timeline.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react"; import { Provider } from "react-redux"; import { describe, expect, it } from "vitest"; +import { TooltipProvider } from "@/components/molecules/compounds/Tooltip"; import { Timeline } from "@/components/organisms/activity/Timeline"; import { fakeCheckRun, @@ -23,21 +24,23 @@ describe("Timeline", () => { , ); - expect(screen.getByText(/No events match/i)).toBeInTheDocument(); + expect(screen.getByTestId("activity-timeline-empty")).toBeInTheDocument(); }); it("renders a day card when there is at least one commit", () => { - const { container } = render( + render( - + + + , ); - expect(container.querySelector(".a-act-day-card")).not.toBeNull(); + expect(screen.getAllByTestId("activity-timeline-day").length).toBeGreaterThan(0); }); }); diff --git a/app/src/components/organisms/activity/Timeline/index.tsx b/app/src/components/organisms/activity/Timeline/index.tsx index b3e60d2..95ae40e 100644 --- a/app/src/components/organisms/activity/Timeline/index.tsx +++ b/app/src/components/organisms/activity/Timeline/index.tsx @@ -193,11 +193,13 @@ export function Timeline({ commits, prEvents, checkRuns, today, reposById }: Pro right={filterChips} > {filteredGroups.length === 0 ? ( -
{t("activity.timeline.empty_filter")}
+
+ {t("activity.timeline.empty_filter")} +
) : (
{filteredGroups.map((g) => ( -
+
{dayLabel(g.day)}
@@ -258,7 +260,7 @@ function FilterPill({ active, label, count, onClick }: FilterPillProps) {
diff --git a/app/src/components/organisms/activity/cards/AuthorsHero/index.tsx b/app/src/components/organisms/activity/cards/AuthorsHero/index.tsx index 36377a9..5350d32 100644 --- a/app/src/components/organisms/activity/cards/AuthorsHero/index.tsx +++ b/app/src/components/organisms/activity/cards/AuthorsHero/index.tsx @@ -30,7 +30,7 @@ export function AuthorsHero({ authors, topAuthors }: Props) { ))}
-
+
{dir === "up" && } {dir === "down" && } {deltaLabel} diff --git a/app/src/components/organisms/activity/cards/CardShell/index.tsx b/app/src/components/organisms/activity/cards/CardShell/index.tsx index 5286e97..56a3092 100644 --- a/app/src/components/organisms/activity/cards/CardShell/index.tsx +++ b/app/src/components/organisms/activity/cards/CardShell/index.tsx @@ -27,7 +27,7 @@ export function CardShell({ return (
-
+

{title}

{sub &&
{sub}
}
diff --git a/app/src/components/organisms/activity/cards/OpenPrsHero/index.tsx b/app/src/components/organisms/activity/cards/OpenPrsHero/index.tsx index a9d14d5..49f7013 100644 --- a/app/src/components/organisms/activity/cards/OpenPrsHero/index.tsx +++ b/app/src/components/organisms/activity/cards/OpenPrsHero/index.tsx @@ -30,12 +30,12 @@ export function OpenPrsHero({ prsByRepo }: Props) {
{t("activity.hero.open_prs")}
{open}
- - + +
- + {t("activity.hero.open_prs_sub", { review, draft })}
diff --git a/app/src/components/organisms/activity/cards/PrVelocityCard/index.tsx b/app/src/components/organisms/activity/cards/PrVelocityCard/index.tsx index c31f754..68d0f98 100644 --- a/app/src/components/organisms/activity/cards/PrVelocityCard/index.tsx +++ b/app/src/components/organisms/activity/cards/PrVelocityCard/index.tsx @@ -57,11 +57,11 @@ export function PrVelocityCard({ rows, loading }: Props) {
- + {t("activity.cards.pr_velocity_opened")} - + {t("activity.cards.pr_velocity_merged")}
diff --git a/app/src/components/organisms/activity/cards/ReviewQueueCard/ReviewQueueCard.test.tsx b/app/src/components/organisms/activity/cards/ReviewQueueCard/ReviewQueueCard.test.tsx index 3ddc54d..2462851 100644 --- a/app/src/components/organisms/activity/cards/ReviewQueueCard/ReviewQueueCard.test.tsx +++ b/app/src/components/organisms/activity/cards/ReviewQueueCard/ReviewQueueCard.test.tsx @@ -34,6 +34,6 @@ describe("ReviewQueueCard", () => { , ); - expect(screen.getByText(/No open PRs/i)).toBeInTheDocument(); + expect(screen.getByTestId("activity-card-review-queue-empty")).toBeInTheDocument(); }); }); diff --git a/app/src/components/organisms/activity/cards/ReviewQueueCard/index.tsx b/app/src/components/organisms/activity/cards/ReviewQueueCard/index.tsx index a3aa3cc..be3686b 100644 --- a/app/src/components/organisms/activity/cards/ReviewQueueCard/index.tsx +++ b/app/src/components/organisms/activity/cards/ReviewQueueCard/index.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; +import { EmptyState } from "@/components/molecules/EmptyState"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip"; import { CardShell } from "@/components/organisms/activity/cards/CardShell"; import type { ReviewQueueEntry } from "@/lib/activityAggregates"; @@ -20,9 +21,16 @@ export function ReviewQueueCard({ entries, loading }: Props) { skeleton="rows" > {entries.length === 0 ? ( -
{t("activity.cards.review_queue_empty")}
+
+ +
) : ( -
+
{entries.map((e) => { const age = Math.round(e.ageDays); const ageLabel = @@ -31,36 +39,29 @@ export function ReviewQueueCard({ entries, loading }: Props) { : t("activity.cards.age_days_other", { count: age }); const open = () => void openExternal(e.url); return ( -
{ - if (ev.key === "Enter" || ev.key === " ") { - ev.preventDefault(); - open(); - } - }} > -
-
{e.title}
-
+ + {e.title} + {e.repoName} · #{e.number} · {e.author} -
-
+ + -
= 7 ? " old" : ""}`}>{age}d
+ = 7 ? " old" : ""}`}>{age}d
{ageLabel}
-
+ ); })}
diff --git a/app/src/components/organisms/feedback/UpdaterBanner/UpdaterBanner.stories.tsx b/app/src/components/organisms/feedback/UpdaterBanner/UpdaterBanner.stories.tsx index 73340e1..8a2e400 100644 --- a/app/src/components/organisms/feedback/UpdaterBanner/UpdaterBanner.stories.tsx +++ b/app/src/components/organisms/feedback/UpdaterBanner/UpdaterBanner.stories.tsx @@ -23,7 +23,10 @@ export const Available: StoryObj = { store.dispatch( setUpdaterBanner({ version: "1.4.0", + currentVersion: "1.3.9", body: "Activity page redesign, new CI pass-rate trend, bug fixes.", + canAutoInstall: true, + downloadUrl: null, }), ); return ; @@ -32,7 +35,29 @@ export const Available: StoryObj = { export const Minimal: StoryObj = { render: () => { - store.dispatch(setUpdaterBanner({ version: "1.4.1", body: null })); + store.dispatch( + setUpdaterBanner({ + version: "1.4.1", + body: null, + canAutoInstall: true, + downloadUrl: null, + }), + ); + return ; + }, +}; + +export const ManualDownload: StoryObj = { + render: () => { + store.dispatch( + setUpdaterBanner({ + version: "1.4.2", + currentVersion: "1.3.9", + body: "Local development build — updates open in your browser.", + canAutoInstall: false, + downloadUrl: "https://github.com/SoftVentures/Recrest/releases/tag/v1.4.2", + }), + ); return ; }, }; diff --git a/app/src/components/organisms/feedback/UpdaterBanner/UpdaterBanner.test.tsx b/app/src/components/organisms/feedback/UpdaterBanner/UpdaterBanner.test.tsx index 075a995..310b474 100644 --- a/app/src/components/organisms/feedback/UpdaterBanner/UpdaterBanner.test.tsx +++ b/app/src/components/organisms/feedback/UpdaterBanner/UpdaterBanner.test.tsx @@ -1,36 +1,103 @@ -import { act, render, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; + +import { configureStore } from "@reduxjs/toolkit"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { Provider } from "react-redux"; -import { afterEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { UpdaterBanner } from "@/components/organisms/feedback/UpdaterBanner"; import "@/i18n"; -import { store } from "@/store"; -import { setUpdaterBanner } from "@/store/slices/uiSlice"; +import { setUpdaterBanner, uiReducer } from "@/store/slices/uiSlice"; + +// Mock tauri bridge — all IPC and shell interactions should be mockable so +// the banner can be exercised in a jsdom environment without __TAURI__. +const openExternalMock = vi.fn<(url: string) => Promise>(() => Promise.resolve()); +const invokeMock = vi.fn<(command: string, args?: Record) => Promise>( + () => Promise.resolve(), +); + +vi.mock("@/lib/tauri", () => ({ + invoke: (command: string, args?: Record) => invokeMock(command, args), + openExternal: (url: string) => openExternalMock(url), + isTauri: () => false, +})); + +function makeStore() { + return configureStore({ reducer: { ui: uiReducer } }); +} + +function renderWithStore(ui: ReactElement, store = makeStore()) { + const utils = render({ui}); + return { ...utils, store }; +} -afterEach(() => { - store.dispatch(setUpdaterBanner(null)); +beforeEach(() => { + openExternalMock.mockClear(); + invokeMock.mockClear(); }); describe("UpdaterBanner", () => { it("renders nothing when there is no pending update", () => { - const { container } = render( - - - , - ); + const { container } = renderWithStore(); expect(container.firstChild).toBeNull(); }); - it("renders version + body when a banner is set", () => { - render( - - - , - ); + it("shows the install button for auto-install-capable builds", () => { + const { store } = renderWithStore(); + act(() => { + store.dispatch( + setUpdaterBanner({ + version: "1.2.3", + currentVersion: "1.2.2", + body: "notes", + canAutoInstall: true, + downloadUrl: null, + }), + ); + }); + expect(screen.getByTestId("updater-banner-install")).toBeInTheDocument(); + expect(screen.queryByTestId("updater-banner-download")).not.toBeInTheDocument(); + }); + + it("shows the download button for manual builds and opens the download URL", () => { + const { store } = renderWithStore(); + act(() => { + store.dispatch( + setUpdaterBanner({ + version: "1.2.3", + body: null, + canAutoInstall: false, + downloadUrl: "https://x", + }), + ); + }); + const downloadBtn = screen.getByTestId("updater-banner-download"); + expect(downloadBtn).toBeInTheDocument(); + expect(screen.queryByTestId("updater-banner-install")).not.toBeInTheDocument(); + + act(() => { + fireEvent.click(downloadBtn); + }); + expect(openExternalMock).toHaveBeenCalledWith("https://x"); + }); + + it("clears the banner when the user clicks Later", () => { + const { store } = renderWithStore(); + act(() => { + store.dispatch( + setUpdaterBanner({ + version: "1.2.3", + body: null, + canAutoInstall: true, + downloadUrl: null, + }), + ); + }); + expect(store.getState().ui.updaterBanner).not.toBeNull(); + act(() => { - store.dispatch(setUpdaterBanner({ version: "1.2.3", body: "new goodies" })); + fireEvent.click(screen.getByTestId("updater-banner-later")); }); - expect(screen.getByText(/1\.2\.3/)).toBeInTheDocument(); - expect(screen.getByText("new goodies")).toBeInTheDocument(); + expect(store.getState().ui.updaterBanner).toBeNull(); }); }); diff --git a/app/src/components/organisms/feedback/UpdaterBanner/index.tsx b/app/src/components/organisms/feedback/UpdaterBanner/index.tsx index aba42a2..d4232bc 100644 --- a/app/src/components/organisms/feedback/UpdaterBanner/index.tsx +++ b/app/src/components/organisms/feedback/UpdaterBanner/index.tsx @@ -1,21 +1,63 @@ +import { useState } from "react"; + import { useTranslation } from "react-i18next"; +import { TauriCommand, formatBytes } from "@recrest/shared"; + import { Button } from "@/components/atoms/Button"; -import { openExternal } from "@/lib/tauri"; +import { invoke, openExternal } from "@/lib/tauri"; +import { toast } from "@/lib/toast"; import { useAppDispatch, useAppSelector } from "@/store/hooks"; -import { setUpdaterBanner } from "@/store/slices/uiSlice"; +import { setUpdaterBanner, setUpdaterProgress } from "@/store/slices/uiSlice"; + +const FALLBACK_RELEASES_URL = "https://github.com/SoftVentures/Recrest/releases"; -/** Persistent banner anchored to the top of the shell that appears when the - * Tauri updater reports a newer version. Clicking "Download" opens the - * release page in the user's browser; the actual binary swap still happens - * via the OS installer so the user never loses an un-saved state. */ +/** Persistent banner anchored to the bottom-right of the shell that appears + * when the Rust updater reports a newer version. + * + * Two modes driven by `canAutoInstall`: + * - signed release build: "Install & restart" → invokes the Rust + * `install_update` command which swaps the binary and relaunches. + * While running we display the `updater://progress` stream. + * - unsigned/local build: "Download" → opens the platform asset URL + * in the system browser so the user can install it themselves. + */ export function UpdaterBanner() { - const { t } = useTranslation(); + const { t } = useTranslation("settings"); const dispatch = useAppDispatch(); const banner = useAppSelector((s) => s.ui.updaterBanner); + const progress = useAppSelector((s) => s.ui.updaterProgress); + const [installing, setInstalling] = useState(false); if (!banner) return null; + + const dismiss = () => { + dispatch(setUpdaterBanner(null)); + dispatch(setUpdaterProgress(null)); + }; + + const handleInstall = async () => { + setInstalling(true); + try { + await invoke(TauriCommand.INSTALL_UPDATE); + // On success Rust relaunches the app, so no further UI is needed. + } catch (err) { + console.warn("[updater] install failed:", err); + toast.error(t("updater.check_failed", { defaultValue: "Couldn't check for updates" })); + setInstalling(false); + } + }; + + const handleDownload = async () => { + const url = banner.downloadUrl ?? FALLBACK_RELEASES_URL; + await openExternal(url); + dismiss(); + }; + return ( -
+
{t("updater.available_title", { @@ -26,17 +68,41 @@ export function UpdaterBanner() { {banner.body && (
{banner.body}
)} + {installing && progress && ( +
+ {progress.total !== null + ? `${formatBytes(progress.chunk)} / ${formatBytes(progress.total)}` + : formatBytes(progress.chunk)} +
+ )}
+ {banner.canAutoInstall ? ( + + ) : ( + + )} -
diff --git a/app/src/components/organisms/layout/AppShell/index.tsx b/app/src/components/organisms/layout/AppShell/index.tsx index 2b49af0..229f7eb 100644 --- a/app/src/components/organisms/layout/AppShell/index.tsx +++ b/app/src/components/organisms/layout/AppShell/index.tsx @@ -24,10 +24,12 @@ import { useGlobalEvents } from "@/hooks/useGlobalEvents"; import { useGlobalShortcuts } from "@/hooks/useGlobalShortcuts"; import { useNotificationTriggers } from "@/hooks/useNotificationTriggers"; import { useWindowChrome } from "@/hooks/usePlatform"; +import { usePrPolling } from "@/hooks/useProviders"; import { useSearchHotkey } from "@/hooks/useSearch"; import { useTauri } from "@/hooks/useTauri"; import { useThemeEffect } from "@/hooks/useTheme"; import { useTrayBadgeSync } from "@/hooks/useTrayBadgeSync"; +import { isTauri } from "@/lib/tauri"; import { useAppDispatch, useAppSelector } from "@/store/hooks"; import { setLocale } from "@/store/slices/settingsSlice"; import { setSidebarCollapsed } from "@/store/slices/uiSlice"; @@ -46,8 +48,9 @@ export function AppShell({ children }: AppShellProps) { useTauri(); useTrayBadgeSync(); useGlobalEvents(); - useNotificationTriggers(); + useNotificationTriggers(isTauri()); useChromeAttribute(); + usePrPolling(); const location = useLocation(); const dispatch = useAppDispatch(); diff --git a/app/src/components/organisms/layout/DetailPane/index.tsx b/app/src/components/organisms/layout/DetailPane/index.tsx index 7b0d85a..aebe387 100644 --- a/app/src/components/organisms/layout/DetailPane/index.tsx +++ b/app/src/components/organisms/layout/DetailPane/index.tsx @@ -2,6 +2,8 @@ import { type ReactNode, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + import { TauriCommand } from "@recrest/shared"; import { BranchChip } from "@/components/atoms/BranchChip"; @@ -9,9 +11,11 @@ import { BrandIcon, brandFromUrl } from "@/components/atoms/BrandIcon"; import { CiDot, type CiState } from "@/components/atoms/CiDot"; import { DiffStat } from "@/components/atoms/DiffStat"; import { Icon } from "@/components/atoms/Icon"; +import { AuthorAvatar } from "@/components/molecules/AuthorAvatar"; import { IconButton } from "@/components/molecules/IconButton"; import { OpenInIdeButton } from "@/components/molecules/OpenInIdeButton"; import { RepoAvatar, setRepoLogo } from "@/components/molecules/RepoAvatar"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip"; import { CommitListSkeleton } from "@/components/molecules/skeletons/CommitListSkeleton"; import { CreateBranchDialog } from "@/components/organisms/repos/CreateBranchDialog"; import { useRecentCommits } from "@/hooks/useRecentCommits"; @@ -42,19 +46,26 @@ function Section({ const [open, setOpen] = useState(defaultOpen); return (
- {meta && {meta}} - +
{open &&
{children}
}
); @@ -69,6 +80,7 @@ function formatRustError(err: unknown, fallback: string): string { } export function DetailPane({ repo, onClose }: DetailPaneProps) { + const { t } = useTranslation("repos"); const meta = langMeta(repo.lang); const prs = useAppSelector((s) => s.prs.items); const repoPrs = prs[repo.id] ?? []; @@ -148,23 +160,32 @@ export function DetailPane({ repo, onClose }: DetailPaneProps) { ); } @@ -353,7 +381,7 @@ function RecentCommitsBody({ repo }: { repo: EnrichedRepo }) {
{commits.slice(0, 15).map((c) => (
- {initials(c.author)} +
{c.summary}
@@ -371,7 +399,7 @@ function RecentCommitsBody({ repo }: { repo: EnrichedRepo }) { return (
- {initials(fallbackSingle.author)} +
{fallbackSingle.summary}
@@ -384,7 +412,7 @@ function RecentCommitsBody({ repo }: { repo: EnrichedRepo }) {
); } - return
No commits yet.
; + return
No commits yet.
; } function ciToDot(s: string | null): CiState { @@ -394,23 +422,17 @@ function ciToDot(s: string | null): CiState { return null; } -function statusLetter(s: string): "M" | "A" | "D" | "R" { - if (s === "staged") return "M"; - if (s === "untracked") return "A"; - if (s === "conflicted") return "R"; +/** Three semantic buckets, mapped to the `.a-dp-st-*` letter badges: + * A=green (new files), M=amber (changes — modified, renamed, typechange), + * D=red (deletions). Renamed collapses into "changes" because users + * reading the list care about *what colour means risk*, not about the + * nuance between a rename and an edit. */ +function kindLetter(kind: string): "M" | "A" | "D" { + if (kind === "added") return "A"; + if (kind === "deleted") return "D"; return "M"; } -function initials(name: string): string { - return name - .split(/\s+/) - .map((p) => p[0]) - .filter(Boolean) - .slice(0, 2) - .join("") - .toUpperCase(); -} - function formatWhen(iso: string): string { const d = new Date(iso); const diff = Date.now() - d.getTime(); diff --git a/app/src/components/organisms/layout/Sidebar/Sidebar.test.tsx b/app/src/components/organisms/layout/Sidebar/Sidebar.test.tsx index f46dffb..50f086c 100644 --- a/app/src/components/organisms/layout/Sidebar/Sidebar.test.tsx +++ b/app/src/components/organisms/layout/Sidebar/Sidebar.test.tsx @@ -4,6 +4,7 @@ import { render, screen } from "@testing-library/react"; import { Provider } from "react-redux"; import { describe, expect, it } from "vitest"; +import { TooltipProvider } from "@/components/molecules/compounds/Tooltip"; import { Sidebar } from "@/components/organisms/layout/Sidebar"; import "@/i18n"; import { store } from "@/store"; @@ -31,14 +32,16 @@ describe("Sidebar", () => { render( - + + + , ); - expect(screen.getByRole("navigation", { hidden: true })).toBeInTheDocument(); - expect(screen.getByText(/dashboard/i)).toBeInTheDocument(); - expect(screen.getByText(/repositories/i)).toBeInTheDocument(); - expect(screen.getByText(/merge requests/i)).toBeInTheDocument(); + expect(screen.getByTestId("sidebar")).toBeInTheDocument(); + expect(screen.getByTestId("nav-dashboard")).toBeInTheDocument(); + expect(screen.getByTestId("nav-repos")).toBeInTheDocument(); + expect(screen.getByTestId("nav-merge-requests")).toBeInTheDocument(); }); }); diff --git a/app/src/components/organisms/layout/Sidebar/index.tsx b/app/src/components/organisms/layout/Sidebar/index.tsx index 87c4f17..c293cd6 100644 --- a/app/src/components/organisms/layout/Sidebar/index.tsx +++ b/app/src/components/organisms/layout/Sidebar/index.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { AppRoute, type AppRoutePath } from "@recrest/shared"; import { Icon, type IconName } from "@/components/atoms/Icon"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip"; import { Logo } from "@/components/organisms/brand/Logo"; import { useAppDispatch, useAppSelector } from "@/store/hooks"; import { toggleSidebar } from "@/store/slices/uiSlice"; @@ -39,7 +40,7 @@ function SideItem({ children?: ReactNode; testId?: string; }) { - return ( + const button = ( ); + if (!collapsed) return button; + return ( + + {button} + {label} + + ); } function testIdForRoute(path: string): string { @@ -120,7 +128,7 @@ export function Sidebar() {