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)}}
-
-
-
+
+
);
}
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 (
<>
-
-
+
>
);
}
+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 (
+
+ );
+ }
+ // 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
+
+ panel.loadPerms(tool)}
+ disabled={loading}
+ >
+ {loading ? : null}
+ {readLabel}
+
+ {report === undefined && (
+
+ {hasScopeReader
+ ? `asks ${tool} what this credential carries`
+ : `no scope reader for ${tool} yet — most permissions live server-side, per resource`}
+
+ )}
+
+ );
+}
+
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
+ — which is also why everything inside it is phrasing content
+ (spans rather than
/
): a button may not contain flow content,
+ and the classes carry the layout either way. The two affordances in
+ the foot are separate buttons and live outside it, so nothing
+ interactive is nested inside anything else interactive. */}
+
-
+ panel.verifyRow(tool, profile.id)}
+ disabled={verifying}
+ >
+ {verifying ? : null}
+ {verifyLabel}
+
+
+ {/* 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 && (
+
+ {busy ? (
+ <>
+
+ {" switching…"}
+ >
+ ) : (
+ <>
+ switch →
+ >
+ )}
+
+ )}
+
+ );
+}
+
+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`)}
-
+
{installing ? : null}
update and relaunch
- setDismissed(true)} disabled={installing}>
+ setDismissed(true)} disabled={installing}>
not now
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 , so: full width of its grid cell, text left, no UA chrome.
+ `font`/`color` already come from the global button reset. */
+ width: 100%;
+ margin: 0;
+ text-align: left;
+ -webkit-appearance: none;
+ appearance: none;
padding: 12px 13px;
border: 1px solid var(--line);
border-radius: 10px;
@@ -634,6 +641,12 @@ button {
display: flex;
align-items: flex-start;
gap: 8px;
+ /* A wearing a code face: reset the UA's centring and width. */
+ width: 100%;
+ margin: 0;
+ text-align: left;
+ -webkit-appearance: none;
+ appearance: none;
padding: 4px 6px;
border: 1px solid var(--line);
border-radius: 4px;
@@ -865,6 +878,7 @@ dialog.detail {
border: 1px solid var(--line-soft);
border-radius: 8px;
background: var(--panel-2);
+ transition: border-color 90ms linear, background 90ms linear;
}
.dprofile.is-active {
@@ -904,6 +918,9 @@ dialog.detail {
}
.dprofile-id {
+ /* A now — the switch target is a , which may only contain
+ phrasing content — so the block box it used to get from
is set here. */
+ display: block;
margin-top: 3px;
font-family: var(--mono);
font-size: 10px;
@@ -920,13 +937,22 @@ dialog.detail {
/* Switching *is* what a profile row is for, so the row's information is the
target — not a 60px button beside it. The whole row lights up; the foot
- names the actions. The active row has nothing to switch to and stays inert. */
-.dprofile {
- transition: border-color 90ms linear, background 90ms linear;
-}
+ names the actions. The active row has nothing to switch to and stays inert.
+ The body is a real , so its chrome is reset back to the block of
+ text it looks like: a UA button is a centred inline-block with its own
+ border, padding and background, none of which a full-width row wants. */
.dprofile-body {
+ display: block;
+ width: 100%;
min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ text-align: left;
+ -webkit-appearance: none;
+ appearance: none;
}
.dprofile.is-armed .dprofile-body {
@@ -939,7 +965,7 @@ dialog.detail {
background: var(--panel);
}
-.dprofile.is-armed .dprofile-body[aria-disabled="true"] {
+.dprofile.is-armed .dprofile-body:disabled {
cursor: default;
}
diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh
index f48bcdc..5e048c5 100755
--- a/scripts/bump-version.sh
+++ b/scripts/bump-version.sh
@@ -47,10 +47,10 @@ collect() {
tauri_version="$(read_tauri_conf)"
pkg_version="$(read_app_pkg)"
- [ -n "$root_version" ] || die "no version found in $root_cargo"
- [ -n "$app_cargo_version" ] || die "no version found in $app_cargo"
- [ -n "$tauri_version" ] || die "no version found in $tauri_conf"
- [ -n "$pkg_version" ] || die "no version found in $app_pkg"
+ [[ -n "$root_version" ]] || die "no version found in $root_cargo"
+ [[ -n "$app_cargo_version" ]] || die "no version found in $app_cargo"
+ [[ -n "$tauri_version" ]] || die "no version found in $tauri_conf"
+ [[ -n "$pkg_version" ]] || die "no version found in $app_pkg"
}
report() {
@@ -65,16 +65,16 @@ check() {
local expected="${1:-}"
collect
- if [ "$root_version" != "$app_cargo_version" ] ||
- [ "$root_version" != "$tauri_version" ] ||
- [ "$root_version" != "$pkg_version" ]; then
+ if [[ "$root_version" != "$app_cargo_version" ]] ||
+ [[ "$root_version" != "$tauri_version" ]] ||
+ [[ "$root_version" != "$pkg_version" ]]; then
echo "bump-version: versions disagree:" >&2
report >&2
echo "run: scripts/bump-version.sh " >&2
exit 1
fi
- if [ -n "$expected" ] && [ "$expected" != "$root_version" ]; then
+ if [[ -n "$expected" ]] && [[ "$expected" != "$root_version" ]]; then
die "expected version $expected, but the tree says $root_version"
fi
@@ -119,7 +119,7 @@ bump() {
case "${1:-}" in
"" | -h | --help)
sed -n '2,25p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
- [ -n "${1:-}" ] || exit 1
+ [[ -n "${1:-}" ]] || exit 1
;;
--check)
check "${2:-}"
diff --git a/scripts/ci/macos-keychain.sh b/scripts/ci/macos-keychain.sh
index d05dbeb..b07e733 100755
--- a/scripts/ci/macos-keychain.sh
+++ b/scripts/ci/macos-keychain.sh
@@ -60,7 +60,7 @@ do_import() {
line="${line#"${line%%[![:space:]]*}"}" # leading whitespace
line="${line%\"}"
line="${line#\"}"
- [ -n "$line" ] && original_keychains+=("$line")
+ [[ -n "$line" ]] && original_keychains+=("$line")
done < <(security list-keychains -d user)
printf '%s\n' "${original_keychains[@]}" >"$orig_list_file"
security default-keychain -d user | tr -d '" ' >"$orig_default_file" || true
@@ -90,9 +90,11 @@ do_import() {
# The exported .p12 holds only the leaf cert, not Apple's intermediate/root.
# Runners normally already have them; import best-effort and tolerate the
# non-zero "already exists" so this does not abort under `set -e`.
- curl -fsSL -o "$state_dir/DeveloperIDG2CA.cer" \
+ curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
+ -o "$state_dir/DeveloperIDG2CA.cer" \
https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer || true
- curl -fsSL -o "$state_dir/AppleRoot.cer" \
+ curl -fsSL --proto '=https' --proto-redir '=https' --tlsv1.2 \
+ -o "$state_dir/AppleRoot.cer" \
https://www.apple.com/appleca/AppleIncRootCertificate.cer || true
security import "$state_dir/DeveloperIDG2CA.cer" -k "$keychain_path" 2>/dev/null || true
security import "$state_dir/AppleRoot.cer" -k "$keychain_path" 2>/dev/null || true
@@ -122,22 +124,22 @@ do_import() {
do_cleanup() {
# Best-effort throughout: this runs with `if: always()`, including after a
# failure that happened before the keychain ever existed.
- if [ -f "$orig_default_file" ]; then
+ if [[ -f "$orig_default_file" ]]; then
local original_default
original_default="$(cat "$orig_default_file")"
- [ -n "$original_default" ] && security default-keychain -s "$original_default" || true
+ [[ -n "$original_default" ]] && security default-keychain -s "$original_default" || true
fi
- if [ -f "$orig_list_file" ]; then
+ if [[ -f "$orig_list_file" ]]; then
local restore=()
while IFS= read -r line; do
- [ -n "$line" ] && restore+=("$line")
+ [[ -n "$line" ]] && restore+=("$line")
done <"$orig_list_file"
- [ ${#restore[@]} -gt 0 ] && security list-keychains -d user -s "${restore[@]}" || true
+ [[ ${#restore[@]} -gt 0 ]] && security list-keychains -d user -s "${restore[@]}" || true
fi
# delete-keychain also drops it from the search list.
- [ -f "$keychain_path" ] && security delete-keychain "$keychain_path" || true
+ [[ -f "$keychain_path" ]] && security delete-keychain "$keychain_path" || true
rm -f "$orig_list_file" "$orig_default_file" "$state_dir/certificate.p12"
echo "macos-keychain: cleaned up $keychain_path"
}