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
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:
@@ -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}`);
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user