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
+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"