From ce2767acd10a79672d10fe0eeffc62bf0aaf2a5e Mon Sep 17 00:00:00 2001
From: morgmart <98432065+morgmart@users.noreply.github.com>
Date: Mon, 21 Sep 2026 15:05:26 -0700
Subject: [PATCH 1/3] Label dev worktrees and pause local notifications by
default
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
---
.env.example | 5 +
dev/dev-desktop.test.mjs | 89 +++++++++++
dev/vite-config.test.mjs | 10 ++
docs/contributing.md | 8 +
docs/notifications.md | 7 +-
justfile | 2 +-
scripts/dev-desktop.mjs | 73 +++++++++
scripts/generate-dev-icon.swift | 216 ++++++++++++++++++++++++++
src/app/NotificationSettings.test.tsx | 90 +++++++++++
src/app/NotificationSettings.tsx | 32 ++--
src/features/notifications/service.ts | 5 +-
vite.config.ts | 3 +
12 files changed, 524 insertions(+), 16 deletions(-)
create mode 100644 dev/dev-desktop.test.mjs
create mode 100644 scripts/dev-desktop.mjs
create mode 100755 scripts/generate-dev-icon.swift
create mode 100644 src/app/NotificationSettings.test.tsx
diff --git a/.env.example b/.env.example
index e17457821..9cda97806 100644
--- a/.env.example
+++ b/.env.example
@@ -12,3 +12,8 @@ BUZZ_DEV_VIEWER=
# Keep the same alias mapped to the same origin to retain existing memberships.
# Values are public configuration included in the frontend bundle, never secrets.
# BUZZ_COMMUNITY_ALIASES='{"primary":"wss://relay.example.com","secondary":"wss://other.example.com"}'
+
+# Local dev servers pause notifications by default, without changing saved choices.
+# Opt in for notification testing; saved choices and OS permissions still apply.
+# Restart the dev server after changing this. Production builds ignore it.
+# BUZZ_DEV_NOTIFICATIONS=1
diff --git a/dev/dev-desktop.test.mjs b/dev/dev-desktop.test.mjs
new file mode 100644
index 000000000..f5606c663
--- /dev/null
+++ b/dev/dev-desktop.test.mjs
@@ -0,0 +1,89 @@
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, expect, it, vi } from "vitest";
+import { desktopArgs, worktreeLabel } from "../scripts/dev-desktop.mjs";
+
+const directories = [];
+afterEach(() => {
+ for (const path of directories.splice(0))
+ rmSync(path, { recursive: true, force: true });
+});
+function fixture() {
+ const base = mkdtempSync(join(tmpdir(), "buzz-dev-launch-"));
+ directories.push(base);
+ const main = join(base, "main");
+ const linked = join(base, 'linked "checkout"');
+ const git = (...args) => execFileSync("git", args, { stdio: "pipe" });
+ git("init", "--initial-branch=main", main);
+ git(
+ "-C",
+ main,
+ "-c",
+ "user.name=Test",
+ "-c",
+ "user.email=test@example.com",
+ "-c",
+ "core.hooksPath=/dev/null",
+ "-c",
+ "commit.gpgsign=false",
+ "commit",
+ "--allow-empty",
+ "-m",
+ "fixture",
+ );
+ git(
+ "-C",
+ main,
+ "-c",
+ "core.hooksPath=/dev/null",
+ "worktree",
+ "add",
+ "-b",
+ "person/onboarding-feedback",
+ linked,
+ );
+ return { main, linked, git };
+}
+it("labels linked worktrees by branch suffix and detached trees by directory", () => {
+ const { main, linked, git } = fixture();
+ expect(worktreeLabel(main)).toBeNull();
+ expect(worktreeLabel(linked)).toBe("onboarding-feedback");
+ git("-C", linked, "-c", "core.hooksPath=/dev/null", "checkout", "--detach");
+ expect(worktreeLabel(linked)).toBe('linked "checkout"');
+});
+it("overrides only the development icon, safely encoding checkout paths", () => {
+ const { linked } = fixture();
+ const run = vi.fn(() => ({ status: 0 }));
+ const args = desktopArgs({ cwd: linked, platform: "darwin", run });
+ expect(args.slice(0, 3)).toEqual(["tauri", "dev", "--config"]);
+ expect(JSON.parse(args[3])).toEqual({
+ bundle: { icon: [join(linked, "src-tauri/target/dev-icons/icon.icns")] },
+ });
+ expect(run.mock.calls[0][1].at(-1)).toBe("onboarding-feedback");
+});
+it("leaves ordinary checkouts and non-macOS launches alone", () => {
+ const { main, linked } = fixture();
+ const run = vi.fn();
+ expect(desktopArgs({ cwd: main, platform: "darwin", run })).toEqual([
+ "tauri",
+ "dev",
+ ]);
+ expect(desktopArgs({ cwd: linked, platform: "linux", run })).toEqual([
+ "tauri",
+ "dev",
+ ]);
+ expect(run).not.toHaveBeenCalled();
+});
+it.each([{ status: 1 }, { status: null, error: new Error("Swift missing") }])(
+ "warns and falls back when icon generation fails: %j",
+ (result) => {
+ const { linked } = fixture();
+ const warn = vi.fn();
+ expect(
+ desktopArgs({ cwd: linked, platform: "darwin", run: () => result, warn }),
+ ).toEqual(["tauri", "dev"]);
+ expect(warn).toHaveBeenCalledOnce();
+ },
+);
diff --git a/dev/vite-config.test.mjs b/dev/vite-config.test.mjs
index 898611403..f0ecb3cb1 100644
--- a/dev/vite-config.test.mjs
+++ b/dev/vite-config.test.mjs
@@ -19,6 +19,16 @@ it("loads the broker's Vite config without native-compatibility warnings", () =>
);
assert(loaded?.config.plugins.some(p => p?.name === 'buzz-relay-broker'));
assert.equal(loaded.config.define['import.meta.env.VITE_BUZZ_LIVE'], '"1"');
+ for (const command of ['serve', 'build']) {
+ for (const optIn of ['', '0', '1', 'true', 'invalid']) {
+ process.env.BUZZ_DEV_NOTIFICATIONS = optIn;
+ const result = await loadConfigFromFile({ command, mode: 'development' });
+ assert.equal(
+ result.config.define['import.meta.env.VITE_BUZZ_NOTIFICATIONS_PAUSED'],
+ JSON.stringify(command === 'serve' && optIn !== '1' ? '1' : '0'),
+ );
+ }
+ }
`,
],
{
diff --git a/docs/contributing.md b/docs/contributing.md
index 464929d33..77311cebb 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -47,6 +47,14 @@ need their own validation.
- `just web`: install locked dependencies and start Vite on port 1430 or the
next available port, allowing parallel browser development across worktrees.
- `just desktop`: install locked dependencies and start Tauri, which starts Vite.
+ On macOS linked worktrees, the Dock icon displays the branch’s final name segment
+ (or the worktree directory name for detached HEAD). Relaunch after changing branches.
+ The generated icon stays in ignored build output; production icons, app identity,
+ and ports are unchanged. If generation fails, startup warns and uses the normal
+ icon. Direct `pnpm tauri dev` bypasses this label.
+- Local dev servers pause notifications unless `BUZZ_DEV_NOTIFICATIONS=1` is set
+ in `.env.local`; restart the server after changing it. Saved preferences are
+ untouched and production builds ignore the variable.
- `just fullstack`: reserved, exits unsuccessfully with an explanation. It will
eventually start local Docker services including the Buzz relay backend.
- `just iterate`: install locked dependencies, format Rust, apply Biome safe
diff --git a/docs/notifications.md b/docs/notifications.md
index 3b1e4971f..f09d3dfbb 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -2,7 +2,12 @@
The host provides one `notifications` service for built-in messages and trusted
plugins. Settings → Notifications stores account-local choices: alerts are on
-by default, subject to system permission; master off preserves category choices.
+by default in production, subject to system permission; master off preserves category choices.
+Local dev servers pause all alerts and permission requests unless
+`BUZZ_DEV_NOTIFICATIONS=1` is set in `.env.local`. Restart the server after changing
+it. This development gate never rewrites saved preferences; opting in still
+honors account choices and system permission. Settings shows the pause and how to
+enable notifications. Production builds ignore this variable.
Browser sound uses the Notification API. Desktop sound is managed in OS settings;
there is no separate audio player.
diff --git a/justfile b/justfile
index b054b841a..d95013a8e 100644
--- a/justfile
+++ b/justfile
@@ -14,7 +14,7 @@ web: install
# Run the shared frontend in Tauri (requires native toolchain).
desktop: install
- pnpm tauri dev
+ node scripts/dev-desktop.mjs
# Run buzzodz; forward arguments unchanged (Rust required).
[positional-arguments]
diff --git a/scripts/dev-desktop.mjs b/scripts/dev-desktop.mjs
new file mode 100644
index 000000000..9cca633b9
--- /dev/null
+++ b/scripts/dev-desktop.mjs
@@ -0,0 +1,73 @@
+import { execFileSync, spawnSync } from "node:child_process";
+import { mkdirSync } from "node:fs";
+import { basename, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = fileURLToPath(new URL("../", import.meta.url));
+
+export function worktreeLabel(cwd) {
+ const git = (...args) =>
+ execFileSync("git", args, {
+ cwd,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ }).trim();
+ try {
+ if (
+ git("rev-parse", "--path-format=absolute", "--git-dir") ===
+ git("rev-parse", "--path-format=absolute", "--git-common-dir")
+ )
+ return null;
+ const branch = git("branch", "--show-current");
+ return branch
+ ? branch.split("/").at(-1)
+ : basename(git("rev-parse", "--show-toplevel"));
+ } catch {
+ return null;
+ }
+}
+
+export function desktopArgs({
+ cwd = root,
+ platform = process.platform,
+ run = spawnSync,
+ warn = console.warn,
+} = {}) {
+ const args = ["tauri", "dev"];
+ if (platform !== "darwin") return args;
+ const label = worktreeLabel(cwd);
+ if (!label) return args;
+ try {
+ const icon = resolve(cwd, "src-tauri/target/dev-icons/icon.icns");
+ mkdirSync(resolve(cwd, "src-tauri/target/dev-icons"), { recursive: true });
+ const result = run(
+ "swift",
+ [
+ resolve(cwd, "scripts/generate-dev-icon.swift"),
+ resolve(cwd, "src-tauri/icons/icon.icns"),
+ icon,
+ label,
+ ],
+ { cwd, stdio: "inherit" },
+ );
+ if (result.error || result.status !== 0)
+ throw result.error ?? new Error("Icon generator failed");
+ args.push("--config", JSON.stringify({ bundle: { icon: [icon] } }));
+ } catch {
+ warn("Worktree icon could not be generated; using the ordinary Buzz icon.");
+ }
+ return args;
+}
+
+if (
+ process.argv[1] &&
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
+) {
+ const result = spawnSync(
+ resolve(root, "bin/pnpm"),
+ [...desktopArgs(), ...process.argv.slice(2)],
+ { cwd: root, stdio: "inherit" },
+ );
+ if (result.error) console.error(result.error.message);
+ process.exitCode = result.status ?? 1;
+}
diff --git a/scripts/generate-dev-icon.swift b/scripts/generate-dev-icon.swift
new file mode 100755
index 000000000..b324478cc
--- /dev/null
+++ b/scripts/generate-dev-icon.swift
@@ -0,0 +1,216 @@
+#!/usr/bin/env swift
+
+import AppKit
+import Foundation
+
+// Ported from block/buzz scripts/generate-dev-icon.swift.
+// Generate a dev icon with worktree name badge
+// Usage: generate-dev-icon.swift
+
+guard CommandLine.arguments.count == 4 else {
+ fputs("Usage: \(CommandLine.arguments[0]) \n", stderr)
+ exit(1)
+}
+
+let inputPath = CommandLine.arguments[1]
+let outputPath = CommandLine.arguments[2]
+let label = CommandLine.arguments[3]
+
+func sanitizedPathComponent(_ value: String) -> String {
+ let lowered = value.lowercased()
+ let sanitizedScalars = lowered.unicodeScalars.map { scalar -> Character in
+ switch scalar {
+ case "a"..."z", "0"..."9":
+ return Character(scalar)
+ default:
+ return "-"
+ }
+ }
+ let collapsed = String(sanitizedScalars).replacingOccurrences(
+ of: "-+",
+ with: "-",
+ options: .regularExpression
+ )
+ let trimmed = collapsed.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
+ return trimmed.isEmpty ? "dev" : trimmed
+}
+
+guard let iconImage = NSImage(contentsOfFile: inputPath) else {
+ fputs("Failed to load image: \(inputPath)\n", stderr)
+ exit(1)
+}
+
+// Get the largest representation for best quality
+guard let rep = iconImage.representations.max(by: { $0.pixelsWide < $1.pixelsWide }) else {
+ fputs("No image representations found\n", stderr)
+ exit(1)
+}
+
+let size = NSSize(width: rep.pixelsWide, height: rep.pixelsHigh)
+
+let newImage = NSImage(size: size)
+newImage.lockFocus()
+
+// Draw the original icon
+iconImage.draw(in: NSRect(origin: .zero, size: size))
+
+// Configure badge
+let singleLineHeight = size.height * 0.22
+let padding = size.width * 0.03
+let maxBadgeWidth = size.width * 0.9
+
+// Text attributes - calculate font size to fit
+let maxFontSize = singleLineHeight * 0.65
+var fontSize = maxFontSize
+var attributes: [NSAttributedString.Key: Any]
+
+// Helper to wrap text on `-` characters
+func wrapText(_ text: String, maxWidth: CGFloat, attributes: [NSAttributedString.Key: Any]) -> [String] {
+ let singleLineSize = (text as NSString).size(withAttributes: attributes)
+ if singleLineSize.width <= maxWidth {
+ return [text]
+ }
+
+ // Split on `-` and try to form lines
+ let parts = text.components(separatedBy: "-")
+ if parts.count == 1 {
+ return [text] // No `-` to wrap on
+ }
+
+ var lines: [String] = []
+ var currentLine = ""
+
+ for (index, part) in parts.enumerated() {
+ let separator = index == 0 ? "" : "-"
+ let testLine = currentLine.isEmpty ? part : currentLine + separator + part
+ let testSize = (testLine as NSString).size(withAttributes: attributes)
+
+ if testSize.width <= maxWidth || currentLine.isEmpty {
+ currentLine = testLine
+ } else {
+ lines.append(currentLine)
+ currentLine = part
+ }
+ }
+ if !currentLine.isEmpty {
+ lines.append(currentLine)
+ }
+
+ return lines
+}
+
+// Find font size that fits (allowing up to 2 lines)
+var lines: [String] = []
+repeat {
+ attributes = [
+ .font: NSFont.systemFont(ofSize: fontSize, weight: .bold),
+ .foregroundColor: NSColor.white
+ ]
+ lines = wrapText(label, maxWidth: maxBadgeWidth - padding * 4, attributes: attributes)
+ fontSize -= 1
+} while (lines.count > 2 || lines.contains {
+ ($0 as NSString).size(withAttributes: attributes).width > maxBadgeWidth - padding * 4
+}) && fontSize > 1
+
+// Calculate text dimensions using typographic metrics
+let lineHeight = (lines.first! as NSString).size(withAttributes: attributes).height
+let textHeight = lineHeight * CGFloat(lines.count)
+let maxLineWidth = lines.map { ($0 as NSString).size(withAttributes: attributes).width }.max() ?? 0
+
+// Badge dimensions based on text
+let badgeHeight = textHeight + padding * 2
+let cornerRadius = badgeHeight * 0.2
+let badgeWidth = maxLineWidth + padding * 4
+let badgeX = (size.width - badgeWidth) / 2
+let badgeY = size.height - badgeHeight - padding - size.height * 0.05
+
+// Draw badge background (light blue semi-transparent)
+let badgePath = NSBezierPath(roundedRect: NSRect(x: badgeX, y: badgeY, width: badgeWidth, height: badgeHeight),
+ xRadius: cornerRadius, yRadius: cornerRadius)
+NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: 0.85).setFill()
+badgePath.fill()
+
+// Draw text centered in badge (multiple lines, bottom to top)
+for (index, line) in lines.reversed().enumerated() {
+ let lineSize = (line as NSString).size(withAttributes: attributes)
+ let textX = badgeX + (badgeWidth - lineSize.width) / 2
+ let textY = badgeY + padding + lineHeight * CGFloat(index)
+ (line as NSString).draw(at: NSPoint(x: textX, y: textY), withAttributes: attributes)
+}
+
+newImage.unlockFocus()
+
+// First, create PNG data at multiple sizes for icns
+guard let tiffData = newImage.tiffRepresentation,
+ let bitmapRep = NSBitmapImageRep(data: tiffData) else {
+ fputs("Failed to create bitmap representation\n", stderr)
+ exit(1)
+}
+
+// For simplicity, we'll create a PNG and then use iconutil if available,
+// or just save as PNG for the icon (Tauri can use PNG)
+guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else {
+ fputs("Failed to create PNG data\n", stderr)
+ exit(1)
+}
+
+// If output is .icns, we need to create an iconset and use iconutil
+if outputPath.hasSuffix(".icns") {
+ let tempDir = FileManager.default.temporaryDirectory
+ let iconsetParent = tempDir.appendingPathComponent(
+ "staged-dev-\(sanitizedPathComponent(label))-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ let iconsetPath = iconsetParent.appendingPathComponent("icon.iconset", isDirectory: true)
+ try! FileManager.default.createDirectory(at: iconsetPath, withIntermediateDirectories: true)
+
+ // Generate all required sizes for iconset
+ let sizes: [(name: String, size: Int)] = [
+ ("icon_16x16", 16),
+ ("icon_16x16@2x", 32),
+ ("icon_32x32", 32),
+ ("icon_32x32@2x", 64),
+ ("icon_128x128", 128),
+ ("icon_128x128@2x", 256),
+ ("icon_256x256", 256),
+ ("icon_256x256@2x", 512),
+ ("icon_512x512", 512),
+ ("icon_512x512@2x", 1024)
+ ]
+
+ for (name, targetSize) in sizes {
+ let resizedImage = NSImage(size: NSSize(width: targetSize, height: targetSize))
+ resizedImage.lockFocus()
+ NSGraphicsContext.current?.imageInterpolation = .high
+ newImage.draw(in: NSRect(x: 0, y: 0, width: targetSize, height: targetSize))
+ resizedImage.unlockFocus()
+
+ guard let resizedTiff = resizedImage.tiffRepresentation,
+ let resizedBitmap = NSBitmapImageRep(data: resizedTiff),
+ let resizedPng = resizedBitmap.representation(using: .png, properties: [:]) else {
+ continue
+ }
+
+ let filePath = iconsetPath.appendingPathComponent("\(name).png")
+ try! resizedPng.write(to: filePath)
+ }
+
+ // Use iconutil to create icns
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
+ process.arguments = ["-c", "icns", iconsetPath.path, "-o", outputPath]
+ try! process.run()
+ process.waitUntilExit()
+
+ try? FileManager.default.removeItem(at: iconsetParent)
+
+ if process.terminationStatus != 0 {
+ fputs("iconutil failed\n", stderr)
+ exit(1)
+ }
+} else {
+ // Just save as PNG
+ try! pngData.write(to: URL(fileURLWithPath: outputPath))
+}
+
+print("Generated: \(outputPath)")
diff --git a/src/app/NotificationSettings.test.tsx b/src/app/NotificationSettings.test.tsx
new file mode 100644
index 000000000..b7d2ca1fc
--- /dev/null
+++ b/src/app/NotificationSettings.test.tsx
@@ -0,0 +1,90 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { Context } from "@deepseek-ai/cordis";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import { provideNavigation } from "../features/navigation/service";
+import { NotificationsService } from "../features/notifications/service";
+import { NotificationSettings } from "./NotificationSettings";
+import { PluginRuntime } from "../plugins/runtime";
+
+const contexts: Context[] = [];
+afterEach(async () => {
+ cleanup();
+ for (const ctx of contexts.splice(0)) await ctx.fiber.dispose();
+ localStorage.clear();
+ vi.unstubAllEnvs();
+});
+
+it.each([true, false])(
+ "represents development pause=%s without changing saved preferences",
+ async (paused) => {
+ vi.stubEnv("VITE_BUZZ_NOTIFICATIONS_PAUSED", paused ? "1" : "0");
+ const ctx = new Context();
+ contexts.push(ctx);
+ const runtime = new PluginRuntime(ctx, async () => ({ apply() {} }));
+ ctx.effect(() => () => runtime.dispose());
+ let permission: "default" | "granted" = "default";
+ const platform = {
+ label: "Browser",
+ permission: vi.fn(async () => permission),
+ requestPermission: vi.fn(async () => {
+ permission = "granted";
+ return permission;
+ }),
+ show: vi.fn(async () => {}),
+ dispose() {},
+ };
+ const service = new NotificationsService(
+ ctx,
+ provideNavigation(ctx).navigation,
+ platform,
+ );
+ service.selectViewer("a".repeat(64));
+ service.updatePreferences({ enabled: true });
+ const saved = localStorage.getItem(
+ `buzz-notification-preferences.v1:${"a".repeat(64)}`,
+ );
+ render( );
+ const toggle = screen.getByRole("switch", { name: "Desktop alerts" });
+ if (paused) {
+ expect(toggle).toBeDisabled();
+ expect(toggle).not.toBeChecked();
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "BUZZ_DEV_NOTIFICATIONS=1",
+ );
+ expect(
+ screen.queryByRole("button", { name: "Allow notifications" }),
+ ).not.toBeInTheDocument();
+ } else {
+ expect(toggle).toBeEnabled();
+ expect(toggle).toBeChecked();
+ expect(
+ screen.getByRole("button", { name: "Allow notifications" }),
+ ).toBeEnabled();
+ }
+ cleanup();
+ await service.requestPermission();
+ expect(platform.requestPermission).toHaveBeenCalledTimes(paused ? 0 : 1);
+ const accepted = await service.admit(
+ "mention",
+ "Mentions",
+ {
+ sourceKey: "test-event",
+ target: { version: 1, kind: "settings" },
+ },
+ () => true,
+ );
+ expect(accepted).toBe(!paused);
+ if (paused) expect(platform.show).not.toHaveBeenCalled();
+ else await vi.waitFor(() => expect(platform.show).toHaveBeenCalledOnce());
+ expect(service.snapshot().preferences.enabled).toBe(true);
+ service.reloadPreferences();
+ expect(service.snapshot().preferences.enabled).toBe(true);
+ expect(
+ localStorage.getItem(
+ `buzz-notification-preferences.v1:${"a".repeat(64)}`,
+ ),
+ ).toBe(saved);
+ },
+);
diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx
index 706f6729f..fc9904bc3 100644
--- a/src/app/NotificationSettings.tsx
+++ b/src/app/NotificationSettings.tsx
@@ -23,23 +23,26 @@ export function NotificationSettings({
notifications.updatePreferences({ enabled })}
/>
- {state.requesting
- ? "Waiting for system permission…"
- : permission === "granted"
- ? "Permission granted. Your alert choices still apply."
- : permission === "denied"
- ? "Blocked. Allow notifications in your browser or system settings."
- : permission === "unsupported"
- ? "System notifications are unavailable in this build."
- : permission === "unknown"
- ? "Permission is controlled by system notification settings."
- : "Allow notifications to receive alerts."}
+ {state.developmentPaused
+ ? "Notifications are paused in local development. Set BUZZ_DEV_NOTIFICATIONS=1 in .env.local and restart the dev server to enable them. Your saved alert choices are unchanged."
+ : state.requesting
+ ? "Waiting for system permission…"
+ : permission === "granted"
+ ? "Permission granted. Your alert choices still apply."
+ : permission === "denied"
+ ? "Blocked. Allow notifications in your browser or system settings."
+ : permission === "unsupported"
+ ? "System notifications are unavailable in this build."
+ : permission === "unknown"
+ ? "Permission is controlled by system notification settings."
+ : "Allow notifications to receive alerts."}
- {!state.systemManaged && (
+ {!state.systemManaged && !state.developmentPaused && (
{permission === "default" && (
onChange(event.target.checked)}
/>
diff --git a/src/features/notifications/service.ts b/src/features/notifications/service.ts
index 98c8b1c61..c74291827 100644
--- a/src/features/notifications/service.ts
+++ b/src/features/notifications/service.ts
@@ -60,6 +60,7 @@ export type NotificationSnapshot = Readonly<{
preferencesError: string | null;
platform: string;
systemManaged: boolean;
+ developmentPaused: boolean;
}>;
/** Running-session delivery only: no notification journal, inbox, or recovery protocol. */
@@ -94,6 +95,7 @@ export class NotificationsService extends Service implements Notifications {
requesting: false,
platform: platform.label,
systemManaged: platform.systemManaged ?? false,
+ developmentPaused: import.meta.env.VITE_BUZZ_NOTIFICATIONS_PAUSED === "1",
});
ctx.effect(() => {
const stopPreferences = preferences.subscribe(() => {
@@ -182,7 +184,7 @@ export class NotificationsService extends Service implements Notifications {
}
}
requestPermission() {
- if (this.closed) return Promise.resolve();
+ if (this.closed || this.state.developmentPaused) return Promise.resolve();
if (this.permissionRequest) return this.permissionRequest;
const generation = ++this.permissionGeneration;
this.publish({ requesting: true, error: null });
@@ -209,6 +211,7 @@ export class NotificationsService extends Service implements Notifications {
private allowed(item: Candidate) {
return (
!this.closed &&
+ !this.state.developmentPaused &&
!item.cancelled &&
item.viewer === this.state.viewer &&
item.expires > Date.now() &&
diff --git a/vite.config.ts b/vite.config.ts
index 0d72c01fe..84b40e260 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -29,6 +29,9 @@ export default defineConfig(async ({ command, mode }) => {
plugins,
define: {
"import.meta.env.VITE_BUZZ_LIVE": JSON.stringify(live ? "1" : "0"),
+ "import.meta.env.VITE_BUZZ_NOTIFICATIONS_PAUSED": JSON.stringify(
+ command === "serve" && env.BUZZ_DEV_NOTIFICATIONS !== "1" ? "1" : "0",
+ ),
"import.meta.env.VITE_BUZZ_COMMUNITY_ALIASES": JSON.stringify(aliases),
},
clearScreen: false,
From efb9598a0eff8e5b5047cce075b20480df47a97c Mon Sep 17 00:00:00 2001
From: morgmart <98432065+morgmart@users.noreply.github.com>
Date: Mon, 21 Sep 2026 15:06:47 -0700
Subject: [PATCH 2/3] Isolate worktree fixtures from Git hook environment
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
---
dev/dev-desktop.test.mjs | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/dev/dev-desktop.test.mjs b/dev/dev-desktop.test.mjs
index f5606c663..b5e9d7c08 100644
--- a/dev/dev-desktop.test.mjs
+++ b/dev/dev-desktop.test.mjs
@@ -2,11 +2,27 @@ import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { afterEach, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { desktopArgs, worktreeLabel } from "../scripts/dev-desktop.mjs";
const directories = [];
+// Git hooks export repository selectors. Keep every fixture command, including
+// the real launcher under test, scoped to the disposable checkout instead.
+const repositoryEnvironment = [
+ "GIT_DIR",
+ "GIT_COMMON_DIR",
+ "GIT_WORK_TREE",
+ "GIT_IMPLICIT_WORK_TREE",
+ "GIT_INDEX_FILE",
+ "GIT_OBJECT_DIRECTORY",
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
+ "GIT_PREFIX",
+];
+beforeEach(() => {
+ for (const key of repositoryEnvironment) vi.stubEnv(key, undefined);
+});
afterEach(() => {
+ vi.unstubAllEnvs();
for (const path of directories.splice(0))
rmSync(path, { recursive: true, force: true });
});
From 9188d1f835718f79914892aa60f77d98f63e4b57 Mon Sep 17 00:00:00 2001
From: Carl
<3e3d196dd9859e7da50eb419bfc7e219beb702c8730adc23dfec69f30d5064df@buzz.block.builderlab.xyz>
Date: Mon, 21 Sep 2026 15:26:31 -0700
Subject: [PATCH 3/3] Limit dev notification pause to an explicit local opt-out
Signed-off-by: Carl <3e3d196dd9859e7da50eb419bfc7e219beb702c8730adc23dfec69f30d5064df@buzz.block.builderlab.xyz>
---
.env.example | 6 +-
dev/dev-desktop.test.mjs | 105 -------------
dev/vite-config.test.mjs | 47 ++++--
docs/contributing.md | 10 +-
docs/notifications.md | 13 +-
justfile | 2 +-
scripts/dev-desktop.mjs | 73 ---------
scripts/generate-dev-icon.swift | 216 --------------------------
src/app/NotificationSettings.test.tsx | 2 +-
src/app/NotificationSettings.tsx | 2 +-
vite.config.ts | 2 +-
11 files changed, 54 insertions(+), 424 deletions(-)
delete mode 100644 dev/dev-desktop.test.mjs
delete mode 100644 scripts/dev-desktop.mjs
delete mode 100755 scripts/generate-dev-icon.swift
diff --git a/.env.example b/.env.example
index 9cda97806..1a8354876 100644
--- a/.env.example
+++ b/.env.example
@@ -13,7 +13,7 @@ BUZZ_DEV_VIEWER=
# Values are public configuration included in the frontend bundle, never secrets.
# BUZZ_COMMUNITY_ALIASES='{"primary":"wss://relay.example.com","secondary":"wss://other.example.com"}'
-# Local dev servers pause notifications by default, without changing saved choices.
-# Opt in for notification testing; saved choices and OS permissions still apply.
+# Optional: pause notifications in this checkout without changing saved choices.
+# Only 0 pauses them; unset keeps normal notification behavior and OS permissions.
# Restart the dev server after changing this. Production builds ignore it.
-# BUZZ_DEV_NOTIFICATIONS=1
+# BUZZ_DEV_NOTIFICATIONS=0
diff --git a/dev/dev-desktop.test.mjs b/dev/dev-desktop.test.mjs
deleted file mode 100644
index b5e9d7c08..000000000
--- a/dev/dev-desktop.test.mjs
+++ /dev/null
@@ -1,105 +0,0 @@
-import { execFileSync } from "node:child_process";
-import { mkdtempSync, rmSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, expect, it, vi } from "vitest";
-import { desktopArgs, worktreeLabel } from "../scripts/dev-desktop.mjs";
-
-const directories = [];
-// Git hooks export repository selectors. Keep every fixture command, including
-// the real launcher under test, scoped to the disposable checkout instead.
-const repositoryEnvironment = [
- "GIT_DIR",
- "GIT_COMMON_DIR",
- "GIT_WORK_TREE",
- "GIT_IMPLICIT_WORK_TREE",
- "GIT_INDEX_FILE",
- "GIT_OBJECT_DIRECTORY",
- "GIT_ALTERNATE_OBJECT_DIRECTORIES",
- "GIT_PREFIX",
-];
-beforeEach(() => {
- for (const key of repositoryEnvironment) vi.stubEnv(key, undefined);
-});
-afterEach(() => {
- vi.unstubAllEnvs();
- for (const path of directories.splice(0))
- rmSync(path, { recursive: true, force: true });
-});
-function fixture() {
- const base = mkdtempSync(join(tmpdir(), "buzz-dev-launch-"));
- directories.push(base);
- const main = join(base, "main");
- const linked = join(base, 'linked "checkout"');
- const git = (...args) => execFileSync("git", args, { stdio: "pipe" });
- git("init", "--initial-branch=main", main);
- git(
- "-C",
- main,
- "-c",
- "user.name=Test",
- "-c",
- "user.email=test@example.com",
- "-c",
- "core.hooksPath=/dev/null",
- "-c",
- "commit.gpgsign=false",
- "commit",
- "--allow-empty",
- "-m",
- "fixture",
- );
- git(
- "-C",
- main,
- "-c",
- "core.hooksPath=/dev/null",
- "worktree",
- "add",
- "-b",
- "person/onboarding-feedback",
- linked,
- );
- return { main, linked, git };
-}
-it("labels linked worktrees by branch suffix and detached trees by directory", () => {
- const { main, linked, git } = fixture();
- expect(worktreeLabel(main)).toBeNull();
- expect(worktreeLabel(linked)).toBe("onboarding-feedback");
- git("-C", linked, "-c", "core.hooksPath=/dev/null", "checkout", "--detach");
- expect(worktreeLabel(linked)).toBe('linked "checkout"');
-});
-it("overrides only the development icon, safely encoding checkout paths", () => {
- const { linked } = fixture();
- const run = vi.fn(() => ({ status: 0 }));
- const args = desktopArgs({ cwd: linked, platform: "darwin", run });
- expect(args.slice(0, 3)).toEqual(["tauri", "dev", "--config"]);
- expect(JSON.parse(args[3])).toEqual({
- bundle: { icon: [join(linked, "src-tauri/target/dev-icons/icon.icns")] },
- });
- expect(run.mock.calls[0][1].at(-1)).toBe("onboarding-feedback");
-});
-it("leaves ordinary checkouts and non-macOS launches alone", () => {
- const { main, linked } = fixture();
- const run = vi.fn();
- expect(desktopArgs({ cwd: main, platform: "darwin", run })).toEqual([
- "tauri",
- "dev",
- ]);
- expect(desktopArgs({ cwd: linked, platform: "linux", run })).toEqual([
- "tauri",
- "dev",
- ]);
- expect(run).not.toHaveBeenCalled();
-});
-it.each([{ status: 1 }, { status: null, error: new Error("Swift missing") }])(
- "warns and falls back when icon generation fails: %j",
- (result) => {
- const { linked } = fixture();
- const warn = vi.fn();
- expect(
- desktopArgs({ cwd: linked, platform: "darwin", run: () => result, warn }),
- ).toEqual(["tauri", "dev"]);
- expect(warn).toHaveBeenCalledOnce();
- },
-);
diff --git a/dev/vite-config.test.mjs b/dev/vite-config.test.mjs
index f0ecb3cb1..60ae5d35f 100644
--- a/dev/vite-config.test.mjs
+++ b/dev/vite-config.test.mjs
@@ -13,21 +13,48 @@ it("loads the broker's Vite config without native-compatibility warnings", () =>
"-e",
`
import assert from 'node:assert/strict';
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+ import { tmpdir } from 'node:os';
+ import { join } from 'node:path';
import { loadConfigFromFile } from 'vite';
- const loaded = await loadConfigFromFile(
- { command: 'serve', mode: 'development' },
- );
- assert(loaded?.config.plugins.some(p => p?.name === 'buzz-relay-broker'));
- assert.equal(loaded.config.define['import.meta.env.VITE_BUZZ_LIVE'], '"1"');
- for (const command of ['serve', 'build']) {
- for (const optIn of ['', '0', '1', 'true', 'invalid']) {
- process.env.BUZZ_DEV_NOTIFICATIONS = optIn;
- const result = await loadConfigFromFile({ command, mode: 'development' });
+ const configFile = join(process.cwd(), 'vite.config.ts');
+ // Load the real config with isolated .env files, never personal settings.
+ const directory = mkdtempSync(join(tmpdir(), 'buzz-vite-config-'));
+ const cwd = process.cwd();
+ try {
+ process.chdir(directory);
+ const loaded = await loadConfigFromFile(
+ { command: 'serve', mode: 'development' }, configFile,
+ );
+ assert(loaded?.config.plugins.some(p => p?.name === 'buzz-relay-broker'));
+ assert.equal(loaded.config.define['import.meta.env.VITE_BUZZ_LIVE'], '"1"');
+ for (const command of ['serve', 'build']) {
+ for (const setting of [undefined, '', '0', '1', 'true', 'invalid']) {
+ if (setting === undefined) delete process.env.BUZZ_DEV_NOTIFICATIONS;
+ else process.env.BUZZ_DEV_NOTIFICATIONS = setting;
+ const result = await loadConfigFromFile(
+ { command, mode: command === 'serve' ? 'development' : 'production' }, configFile,
+ );
+ assert.equal(
+ result.config.define['import.meta.env.VITE_BUZZ_NOTIFICATIONS_PAUSED'],
+ JSON.stringify(command === 'serve' && setting === '0' ? '1' : '0'),
+ );
+ }
+ }
+ delete process.env.BUZZ_DEV_NOTIFICATIONS;
+ writeFileSync('.env.local', 'BUZZ_DEV_NOTIFICATIONS=0');
+ for (const command of ['serve', 'build']) {
+ const result = await loadConfigFromFile(
+ { command, mode: 'development' }, configFile,
+ );
assert.equal(
result.config.define['import.meta.env.VITE_BUZZ_NOTIFICATIONS_PAUSED'],
- JSON.stringify(command === 'serve' && optIn !== '1' ? '1' : '0'),
+ JSON.stringify(command === 'serve' ? '1' : '0'),
);
}
+ } finally {
+ process.chdir(cwd);
+ rmSync(directory, { recursive: true, force: true });
}
`,
],
diff --git a/docs/contributing.md b/docs/contributing.md
index 77311cebb..e007f8ecf 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -47,13 +47,9 @@ need their own validation.
- `just web`: install locked dependencies and start Vite on port 1430 or the
next available port, allowing parallel browser development across worktrees.
- `just desktop`: install locked dependencies and start Tauri, which starts Vite.
- On macOS linked worktrees, the Dock icon displays the branch’s final name segment
- (or the worktree directory name for detached HEAD). Relaunch after changing branches.
- The generated icon stays in ignored build output; production icons, app identity,
- and ports are unchanged. If generation fails, startup warns and uses the normal
- icon. Direct `pnpm tauri dev` bypasses this label.
-- Local dev servers pause notifications unless `BUZZ_DEV_NOTIFICATIONS=1` is set
- in `.env.local`; restart the server after changing it. Saved preferences are
+- To pause notifications in your local dev server, set `BUZZ_DEV_NOTIFICATIONS=0`
+ in `.env.local` and restart the server. Only `0` pauses alerts and permission
+ requests; removing the setting restores normal behavior. Saved preferences are
untouched and production builds ignore the variable.
- `just fullstack`: reserved, exits unsuccessfully with an explanation. It will
eventually start local Docker services including the Buzz relay backend.
diff --git a/docs/notifications.md b/docs/notifications.md
index f09d3dfbb..2a63fd1a5 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -2,12 +2,13 @@
The host provides one `notifications` service for built-in messages and trusted
plugins. Settings → Notifications stores account-local choices: alerts are on
-by default in production, subject to system permission; master off preserves category choices.
-Local dev servers pause all alerts and permission requests unless
-`BUZZ_DEV_NOTIFICATIONS=1` is set in `.env.local`. Restart the server after changing
-it. This development gate never rewrites saved preferences; opting in still
-honors account choices and system permission. Settings shows the pause and how to
-enable notifications. Production builds ignore this variable.
+by default, subject to system permission; master off preserves category choices.
+To pause all alerts and permission requests in a local dev server, set
+`BUZZ_DEV_NOTIFICATIONS=0` in `.env.local` and restart the server. Only `0` pauses
+notifications; unset or any other value keeps normal behavior. This development
+gate never rewrites saved preferences. Settings shows the pause and how to remove
+it; normal behavior still honors account choices and system permission.
+Production builds ignore this variable.
Browser sound uses the Notification API. Desktop sound is managed in OS settings;
there is no separate audio player.
diff --git a/justfile b/justfile
index d95013a8e..b054b841a 100644
--- a/justfile
+++ b/justfile
@@ -14,7 +14,7 @@ web: install
# Run the shared frontend in Tauri (requires native toolchain).
desktop: install
- node scripts/dev-desktop.mjs
+ pnpm tauri dev
# Run buzzodz; forward arguments unchanged (Rust required).
[positional-arguments]
diff --git a/scripts/dev-desktop.mjs b/scripts/dev-desktop.mjs
deleted file mode 100644
index 9cca633b9..000000000
--- a/scripts/dev-desktop.mjs
+++ /dev/null
@@ -1,73 +0,0 @@
-import { execFileSync, spawnSync } from "node:child_process";
-import { mkdirSync } from "node:fs";
-import { basename, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-
-const root = fileURLToPath(new URL("../", import.meta.url));
-
-export function worktreeLabel(cwd) {
- const git = (...args) =>
- execFileSync("git", args, {
- cwd,
- encoding: "utf8",
- stdio: ["ignore", "pipe", "pipe"],
- }).trim();
- try {
- if (
- git("rev-parse", "--path-format=absolute", "--git-dir") ===
- git("rev-parse", "--path-format=absolute", "--git-common-dir")
- )
- return null;
- const branch = git("branch", "--show-current");
- return branch
- ? branch.split("/").at(-1)
- : basename(git("rev-parse", "--show-toplevel"));
- } catch {
- return null;
- }
-}
-
-export function desktopArgs({
- cwd = root,
- platform = process.platform,
- run = spawnSync,
- warn = console.warn,
-} = {}) {
- const args = ["tauri", "dev"];
- if (platform !== "darwin") return args;
- const label = worktreeLabel(cwd);
- if (!label) return args;
- try {
- const icon = resolve(cwd, "src-tauri/target/dev-icons/icon.icns");
- mkdirSync(resolve(cwd, "src-tauri/target/dev-icons"), { recursive: true });
- const result = run(
- "swift",
- [
- resolve(cwd, "scripts/generate-dev-icon.swift"),
- resolve(cwd, "src-tauri/icons/icon.icns"),
- icon,
- label,
- ],
- { cwd, stdio: "inherit" },
- );
- if (result.error || result.status !== 0)
- throw result.error ?? new Error("Icon generator failed");
- args.push("--config", JSON.stringify({ bundle: { icon: [icon] } }));
- } catch {
- warn("Worktree icon could not be generated; using the ordinary Buzz icon.");
- }
- return args;
-}
-
-if (
- process.argv[1] &&
- resolve(process.argv[1]) === fileURLToPath(import.meta.url)
-) {
- const result = spawnSync(
- resolve(root, "bin/pnpm"),
- [...desktopArgs(), ...process.argv.slice(2)],
- { cwd: root, stdio: "inherit" },
- );
- if (result.error) console.error(result.error.message);
- process.exitCode = result.status ?? 1;
-}
diff --git a/scripts/generate-dev-icon.swift b/scripts/generate-dev-icon.swift
deleted file mode 100755
index b324478cc..000000000
--- a/scripts/generate-dev-icon.swift
+++ /dev/null
@@ -1,216 +0,0 @@
-#!/usr/bin/env swift
-
-import AppKit
-import Foundation
-
-// Ported from block/buzz scripts/generate-dev-icon.swift.
-// Generate a dev icon with worktree name badge
-// Usage: generate-dev-icon.swift
-
-guard CommandLine.arguments.count == 4 else {
- fputs("Usage: \(CommandLine.arguments[0]) \n", stderr)
- exit(1)
-}
-
-let inputPath = CommandLine.arguments[1]
-let outputPath = CommandLine.arguments[2]
-let label = CommandLine.arguments[3]
-
-func sanitizedPathComponent(_ value: String) -> String {
- let lowered = value.lowercased()
- let sanitizedScalars = lowered.unicodeScalars.map { scalar -> Character in
- switch scalar {
- case "a"..."z", "0"..."9":
- return Character(scalar)
- default:
- return "-"
- }
- }
- let collapsed = String(sanitizedScalars).replacingOccurrences(
- of: "-+",
- with: "-",
- options: .regularExpression
- )
- let trimmed = collapsed.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
- return trimmed.isEmpty ? "dev" : trimmed
-}
-
-guard let iconImage = NSImage(contentsOfFile: inputPath) else {
- fputs("Failed to load image: \(inputPath)\n", stderr)
- exit(1)
-}
-
-// Get the largest representation for best quality
-guard let rep = iconImage.representations.max(by: { $0.pixelsWide < $1.pixelsWide }) else {
- fputs("No image representations found\n", stderr)
- exit(1)
-}
-
-let size = NSSize(width: rep.pixelsWide, height: rep.pixelsHigh)
-
-let newImage = NSImage(size: size)
-newImage.lockFocus()
-
-// Draw the original icon
-iconImage.draw(in: NSRect(origin: .zero, size: size))
-
-// Configure badge
-let singleLineHeight = size.height * 0.22
-let padding = size.width * 0.03
-let maxBadgeWidth = size.width * 0.9
-
-// Text attributes - calculate font size to fit
-let maxFontSize = singleLineHeight * 0.65
-var fontSize = maxFontSize
-var attributes: [NSAttributedString.Key: Any]
-
-// Helper to wrap text on `-` characters
-func wrapText(_ text: String, maxWidth: CGFloat, attributes: [NSAttributedString.Key: Any]) -> [String] {
- let singleLineSize = (text as NSString).size(withAttributes: attributes)
- if singleLineSize.width <= maxWidth {
- return [text]
- }
-
- // Split on `-` and try to form lines
- let parts = text.components(separatedBy: "-")
- if parts.count == 1 {
- return [text] // No `-` to wrap on
- }
-
- var lines: [String] = []
- var currentLine = ""
-
- for (index, part) in parts.enumerated() {
- let separator = index == 0 ? "" : "-"
- let testLine = currentLine.isEmpty ? part : currentLine + separator + part
- let testSize = (testLine as NSString).size(withAttributes: attributes)
-
- if testSize.width <= maxWidth || currentLine.isEmpty {
- currentLine = testLine
- } else {
- lines.append(currentLine)
- currentLine = part
- }
- }
- if !currentLine.isEmpty {
- lines.append(currentLine)
- }
-
- return lines
-}
-
-// Find font size that fits (allowing up to 2 lines)
-var lines: [String] = []
-repeat {
- attributes = [
- .font: NSFont.systemFont(ofSize: fontSize, weight: .bold),
- .foregroundColor: NSColor.white
- ]
- lines = wrapText(label, maxWidth: maxBadgeWidth - padding * 4, attributes: attributes)
- fontSize -= 1
-} while (lines.count > 2 || lines.contains {
- ($0 as NSString).size(withAttributes: attributes).width > maxBadgeWidth - padding * 4
-}) && fontSize > 1
-
-// Calculate text dimensions using typographic metrics
-let lineHeight = (lines.first! as NSString).size(withAttributes: attributes).height
-let textHeight = lineHeight * CGFloat(lines.count)
-let maxLineWidth = lines.map { ($0 as NSString).size(withAttributes: attributes).width }.max() ?? 0
-
-// Badge dimensions based on text
-let badgeHeight = textHeight + padding * 2
-let cornerRadius = badgeHeight * 0.2
-let badgeWidth = maxLineWidth + padding * 4
-let badgeX = (size.width - badgeWidth) / 2
-let badgeY = size.height - badgeHeight - padding - size.height * 0.05
-
-// Draw badge background (light blue semi-transparent)
-let badgePath = NSBezierPath(roundedRect: NSRect(x: badgeX, y: badgeY, width: badgeWidth, height: badgeHeight),
- xRadius: cornerRadius, yRadius: cornerRadius)
-NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: 0.85).setFill()
-badgePath.fill()
-
-// Draw text centered in badge (multiple lines, bottom to top)
-for (index, line) in lines.reversed().enumerated() {
- let lineSize = (line as NSString).size(withAttributes: attributes)
- let textX = badgeX + (badgeWidth - lineSize.width) / 2
- let textY = badgeY + padding + lineHeight * CGFloat(index)
- (line as NSString).draw(at: NSPoint(x: textX, y: textY), withAttributes: attributes)
-}
-
-newImage.unlockFocus()
-
-// First, create PNG data at multiple sizes for icns
-guard let tiffData = newImage.tiffRepresentation,
- let bitmapRep = NSBitmapImageRep(data: tiffData) else {
- fputs("Failed to create bitmap representation\n", stderr)
- exit(1)
-}
-
-// For simplicity, we'll create a PNG and then use iconutil if available,
-// or just save as PNG for the icon (Tauri can use PNG)
-guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else {
- fputs("Failed to create PNG data\n", stderr)
- exit(1)
-}
-
-// If output is .icns, we need to create an iconset and use iconutil
-if outputPath.hasSuffix(".icns") {
- let tempDir = FileManager.default.temporaryDirectory
- let iconsetParent = tempDir.appendingPathComponent(
- "staged-dev-\(sanitizedPathComponent(label))-\(UUID().uuidString)",
- isDirectory: true
- )
- let iconsetPath = iconsetParent.appendingPathComponent("icon.iconset", isDirectory: true)
- try! FileManager.default.createDirectory(at: iconsetPath, withIntermediateDirectories: true)
-
- // Generate all required sizes for iconset
- let sizes: [(name: String, size: Int)] = [
- ("icon_16x16", 16),
- ("icon_16x16@2x", 32),
- ("icon_32x32", 32),
- ("icon_32x32@2x", 64),
- ("icon_128x128", 128),
- ("icon_128x128@2x", 256),
- ("icon_256x256", 256),
- ("icon_256x256@2x", 512),
- ("icon_512x512", 512),
- ("icon_512x512@2x", 1024)
- ]
-
- for (name, targetSize) in sizes {
- let resizedImage = NSImage(size: NSSize(width: targetSize, height: targetSize))
- resizedImage.lockFocus()
- NSGraphicsContext.current?.imageInterpolation = .high
- newImage.draw(in: NSRect(x: 0, y: 0, width: targetSize, height: targetSize))
- resizedImage.unlockFocus()
-
- guard let resizedTiff = resizedImage.tiffRepresentation,
- let resizedBitmap = NSBitmapImageRep(data: resizedTiff),
- let resizedPng = resizedBitmap.representation(using: .png, properties: [:]) else {
- continue
- }
-
- let filePath = iconsetPath.appendingPathComponent("\(name).png")
- try! resizedPng.write(to: filePath)
- }
-
- // Use iconutil to create icns
- let process = Process()
- process.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
- process.arguments = ["-c", "icns", iconsetPath.path, "-o", outputPath]
- try! process.run()
- process.waitUntilExit()
-
- try? FileManager.default.removeItem(at: iconsetParent)
-
- if process.terminationStatus != 0 {
- fputs("iconutil failed\n", stderr)
- exit(1)
- }
-} else {
- // Just save as PNG
- try! pngData.write(to: URL(fileURLWithPath: outputPath))
-}
-
-print("Generated: \(outputPath)")
diff --git a/src/app/NotificationSettings.test.tsx b/src/app/NotificationSettings.test.tsx
index b7d2ca1fc..29ca44bc3 100644
--- a/src/app/NotificationSettings.test.tsx
+++ b/src/app/NotificationSettings.test.tsx
@@ -51,7 +51,7 @@ it.each([true, false])(
expect(toggle).toBeDisabled();
expect(toggle).not.toBeChecked();
expect(screen.getByRole("status")).toHaveTextContent(
- "BUZZ_DEV_NOTIFICATIONS=1",
+ "Remove BUZZ_DEV_NOTIFICATIONS=0",
);
expect(
screen.queryByRole("button", { name: "Allow notifications" }),
diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx
index fc9904bc3..d14dd35bc 100644
--- a/src/app/NotificationSettings.tsx
+++ b/src/app/NotificationSettings.tsx
@@ -29,7 +29,7 @@ export function NotificationSettings({
/>
{state.developmentPaused
- ? "Notifications are paused in local development. Set BUZZ_DEV_NOTIFICATIONS=1 in .env.local and restart the dev server to enable them. Your saved alert choices are unchanged."
+ ? "Notifications are paused by your local development setting. Remove BUZZ_DEV_NOTIFICATIONS=0 from .env.local and restart the dev server to resume normal behavior. Your saved alert choices are unchanged."
: state.requesting
? "Waiting for system permission…"
: permission === "granted"
diff --git a/vite.config.ts b/vite.config.ts
index 84b40e260..b569bbe90 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -30,7 +30,7 @@ export default defineConfig(async ({ command, mode }) => {
define: {
"import.meta.env.VITE_BUZZ_LIVE": JSON.stringify(live ? "1" : "0"),
"import.meta.env.VITE_BUZZ_NOTIFICATIONS_PAUSED": JSON.stringify(
- command === "serve" && env.BUZZ_DEV_NOTIFICATIONS !== "1" ? "1" : "0",
+ command === "serve" && env.BUZZ_DEV_NOTIFICATIONS === "0" ? "1" : "0",
),
"import.meta.env.VITE_BUZZ_COMMUNITY_ALIASES": JSON.stringify(aliases),
},