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
74 changes: 63 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,32 @@ on:
branches: [main]
pull_request:

# Third-party actions are pinned to full commit SHAs (#176 supply-chain hygiene);
# the trailing comment records the human-readable version they were pinned at.
jobs:
no-stub-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- run: ./scripts/no-stub-gate.sh

color-guard:
# #116: fail if src CSS uses a raw color literal outside the :root tokens.
# #116: fail if src CSS or a TS-set inline style uses a raw color literal
# outside the :root tokens.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- run: ./scripts/color-guard.sh

packages-linux:
# Engine/archive packages are Tauri-free and must build & test headless.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 10
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm
Expand All @@ -41,19 +44,68 @@ jobs:
app-macos:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 10
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # dtolnay/rust-toolchain master (channel via `toolchain:` input)
with:
toolchain: stable
components: clippy
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo test --workspace

release-invariants:
# #176: turn the #161-class privacy invariant into a permanent gate. CI's
# other jobs only compile/test the DEBUG profile, so a dropped
# `#[cfg(debug_assertions)]` guard (e.g. a bad merge on the #64 bleed dump or a
# #108 dev flag) is invisible to them and only surfaces at tag time. This job
# compiles livecap-app in RELEASE and asserts the debug-only env-escape NAMES
# are absent from the binary — they exist only inside `#[cfg(debug_assertions)]`
# items, so a release build compiles them out entirely (#146/#161).
runs-on: macos-14
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 10
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # dtolnay/rust-toolchain master (channel via `toolchain:` input)
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- run: pnpm install --frozen-lockfile
- run: pnpm build # tauri-build embeds the built frontend, so dist/ must exist
- name: Compile livecap-app in RELEASE (tests no-run + binary)
run: |
cargo test --release -p livecap-app --no-run
cargo build --release -p livecap-app
- name: Assert the release binary strips debug-only env escapes (#161/#176)
run: |
set -euo pipefail
BIN=target/release/livecap-app
test -f "$BIN" || { echo "::error::release binary not found at $BIN"; exit 1; }
# These env vars are read ONLY inside #[cfg(debug_assertions)] items, so a
# release build must contain none of their names. A hit means a guard was
# dropped and the shipped binary regained a covert env path (#161-class).
FORBIDDEN="LIVECAP_BLEED_DUMP_DIR LIVECAP_CAPTURE_VISIBLE LIVECAP_AUTOSTART LIVECAP_UI_PROBE"
fail=0
for name in $FORBIDDEN; do
if strings "$BIN" | grep -q "$name"; then
echo "::error::release binary contains debug-only env name '$name' — a #[cfg(debug_assertions)] guard was dropped (regression of #161)."
fail=1
else
echo "ok: '$name' absent from the release binary"
fi
done
exit $fail
25 changes: 20 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ jobs:
# Apple silicon; universal binary doubles CI time — revisit on demand).
runs-on: macos-14
timeout-minutes: 90
# Third-party actions are pinned to full commit SHAs (#176 supply-chain
# hygiene) — this job materializes the Apple signing secrets, so a compromised
# mutable tag/branch must not be able to run in it; the trailing comment
# records the human-readable version pinned at.
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 10
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm
Expand All @@ -39,14 +43,25 @@ jobs:
exit 1
fi

- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # dtolnay/rust-toolchain master (channel via `toolchain:` input)
with:
toolchain: stable
targets: aarch64-apple-darwin
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2

- run: pnpm install --frozen-lockfile
- run: pnpm build

- name: Quality gates before building the release (#176)
# Tag pushes bypass branch protection, so nothing otherwise enforces that a
# tagged commit ever passed CI. Run the same gates here so a release is
# always cut from a green tree — no stub/color-token drift, tests passing.
run: |
./scripts/no-stub-gate.sh
./scripts/color-guard.sh
cargo test --workspace
pnpm test

- name: Detect Apple signing secrets
# GitHub cannot test secret presence in `if:` reliably without first
# mapping secrets into env, so this step maps them (values never
Expand Down
7 changes: 4 additions & 3 deletions crates/livecap-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ use thiserror::Error;

#[derive(Debug, Error)]
pub enum CoreError {
/// System-audio capture is not implemented for the current platform.
/// (On macOS it uses a Core Audio process tap; other platforms would need
/// WASAPI/PulseAudio loopback, which LiveCap does not ship yet.)
/// System-audio capture is unavailable on the current platform. (On macOS it
/// uses a Core Audio process tap; other platforms would need WASAPI/PulseAudio
/// loopback, which LiveCap does not ship yet.)
// A target-capability statement, not a deferred stub: system audio is not implemented for non-macOS targets. no-stub-allow
#[error("system audio capture is unavailable on {platform}: {reason}")]
SystemAudioUnavailable {
platform: &'static str,
Expand Down
30 changes: 24 additions & 6 deletions crates/livecap-core/src/vad.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,17 @@ mod tests {
let segments_400 = run(400);
let segments_2000 = run(2000);

// Guard against a vacuous pass (#176): if this Silero build ever stops
// classifying the synthetic generator as speech, both runs return 0 and the
// `<=` below would pass silently (0 <= 0). Require the baseline to have
// produced segments so the comparison is real — a failure here signals the
// synthetic fixture needs porting to a real-speech WAV (cf. force_cut_wav).
assert!(
!segments_400.is_empty(),
"precondition: the synthetic generator must be detected as speech; 0 segments \
makes the redemption comparison vacuous (Silero build changed?)"
);

// Longer redemption bridges more pauses, so it must not produce more
// segments than the short one.
assert!(
Expand Down Expand Up @@ -464,10 +475,17 @@ mod tests {
}
}

// NOTE: the force-cut → SpeechEnd no-duplication / no-panic invariant (#138/
// #162) is covered by the REAL-SPEECH integration test in
// `tests/force_cut_wav.rs`. A synthetic-harmonic unit test cannot exercise
// it: this Silero build never classifies the generator's output as speech,
// so the force-cut path is never entered (the earlier synthetic test was
// vacuous and gave false confidence).
// NOTE (#176 — corrected): the force-cut → natural-SpeechEnd no-duplication /
// no-panic invariant (#138/#162) is covered by the REAL-SPEECH integration
// test in `tests/force_cut_wav.rs`, which drives the exact production path with
// realistic utterance timing (the #162 panic reproduced only there). The
// synthetic-harmonic generator above IS classified as speech by this Silero
// build — `vad_state_is_maintained_across_chunks` asserts non-empty segments
// and passes, so the earlier claim that it is "never" detected was wrong — but
// the continuous synthetic signal does not reproduce the force-cut → natural
// end sequence. Because these two unit tests DO depend on synthetic detection,
// they are Silero-version-sensitive: a model bump that changes detection would
// fail `vad_state_is_maintained_across_chunks` (and trip the vacuity guard in
// `longer_redemption_does_not_fragment_more`), signalling the fixtures need a
// refresh rather than passing on false confidence.
}
8 changes: 5 additions & 3 deletions crates/livecap-core/tests/force_cut_wav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
//!
//! This exercises the exact production path (`pipeline.rs` force-cuts at
//! `max_utterance_ms`, then the utterance ends naturally). It uses REAL speech
//! synthesized with macOS `say`: the synthetic-harmonic generator in the vad
//! unit tests is never classified as speech by this Silero build, so the
//! force-cut → SpeechEnd path can only be reached with real audio.
//! synthesized with macOS `say` for production-fidelity timing. (The
//! synthetic-harmonic generator in the vad unit tests IS classified as speech by
//! this Silero build — see `vad_state_is_maintained_across_chunks` — but its
//! continuous signal does not reproduce the force-cut → natural-SpeechEnd
//! sequence where the #162 panic occurred, so that regression is guarded here.)
//!
//! Before the #162 fix this test PANICS at the natural SpeechEnd (draining
//! Silero's buffer on the cut left its state-enum `start_ms` stale). macOS only.
Expand Down
129 changes: 86 additions & 43 deletions scripts/color-guard.sh
Original file line number Diff line number Diff line change
@@ -1,60 +1,103 @@
#!/bin/bash
# Raw-color guard (#116): src CSS must reference colors through design tokens,
# not raw literals — every hex / rgb() / rgba() color belongs in the :root token
# block. A raw color anywhere else fails CI so the debt can't regrow with each
# new UI surface (the reason #116 exists).
# Raw-color guard (#116, extended #176): colors must come from design tokens, not
# raw literals — every hex / rgb() / hsl() / modern-color-function / named color
# belongs behind a :root token (as var(...)). A raw color anywhere else fails CI
# so the #116 token debt can't regrow with each new UI surface.
#
# What it flags: a hex (#rgb / #rgba / #rrggbb / #rrggbbaa) or rgb()/rgba()
# literal appearing in a DECLARATION VALUE (the text after the first ':' on a
# line) in src/**/*.css, OUTSIDE the ':root { ... }' block.
# Two passes:
# 1) src/**/*.css — a color literal in a DECLARATION VALUE (text after the first
# ':' on a line), OUTSIDE the ':root { ... }' block.
# 2) src/**/*.ts — a color literal (hex / rgb() / hsl()-family) on a line that
# SETS an inline style (`.style.`, `.setProperty(`, or a `style=` attribute in
# a template string). Best-effort grep; the LIMITS are documented below.
#
# Why value-only (casebook #7 — color-scan false positives): hex-looking ID
# selectors such as `#feed`, `#feed-wrap`, `#feed-note` are legitimate and must
# NOT be flagged. They sit in selector position — no property ':' precedes them
# — so scanning only the value side skips them WITHOUT a brittle name allowlist.
# Why value-only for CSS (casebook #7 — color-scan false positives): hex-looking
# ID selectors (`#feed`, `#feed-wrap`) sit in SELECTOR position (no property ':'
# precedes them), so scanning only the value side skips them without a name
# allowlist.
#
# Escape hatch: prefer moving the literal into a :root token. As a last resort,
# append `/* color-guard-allow */` to the specific line to exempt it.
# LIMITS (best-effort, #176) — NOT covered, by design:
# - named colors in TS (bare `red`/`blue` collide with ordinary identifiers/
# strings, so the TS pass matches only hex/rgb()/hsl()-family literals);
# - a color built across lines or from a runtime expression / concatenation;
# - values inside CSS/JS block comments on a value line (use the escape hatch).
#
# Escape hatch: prefer a :root token. As a last resort, append
# `/* color-guard-allow */` to the specific line to exempt it.
set -euo pipefail

# Draft pattern from the issue, refined: hex must be followed by a non-hex char
# (or line end) so `#feedcafe`-style tails don't over/under-match a color.
COLOR_RE='#[0-9a-fA-F]{3,8}([^0-9a-fA-F]|$)|rgba?\('
# Hex (#rgb / #rgba / #rrggbb / #rrggbbaa): a trailing non-hex char (or EOL) keeps
# `#feedcafe`-style tails from over/under-matching. Modern CSS color functions
# (#176): hsl/hsla/hwb/oklch/oklab/lch/lab/color-mix/color(). A curated set of CSS
# NAMED colors (#176) — the CSS-wide keywords (transparent/currentColor/inherit/
# initial/unset/revert/none/auto) are deliberately absent, so those still pass.
HEX='#[0-9a-fA-F]{3,8}([^0-9a-fA-F]|$)'
COLOR_FUNC='rgba?\(|hsla?\(|hwb\(|oklch\(|oklab\(|lch\(|lab\(|color-mix\(|color\('
NAMED='\b(aqua|aquamarine|beige|black|blue|brown|chocolate|coral|crimson|cyan|fuchsia|gold|goldenrod|gray|grey|green|indigo|ivory|khaki|lavender|lime|magenta|maroon|navy|olive|orange|orchid|pink|plum|purple|red|salmon|sienna|silver|tan|teal|tomato|turquoise|violet|wheat|white|yellow)\b'
COLOR_RE="$HEX|$COLOR_FUNC|$NAMED"
# The TS pass omits NAMED — bare color words are too common in code/strings.
TS_COLOR_RE="$HEX|$COLOR_FUNC"

# ---- Pass 1: CSS ----------------------------------------------------------
# bash-3.2 compatible (stock macOS bash has no `mapfile`, #126).
FILES=()
while IFS= read -r f; do
FILES+=("$f")
done < <(find src -type f -name '*.css' 2>/dev/null | sort)
if [ ${#FILES[@]} -eq 0 ]; then
echo "color-guard: no src CSS files found (nothing to check)"
exit 0
CSS_FILES=()
while IFS= read -r f; do [ -n "$f" ] && CSS_FILES+=("$f"); done < <(find src -type f -name '*.css' 2>/dev/null | sort)

CSS_HITS=""
if [ ${#CSS_FILES[@]} -gt 0 ]; then
# awk strips the :root block and emits "<file>:<lineno>:<value-side>" for every
# other line that has a property ':'; grep then tests only that value side.
CSS_HITS=$(
for f in "${CSS_FILES[@]}"; do
awk '
/^[[:space:]]*:root[[:space:]]*\{/ { inroot = 1 }
inroot { if ($0 ~ /\}/) inroot = 0; next }
/color-guard-allow/ { next }
{
i = index($0, ":")
if (i == 0) next
printf "%s:%d:%s\n", FILENAME, NR, substr($0, i + 1)
}
' "$f"
done | grep -E "$COLOR_RE" || true
)
fi

# awk strips the :root block and emits "<file>:<lineno>:<value-side>" for every
# other line that has a property ':' — grep then tests only that value side.
HITS=$(
for f in "${FILES[@]}"; do
awk '
/^[[:space:]]*:root[[:space:]]*\{/ { inroot = 1 }
inroot { if ($0 ~ /\}/) inroot = 0; next }
/color-guard-allow/ { next }
{
i = index($0, ":")
if (i == 0) next
printf "%s:%d:%s\n", FILENAME, NR, substr($0, i + 1)
}
' "$f"
done | grep -E "$COLOR_RE" || true
)
# ---- Pass 2: TS inline styles (#176) --------------------------------------
# A raw color literal on a line that sets an inline style. Value-side heuristic
# via the style-set anchor; the escape hatch and var() tokens are honored.
TS_FILES=()
while IFS= read -r f; do [ -n "$f" ] && TS_FILES+=("$f"); done < <(find src -type f -name '*.ts' 2>/dev/null | sort)

TS_HITS=""
if [ ${#TS_FILES[@]} -gt 0 ]; then
TS_HITS=$(
for f in "${TS_FILES[@]}"; do
grep -nE '(\.style\.|\.setProperty\(|style=)' "$f" 2>/dev/null \
| grep -v 'color-guard-allow' \
| grep -E "$TS_COLOR_RE" \
| sed "s|^|$f:|" || true
done
)
fi

if [ -n "$HITS" ]; then
echo "color-guard FAILED — raw color literal(s) outside the :root token block:"
echo "$HITS"
FAILED=0
if [ -n "$CSS_HITS" ]; then
echo "color-guard FAILED — raw color literal(s) outside the :root token block (CSS):"
echo "$CSS_HITS"
FAILED=1
fi
if [ -n "$TS_HITS" ]; then
echo "color-guard FAILED — raw color literal(s) in a TS-set inline style:"
echo "$TS_HITS"
FAILED=1
fi
if [ "$FAILED" -ne 0 ]; then
echo
echo "Fix: replace with a design token — e.g. var(--surface-2) — and define any"
echo "new token in the :root block (mirror it in design/system/tokens.css)."
echo "Last resort: append /* color-guard-allow */ to the specific line."
exit 1
fi

echo "color-guard passed — all colors in src CSS come from :root tokens"
echo "color-guard passed — all colors in src CSS + TS-set inline styles come from :root tokens"
14 changes: 14 additions & 0 deletions scripts/fixtures/no-stub-selftest.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# no-stub-gate self-test fixture (#176): one banned marker per non-comment line.
# The gate asserts its PATTERN matches EVERY line below, so a future edit that
# weakens the pattern fails loudly here instead of silently letting stubs ship.
# This file lives under fixtures/ so the gate's own app-code scan skips it.
TODO leftover work
FIXME a broken thing
HACK around a bug
a placeholder value
a mock server
function stubTranslateBatch() {}
a dummy value
unimplemented!()
this feature is not implemented yet
danger XXX here
Loading
Loading