Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 54 additions & 24 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
31 changes: 22 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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 }}
Expand Down
6 changes: 5 additions & 1 deletion app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
</style>
Expand Down
4 changes: 2 additions & 2 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export default function App() {
</span>
<span className="header-right">
{refreshedAt && <span className="stamp">{clockTime(refreshedAt)}</span>}
<button className="action" onClick={() => void refresh()} disabled={refreshing}>
<button type="button" className="action" onClick={() => void refresh()} disabled={refreshing}>
{refreshing ? <span className="spinner" /> : null}
refresh
</button>
Expand Down Expand Up @@ -221,7 +221,7 @@ export default function App() {
{statuses && all.length > 0 && shown.length === 0 && (
<div className="empty">
<p className="empty-line">no tools match</p>
<button className="action" onClick={() => setFilters(NO_FILTERS)}>
<button type="button" className="action" onClick={() => setFilters(NO_FILTERS)}>
clear filters
</button>
</div>
Expand Down
5 changes: 4 additions & 1 deletion app/src/components/Chip.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={`chip chip-${level}`} title={expiresAt ?? "this tool does not expose an expiry"}>
Expand Down
15 changes: 11 additions & 4 deletions app/src/components/Copyable.tsx
Original file line number Diff line number Diff line change
@@ -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 <button> rather than a <code> with an onClick: copying is an action, so it
* has to answer to Enter and Space and to announce itself as something you can
* press. The monospace face comes from `.copyable`, and the text inside stays
* selectable, so nothing is lost by dropping the <code> element.
*/
export function Copyable({ text }: Readonly<{ text: string }>) {
const [copied, setCopied] = useState(false);

const copy = async () => {
Expand All @@ -15,9 +22,9 @@ export function Copyable({ text }: { text: string }) {
};

return (
<code className="copyable" onClick={copy} title="click to copy">
<button type="button" className="copyable" onClick={copy} title="click to copy">
<span className="copyable-text">{text}</span>
<span className="copyable-tag">{copied ? "copied" : "copy"}</span>
</code>
</button>
);
}
11 changes: 8 additions & 3 deletions app/src/components/McpView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,7 @@ export function McpView() {
<li key={c.client} className={c.present ? "" : "is-absent"}>
<span className="path-label">{c.label}</span>
<span className="path-file">{c.config_path}</span>
<span className="path-count">
{c.present ? `${c.servers.length} server${c.servers.length === 1 ? "" : "s"}` : "—"}
</span>
<span className="path-count">{serverCount(c)}</span>
</li>
))}
</ul>
Expand Down Expand Up @@ -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"}`;
}
6 changes: 6 additions & 0 deletions app/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const Sidebar = forwardRef<HTMLInputElement, Props>(function Sidebar(
<ul className="side-list">
<li>
<button
type="button"
className={`side-item${onBoard && filters.category === null ? " is-on" : ""}`}
onClick={() => filter({ ...filters, category: null })}
>
Expand All @@ -78,6 +79,7 @@ export const Sidebar = forwardRef<HTMLInputElement, Props>(function Sidebar(
{categories.map((c) => (
<li key={c}>
<button
type="button"
className={`side-item${onBoard && filters.category === c ? " is-on" : ""}`}
onClick={() => filter({ ...filters, category: filters.category === c ? null : c })}
>
Expand All @@ -94,6 +96,7 @@ export const Sidebar = forwardRef<HTMLInputElement, Props>(function Sidebar(
<ul className="side-list">
<li>
<button
type="button"
className={`side-item${onBoard && filters.state === null ? " is-on" : ""}`}
onClick={() => filter({ ...filters, state: null })}
>
Expand All @@ -106,6 +109,7 @@ export const Sidebar = forwardRef<HTMLInputElement, Props>(function Sidebar(
return (
<li key={s}>
<button
type="button"
className={`side-item${onBoard && filters.state === s ? " is-on" : ""}${n === 0 ? " is-void" : ""}`}
onClick={() => filter({ ...filters, state: filters.state === s ? null : s })}
disabled={n === 0}
Expand All @@ -126,6 +130,7 @@ export const Sidebar = forwardRef<HTMLInputElement, Props>(function Sidebar(
<ul className="side-list">
<li>
<button
type="button"
className={`side-item${view === "keys" ? " is-on" : ""}`}
onClick={() => onView(view === "keys" ? "board" : "keys")}
aria-pressed={view === "keys"}
Expand All @@ -138,6 +143,7 @@ export const Sidebar = forwardRef<HTMLInputElement, Props>(function Sidebar(
</li>
<li>
<button
type="button"
className={`side-item${view === "mcp" ? " is-on" : ""}`}
onClick={() => onView(view === "mcp" ? "board" : "mcp")}
aria-pressed={view === "mcp"}
Expand Down
Loading
Loading