diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml new file mode 100644 index 0000000..25a0669 --- /dev/null +++ b/.github/workflows/linux-build.yml @@ -0,0 +1,118 @@ +# Linux build — the canonical recipe for producing brew-browser's +# .deb / .rpm / .AppImage bundles. +# +# Tauri 2 builds all three Linux bundle targets natively via +# `cargo tauri build` (driven here through `npm run tauri build`, which +# also runs the SvelteKit frontend build first via beforeBuildCommand). +# +# Why this exists separately from the macOS flow: +# - macOS releases are built+signed+notarized locally on an Apple +# machine via tools/build/sign-and-notarize.sh (Developer ID + +# Apple notary — both macOS-only). That path is untouched. +# - Linux has no equivalent code-signing requirement for v0: +# * AppImage ships unsigned (the common convention). +# * .deb / .rpm GPG signing is a future optional step — NOT +# implemented here. When we add it, it slots in after the build +# step (sign the produced .deb/.rpm with a repo GPG key, then +# publish to an apt/yum repo). Left out deliberately for v0. +# +# What this produces: build artifacts (.deb, .rpm, .AppImage) uploaded +# to the workflow run. Cutting an actual GitHub Release + the in-app +# updater manifest (dist/updater.json via tools/release/publish-manifest.sh) +# remain deliberate human steps for now. + +name: Linux Build + +on: + push: + # feat/linux-support: prove the recipe works during development. + # Tags v*: every tagged release builds Linux artifacts too. + branches: + - feat/linux-support + tags: + - "v*" + # Manual trigger from the Actions tab. + workflow_dispatch: + +jobs: + build: + name: Build Linux bundles (.deb / .rpm / .AppImage) + # Pin to ubuntu-22.04, NOT ubuntu-latest. 22.04 (Jammy) is the + # webkit2gtk-4.1 era and gives us the oldest glibc we commit to + # supporting — binaries built here run on 22.04+ and newer distros. + # ubuntu-latest drifts forward and would silently raise our glibc + # floor, breaking older targets. + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Standard Tauri 2 Linux build dependencies. Sourced from + # https://v2.tauri.app/start/prerequisites/ (the -dev packages are + # the build-time headers), plus: + # - libgtk-3-dev: GTK3 dev headers (Tauri's Linux webview shell) + # - patchelf: required by the AppImage bundler to rewrite + # rpaths in the packaged binary + # - file, wget: used by the AppImage tooling at bundle time + # libwebkit2gtk-4.1-dev is the 4.1 ABI Tauri 2 targets (22.04+). + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + build-essential \ + file \ + wget + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + # Cache the cargo registry + the src-tauri/target dir keyed on the + # Linux target, so incremental CI runs skip recompiling unchanged + # crates. workspaces points at the crate root that holds Cargo.lock. + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Setup Node 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install npm dependencies + run: npm ci + + # Builds the frontend (beforeBuildCommand: npm run build), compiles + # the Rust binary, and bundles .deb / .rpm / .AppImage. bundle.targets + # is "all" in tauri.conf.json, which on Linux means these three. + - name: Build Tauri app + run: npm run tauri build + + - name: Upload .deb + uses: actions/upload-artifact@v4 + with: + name: brew-browser-deb + path: src-tauri/target/release/bundle/deb/*.deb + if-no-files-found: error + + - name: Upload .rpm + uses: actions/upload-artifact@v4 + with: + name: brew-browser-rpm + path: src-tauri/target/release/bundle/rpm/*.rpm + if-no-files-found: error + + - name: Upload .AppImage + uses: actions/upload-artifact@v4 + with: + name: brew-browser-appimage + path: src-tauri/target/release/bundle/appimage/*.AppImage + if-no-files-found: error diff --git a/.gitignore b/.gitignore index d18ae7f..f2a2633 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,11 @@ __pycache__/ # Icon previews — regenerable from docs/icon/brew-browser.svg via qlmanage docs/icon/preview-*.png + +# Build artifacts (never commit binaries) +*.AppImage +*.deb +*.rpm +*.dmg +*.app.tar.gz +*.app.tar.gz.sig diff --git a/README.md b/README.md index e257b7c..683f8e1 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Homebrew is the standard package manager on macOS. brew-browser gives it a real ## Features -- **Dashboard** — your Homebrew setup at a glance: installed count, updates available, brew version, formula/cask split, top-categories donut chart, storage usage (Cellar / Caskroom / var/log / cache) with one-click "Reveal in Finder" +- **Dashboard** — your Homebrew setup at a glance: installed count, updates available, brew version, formula/cask split, top-categories donut chart, storage usage (Cellar / Caskroom / var/log / cache) with one-click "Reveal in Finder" (macOS) / "Show in file manager" (Linux) - **Library** — every installed formula and cask in one dense, filterable list, with outdated badges, sortable columns, category chip filters, and a slide-over detail panel - **Discover** — search the full Homebrew catalog (15,974 packages, bundled at build time + user-refreshable) by name or browse via the 19-category tile grid; multi-select chip filter - **Trending** — top packages from Homebrew's published `formulae.brew.sh` analytics, with 30 / 90 / 365-day windows and sortable columns @@ -35,34 +35,41 @@ A global Cmd+K command palette covers the verbs. Cmd+0 returns to the Dashboard; ## Install (end users) -Download the latest signed + notarized `.dmg` from the [releases page](https://github.com/msitarzewski/brew-browser/releases/latest), open it, and drag **brew-browser** to your Applications folder. No Gatekeeper warning — the build is signed with a Developer ID Application certificate and notarized by Apple. +**macOS.** Download the latest signed + notarized `.dmg` from the [releases page](https://github.com/msitarzewski/brew-browser/releases/latest), open it, and drag **brew-browser** to your Applications folder. No Gatekeeper warning — the build is signed with a Developer ID Application certificate and notarized by Apple. Apple Silicon only for now. macOS 13 (Ventura) or newer. -Apple Silicon only for now. macOS 13 (Ventura) or newer. +**Linux (newly supported).** Linux bundles are produced by CI as `.deb`, `.rpm`, and `.AppImage`. For tagged releases, grab the `.deb` or `.AppImage` from the [releases page](https://github.com/msitarzewski/brew-browser/releases/latest); for the latest development build, download the artifacts from the most recent [Linux Build workflow run](https://github.com/msitarzewski/brew-browser/actions/workflows/linux-build.yml). Targets Ubuntu 22.04+ (the webkit2gtk-4.1 ABI) and equivalently-recent distros. Two things to know up front: + +- **Linux artifacts are currently unsigned.** The AppImage ships unsigned by convention; `.deb` / `.rpm` GPG signing is a documented future step. There is no Gatekeeper/notarization equivalent to satisfy, but you are running an unsigned binary — verify your download against the release checksums. +- **GitHub sign-in needs a Secret Service daemon.** The optional GitHub integration stores its token in your system keyring via the Secret Service API (gnome-keyring, KWallet). On a desktop session this is already running; on a headless box or a minimal window manager without a Secret Service provider, GitHub sign-in fails with a "keyring unavailable" message. Everything else — browse, search, install, snapshot, services, vulnerability scanning — works regardless. A `brew tap` for one-line install is on the roadmap. ## Build from source -Prereqs: +Prereqs (all platforms): - [Rust](https://rustup.rs/) (stable, edition 2021+) - [Node.js 22+](https://nodejs.org/) and npm - [Homebrew](https://brew.sh/) itself -- Xcode Command Line Tools: `xcode-select --install` -Then: +Platform build prereqs: + +- **macOS** — Xcode Command Line Tools: `xcode-select --install` +- **Linux** — the Tauri 2 GTK/WebKit build dependencies (`libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `patchelf`, plus `build-essential`). The canonical, always-current apt recipe lives in the CI workflow at [`.github/workflows/linux-build.yml`](./.github/workflows/linux-build.yml) — copy the `apt-get install` block from there rather than maintaining a second list here. + +Then (same command on every platform): ```sh git clone https://github.com/msitarzewski/brew-browser cd brew-browser npm install npm run tauri dev # development with HMR -npm run tauri build # produces a .dmg in src-tauri/target/release/bundle/ +npm run tauri build # macOS: .dmg · Linux: .deb / .rpm / .AppImage, all under src-tauri/target/release/bundle/ ``` ## Architecture -A Tauri 2 shell hosts a SvelteKit + Svelte 5 frontend in the system WebView. A Rust backend exposes ~55 typed Tauri commands that shell out to `brew` via `tokio::process` and stream stdout/stderr back over typed IPC channels. The full Homebrew catalog is bundled at build time (~6 MiB gzipped) and refreshable on demand. Trending data comes straight from `formulae.brew.sh`'s public analytics JSON, cached in memory for an hour. Optional GitHub integration uses OAuth Device Flow with the token stored only in the macOS Keychain. No shell plugin, no arbitrary command execution — every `brew` invocation is built in Rust from a small set of enumerated inputs. See [docs/PLAN.md](./docs/PLAN.md) for the full design and [memory-bank/backendApi.md](./memory-bank/backendApi.md) for the complete IPC surface. +A Tauri 2 shell hosts a SvelteKit + Svelte 5 frontend in the system WebView. macOS is the primary target; Linux is newly supported (same codebase, built on Ubuntu 22.04+ via CI). A Rust backend exposes ~55 typed Tauri commands that shell out to `brew` via `tokio::process` and stream stdout/stderr back over typed IPC channels. Paths are derived from `brew --prefix` / `brew --cache` rather than hardcoded, so the Linuxbrew prefix (`/home/linuxbrew/.linuxbrew`, or `~/.linuxbrew`) is picked up automatically alongside the macOS `/opt/homebrew`. The full Homebrew catalog is bundled at build time (~6 MiB gzipped) and refreshable on demand. Trending data comes straight from `formulae.brew.sh`'s public analytics JSON, cached in memory for an hour. Optional GitHub integration uses OAuth Device Flow with the token stored only in the system keyring (macOS Keychain; Secret Service / gnome-keyring / KWallet on Linux). No shell plugin, no arbitrary command execution — every `brew` invocation is built in Rust from a small set of enumerated inputs. See [docs/PLAN.md](./docs/PLAN.md) for the full design and [memory-bank/backendApi.md](./memory-bank/backendApi.md) for the complete IPC surface. ## Open-source posture @@ -74,7 +81,7 @@ brew-browser makes outbound network calls in exactly eleven documented circumsta - **`https://formulae.brew.sh/api/{formula,cask}.json`** — the full Homebrew catalog. Bundled at build time so the app works offline. A user-initiated **Refresh** button on the Dashboard (or the Discover stale-catalog banner) writes a fresh copy to `~/Library/Application Support/brew-browser/catalog/`. Auto-refresh is **off** by default; Settings → Network offers weekly / daily opt-in. - **Cask homepage probes** — when the Discover or Trending tab renders an uninstalled cask that has a `homepage` field, the Rust backend probes that homepage for an icon (in order: `/apple-touch-icon.png`, `` parsed from the homepage HTML, `/favicon.ico`). One probe per cask per week max — the result, including misses, is cached for 7 days. These probes are sandboxed: link-local, loopback, RFC1918, and cloud-metadata IPs are rejected before the request, and the same check runs again on every redirect hop to prevent SSRF. Settings → Network can scope this to **installed only** or disable it entirely. - **`https://api.github.com/repos/{owner}/{repo}`** (read) — optional, **off by default**. When **Settings → GitHub → "Show GitHub stats on package pages"** is on, the PackageDetail panel fetches public repo metadata (stars, forks, last release date, archived state) for packages whose homepage (or `urls.stable.url` / `urls.head.url` / cask `url`) parses as a GitHub URL. The URL parser strictly allowlists `github.com` (rejects `gist.`, `raw.githubusercontent.`, suffix-attack domains, path traversal). Results cached to `~/Library/Application Support/brew-browser/github-cache/` for 24 hours. Anonymous rate limit is 60 reqs/hr per IP; sign-in lifts it to 5,000/hr. -- **`https://github.com/login/{device,oauth}/*`** — optional, only when you click **Sign in with GitHub** in Settings (or hit the inline Re-authorize button on a scope-required toast). Uses OAuth Device Flow (RFC 8628): you see a user code, open `github.com/login/device` in your browser, paste it, done. No embedded webview, no client secret, no callback URL. Scopes requested: `read:user` + `public_repo` + `notifications` (the minimum for username + star + file-issue + watch). Access token stored exclusively in **macOS Keychain** under `com.zerologic.brew-browser/github_access_token`. **The token is never returned to the frontend, never written to disk, and never logged** — verified by unit tests. +- **`https://github.com/login/{device,oauth}/*`** — optional, only when you click **Sign in with GitHub** in Settings (or hit the inline Re-authorize button on a scope-required toast). Uses OAuth Device Flow (RFC 8628): you see a user code, open `github.com/login/device` in your browser, paste it, done. No embedded webview, no client secret, no callback URL. Scopes requested: `read:user` + `public_repo` + `notifications` (the minimum for username + star + file-issue + watch). Access token stored exclusively in the OS credential store under `com.zerologic.brew-browser/github_access_token` — the **macOS Keychain** on macOS, the **Secret Service** (gnome-keyring / KWallet, persistent across reboot) on Linux. On a Linux session with no Secret Service daemon, sign-in fails with a "keyring unavailable" message and the rest of the app is unaffected. **The token is never returned to the frontend, never written to disk by us, and never logged** — verified by unit tests. - **`https://api.github.com/{user/starred,repos/.../subscription,repos/.../issues}`** (write) — optional, only when you click Star, Watch, or File-issue on a package detail page after signing in. Each action is gated server-side by a per-action OAuth scope check (`public_repo` for star + file-issue; `notifications` for watch/unwatch) so a token missing the right scope fails fast with a typed `scope_required` error before any GitHub round-trip. - **`brew` itself** — every install, uninstall, upgrade, search, and snapshot shells out to the real `brew` CLI. Whatever network calls `brew` makes (GitHub, OCI registries, bottle mirrors) happen exactly as they would if you ran the command yourself in a terminal. The full stdout/stderr stream is visible in the Activity drawer. - **Your default browser** — when you click the homepage button on a package, the URL is opened in your default browser via macOS `open(1)`. The app rejects any non-`http(s)` scheme before opening. @@ -147,7 +154,7 @@ Contributions welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the dev loop ## Built with -Built with **[Agency Agents](https://github.com/msitarzewski/agency-agents)**, by the creator of Agency Agents — the multi-agent toolkit (Backend Architect, Frontend Developer, Security Engineer, Code Reviewer, Technical Writer, and friends) that orchestrated brew-browser's design and implementation. Powered by Claude Code in the terminal, running Opus 4.7 [1m]. +Built with **[Agency Agents](https://github.com/msitarzewski/agency-agents)**, by the creator of Agency Agents — the multi-agent toolkit (Backend Architect, Frontend Developer, Security Engineer, Code Reviewer, Technical Writer, and friends) that orchestrated brew-browser's design and implementation. Powered by Claude Code in the terminal, running Opus 4.8 (1M context). ## Support the project diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 71bc775..14b1ef1 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -1,65 +1,63 @@ # Active Context -**Date:** 2026-05-27 (v0.5.0 ready for PR / review — post-smoke-test cycle) -**State:** All 8 steps of the v0.5.0 plan complete on branch `feat/v0.5.0-vulnerability-scanning`, PLUS a 5-bug smoke-test cycle that surfaced and resolved every subprocess-integration assumption that was wrong. Opt-in vulnerability scanning via `brew vulns` ships behind a per-feature Settings toggle, with the install-set fingerprint optimisation, optional GHSA enrichment when GitHub auth is on, and the full UI surface (Settings card + Dashboard Exposure card + Sidebar count badge + PackageRow severity dot + PackageDetail Security card). Awaiting PR + merge + release cut. +**Date:** 2026-05-28 (Linux build support added on branch — macOS regression-verified, Linux binary UNPROVEN) +**Session lead:** Claude Opus 4.8 (1M context) (Claude Code in the terminal) with Michael +**State:** v0.5.0 is **shipped and released** (live on GitHub Releases). The `feat/linux-support` branch adds Linux build support to the existing macOS app. The macOS side is **regression-verified** (586 Rust tests pass, 0 frontend check errors, clean Vite build). The Linux binary builds in CI but is **UNPROVEN** — it has never run on a real Linux machine. No Linux release until a real-Linux smoke test passes. ## Repo - **github.com/msitarzewski/brew-browser** — public, MIT -- **Released:** v0.1.0, v0.2.0, v0.2.1, v0.3.0, v0.3.1, v0.4.0 (live on GitHub Releases — `gh release list`) -- **Working toward:** v0.5.0 (branch ready; PR follows docs commit) -- **Branch:** `feat/v0.5.0-vulnerability-scanning` -- **Stars:** 18+ (as of v0.4.0 ship) +- **Released:** v0.1.0, v0.2.0, v0.2.1, v0.3.0, v0.3.1, v0.4.0, **v0.5.0** (live on GitHub Releases — `gh release list`) +- **Working on:** `feat/linux-support` — Linux build support (`.deb` / `.rpm` / `.AppImage` via CI), targeting a future v0.6.0-track release. +- **Branch:** `feat/linux-support` +- **Stars:** 18+ -## v0.5.0 shipped on the branch (Steps 1–8) +## What landed on `feat/linux-support` -Full file:line detail + decisions + verification narrative in `tasks/2026-05/20-v0.5.0-vulnerability-scanning.md`. Bullet summary: +Full file:line detail + the before-a-Linux-release checklist in `tasks/2026-05/21-linux-support.md`. ADR in `decisions.md` (2026-05-28). Eight changes: -- **Step 1** — `Settings.vulnerability_scanning_enabled` (default `false`, forward-compat tested), `state::require_vulnerability_scanning()` gate composing master paranoid with per-feature toggle, new `BrewError::VulnsNotInstalled { install_command }` variant routing the user to the one-click installer affordance instead of a generic exit-non-zero toast. Five rejection paths pinned by tests (toggle off, paranoid on, paranoid-wins-over-toggle, FirstLaunch, Corrupt). -- **Step 2** — New `src-tauri/src/vulns/{client,cache,fingerprint,enrich}.rs` module (~2,100 lines). `client` invokes `brew vulns --json` via `tokio::process::Command`, parses with serde-default defenses, exposes `check_brew_vulns_installed` + `scan_all` + `scan_one` + `install_brew_vulns`. `cache` is the persistent `vulns_cache.json` layer (1 MiB cap, atomic-write, fail-soft, 6h per-record TTL). `fingerprint` produces a deterministic SHA-256 over sorted `kind:name:version` lines for the whole-scan skip predicate. New `sha2 = "0.10"` + `hex = "0.4"` deps. -- **Step 3** — Four IPC commands: `vulns_scan_all(force)`, `vulns_scan_one(name)`, `vulns_install_helper`, `vulns_invalidate(kind, name, version)`. Gate composition documented inline + pinned by tests. `vulns_install_helper` intentionally bypasses the per-feature toggle (first-run flow is "install → toggle on → scan"); still respects master paranoid gate. -- **Step 4** — GHSA enrichment via `vulns::enrich::enrich()`. Fetches `api.github.com/advisories/{GHSA_ID}` when (a) the OSV record carries a `GHSA-…` ID AND (b) `settings.github_enabled` is on AND (c) the master paranoid gate is off — triple-defense. Parallel cache at `ghsa_cache.json` (2 MiB cap). Best-effort: 403/429/network error leaves the OSV record unchanged and logs (no toast). -- **Step 5** — Frontend store `src/lib/stores/vulnerabilities.svelte.ts` (~350 lines). `byPackage` Map keyed by `"{kind}:{name}"`, `severityCounts` derived rollup, `scanAll` / `scanOne` / `installHelper` / `invalidate` wrappers, sync lookups for inline UI consumers (`maxSeverityFor`, `vulnsFor`). Error routing: `vulns_not_installed` → captured for Settings card install affordance; everything else → `reportableToastError`. Types ported in `src/lib/types.ts`, IPC bindings in `src/lib/api.ts`. -- **Step 6** — UI surface: new `SettingsSectionVulnerabilities.svelte` opt-in subsection mounted in `SettingsSectionNetwork.svelte` alongside Updates + Enhanced Trending History; Dashboard `Exposure` card with severity counts + Scan-now button + ✓ clean-state framing; Sidebar count badge with max-severity tone; PackageRow inline severity dot; PackageDetail Security card with per-CVE rows, severity pills, fixed-in ranges, "Upgrade to fix" button wired to existing `brew_upgrade` pipeline. Cask rows render honest "Cask coverage isn't supported — brew vulns is formula-only" message rather than fake clean state. -- **Step 7** — Refresh-feed integration: post-`brew update` fan-out (Dashboard Refresh, Library Refresh) fires `vulnerabilities.scanAll(force=false)` so freshly learned upstream versions get scanned. Post-mutation hooks (install / upgrade / uninstall in `packages.svelte.ts`) call `vulns_invalidate(kind, name, version)` + `vulnerabilities.scanOne(name)` so the affected package's CVE row reflects the new state immediately. The `force=false` parameter on the post-update scan means the install-set fingerprint skip predicate still applies — a refresh that didn't change install state won't re-shell `brew vulns`. -- **Step 8** — Memory bank + docs (this commit): projectbrief ten → eleven paths, decisions.md ADR, security.md §17 endpoint audit, techContext.md (brew-vulns + sha2/hex deps), backendApi.md §13.15, frontendComponents.md v0.5.0 additions block, `docs/release-notes/0.5.0.md`, README disclosure refresh, task record `tasks/2026-05/20-v0.5.0-vulnerability-scanning.md`. +1. **Keyring cfg-gate** — `Cargo.toml` splits the `keyring` dep per target: macOS keeps `apple-native`; Linux uses `sync-secret-service` + `crypto-rust` (persistent Secret Service via gnome-keyring/KWallet; pure-Rust crypto so no system OpenSSL on CI). `github/auth.rs` needed **zero changes** (unified `keyring::Entry` API). Runtime caveat: Linux needs a Secret Service daemon for GitHub sign-in; without one, sign-in fails via the existing `KeychainUnavailable` path and the rest of the app is unaffected. +2. **Linuxbrew path** — `brew/paths.rs` also checks `/home/linuxbrew/.linuxbrew/bin/brew` and `~/.linuxbrew/bin/brew`. +3. **`open_in_finder`** — IPC name unchanged; cfg-gated: macOS `open -R`, Linux `xdg-open` on the parent directory. Security gate + disk-usage paths derive from `brew --prefix` / `brew --cache`, so the Linux prefix works automatically. +4. **`cask_icon`** — macOS `.app`/`sips`/`defaults` extraction cfg-gated; Linux short-circuits to `Ok(None)`. Casks are NOT removed — they list, install, and get homepage-favicon icons on Linux. +5. **CI** — new `.github/workflows/linux-build.yml` on `ubuntu-22.04` (webkit2gtk-4.1 era, oldest-glibc floor) producing `.deb`, `.rpm`, `.AppImage`. Triggers: push to `feat/linux-support`, `v*` tags, manual dispatch. +6. **`tauri.conf.json`** — added `bundle.linux` (deb runtime `depends` + appimage config). macOS bundle untouched. +7. **`publish-manifest.sh`** — emits an additional `linux-x86_64` updater platform block when the AppImage + `.sig` are present; macOS-only path byte-identical when not. +8. **Frontend** — `src/lib/util/platform.ts` (navigator.userAgent-based `isMac`/`isLinux`, zero new deps); "Reveal in Finder" → "Show in file manager" on Linux; "macOS Keychain" → "system keyring" on Linux. -## Tests & lint at PR-open +## Verified vs unverified -- `cargo test`: **585 passed**, 0 failed, 6 ignored (507 → 585, +78 new — the +6 over the original +72 is the captured-fixture suite added during the smoke-test cycle) -- `cargo build`: clean (zero dead-code warnings — every new symbol is wired) -- `npm run check`: 0 errors, 3 pre-existing warnings (v0.4.0 baseline) -- `npm run build`: clean Vite build +**Verified (macOS):** -## Smoke-test cycle (2026-05-27) +- `cargo test`: **586 passed**, 0 failed (the per-target cfg-gating compiles cleanly on macOS; the keyring split selects `apple-native`). +- `npm run check`: 0 errors. +- `npm run build`: clean Vite build. +- The macOS bundle config, signing/notarization path, and updater manifest are byte-identical to v0.5.0 — no macOS regression. -Five integration bugs surfaced + fixed during the first end-to-end smoke test on the user's real install (326 packages, 11 vulnerable). Each required either a real `brew` subprocess or the actual `brew vulns` binary on disk — none were catchable by unit-test sandbox. Full table + lessons in `tasks/2026-05/20-v0.5.0-vulnerability-scanning.md` under "Smoke test cycle". Summary: +**Unverified (Linux / CI):** -1. `brew commands --include-aliases` errors without `--quiet` (modern brew 5.x) — added `--quiet`, then superseded -2. `brew commands` doesn't list external `brew-FOO` formula shims — switched install probe to `brew --prefix brew-vulns` -3. JSON severity is UPPERCASE in wire — custom case-folding `Deserialize` impl (also accepts `MODERATE` → Medium) -4. JSON uses `fixed_versions: [String]` (array), not `fixed_in: String` — `first_string_or_none` deserializer maps array's first element into the existing `fixed_in: Option` field -5. `brew vulns --json` exits 1 when findings present (CI-scanner convention) — new `run_vulns_capture` helper accepts exit 0 OR 1 as success, only typed-errors on ≥ 2 +- The Linux binary has **never run on a real Linux machine.** webkit2gtk can't be cross-compiled from macOS, so the binary is produced only by CI. +- The CI workflow has **not had its first run confirmed green** as of this writing. +- Nothing on the Linux runtime path — brew detection at the Linuxbrew prefix, GitHub sign-in against a Secret Service daemon, `xdg-open` file-manager reveal, an install/upgrade/uninstall round-trip, the vuln scan — has been exercised end-to-end on Linux. The macOS test suite and a CI compile do not cover these. -Regression-pinned by the captured-fixture test `vulns::client::tests::raw_scan_result_parses_real_brew_vulns_output` using real `brew vulns --json` output from the user's install. All five failure modes are commented at their trap sites in `vulns/client.rs` so future maintainers see why the fix exists. +The honest claim: **Linux build support added; macOS regression-verified; the Linux binary builds in CI but is unproven until a real-Linux smoke test.** This mirrors the cask-coverage-gap honesty posture from v0.5.0 and the smoke-test-discipline ADR (2026-05-27) — a CI compile proves the binary builds, not that the feature works. -## Decisions locked (per-decision rationale in task #20 + decisions.md ADR) +## Before a Linux release (hard gate) -- **Why shell out to brew-vulns instead of native OSV query?** Inherits upstream fixes automatically; correct attribution ("Powered by brew vulns"); escape hatch via the internal interface if upstream stagnates. Cost: requires brew-vulns to be installed (we provide the one-click installer). -- **Why GHSA enrichment is best-effort?** GHSA enrichment is a UX nicety, not a correctness requirement. A 403/429/network error from `api.github.com` should not break the whole scan; we leave the OSV record unchanged and log without toasting. -- **Why install-set SHA-256 fingerprint?** `DefaultHasher` is non-deterministic across runs (HashDoS defense) — a hash recorded in v0.5.0 disk cache would mismatch every subsequent launch, silently invalidating the skip predicate. SHA-256 is deterministic across runs, machines, Rust versions. -- **Why opt-in?** Adds an eleventh outbound path (`api.osv.dev` via subprocess + `api.github.com/advisories` from our code). The first-launch posture stays "zero outbound beyond what the user has explicitly consented to." -- **Casks not supported.** `brew vulns` is formula-only; we render honest "coverage isn't supported" rather than fake clean state. +1. **CI green** — first `linux-build.yml` run completes and produces `.deb` / `.rpm` / `.AppImage`. +2. **Real-Linux smoke test** on an actual Linux box (Ubuntu 22.04+) covering: + - brew detection at the Linuxbrew prefix (`/home/linuxbrew/.linuxbrew` and `~/.linuxbrew`), + - GitHub sign-in with a Secret Service daemon running (token persists to the keyring; survives relaunch), + - "Show in file manager" (`xdg-open` on the parent directory), + - a formula install / upgrade / uninstall round-trip with live Activity streaming, + - the opt-in vulnerability scan, IF `brew vulns` installs cleanly on Linux. -## Workflow note - -Branch ready for PR. Follows the durable v0.4.0+ workflow: push branch → `gh pr create` → review → merge. No direct pushes to `main`. +Full checklist in `tasks/2026-05/21-linux-support.md`. -## What's left +## Workflow note -- Open PR for the v0.5.0 branch. -- Cut the v0.5.0 release after merge: `tools/build/sign-and-notarize.sh` → `tools/release/publish-manifest.sh 0.5.0` → `gh release create v0.5.0 ...` → `gh api PATCH` for asset rename → manifest rsync to `brew-browser.zerologic.com:Sites/brew-browser/updater.json`. Same flow as v0.4.0; Tauri-release gotchas in cross-session memory `tauri_release_pipeline_gotchas.md`. +Branch ready for PR review. Follows the durable v0.4.0+ workflow: push branch → `gh pr create` → review → merge. No direct pushes to `main`. **No Linux release until the smoke test passes** — merging the build-support branch is fine, but advertising a Linux download is not until the binary is proven. ## Memory bank inventory -`toc.md`, `projectbrief.md`, `techContext.md`, `decisions.md`, `activeContext.md` (this), `progress.md`, `systemPatterns.md`, `designSystem.md`, `uxArchitecture.md`, `visualStory.md`, `backendApi.md`, `frontendComponents.md`, `codeReview.md`, `apiTests.md`, `accessibility.md`, `realityCheck.md`, `security.md` (now through §17), `ideas.md`, `agentLog.md` (dormant), `NEXT-SESSION.md`, `tasks/2026-05/` (20 task records + README + deferred), `phases/`, `scans/2026-05-23/`. +`toc.md`, `projectbrief.md`, `techContext.md` (now with the Cross-platform/Linux section), `decisions.md` (now through the 2026-05-28 Linux ADR), `activeContext.md` (this), `progress.md`, `systemPatterns.md`, `designSystem.md`, `uxArchitecture.md`, `visualStory.md`, `backendApi.md`, `frontendComponents.md`, `codeReview.md`, `apiTests.md`, `accessibility.md`, `realityCheck.md`, `security.md` (through §17), `ideas.md`, `agentLog.md` (dormant), `NEXT-SESSION.md`, `tasks/2026-05/` (21 task records + README + deferred), `phases/`, `scans/2026-05-23/`. diff --git a/memory-bank/decisions.md b/memory-bank/decisions.md index 6d69dda..af35619 100644 --- a/memory-bank/decisions.md +++ b/memory-bank/decisions.md @@ -320,3 +320,35 @@ The v0.4.0 outbound enumeration is at ten paths (path j is the most recent — o - "Mock the subprocess with a recorded fixture." Useful, but only after the fixture has been captured live at least once. The mock-first failure mode is "mock matches expectations; reality differs." **Outcome:** added the §"Smoke test cycle" block to `tasks/2026-05/20-v0.5.0-vulnerability-scanning.md` documenting the five bugs + their fixes + the captured-fixture regression pin. Future subprocess-integration tasks (e.g. if we ever wrap `brew livecheck`, `brew bundle-doctor`, or any other third-party brew subcommand) should reference this ADR before committing to a "defensive parse" without a captured fixture. + +--- + +## 2026-05-28: Linux support via Tauri's native cross-compilation (v0.6.0-track) + +**Status:** Build support added on branch `feat/linux-support`. macOS regression-verified (586 Rust tests pass, frontend check clean, Vite build clean). The Linux binary builds in CI but is **unproven** until a real-Linux smoke test runs (see "Before a Linux release" below and the reasoning carried over from the 2026-05-27 smoke-test-discipline ADR). + +**Context:** brew-browser shipped macOS-only through v0.5.0. Homebrew also runs on Linux (Linuxbrew, prefix `/home/linuxbrew/.linuxbrew`). The question was how much it would cost to support Linux — and whether the answer changed the "macOS-first" posture. It turned out to be cheap, because that cheapness is exactly Tauri's whole value proposition: one codebase, add a target. + +**Decision:** add Linux as a supported build target. Keep macOS as the primary target. Build Linux artifacts (`.deb` / `.rpm` / `.AppImage`) in CI on `ubuntu-22.04`. Ship them **unsigned for v0**. Do not claim Linux "works" until a real-Linux smoke test passes. + +**Why it was cheap (the eight changes):** + +- **No frontend logic changes beyond label copy.** The WebView and the IPC surface are platform-agnostic. A new `src/lib/util/platform.ts` (navigator.userAgent-based `isMac` / `isLinux`, zero new deps) swaps "Reveal in Finder" → "Show in file manager" and "macOS Keychain" → "system keyring" in user-facing copy. That's it. +- **Vibrancy was already cfg-gated.** The macOS `NSVisualEffectView` setup lives behind a macOS cfg from the original v0.1.0 work, so the Linux build doesn't try to apply it. Nothing to do. +- **`cask_icon` used no macOS-only crates.** The `.app`/`sips`/`defaults` extraction is shell-outs to system binaries, already cfg-gated `#[cfg(target_os = "macos")]`. On Linux it compiles free and short-circuits to `Ok(None)`. +- **Paths were already derived from `brew --prefix` / `brew --cache`**, not hardcoded `/opt/homebrew`. The Finder-reveal security gate and disk-usage paths pick up the Linuxbrew prefix automatically once `brew/paths.rs` knows where to find `brew` on Linux. + +**The keyring feature decision + rationale.** The only dependency change. `keyring::Entry` is a unified API across backends, so `github/auth.rs` needed **zero changes** — the split is entirely in `Cargo.toml`'s per-target dependency tables: + +- macOS → `apple-native` (Security framework / Keychain), unchanged. +- Linux → `sync-secret-service` + `crypto-rust`. `sync-secret-service` gives **persistent** token storage via the Secret Service D-Bus API (gnome-keyring / KWallet, survives reboot) rather than the session-scoped kernel keyutils. `crypto-rust` is pure-Rust AES so CI has **no system OpenSSL build dependency**. Runtime caveat: Linux needs a Secret Service daemon running for GitHub sign-in; without one, sign-in fails via the existing `BrewError::KeychainUnavailable` path and nothing else breaks. + +**Cask graceful-degradation call (not ripped out).** It was tempting to disable casks on Linux entirely since the macOS `.app`-icon pipeline doesn't apply. Rejected — Linux casks still **list, install, and get homepage-favicon icons**; only the `.app`-bundle icon extraction is macOS-only. Same posture as the cask-coverage-gap honesty in v0.5.0 (render the row, tell the truth in the label, don't fake state). Linux `cask_icon` short-circuits to `Ok(None)` and the homepage-favicon cascade still runs. + +**Unsigned Linux artifacts for v0.** AppImage ships unsigned by convention. `.deb` / `.rpm` GPG signing is a documented future step (slots in after the build step: sign with a repo GPG key, publish to an apt/yum repo). There is no notarization equivalent to satisfy on Linux. Decided to ship unsigned for v0 rather than block Linux support on a signing pipeline. + +**CI is the build host (no local cross-compile).** webkit2gtk is Linux-native; it cannot be cross-compiled from macOS. So the Linux binary is produced by `.github/workflows/linux-build.yml` on `ubuntu-22.04` — pinned, NOT `ubuntu-latest`, so the glibc floor stays fixed (binaries run on 22.04+; `ubuntu-latest` would silently raise the floor). That workflow is also the canonical apt-dependency recipe; the README points at it rather than duplicating the list. + +**Why "unproven until smoke test" is load-bearing here.** Per the 2026-05-27 ADR "Smoke-test discipline for subprocess-integration features": unit tests validate *internal* correctness; only a live run on the real platform catches *integration* assumptions. Linux support touches subprocess semantics (`xdg-open` vs `open -R`), the Secret Service daemon dependency, and the Linuxbrew prefix — none of which the macOS test suite or the CI compile exercises end-to-end. CI proving the binary *builds* is necessary but not sufficient. The honest claim is "Linux build support added; macOS regression-verified; Linux binary builds in CI but is unproven until a real-Linux smoke test." A real-Linux smoke test is a hard gate before any Linux release. + +**Outcome:** documented in `techContext.md` ("Cross-platform (Linux)" section), `README.md` (Install + Build-from-source + Architecture), `progress.md` (Linux-support section), and `tasks/2026-05/21-linux-support.md` (full file:line detail + the before-a-Linux-release checklist). Eight changes total: keyring cfg-split, Linuxbrew path, `open_in_finder` cfg-gate, `cask_icon` cfg-gate, CI workflow, `tauri.conf.json` `bundle.linux`, `publish-manifest.sh` Linux platform block, and the frontend `platform.ts` label module. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 7ca0bae..b917520 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -811,3 +811,42 @@ Regression-pinned by `vulns::client::tests::raw_scan_result_parses_real_brew_vul - Open the PR for `feat/v0.5.0-vulnerability-scanning` → review → merge. - Cut the v0.5.0 release via the standard pipeline (same as v0.4.0). + +--- + +## 2026-05-28 (Linux build support — branch `feat/linux-support`) + +v0.5.0 is shipped and released. This branch adds **Linux build support** to the existing macOS app, targeting a future v0.6.0-track release. macOS is and stays the primary target. The macOS side is **regression-verified**; the Linux binary builds **only in CI** and is **UNPROVEN** until a real-Linux smoke test runs. + +### What landed (8 changes) + +| # | Change | Where | +|---|---|---| +| 1 | Keyring dep split per target — macOS `apple-native`, Linux `sync-secret-service` + `crypto-rust` (persistent Secret Service, pure-Rust crypto, no system OpenSSL on CI). `github/auth.rs` unchanged (unified `keyring::Entry`). | `src-tauri/Cargo.toml:87-96` | +| 2 | Linuxbrew path detection — `/home/linuxbrew/.linuxbrew/bin/brew` + `~/.linuxbrew/bin/brew`. | `src-tauri/src/brew/paths.rs:31-37` | +| 3 | `open_in_finder` cfg-gated — macOS `open -R`, Linux `xdg-open` on parent dir. Security gate + disk paths from `brew --prefix`/`--cache`. | `src-tauri/src/commands/disk_usage.rs:240-271` | +| 4 | `cask_icon` cfg-gated — macOS `.app`/`sips`/`defaults`; Linux `Ok(None)`. Casks still list/install/get favicon icons. | `src-tauri/src/commands/cask_icon.rs` | +| 5 | CI workflow on `ubuntu-22.04` (webkit2gtk-4.1, oldest-glibc floor) → `.deb`/`.rpm`/`.AppImage`. Triggers: branch push, `v*` tags, manual dispatch. Canonical apt recipe. | `.github/workflows/linux-build.yml` | +| 6 | `bundle.linux` block (deb runtime `depends` + appimage config); macOS bundle untouched. | `src-tauri/tauri.conf.json:55-66` | +| 7 | Emit `linux-x86_64` updater platform block when AppImage + `.sig` present; macOS-only path byte-identical when not. | `tools/release/publish-manifest.sh` | +| 8 | Platform-aware frontend copy via `platform.ts` (navigator.userAgent, zero deps): "Show in file manager" / "system keyring" on Linux. | `src/lib/util/platform.ts`, `Dashboard.svelte`, `SettingsSectionGitHub.svelte`, `types.ts` | + +### Verified (macOS) vs unverified (Linux/CI) + +- ✅ **Verified (macOS):** `cargo test` **586 passed / 0 failed**; `npm run check` 0 errors; `npm run build` clean Vite build. The per-target cfg-gating compiles on macOS (keyring selects `apple-native`). macOS bundle/signing/updater path byte-identical to v0.5.0 — no regression. +- ⚠️ **Unverified (Linux/CI):** the Linux binary has **never run on a real Linux machine** (webkit2gtk can't cross-compile from macOS; only CI produces it). First CI run not yet confirmed green. No Linux runtime path exercised end-to-end. + +The accurate claim is **"Linux build support added; macOS regression-verified; Linux binary builds in CI but is unproven until a real-Linux smoke test"** — same honesty posture as the v0.5.0 cask-coverage gap, and consistent with the 2026-05-27 smoke-test-discipline ADR (a compile proves the binary builds, not that the feature works). + +### Before a Linux release (hard gate) + +1. CI green — `linux-build.yml` produces `.deb`/`.rpm`/`.AppImage`. +2. Real-Linux smoke test (Ubuntu 22.04+): brew detection at the Linuxbrew prefix · GitHub sign-in with a Secret Service daemon · "Show in file manager" (`xdg-open`) · formula install/upgrade/uninstall round-trip · the vuln scan if `brew vulns` installs on Linux. + +Full detail: `tasks/2026-05/21-linux-support.md`. ADR: `decisions.md` (2026-05-28). + +### What's left + +- Confirm the first CI run is green. +- Run the real-Linux smoke test before advertising any Linux download. +- Open the PR for `feat/linux-support` → review → merge (merging build support is fine; a Linux release waits on the smoke test). diff --git a/memory-bank/tasks/2026-05/21-linux-support.md b/memory-bank/tasks/2026-05/21-linux-support.md new file mode 100644 index 0000000..672259b --- /dev/null +++ b/memory-bank/tasks/2026-05/21-linux-support.md @@ -0,0 +1,119 @@ +# 2026-05-28 — Linux build support + +**Phase:** Linux build support — add Linux as a supported build target alongside macOS +**Status:** ✅ 8 changes complete on branch `feat/linux-support`. macOS **regression-verified** (586 Rust tests, 0 frontend errors, clean Vite build). Linux binary builds **only in CI** and is **UNPROVEN** — never run on a real Linux machine. No Linux release until a real-Linux smoke test passes. +**Branch:** `feat/linux-support` (off `main` at the v0.5.0 merge commit) +**Release track:** v0.6.0-track (build support; not a release in itself) +**Workflow:** PRs into main, no direct pushes (durable rule since v0.4.0). + +## Scope + +brew-browser shipped macOS-only through v0.5.0. Homebrew also runs on Linux (Linuxbrew, prefix `/home/linuxbrew/.linuxbrew`). This branch adds Linux build support — `.deb` / `.rpm` / `.AppImage` produced by CI on Ubuntu 22.04 — without disturbing the macOS path. The work was cheap precisely because it leans on Tauri's "one codebase, add a target" model: the WebView and IPC surface are platform-agnostic, vibrancy was already cfg-gated, `cask_icon` used no macOS-only crates, and paths were already derived from `brew --prefix` / `brew --cache` rather than a hardcoded `/opt/homebrew`. The only dependency change was the keyring feature split. + +macOS remains the **primary** target. Linux is **newly supported** and its binary is **unproven** until a real-Linux smoke test runs (the build compiling in CI proves the binary builds, not that the feature works — per the 2026-05-27 smoke-test-discipline ADR). + +## Decisions + +Full ADR in `decisions.md` (2026-05-28: "Linux support via Tauri's native cross-compilation"). Summary: + +- **D1 — Add Linux as a supported target; keep macOS primary.** Cheap because of Tauri's value prop. Don't overclaim: the honest posture is "build support added, macOS-verified, Linux unproven." +- **D2 — Keyring feature split per target, `github/auth.rs` unchanged.** `keyring::Entry` is a unified API, so only `Cargo.toml`'s per-target dependency tables differ. macOS `apple-native`; Linux `sync-secret-service` (persistent Secret Service via gnome-keyring/KWallet over D-Bus — survives reboot, NOT session-scoped keyutils) + `crypto-rust` (pure-Rust AES → no system OpenSSL build dependency on CI). Runtime caveat: Linux needs a Secret Service daemon for GitHub sign-in; without one, sign-in fails via the existing `BrewError::KeychainUnavailable` path and nothing else breaks. +- **D3 — Casks degrade gracefully, not removed.** Linux casks still list, install, and get homepage-favicon icons. Only the macOS `.app`-bundle icon extraction (`sips`/`defaults`) is gated off; Linux `cask_icon` short-circuits to `Ok(None)` and the homepage-favicon cascade still runs. Same honesty posture as the v0.5.0 cask-coverage gap. +- **D4 — Unsigned Linux artifacts for v0.** AppImage unsigned by convention; `.deb`/`.rpm` GPG signing is a documented future step (sign with a repo GPG key after the build, publish to apt/yum). No notarization equivalent on Linux. Ship unsigned for v0 rather than block on a signing pipeline. +- **D5 — CI is the build host; no local cross-compile.** webkit2gtk is Linux-native and can't be cross-compiled from macOS. `.github/workflows/linux-build.yml` on pinned `ubuntu-22.04` (NOT `ubuntu-latest`, to keep the glibc floor fixed) produces the artifacts and is the canonical apt-dependency recipe. + +## What landed (8 changes) + +### 1 — Keyring cfg-gate + +- `src-tauri/Cargo.toml:87-89` — `[target.'cfg(target_os = "macos")'.dependencies]` keeps `keyring = { version = "3", features = ["apple-native"] }`. +- `src-tauri/Cargo.toml:95-96` — `[target.'cfg(target_os = "linux")'.dependencies]` adds `keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }`. +- `src-tauri/src/github/auth.rs` — **ZERO changes.** The unified `keyring::Entry` API (`auth.rs:275-290`) compiles against whichever backend the per-target feature selects. The `BrewError::KeychainUnavailable` path (`error.rs:104`, surfaced from `auth.rs:276-290`) is the existing failure mode that Linux-without-a-daemon hits. +- `src-tauri/Cargo.lock` — updated for the new Linux-target crates. + +### 2 — Linuxbrew path detection + +- `src-tauri/src/brew/paths.rs:31` — checks `/home/linuxbrew/.linuxbrew/bin/brew` (shared install). +- `src-tauri/src/brew/paths.rs:37` — checks `~/.linuxbrew/bin/brew` (per-user install). +- Doc comment at `paths.rs:10-11` records the prefixes. Test `resolve_brew_path_finds_linuxbrew_shared_when_present` (`paths.rs:99`) pins the shared-prefix resolution. + +### 3 — `open_in_finder` cfg-gate + +- `src-tauri/src/commands/disk_usage.rs:203` — `open_in_finder(path, state)` IPC name unchanged. Security gate (`disk_usage.rs:220`) refuses any path not inside the Homebrew prefix or cache, both derived from `brew --prefix` / `brew --cache` (so the Linux prefix works automatically). +- `disk_usage.rs:240-256` — `#[cfg(target_os = "macos")]` impl: `open -R ` (reveal-and-select in Finder). +- `disk_usage.rs:258-271` — `#[cfg(target_os = "linux")]` impl: `xdg-open `. No portable reveal-and-select verb on Linux, so we open the containing directory. Documented at `disk_usage.rs:233-239`. + +### 4 — `cask_icon` cfg-gate + +- `src-tauri/src/commands/cask_icon.rs` — the entire `.app`/`sips`/`defaults` extraction pipeline (`read_bundle_icon_file`, `sips_convert_to_png`, helpers) is `#[cfg(target_os = "macos")]` (e.g. `cask_icon.rs:48,92,172,222,255,286,353,396`). +- `cask_icon.rs:89-95` — Linux short-circuits to `Ok(None)` (Linux casks don't produce `.app` bundles). The homepage-favicon cascade in the homepage-probe path is platform-agnostic and still runs, so Linux casks still get icons. + +### 5 — CI workflow + +- `.github/workflows/linux-build.yml` (NEW) — `name: Linux Build`, `runs-on: ubuntu-22.04` (pinned for webkit2gtk-4.1 + fixed glibc floor; rationale at `linux-build.yml:40-45`). + - Triggers (`linux-build.yml:26-35`): push to `feat/linux-support`, `v*` tags, `workflow_dispatch`. + - apt deps (`linux-build.yml:59-71`): `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `patchelf`, `libssl-dev`, `build-essential`, `file`, `wget`. **This is the canonical recipe** — the README points here rather than duplicating it. + - Build: `npm run tauri build` (`linux-build.yml:96-97`); `bundle.targets: "all"` → `.deb`/`.rpm`/`.AppImage` on Linux. + - Uploads each artifact with `if-no-files-found: error` (`linux-build.yml:99-118`). + - Header comment (`linux-build.yml:8-22`) records: macOS path untouched (local sign+notarize), AppImage unsigned by convention, deb/rpm GPG signing is a future step, and that cutting a Release + the updater manifest stay deliberate human steps. + +### 6 — `tauri.conf.json` bundle.linux + +- `src-tauri/tauri.conf.json:55-66` — new `bundle.linux` block: `deb.depends` = `["libwebkit2gtk-4.1-0", "libgtk-3-0", "libayatana-appindicator3-1"]`; `appimage.bundleMediaFramework: false`. +- `bundle.macOS` (`tauri.conf.json:50-54`) and `bundle.targets: "all"` (`tauri.conf.json:42`) untouched. + +### 7 — `publish-manifest.sh` Linux platform block + +- `tools/release/publish-manifest.sh:102` — globs `src-tauri/target/release/bundle/appimage/*.AppImage` for the Linux artifact; `:103` names it `brew-browser_${VERSION}_amd64.AppImage`. +- `:149-158` — builds the `linux-x86_64` updater platform block only when the AppImage **and** its `.sig` are both present. On a Mac that only built `.app.tar.gz` (no AppImage), `LINUX_BLOCK` stays empty and the emitted manifest is byte-identical to the macOS-only output. Signs with the same `TAURI_SIGNING_PRIVATE_KEY` (minisign is cross-platform). + +### 8 — Frontend platform-aware copy + +- `src/lib/util/platform.ts` (NEW, 31 lines) — navigator.userAgent-based `isMac` / `isLinux`, zero new deps (no `@tauri-apps/plugin-os`). Exports `fileManagerName` ("Finder" / "file manager"), `keyringName` ("macOS Keychain" / "system keyring"), `keyringNameCapitalized`. Defaults to macOS wording under SSR/no-navigator so the static build keeps macOS copy. +- `src/lib/components/Dashboard.svelte:36` imports `isMac`; `:62` error copy and `:807-808` button title/aria-label swap "Reveal in Finder" ↔ "Show in file manager". +- `src/lib/components/SettingsSectionGitHub.svelte:31` imports `keyringName`; `:121` — "brew-browser stores your token in the {keyringName}." +- `src/lib/types.ts:8` imports `keyringNameCapitalized`; `:884` — `KeychainUnavailable` friendly message reads "{keyringNameCapitalized} unavailable: …". + +## What's verified vs unverified + +### ✅ Verified (macOS) + +- `cargo test` — **586 passed**, 0 failed. The per-target cfg-gating compiles cleanly on macOS (the `linux` cfg arms are excluded; the keyring feature resolves to `apple-native`). +- `npm run check` — 0 errors. +- `npm run build` — clean Vite build. +- macOS bundle config, signing/notarization path, and updater manifest output are byte-identical to v0.5.0 — confirmed no macOS regression. + +### ⚠️ Unverified (Linux / CI) + +- The Linux binary has **never run on a real Linux machine.** webkit2gtk can't be cross-compiled from macOS, so only CI produces it. +- The first `linux-build.yml` run has **not been confirmed green** as of this writing. +- No Linux runtime path has been exercised end-to-end: brew detection at the Linuxbrew prefix, GitHub sign-in against a Secret Service daemon, `xdg-open` reveal, an install/upgrade/uninstall round-trip, the vuln scan. The macOS test suite and a CI **compile** do not cover any of these. + +The accurate claim: **Linux build support added; macOS regression-verified; the Linux binary builds in CI but is unproven until a real-Linux smoke test.** + +## Before a Linux release (hard gate) + +- [ ] **CI green** — `linux-build.yml` completes and uploads `.deb`, `.rpm`, `.AppImage`. +- [ ] **Real-Linux smoke test** on an actual Ubuntu 22.04+ box: + - [ ] **brew detection at the Linuxbrew prefix** — app finds `brew` at `/home/linuxbrew/.linuxbrew/bin/brew` (shared) and `~/.linuxbrew/bin/brew` (per-user); Dashboard populates. + - [ ] **GitHub sign-in with a Secret Service daemon running** — Device Flow completes; token persists to the keyring and survives an app relaunch. (Separately confirm the no-daemon case fails with the "keyring unavailable" message and the rest of the app keeps working.) + - [ ] **Show in file manager** — the storage-card button runs `xdg-open` on the parent directory and the system file manager opens there. + - [ ] **Formula install / upgrade / uninstall round-trip** — each shells out correctly with live Activity streaming; state reflects afterward. + - [ ] **Vulnerability scan** — IF `brew vulns` installs cleanly on Linux (`brew install homebrew/brew-vulns/brew-vulns`), the opt-in scan runs and the Exposure card / Security card populate. If `brew vulns` is macOS-only or doesn't install, document that as a Linux coverage gap honestly (same posture as casks). + +Only after both gates pass should a Linux download be advertised. Merging the build-support branch is fine before then; a Linux **release** is not. + +## Notes + +- `github/auth.rs` having **zero changes** is the headline proof that the keyring abstraction held — the entire Linux credential-store difference is expressed in `Cargo.toml` feature flags. +- The `cask_icon` Linux `Ok(None)` short-circuit is the same graceful-degradation posture as the v0.5.0 cask vulnerability-coverage gap: render the row, tell the truth about what's missing, never fake state. +- `publish-manifest.sh` is byte-identical on the macOS-only path — the Linux block is strictly additive and gated on the AppImage + `.sig` both existing, so running it on a Mac build produces exactly the v0.5.0 manifest. +- This work tracks toward v0.6.0; it is build support, not a release. The release decision waits on the smoke test. + +## References + +- ADR: `memory-bank/decisions.md` — "2026-05-28: Linux support via Tauri's native cross-compilation (v0.6.0-track)". +- Smoke-test discipline rationale: `memory-bank/decisions.md` — "2026-05-27: Smoke-test discipline for subprocess-integration features" (the reason CI + a real-Linux smoke test matter before claiming done). +- Tech context: `memory-bank/techContext.md` — "Cross-platform (Linux)" section. +- CI recipe: `.github/workflows/linux-build.yml` (canonical apt deps + build steps). +- Tauri 2 Linux prerequisites: . diff --git a/memory-bank/tasks/2026-05/README.md b/memory-bank/tasks/2026-05/README.md index 8856a2e..2eac0d9 100644 --- a/memory-bank/tasks/2026-05/README.md +++ b/memory-bank/tasks/2026-05/README.md @@ -26,6 +26,7 @@ Per-task records for May 2026 work on brew-browser. **Retroactively reconstructe | 18 | [v0.3.1 release — magic search + curated upgrade + identity cleanup + lists overhaul](./18-v0.3.1-release.md) | 2026-05-25 | `6b97e65` (prep) + `v0.3.1` tag | v0.3.1 | | 19 | [v0.4.0 ship — velocity scoring + opt-in history endpoint (backend + frontend + collector + docs)](./19-v0.4.0-backend.md) | 2026-05-26 | branch `feat/v0.4.0-velocity-and-history` | target v0.4.0 | | 20 | [v0.5.0 ship — opt-in vulnerability scanning via brew vulns + optional GHSA enrichment](./20-v0.5.0-vulnerability-scanning.md) | 2026-05-27 | branch `feat/v0.5.0-vulnerability-scanning` | target v0.5.0 | +| 21 | [Linux build support — keyring cfg-split + Linuxbrew path + cfg-gated OS integrations + CI (macOS-verified, Linux unproven)](./21-linux-support.md) | 2026-05-28 | branch `feat/linux-support` | v0.6.0-track | | 99 | [Deferred / dropped tasks](./99-deferred-and-dropped.md) | (ongoing) | — | — | ## Reconstruction notes diff --git a/memory-bank/techContext.md b/memory-bank/techContext.md index edc8ba9..09075cb 100644 --- a/memory-bank/techContext.md +++ b/memory-bank/techContext.md @@ -93,6 +93,25 @@ Serialize concurrent `brew` invocations using a `tokio::sync::Mutex<()>` in Taur **Casks not supported:** `brew vulns` is formula-only (casks ship vendor binaries with their own update channels; OSV's source-URL approach doesn't map). Cask rows render the same UI shells but the Security card honestly says "Cask coverage isn't supported." +## Cross-platform (Linux) — newly supported on `feat/linux-support` + +macOS remains the primary target. Linux build support was added on the `feat/linux-support` branch. The macOS side is regression-verified (586 Rust tests pass, frontend check clean, Vite build clean); the Linux binary is produced **only in CI** — it cannot be cross-compiled from macOS because webkit2gtk is Linux-native — and is **unproven until a real-Linux smoke test runs**. + +**Keyring per-target feature split.** Tauri's "one codebase, add a target" model means the GitHub-token store needed no API change — the `keyring` crate exposes a unified `keyring::Entry` API (`src-tauri/src/github/auth.rs` is byte-identical, zero changes). Only `src-tauri/Cargo.toml` splits the feature set per target: + +- macOS (`Cargo.toml:87-89`) — `keyring = { features = ["apple-native"] }` (Security framework / Keychain). +- Linux (`Cargo.toml:95-96`) — `keyring = { features = ["sync-secret-service", "crypto-rust"] }`. `sync-secret-service` is the persistent Secret Service backend (gnome-keyring / KWallet via D-Bus, survives reboot — NOT the session-scoped kernel keyutils). `crypto-rust` is pure-Rust AES, so there is **no system OpenSSL build dependency** on CI. + +**Runtime caveat:** Linux needs a Secret Service daemon running for GitHub sign-in. Without one (headless box, minimal WM), sign-in fails via the existing `BrewError::KeychainUnavailable` path (`error.rs:104`) — the rest of the app is unaffected. + +**Linuxbrew path detection.** `src-tauri/src/brew/paths.rs:31-37` checks the shared Linuxbrew prefix `/home/linuxbrew/.linuxbrew/bin/brew` and the per-user `~/.linuxbrew/bin/brew` in addition to the macOS prefixes. Disk-usage and the Finder-reveal security gate derive their paths from `brew --prefix` / `brew --cache` (not a hardcoded `/opt/homebrew`), so the Linux prefix works automatically. + +**Platform-gated OS integrations.** `open_in_finder` keeps its IPC name but cfg-gates its implementation: macOS `open -R ` (reveal-and-select); Linux `xdg-open ` (no portable reveal-and-select verb, so we open the containing directory) — `disk_usage.rs:240-271`. `cask_icon` cfg-gates the macOS `.app`/`sips`/`defaults` extraction pipeline (`cask_icon.rs`, every extraction fn is `#[cfg(target_os = "macos")]`); Linux short-circuits to `Ok(None)` because Linux casks don't produce `.app` bundles. **Casks degrade gracefully** — they still list, install, and get homepage-favicon icons on Linux; they are not removed. + +**Build floor: webkit2gtk-4.1 / Ubuntu 22.04.** CI pins `ubuntu-22.04` (NOT `ubuntu-latest`) so the glibc floor stays fixed — binaries built there run on 22.04+ and newer distros; `ubuntu-latest` would silently drift the floor forward and break older targets. Tauri 2 targets the webkit2gtk-4.1 ABI. + +**CI is the canonical build recipe.** `.github/workflows/linux-build.yml` (on `ubuntu-22.04`) is the single source of truth for the apt dependency list and the build steps. It produces `.deb` / `.rpm` / `.AppImage` and triggers on push to `feat/linux-support`, on `v*` tags, and via manual dispatch. `tauri.conf.json` gained a `bundle.linux` block (deb runtime `depends` + appimage config; `tauri.conf.json:55-66`); the macOS bundle config is untouched. `tools/release/publish-manifest.sh` emits an additional `linux-x86_64` updater platform block when the AppImage + its `.sig` are present, and is byte-identical on the macOS-only path when they are not. Linux artifacts ship **unsigned** for v0 (AppImage unsigned by convention; deb/rpm GPG signing is a documented future step). + ## Known sharp edges - **Tauri sandbox vs. shell execution** — explicit allowlist in `tauri.conf.json` is required to permit any subprocess diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5eba542..9f83475 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -285,6 +296,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -461,6 +481,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.62" @@ -524,6 +553,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -747,6 +786,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -797,6 +854,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1579,6 +1637,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1868,6 +1944,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2047,7 +2133,9 @@ version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ + "dbus-secret-service", "log", + "secret-service", "security-framework 2.11.1", "security-framework 3.7.0", "zeroize", @@ -2275,6 +2363,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2284,12 +2385,76 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2918,7 +3083,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -2965,14 +3130,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -2982,7 +3168,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -3384,6 +3579,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2", + "zbus 4.4.0", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3619,6 +3833,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3759,6 +3984,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -4118,7 +4349,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows", - "zbus", + "zbus 5.15.0", ] [[package]] @@ -5850,6 +6081,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.2" @@ -5873,6 +6114,38 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.15.0" @@ -5903,9 +6176,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.3", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.15.0", + "zbus_names 4.3.2", + "zvariant 5.11.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -5918,9 +6204,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.2", + "zvariant 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -5931,7 +6228,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", "winnow 1.0.3", - "zvariant", + "zvariant 5.11.0", ] [[package]] @@ -5980,6 +6277,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -6032,6 +6343,19 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.11.0" @@ -6042,8 +6366,21 @@ dependencies = [ "enumflags2", "serde", "winnow 1.0.3", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -6056,7 +6393,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fc460bd..3966ffd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -64,12 +64,6 @@ flate2 = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -# OS-native secret storage. Phase 12e (GitHub Device Flow) stores the -# OAuth access token in the macOS Keychain under -# `com.zerologic.brew-browser` / `github_access_token`. The crate fans out to -# Keychain on macOS, Credential Manager on Windows, Secret Service on -# Linux — we only use the macOS path. -keyring = { version = "3", features = ["apple-native"] } # URL-encoded form bodies for the GitHub Device Flow endpoints. # `serde_urlencoded` is already pulled in transitively by `reqwest` # but `url` (parsing) is not. Keep this dep tight. @@ -78,8 +72,28 @@ url = "2" # Native macOS window vibrancy (NSVisualEffectView) — gives the sidebar that # "frosted glass" look the rest of macOS uses. Cross-platform crate; we only # call its macOS APIs. +# +# OS-native secret storage. Phase 12e (GitHub Device Flow) stores the +# OAuth access token under `com.zerologic.brew-browser` / +# `github_access_token`. The `keyring` crate exposes a single unified +# `Entry` / `Error` API (`github/auth.rs::SystemKeychain`) regardless of +# backend, so only the Cargo feature set is platform-specific: +# - macOS → `apple-native` (Security framework / Keychain). +# - Linux → `sync-secret-service` (persistent across reboot, backed by +# gnome-keyring / kwallet via the Secret Service D-Bus API — NOT the +# session-scoped kernel keyutils) + `crypto-rust` (pure-Rust AES, so +# no system OpenSSL build dependency on CI). See the Linux target +# section below. [target.'cfg(target_os = "macos")'.dependencies] window-vibrancy = "0.6" +keyring = { version = "3", features = ["apple-native"] } + +# Linux secret storage. Secret Service requires a running daemon +# (gnome-keyring / kwallet); on a headless box or a minimal WM without +# one, GitHub sign-in fails closed via `BrewError::KeychainUnavailable` +# (the rest of the app is unaffected). See `github/auth.rs`. +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] } [dev-dependencies] # Reserved for future integration tests that need a sandbox dir diff --git a/src-tauri/src/brew/exec.rs b/src-tauri/src/brew/exec.rs index fd30d8d..8a3f6c7 100644 --- a/src-tauri/src/brew/exec.rs +++ b/src-tauri/src/brew/exec.rs @@ -35,6 +35,24 @@ pub type JobsMap = Arc>>; /// /// On non-zero exit, returns `BrewError::BrewExitNonZero` with the last /// ~4 KB of stderr. The `display_command` string is the user-facing form. +/// A directory that is guaranteed to exist and be readable by the +/// invoking user on every supported platform. We set this as the +/// working directory for every `brew` subprocess. +/// +/// **Why:** Homebrew refuses to run when its current working directory +/// isn't readable by the user — on Linux it aborts with "The current +/// working directory must be readable to to run brew." A GUI app +/// inherits whatever cwd it was launched from (the app-launcher's cwd, +/// a stale deleted directory, or — when launched oddly — a root-owned +/// path the user can't read). Pinning every spawn to `/` makes the brew +/// invocation independent of how the app happened to be launched. `/` +/// is world-readable on macOS and Linux and always exists. +/// +/// Discovered during the v0.6.0 Linux bring-up: launching the app from +/// a directory the user couldn't read made every `brew info` fail with +/// the readable-cwd error, surfacing as "Couldn't load packages." +const BREW_SPAWN_CWD: &str = "/"; + pub async fn run_brew_capture( brew_path: &Path, args: &[&str], @@ -42,6 +60,7 @@ pub async fn run_brew_capture( ) -> Result { let mut cmd = Command::new(brew_path); cmd.args(args) + .current_dir(BREW_SPAWN_CWD) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -99,6 +118,7 @@ pub async fn run_brew_streaming( let mut cmd = Command::new(brew_path); cmd.args(&str_args) + .current_dir(BREW_SPAWN_CWD) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/src-tauri/src/brew/paths.rs b/src-tauri/src/brew/paths.rs index 88ddaaa..5cf051e 100644 --- a/src-tauri/src/brew/paths.rs +++ b/src-tauri/src/brew/paths.rs @@ -2,22 +2,43 @@ use std::path::PathBuf; -/// Resolve the path to the `brew` binary by checking the known prefixes -/// (Apple Silicon first, then Intel), then falling back to a PATH lookup. +/// Resolve the path to the `brew` binary by checking the known prefixes, +/// then falling back to a PATH lookup. +/// +/// Order: macOS Apple Silicon (`/opt/homebrew`), macOS Intel +/// (`/usr/local`), Linuxbrew shared install +/// (`/home/linuxbrew/.linuxbrew`), Linuxbrew per-user install +/// (`~/.linuxbrew`). The prefixes are checked unconditionally rather than +/// `#[cfg]`-gated by OS: the `is_file()` guard makes a macOS prefix +/// harmless on Linux (and vice versa), and an explicit list avoids +/// relying on the PATH fallback — a GUI-launched app inherits a minimal +/// PATH that often omits the Homebrew bin dir. /// /// Returns `None` if brew can't be located. Callers should map this to /// `BrewError::BrewNotFound`. pub fn resolve_brew_path() -> Option { - // Apple Silicon default + // macOS Apple Silicon default let arm = PathBuf::from("/opt/homebrew/bin/brew"); if arm.is_file() { return Some(arm); } - // Intel default + // macOS Intel default let intel = PathBuf::from("/usr/local/bin/brew"); if intel.is_file() { return Some(intel); } + // Linuxbrew shared install (default for the official install script). + let linux_shared = PathBuf::from("/home/linuxbrew/.linuxbrew/bin/brew"); + if linux_shared.is_file() { + return Some(linux_shared); + } + // Linuxbrew per-user install (~/.linuxbrew/bin/brew). + if let Some(home) = dirs::home_dir() { + let linux_user = home.join(".linuxbrew").join("bin").join("brew"); + if linux_user.is_file() { + return Some(linux_user); + } + } // PATH fallback if let Ok(path_var) = std::env::var("PATH") { for dir in std::env::split_paths(&path_var) { @@ -69,4 +90,22 @@ mod tests { ); } } + + /// On a Linuxbrew host where the shared install exists, the resolver + /// must return it verbatim. Mirrors the Apple-Silicon test: it can't + /// override the filesystem, so it only asserts when the path is + /// actually present (a no-op on macOS CI / dev hosts). + #[test] + fn resolve_brew_path_finds_linuxbrew_shared_when_present() { + let shared = std::path::PathBuf::from("/home/linuxbrew/.linuxbrew/bin/brew"); + if shared.is_file() { + // The macOS prefixes won't exist on a Linux box, so the + // shared Linuxbrew prefix is the first explicit hit. + assert_eq!( + resolve_brew_path().as_deref(), + Some(shared.as_path()), + "must resolve /home/linuxbrew/.linuxbrew/bin/brew when no macOS prefix exists" + ); + } + } } diff --git a/src-tauri/src/commands/cask_icon.rs b/src-tauri/src/commands/cask_icon.rs index 2c331d4..7f7a607 100644 --- a/src-tauri/src/commands/cask_icon.rs +++ b/src-tauri/src/commands/cask_icon.rs @@ -36,14 +36,24 @@ use std::time::{Duration, SystemTime}; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine as _; use tauri::State; -use tokio::process::Command; -use crate::brew::exec::run_brew_capture; -use crate::brew::parse::RawInfoV2; use crate::commands::info::validate_cask_token; -use crate::error::{truncate_head, BrewError}; +use crate::error::BrewError; use crate::state::AppState; +// macOS-only: the `.app`-bundle icon extraction pipeline (defaults/sips +// shell-outs, brew-info resolution, .icns discovery). These symbols are +// unused on non-macOS targets where `cask_icon` short-circuits to +// `Ok(None)`, so gate them to avoid dead-code warnings on the Linux build. +#[cfg(target_os = "macos")] +use tokio::process::Command; +#[cfg(target_os = "macos")] +use crate::brew::exec::run_brew_capture; +#[cfg(target_os = "macos")] +use crate::brew::parse::RawInfoV2; +#[cfg(target_os = "macos")] +use crate::error::truncate_head; + /// Cache TTL — re-extract if the cached PNG is older than this. const ICON_CACHE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60); @@ -72,27 +82,42 @@ pub async fn cask_icon( return Ok(Some(data_url)); } - // Resolve the .app bundle. None → cask not installed or no `app` - // artifact (common for pkg / binary-only casks). Return Ok(None). - let app_path = match resolve_app_path(&state, &token).await? { - Some(p) => p, - None => return Ok(None), - }; - - // Find the .icns file inside the bundle. None → unbundled app or - // some app shipping non-standard resources. Return Ok(None). - let icns_path = match find_icns(&app_path).await { - Some(p) => p, - None => return Ok(None), - }; - - // Convert .icns → cached PNG. sips failure is a real error - // (sips ships with macOS; missing or crashing it is genuinely - // exceptional). - sips_convert_to_png(&icns_path, &cache_path).await?; - - // Read back, encode, return. - encode_png_as_data_url(&cache_path).await.map(Some) + // `.app`-bundle icon extraction is macOS-only — it shells out to the + // macOS-native `/usr/bin/defaults` and `/usr/bin/sips` and walks the + // `Contents/Resources/*.icns` bundle layout. Linux casks don't + // produce `.app` bundles, so the `IconSource` routing won't even + // select this path; we short-circuit to `Ok(None)` here anyway as + // defense in depth, so we never attempt to spawn binaries that don't + // exist on Linux. + #[cfg(target_os = "macos")] + { + // Resolve the .app bundle. None → cask not installed or no `app` + // artifact (common for pkg / binary-only casks). Return Ok(None). + let app_path = match resolve_app_path(&state, &token).await? { + Some(p) => p, + None => return Ok(None), + }; + + // Find the .icns file inside the bundle. None → unbundled app or + // some app shipping non-standard resources. Return Ok(None). + let icns_path = match find_icns(&app_path).await { + Some(p) => p, + None => return Ok(None), + }; + + // Convert .icns → cached PNG. sips failure is a real error + // (sips ships with macOS; missing or crashing it is genuinely + // exceptional). + sips_convert_to_png(&icns_path, &cache_path).await?; + + // Read back, encode, return. + encode_png_as_data_url(&cache_path).await.map(Some) + } + + #[cfg(not(target_os = "macos"))] + { + Ok(None) + } } // ---------- Cache layer ---------- @@ -144,6 +169,7 @@ fn ensure_dir(dir: &Path) -> Result<(), BrewError> { /// Return the absolute path to the cask's `.app` bundle, or `None` if /// the cask isn't installed / has no `app` artifact. +#[cfg(target_os = "macos")] async fn resolve_app_path( state: &State<'_, AppState>, token: &str, @@ -193,6 +219,7 @@ async fn resolve_app_path( /// Walk `artifacts[].app[]` and return the first string entry. Brew /// sometimes serializes `app` as `["Firefox.app"]`, sometimes as /// `[{"target": "Firefox.app", "source": "..."}]`; handle both. +#[cfg(target_os = "macos")] fn first_app_filename(artifacts: &Option) -> Option { let arr = artifacts.as_ref()?.as_array()?; for entry in arr { @@ -225,6 +252,7 @@ fn first_app_filename(artifacts: &Option) -> Option { /// Given an `.app` filename like `"Firefox.app"`, return the first of /// `/Applications/` or `~/Applications/` that exists. /// Returns `None` if neither does. +#[cfg(target_os = "macos")] fn resolve_app_bundle(filename: &str) -> Option { // Filename safety — must end with `.app` and not contain path // separators (so we don't accidentally resolve into a parent dir). @@ -255,6 +283,7 @@ fn resolve_app_bundle(filename: &str) -> Option { /// /// `read_bundle_icon_file` is unchanged — `defaults read` happily /// reads any plist path; the gate is the *use* of its return value. +#[cfg(target_os = "macos")] async fn find_icns(app_path: &Path) -> Option { let info_plist = app_path.join("Contents").join("Info.plist"); let resources = app_path.join("Contents").join("Resources"); @@ -301,6 +330,7 @@ async fn find_icns(app_path: &Path) -> Option { /// Both sides are canonicalized so that a symlink farm pointing back /// into `resources` from an external location is still detected as a /// traversal — we compare resolved physical paths, not lexical paths. +#[cfg(target_os = "macos")] fn safe_join_in_resources(resources: &Path, candidate: &str) -> Option { // Reject obvious lexical traversal before touching the disk — // canonicalize would either resolve out or fail, but a quick @@ -320,6 +350,7 @@ fn safe_join_in_resources(resources: &Path, candidate: &str) -> Option /// Shell out to `defaults read CFBundleIconFile`. Returns /// `None` when the key is absent or `defaults` exits non-zero (binary /// plists are still readable by `defaults`). +#[cfg(target_os = "macos")] async fn read_bundle_icon_file(info_plist: &Path) -> Option { // `defaults read` wants the path without the trailing `.plist`. let arg = info_plist @@ -343,6 +374,7 @@ async fn read_bundle_icon_file(info_plist: &Path) -> Option { } } +#[cfg(target_os = "macos")] async fn first_icns_in_dir(dir: &Path) -> Option { let mut rd = tokio::fs::read_dir(dir).await.ok()?; // Read all entries first so we can sort for determinism. @@ -361,6 +393,7 @@ async fn first_icns_in_dir(dir: &Path) -> Option { // ---------- sips conversion ---------- +#[cfg(target_os = "macos")] async fn sips_convert_to_png(input: &Path, output: &Path) -> Result<(), BrewError> { // sips: macOS-native, no extra deps. Resize to ICON_PIXELS square. let out = Command::new("/usr/bin/sips") @@ -400,6 +433,8 @@ async fn sips_convert_to_png(input: &Path, output: &Path) -> Result<(), BrewErro mod tests { use super::*; use crate::commands::info::validate_package_name; + // `json!` is only used by the macOS-gated `first_app_filename` tests. + #[cfg(target_os = "macos")] use serde_json::json; // ---------- token validation reuse ---------- @@ -438,8 +473,9 @@ mod tests { assert!(validate_cask_token(".").is_err()); } - // ---------- first_app_filename ---------- + // ---------- first_app_filename (macOS-only: gated with the fn) ---------- + #[cfg(target_os = "macos")] #[test] fn first_app_filename_handles_string_form() { let artifacts = Some(json!([ @@ -448,6 +484,7 @@ mod tests { assert_eq!(first_app_filename(&artifacts).as_deref(), Some("Firefox.app")); } + #[cfg(target_os = "macos")] #[test] fn first_app_filename_handles_object_target_form() { let artifacts = Some(json!([ @@ -459,6 +496,7 @@ mod tests { ); } + #[cfg(target_os = "macos")] #[test] fn first_app_filename_falls_back_to_source_basename() { let artifacts = Some(json!([ @@ -467,6 +505,7 @@ mod tests { assert_eq!(first_app_filename(&artifacts).as_deref(), Some("Some.app")); } + #[cfg(target_os = "macos")] #[test] fn first_app_filename_skips_non_app_artifacts() { let artifacts = Some(json!([ @@ -477,6 +516,7 @@ mod tests { assert_eq!(first_app_filename(&artifacts).as_deref(), Some("Real.app")); } + #[cfg(target_os = "macos")] #[test] fn first_app_filename_returns_none_for_no_artifacts() { assert_eq!(first_app_filename(&None), None); @@ -489,6 +529,7 @@ mod tests { // ---------- resolve_app_bundle ---------- + #[cfg(target_os = "macos")] #[test] fn resolve_app_bundle_rejects_non_app_filenames() { assert_eq!(resolve_app_bundle("Firefox"), None); @@ -496,6 +537,7 @@ mod tests { assert_eq!(resolve_app_bundle(""), None); } + #[cfg(target_os = "macos")] #[test] fn resolve_app_bundle_rejects_path_traversal() { assert_eq!(resolve_app_bundle("../etc/passwd.app"), None); @@ -503,6 +545,7 @@ mod tests { assert_eq!(resolve_app_bundle("sub/dir/Foo.app"), None); } + #[cfg(target_os = "macos")] #[test] fn resolve_app_bundle_returns_none_when_neither_path_exists() { // This filename is overwhelmingly unlikely to be installed. @@ -564,6 +607,7 @@ mod tests { // ---------- M5: safe_join_in_resources ---------- + #[cfg(target_os = "macos")] #[test] fn safe_join_accepts_plain_filename_in_resources() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -576,6 +620,7 @@ mod tests { assert!(resolved.ends_with("AppIcon.icns")); } + #[cfg(target_os = "macos")] #[test] fn safe_join_rejects_dotdot_traversal() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -590,6 +635,7 @@ mod tests { assert!(safe_join_in_resources(&resources, "../../../../etc/passwd.icns").is_none()); } + #[cfg(target_os = "macos")] #[test] fn safe_join_rejects_slash_separated_subpath() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -602,6 +648,7 @@ mod tests { assert!(safe_join_in_resources(&resources, "sub/x.icns").is_none()); } + #[cfg(target_os = "macos")] #[test] fn safe_join_rejects_symlink_pointing_outside_resources() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -620,6 +667,7 @@ mod tests { assert!(r.is_none(), "symlink escape should be rejected, got {:?}", r); } + #[cfg(target_os = "macos")] #[test] fn safe_join_returns_none_for_nonexistent_target() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/src-tauri/src/commands/disk_usage.rs b/src-tauri/src/commands/disk_usage.rs index bb8666f..aea9b54 100644 --- a/src-tauri/src/commands/disk_usage.rs +++ b/src-tauri/src/commands/disk_usage.rs @@ -223,9 +223,25 @@ pub async fn open_in_finder(path: String, state: State<'_, AppState>) -> Result< }); } + reveal_in_file_manager(&path).await +} + +/// Reveal `path` in the platform file manager. The caller has already +/// passed the path through the Homebrew-prefix/cache security gate; this +/// only spawns the reveal. +/// +/// - macOS: `open -R ` selects the item in Finder. +/// - Linux: there is no portable "reveal and select" verb. `xdg-open +/// ` would *launch* the file in its default app (wrong for a +/// "show me where this is" action), so we open the containing +/// directory instead — `xdg-open ` pops the file manager +/// at the right location. A path with no parent (shouldn't happen for +/// gated Homebrew paths) falls back to the path itself. +#[cfg(target_os = "macos")] +async fn reveal_in_file_manager(path: &str) -> Result<(), BrewError> { let status = Command::new("open") .arg("-R") - .arg(&path) + .arg(path) .status() .await .map_err(|e| BrewError::Io { @@ -239,6 +255,25 @@ pub async fn open_in_finder(path: String, state: State<'_, AppState>) -> Result< Ok(()) } +#[cfg(target_os = "linux")] +async fn reveal_in_file_manager(path: &str) -> Result<(), BrewError> { + let target = Path::new(path); + let dir = target.parent().unwrap_or(target); + let status = Command::new("xdg-open") + .arg(dir) + .status() + .await + .map_err(|e| BrewError::Io { + message: format!("failed to spawn xdg-open: {e}"), + })?; + if !status.success() { + return Err(BrewError::Internal { + message: format!("xdg-open exited with {:?}", status.code()), + }); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e05a6f..d1ded55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,7 +63,7 @@ pub fn run() { ) .try_init(); - tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) // Phase 15 — register the updater plugin. The endpoint URL and @@ -73,9 +73,22 @@ pub fn run() { // through `state.require_network("update_check")` first so // Offline Mode kills the path even though the plugin itself // would otherwise try the manifest endpoint. - .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_updater::Builder::new().build()); + + // The native menu is macOS-idiomatic: on macOS it populates the + // global menu bar at the top of the screen. On Linux/GTK there is + // no global menu bar, so Tauri renders it as an in-window GTK + // MenuBar strip — redundant (every action is reachable from the + // in-app UI) and it clashes with the transparent-window config + // (the strip paints see-through). Gate it to macOS so Linux gets a + // clean chromeless window. Discovered during the v0.6.0 Linux + // bring-up. + #[cfg(target_os = "macos")] + let builder = builder .menu(build_app_menu) - .on_menu_event(handle_menu_event) + .on_menu_event(handle_menu_event); + + builder .setup(|app| { state::initialize(app)?; // Phase 15 — spawn the auto-check scheduler. The task diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6098169..f847cc2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -51,6 +51,18 @@ "signingIdentity": "Developer ID Application: Michael Sitarzewski (7JQGQ7CRH8)", "hardenedRuntime": true, "minimumSystemVersion": "13.0" + }, + "linux": { + "deb": { + "depends": [ + "libwebkit2gtk-4.1-0", + "libgtk-3-0", + "libayatana-appindicator3-1" + ] + }, + "appimage": { + "bundleMediaFramework": false + } } } } diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 0000000..7a9e50d --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "title": "brew-browser", + "width": 1100, + "height": 720, + "minWidth": 800, + "minHeight": 500, + "transparent": false + } + ] + } +} diff --git a/src/lib/components/Dashboard.svelte b/src/lib/components/Dashboard.svelte index 1416371..09d7a6b 100644 --- a/src/lib/components/Dashboard.svelte +++ b/src/lib/components/Dashboard.svelte @@ -33,6 +33,7 @@ import { resolveCategoryIcon } from "$lib/util/categoryIcon"; import { brewErrorMessage, isBrewError, type DiskUsageReport } from "$lib/types"; import { reportableToastError } from "$lib/util/reportIssue"; + import { isMac } from "$lib/util/platform"; let disk = $state(null); let diskLoading = $state(false); @@ -58,7 +59,7 @@ try { await openInFinder(path); } catch (e) { - reportableToastError("Couldn't reveal in Finder", e); + reportableToastError(isMac ? "Couldn't reveal in Finder" : "Couldn't open in file manager", e); } } @@ -803,8 +804,8 @@ class="s-open" onclick={() => reveal(e.path)} disabled={!e.exists} - title={e.exists ? `Reveal ${e.path} in Finder` : "Path doesn't exist"} - aria-label={`Reveal ${e.label} in Finder`} + title={e.exists ? (isMac ? `Reveal ${e.path} in Finder` : `Show ${e.path} in file manager`) : "Path doesn't exist"} + aria-label={isMac ? `Reveal ${e.label} in Finder` : `Show ${e.label} in file manager`} > diff --git a/src/lib/components/SettingsSectionGitHub.svelte b/src/lib/components/SettingsSectionGitHub.svelte index aef0f0d..3280ce5 100644 --- a/src/lib/components/SettingsSectionGitHub.svelte +++ b/src/lib/components/SettingsSectionGitHub.svelte @@ -28,6 +28,7 @@ import { settings } from "$lib/stores/settings.svelte"; import { github } from "$lib/stores/github.svelte"; + import { keyringName } from "$lib/util/platform"; onMount(() => { void github.loadStatus(); @@ -117,7 +118,7 @@ What sign-in is used for

- brew-browser stores your token in the macOS Keychain. The token is + brew-browser stores your token in the {keyringName}. The token is never sent over IPC to the renderer, never written to disk, and never logged. Only the derived {`{ signedIn, username, scopes }`} diff --git a/src/lib/types.ts b/src/lib/types.ts index 6c4222c..729d151 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -5,6 +5,8 @@ * `invoke()` returns for each Tauri command. */ +import { keyringNameCapitalized } from "$lib/util/platform"; + // ========================================================= // 2.1 Common enums // ========================================================= @@ -879,7 +881,7 @@ export function brewErrorMessage(e: BrewErrorPayload): string { return `GitHub API rate limit reached. Resets at ${reset}. Sign in to lift the limit.`; } case "keychain_unavailable": - return `macOS Keychain unavailable: ${e.message}`; + return `${keyringNameCapitalized} unavailable: ${e.message}`; case "auth_required": return "Sign in to GitHub to use this feature."; case "scope_required": diff --git a/src/lib/util/platform.ts b/src/lib/util/platform.ts new file mode 100644 index 0000000..c6ec95e --- /dev/null +++ b/src/lib/util/platform.ts @@ -0,0 +1,31 @@ +/** + * Host-OS detection for the renderer — the single source of truth for + * platform-aware user-facing copy (e.g. "Reveal in Finder" on macOS vs + * "Show in file manager" on Linux). + * + * Why navigator-based and not `@tauri-apps/plugin-os`: that plugin is not a + * dependency, and adding one for a handful of label swaps isn't worth the + * weight. The WebView's `navigator.userAgent` faithfully reflects the host OS + * — macOS reports "Macintosh"/"Mac OS X", Linux reports "Linux"/"X11". That's + * more than precise enough for choosing a noun in a button label. + * + * Evaluated once at module load: the host OS does not change mid-session. + */ +const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; + +/** True when running on macOS. Defaults to true under SSR/no-navigator so the + * static build (and any non-WebView render) keeps the macOS wording. */ +export const isMac = ua === "" || /Mac|Macintosh|Mac OS X/i.test(ua); + +/** True when running on Linux (X11/Wayland WebView). */ +export const isLinux = /Linux|X11/i.test(ua) && !isMac; + +/** The OS file manager's name, for interpolation into copy. */ +export const fileManagerName = isMac ? "Finder" : "file manager"; + +/** The OS credential store's name, for mid-sentence interpolation. */ +export const keyringName = isMac ? "macOS Keychain" : "system keyring"; + +/** Sentence-leading form of {@link keyringName} ("macOS Keychain" already + * starts uppercase; "system keyring" becomes "System keyring"). */ +export const keyringNameCapitalized = isMac ? "macOS Keychain" : "System keyring"; diff --git a/tools/release/publish-manifest.sh b/tools/release/publish-manifest.sh index f3d4094..aafb245 100755 --- a/tools/release/publish-manifest.sh +++ b/tools/release/publish-manifest.sh @@ -92,6 +92,16 @@ ARTIFACT_RELEASE_NAME="brew-browser_${VERSION}_aarch64.app.tar.gz" DIST_DIR="$REPO_ROOT/dist" MANIFEST_PATH="$DIST_DIR/updater.json" +# Linux updater artifact. Tauri's Linux updater install path uses the +# .AppImage (NOT the .deb/.rpm), and signs it with the same +# TAURI_SIGNING_PRIVATE_KEY (minisign is cross-platform). The bundler +# stamps the AppImage with the version + arch, so glob for it rather +# than hard-coding the name. This block is OPTIONAL: when running on a +# Mac that only built the .app.tar.gz, no AppImage exists and we emit a +# macOS-only manifest exactly as before. +LINUX_ARTIFACT_PATH="$(ls -t "$REPO_ROOT"/src-tauri/target/release/bundle/appimage/*.AppImage 2>/dev/null | head -1 || true)" +LINUX_ARTIFACT_RELEASE_NAME="brew-browser_${VERSION}_amd64.AppImage" + # ---------- Preflight ---------- if [[ ! -f "$ARTIFACT_PATH" ]]; then @@ -136,6 +146,50 @@ SIGNATURE_JSON="${SIGNATURE_RAW%$'\n'}" SIGNATURE_JSON=$(perl -pe 's/\n/\\n/g' <<< "$SIGNATURE_JSON") SIGNATURE_JSON="${SIGNATURE_JSON%\\n}" +# ---------- Linux platform block (conditional) ---------- + +# Build the linux-x86_64 platform entry only when the AppImage + its +# .sig are both present. Absent (Mac-only build) → LINUX_BLOCK stays +# empty and the manifest is macOS-only, byte-for-byte as before. +LINUX_BLOCK="" +if [[ -n "$LINUX_ARTIFACT_PATH" && -f "$LINUX_ARTIFACT_PATH" ]]; then + LINUX_SIGNATURE_FILE="${LINUX_ARTIFACT_PATH}.sig" + if [[ ! -f "$LINUX_SIGNATURE_FILE" ]]; then + echo "error: found AppImage but its signature is missing:" >&2 + echo " $LINUX_SIGNATURE_FILE" >&2 + echo " Tauri produces this when TAURI_SIGNING_PRIVATE_KEY[_PATH] is set" >&2 + echo " during the Linux 'npm run tauri build'. Re-run with signing env." >&2 + exit 2 + fi + + echo "info: computing SHA-256 of $(basename "$LINUX_ARTIFACT_PATH")..." >&2 + LINUX_SHA256=$(shasum -a 256 "$LINUX_ARTIFACT_PATH" | awk '{print $1}') + echo "info: linux sha256 = $LINUX_SHA256" >&2 + + # Same single-line Tauri .sig format + defensive newline-escaping as + # the macOS signature above. + LINUX_SIGNATURE_RAW=$(cat "$LINUX_SIGNATURE_FILE") + LINUX_SIGNATURE_JSON="${LINUX_SIGNATURE_RAW%$'\n'}" + LINUX_SIGNATURE_JSON=$(perl -pe 's/\n/\\n/g' <<< "$LINUX_SIGNATURE_JSON") + LINUX_SIGNATURE_JSON="${LINUX_SIGNATURE_JSON%\\n}" + + LINUX_URL="https://github.com/msitarzewski/brew-browser/releases/download/v${VERSION}/${LINUX_ARTIFACT_RELEASE_NAME}" + + # Leading comma + newline so it appends cleanly after the + # darwin-aarch64 entry inside the "platforms" object. + LINUX_BLOCK=$(cat <&2 +fi + # ---------- Emit manifest ---------- mkdir -p "$DIST_DIR" @@ -157,7 +211,7 @@ cat > "$MANIFEST_PATH" <