Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions .github/workflows/fork-desktop-canary.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
name: Fork Desktop Canary

# Owner fork builds: unsigned Windows NSIS (+ optional macOS DMG) without
# requiring block/buzz secrets. Artifacts only — no GitHub Release, no updater.

on:
workflow_dispatch:
inputs:
platforms:
description: "Platforms to build"
type: choice
options:
- windows
- windows-and-macos
- macos
default: windows
ref_note:
description: "Optional note (ignored by build)"
required: false
default: ""

permissions:
contents: read

jobs:
windows:
name: Windows NSIS (unsigned)
if: inputs.platforms == 'windows' || inputs.platforms == 'windows-and-macos'
runs-on: windows-latest
timeout-minutes: 75
env:
TARGET: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

- name: Add Rust target
shell: bash
run: rustup target add "$TARGET"

- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: |
.
desktop/src-tauri
shared-key: fork-windows-canary

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.14.1
package-manager-cache: false

- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with:
version: 11.4.0

- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"

- name: Restore pnpm store cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-

- name: Install desktop dependencies
shell: bash
run: pnpm install --frozen-lockfile

- name: Derive canary version
id: version
shell: bash
run: |
set -euo pipefail
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Desktop version '$BASE_VERSION' is not semver"
exit 1
fi
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-fork.${GITHUB_RUN_NUMBER}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building fork canary $VERSION from $GITHUB_SHA on $GITHUB_REF"

- name: Patch canary version
shell: bash
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace

- name: Generate non-updating bundle config
shell: bash
run: |
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
{
"bundle": {
"createUpdaterArtifacts": false
}
}
JSON

- name: Build sidecars
shell: bash
run: |
cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh "$TARGET"

- name: Build Windows NSIS installer (unsigned)
shell: bash
run: cd desktop && pnpm tauri build --target "$TARGET" --bundles nsis --config src-tauri/tauri.canary.conf.json
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"

- name: Locate NSIS installer
id: artifact
shell: bash
run: |
set -euo pipefail
BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle"
EXE=$(find "$BUNDLE_DIR/nsis" -name '*.exe' -type f | head -1)
if [[ -z "$EXE" ]]; then
echo "::error::No NSIS installer found in $BUNDLE_DIR/nsis"
exit 1
fi
echo "exe=$EXE" >> "$GITHUB_OUTPUT"
ls -la "$EXE"

- name: Upload Windows installer
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: buzz-windows-nsis-${{ github.sha }}
path: ${{ steps.artifact.outputs.exe }}
if-no-files-found: error
retention-days: 14

macos:
name: macOS DMG (unsigned)
if: inputs.platforms == 'macos' || inputs.platforms == 'windows-and-macos'
runs-on: macos-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: |
.
desktop/src-tauri
shared-key: fork-macos-canary

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.14.1
package-manager-cache: false

- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with:
version: 11.4.0

- name: Install desktop dependencies
run: pnpm install --frozen-lockfile

- name: Derive canary version
id: version
run: |
set -euo pipefail
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
VERSION="${BASE_VERSION%-*}-fork.${GITHUB_RUN_NUMBER}"
# bump patch-ish label only
BASE_VERSION=$(node -p "require('./desktop/package.json').version")
if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::bad version $BASE_VERSION"; exit 1
fi
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-fork.${GITHUB_RUN_NUMBER}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Patch canary version
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace

- name: Generate non-updating bundle config
run: |
cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON'
{
"bundle": {
"createUpdaterArtifacts": false
}
}
JSON

- name: Build sidecars
run: |
cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh

- name: Build macOS DMG (unsigned / no-sign)
run: cd desktop && pnpm tauri build --verbose --no-sign --bundles dmg --config src-tauri/tauri.canary.conf.json

- name: Locate DMG
id: artifact
run: |
set -euo pipefail
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
if [[ -z "$DMG" ]]; then
echo "::error::No DMG in $BUNDLE_DIR/dmg"; exit 1
fi
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
ls -la "$DMG"

- name: Upload macOS DMG
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: buzz-macos-dmg-${{ github.sha }}
path: ${{ steps.artifact.outputs.dmg }}
if-no-files-found: error
retention-days: 14
4 changes: 3 additions & 1 deletion admin-web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
<meta name="referrer" content="no-referrer" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Buzz admin</title>
</head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
Expand Down
37 changes: 30 additions & 7 deletions admin-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
useState,
} from "react";
import { ApiFailure, request } from "./api";
import { getLang, setLang as persistLang, t, type Lang } from "./i18n";
import type {
FeedbackDetail,
FeedbackSummary,
Expand Down Expand Up @@ -68,16 +69,16 @@ function StateView<T>({
children: (data: T) => ReactNode;
}) {
if (resource.loading && !resource.data)
return <div className="state">Loading…</div>;
return <div className="state">{t("loading")}</div>;
if (resource.error && !resource.data) {
const forbidden =
resource.error instanceof ApiFailure && resource.error.status === 403;
return (
<div className="state error" role="alert">
<h2>{forbidden ? "Access denied" : "Could not load data"}</h2>
<h2>{forbidden ? t("access_denied") : t("load_failed")}</h2>
<p>{resource.error.message}</p>
<button type="button" onClick={resource.refetch}>
Retry
{t("retry")}
</button>
</div>
);
Expand Down Expand Up @@ -799,6 +800,7 @@ function ArrowIcon() {

export function App() {
const { path } = usePath();
const [lang, setLangState] = useState<Lang>(() => getLang());
const report = path.match(/^\/reports\/([^/]+)$/);
const feedback = path.match(/^\/feedback\/([^/]+)$/);
const content = report ? (
Expand All @@ -810,24 +812,45 @@ export function App() {
) : (
<Reports />
);
const switchLang = (next: Lang) => {
persistLang(next);
setLangState(next);
document.documentElement.lang = next === "zh" ? "zh-CN" : "en";
};
return (
<div className="app">
<div className="app" data-lang={lang}>
<header className="app-header">
<Link href="/reports" className="brand">
<span className="brand-mark">
<BuzzMark />
</span>
<span>
Buzz <b>Admin</b>
{t("brand", lang)}
</span>
</Link>
<nav>
<Link href="/reports" className="nav-link" activeWhenNested>
<ReportIcon /> Reports
<ReportIcon /> {t("nav_reports", lang)}
</Link>
<Link href="/feedback" className="nav-link" activeWhenNested>
<FeedbackIcon /> Feedback
<FeedbackIcon /> {t("nav_feedback", lang)}
</Link>
<span className="lang-switch" aria-label={t("lang", lang)}>
<button
type="button"
className={lang === "en" ? "on" : undefined}
onClick={() => switchLang("en")}
>
EN
</button>
<button
type="button"
className={lang === "zh" ? "on" : undefined}
onClick={() => switchLang("zh")}
>
中文
</button>
</span>
</nav>
</header>
<main>{content}</main>
Expand Down
41 changes: 41 additions & 0 deletions admin-web/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export type Lang = "en" | "zh";

const dict = {
en: {
brand: "Buzz Admin",
nav_reports: "Reports",
nav_feedback: "Feedback",
loading: "Loading…",
access_denied: "Access denied",
load_failed: "Could not load data",
retry: "Retry",
lang: "Language",
},
zh: {
brand: "Buzz 管理台",
nav_reports: "举报",
nav_feedback: "反馈",
loading: "加载中…",
access_denied: "无权限",
load_failed: "无法加载数据",
retry: "重试",
lang: "语言",
},
} as const;

export type MsgKey = keyof (typeof dict)["en"];

const STORAGE_KEY = "buzz-admin-lang";

export function getLang(): Lang {
const v = localStorage.getItem(STORAGE_KEY);
return v === "zh" || v === "en" ? v : "zh";
}

export function setLang(lang: Lang) {
localStorage.setItem(STORAGE_KEY, lang);
}

export function t(key: MsgKey, lang: Lang = getLang()): string {
return dict[lang][key] ?? dict.en[key] ?? key;
}
Loading