From cc288e9f3117841bb15d12c2e3c51473000cff64 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Fri, 14 Aug 2026 13:32:43 +0800 Subject: [PATCH 1/2] [fix] clear the SonarCloud board: supply-chain hardening in CI, native controls in the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflows - Pin every third-party action to a full commit SHA (dtolnay/rust-toolchain, Swatinem/rust-cache, oven-sh/setup-bun, tauri-apps/tauri-action, softprops/action-gh-release). rust-toolchain derives its channel from `github.action_ref`, which a SHA pin turns into the SHA — so each use now names `toolchain: stable` explicitly. - `bun install` gets `--ignore-scripts`; nothing in the tree needs a lifecycle script (esbuild and the tauri CLI ship platform binaries as optional deps). - `cargo build/test/clippy/run` get `--locked`, so CI fails on a stale lockfile instead of silently resolving a different tree. - release.yml drops to `contents: read` at the workflow level; only the `release` job raises itself to write. The three jobs that handle the signing secrets now carry a token that cannot publish. Scripts - macos-keychain.sh fetches Apple's CA anchors with `--proto '=https' --proto-redir '=https' --tlsv1.2`; `-L` would otherwise follow a redirect into cleartext. - `[` -> `[[` throughout bump-version.sh and macos-keychain.sh. Panel - The tool card, the profile-row switch target, the copyable command and the detail scrim are all real @@ -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