diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fcb6bf..017b659 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,25 +24,30 @@ jobs: steps: - uses: actions/checkout@v4 + # `toolchain:` is not decoration. rust-toolchain defaults it to + # `github.action_ref`, which is how `@stable` normally selects the + # channel — pinned to a SHA that default becomes the SHA, so the channel + # has to be named here or the action fails to resolve one. - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: + toolchain: stable components: rustfmt, clippy - name: Cache cargo - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - name: Format run: cargo fmt --all --check - name: Clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets --locked -- -D warnings - name: Test - run: cargo test --workspace + run: cargo test --workspace --locked - name: Build - run: cargo build --workspace + run: cargo build --workspace --locked # The Tauri panel. `app/src-tauri` is excluded from the root workspace and # carries its own lockfile, so it gets its own checks and its own cache. @@ -53,23 +58,28 @@ jobs: - uses: actions/checkout@v4 - name: Install bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: + toolchain: stable components: clippy - name: Cache cargo (src-tauri) - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: app/src-tauri key: src-tauri + # --ignore-scripts: nothing in the dependency tree has a lifecycle script + # this build needs (the platform binaries for esbuild and the tauri CLI + # arrive as optional deps, not as postinstall downloads), so running them + # would only widen what a compromised package can reach. - name: Install front-end deps - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts working-directory: app # `bun run build` is `tsc && vite build`: typecheck plus the production @@ -87,27 +97,47 @@ jobs: # See the rule in CONTRIBUTING.md. - name: Logo check (every tool has a mark) run: | - cargo run -q -p patchbay-cli -- status --json \ + cargo run -q --locked -p patchbay-cli -- status --json \ | bun scripts/ci/logo-check.ts - name: Clippy (src-tauri) - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --all-targets --locked -- -D warnings working-directory: app/src-tauri - name: Build (src-tauri) - run: cargo build + run: cargo build --locked working-directory: app/src-tauri - # TODO(linux): the probes read macOS-shaped paths (~/Library/..., Keychain - # notes), so a Linux job would fail on path assumptions rather than on real - # regressions. Once paths.rs grows an XDG branch, turn this on for - # core-only tests: + # patchbay-core on Linux, tests only. # - # linux-core: - # name: core (Linux) - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # - uses: dtolnay/rust-toolchain@stable - # - uses: Swatinem/rust-cache@v2 - # - run: cargo test -p patchbay-core + # The paths the probes read are macOS-shaped (~/Library/..., Keychain notes) + # and that is a deliberate product decision, not an accident — but it lives + # in the *data*, not in the build: paths.rs has no `cfg(target_os)` branch, + # it builds every location as a plain string under a home directory the + # caller supplies, and the suite supplies a synthetic one. Nothing in core + # shells out to a macOS binary at test time either; the Keychain calls in + # keystore.rs are covered through MemoryKeystore. + # + # So the core suite is portable today, and this job is what keeps it that + # way — it fails the moment someone reaches for a real $HOME, a `security` + # invocation or a platform cfg inside core. It is not a claim that patchbay + # runs on Linux; `pb`, the panel and the probes are still macOS-only, which + # is why only this one crate is built here. + linux-core: + name: core (Linux) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: stable + + - name: Cache cargo + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: linux-core + + - name: Test + run: cargo test -p patchbay-core --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ae4f6b..3046902 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,12 @@ on: push: tags: ["v*"] +# Read-only by default. Only `release` actually creates anything on the repo, +# and it raises this to `contents: write` for itself — so the three jobs that +# merely build (and that handle the signing secrets) carry a token that cannot +# publish a release even if something in the toolchain gets hold of it. permissions: - contents: write + contents: read env: CARGO_TERM_COLOR: always @@ -50,12 +54,15 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: + # Pinned by SHA, so the channel cannot be inferred from the ref the + # way `@stable` does it — see the note in ci.yml. + toolchain: stable targets: ${{ matrix.target }} - name: Cache cargo - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: key: release-${{ matrix.target }} @@ -139,23 +146,26 @@ jobs: - uses: actions/checkout@v4 - name: Install bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest - name: Install Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: + toolchain: stable targets: aarch64-apple-darwin,x86_64-apple-darwin - name: Cache cargo (src-tauri) - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: app/src-tauri key: release-src-tauri + # No lifecycle scripts — see the note on the same step in ci.yml. It + # matters more here: this job holds the signing secrets. - name: Install front-end deps - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts working-directory: app - name: Import signing certificate @@ -186,7 +196,7 @@ jobs: echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV" - name: Build bundle - uses: tauri-apps/tauri-action@v0 + uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # APPLE_SIGNING_IDENTITY / APPLE_TEAM_ID / APPLE_API_* arrive from @@ -319,6 +329,9 @@ jobs: name: publish release needs: [verify, binaries, app] runs-on: ubuntu-latest + # The one job that writes: it creates the GitHub release and uploads to it. + permissions: + contents: write steps: - uses: actions/checkout@v4 @@ -339,7 +352,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Publish - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: name: patchbay ${{ github.ref_name }} body: ${{ steps.notes.outputs.body }} diff --git a/app/index.html b/app/index.html index 3150174..8958b97 100644 --- a/app/index.html +++ b/app/index.html @@ -23,11 +23,15 @@ text-align: center; font: 12px/1.5 ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; } + /* One shade darker than the app's --muted: on #f1f2f0 that token lands at + 4.1:1, just under WCAG AA for body text, and this line is the only + thing on screen while it shows. The dark pairing above already clears + AA at 5.3:1. */ @media (prefers-color-scheme: light) { html, body { background: #f1f2f0; - color: #6b7778; + color: #5a6667; } } diff --git a/app/src/App.tsx b/app/src/App.tsx index ec8d978..fd67b9c 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -178,7 +178,7 @@ export default function App() { {refreshedAt && {clockTime(refreshedAt)}} - @@ -221,7 +221,7 @@ export default function App() { {statuses && all.length > 0 && shown.length === 0 && (

no tools match

-
diff --git a/app/src/components/Chip.tsx b/app/src/components/Chip.tsx index 6dc9789..37ff2f2 100644 --- a/app/src/components/Chip.tsx +++ b/app/src/components/Chip.tsx @@ -1,7 +1,10 @@ import { countdown, levelOf } from "../expiry"; /** Expiry chip: severity by colour, human countdown as text. */ -export function ExpiryChip({ expiresAt, now }: { expiresAt: string | null; now: number }) { +export function ExpiryChip({ + expiresAt, + now, +}: Readonly<{ expiresAt: string | null; now: number }>) { const level = levelOf(expiresAt, now); return ( diff --git a/app/src/components/Copyable.tsx b/app/src/components/Copyable.tsx index e1833cd..4ff3283 100644 --- a/app/src/components/Copyable.tsx +++ b/app/src/components/Copyable.tsx @@ -1,7 +1,14 @@ import { useState } from "react"; -/** A shell command you are meant to run yourself. Click copies it. */ -export function Copyable({ text }: { text: string }) { +/** + * A shell command you are meant to run yourself. Click copies it. + * + * A ); } diff --git a/app/src/components/McpView.tsx b/app/src/components/McpView.tsx index 6f80dc0..6dbe82d 100644 --- a/app/src/components/McpView.tsx +++ b/app/src/components/McpView.tsx @@ -128,9 +128,7 @@ export function McpView() {
  • {c.label} {c.config_path} - - {c.present ? `${c.servers.length} server${c.servers.length === 1 ? "" : "s"}` : "—"} - + {serverCount(c)}
  • ))} @@ -160,3 +158,10 @@ function transportOf(s: McpServerEntry): string { } return `${s.transport} ${s.url}`; } + +/** "3 servers" for a client whose config exists, an em dash for one that does not. */ +function serverCount(client: McpClient): string { + if (!client.present) return "—"; + const n = client.servers.length; + return `${n} server${n === 1 ? "" : "s"}`; +} diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index 3e5df02..0d17c5f 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -68,6 +68,7 @@ export const Sidebar = forwardRef(function Sidebar(
    • ); } diff --git a/app/src/components/ToolDetail.tsx b/app/src/components/ToolDetail.tsx index 89bf6f8..651c1d6 100644 --- a/app/src/components/ToolDetail.tsx +++ b/app/src/components/ToolDetail.tsx @@ -1,11 +1,12 @@ -import { useEffect, type KeyboardEvent } from "react"; +import { useEffect } from "react"; import { countdown, levelOf } from "../expiry"; import { profileMatches } from "../filters"; -import { metaEntries, rowKey, verdictText, type Panel } from "../panel"; +import { metaEntries, rowKey, verdictText, type Panel, type SwitchNote } from "../panel"; import { KEY_EXPIRY_LABEL, KEY_EXPIRY_LEVEL, PERMISSIONS_TOOLS, + type PermissionsReport, type Profile, type ToolStatus, } from "../types"; @@ -23,17 +24,16 @@ export function ToolDetail({ query, wantPermissions, onClose, -}: { +}: Readonly<{ status: ToolStatus; panel: Panel; /** The board's search text; matching profiles are marked here too. */ query: string; wantPermissions: boolean; onClose: () => void; -}) { +}>) { const report = panel.perms[status.tool]; const note = panel.switchNotes[status.tool]; - const hasScopeReader = PERMISSIONS_TOOLS.has(status.tool); useEffect(() => { // `globalThis.` because the React KeyboardEvent type is in scope here. @@ -50,17 +50,22 @@ export function ToolDetail({ return ( <> -
      -
    )} - {note && - (note.hint ? ( - /* The one case the panel genuinely cannot do for you: this kind - of switch is an environment variable in *your* shell, and no - child process can reach back and set it. So say that plainly, - then hand over the line — a copyable command is a last resort - here, not the panel's answer to things it could have run. */ -
    - - {note.text} patchbay cannot do this one for you: it changes a variable in the - shell that launched it, and a program cannot reach back into its parent shell. - - -
    - ) : ( -
    - {note.text} -
    - ))} + {note && } {/* Vault keys that belong beside this tool's login. Metadata only — @@ -133,104 +120,154 @@ export function ToolDetail({ )} -
    - permissions - {/* States what it knows, then offers the action that gets more. The - button is always here: even where patchbay has no scope reader, - asking and reporting the answer beats a sentence that just says - no and gives you nothing to press. */} -
    - - {report === undefined && ( - - {hasScopeReader - ? `asks ${status.tool} what this credential carries` - : `no scope reader for ${status.tool} yet — most permissions live server-side, per resource`} - - )} -
    - {report && ( -
    - {report.subject && ( -
    - subject - {report.subject} -
    - )} - {report.supported ? ( - report.scopes.length ? ( -
    - {report.scopes.map((s) => ( - - {s} - - ))} -
    - ) : ( -

    the tool reported no scopes

    - ) - ) : ( -

    not supported for this tool

    - )} - {report.notes.length > 0 && ( -
      - {report.notes.map((n, i) => ( -
    • - - {n} -
    • - ))} -
    - )} - {report.hint && } -
    - )} -
    + {status.notes.length > 0 && (
    notes -
      - {status.notes.map((n, i) => ( -
    • - - {n} -
    • - ))} -
    +
    )} - + ); } +function subtitle(status: ToolStatus): string { + if (!status.installed) return "not installed"; + const n = status.profiles.length; + return `${n} profile${n === 1 ? "" : "s"}`; +} + +/** + * The notes core attached to a tool or a permission report. Keyed by the text + * itself — a note has no id, and its content is what makes it that note, so it + * survives reordering in a way an array index does not. + */ +function NoteList({ tool, notes }: Readonly<{ tool: string; notes: readonly string[] }>) { + return ( +
      + {notes.map((n) => ( +
    • + + {n} +
    • + ))} +
    + ); +} + +function SwitchNoteBlock({ note }: Readonly<{ note: SwitchNote }>) { + if (!note.hint) { + return ( +
    + {note.text} +
    + ); + } + // The one case the panel genuinely cannot do for you: this kind of switch is + // an environment variable in *your* shell, and no child process can reach + // back and set it. So say that plainly, then hand over the line — a copyable + // command is a last resort here, not the panel's answer to things it could + // have run. + return ( +
    + + {note.text} patchbay cannot do this one for you: it changes a variable in the shell that + launched it, and a program cannot reach back into its parent shell. + + +
    + ); +} + +/** + * States what it knows, then offers the action that gets more. The button is + * always here: even where patchbay has no scope reader, asking and reporting + * the answer beats a sentence that just says no and gives you nothing to press. + */ +function PermissionsSection({ + tool, + report, + panel, +}: Readonly<{ tool: string; report: PermissionsReport | null | undefined; panel: Panel }>) { + const loading = report === null; + const hasScopeReader = PERMISSIONS_TOOLS.has(tool); + + let readLabel: string; + if (loading) readLabel = "reading…"; + else if (report) readLabel = "re-read scopes"; + else readLabel = "read scopes"; + + return ( +
    + permissions +
    + + {report === undefined && ( + + {hasScopeReader + ? `asks ${tool} what this credential carries` + : `no scope reader for ${tool} yet — most permissions live server-side, per resource`} + + )} +
    + {report && ( +
    + {report.subject && ( +
    + subject + {report.subject} +
    + )} + + {report.notes.length > 0 && } + {report.hint && } +
    + )} +
    + ); +} + +function Scopes({ report }: Readonly<{ report: PermissionsReport }>) { + if (!report.supported) return

    not supported for this tool

    ; + if (report.scopes.length === 0) return

    the tool reported no scopes

    ; + return ( +
    + {report.scopes.map((s) => ( + + {s} + + ))} +
    + ); +} + function ProfileRow({ profile, active, matched, tool, panel, -}: { +}: Readonly<{ profile: Profile; active: boolean; matched: boolean; tool: string; panel: Panel; -}) { +}>) { const entries = metaEntries(profile.meta); const level = levelOf(profile.expires_at, panel.now); const key = rowKey(tool, profile.id); const busy = panel.switching === key; - const verdict = panel.verdicts[key]; - const verifying = verdict === null; // Switching is the whole point of a profile row, so the row *is* the button. // A 60px target next to a 380px row of the same information reads as the @@ -243,88 +280,124 @@ function ProfileRow({ if (!armed || blocked) return; panel.switchTo(tool, profile.id); }; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - go(); - } - }; + + const classes = [ + "dprofile", + active && "is-active", + matched && "is-match", + armed && "is-armed", + busy && "is-busy", + ] + .filter(Boolean) + .join(" "); return ( -
  • - {/* The row's information *is* the switch target. The two affordances in - the foot are real buttons and live outside it, so nothing interactive - is nested inside anything else interactive. */} -
    + {/* The row's information *is* the switch target, so the body is a real +
    - -
    - - - {/* idle → running → result, and the result stays until the next - refresh. Never a raw error dump: `verdictText` is core's sentence. */} - {verdict && ( - - - {verdict.result === "valid" ? "✓" : verdict.result === "invalid" ? "✗" : "—"} - - {verdictText(verdict)} )} + - {armed && ( - - )} -
    +
  • ); } + +function ProfileFoot({ + tool, + profile, + armed, + busy, + blocked, + go, + panel, +}: Readonly<{ + tool: string; + profile: Profile; + armed: boolean; + busy: boolean; + blocked: boolean; + go: () => void; + panel: Panel; +}>) { + const verdict = panel.verdicts[rowKey(tool, profile.id)]; + const verifying = verdict === null; + + let verifyLabel: string; + if (verifying) verifyLabel = "checking…"; + else if (verdict) verifyLabel = "re-check"; + else verifyLabel = "verify"; + + return ( +
    + + + {/* idle → running → result, and the result stays until the next + refresh. Never a raw error dump: `verdictText` is core's sentence. */} + {verdict && ( + + {verdictMark(verdict.result)} + {verdictText(verdict)} + + )} + + {armed && ( + + )} +
    + ); +} + +function verdictMark(result: string): string { + if (result === "valid") return "✓"; + if (result === "invalid") return "✗"; + return "—"; +} diff --git a/app/src/components/ToolLogo.tsx b/app/src/components/ToolLogo.tsx index c21beb5..e3bf2ad 100644 --- a/app/src/components/ToolLogo.tsx +++ b/app/src/components/ToolLogo.tsx @@ -325,7 +325,7 @@ const MARKS: Record = { }, }; -export function ToolLogo({ tool, size = 20 }: { tool: string; size?: number }) { +export function ToolLogo({ tool, size = 20 }: Readonly<{ tool: string; size?: number }>) { const mark = MARKS[tool]; const style = { width: size, @@ -338,8 +338,10 @@ export function ToolLogo({ tool, size = 20 }: { tool: string; size?: number }) { return ( diff --git a/app/src/components/UpdateBanner.tsx b/app/src/components/UpdateBanner.tsx index efe3d27..2d3a418 100644 --- a/app/src/components/UpdateBanner.tsx +++ b/app/src/components/UpdateBanner.tsx @@ -51,11 +51,11 @@ export function UpdateBanner() { {failure ?? (installing ? updatingLabel(progress) : `patchbay ${update.version} is available`)}
    - - diff --git a/app/src/expiry.ts b/app/src/expiry.ts index 2d324ae..73b6b58 100644 --- a/app/src/expiry.ts +++ b/app/src/expiry.ts @@ -44,7 +44,9 @@ export function headlineExpiry(status: ToolStatus): string | null { const known = status.profiles .map((p) => p.expires_at) .filter((e): e is string => Boolean(e)) - .sort(); + // Explicit comparator: the default sort compares UTF-16 code units, which + // happens to be right for RFC 3339 stamps but says so nowhere. + .sort((a, b) => a.localeCompare(b)); return known[0] ?? null; } diff --git a/app/src/styles.css b/app/src/styles.css index 21fa516..c53b414 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -431,6 +431,13 @@ button { grid-template-rows: auto 1fr auto; align-content: space-between; min-width: 0; + /* A