From a16540a475f330a97d75699b8c14d4dc6491cb81 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Thu, 12 Feb 2026 21:36:02 -0500 Subject: [PATCH 01/13] fix: repair release workflow bootstrap --- .github/workflows/deploy-web.yml | 14 ++++++++++++-- .github/workflows/release-dev.yml | 1 + .github/workflows/release-stable.yml | 1 + 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 8cd979e..5ff3671 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -21,6 +21,7 @@ jobs: outputs: release_channel: ${{ steps.meta.outputs.release_channel }} release_repository: ${{ steps.meta.outputs.release_repository }} + cloudflare_enabled: ${{ steps.meta.outputs.cloudflare_enabled }} steps: - name: Checkout uses: actions/checkout@v6 @@ -39,6 +40,9 @@ jobs: env: RELEASE_REPOSITORY: ${{ vars.RELEASE_REPOSITORY }} GITHUB_REPOSITORY: ${{ github.repository }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT }} run: | CHANNEL="stable" if [[ "$GITHUB_REF_NAME" == "dev" ]]; then @@ -53,6 +57,12 @@ jobs: echo "release_channel=$CHANNEL" >> "$GITHUB_OUTPUT" echo "release_repository=$REPOSITORY" >> "$GITHUB_OUTPUT" + CLOUDFLARE_ENABLED="false" + if [[ -n "$CLOUDFLARE_API_TOKEN" && -n "$CLOUDFLARE_ACCOUNT_ID" && -n "$CLOUDFLARE_PAGES_PROJECT" ]]; then + CLOUDFLARE_ENABLED="true" + fi + echo "cloudflare_enabled=$CLOUDFLARE_ENABLED" >> "$GITHUB_OUTPUT" + - name: Build static website env: VITE_RELEASE_CHANNEL: ${{ steps.meta.outputs.release_channel }} @@ -69,7 +79,7 @@ jobs: name: Deploy to Cloudflare Pages needs: build runs-on: ubuntu-latest - if: ${{ secrets.CLOUDFLARE_API_TOKEN != '' && secrets.CLOUDFLARE_ACCOUNT_ID != '' && vars.CLOUDFLARE_PAGES_PROJECT != '' }} + if: ${{ needs.build.outputs.cloudflare_enabled == 'true' }} permissions: contents: read deployments: write @@ -93,7 +103,7 @@ jobs: name: Deploy to GitHub Pages (fallback) needs: build runs-on: ubuntu-latest - if: ${{ github.ref_name == 'main' && (secrets.CLOUDFLARE_API_TOKEN == '' || secrets.CLOUDFLARE_ACCOUNT_ID == '' || vars.CLOUDFLARE_PAGES_PROJECT == '') }} + if: ${{ github.ref_name == 'main' && needs.build.outputs.cloudflare_enabled != 'true' }} permissions: contents: read pages: write diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index ea359a7..889f959 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -100,6 +100,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@v1 with: + toolchain: stable targets: ${{ matrix.target }} - name: Rust cache diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index d517a9d..218271a 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -123,6 +123,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@v1 with: + toolchain: stable targets: ${{ matrix.target }} - name: Rust cache From ecec2bdf754b7acd3237cbcda7e6975e9e691b76 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Thu, 12 Feb 2026 21:56:15 -0500 Subject: [PATCH 02/13] fix: unblock web build and tauri release jobs --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy-web.yml | 2 +- .github/workflows/release-dev.yml | 2 +- .github/workflows/release-stable.yml | 2 +- .../src-tauri/src/plugin_engine/host_api.rs | 63 ++++++++++++++----- 5 files changed, 53 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40317d4..b4cad27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,4 +37,4 @@ jobs: run: bun run check-types - name: Build website - run: bun --cwd apps/web run build + run: bun run --cwd apps/web build diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 5ff3671..7722313 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -67,7 +67,7 @@ jobs: env: VITE_RELEASE_CHANNEL: ${{ steps.meta.outputs.release_channel }} VITE_RELEASE_REPOSITORY: ${{ steps.meta.outputs.release_repository }} - run: bun --cwd apps/web run build + run: bun run --cwd apps/web build - name: Upload website artifact uses: actions/upload-artifact@v6 diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 889f959..88dc59a 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -36,7 +36,7 @@ jobs: run: bun run check-types - name: Build website - run: bun --cwd apps/web run build + run: bun run --cwd apps/web build - name: Validate version alignment shell: bash diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 218271a..db8d62f 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -57,7 +57,7 @@ jobs: run: bun run check-types - name: Build website - run: bun --cwd apps/web run build + run: bun run --cwd apps/web build - name: Validate version alignment shell: bash diff --git a/packages/tauri-src/src-tauri/src/plugin_engine/host_api.rs b/packages/tauri-src/src-tauri/src/plugin_engine/host_api.rs index c2e323f..b7b2a3c 100644 --- a/packages/tauri-src/src-tauri/src/plugin_engine/host_api.rs +++ b/packages/tauri-src/src-tauri/src/plugin_engine/host_api.rs @@ -32,10 +32,24 @@ fn redact_value(value: &str) -> String { /// Redact sensitive query parameters in URL fn redact_url(url: &str) -> String { let sensitive_params = [ - let sensitive_params = [ - "key", "api_key", "apikey", "token", "access_token", "secret", - "password", "auth", "authorization", "bearer", "credential", - "user", "user_id", "userid", "account_id", "accountid", "email", "login", + "key", + "api_key", + "apikey", + "token", + "access_token", + "secret", + "password", + "auth", + "authorization", + "bearer", + "credential", + "user", + "user_id", + "userid", + "account_id", + "accountid", + "email", + "login", ]; if let Some(query_start) = url.find('?') { @@ -1332,10 +1346,7 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() .args(["-readonly", "-json", &expanded, &sql]) .output() .map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("sqlite3 exec failed: {}", e), - ) + Exception::throw_message(&ctx_inner, &format!("sqlite3 exec failed: {}", e)) })?; if primary.status.success() { @@ -1645,8 +1656,16 @@ mod tests { fn redact_url_redacts_user_query_param() { let url = "https://cursor.com/api/usage?user=user_abcdefghijklmnopqrstuvwxyz&limit=10"; let redacted = redact_url(url); - assert!(redacted.contains("user=user...wxyz"), "user query param should be redacted, got: {}", redacted); - assert!(redacted.contains("limit=10"), "non-sensitive params should be preserved, got: {}", redacted); + assert!( + redacted.contains("user=user...wxyz"), + "user query param should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("limit=10"), + "non-sensitive params should be preserved, got: {}", + redacted + ); } #[test] @@ -1716,10 +1735,26 @@ mod tests { fn redact_body_redacts_camel_case_user_and_account_ids() { let body = r#"{"userId": "user_abcdefghijklmnopqrstuvwxyz", "accountId": "acct_1234567890abcdef"}"#; let redacted = redact_body(body); - assert!(!redacted.contains("user_abcdefghijklmnopqrstuvwxyz"), "userId should be redacted, got: {}", redacted); - assert!(!redacted.contains("acct_1234567890abcdef"), "accountId should be redacted, got: {}", redacted); - assert!(redacted.contains("user...wxyz"), "userId should show first4...last4, got: {}", redacted); - assert!(redacted.contains("acct...cdef"), "accountId should show first4...last4, got: {}", redacted); + assert!( + !redacted.contains("user_abcdefghijklmnopqrstuvwxyz"), + "userId should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("acct_1234567890abcdef"), + "accountId should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("user...wxyz"), + "userId should show first4...last4, got: {}", + redacted + ); + assert!( + redacted.contains("acct...cdef"), + "accountId should show first4...last4, got: {}", + redacted + ); } #[test] From 036e233e5a1b4c834bf5ec0e7cae9f0b27680c64 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Thu, 12 Feb 2026 22:29:26 -0500 Subject: [PATCH 03/13] chore: use vercel git integration for web deploys --- .github/workflows/deploy-web.yml | 131 ------------------------------- apps/web/src/routes/index.tsx | 10 ++- apps/web/vercel.json | 9 +++ docs/release-flow.md | 21 ++--- 4 files changed, 26 insertions(+), 145 deletions(-) delete mode 100644 .github/workflows/deploy-web.yml create mode 100644 apps/web/vercel.json diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml deleted file mode 100644 index 7722313..0000000 --- a/.github/workflows/deploy-web.yml +++ /dev/null @@ -1,131 +0,0 @@ -name: Deploy Website - -on: - push: - branches: - - main - - dev - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: deploy-web-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: Build website - runs-on: ubuntu-latest - outputs: - release_channel: ${{ steps.meta.outputs.release_channel }} - release_repository: ${{ steps.meta.outputs.release_repository }} - cloudflare_enabled: ${{ steps.meta.outputs.cloudflare_enabled }} - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Resolve release channel and repository - id: meta - shell: bash - env: - RELEASE_REPOSITORY: ${{ vars.RELEASE_REPOSITORY }} - GITHUB_REPOSITORY: ${{ github.repository }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT }} - run: | - CHANNEL="stable" - if [[ "$GITHUB_REF_NAME" == "dev" ]]; then - CHANNEL="dev" - fi - - REPOSITORY="$RELEASE_REPOSITORY" - if [[ -z "$REPOSITORY" ]]; then - REPOSITORY="$GITHUB_REPOSITORY" - fi - - echo "release_channel=$CHANNEL" >> "$GITHUB_OUTPUT" - echo "release_repository=$REPOSITORY" >> "$GITHUB_OUTPUT" - - CLOUDFLARE_ENABLED="false" - if [[ -n "$CLOUDFLARE_API_TOKEN" && -n "$CLOUDFLARE_ACCOUNT_ID" && -n "$CLOUDFLARE_PAGES_PROJECT" ]]; then - CLOUDFLARE_ENABLED="true" - fi - echo "cloudflare_enabled=$CLOUDFLARE_ENABLED" >> "$GITHUB_OUTPUT" - - - name: Build static website - env: - VITE_RELEASE_CHANNEL: ${{ steps.meta.outputs.release_channel }} - VITE_RELEASE_REPOSITORY: ${{ steps.meta.outputs.release_repository }} - run: bun run --cwd apps/web build - - - name: Upload website artifact - uses: actions/upload-artifact@v6 - with: - name: web-dist - path: apps/web/dist - - deploy-cloudflare: - name: Deploy to Cloudflare Pages - needs: build - runs-on: ubuntu-latest - if: ${{ needs.build.outputs.cloudflare_enabled == 'true' }} - permissions: - contents: read - deployments: write - steps: - - name: Download website artifact - uses: actions/download-artifact@v7 - with: - name: web-dist - path: web-dist - - - name: Deploy Cloudflare Pages - uses: cloudflare/pages-action@v1 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - projectName: ${{ vars.CLOUDFLARE_PAGES_PROJECT }} - directory: web-dist - branch: ${{ github.ref_name }} - - deploy-github-pages: - name: Deploy to GitHub Pages (fallback) - needs: build - runs-on: ubuntu-latest - if: ${{ github.ref_name == 'main' && needs.build.outputs.cloudflare_enabled != 'true' }} - permissions: - contents: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Download website artifact - uses: actions/download-artifact@v7 - with: - name: web-dist - path: web-dist - - - name: Setup GitHub Pages - uses: actions/configure-pages@v5 - - - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@v4 - with: - path: web-dist - - - name: Deploy GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 6472cbb..28843c8 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -15,7 +15,15 @@ const RELEASE_REPOSITORY = import.meta.env.VITE_RELEASE_REPOSITORY && import.meta.env.VITE_RELEASE_REPOSITORY.trim().length > 0 ? import.meta.env.VITE_RELEASE_REPOSITORY.trim() : DEFAULT_RELEASE_REPOSITORY; -const RELEASE_CHANNEL = import.meta.env.VITE_RELEASE_CHANNEL === "dev" ? "dev" : "stable"; +const RAW_RELEASE_CHANNEL = import.meta.env.VITE_RELEASE_CHANNEL; +const RELEASE_CHANNEL = + RAW_RELEASE_CHANNEL === "dev" + ? "dev" + : RAW_RELEASE_CHANNEL === "stable" + ? "stable" + : import.meta.env.VITE_VERCEL_ENV === "preview" + ? "dev" + : "stable"; const RELEASES_LATEST_URL = `https://github.com/${RELEASE_REPOSITORY}/releases/latest`; const RELEASES_API_BASE_URL = `https://api.github.com/repos/${RELEASE_REPOSITORY}`; const RELEASES_API_URL = `${RELEASES_API_BASE_URL}/releases/latest`; diff --git a/apps/web/vercel.json b/apps/web/vercel.json new file mode 100644 index 0000000..072cd7d --- /dev/null +++ b/apps/web/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} diff --git a/docs/release-flow.md b/docs/release-flow.md index 34a0c3b..9fe376d 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -10,7 +10,7 @@ This repository now supports a two-channel flow: 1. Open PRs into `dev` for ongoing feature work. 2. Merge to `dev` to trigger: - CI checks (`.github/workflows/ci.yml`) - - website deploy pipeline (`.github/workflows/deploy-web.yml` with `VITE_RELEASE_CHANNEL=dev`) + - Vercel preview deployment via Git integration - desktop prerelease artifacts (`.github/workflows/release-dev.yml`) 3. When stable, open PR from `dev` -> `main`. 4. Merge to `main`, then cut a tag (`vX.Y.Z`) to publish a stable desktop release. @@ -20,10 +20,6 @@ This repository now supports a two-channel flow: - `ci.yml` - runs on pushes/PRs to `main` and `dev` - checks types and builds the web app -- `deploy-web.yml` - - builds website with branch-based release channel - - deploys to Cloudflare Pages when configured - - falls back to GitHub Pages for `main` when Cloudflare is not configured - `release-dev.yml` - runs on `dev` branch pushes - validates versions and publishes prerelease desktop binaries @@ -66,7 +62,10 @@ Configure via env vars at build time: - `VITE_RELEASE_REPOSITORY` (default: `Noisemaker111/opencode-mono`) - `VITE_RELEASE_CHANNEL` (`stable` or `dev`) -`deploy-web.yml` sets these automatically from branch context. +When deployed on Vercel Git integration: + +- `VITE_RELEASE_CHANNEL` can be set explicitly in Vercel project env vars (`dev` for preview, `stable` for production) +- if unset, the app falls back to `VITE_VERCEL_ENV` (`preview` -> `dev`, everything else -> `stable`) ## Required Secrets and Variables @@ -75,14 +74,10 @@ Configure via env vars at build time: - `TAURI_SIGNING_PRIVATE_KEY` - `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` -### Optional for Cloudflare Pages deployment - -- `CLOUDFLARE_API_TOKEN` -- `CLOUDFLARE_ACCOUNT_ID` -- `CLOUDFLARE_PAGES_PROJECT` (Repository Variable) -- `RELEASE_REPOSITORY` (Repository Variable, optional override) +### Vercel deployment -If Cloudflare is not configured, `main` deploys to GitHub Pages as fallback. +- no GitHub Actions secrets required when using Vercel Git integration +- `RELEASE_REPOSITORY` can be set as a Vercel environment variable (optional override) ## Feature Gating Approach From c439aa12dce2e3a27e151f0b5b940fe14d63185d Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Thu, 12 Feb 2026 23:11:25 -0500 Subject: [PATCH 04/13] feat: cache github stars in Convex for shared web badge --- apps/web/src/routes/index.tsx | 154 ++++++++++++++++ docs/release-flow.md | 16 ++ packages/backend/convex/_generated/api.d.ts | 2 + packages/backend/convex/github.ts | 189 ++++++++++++++++++++ packages/backend/convex/schema.ts | 11 +- 5 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 packages/backend/convex/github.ts diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 28843c8..cd4f87e 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,4 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; +import { api } from "@openusage-mono/backend/convex/_generated/api"; +import { useAction, useQuery } from "convex/react"; import { useEffect } from "react"; import openUsageBodyHtmlRaw from "./openusage-body.html?raw"; @@ -15,6 +17,7 @@ const RELEASE_REPOSITORY = import.meta.env.VITE_RELEASE_REPOSITORY && import.meta.env.VITE_RELEASE_REPOSITORY.trim().length > 0 ? import.meta.env.VITE_RELEASE_REPOSITORY.trim() : DEFAULT_RELEASE_REPOSITORY; +const DEFAULT_GITHUB_REPOSITORY_URL = `https://github.com/${DEFAULT_RELEASE_REPOSITORY}`; const RAW_RELEASE_CHANNEL = import.meta.env.VITE_RELEASE_CHANNEL; const RELEASE_CHANNEL = RAW_RELEASE_CHANNEL === "dev" @@ -55,6 +58,45 @@ interface DownloadOption { available: boolean; } +interface GitHubRepository { + owner: string; + repo: string; + slug: string; + url: string; +} + +interface RepositoryStarsSnapshot { + repository: string; + stars: number | null; + fetchedAt: number | null; + lastRefreshRequestedAt: number | null; + cacheTtlMs: number; + refreshCooldownMs: number; +} + +function parseGitHubRepository(value: string): GitHubRepository | null { + const parts = value + .trim() + .replace(/^https?:\/\/github\.com\//, "") + .replace(/\.git$/, "") + .split("/") + .filter((part) => part.length > 0); + + if (parts.length !== 2) { + return null; + } + + const [owner, repo] = parts; + return { + owner, + repo, + slug: `${owner}/${repo}`, + url: `https://github.com/${owner}/${repo}`, + }; +} + +const GITHUB_REPOSITORY = parseGitHubRepository(RELEASE_REPOSITORY); + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -576,6 +618,82 @@ function formatMenuBarTime(date: Date): string { return `${weekday} ${month} ${day} ${hour}:${minute} ${dayPeriod}`.trim(); } +function formatStarCount(stars: number): string { + if (stars >= 1_000_000) { + const millions = stars / 1_000_000; + const value = millions >= 10 ? Math.round(millions).toString() : millions.toFixed(1); + return `${value}M`; + } + + if (stars >= 1_000) { + const thousands = stars / 1_000; + const value = thousands >= 10 ? Math.round(thousands).toString() : thousands.toFixed(1); + return `${value}k`; + } + + return stars.toString(); +} + +function formatStarLabel(stars: number): string { + if (stars === 1) { + return "1 Star"; + } + + return `${formatStarCount(stars)} Stars`; +} + +function shouldRefreshRepositoryStars(snapshot: RepositoryStarsSnapshot | null): boolean { + if (snapshot === null) { + return true; + } + + const now = Date.now(); + const hasFreshCache = snapshot.fetchedAt !== null && now - snapshot.fetchedAt < snapshot.cacheTtlMs; + if (hasFreshCache) { + return false; + } + + const isInCooldown = + snapshot.lastRefreshRequestedAt !== null && now - snapshot.lastRefreshRequestedAt < snapshot.refreshCooldownMs; + return !isInCooldown; +} + +function updateMenuBarGithubStars(stars: number | null, repositoryUrl: string): void { + const trayIcon = document.getElementById("tray-icon"); + if (trayIcon === null || trayIcon.parentElement === null) { + return; + } + + const rightMenuGroup = trayIcon.parentElement; + const existingAnchor = rightMenuGroup.querySelector("a[data-openusage-github-stars='true']"); + if (existingAnchor !== null) { + existingAnchor.remove(); + } + + const starsAnchor = document.createElement("a"); + starsAnchor.setAttribute("data-openusage-github-stars", "true"); + starsAnchor.href = repositoryUrl; + starsAnchor.target = "_blank"; + starsAnchor.rel = "noopener noreferrer"; + starsAnchor.className = "text-[11px] font-semibold opacity-90 hover:opacity-100 transition-opacity"; + starsAnchor.style.display = "inline-flex"; + starsAnchor.style.alignItems = "center"; + starsAnchor.style.gap = "4px"; + starsAnchor.style.color = "var(--bar-fg)"; + + const starGlyph = document.createElement("span"); + starGlyph.textContent = "★"; + starGlyph.style.color = "#f4c542"; + + const starLabel = document.createElement("span"); + starLabel.textContent = stars === null ? "Star" : formatStarLabel(stars); + + starsAnchor.append(starGlyph, starLabel); + starsAnchor.title = stars === null ? "Open repository on GitHub" : `Open repository on GitHub (${stars.toLocaleString()} stars)`; + + rightMenuGroup.insertBefore(starsAnchor, trayIcon); +} + function updateMenuBarClock(): void { const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const nextText = formatMenuBarTime(new Date()); @@ -694,6 +812,17 @@ export const Route = createFileRoute("/")({ }); function HomeComponent() { + const repositoryStars = useQuery( + api.github.getRepositoryStars, + GITHUB_REPOSITORY === null + ? "skip" + : { + owner: GITHUB_REPOSITORY.owner, + repo: GITHUB_REPOSITORY.repo, + }, + ); + const refreshRepositoryStars = useAction(api.github.refreshRepositoryStars); + useEffect(() => { const previousBodyClassName = document.body.className; document.body.className = PRODUCTION_BODY_CLASS; @@ -708,6 +837,7 @@ function HomeComponent() { applyDownloadUi(buildDownloadOptions([]), platform, architecture, null); let isActive = true; + void (async () => { try { const endpoint = RELEASE_CHANNEL === "dev" ? RELEASES_LIST_API_URL : RELEASES_API_URL; @@ -748,9 +878,33 @@ function HomeComponent() { if (moreDownloadsSection !== null) { moreDownloadsSection.remove(); } + + const starsAnchor = document.querySelector("a[data-openusage-github-stars='true']"); + if (starsAnchor !== null) { + starsAnchor.remove(); + } + document.body.className = previousBodyClassName; }; }, []); + useEffect(() => { + const repositoryUrl = GITHUB_REPOSITORY?.url ?? DEFAULT_GITHUB_REPOSITORY_URL; + updateMenuBarGithubStars(repositoryStars?.stars ?? null, repositoryUrl); + + if (GITHUB_REPOSITORY === null || repositoryStars === undefined) { + return; + } + + if (!shouldRefreshRepositoryStars(repositoryStars)) { + return; + } + + void refreshRepositoryStars({ + owner: GITHUB_REPOSITORY.owner, + repo: GITHUB_REPOSITORY.repo, + }); + }, [repositoryStars, refreshRepositoryStars]); + return
; } diff --git a/docs/release-flow.md b/docs/release-flow.md index 9fe376d..029869e 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -79,6 +79,22 @@ When deployed on Vercel Git integration: - no GitHub Actions secrets required when using Vercel Git integration - `RELEASE_REPOSITORY` can be set as a Vercel environment variable (optional override) +Use these Vercel project settings for this monorepo: + +- Root Directory: `apps/web` +- Install Command: `cd ../.. && bun install --frozen-lockfile` +- Build Command: `cd ../.. && cd packages/backend && npx convex deploy --cmd-url-env-var-name VITE_CONVEX_URL --cmd "bun run --cwd ../../apps/web build"` +- Output Directory: `dist` + +Required Vercel environment variables: + +- `CONVEX_DEPLOY_KEY` (Production deploy key, Production environment) +- `CONVEX_DEPLOY_KEY` (Preview deploy key, Preview environment) + +Optional Convex deployment variables (for private GitHub repository metadata): + +- `GITHUB_TOKEN` (set in Convex deployment env vars, not Vercel) + ## Feature Gating Approach Use branch/channel gating as the default safety mechanism: diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 24167e6..ab70c46 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -8,6 +8,7 @@ * @module */ +import type * as github from "../github.js"; import type * as healthCheck from "../healthCheck.js"; import type { @@ -17,6 +18,7 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + github: typeof github; healthCheck: typeof healthCheck; }>; diff --git a/packages/backend/convex/github.ts b/packages/backend/convex/github.ts new file mode 100644 index 0000000..6cf68ef --- /dev/null +++ b/packages/backend/convex/github.ts @@ -0,0 +1,189 @@ +import { v } from "convex/values"; + +import { api, internal } from "./_generated/api"; +import { action, internalMutation, query } from "./_generated/server"; + +const STARS_CACHE_TTL_MS = 1000 * 60 * 30; +const REFRESH_COOLDOWN_MS = 1000 * 60 * 2; + +interface GitHubRepositoryInput { + owner: string; + repo: string; +} + +function toRepositorySlug(input: GitHubRepositoryInput): string { + return `${input.owner}/${input.repo}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseGitHubStars(payload: unknown): number | null { + if (!isRecord(payload)) { + return null; + } + + const maybeStars = payload.stargazers_count; + if (typeof maybeStars !== "number" || !Number.isFinite(maybeStars) || maybeStars < 0) { + return null; + } + + return Math.floor(maybeStars); +} + +export const getRepositoryStars = query({ + args: { + owner: v.string(), + repo: v.string(), + }, + handler: async (ctx, args) => { + const slug = toRepositorySlug(args); + const existing = await ctx.db + .query("githubRepositoryStats") + .withIndex("by_slug", (q) => q.eq("slug", slug)) + .unique(); + + return { + repository: slug, + stars: existing?.stars ?? null, + fetchedAt: existing?.fetchedAt ?? null, + lastRefreshRequestedAt: existing?.lastRefreshRequestedAt ?? null, + cacheTtlMs: STARS_CACHE_TTL_MS, + refreshCooldownMs: REFRESH_COOLDOWN_MS, + }; + }, +}); + +export const markRefreshRequested = internalMutation({ + args: { + owner: v.string(), + repo: v.string(), + requestedAt: v.number(), + }, + handler: async (ctx, args) => { + const slug = toRepositorySlug(args); + const existing = await ctx.db + .query("githubRepositoryStats") + .withIndex("by_slug", (q) => q.eq("slug", slug)) + .unique(); + + if (existing === null) { + await ctx.db.insert("githubRepositoryStats", { + slug, + owner: args.owner, + repo: args.repo, + stars: null, + fetchedAt: null, + lastRefreshRequestedAt: args.requestedAt, + }); + return; + } + + await ctx.db.patch(existing._id, { + lastRefreshRequestedAt: args.requestedAt, + owner: args.owner, + repo: args.repo, + }); + }, +}); + +export const setRepositoryStars = internalMutation({ + args: { + owner: v.string(), + repo: v.string(), + stars: v.number(), + fetchedAt: v.number(), + }, + handler: async (ctx, args) => { + const slug = toRepositorySlug(args); + const existing = await ctx.db + .query("githubRepositoryStats") + .withIndex("by_slug", (q) => q.eq("slug", slug)) + .unique(); + + if (existing === null) { + await ctx.db.insert("githubRepositoryStats", { + slug, + owner: args.owner, + repo: args.repo, + stars: args.stars, + fetchedAt: args.fetchedAt, + lastRefreshRequestedAt: args.fetchedAt, + }); + return; + } + + await ctx.db.patch(existing._id, { + owner: args.owner, + repo: args.repo, + stars: args.stars, + fetchedAt: args.fetchedAt, + lastRefreshRequestedAt: args.fetchedAt, + }); + }, +}); + +export const refreshRepositoryStars = action({ + args: { + owner: v.string(), + repo: v.string(), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const current = await ctx.runQuery(api.github.getRepositoryStars, args); + + const hasFreshCache = current.fetchedAt !== null && now - current.fetchedAt < current.cacheTtlMs; + if (hasFreshCache) { + return { refreshed: false, source: "cache" }; + } + + const recentlyRequested = + current.lastRefreshRequestedAt !== null && now - current.lastRefreshRequestedAt < current.refreshCooldownMs; + if (recentlyRequested) { + return { refreshed: false, source: "cooldown" }; + } + + await ctx.runMutation(internal.github.markRefreshRequested, { + owner: args.owner, + repo: args.repo, + requestedAt: now, + }); + + const endpoint = `https://api.github.com/repos/${encodeURIComponent(args.owner)}/${encodeURIComponent(args.repo)}`; + const githubToken = process.env.GITHUB_TOKEN; + const headers: Record = { + Accept: "application/vnd.github+json", + }; + if (typeof githubToken === "string" && githubToken.trim().length > 0) { + headers.Authorization = `Bearer ${githubToken.trim()}`; + } + + try { + const response = await fetch(endpoint, { + headers, + }); + + if (!response.ok) { + return { refreshed: false, source: "github-error" }; + } + + const payload: unknown = await response.json(); + const stars = parseGitHubStars(payload); + if (stars === null) { + return { refreshed: false, source: "invalid-payload" }; + } + + await ctx.runMutation(internal.github.setRepositoryStars, { + owner: args.owner, + repo: args.repo, + stars, + fetchedAt: now, + }); + + return { refreshed: true, source: "github" }; + } catch { + return { refreshed: false, source: "network-error" }; + } + }, +}); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 70ee4a2..acaea8a 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1,4 +1,13 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; -export default defineSchema({}); +export default defineSchema({ + githubRepositoryStats: defineTable({ + slug: v.string(), + owner: v.string(), + repo: v.string(), + stars: v.union(v.number(), v.null()), + fetchedAt: v.union(v.number(), v.null()), + lastRefreshRequestedAt: v.number(), + }).index("by_slug", ["slug"]), +}); From 13d1208e92f1aabf1bdaa26caded34026d27909d Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Thu, 12 Feb 2026 23:24:10 -0500 Subject: [PATCH 05/13] feat: label download tracks and improve direct links --- apps/web/src/routes/index.tsx | 259 ++++++++++++++++++++++++++++------ 1 file changed, 219 insertions(+), 40 deletions(-) diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index cd4f87e..ca1bf1e 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { api } from "@openusage-mono/backend/convex/_generated/api"; -import { useAction, useQuery } from "convex/react"; +import { useAction, useConvex } from "convex/react"; import { useEffect } from "react"; import openUsageBodyHtmlRaw from "./openusage-body.html?raw"; @@ -31,6 +31,7 @@ const RELEASES_LATEST_URL = `https://github.com/${RELEASE_REPOSITORY}/releases/l const RELEASES_API_BASE_URL = `https://api.github.com/repos/${RELEASE_REPOSITORY}`; const RELEASES_API_URL = `${RELEASES_API_BASE_URL}/releases/latest`; const RELEASES_LIST_API_URL = `${RELEASES_API_BASE_URL}/releases?per_page=20`; +const UPDATER_MANIFEST_URL = `https://github.com/${RELEASE_REPOSITORY}/releases/latest/download/latest.json`; const MORE_DOWNLOADS_SECTION_ID = "downloads"; const productionBodyHtml = openUsageBodyHtmlRaw.replace(/]*>[\s\S]*?<\/script>/g, ""); @@ -56,6 +57,15 @@ interface DownloadOption { subtitle: string; href: string; available: boolean; + releaseTrack: "stable" | "beta"; + comingSoon: boolean; +} + +interface DownloadFallbackUrls { + macArmUrl: string | null; + macIntelUrl: string | null; + windowsUrl: string | null; + linuxUrl: string | null; } interface GitHubRepository { @@ -211,11 +221,91 @@ function findAssetUrl( return null; } -function buildDownloadOptions(assets: ReadonlyArray): ReadonlyArray { +function extractUpdaterUrl(platformEntries: ReadonlyArray<[string, string]>, matcher: (key: string, url: string) => boolean): string | null { + for (const [platformKey, url] of platformEntries) { + if (matcher(platformKey, url)) { + return url; + } + } + + return null; +} + +function extractUpdaterFallbackUrls(payload: unknown): DownloadFallbackUrls | null { + if (!isRecord(payload)) { + return null; + } + + const maybePlatforms = payload.platforms; + if (!isRecord(maybePlatforms)) { + return null; + } + + const platformEntries: Array<[string, string]> = []; + for (const [rawKey, rawValue] of Object.entries(maybePlatforms)) { + if (!isRecord(rawValue)) { + continue; + } + + const maybeUrl = rawValue.url; + if (typeof maybeUrl !== "string" || maybeUrl.length === 0) { + continue; + } + + platformEntries.push([rawKey.toLowerCase(), maybeUrl]); + } + + if (platformEntries.length === 0) { + return null; + } + + const macArmUrl = extractUpdaterUrl( + platformEntries, + (key, url) => + (key.includes("darwin") || key.includes("mac")) && + (key.includes("aarch64") || key.includes("arm64") || url.toLowerCase().includes("aarch64")), + ); + const macIntelUrl = extractUpdaterUrl( + platformEntries, + (key, url) => + (key.includes("darwin") || key.includes("mac")) && + (key.includes("x86_64") || key.includes("x64") || url.toLowerCase().includes("x64")), + ); + const windowsUrl = extractUpdaterUrl( + platformEntries, + (key, url) => + key.includes("windows") || url.toLowerCase().includes(".msi") || url.toLowerCase().includes(".exe") || url.toLowerCase().includes("windows"), + ); + const linuxUrl = extractUpdaterUrl( + platformEntries, + (key, url) => + key.includes("linux") || + url.toLowerCase().includes(".appimage") || + url.toLowerCase().includes(".deb") || + url.toLowerCase().includes(".rpm") || + url.toLowerCase().includes("linux"), + ); + + return { + macArmUrl, + macIntelUrl, + windowsUrl, + linuxUrl, + }; +} + +function buildDownloadOptions( + assets: ReadonlyArray, + fallbackUrls: DownloadFallbackUrls | null = null, +): ReadonlyArray { const macArmUrl = - findAssetUrl(assets, (name) => name.endsWith("aarch64.dmg") || name.endsWith("arm64.dmg")) ?? RELEASES_LATEST_URL; + findAssetUrl(assets, (name) => name.endsWith("aarch64.dmg") || name.endsWith("arm64.dmg")) ?? + fallbackUrls?.macArmUrl ?? + RELEASES_LATEST_URL; const macIntelUrl = - findAssetUrl(assets, (name) => name.endsWith("x64.dmg") || name.endsWith("intel.dmg")) ?? RELEASES_LATEST_URL; + findAssetUrl(assets, (name) => name.endsWith("x64.dmg") || name.endsWith("intel.dmg")) ?? + fallbackUrls?.macIntelUrl ?? + RELEASES_LATEST_URL; const windowsUrl = findAssetUrl( @@ -223,6 +313,7 @@ function buildDownloadOptions(assets: ReadonlyArray): ReadonlyArra (name) => (name.endsWith(".exe") || name.endsWith(".msi")) && (name.includes("x64") || name.includes("amd64") || name.includes("windows")), ) ?? findAssetUrl(assets, (name) => name.endsWith(".exe") || name.endsWith(".msi") || name.includes("windows")) ?? + fallbackUrls?.windowsUrl ?? RELEASES_LATEST_URL; const linuxUrl = @@ -233,7 +324,9 @@ function buildDownloadOptions(assets: ReadonlyArray): ReadonlyArra name.endsWith(".deb") || name.endsWith(".rpm") || (name.includes("linux") && (name.endsWith(".tar.gz") || name.endsWith(".zip"))), - ) ?? RELEASES_LATEST_URL; + ) ?? + fallbackUrls?.linuxUrl ?? + RELEASES_LATEST_URL; return [ { @@ -242,6 +335,8 @@ function buildDownloadOptions(assets: ReadonlyArray): ReadonlyArra subtitle: "arm64 dmg", href: macArmUrl, available: macArmUrl !== RELEASES_LATEST_URL, + releaseTrack: "stable", + comingSoon: false, }, { id: "macos-intel", @@ -249,6 +344,8 @@ function buildDownloadOptions(assets: ReadonlyArray): ReadonlyArra subtitle: "x64 dmg", href: macIntelUrl, available: macIntelUrl !== RELEASES_LATEST_URL, + releaseTrack: "stable", + comingSoon: false, }, { id: "windows-x64", @@ -256,6 +353,8 @@ function buildDownloadOptions(assets: ReadonlyArray): ReadonlyArra subtitle: "x64 installer", href: windowsUrl, available: windowsUrl !== RELEASES_LATEST_URL, + releaseTrack: "beta", + comingSoon: false, }, { id: "linux-x64", @@ -263,6 +362,8 @@ function buildDownloadOptions(assets: ReadonlyArray): ReadonlyArra subtitle: "AppImage / DEB / RPM", href: linuxUrl, available: linuxUrl !== RELEASES_LATEST_URL, + releaseTrack: "beta", + comingSoon: linuxUrl === RELEASES_LATEST_URL, }, ]; } @@ -315,9 +416,23 @@ function getPrimaryDownloadOption( subtitle: "latest release", href: RELEASES_LATEST_URL, available: true, + releaseTrack: "stable", + comingSoon: false, }; } +function getDownloadTrackLabel(option: DownloadOption): string { + if (option.releaseTrack === "stable") { + return "Stable"; + } + + if (option.comingSoon) { + return "Beta soon"; + } + + return "Beta"; +} + function getPlatformLabel(platform: Platform): string { if (platform === "macos") { return "macOS"; @@ -467,7 +582,8 @@ function updatePrimaryDownloadCtas(primaryOption: DownloadOption, platformLabel: return text.startsWith("Download for "); }); - const primaryLabel = `Download for ${platformLabel}`; + const primaryTrackLabel = getDownloadTrackLabel(primaryOption); + const primaryLabel = `Download for ${platformLabel} (${primaryTrackLabel})`; for (const anchor of anchors.slice(0, 2)) { anchor.textContent = primaryLabel; anchor.setAttribute("href", primaryOption.href); @@ -479,7 +595,7 @@ function updatePrimaryDownloadCtas(primaryOption: DownloadOption, platformLabel: }); if (ctaParagraph !== undefined) { - ctaParagraph.textContent = `Download OpenUsage for ${platformLabel}. It is free, and you will never have to guess your limits again.`; + ctaParagraph.textContent = `Download OpenUsage for ${platformLabel} (${primaryTrackLabel}). It is free, and you will never have to guess your limits again.`; } const ctaFootnote = Array.from(document.querySelectorAll("p")).find((paragraph) => { @@ -488,7 +604,7 @@ function updatePrimaryDownloadCtas(primaryOption: DownloadOption, platformLabel: }); if (ctaFootnote !== undefined) { - ctaFootnote.textContent = "Latest release - MIT License"; + ctaFootnote.textContent = "macOS Stable - Windows Beta - Linux Beta soon - MIT License"; } ensureHeroMoreDownloadsAnchor(); @@ -530,7 +646,7 @@ function renderMoreDownloadsSection(options: ReadonlyArray, rele description.className = "text-sm lg:text-base mt-2"; description.style.color = "var(--page-fg-muted)"; const releaseSummary = releaseTag === null ? "Latest release" : `Latest release ${releaseTag}`; - description.textContent = `${releaseSummary}. Pick the package for your platform.`; + description.textContent = `${releaseSummary}. macOS is Stable, Windows is Beta, Linux is Beta soon.`; header.append(title, description); @@ -548,11 +664,22 @@ function renderMoreDownloadsSection(options: ReadonlyArray, rele card.style.backdropFilter = "blur(20px)"; card.style.setProperty("-webkit-backdrop-filter", "blur(20px)"); + const cardHeader = document.createElement("div"); + cardHeader.className = "flex items-center justify-between gap-2"; + const cardTitle = document.createElement("p"); cardTitle.className = "text-sm font-semibold"; cardTitle.style.color = "var(--page-fg)"; cardTitle.textContent = option.title; + const stageBadge = document.createElement("span"); + stageBadge.className = "text-[11px] px-2 py-[2px] rounded"; + stageBadge.style.border = "1px solid var(--page-border)"; + stageBadge.style.backgroundColor = "rgba(255,255,255,0.08)"; + stageBadge.style.color = "var(--page-fg-subtle)"; + stageBadge.textContent = getDownloadTrackLabel(option); + cardHeader.append(cardTitle, stageBadge); + const cardSubtitle = document.createElement("p"); cardSubtitle.className = "text-xs mt-1"; cardSubtitle.style.color = "var(--page-fg-subtle)"; @@ -561,9 +688,9 @@ function renderMoreDownloadsSection(options: ReadonlyArray, rele const cardAction = document.createElement("p"); cardAction.className = "text-xs mt-3"; cardAction.style.color = option.available ? "var(--page-accent)" : "var(--page-fg-muted)"; - cardAction.textContent = option.available ? "Download" : "View releases"; + cardAction.textContent = option.available ? `Download ${getDownloadTrackLabel(option).toLowerCase()}` : option.comingSoon ? "Coming soon" : "View releases"; - card.append(cardTitle, cardSubtitle, cardAction); + card.append(cardHeader, cardSubtitle, cardAction); grid.append(card); } @@ -812,15 +939,7 @@ export const Route = createFileRoute("/")({ }); function HomeComponent() { - const repositoryStars = useQuery( - api.github.getRepositoryStars, - GITHUB_REPOSITORY === null - ? "skip" - : { - owner: GITHUB_REPOSITORY.owner, - repo: GITHUB_REPOSITORY.repo, - }, - ); + const convex = useConvex(); const refreshRepositoryStars = useAction(api.github.refreshRepositoryStars); useEffect(() => { @@ -833,11 +952,42 @@ function HomeComponent() { updateMenuBarClock(); const clockInterval = window.setInterval(updateMenuBarClock, 30_000); + const repositoryUrl = GITHUB_REPOSITORY?.url ?? DEFAULT_GITHUB_REPOSITORY_URL; + updateMenuBarGithubStars(null, repositoryUrl); applyDownloadUi(buildDownloadOptions([]), platform, architecture, null); let isActive = true; + const applyUpdaterManifestFallback = async (): Promise => { + try { + const response = await fetch(UPDATER_MANIFEST_URL, { + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + return false; + } + + const payload: unknown = await response.json(); + const fallbackUrls = extractUpdaterFallbackUrls(payload); + if (fallbackUrls === null) { + return false; + } + + if (!isActive) { + return false; + } + + applyDownloadUi(buildDownloadOptions([], fallbackUrls), platform, architecture, null); + return true; + } catch { + return false; + } + }; + void (async () => { try { const endpoint = RELEASE_CHANNEL === "dev" ? RELEASES_LIST_API_URL : RELEASES_API_URL; @@ -848,6 +998,7 @@ function HomeComponent() { }); if (!response.ok) { + await applyUpdaterManifestFallback(); return; } @@ -862,12 +1013,58 @@ function HomeComponent() { } if (release === null) { + await applyUpdaterManifestFallback(); return; } applyDownloadUi(buildDownloadOptions(release.assets), platform, architecture, release.tag); } catch { - // Keep the fallback links when release metadata cannot be fetched. + await applyUpdaterManifestFallback(); + } + })(); + + void (async () => { + if (GITHUB_REPOSITORY === null) { + return; + } + + try { + const currentSnapshot = await convex.query(api.github.getRepositoryStars, { + owner: GITHUB_REPOSITORY.owner, + repo: GITHUB_REPOSITORY.repo, + }); + + if (!isActive) { + return; + } + + updateMenuBarGithubStars(currentSnapshot.stars, repositoryUrl); + + if (!shouldRefreshRepositoryStars(currentSnapshot)) { + return; + } + + await refreshRepositoryStars({ + owner: GITHUB_REPOSITORY.owner, + repo: GITHUB_REPOSITORY.repo, + }); + + const updatedSnapshot = await convex.query(api.github.getRepositoryStars, { + owner: GITHUB_REPOSITORY.owner, + repo: GITHUB_REPOSITORY.repo, + }); + + if (!isActive) { + return; + } + + updateMenuBarGithubStars(updatedSnapshot.stars, repositoryUrl); + } catch { + if (!isActive) { + return; + } + + updateMenuBarGithubStars(null, repositoryUrl); } })(); @@ -886,25 +1083,7 @@ function HomeComponent() { document.body.className = previousBodyClassName; }; - }, []); - - useEffect(() => { - const repositoryUrl = GITHUB_REPOSITORY?.url ?? DEFAULT_GITHUB_REPOSITORY_URL; - updateMenuBarGithubStars(repositoryStars?.stars ?? null, repositoryUrl); - - if (GITHUB_REPOSITORY === null || repositoryStars === undefined) { - return; - } - - if (!shouldRefreshRepositoryStars(repositoryStars)) { - return; - } - - void refreshRepositoryStars({ - owner: GITHUB_REPOSITORY.owner, - repo: GITHUB_REPOSITORY.repo, - }); - }, [repositoryStars, refreshRepositoryStars]); + }, [convex, refreshRepositoryStars]); return
; } From 081e93da6c7a2ee9a7f63e6640cfdcc738d9a198 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Thu, 12 Feb 2026 23:33:13 -0500 Subject: [PATCH 06/13] fix: remove duplicated tray menu item call --- packages/tauri-src/src-tauri/src/tray.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/tauri-src/src-tauri/src/tray.rs b/packages/tauri-src/src-tauri/src/tray.rs index 0b46232..42bb484 100644 --- a/packages/tauri-src/src-tauri/src/tray.rs +++ b/packages/tauri-src/src-tauri/src/tray.rs @@ -49,7 +49,6 @@ fn set_stored_log_level(app_handle: &AppHandle, level: log::LevelFilter) { log::set_max_level(level); } - /// Build a dynamic tray menu with plugin data fn build_tray_menu( app_handle: &AppHandle, @@ -71,10 +70,6 @@ fn build_tray_menu( true, None::<&str>, )?; - "Go to Settings", - true, - None::<&str>, - )?; // Log level submenu let current_level = get_stored_log_level(app_handle); From 4b4b414bb585a7a2bf07b76db06e38ac49e78ad1 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Thu, 12 Feb 2026 23:44:36 -0500 Subject: [PATCH 07/13] fix: restore tauri trait imports for desktop builds --- packages/tauri-src/src-tauri/src/lib.rs | 90 ++++++++++++++----- packages/tauri-src/src-tauri/src/tray.rs | 5 +- .../tauri-src/src-tauri/src/window_manager.rs | 11 ++- 3 files changed, 79 insertions(+), 27 deletions(-) diff --git a/packages/tauri-src/src-tauri/src/lib.rs b/packages/tauri-src/src-tauri/src/lib.rs index 03badcb..7bf8aae 100644 --- a/packages/tauri-src/src-tauri/src/lib.rs +++ b/packages/tauri-src/src-tauri/src/lib.rs @@ -4,17 +4,17 @@ mod app_nap; mod panel; mod plugin_engine; mod tray; -mod window_manager; #[cfg(target_os = "macos")] mod webkit_config; +mod window_manager; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use serde::Serialize; use tauri::{Emitter, Manager, State}; +use tauri_plugin_aptabase::EventTracker; use tauri_plugin_log::{Target, TargetKind}; use uuid::Uuid; @@ -34,10 +34,29 @@ fn managed_shortcut_slot() -> &'static Mutex> { } #[cfg(desktop)] -fn handle_global_shortcut(app: &tauri::AppHandle, event: tauri_plugin_global_shortcut::ShortcutEvent) { +fn handle_global_shortcut( + app: &tauri::AppHandle, + event: tauri_plugin_global_shortcut::ShortcutEvent, +) { if event.state == ShortcutState::Pressed { log::debug!("Global shortcut triggered"); - panel::toggle_panel(app); + + #[cfg(target_os = "macos")] + { + panel::toggle_panel(app); + } + + #[cfg(not(target_os = "macos"))] + { + if let Some(window) = app.get_webview_window("main") { + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); + } + } + } } } @@ -52,12 +71,20 @@ pub struct AppState { #[tauri::command] fn get_taskbar_position(state: State<'_, Mutex>) -> Option { - state.lock().unwrap().last_taskbar_position.as_ref().map(|p| match p { - TaskbarPosition::Top => "top", - TaskbarPosition::Bottom => "bottom", - TaskbarPosition::Left => "left", - TaskbarPosition::Right => "right", - }.to_string()) + state + .lock() + .unwrap() + .last_taskbar_position + .as_ref() + .map(|p| { + match p { + TaskbarPosition::Top => "top", + TaskbarPosition::Bottom => "bottom", + TaskbarPosition::Left => "left", + TaskbarPosition::Right => "right", + } + .to_string() + }) } #[tauri::command] @@ -65,7 +92,6 @@ fn get_arrow_offset(state: State<'_, Mutex>) -> Option { state.lock().unwrap().last_arrow_offset } - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PluginMeta { @@ -118,8 +144,6 @@ fn hide_panel(app_handle: tauri::AppHandle) { window_manager::WindowManager::hide(&app_handle).expect("Failed to hide window"); } - - #[tauri::command] async fn start_probe_batch( app_handle: tauri::AppHandle, @@ -214,18 +238,30 @@ async fn start_probe_batch( if has_error { log::warn!("probe {} completed with error", plugin_id); } else { - log::info!("probe {} completed ok ({} lines)", plugin_id, output.lines.len()); + log::info!( + "probe {} completed ok ({} lines)", + plugin_id, + output.lines.len() + ); } - + // Store result in AppState for tray menu access { let state = handle.state::>(); if let Ok(mut app_state) = state.lock() { - app_state.latest_probe_results.insert(plugin_id.clone(), output.clone()); + app_state + .latest_probe_results + .insert(plugin_id.clone(), output.clone()); } } - - let _ = handle.emit("probe:result", ProbeResult { batch_id: bid, output }); + + let _ = handle.emit( + "probe:result", + ProbeResult { + batch_id: bid, + output, + }, + ); } Err(_) => { log::error!("probe {} panicked", plugin_id); @@ -266,7 +302,10 @@ fn get_log_path(app_handle: tauri::AppHandle) -> Result { /// Pass `null` to disable the shortcut, or a shortcut string like "CommandOrControl+Shift+U". #[cfg(desktop)] #[tauri::command] -fn update_global_shortcut(app_handle: tauri::AppHandle, shortcut: Option) -> Result<(), String> { +fn update_global_shortcut( + app_handle: tauri::AppHandle, + shortcut: Option, +) -> Result<(), String> { let global_shortcut = app_handle.global_shortcut(); let normalized_shortcut = shortcut.and_then(|value| { let trimmed = value.trim().to_string(); @@ -293,7 +332,11 @@ fn update_global_shortcut(app_handle: tauri::AppHandle, shortcut: Option *managed_shortcut = None; } Err(e) => { - log::warn!("Failed to unregister existing shortcut '{}': {}", existing, e); + log::warn!( + "Failed to unregister existing shortcut '{}': {}", + existing, + e + ); } } } @@ -433,10 +476,10 @@ pub fn run() { last_arrow_offset: None, })); - tray::create(app.handle())?; - app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; + app.handle() + .plugin(tauri_plugin_updater::Builder::new().build())?; // Register global shortcut from stored settings #[cfg(desktop)] @@ -457,7 +500,8 @@ pub fn run() { }, ) { log::warn!("Failed to register initial global shortcut: {}", e); - } else if let Ok(mut managed_shortcut) = managed_shortcut_slot().lock() + } else if let Ok(mut managed_shortcut) = + managed_shortcut_slot().lock() { *managed_shortcut = Some(shortcut.to_string()); } else { diff --git a/packages/tauri-src/src-tauri/src/tray.rs b/packages/tauri-src/src-tauri/src/tray.rs index 42bb484..9016bdd 100644 --- a/packages/tauri-src/src-tauri/src/tray.rs +++ b/packages/tauri-src/src-tauri/src/tray.rs @@ -8,10 +8,9 @@ use tauri::tray::{MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_store::StoreExt; -use crate::panel::{get_or_init_panel, position_panel_at_tray_icon, show_panel}; -use crate::plugin_engine::runtime::{MetricLine, PluginOutput}; -use crate::window_manager::{position_window_at_tray, WindowManager}; use crate::AppState; +use crate::plugin_engine::runtime::{MetricLine, PluginOutput}; +use crate::window_manager::{WindowManager, position_window_at_tray}; const LOG_LEVEL_STORE_KEY: &str = "logLevel"; diff --git a/packages/tauri-src/src-tauri/src/window_manager.rs b/packages/tauri-src/src-tauri/src/window_manager.rs index 66e01cb..34776ee 100644 --- a/packages/tauri-src/src-tauri/src/window_manager.rs +++ b/packages/tauri-src/src-tauri/src/window_manager.rs @@ -1,8 +1,17 @@ -use tauri::{AppHandle, Emitter, Manager, PhysicalPosition}; +use tauri::AppHandle; + +#[cfg(any(target_os = "windows", target_os = "linux"))] +use tauri::Manager; + +#[cfg(target_os = "windows")] +use tauri::{Emitter, PhysicalPosition}; #[cfg(target_os = "macos")] use tauri::{Position, Size}; +#[cfg(target_os = "macos")] +use tauri_nspanel::ManagerExt; + /// Platform-specific window manager pub struct WindowManager; From 53145c40b5ad64676ec7536ddd4817c9bd90970e Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Thu, 12 Feb 2026 23:59:21 -0500 Subject: [PATCH 08/13] ci: fail fast when updater signing secrets are invalid --- .github/workflows/release-dev.yml | 23 +++++++++++++++++++++++ .github/workflows/release-stable.yml | 23 +++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 88dc59a..03425ea 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -32,6 +32,29 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Validate updater signing credentials (preflight) + shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + if [[ -z "$TAURI_SIGNING_PRIVATE_KEY" ]]; then + echo "Missing TAURI_SIGNING_PRIVATE_KEY secret." + exit 1 + fi + + if [[ -z "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" ]]; then + echo "Missing TAURI_SIGNING_PRIVATE_KEY_PASSWORD secret." + exit 1 + fi + + CHECK_FILE="$(mktemp)" + printf 'openusage-signing-preflight\n' > "$CHECK_FILE" + + npm --workspace @openusage-mono/tauri-src run tauri -- signer sign --private-key "$TAURI_SIGNING_PRIVATE_KEY" --password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$CHECK_FILE" >/dev/null + + rm -f "$CHECK_FILE" "$CHECK_FILE.sig" + - name: Typecheck workspace run: bun run check-types diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index db8d62f..a5c358d 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -53,6 +53,29 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Validate updater signing credentials (preflight) + shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + if [[ -z "$TAURI_SIGNING_PRIVATE_KEY" ]]; then + echo "Missing TAURI_SIGNING_PRIVATE_KEY secret." + exit 1 + fi + + if [[ -z "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" ]]; then + echo "Missing TAURI_SIGNING_PRIVATE_KEY_PASSWORD secret." + exit 1 + fi + + CHECK_FILE="$(mktemp)" + printf 'openusage-signing-preflight\n' > "$CHECK_FILE" + + npm --workspace @openusage-mono/tauri-src run tauri -- signer sign --private-key "$TAURI_SIGNING_PRIVATE_KEY" --password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$CHECK_FILE" >/dev/null + + rm -f "$CHECK_FILE" "$CHECK_FILE.sig" + - name: Typecheck workspace run: bun run check-types From 6c3c111bf9d36ed97950622a632e6c6fb07cf607 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Fri, 13 Feb 2026 00:00:23 -0500 Subject: [PATCH 09/13] chore: remove unused macOS panel helpers --- packages/tauri-src/src-tauri/src/panel.rs | 59 +++++++++++------------ 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/packages/tauri-src/src-tauri/src/panel.rs b/packages/tauri-src/src-tauri/src/panel.rs index e776022..bf15511 100644 --- a/packages/tauri-src/src-tauri/src/panel.rs +++ b/packages/tauri-src/src-tauri/src/panel.rs @@ -1,5 +1,7 @@ use tauri::{AppHandle, Manager, Position, Size}; -use tauri_nspanel::{tauri_panel, CollectionBehavior, ManagerExt, PanelLevel, StyleMask, WebviewWindowExt}; +use tauri_nspanel::{ + tauri_panel, CollectionBehavior, ManagerExt, PanelLevel, StyleMask, WebviewWindowExt, +}; /// Macro to get existing panel or initialize it if needed. /// Returns Option - Some if panel is available, None on error. @@ -25,16 +27,6 @@ macro_rules! get_or_init_panel { }; } -// Export macro for use in other modules -pub(crate) use get_or_init_panel; - -/// Show the panel (initializing if needed). -pub fn show_panel(app_handle: &AppHandle) { - if let Some(panel) = get_or_init_panel!(app_handle) { - panel.show_and_make_key(); - } -} - /// Toggle panel visibility. If visible, hide it. If hidden, show it. /// Used by global shortcut handler. pub fn toggle_panel(app_handle: &AppHandle) { @@ -146,27 +138,30 @@ pub fn position_panel_at_tray_icon( let window_width_phys = window_size.width as i32; // Convert icon position/size to physical coordinates - let (icon_phys_x, icon_phys_y, icon_width_phys, icon_height_phys) = match (icon_position, icon_size) { - (Position::Physical(pos), Size::Physical(size)) => (pos.x, pos.y, size.width as i32, size.height as i32), - (Position::Logical(pos), Size::Logical(size)) => ( - (pos.x * scale_factor) as i32, - (pos.y * scale_factor) as i32, - (size.width * scale_factor) as i32, - (size.height * scale_factor) as i32, - ), - (Position::Physical(pos), Size::Logical(size)) => ( - pos.x, - pos.y, - (size.width * scale_factor) as i32, - (size.height * scale_factor) as i32, - ), - (Position::Logical(pos), Size::Physical(size)) => ( - (pos.x * scale_factor) as i32, - (pos.y * scale_factor) as i32, - size.width as i32, - size.height as i32, - ), - }; + let (icon_phys_x, icon_phys_y, icon_width_phys, icon_height_phys) = + match (icon_position, icon_size) { + (Position::Physical(pos), Size::Physical(size)) => { + (pos.x, pos.y, size.width as i32, size.height as i32) + } + (Position::Logical(pos), Size::Logical(size)) => ( + (pos.x * scale_factor) as i32, + (pos.y * scale_factor) as i32, + (size.width * scale_factor) as i32, + (size.height * scale_factor) as i32, + ), + (Position::Physical(pos), Size::Logical(size)) => ( + pos.x, + pos.y, + (size.width * scale_factor) as i32, + (size.height * scale_factor) as i32, + ), + (Position::Logical(pos), Size::Physical(size)) => ( + (pos.x * scale_factor) as i32, + (pos.y * scale_factor) as i32, + size.width as i32, + size.height as i32, + ), + }; let icon_center_x_phys = icon_phys_x + (icon_width_phys / 2); let panel_x_phys = icon_center_x_phys - (window_width_phys / 2); From 984d7be7d2da63c417c2a2fb0ea44e484e5e734e Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Fri, 13 Feb 2026 00:24:07 -0500 Subject: [PATCH 10/13] fix: use raw updater pubkey encoding --- packages/tauri-src/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tauri-src/src-tauri/tauri.conf.json b/packages/tauri-src/src-tauri/tauri.conf.json index 9794de8..39a0d17 100644 --- a/packages/tauri-src/src-tauri/tauri.conf.json +++ b/packages/tauri-src/src-tauri/tauri.conf.json @@ -64,7 +64,7 @@ }, "plugins": { "updater": { - "pubkey": "ZFc1MGNuVnpkR1ZrSUdOdmJXMWxiblE2SUcxcGJtbHphV2R1SUhCMVlteHBZeUJyWlhrNklESXhORGRCTkVJeFFVRkdOelkzUWpBS1VsZFRkMW92WlhGellWSklTV0pJUkdSeU5sQllUbGxyYmxSMGVHNTZSaTlaVUcxUE5FUmtZalpvV2t4RVdXVlhjRzhyUldOVWNVWUs=", + "pubkey": "RWSwZ/eqsaRHIbHDdr6PXNYknTtxnzF/YPmO4Ddb6hZLDYeWpo+EcTqF", "endpoints": [ "https://github.com/Noisemaker111/opencode-mono/releases/latest/download/latest.json" ] From cc442e45141378f9ee3bac748bc620815f933356 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Fri, 13 Feb 2026 00:54:37 -0500 Subject: [PATCH 11/13] fix: use encoded updater public key --- packages/tauri-src/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tauri-src/src-tauri/tauri.conf.json b/packages/tauri-src/src-tauri/tauri.conf.json index 39a0d17..e1df560 100644 --- a/packages/tauri-src/src-tauri/tauri.conf.json +++ b/packages/tauri-src/src-tauri/tauri.conf.json @@ -64,7 +64,7 @@ }, "plugins": { "updater": { - "pubkey": "RWSwZ/eqsaRHIbHDdr6PXNYknTtxnzF/YPmO4Ddb6hZLDYeWpo+EcTqF", + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM4ODZBQUU4MTUyODU5NDkKUldSSldTZ1Y2S3FHT09sam9jVXRKSTFFNjIrRkJjdDRUc0xGNDRnbU5HNG9pZ1YvVS9ZdndMMDQK", "endpoints": [ "https://github.com/Noisemaker111/opencode-mono/releases/latest/download/latest.json" ] From 95325a7c03f2a864c2fd16fc5da8d72ac21775c0 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Fri, 13 Feb 2026 01:13:40 -0500 Subject: [PATCH 12/13] fix: prefer downloadable release assets for landing CTA --- apps/web/src/routes/index.tsx | 56 ++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index ca1bf1e..9442d60 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -29,7 +29,6 @@ const RELEASE_CHANNEL = : "stable"; const RELEASES_LATEST_URL = `https://github.com/${RELEASE_REPOSITORY}/releases/latest`; const RELEASES_API_BASE_URL = `https://api.github.com/repos/${RELEASE_REPOSITORY}`; -const RELEASES_API_URL = `${RELEASES_API_BASE_URL}/releases/latest`; const RELEASES_LIST_API_URL = `${RELEASES_API_BASE_URL}/releases?per_page=20`; const UPDATER_MANIFEST_URL = `https://github.com/${RELEASE_REPOSITORY}/releases/latest/download/latest.json`; const MORE_DOWNLOADS_SECTION_ID = "downloads"; @@ -154,6 +153,24 @@ function extractReleaseList(payload: unknown): ReadonlyArray { return releases; } +function hasDownloadableAsset(release: ReleaseData): boolean { + for (const asset of release.assets) { + const normalizedName = asset.name.toLowerCase(); + if ( + normalizedName.endsWith(".dmg") || + normalizedName.endsWith(".exe") || + normalizedName.endsWith(".msi") || + normalizedName.endsWith(".appimage") || + normalizedName.endsWith(".deb") || + normalizedName.endsWith(".rpm") + ) { + return true; + } + } + + return false; +} + function pickReleaseForChannel( releases: ReadonlyArray, channel: ReleaseChannel, @@ -162,18 +179,27 @@ function pickReleaseForChannel( return null; } - if (channel === "dev") { - for (const release of releases) { - if (release.prerelease) { - return release; - } - } + const primaryMatch = + channel === "dev" + ? releases.find((release) => release.prerelease && hasDownloadableAsset(release)) + : releases.find((release) => !release.prerelease && hasDownloadableAsset(release)); + + if (primaryMatch !== undefined) { + return primaryMatch; } - for (const release of releases) { - if (!release.prerelease) { - return release; - } + const channelFallback = + channel === "dev" + ? releases.find((release) => release.prerelease) + : releases.find((release) => !release.prerelease); + + if (channelFallback !== undefined) { + return channelFallback; + } + + const releaseWithDownloads = releases.find((release) => hasDownloadableAsset(release)); + if (releaseWithDownloads !== undefined) { + return releaseWithDownloads; } return releases[0] ?? null; @@ -990,8 +1016,7 @@ function HomeComponent() { void (async () => { try { - const endpoint = RELEASE_CHANNEL === "dev" ? RELEASES_LIST_API_URL : RELEASES_API_URL; - const response = await fetch(endpoint, { + const response = await fetch(RELEASES_LIST_API_URL, { headers: { Accept: "application/vnd.github+json", }, @@ -1003,10 +1028,7 @@ function HomeComponent() { } const payload: unknown = await response.json(); - const release = - RELEASE_CHANNEL === "dev" - ? pickReleaseForChannel(extractReleaseList(payload), "dev") - : extractReleaseData(payload); + const release = pickReleaseForChannel(extractReleaseList(payload), RELEASE_CHANNEL); if (!isActive) { return; From f999bb5c3d6b4af8cc8e2e1e41eb08a360f8bf45 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Fri, 13 Feb 2026 01:16:08 -0500 Subject: [PATCH 13/13] fix: keep landing CTAs on direct installer downloads --- apps/web/src/routes/index.tsx | 139 ++++++++++++++++++++++++++++------ 1 file changed, 114 insertions(+), 25 deletions(-) diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 9442d60..7a113d9 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -27,7 +27,6 @@ const RELEASE_CHANNEL = : import.meta.env.VITE_VERCEL_ENV === "preview" ? "dev" : "stable"; -const RELEASES_LATEST_URL = `https://github.com/${RELEASE_REPOSITORY}/releases/latest`; const RELEASES_API_BASE_URL = `https://api.github.com/repos/${RELEASE_REPOSITORY}`; const RELEASES_LIST_API_URL = `${RELEASES_API_BASE_URL}/releases?per_page=20`; const UPDATER_MANIFEST_URL = `https://github.com/${RELEASE_REPOSITORY}/releases/latest/download/latest.json`; @@ -247,6 +246,20 @@ function findAssetUrl( return null; } +function findAssetUrlAcrossReleases( + releases: ReadonlyArray, + predicate: (normalizedAssetName: string) => boolean, +): string | null { + for (const release of releases) { + const url = findAssetUrl(release.assets, predicate); + if (url !== null) { + return url; + } + } + + return null; +} + function extractUpdaterUrl(platformEntries: ReadonlyArray<[string, string]>, matcher: (key: string, url: string) => boolean): string | null { for (const [platformKey, url] of platformEntries) { if (matcher(platformKey, url)) { @@ -320,6 +333,43 @@ function extractUpdaterFallbackUrls(payload: unknown): DownloadFallbackUrls | nu }; } +function extractReleaseFallbackUrls(releases: ReadonlyArray): DownloadFallbackUrls | null { + const macArmUrl = findAssetUrlAcrossReleases( + releases, + (name) => name.endsWith("aarch64.dmg") || name.endsWith("arm64.dmg"), + ); + const macIntelUrl = findAssetUrlAcrossReleases( + releases, + (name) => name.endsWith("x64.dmg") || name.endsWith("intel.dmg"), + ); + const windowsUrl = + findAssetUrlAcrossReleases( + releases, + (name) => + (name.endsWith(".exe") || name.endsWith(".msi")) && + (name.includes("x64") || name.includes("amd64") || name.includes("windows")), + ) ?? findAssetUrlAcrossReleases(releases, (name) => name.endsWith(".exe") || name.endsWith(".msi") || name.includes("windows")); + const linuxUrl = findAssetUrlAcrossReleases( + releases, + (name) => + name.endsWith(".appimage") || + name.endsWith(".deb") || + name.endsWith(".rpm") || + (name.includes("linux") && (name.endsWith(".tar.gz") || name.endsWith(".zip"))), + ); + + if (macArmUrl === null && macIntelUrl === null && windowsUrl === null && linuxUrl === null) { + return null; + } + + return { + macArmUrl, + macIntelUrl, + windowsUrl, + linuxUrl, + }; +} + function buildDownloadOptions( assets: ReadonlyArray, fallbackUrls: DownloadFallbackUrls | null = null, @@ -327,11 +377,11 @@ function buildDownloadOptions( const macArmUrl = findAssetUrl(assets, (name) => name.endsWith("aarch64.dmg") || name.endsWith("arm64.dmg")) ?? fallbackUrls?.macArmUrl ?? - RELEASES_LATEST_URL; + null; const macIntelUrl = findAssetUrl(assets, (name) => name.endsWith("x64.dmg") || name.endsWith("intel.dmg")) ?? fallbackUrls?.macIntelUrl ?? - RELEASES_LATEST_URL; + null; const windowsUrl = findAssetUrl( @@ -340,7 +390,7 @@ function buildDownloadOptions( ) ?? findAssetUrl(assets, (name) => name.endsWith(".exe") || name.endsWith(".msi") || name.includes("windows")) ?? fallbackUrls?.windowsUrl ?? - RELEASES_LATEST_URL; + null; const linuxUrl = findAssetUrl( @@ -352,15 +402,17 @@ function buildDownloadOptions( (name.includes("linux") && (name.endsWith(".tar.gz") || name.endsWith(".zip"))), ) ?? fallbackUrls?.linuxUrl ?? - RELEASES_LATEST_URL; + null; + + const fallbackSectionHref = `#${MORE_DOWNLOADS_SECTION_ID}`; return [ { id: "macos-apple-silicon", title: "macOS (Apple Silicon)", subtitle: "arm64 dmg", - href: macArmUrl, - available: macArmUrl !== RELEASES_LATEST_URL, + href: macArmUrl ?? fallbackSectionHref, + available: macArmUrl !== null, releaseTrack: "stable", comingSoon: false, }, @@ -368,8 +420,8 @@ function buildDownloadOptions( id: "macos-intel", title: "macOS (Intel)", subtitle: "x64 dmg", - href: macIntelUrl, - available: macIntelUrl !== RELEASES_LATEST_URL, + href: macIntelUrl ?? fallbackSectionHref, + available: macIntelUrl !== null, releaseTrack: "stable", comingSoon: false, }, @@ -377,8 +429,8 @@ function buildDownloadOptions( id: "windows-x64", title: "Windows", subtitle: "x64 installer", - href: windowsUrl, - available: windowsUrl !== RELEASES_LATEST_URL, + href: windowsUrl ?? fallbackSectionHref, + available: windowsUrl !== null, releaseTrack: "beta", comingSoon: false, }, @@ -386,10 +438,10 @@ function buildDownloadOptions( id: "linux-x64", title: "Linux", subtitle: "AppImage / DEB / RPM", - href: linuxUrl, - available: linuxUrl !== RELEASES_LATEST_URL, + href: linuxUrl ?? fallbackSectionHref, + available: linuxUrl !== null, releaseTrack: "beta", - comingSoon: linuxUrl === RELEASES_LATEST_URL, + comingSoon: linuxUrl === null, }, ]; } @@ -412,21 +464,27 @@ function getPrimaryDownloadOption( if (platform === "macos") { const macOptionId = architecture === "arm64" ? "macos-apple-silicon" : "macos-intel"; const option = getOptionById(options, macOptionId); - if (option !== null) { + if (option !== null && option.available) { return option; } } if (platform === "windows") { const option = getOptionById(options, "windows-x64"); - if (option !== null) { + if (option !== null && option.available) { return option; } } if (platform === "linux") { const option = getOptionById(options, "linux-x64"); - if (option !== null) { + if (option !== null && option.available) { + return option; + } + } + + for (const option of options) { + if (option.available) { return option; } } @@ -440,10 +498,10 @@ function getPrimaryDownloadOption( id: "fallback", title: "All Downloads", subtitle: "latest release", - href: RELEASES_LATEST_URL, - available: true, + href: `#${MORE_DOWNLOADS_SECTION_ID}`, + available: false, releaseTrack: "stable", - comingSoon: false, + comingSoon: true, }; } @@ -613,6 +671,26 @@ function updatePrimaryDownloadCtas(primaryOption: DownloadOption, platformLabel: for (const anchor of anchors.slice(0, 2)) { anchor.textContent = primaryLabel; anchor.setAttribute("href", primaryOption.href); + + if (primaryOption.available) { + anchor.setAttribute("target", "_blank"); + anchor.setAttribute("rel", "noopener noreferrer"); + anchor.onclick = null; + continue; + } + + anchor.removeAttribute("target"); + anchor.removeAttribute("rel"); + anchor.onclick = (event) => { + event.preventDefault(); + const targetSection = document.getElementById(MORE_DOWNLOADS_SECTION_ID); + if (targetSection === null) { + return; + } + + targetSection.scrollIntoView({ behavior: "smooth", block: "start" }); + window.history.replaceState(null, "", `#${MORE_DOWNLOADS_SECTION_ID}`); + }; } const ctaParagraph = Array.from(document.querySelectorAll("p")).find((paragraph) => { @@ -682,8 +760,17 @@ function renderMoreDownloadsSection(options: ReadonlyArray, rele for (const option of options) { const card = document.createElement("a"); card.href = option.href; - card.target = "_blank"; - card.rel = "noopener noreferrer"; + if (option.available) { + card.target = "_blank"; + card.rel = "noopener noreferrer"; + } else { + card.removeAttribute("target"); + card.removeAttribute("rel"); + card.setAttribute("aria-disabled", "true"); + card.addEventListener("click", (event) => { + event.preventDefault(); + }); + } card.className = "rounded-xl p-4 transition-colors hover:brightness-110"; card.style.border = "1px solid var(--page-border)"; card.style.backgroundColor = "rgba(0, 0, 0, 0.15)"; @@ -714,7 +801,7 @@ function renderMoreDownloadsSection(options: ReadonlyArray, rele const cardAction = document.createElement("p"); cardAction.className = "text-xs mt-3"; cardAction.style.color = option.available ? "var(--page-accent)" : "var(--page-fg-muted)"; - cardAction.textContent = option.available ? `Download ${getDownloadTrackLabel(option).toLowerCase()}` : option.comingSoon ? "Coming soon" : "View releases"; + cardAction.textContent = option.available ? `Download ${getDownloadTrackLabel(option).toLowerCase()}` : option.comingSoon ? "Coming soon" : "Unavailable"; card.append(cardHeader, cardSubtitle, cardAction); grid.append(card); @@ -1028,7 +1115,8 @@ function HomeComponent() { } const payload: unknown = await response.json(); - const release = pickReleaseForChannel(extractReleaseList(payload), RELEASE_CHANNEL); + const releases = extractReleaseList(payload); + const release = pickReleaseForChannel(releases, RELEASE_CHANNEL); if (!isActive) { return; @@ -1039,7 +1127,8 @@ function HomeComponent() { return; } - applyDownloadUi(buildDownloadOptions(release.assets), platform, architecture, release.tag); + const releaseFallbackUrls = extractReleaseFallbackUrls(releases); + applyDownloadUi(buildDownloadOptions(release.assets, releaseFallbackUrls), platform, architecture, release.tag); } catch { await applyUpdaterManifestFallback(); }