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/.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 diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 6472cbb..7a113d9 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, useConvex } from "convex/react"; import { useEffect } from "react"; import openUsageBodyHtmlRaw from "./openusage-body.html?raw"; @@ -15,11 +17,19 @@ 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 RELEASES_LATEST_URL = `https://github.com/${RELEASE_REPOSITORY}/releases/latest`; +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" + ? "dev" + : RAW_RELEASE_CHANNEL === "stable" + ? "stable" + : import.meta.env.VITE_VERCEL_ENV === "preview" + ? "dev" + : "stable"; 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, ""); @@ -45,8 +55,56 @@ 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 { + 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; } @@ -94,6 +152,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, @@ -102,18 +178,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; @@ -161,11 +246,142 @@ function findAssetUrl( return null; } -function buildDownloadOptions(assets: ReadonlyArray): ReadonlyArray { +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)) { + 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 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, +): 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 ?? + null; 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 ?? + null; const windowsUrl = findAssetUrl( @@ -173,7 +389,8 @@ 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")) ?? - RELEASES_LATEST_URL; + fallbackUrls?.windowsUrl ?? + null; const linuxUrl = findAssetUrl( @@ -183,36 +400,48 @@ 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 ?? + 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, }, { 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, }, { 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, }, { 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 === null, }, ]; } @@ -235,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; } } @@ -263,11 +498,25 @@ 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: true, }; } +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"; @@ -417,10 +666,31 @@ 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); + + 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) => { @@ -429,7 +699,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) => { @@ -438,7 +708,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(); @@ -480,7 +750,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); @@ -490,19 +760,39 @@ 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)"; 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)"; @@ -511,9 +801,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" : "Unavailable"; - card.append(cardTitle, cardSubtitle, cardAction); + card.append(cardHeader, cardSubtitle, cardAction); grid.append(card); } @@ -568,6 +858,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()); @@ -686,6 +1052,9 @@ export const Route = createFileRoute("/")({ }); function HomeComponent() { + const convex = useConvex(); + const refreshRepositoryStars = useAction(api.github.refreshRepositoryStars); + useEffect(() => { const previousBodyClassName = document.body.className; document.body.className = PRODUCTION_BODY_CLASS; @@ -696,40 +1065,117 @@ 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; - const response = await fetch(endpoint, { + const response = await fetch(RELEASES_LIST_API_URL, { headers: { Accept: "application/vnd.github+json", }, }); if (!response.ok) { + await applyUpdaterManifestFallback(); return; } const payload: unknown = await response.json(); - const release = - RELEASE_CHANNEL === "dev" - ? pickReleaseForChannel(extractReleaseList(payload), "dev") - : extractReleaseData(payload); + const releases = extractReleaseList(payload); + const release = pickReleaseForChannel(releases, RELEASE_CHANNEL); if (!isActive) { return; } if (release === null) { + await applyUpdaterManifestFallback(); + return; + } + + const releaseFallbackUrls = extractReleaseFallbackUrls(releases); + applyDownloadUi(buildDownloadOptions(release.assets, releaseFallbackUrls), platform, architecture, release.tag); + } catch { + 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; } - applyDownloadUi(buildDownloadOptions(release.assets), platform, architecture, release.tag); + updateMenuBarGithubStars(updatedSnapshot.stars, repositoryUrl); } catch { - // Keep the fallback links when release metadata cannot be fetched. + if (!isActive) { + return; + } + + updateMenuBarGithubStars(null, repositoryUrl); } })(); @@ -740,9 +1186,15 @@ 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; }; - }, []); + }, [convex, refreshRepositoryStars]); return
; } 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..029869e 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,26 @@ Configure via env vars at build time: - `TAURI_SIGNING_PRIVATE_KEY` - `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` -### Optional for Cloudflare Pages deployment +### Vercel deployment + +- 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) -- `CLOUDFLARE_API_TOKEN` -- `CLOUDFLARE_ACCOUNT_ID` -- `CLOUDFLARE_PAGES_PROJECT` (Repository Variable) -- `RELEASE_REPOSITORY` (Repository Variable, optional override) +Optional Convex deployment variables (for private GitHub repository metadata): -If Cloudflare is not configured, `main` deploys to GitHub Pages as fallback. +- `GITHUB_TOKEN` (set in Convex deployment env vars, not Vercel) ## Feature Gating Approach 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"]), +}); 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/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); diff --git a/packages/tauri-src/src-tauri/src/tray.rs b/packages/tauri-src/src-tauri/src/tray.rs index 0b46232..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"; @@ -49,7 +48,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 +69,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); 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; diff --git a/packages/tauri-src/src-tauri/tauri.conf.json b/packages/tauri-src/src-tauri/tauri.conf.json index 9794de8..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": "ZFc1MGNuVnpkR1ZrSUdOdmJXMWxiblE2SUcxcGJtbHphV2R1SUhCMVlteHBZeUJyWlhrNklESXhORGRCTkVJeFFVRkdOelkzUWpBS1VsZFRkMW92WlhGellWSklTV0pJUkdSeU5sQllUbGxyYmxSMGVHNTZSaTlaVUcxUE5FUmtZalpvV2t4RVdXVlhjRzhyUldOVWNVWUs=", + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM4ODZBQUU4MTUyODU5NDkKUldSSldTZ1Y2S3FHT09sam9jVXRKSTFFNjIrRkJjdDRUc0xGNDRnbU5HNG9pZ1YvVS9ZdndMMDQK", "endpoints": [ "https://github.com/Noisemaker111/opencode-mono/releases/latest/download/latest.json" ]