From 73589408db6fd96b87ac570935d414ecc4120f53 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 30 Jul 2026 15:58:48 +0100 Subject: [PATCH 01/15] fix: align responsive agent views (#3688) ## Summary - Keep the Agents header full width while cards reflow independently. - Collapse header actions into an overflow menu at the compact layout threshold. - Apply the same responsive grid rules to Agent Teams. ## Validation - `pnpm -C desktop build:e2e` - Focused Agents Playwright coverage - Pre-push desktop checks and unit tests --------- Signed-off-by: kenny lopez --- desktop/src/features/agents/ui/AgentsView.tsx | 124 ++++++++++++----- .../src/features/agents/ui/TeamsSection.tsx | 6 +- .../agents/ui/UnifiedAgentsSection.tsx | 8 +- desktop/tests/e2e/agents.spec.ts | 129 +++++++++++++++++- 4 files changed, 223 insertions(+), 44 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index f24a3c06d..3d1673c36 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { OctagonX, Settings2 } from "lucide-react"; +import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react"; import { consumePendingSnapshotImport, subscribeSnapshotImport, @@ -20,10 +20,7 @@ import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; -import { - AGENT_CARD_GRID_COLUMNS_CLASS, - UnifiedAgentsSection, -} from "./UnifiedAgentsSection"; +import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -32,6 +29,12 @@ import { useBakedBuildEnvQuery } from "@/features/agents/hooks"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { PageHeader } from "@/shared/ui/PageHeader"; import { getInheritedAgentDefaults } from "./bakedEnvHelpers"; @@ -44,6 +47,8 @@ export function AgentsView() { const personas = usePersonaActions(); const teamImportInputRef = React.useRef(null); const aiDefaultsTriggerRef = React.useRef(null); + const fullAiDefaultsTriggerRef = React.useRef(null); + const compactActionsTriggerRef = React.useRef(null); const [isAiDefaultsOpen, setIsAiDefaultsOpen] = React.useState(false); // Exclusivity: create never sets `personaDialogState` (edit/dup/import do), // so the create-mode and definition-edit AgentDialog mounts never coexist. @@ -53,6 +58,22 @@ export function AgentsView() { personas.prepareCreate(); setIsCreateDialogOpen(true); } + + function openAiDefaults(trigger: HTMLButtonElement | null) { + aiDefaultsTriggerRef.current = trigger; + setIsAiDefaultsOpen(true); + } + + function setAiDefaultsDialogOpen(open: boolean) { + if (!open) { + aiDefaultsTriggerRef.current = + fullAiDefaultsTriggerRef.current?.offsetParent !== null + ? fullAiDefaultsTriggerRef.current + : compactActionsTriggerRef.current; + } + setIsAiDefaultsOpen(open); + } + const teamActions = useTeamActions( { setActionNoticeMessage: agents.setActionNoticeMessage, @@ -113,43 +134,84 @@ export function AgentsView() { <>
- - {runningAgentCount > 0 ? ( + <> +
- ) : null} -
+ {runningAgentCount > 0 ? ( + + ) : null} +
+ + + + + + + { + openAiDefaults(compactActionsTriggerRef.current); + }} + > + + {hasSavedAgentDefaults + ? "Agent defaults" + : "Set agent defaults"} + + {runningAgentCount > 0 ? ( + { + void agents.handleBulkStopRunning(); + }} + > + + Stop running agents + + ) : null} + + + } description="Set up and manage your agents." title="Agents" /> -
+
diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index f974081b7..c5a7a078b 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -20,9 +20,9 @@ import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; import { SectionHeader } from "@/shared/ui/PageHeader"; import { CreateIdentityCard } from "./CreateIdentityCard"; import { TeamIdentityCard } from "./TeamIdentityCard"; +import { IDENTITY_CARD_GRID_CLASS } from "./UnifiedAgentsSection"; const TEAM_CARD_COLUMN_CLASS = "w-full"; -const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} mx-auto grid max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`; type TeamsSectionProps = { teams: AgentTeam[]; @@ -63,7 +63,7 @@ export function TeamsSection({
{isLoading ? ( -
+
+
{teams.map((team) => { const resolution = resolveTeamPersonas(team, personas); const missingPersonaCount = resolution.missingPersonaCount; diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 9bbe3feef..19a5ef117 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -68,7 +68,7 @@ type UnifiedAgentsSectionProps = { const AGENT_CARD_COLUMN_CLASS = "w-full"; export const AGENT_CARD_GRID_COLUMNS_CLASS = "grid-cols-[repeat(auto-fill,minmax(220px,240px))]"; -const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3`; +export const IDENTITY_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3 [@container(max-width:40rem)]:justify-center`; export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const { @@ -153,7 +153,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { {!isLoading ? (
-
+
{groups.map((group) => { const profileAgent = pickProfileAgent(group.agents); return ( @@ -479,7 +479,7 @@ function NewAgentCard({ function LoadingSkeleton() { return ( -
+
({agents.length}) {!isCollapsed ? ( -
+
{agents.map((agent) => ( cards.map((card) => { const box = card.getBoundingClientRect(); - return { right: box.right, top: box.top }; + return { left: box.left, right: box.right, top: box.top }; }), ); const firstRowTop = Math.min(...cardBoxes.map(({ top }) => top)); @@ -425,12 +425,16 @@ test("the new agent card offers create, discover, and import", async ({ .filter(({ top }) => Math.abs(top - firstRowTop) < 1) .map(({ right }) => right), ); + const leftmostFirstRowCard = Math.min( + ...cardBoxes + .filter(({ top }) => Math.abs(top - firstRowTop) < 1) + .map(({ left }) => left), + ); expect(headerBox).not.toBeNull(); - expect( - Math.abs( - (headerBox?.x ?? 0) + (headerBox?.width ?? 0) - rightmostFirstRowCard, - ), - ).toBeLessThan(1); + expect(Math.abs((headerBox?.x ?? 0) - leftmostFirstRowCard)).toBeLessThan(1); + expect(rightmostFirstRowCard).toBeLessThanOrEqual( + (headerBox?.x ?? 0) + (headerBox?.width ?? 0) + 1, + ); await newAgentCard.click(); await expect( @@ -492,6 +496,63 @@ test("the new team card offers create and import", async ({ page }) => { ).toBeVisible(); }); +test("team cards follow the agents grid alignment at compact widths", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:team-layout", + displayName: "Team layout agent", + systemPrompt: "A test agent for team layout alignment.", + }, + ], + teams: [ + { + id: "team-layout", + name: "Team layout", + personaIds: ["custom:team-layout"], + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const agentsContent = page.getByTestId("agents-page-content"); + const firstAgentCard = page.getByTestId( + "persona-agent-row-custom:team-layout", + ); + const firstTeamCard = page.getByTestId("team-card-team-layout"); + const agentGrid = firstAgentCard.locator("xpath=.."); + const teamGrid = firstTeamCard.locator("xpath=.."); + const firstAgentGridCard = agentGrid.locator(":scope > *").first(); + const firstTeamGridCard = teamGrid.locator(":scope > *").first(); + + await agentsContent.evaluate((element) => { + (element as HTMLElement).style.width = "650px"; + }); + const wideAgentBox = await firstAgentGridCard.boundingBox(); + const wideTeamBox = await firstTeamGridCard.boundingBox(); + expect(wideAgentBox).not.toBeNull(); + expect(wideTeamBox).not.toBeNull(); + expect(Math.abs((wideAgentBox?.x ?? 0) - (wideTeamBox?.x ?? 0))).toBeLessThan( + 1, + ); + + await agentsContent.evaluate((element) => { + (element as HTMLElement).style.width = "600px"; + }); + await expect + .poll(async () => (await firstAgentGridCard.boundingBox())?.x ?? 0) + .toBeGreaterThan(wideAgentBox?.x ?? 0); + + const compactAgentBox = await firstAgentGridCard.boundingBox(); + const compactTeamBox = await firstTeamGridCard.boundingBox(); + expect( + Math.abs((compactAgentBox?.x ?? 0) - (compactTeamBox?.x ?? 0)), + ).toBeLessThan(1); +}); + test("team cards use the thread-style overlapping avatar stack", async ({ page, }) => { @@ -634,6 +695,62 @@ test("unconfigured agent defaults use the setup label", async ({ page }) => { ); }); +test("moves agent actions into an overflow menu in a narrow view", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:compact-actions", + displayName: "Compact actions agent", + isActive: true, + systemPrompt: "A test agent for compact header actions.", + }, + ], + managedAgents: [ + { + name: "Compact actions instance", + personaId: "custom:compact-actions", + pubkey: "cd".repeat(32), + status: "running", + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("agents-page-content").evaluate((element) => { + (element as HTMLElement).style.width = "650px"; + }); + + await expect(page.getByTestId("agent-defaults-button")).toBeVisible(); + await expect( + page.getByText("Set up and manage your agents.", { exact: true }), + ).toHaveJSProperty("scrollHeight", 24); + + await page.getByTestId("agents-page-content").evaluate((element) => { + (element as HTMLElement).style.width = "600px"; + }); + await expect(page.getByTestId("agent-defaults-button")).toBeHidden(); + await page.getByTestId("agent-actions-menu-trigger").click(); + await expect( + page.getByRole("menuitem", { name: "Set agent defaults" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Stop running agents" }), + ).toBeVisible(); + + await page.getByRole("menuitem", { name: "Set agent defaults" }).click(); + await expect(page.getByTestId("agent-ai-defaults-dialog")).toBeVisible(); + + await page.getByTestId("agents-page-content").evaluate((element) => { + (element as HTMLElement).style.width = "650px"; + }); + await expect(page.getByTestId("agent-defaults-button")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("agent-ai-defaults-dialog")).toHaveCount(0); + await expect(page.getByTestId("agent-defaults-button")).toBeFocused(); +}); + test("agent catalog chooser order stays stable when selection changes", async ({ page, }) => { From c9aa55505c544c608ff71648bbfd21b235637f19 Mon Sep 17 00:00:00 2001 From: Matthew Beckley Date: Thu, 30 Jul 2026 11:19:42 -0400 Subject: [PATCH 02/15] desktop: enable getUserMedia in the Linux WebKitGTK webview (#3607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Microphone/camera capture works on macOS (WKWebView) and Windows (WebView2) but fails on Linux with `NotAllowedError`. WebKitGTK ships with `enable-media-stream` off and a default `permission-request` handler that denies every request. This reaches the underlying `webkit2gtk::WebView` from `on_webview_ready` and enables `enable-media-stream`, then installs a **deny-by-default** `permission-request` handler: a `UserMedia` request is allowed only from a trusted app origin (`tauri://localhost` in prod, the Vite dev origin in debug) **and** when it targets an audio/video device — everything else is denied. No-op on macOS/Windows. - `webkit2gtk` is pinned to the version wry already uses (`=2.0.2`) so there's a single shared copy of the native binding. --------- Signed-off-by: Beckley --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 4 + desktop/src-tauri/src/lib.rs | 6 ++ desktop/src-tauri/src/linux_media.rs | 141 +++++++++++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 desktop/src-tauri/src/linux_media.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index d1b11b289..7e23289f5 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1087,6 +1087,7 @@ dependencies = [ "url", "user-idle", "uuid", + "webkit2gtk", "window-vibrancy", "windows-sys 0.61.2", "zeroize", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 90f90870f..b5b119185 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -40,6 +40,10 @@ keyring = { version = "3.6.3", default-features = false, features = ["sync-secre # connection is dropped, which the plugin does immediately. Default features # keep the pure-Rust zbus backend, matching the plugin (no libdbus needed). notify-rust = "4" +# Enable getUserMedia in the WebKitGTK webview (see src/linux_media.rs). Pinned +# to the exact version wry links so both resolve to one webkit2gtk-sys and we +# don't get duplicate symbols; bump in lockstep with wry. +webkit2gtk = { version = "=2.0.2", features = ["v2_22"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.6.4", default-features = false } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35ba41bad..c005d511e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod deep_link; mod event_sync; mod events; mod huddle; +mod linux_media; mod managed_agents; mod media_proxy; #[cfg(feature = "mesh-llm")] @@ -199,6 +200,11 @@ pub fn run() { return; } + // Linux/WebKitGTK needs media-stream settings and a + // permission-request handler for getUserMedia; no-op + // on macOS/Windows. + linux_media::enable_media_capture(&webview); + // macOS applies the restored geometry asynchronously. Wait // for several identical outer bounds and for React to // commit the startup surface before revealing it. diff --git a/desktop/src-tauri/src/linux_media.rs b/desktop/src-tauri/src/linux_media.rs new file mode 100644 index 000000000..c768e1542 --- /dev/null +++ b/desktop/src-tauri/src/linux_media.rs @@ -0,0 +1,141 @@ +//! Linux-only: enable media capture (`getUserMedia`) in the WebKitGTK webview. +//! +//! On macOS (WKWebView) and Windows (WebView2) the media-permission prompt is +//! routed to the OS automatically, so microphone/camera capture "just works". +//! WebKitGTK is different on two counts, and both must be handled or capture +//! fails on Linux only: +//! +//! * `enable-media-stream` is **off by default**, so `navigator.mediaDevices` +//! never exposes a working `getUserMedia`; and +//! * the default `permission-request` handler **denies every request**, so even +//! with media-stream on, the call rejects with `NotAllowedError`. +//! +//! This module reaches the underlying `webkit2gtk::WebView` via +//! [`tauri::Webview::with_webview`], enables media-stream, and installs a +//! `permission-request` handler that is **deny-by-default**: a `UserMedia` +//! request is allowed only when it comes from a trusted app origin and asks for +//! an audio and/or video device. Tauri does not restrict navigation by default, +//! so without the origin check any document that ended up in this webview would +//! inherit silent mic/camera access for the process lifetime. +//! +//! Buzz's AppImage pins `GDK_BACKEND=x11` (see [`crate::webkit_rendering`]), +//! which is the backend WebKitGTK media capture is reliable on. + +/// The origin Tauri serves the packaged app from on Linux. +const PROD_ORIGIN: &str = "tauri://localhost"; + +/// The Vite dev-server origin (`devUrl` in `tauri.conf.json`, `strictPort` +/// 1420 in `vite.config.ts`). Only trusted in debug builds. +#[cfg(debug_assertions)] +const DEV_ORIGIN: &str = "http://localhost:1420"; + +/// Whether `uri` (the webview's current document URI) is a trusted app origin +/// allowed to use mic/camera. Matches the origin exactly or as a path prefix so +/// `tauri://localhost.evil.com` and `http://localhost:14200` do not slip +/// through. Pure and platform-independent so it can be unit-tested everywhere. +fn is_trusted_media_origin(uri: &str) -> bool { + fn matches(uri: &str, origin: &str) -> bool { + uri == origin + || uri + .strip_prefix(origin) + .is_some_and(|rest| rest.starts_with('/')) + } + + if matches(uri, PROD_ORIGIN) { + return true; + } + #[cfg(debug_assertions)] + if matches(uri, DEV_ORIGIN) { + return true; + } + false +} + +/// Enable microphone/camera capture for `webview` if it is running on +/// WebKitGTK. A no-op on every non-Linux target, so callers can invoke it +/// unconditionally from shared startup code. +#[cfg(target_os = "linux")] +pub fn enable_media_capture(webview: &tauri::Webview) { + use webkit2gtk::{ + glib::prelude::Cast, PermissionRequestExt, SettingsExt, UserMediaPermissionRequest, + UserMediaPermissionRequestExt, WebViewExt, + }; + + // `with_webview` runs the closure on the UI thread, which GTK calls + // require. It errors only if the platform webview is unavailable. + let result = webview.with_webview(|platform_webview| { + // On Linux this is the underlying `webkit2gtk::WebView`. + let webview = platform_webview.inner(); + + if let Some(settings) = WebViewExt::settings(&webview) { + settings.set_enable_media_stream(true); + } + + // Deny-by-default: allow only mic/camera requests from a trusted app + // origin; deny everything else (still returning `true` so WebKit's + // auto-deny default does not also run). Non-`UserMedia` requests return + // `false` and keep their default handling. + webview.connect_permission_request(|wv, request| { + let Some(request) = request.downcast_ref::() else { + return false; + }; + + let uri = wv.uri().map(|u| u.to_string()).unwrap_or_default(); + let for_device = request.is_for_audio_device() || request.is_for_video_device(); + + if for_device && is_trusted_media_origin(&uri) { + request.allow(); + } else { + request.deny(); + } + true + }); + }); + + if let Err(error) = result { + eprintln!("buzz-desktop: could not enable WebKitGTK media capture: {error}"); + } +} + +/// No-op stub so shared startup code can call [`enable_media_capture`] on every +/// platform. macOS and Windows route media permissions through the OS. +#[cfg(not(target_os = "linux"))] +pub fn enable_media_capture(_webview: &tauri::Webview) {} + +#[cfg(test)] +mod tests { + use super::is_trusted_media_origin; + + #[test] + fn allows_production_app_origin() { + assert!(is_trusted_media_origin("tauri://localhost")); + assert!(is_trusted_media_origin( + "tauri://localhost/channels/general" + )); + } + + #[test] + fn denies_untrusted_origins() { + assert!(!is_trusted_media_origin("")); + assert!(!is_trusted_media_origin("https://evil.example.com")); + // Prefix look-alikes must not slip through. + assert!(!is_trusted_media_origin("tauri://localhost.evil.com")); + assert!(!is_trusted_media_origin("tauri://localhostfoo")); + } + + #[cfg(debug_assertions)] + #[test] + fn allows_dev_origin_in_debug_only() { + assert!(is_trusted_media_origin("http://localhost:1420")); + assert!(is_trusted_media_origin("http://localhost:1420/")); + // A different localhost port is still untrusted. + assert!(!is_trusted_media_origin("http://localhost:14200")); + assert!(!is_trusted_media_origin("http://localhost:3000")); + } + + #[cfg(not(debug_assertions))] + #[test] + fn denies_dev_origin_in_release() { + assert!(!is_trusted_media_origin("http://localhost:1420")); + } +} From 9a386a0defbf2b355ee17646c7c11817a535b85f Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 30 Jul 2026 16:23:16 +0100 Subject: [PATCH 03/15] Refine agent sharing dialog (#3699) ## Summary - Refine the agent share dialog around recipient sharing, link copying, catalog sharing, and export. - Show memory settings only when a linked agent has memories to include. - Use a catalog toggle for custom agents and keep built-in agents out of the catalog flow. ## Validation - `pnpm typecheck` - Focused Playwright share and catalog flows --------- Signed-off-by: kenny lopez Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- .../features/agents/ui/PersonaShareDialog.tsx | 149 +++++-------- desktop/tests/e2e/agents.spec.ts | 210 +++++++----------- 2 files changed, 137 insertions(+), 222 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index c641de9c7..5cf4f9ea3 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -37,10 +37,13 @@ import { Dialog, DialogClose, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; +import { Switch } from "@/shared/ui/switch"; import { formatShareRecipientName, @@ -63,7 +66,7 @@ type PersonaShareDialogProps = { }; type SnapshotShareDialogProps = { - afterLink?: React.ReactNode; + beforeExport?: React.ReactNode; displayName: string; encodeSnapshot: ( memoryLevel: SnapshotMemoryLevel, @@ -198,7 +201,6 @@ function MemoryShareConfirmation({ function ShareLevelControl({ ariaLabel, disabled, - hasMemoryOptions, testId, value, options, @@ -206,27 +208,11 @@ function ShareLevelControl({ }: { ariaLabel: string; disabled: boolean; - hasMemoryOptions: boolean; testId: string; value: SnapshotMemoryLevel; options: { value: SnapshotMemoryLevel; label: string }[]; onChange: (level: SnapshotMemoryLevel) => void; }) { - if (!hasMemoryOptions) { - // Nothing to choose from, so there is no dropdown to open. State the - // outcome rather than naming the sole option: the memory-level labels - // ("Agent only", "+ core memory", …) are comparative and only make sense - // when the alternatives are actually offered. - return ( - - No memories included - - ); - } - return ( - + Share {displayName} + + Anyone you share this {itemLabel} with will receive a copy they + can add and use. Changes you make later won’t sync. +
-

- They’ll receive a copy they can add and use. Changes you make - later won’t sync. -

+ {hasMemoryOptions ? ( +
+

+ Share settings +

+
+

+ What’s included +

+ +
+
+ ) : null} + + +
- - - -
-

Share with a link

-

- Anyone with the link can add and use a copy. -

-
-
-

- What’s included -

- -
- {showMemoryWarning ? ( ) : null} - - {afterLink}
+ {beforeExport} - + {actions?.length ? ( + + + + + + + + {actions.map((action) => ( + + {action.icon} + {action.label} + + ))} + + + ) : ( + + )}
diff --git a/desktop/src/features/settings/EncryptedBackupProvider.tsx b/desktop/src/features/settings/EncryptedBackupProvider.tsx new file mode 100644 index 000000000..7375a85cf --- /dev/null +++ b/desktop/src/features/settings/EncryptedBackupProvider.tsx @@ -0,0 +1,251 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + createNcryptsecBackup, + saveNcryptsecCopy, +} from "@/shared/api/tauriIdentity"; +import { + type EncryptedBackupEvent, + type EncryptedBackupState, + encryptedBackupReducer, + initialEncryptedBackupState, + pendingEncryptPassphrase, +} from "./lib/encryptedBackup"; + +const ENCRYPT_DEBOUNCE_MS = 400; +/** How long a completed encrypted backup remains available in memory. */ +export const BACKUP_AVAILABILITY_MS = 5 * 60 * 1000; +const BACKUP_READY_TOAST_ID = "encrypted-key-backup-ready"; + +type EncryptedBackupContextValue = { + state: EncryptedBackupState; + dispatch: React.Dispatch; + backupAvailable: boolean; + availableUntil: number | null; + isSaving: boolean; + saveError: string | null; + downloadBackup: () => Promise; + startNewBackup: () => void; +}; + +const EncryptedBackupContext = + React.createContext(null); + +export function EncryptedBackupProvider({ + children, + onOpenSettings, +}: { + children: React.ReactNode; + onOpenSettings: () => void; +}) { + const [state, dispatch] = React.useReducer( + encryptedBackupReducer, + initialEncryptedBackupState, + ); + const [availableUntil, setAvailableUntil] = React.useState( + null, + ); + const [isSaving, setIsSaving] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); + const autoSaveStartedForRef = React.useRef(null); + const mountedRef = React.useRef(true); + const onOpenSettingsRef = React.useRef(onOpenSettings); + + React.useEffect(() => { + onOpenSettingsRef.current = onOpenSettings; + }, [onOpenSettings]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + React.useEffect(() => { + if (availableUntil === null) return; + const expiresIn = Math.max(0, availableUntil - Date.now()); + const timer = window.setTimeout(() => { + autoSaveStartedForRef.current = null; + setAvailableUntil(null); + setSaveError(null); + dispatch({ type: "start-new-backup" }); + toast.dismiss(BACKUP_READY_TOAST_ID); + }, expiresIn); + return () => window.clearTimeout(timer); + }, [availableUntil]); + + const pendingPassphrase = pendingEncryptPassphrase(state); + const skipDebounce = state.downloadPending; + React.useEffect(() => { + if (!pendingPassphrase) return; + let started = false; + let cancelledBeforeStart = false; + const requestId = state.nextRequestId; + const start = () => { + if (cancelledBeforeStart) return; + started = true; + dispatch({ type: "encrypt-started", requestId }); + void createNcryptsecBackup(pendingPassphrase) + .then((ncryptsec) => { + dispatch({ type: "encrypt-succeeded", requestId, ncryptsec }); + }) + .catch((err: unknown) => { + dispatch({ + type: "encrypt-failed", + requestId, + message: + err instanceof Error + ? err.message + : "Failed to encrypt your key.", + }); + }); + }; + const timer = window.setTimeout( + start, + skipDebounce ? 0 : ENCRYPT_DEBOUNCE_MS, + ); + return () => { + if (!started) cancelledBeforeStart = true; + window.clearTimeout(timer); + }; + }, [pendingPassphrase, skipDebounce, state.nextRequestId]); + + React.useEffect(() => { + if (state.downloadPending) { + toast.loading("Preparing backup…", { + description: "You can close this window while Buzz finishes.", + duration: Number.POSITIVE_INFINITY, + id: BACKUP_READY_TOAST_ID, + }); + return; + } + if ( + state.createError && + state.passphrase.length === 0 && + !state.ncryptsec + ) { + toast.error("Couldn’t create backup", { + description: state.createError, + id: BACKUP_READY_TOAST_ID, + }); + } + }, [ + state.createError, + state.downloadPending, + state.ncryptsec, + state.passphrase.length, + ]); + + const showAvailableToast = React.useCallback( + (description: string, error = false) => { + const options = { + action: { + label: "Open settings", + onClick: () => onOpenSettingsRef.current(), + }, + description, + id: BACKUP_READY_TOAST_ID, + }; + if (error) toast.error("Backup ready to download", options); + else toast.success("Backup ready to download", options); + }, + [], + ); + + const saveBackup = React.useCallback( + async (ncryptsec: string) => { + if (isSaving) return; + setIsSaving(true); + setSaveError(null); + toast("Saving backup…", { + description: "The download window will open when it’s ready.", + id: BACKUP_READY_TOAST_ID, + }); + try { + const path = await saveNcryptsecCopy(ncryptsec); + if (mountedRef.current) { + showAvailableToast( + path === null + ? "Your backup will be available to download for 5 minutes." + : "You can download another copy for 5 minutes.", + ); + } + } catch (err) { + if (!mountedRef.current) return; + const message = + err instanceof Error ? err.message : "Failed to save your key."; + setSaveError(message); + showAvailableToast( + `${message} It will be available to download for 5 minutes.`, + true, + ); + } finally { + if (mountedRef.current) setIsSaving(false); + } + }, + [isSaving, showAvailableToast], + ); + + React.useEffect(() => { + const ncryptsec = state.ncryptsec; + if (!ncryptsec || autoSaveStartedForRef.current === ncryptsec) return; + autoSaveStartedForRef.current = ncryptsec; + setAvailableUntil(Date.now() + BACKUP_AVAILABILITY_MS); + void saveBackup(ncryptsec); + }, [saveBackup, state.ncryptsec]); + + const downloadBackup = React.useCallback(async () => { + if (!state.ncryptsec) return; + await saveBackup(state.ncryptsec); + }, [saveBackup, state.ncryptsec]); + + const startNewBackup = React.useCallback(() => { + autoSaveStartedForRef.current = null; + setAvailableUntil(null); + setSaveError(null); + toast.dismiss(BACKUP_READY_TOAST_ID); + dispatch({ type: "start-new-backup" }); + }, []); + + const value = React.useMemo( + () => ({ + state, + dispatch, + backupAvailable: + state.savedPassword && + state.ncryptsec !== null && + availableUntil !== null, + availableUntil, + isSaving, + saveError, + downloadBackup, + startNewBackup, + }), + [ + availableUntil, + downloadBackup, + isSaving, + saveError, + startNewBackup, + state, + ], + ); + + return ( + + {children} + + ); +} + +export function useEncryptedBackup(): EncryptedBackupContextValue { + const value = React.useContext(EncryptedBackupContext); + if (!value) { + throw new Error( + "useEncryptedBackup must be used within EncryptedBackupProvider", + ); + } + return value; +} diff --git a/desktop/src/features/settings/lib/encryptedBackup.test.mjs b/desktop/src/features/settings/lib/encryptedBackup.test.mjs new file mode 100644 index 000000000..306e937ec --- /dev/null +++ b/desktop/src/features/settings/lib/encryptedBackup.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MIN_PASSPHRASE_LEN, + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, + effectivePassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, +} from "./encryptedBackup.ts"; +const reduce = (events, from = initialEncryptedBackupState) => + events.reduce(encryptedBackupReducer, from); +test("password validation mirrors Rust character counting", () => { + assert.equal(passphraseIssue(""), null); + assert.match(passphraseIssue("short"), new RegExp(`${MIN_PASSPHRASE_LEN}`)); + const emoji = "😀".repeat(MIN_PASSPHRASE_LEN); + assert.equal(passphraseIssue(emoji), null); + assert.equal( + effectivePassphrase(reduce([{ type: "set-passphrase", value: emoji }])), + emoji, + ); +}); +test("valid password requests encryption without copying it into events", () => { + const ready = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + ]); + assert.equal(pendingEncryptPassphrase(ready), "one-two-three-four"); + const started = reduce([{ type: "encrypt-started", requestId: 1 }], ready); + assert.equal(isEncrypting(started), true); + assert.equal(started.requestId, 1); + assert.equal(Object.hasOwn(started, "encryptingPassphrase"), false); +}); +test("background success retains the password without committing the download", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.passphrase, "one-two-three-four"); + assert.equal(state.encrypted, "ncryptsec1abc"); + assert.equal(state.ncryptsec, null); + assert.equal(state.savedPassword, false); + assert.equal(state.requestId, null); + assert.equal(downloadDisabled(state), false); +}); +test("submit commits a completed preload immediately", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + { type: "download-clicked" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); +}); +test("stale async completions cannot replace current request", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "set-passphrase", value: "five-six-seven-eight" }, + { type: "encrypt-started", requestId: 2 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1stale" }, + ]); + assert.equal(state.requestId, 2); + assert.equal(state.encrypted, null); + assert.equal(state.passphrase, "five-six-seven-eight"); +}); +test("failure clears submitted password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(state.passphrase, ""); + assert.equal(state.createError, "keychain unavailable"); + assert.equal(state.downloadPending, false); + assert.equal(downloadDisabled(state), true); +}); +test("background failure stays silent until submit retries encryption", () => { + const failed = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(failed.passphrase, "one-two-three-four"); + assert.equal(failed.createError, "keychain unavailable"); + assert.equal(pendingEncryptPassphrase(failed), null); + + const retrying = reduce([{ type: "download-clicked" }], failed); + assert.equal(retrying.createError, null); + assert.equal(retrying.downloadPending, true); + assert.equal(pendingEncryptPassphrase(retrying), "one-two-three-four"); +}); +test("queued download commits and clears password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); +}); +test("starting over discards blob and invalidates late requests", () => { + const made = { + ...initialEncryptedBackupState, + ncryptsec: "ncryptsec1abc", + encrypted: "ncryptsec1abc", + savedPassword: true, + nextRequestId: 3, + }; + const fresh = reduce([{ type: "start-new-backup" }], made); + assert.equal(fresh.ncryptsec, null); + assert.equal(fresh.nextRequestId, 4); + assert.equal( + reduce( + [ + { + type: "encrypt-succeeded", + requestId: 2, + ncryptsec: "ncryptsec1stale", + }, + ], + fresh, + ).ncryptsec, + null, + ); +}); diff --git a/desktop/src/features/settings/lib/encryptedBackup.ts b/desktop/src/features/settings/lib/encryptedBackup.ts new file mode 100644 index 000000000..e54189769 --- /dev/null +++ b/desktop/src/features/settings/lib/encryptedBackup.ts @@ -0,0 +1,143 @@ +/** Pure state model for NIP-49 backup creation. */ +export const MIN_PASSPHRASE_LEN = 12; + +export type EncryptedBackupState = { + passphrase: string; + requestId: number | null; + nextRequestId: number; + encrypted: string | null; + createError: string | null; + downloadPending: boolean; + ncryptsec: string | null; + savedPassword: boolean; +}; + +export const initialEncryptedBackupState: EncryptedBackupState = { + passphrase: "", + requestId: null, + nextRequestId: 1, + encrypted: null, + createError: null, + downloadPending: false, + ncryptsec: null, + savedPassword: false, +}; + +export type EncryptedBackupEvent = + | { type: "set-passphrase"; value: string } + | { type: "encrypt-started"; requestId: number } + | { type: "encrypt-succeeded"; requestId: number; ncryptsec: string } + | { type: "encrypt-failed"; requestId: number; message: string } + | { type: "download-clicked" } + | { type: "start-new-backup" }; + +export function encryptedBackupReducer( + state: EncryptedBackupState, + event: EncryptedBackupEvent, +): EncryptedBackupState { + switch (event.type) { + case "set-passphrase": + return { + ...state, + passphrase: event.value, + requestId: null, + encrypted: null, + createError: null, + }; + case "encrypt-started": + return { + ...state, + requestId: event.requestId, + nextRequestId: Math.max(state.nextRequestId, event.requestId + 1), + createError: null, + }; + case "encrypt-succeeded": + if (event.requestId !== state.requestId) return state; + return state.downloadPending + ? { + ...state, + passphrase: "", + requestId: null, + encrypted: event.ncryptsec, + ncryptsec: event.ncryptsec, + downloadPending: false, + savedPassword: true, + } + : { + ...state, + requestId: null, + encrypted: event.ncryptsec, + }; + case "encrypt-failed": + if (event.requestId !== state.requestId) return state; + return state.downloadPending + ? { + ...state, + passphrase: "", + requestId: null, + createError: event.message, + downloadPending: false, + } + : { + ...state, + requestId: null, + createError: event.message, + }; + case "download-clicked": + if ( + state.ncryptsec || + state.downloadPending || + (!state.encrypted && !effectivePassphrase(state)) + ) + return state; + return state.encrypted + ? { + ...state, + ncryptsec: state.encrypted, + passphrase: "", + savedPassword: true, + } + : { ...state, createError: null, downloadPending: true }; + case "start-new-backup": + return { + ...initialEncryptedBackupState, + nextRequestId: state.nextRequestId + 1, + }; + } +} + +export function passphraseIssue(passphrase: string): string | null { + if (passphrase.length === 0) return null; + return [...passphrase].length < MIN_PASSPHRASE_LEN + ? `Use at least ${MIN_PASSPHRASE_LEN} characters.` + : null; +} +export function effectivePassphrase( + state: EncryptedBackupState, +): string | null { + return [...state.passphrase].length < MIN_PASSPHRASE_LEN + ? null + : state.passphrase; +} +export function pendingEncryptPassphrase( + state: EncryptedBackupState, +): string | null { + if ( + state.savedPassword || + state.encrypted || + state.requestId !== null || + (state.createError !== null && !state.downloadPending) + ) + return null; + return effectivePassphrase(state); +} +export function isEncrypting(state: EncryptedBackupState): boolean { + return state.requestId !== null; +} +export function downloadDisabled(state: EncryptedBackupState): boolean { + if (state.savedPassword && state.ncryptsec) return false; + return ( + state.downloadPending || + (!state.encrypted && effectivePassphrase(state) === null) + ); +} diff --git a/desktop/src/features/settings/ui/BackupTestFlow.tsx b/desktop/src/features/settings/ui/BackupTestFlow.tsx new file mode 100644 index 000000000..65ef40a98 --- /dev/null +++ b/desktop/src/features/settings/ui/BackupTestFlow.tsx @@ -0,0 +1,459 @@ +import { Check, Eye, EyeOff, FileKey2, FileUp } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import { + verifyNcryptsecBackup, + type BackupVerification, +} from "@/shared/api/tauriIdentity"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { PubKey } from "@/shared/ui/PubKey"; +import { Spinner } from "@/shared/ui/spinner"; + +type BackupTestStage = "drop" | "password" | "success"; + +/** + * Progress through the Settings backup-test flow. The password attempt is + * deliberately NOT part of this state — it lives only in short-lived + * moment it's submitted or the component unmounts. + */ +export type BackupTestProgress = { + stage: BackupTestStage; + /** Name of the accepted file once the drop check passed. */ + fileName: string | null; + /** Contents of the accepted file, pending or past verification. */ + ncryptsec: string | null; + /** The Rust-verified public identity once decryption succeeded. */ + result: BackupVerification | null; +}; + +export const initialBackupTestProgress: BackupTestProgress = { + stage: "drop", + fileName: null, + ncryptsec: null, + result: null, +}; + +type BackupTestFlowProps = { + progress: BackupTestProgress; + onProgressChange: React.Dispatch>; +}; + +const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const; +const BURST_PARTICLE_COUNT = 18; + +type BurstParticle = { + id: number; + x: number; + y: number; + emoji: string; + delay: number; + scale: number; + rotate: number; +}; + +/** + * One-shot radial emoji burst behind the success badge. Purely decorative — + * skipped entirely under reduced motion. + */ +function SuccessBurst() { + const particles = React.useMemo( + () => + Array.from({ length: BURST_PARTICLE_COUNT }, (_, i) => { + const angle = + (i / BURST_PARTICLE_COUNT) * Math.PI * 2 + Math.random() * 0.5; + const distance = 70 + Math.random() * 80; + return { + id: i, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + emoji: BURST_EMOJIS[i % BURST_EMOJIS.length], + delay: Math.random() * 0.18, + scale: 0.8 + Math.random() * 0.7, + rotate: -120 + Math.random() * 240, + }; + }), + [], + ); + + return ( +
+ {particles.map((particle) => ( + + {particle.emoji} + + ))} +
+ ); +} + +/** + * "Test your backup" flow: the user drops a backup file onto a large + * dropzone, then enters its password. Verification is a real NIP-49 decrypt + * in Rust — the submitted password is cleared immediately after the result + * and only the derived public identity ever comes back. + */ +export function BackupTestFlow({ + progress, + onProgressChange, +}: BackupTestFlowProps) { + const reduceMotion = useReducedMotion() ?? false; + const { stage, fileName, ncryptsec, result } = progress; + // True while a file drag is anywhere over the window — the drop overlay + // takes over the host surface only for the duration of the drag. + const [isWindowDragging, setIsWindowDragging] = React.useState(false); + const dragDepthRef = React.useRef(0); + + React.useEffect(() => { + // dragenter/dragleave fire per nested element, so track depth to know + // when the drag has actually left the window. + const handleDragEnter = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes("Files")) return; + dragDepthRef.current += 1; + setIsWindowDragging(true); + }; + const handleDragLeave = () => { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsWindowDragging(false); + }; + const handleDragEnd = () => { + dragDepthRef.current = 0; + setIsWindowDragging(false); + }; + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("drop", handleDragEnd); + window.addEventListener("dragend", handleDragEnd); + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("drop", handleDragEnd); + window.removeEventListener("dragend", handleDragEnd); + }; + }, []); + + // The password attempt is component-local, never host state: it is cleared + // when verification is submitted and when this component unmounts. + const [attempt, setAttempt] = React.useState(""); + const [error, setError] = React.useState(null); + const [isVerifying, setIsVerifying] = React.useState(false); + const [isRevealed, setIsRevealed] = React.useState(false); + const fileInputRef = React.useRef(null); + const passwordInputRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Opaque correlation id so a stale in-flight verification can't commit + // after "Use a different file" or unmount. + const requestRef = React.useRef(0); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + requestRef.current += 1; + setAttempt(""); + }; + }, []); + + React.useEffect(() => { + if (stage === "password") passwordInputRef.current?.focus(); + }, [stage]); + + const handleFile = React.useCallback( + async (file: File) => { + let text: string; + try { + text = (await file.text()).trim(); + } catch { + if (mountedRef.current) setError("Could not read that file."); + return; + } + if (!mountedRef.current) return; + if (!text.toLowerCase().startsWith("ncryptsec1")) { + setError("That doesn't look like a key backup file."); + return; + } + setError(null); + setAttempt(""); + onProgressChange({ + stage: "password", + fileName: file.name, + ncryptsec: text, + result: null, + }); + }, + [onProgressChange], + ); + + const handleVerify = React.useCallback(async () => { + if (!ncryptsec || !attempt || isVerifying) return; + const password = attempt; + const requestId = ++requestRef.current; + setIsVerifying(true); + setError(null); + setIsRevealed(false); + // Clear the attempt the moment it's handed to Rust — success or failure, + // the typed password never lingers in the field. + setAttempt(""); + try { + const verified = await verifyNcryptsecBackup(ncryptsec, password); + if (!mountedRef.current || requestId !== requestRef.current) return; + onProgressChange((prev) => ({ + ...prev, + stage: "success", + result: verified, + })); + } catch (err) { + if (mountedRef.current && requestId === requestRef.current) + setError( + err instanceof Error ? err.message : "Could not verify this backup.", + ); + } finally { + if (mountedRef.current && requestId === requestRef.current) + setIsVerifying(false); + } + }, [attempt, isVerifying, ncryptsec, onProgressChange]); + + if (stage === "success" && result) { + return ( +
+ {reduceMotion ? null : } + + + +

+ This backup works +

+

+ {result.matchesCurrentIdentity + ? "It restores your current Buzz identity." + : "It restores a different identity than the one signed in here."} +

+
+ +
+
+ +
+ ); + } + + return ( +
+ {stage === "drop" ? ( + <> + { + const file = event.target.files?.[0]; + // Allow re-selecting the same file after an error. + event.target.value = ""; + if (file) void handleFile(file); + }} + ref={fileInputRef} + tabIndex={-1} + type="file" + /> + + {isWindowDragging ? ( + /* + * Composer-style takeover: fills the nearest positioned host + * surface (the settings backup row) and is + * itself the drop target, so anywhere on that surface accepts + * the file. + */ + // biome-ignore lint/a11y/noStaticElementInteractions: pointer-only drop target; the select button is the keyboard-accessible path +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + const file = event.dataTransfer.files?.[0]; + if (file) void handleFile(file); + }} + > + + +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + ) : ( + <> +
+
+

+ That's the one. Now enter your password to prove you can unlock it. +

+
+ setAttempt(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleVerify(); + } + }} + placeholder="Your backup password" + ref={passwordInputRef} + type={isRevealed ? "text" : "password"} + value={attempt} + /> + + {error ? ( +

+ {error} +

+ ) : null} +
+
+ + +
+ + )} +
+ ); +} diff --git a/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx new file mode 100644 index 000000000..fb20eb9c6 --- /dev/null +++ b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx @@ -0,0 +1,375 @@ +import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react"; +import * as React from "react"; + +import { generateBackupPassphrase } from "@/shared/api/tauriIdentity"; +import { useEncryptedBackup } from "@/features/settings/EncryptedBackupProvider"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { downloadDisabled, MIN_PASSPHRASE_LEN } from "../lib/encryptedBackup"; + +/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ +const MIN_GENERATED_WORDS = 3; +const MAX_GENERATED_WORDS = 10; +const DEFAULT_GENERATED_WORDS = 3; + +const SEPARATOR_OPTIONS = [ + { label: "Spaces", value: " " }, + { label: "Hyphens", value: "-" }, + { label: "Periods", value: "." }, + { label: "Commas", value: "," }, +] as const; + +const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value; + +/** + * Indeterminate KDF progress. Scrypt does not expose intermediate progress, + * so randomized increments consume a shrinking fraction of the remaining + * distance. The bar moves quickly at first and can never reach completion. + */ +function FakeKdfProgressBar() { + const [progress, setProgress] = React.useState(0); + + React.useEffect(() => { + let animationFrame = 0; + let nextAdvanceAt = 0; + const advance = (now: number) => { + if (now >= nextAdvanceAt) { + setProgress((current) => { + const remaining = 90 - current; + const fraction = 0.08 + Math.random() * 0.22; + return Math.min(90, current + Math.max(0.25, remaining * fraction)); + }); + nextAdvanceAt = now + 180 + Math.random() * 420; + } + animationFrame = window.requestAnimationFrame(advance); + }; + animationFrame = window.requestAnimationFrame(advance); + return () => window.cancelAnimationFrame(animationFrame); + }, []); + + return ( +
+
+
+ ); +} + +/** + * 1Password-style memorable-password generator popover with word-count and + * separator fields, anchored to a refresh icon inset in the password field + * (the anchor assumes a `relative` parent). The first click opens the + * popover and generates; further clicks on the icon re-roll while the + * popover stays open — only click-outside or Esc closes it. There is no + * candidate preview: every generation writes the passphrase straight into + * the parent's password field via `onGenerated`. + */ +function PassphraseGeneratorPopover({ + disabled = false, + onRequestGenerate, + onGenerated, +}: { + disabled?: boolean; + onRequestGenerate?: () => void; + onGenerated: (value: string) => void; +}) { + const [open, setOpen] = React.useState(false); + const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS); + const [separator, setSeparator] = React.useState(DEFAULT_SEPARATOR); + const [error, setError] = React.useState(null); + const anchorRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Read via a ref so `generate` stays reference-stable even though parents + // pass an inline `onGenerated`. Otherwise each generated password would + // re-render the parent, rebuild `generate`, and re-fire the open/controls + // effect below — an infinite generate loop while the popover is open. + const onGeneratedRef = React.useRef(onGenerated); + + React.useEffect(() => { + onGeneratedRef.current = onGenerated; + }, [onGenerated]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const generate = React.useCallback(async (wordCount: number, sep: string) => { + setError(null); + try { + const passphrase = await generateBackupPassphrase({ + words: wordCount, + separator: sep, + }); + if (mountedRef.current) onGeneratedRef.current(passphrase); + } catch (err) { + if (!mountedRef.current) return; + setError( + err instanceof Error ? err.message : "Failed to generate a password.", + ); + } + }, []); + + // Fill the password field on every open and whenever a control changes. + React.useEffect(() => { + if (open) void generate(words, separator); + }, [open, words, separator, generate]); + + return ( + + {/* Anchor (not Trigger): Radix triggers toggle on click, but repeat + clicks here must generate a fresh password while the popover stays + open. Only click-outside or Esc closes it. */} + + + + { + // Clicking the anchor icon is "outside" the content — keep the + // popover open so that click re-rolls instead of closing. + if ( + event.target instanceof Node && + anchorRef.current?.contains(event.target) + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + > +
+ +
+ setWords(Number(event.target.value))} + type="range" + value={words} + /> + + {words} + +
+
+ +
+ + +
+ + {error ? ( +

+ + {error} +

+ ) : null} +
+
+ ); +} + +/** + * Password-first encrypted key download flow for Settings. The raw private + * key never enters this component. Rust creates the + * NIP-49 payload locally, then the native save dialog produces the user-owned + * file. + * + * The flow is a single password input; a refresh icon inset in the field + * opens a 1Password-style generator popover (word count + separator). + * Encryption starts eagerly once the password is valid, so Download usually + * opens the save dialog instantly; clicking mid-encryption queues the + * download until the KDF finishes. + */ +export function EncryptedBackupCreator({ + onOpenChange, + open, +}: { + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const { state, dispatch, isSaving, saveError } = useEncryptedBackup(); + const [isRevealed, setIsRevealed] = React.useState(false); + + // A queued download hides the form; mask the password before it can return + // in any error state. + React.useEffect(() => { + if (state.downloadPending) setIsRevealed(false); + }, [state.downloadPending]); + + React.useEffect(() => { + if (state.ncryptsec) onOpenChange(false); + }, [onOpenChange, state.ncryptsec]); + + return ( + + + + Create a key backup + + You can close this window while Buzz finishes the backup in the + background. + + +
+ {state.downloadPending ? ( + + ) : !state.savedPassword ? ( +
+ + dispatch({ + type: "set-passphrase", + value: event.target.value, + }) + } + placeholder={`Password (min ${MIN_PASSPHRASE_LEN} characters)`} + type={isRevealed ? "text" : "password"} + value={state.passphrase} + /> + + { + dispatch({ type: "set-passphrase", value }); + // A generated password must be visible so the user can save it. + setIsRevealed(true); + }} + /> +
+ ) : null} + + {!state.downloadPending && !state.savedPassword ? ( +

+ Keep the file private and save its password somewhere safe — Buzz + cannot reset it. Once ready, the backup remains available to + download for 5 minutes. +

+ ) : null} + + {state.createError && state.passphrase.length === 0 ? ( +

+ {state.createError} +

+ ) : null} + + {saveError ? ( +

+ {saveError} +

+ ) : null} + + {!state.downloadPending ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx b/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx new file mode 100644 index 000000000..8eb0256a5 --- /dev/null +++ b/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx @@ -0,0 +1,215 @@ +import { Download, Eye, EyeOff, ShieldCheck } from "lucide-react"; +import * as React from "react"; + +import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay"; +import { + BACKUP_AVAILABILITY_MS, + useEncryptedBackup, +} from "@/features/settings/EncryptedBackupProvider"; +import { + BackupTestFlow, + initialBackupTestProgress, +} from "@/features/settings/ui/BackupTestFlow"; +import { EncryptedBackupCreator } from "@/features/settings/ui/EncryptedBackupCreator"; +import { getNsec } from "@/shared/api/tauriIdentity"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +function BackupAvailabilityFill({ + availableUntil, +}: { + availableUntil: number; +}) { + const [{ durationMs, initialWidth }] = React.useState(() => { + const remainingMs = Math.max(0, availableUntil - Date.now()); + return { + durationMs: remainingMs, + initialWidth: Math.min(100, (remainingMs / BACKUP_AVAILABILITY_MS) * 100), + }; + }); + const [width, setWidth] = React.useState(initialWidth); + + React.useEffect(() => { + const frame = window.requestAnimationFrame(() => setWidth(0)); + return () => window.cancelAnimationFrame(frame); + }, []); + + return ( +
diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts index e6056a4a5..e6ec266bf 100644 --- a/desktop/src/shared/api/tauriIdentity.ts +++ b/desktop/src/shared/api/tauriIdentity.ts @@ -49,3 +49,52 @@ export async function persistCurrentIdentity(): Promise { export async function signOut(): Promise { await invokeTauri("sign_out"); } + +export type GeneratePassphraseOptions = { + /** Word count; Rust clamps to its allowed range (currently 3–10). */ + words?: number; + /** Separator joined between words. Defaults to a space in Rust. */ + separator?: string; +}; + +/** Generate a word passphrase (EFF short wordlist, OS entropy) in Rust. */ +export async function generateBackupPassphrase( + options?: GeneratePassphraseOptions, +): Promise { + return invokeTauri("generate_backup_passphrase", { + words: options?.words, + separator: options?.separator, + }); +} + +/** Encrypt the current identity as an in-memory NIP-49 backup for native save. */ +export async function createNcryptsecBackup(password: string): Promise { + return invokeTauri("create_ncryptsec_backup", { password }); +} + +/** Save a portable backup copy. Returns null when the native dialog is cancelled. */ +export async function saveNcryptsecCopy( + ncryptsec: string, +): Promise { + return ( + (await invokeTauri("save_ncryptsec_copy", { ncryptsec })) ?? + null + ); +} + +export type BackupVerification = { + pubkey: string; + npub: string; + matchesCurrentIdentity: boolean; +}; + +/** Decrypt locally and return only the backup's public identity and match state. */ +export async function verifyNcryptsecBackup( + ncryptsec: string, + password: string, +): Promise { + return invokeTauri("verify_ncryptsec_backup", { + ncryptsec, + password, + }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index d15f2269d..4b1900496 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1,6 +1,6 @@ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; -import { decode } from "nostr-tools/nip19"; +import { decode, npubEncode } from "nostr-tools/nip19"; import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; import { parse as yamlParse } from "yaml"; import { @@ -417,6 +417,14 @@ type E2eConfig = { * autosave behaviour while a request is in flight. 0/undefined = instant. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Errors returned by successive backup verification attempts. Null succeeds. */ + backupVerificationErrors?: (string | null)[]; + /** Public identities returned by successive successful backup verifications. */ + backupVerificationPubkeys?: string[]; + /** Delay (ms) applied to backup encryption so specs can observe pending UI. */ + backupEncryptionDelayMs?: number; + /** Native paths returned by successive backup saves. */ + backupSavePaths?: Array; /** * When set, `get_nsec` throws with this message instead of returning the * mock nsec string. Use `nsecErrors` for sequenced failure/success. @@ -7204,6 +7212,8 @@ let mockGlobalAgentConfig: { // Per-page get_nsec call counter for sequenced error testing. let nsecCallCount = 0; +let backupVerificationCallCount = 0; +let backupSaveCallCount = 0; // Per-page explicit catalog publication outcomes. let personaSharePublicationCallCount = 0; @@ -9843,6 +9853,43 @@ export function maybeInstallE2eTauriMocks() { // harness there is nothing to wipe; resolving is enough — specs // assert invocation via __BUZZ_E2E_COMMANDS__ and the pending UI. return; + case "generate_backup_passphrase": + return "correct horse battery staple"; + case "create_ncryptsec_backup": { + const delayMs = activeConfig?.mock?.backupEncryptionDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + return "ncryptsec1mockbackupmaterial"; + } + case "save_ncryptsec_copy": { + const paths = activeConfig?.mock?.backupSavePaths ?? [ + "/tmp/buzz-identity.ncryptsec", + ]; + const index = Math.min(backupSaveCallCount, paths.length - 1); + backupSaveCallCount += 1; + return paths[index]; + } + case "verify_ncryptsec_backup": { + const errors = activeConfig?.mock?.backupVerificationErrors ?? [null]; + const index = Math.min(backupVerificationCallCount, errors.length - 1); + const error = errors[index]; + if (error) { + backupVerificationCallCount += 1; + throw new Error(error); + } + const pubkeys = activeConfig?.mock?.backupVerificationPubkeys ?? [ + identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey, + ]; + const pubkey = pubkeys[Math.min(index, pubkeys.length - 1)]; + backupVerificationCallCount += 1; + return { + pubkey, + npub: npubEncode(pubkey), + matchesCurrentIdentity: + pubkey === (identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey), + }; + } case "get_nsec": { const nsecSequence = activeConfig?.mock?.nsecErrors; if (nsecSequence && nsecSequence.length > 0) { diff --git a/desktop/tests/e2e/profile-backup-settings.spec.ts b/desktop/tests/e2e/profile-backup-settings.spec.ts new file mode 100644 index 000000000..edd66170d --- /dev/null +++ b/desktop/tests/e2e/profile-backup-settings.spec.ts @@ -0,0 +1,259 @@ +import { expect, test, type Page } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; + +import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; + +const CURRENT_PUBKEY = "deadbeef".repeat(8); +const DIFFERENT_PUBKEY = "c0ffee00".repeat(8); +const BACKUP_FILE = { + name: "identity.ncryptsec", + mimeType: "text/plain", + buffer: Buffer.from("ncryptsec1mockbackupmaterial"), +}; + +async function openIdentity(page: Page) { + const identity = page.getByTestId("profile-identity-card"); + if ( + !(await identity.evaluate( + (element) => element instanceof HTMLDetailsElement && element.open, + )) + ) { + await page.getByTestId("profile-identity-toggle").click(); + } +} + +async function openBackupSettings( + page: Page, + mock?: Parameters[1], +) { + await installMockBridge(page, mock); + await page.goto("/"); + await openSettings(page, "profile"); + await openIdentity(page); +} + +async function openPrivateKeyMenu(page: Page) { + const reveal = page.getByTestId("profile-private-key-toggle"); + if ((await reveal.textContent())?.trim() === "Reveal") { + await reveal.click(); + } + await page.getByTestId("nsec-actions").click(); + await expect(page.getByTestId("private-key-create-backup")).toBeVisible(); +} + +async function openCreateBackup(page: Page) { + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-create-backup").click(); + const dialog = page.getByTestId("encrypted-backup-dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function openTestBackup(page: Page) { + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-test-backup").click(); + const dialog = page.getByTestId("backup-test-dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function selectBackupFile(page: Page) { + await page.getByTestId("backup-test-file-input").setInputFiles(BACKUP_FILE); + await expect(page.getByTestId("backup-test-file-accepted")).toContainText( + BACKUP_FILE.name, + ); +} + +async function verifyBackup(page: Page, password: string) { + await page.getByTestId("backup-test-password").fill(password); + await page.getByTestId("backup-test-verify").click(); +} + +async function backupSaveCallCount(page: Page) { + return page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "save_ncryptsec_copy", + ).length ?? 0, + ); +} + +test("private key menu replaces the backup settings rows", async ({ page }) => { + await openBackupSettings(page); + + await expect(page.getByTestId("profile-encrypted-backup-row")).toHaveCount(0); + await expect(page.getByTestId("profile-backup-test-row")).toHaveCount(0); + + await openPrivateKeyMenu(page); + await expect(page.getByTestId("nsec-copy")).toContainText("Copy"); + await expect(page.getByTestId("private-key-create-backup")).toHaveText( + "Create backup", + ); + await expect(page.getByTestId("private-key-test-backup")).toHaveText( + "Test backup", + ); + + await page.getByTestId("nsec-copy").click(); + await expect(page.getByText(/clipboard$/i)).toBeVisible(); + await expect(page.getByTestId("private-key-create-backup")).toHaveCount(0); + + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-test-backup").click(); + const testDialog = page.getByTestId("backup-test-dialog"); + await expect(testDialog).toContainText("Test a key backup"); + await expect(testDialog.getByText("Select your backup file")).toBeVisible(); + await expect(testDialog).toContainText("standard NIP-49 format"); +}); + +test("creation requires a sufficiently long password and exposes a temporary header download", async ({ + page, +}) => { + await openBackupSettings(page, { + backupSavePaths: [ + "/Users/test/Downloads/identity.ncryptsec", + "/Users/test/Desktop/identity-copy.ncryptsec", + ], + }); + const dialog = await openCreateBackup(page); + + const password = dialog.getByTestId("backup-passphrase-input"); + const submit = dialog.getByTestId("encrypted-backup-create"); + await expect(password).toHaveAttribute( + "placeholder", + "Password (min 12 characters)", + ); + await expect(submit).toBeDisabled(); + await password.fill("short"); + await expect(submit).toBeDisabled(); + + await password.fill("custom password"); + await expect(submit).toBeEnabled(); + await submit.click(); + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + await expect(dialog).toBeHidden(); + + const keyRow = page.getByTestId("profile-private-key-row"); + const download = keyRow.getByTestId("encrypted-backup-download"); + await expect(download).toBeVisible(); + await expect(download).toHaveText("Download backup"); + await expect(download).toHaveClass(/bg-primary/); + await expect( + download.getByTestId("encrypted-backup-availability-fill"), + ).toBeVisible(); + await expect(keyRow.getByTestId("profile-private-key-toggle")).toBeVisible(); + + await download.click(); + await expect.poll(() => backupSaveCallCount(page)).toBe(2); +}); + +test("encryption and native save continue after closing the dialog and settings", async ({ + page, +}) => { + await openBackupSettings(page, { + backupEncryptionDelayMs: 750, + backupSavePaths: [null], + }); + const dialog = await openCreateBackup(page); + await dialog + .getByTestId("backup-passphrase-input") + .fill("background password"); + await dialog.getByTestId("encrypted-backup-create").click(); + await expect(dialog.getByTestId("encrypted-backup-progress")).toBeVisible(); + + await dialog.getByRole("button", { name: "Close" }).click(); + await page.getByTestId("settings-back-to-app").click(); + await expect(page.getByTestId("settings-back-to-app")).toHaveCount(0); + await expect( + page.getByText("Preparing backup…", { exact: true }), + ).toBeVisible(); + + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + const readyToast = page.getByText("Backup ready to download", { + exact: true, + }); + await expect(readyToast).toBeVisible(); + await expect( + page.getByText("Your backup will be available to download for 5 minutes.", { + exact: true, + }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Open settings" }).click(); + await expect(page.getByTestId("settings-back-to-app")).toBeVisible(); + await openIdentity(page); + await expect(page.getByTestId("encrypted-backup-download")).toBeVisible(); +}); + +test("the temporary download expires after five minutes", async ({ page }) => { + await page.clock.install({ time: new Date("2026-07-29T12:00:00Z") }); + await openBackupSettings(page); + const dialog = await openCreateBackup(page); + await dialog.getByTestId("backup-passphrase-input").fill("expiring password"); + await dialog.getByTestId("encrypted-backup-create").click(); + await page.clock.fastForward(1); + + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + const download = page.getByTestId("encrypted-backup-download"); + await expect(download).toHaveText("Download backup"); + await expect( + download.getByTestId("encrypted-backup-availability-fill"), + ).toBeVisible(); + await page.clock.fastForward(5 * 60 * 1000 + 1); + await expect(page.getByTestId("encrypted-backup-download")).toHaveCount(0); +}); + +test("wrong backup password permits a successful retry in the test modal", async ({ + page, +}) => { + await openBackupSettings(page, { + backupVerificationErrors: ["Wrong password.", null], + }); + const dialog = await openTestBackup(page); + await selectBackupFile(page); + + await verifyBackup(page, "wrong password"); + await expect(dialog.getByTestId("backup-test-error")).toHaveText( + "Wrong password.", + ); + await expect(dialog.getByTestId("backup-test-password")).toHaveValue(""); + await expect(dialog.getByTestId("backup-test-verify")).toBeDisabled(); + + await verifyBackup(page, "correct password"); + await expect(dialog.getByTestId("backup-test-success")).toContainText( + "It restores your current Buzz identity.", + ); +}); + +for (const identity of [ + { + label: "current", + pubkey: CURRENT_PUBKEY, + message: "It restores your current Buzz identity.", + }, + { + label: "different", + pubkey: DIFFERENT_PUBKEY, + message: "It restores a different identity than the one signed in here.", + }, +]) { + test(`successful modal verification identifies the ${identity.label} identity using only its npub`, async ({ + page, + }) => { + await openBackupSettings(page, { + backupVerificationPubkeys: [identity.pubkey], + }); + const dialog = await openTestBackup(page); + await selectBackupFile(page); + await verifyBackup(page, "one-time password"); + + const success = dialog.getByTestId("backup-test-success"); + await expect(success).toContainText(identity.message); + await expect(success.getByTestId("backup-test-npub")).toContainText( + npubEncode(identity.pubkey), + ); + await expect(success).not.toContainText(identity.pubkey); + await expect(success).not.toContainText("one-time password"); + await expect(success).not.toContainText(BACKUP_FILE.buffer.toString()); + }); +} diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index b48a75914..468f86020 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -441,6 +441,14 @@ type MockBridgeOptions = { /** Delay (ms) for `set_global_agent_config` — hold saves open in tests. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Errors returned by successive backup verification attempts. Null succeeds. */ + backupVerificationErrors?: (string | null)[]; + /** Public identities returned by successive successful backup verifications. */ + backupVerificationPubkeys?: string[]; + /** Delay (ms) applied to backup encryption so specs can observe pending UI. */ + backupEncryptionDelayMs?: number; + /** Native paths returned by successive backup saves. */ + backupSavePaths?: Array; /** * When set, `get_nsec` throws with this message. For a single always-fail * scenario. Use `nsecErrors` for sequenced fail/succeed. From cca8839034eb571a7ce943c3ace7f85a82330898 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 12:25:08 -0600 Subject: [PATCH 12/15] Make relay reconnect backoff authoritative (#3774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - make the relay reconnect coordinator authoritative during outages so query, publish, and subscription traffic waits for the scheduled attempt instead of cancelling backoff - release waiting operations after the coordinated AUTH + live-subscription replay attempt, while preserving one explicit manual reconnect fast path - suppress duplicate notification side effects when reconnect replay overlaps previously delivered events ## Root cause `resetConnection()` scheduled exponential backoff, but `ensureConnected()` cleared any pending reconnect timer. Operation-level retry paths immediately called `ensureConnected()`, so ordinary app traffic could repeatedly bypass the reconnect policy during an outage. The resulting churn also replayed overlapping live events into notification side effects without a shared event-ID guard. ## Validation - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 3,823 passed - pre-push: `desktop-check`, `desktop-test`, and `branch-skew` passed - file-size, px-text, and pubkey-truncation ratchets passed --------- Signed-off-by: Wes Co-authored-by: Carl --- .../channels/unreadReadMarker.test.mjs | 11 ++ .../channels/useLiveChannelUpdates.ts | 98 ++++++----- desktop/src/shared/api/relayClientSession.ts | 94 +++++------ .../shared/api/relayReconnectPolicy.test.mjs | 12 ++ .../src/shared/api/relayReconnectPolicy.ts | 6 + .../shared/api/relayReconnectWaiters.test.mjs | 26 +++ .../src/shared/api/relayReconnectWaiters.ts | 21 +++ desktop/src/testing/e2eBridge.ts | 24 +++ desktop/tests/e2e/helpers/twoRelayHarness.ts | 1 + desktop/tests/e2e/relay-reconnect.spec.ts | 74 ++++++++ desktop/tests/e2e/relay-restart.live.spec.ts | 158 +++++++++++++++++- 11 files changed, 429 insertions(+), 96 deletions(-) create mode 100644 desktop/src/shared/api/relayReconnectWaiters.test.mjs create mode 100644 desktop/src/shared/api/relayReconnectWaiters.ts diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index e05e2ba0b..6ea091641 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -18,6 +18,7 @@ import { } from "./useUnreadChannels.ts"; import { isChannelUnreadTriggerKind, + trackSeenEvent, withChannelTagFallback, } from "./useLiveChannelUpdates.ts"; import { @@ -90,6 +91,16 @@ test("live event with h tag is preserved", () => { assert.equal(withChannelTagFallback(message, "other-channel"), message); }); +test("notification event guard suppresses reconnect replay and stays bounded", () => { + const seen = new Set(); + + assert.equal(trackSeenEvent(seen, "event-a", 2), true); + assert.equal(trackSeenEvent(seen, "event-a", 2), false); + assert.equal(trackSeenEvent(seen, "event-b", 2), true); + assert.equal(trackSeenEvent(seen, "event-c", 2), true); + assert.deepEqual([...seen], ["event-b", "event-c"]); +}); + test("dmHuddleStart_isDmOnlyUnreadTrigger", () => { assert.equal( isChannelUnreadTriggerKind(KIND_HUDDLE_STARTED, true), diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index aeb3abb90..800467b6e 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -110,13 +110,19 @@ function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { ); } -function trackSeenEvent(seenEventIds: Set, eventId: string): boolean { +const SEEN_NOTIFICATION_EVENT_LIMIT = 5_000; + +export function trackSeenEvent( + seenEventIds: Set, + eventId: string, + limit = 200, +): boolean { if (seenEventIds.has(eventId)) { return false; } seenEventIds.add(eventId); - if (seenEventIds.size > 200) { + if (seenEventIds.size > limit) { const oldestEventId = seenEventIds.values().next().value; if (oldestEventId) { seenEventIds.delete(oldestEventId); @@ -135,6 +141,11 @@ export function useLiveChannelUpdates( const normalizedCurrentPubkey = options.currentPubkey?.trim().toLowerCase() ?? ""; const seenMentionEventIdsRef = React.useRef(new Set()); + // Reconnect replay overlaps each live filter by five seconds so no message is + // lost at the boundary. Keep one shared guard for every notification side + // effect: the same event can be replayed repeatedly while a relay flaps, and + // mention events also arrive through both the channel and mention filters. + const seenNotificationEventIdsRef = React.useRef(new Set()); const channelsInvalidateRef = React.useRef(null); if (channelsInvalidateRef.current === null) { channelsInvalidateRef.current = createTrailingDebounce(() => { @@ -164,7 +175,6 @@ export function useLiveChannelUpdates( ), [channels], ); - const seenDmEventIdsRef = React.useRef(new Set()); const dmSubscriptionStartedAtRef = React.useRef(0); // Reset subscription timestamp when identity changes. @@ -181,44 +191,42 @@ export function useLiveChannelUpdates( [channels], ); - const handleDmEvent = React.useEffectEvent((event: RelayEvent) => { - // Only human-visible message kinds should fire DM notifications. - if (!isDmNotifiableKind(event.kind)) { - return; - } + const handleDmEvent = React.useEffectEvent( + (event: RelayEvent, isFirstNotificationDelivery: boolean) => { + // Only human-visible message kinds should fire DM notifications. + if (!isDmNotifiableKind(event.kind) || !isFirstNotificationDelivery) { + return; + } - // Suppress backlog events that predate our subscription — these are - // historical replays, not live messages. - if (event.created_at < dmSubscriptionStartedAtRef.current) { - return; - } + // Suppress backlog events that predate our subscription — these are + // historical replays, not live messages. + if (event.created_at < dmSubscriptionStartedAtRef.current) { + return; + } - const channelId = getChannelIdFromTags(event.tags); - if (!channelId) { - return; - } + const channelId = getChannelIdFromTags(event.tags); + if (!channelId) { + return; + } - if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) { - return; - } + if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) { + return; + } - const dmChannel = dmChannelMap.get(channelId); - if (!dmChannel) { - return; - } + const dmChannel = dmChannelMap.get(channelId); + if (!dmChannel) { + return; + } - if (!trackSeenEvent(seenDmEventIdsRef.current, event.id)) { - return; - } + // Don't fire a notification for the channel the user is already viewing, + // unless the notify-while-viewing setting opts in. + if (channelId === activeChannelId && !options.notifyForActiveChannel) { + return; + } - // Don't fire a notification for the channel the user is already viewing, - // unless the notify-while-viewing setting opts in. - if (channelId === activeChannelId && !options.notifyForActiveChannel) { - return; - } - - options.onDmMessage?.(event, dmChannel); - }); + options.onDmMessage?.(event, dmChannel); + }, + ); const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => { const channelId = getChannelIdFromTags(event.tags); @@ -226,12 +234,6 @@ export function useLiveChannelUpdates( return; } - // Track DM events even for the active channel so the dedup set stays - // current. The handler itself skips firing the notification callback - // when the user is already viewing the DM (unless opted in via - // notifyForActiveChannel). - handleDmEvent(event); - if (!liveChannelIds.has(channelId)) { if (channelId !== activeChannelId) { invalidateChannelsDebounced(); @@ -263,9 +265,21 @@ export function useLiveChannelUpdates( isUnreadTriggerKind && (normalizedCurrentPubkey.length === 0 || event.pubkey.toLowerCase() !== normalizedCurrentPubkey); + const isFirstNotificationDelivery = + !isExternalTriggerEvent || + trackSeenEvent( + seenNotificationEventIdsRef.current, + event.id, + SEEN_NOTIFICATION_EVENT_LIMIT, + ); const isThreadedReply = isThreadReply(event.tags); - if (isExternalTriggerEvent) { + // DM alerts and every other notification side effect share this delivery + // decision, preventing a replayed event from escaping through a second + // callback path. + handleDmEvent(event, isFirstNotificationDelivery); + + if (isExternalTriggerEvent && isFirstNotificationDelivery) { const shouldNotify = shouldNotifyForEvent( event, normalizedCurrentPubkey, diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 84ee10b68..8274034ed 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -49,7 +49,9 @@ import { isWebSocketClose, shouldRefuseConnect, shouldScheduleReconnect, + shouldWaitForScheduledReconnect, } from "@/shared/api/relayReconnectPolicy"; +import { RelayReconnectWaiters } from "@/shared/api/relayReconnectWaiters"; import { RelayStallWatchdog } from "@/shared/api/relayStallWatchdog"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; @@ -57,26 +59,12 @@ const RECONNECT_BASE_DELAY_MS = 1_000, RECONNECT_MAX_DELAY_MS = 30_000, EVENT_BATCH_MS = 16; -/** - * Op-level timeout constants. Raised from 8 s to 25 s to survive degraded - * networks where TLS handshakes and DNS resolution can take 3–10 s. - */ export const AUTH_TIMEOUT_MS = 25_000; export const HISTORY_TIMEOUT_MS = 25_000; export const PUBLISH_TIMEOUT_MS = 25_000; -/** - * The connection must remain stable for this long after a successful AUTH - * before the reconnect backoff delay resets to its base value. Stability- - * gated reset prevents repeated fast reconnects (flapping) from erasing the - * backoff that throttles them. - */ export const BACKOFF_RESET_STABLE_MS = 60_000; -/** - * Passive liveness check. The relay sends heartbeat pings every 30s; if no - * inbound frame arrives for two heartbeat windows, treat the socket as stalled. - */ const STALL_CHECK_INTERVAL_MS = 10_000; const STALL_IDLE_TIMEOUT_MS = 60_000; @@ -85,6 +73,7 @@ export class RelayClient { private relayUrl: string | null = null; private connectPromise: Promise | null = null; private reconnectTimeout: number | null = null; + private reconnectWaiters = new RelayReconnectWaiters(); private reconnectDelayMs = RECONNECT_BASE_DELAY_MS; private keepAliveRequested = false; private authRequest: { @@ -105,16 +94,6 @@ export class RelayClient { private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; - /** - * Sticky terminal flag. Set when `resetConnection` is called with - * `reconnect: false` (today: auth rejection). Acts as a hard guard against - * the reconnect-timer / retry-wrapper paths racing back to "reconnecting" - * after we've already declared the session dead. - * - * Cleared only on explicit user re-engagement: `disconnect()` (community - * switch — the singleton is being reused for a different community) and - * `preconnect()` (caller is asking us to come back up). - */ private terminal = false; private connectionStateEmitter = new RelayConnectionStateEmitter("idle"); @@ -127,21 +106,10 @@ export class RelayClient { }, }); - /** - * Track which channel the user is currently viewing so its subscriptions - * are sent first during reconnect replay — reducing visible latency on - * degraded networks where the relay REQ storm would otherwise delay all - * channels equally. - */ setVisibleChannelId(id: string | null) { this.visibleChannelId = id; } - /** - * Cleanly tear down the connection without scheduling a reconnect. - * Used during community switches to reset the singleton before the - * new community applies. - */ disconnect() { const error = new Error("Relay disconnected for community switch."); @@ -169,6 +137,7 @@ export class RelayClient { } this.connectPromise = null; + this.reconnectWaiters.settle(error); if (this.authRequest) { window.clearTimeout(this.authRequest.timeout); @@ -247,10 +216,6 @@ export class RelayClient { return this.fetchHistory(filter); } - /** - * Return the first event matching `filter` as soon as it arrives, without - * waiting for EOSE. Resolves to `null` when EOSE arrives before any event. - */ async fetchFirstEvent( filter: RelaySubscriptionFilter, ): Promise { @@ -462,10 +427,24 @@ export class RelayClient { async preconnect() { // Explicit re-engagement. If the session went terminal (auth rejection) - // the caller is asking us to try again, so clear the latch. + // the caller is asking us to try again, so clear the latch. A manual + // reconnect also bypasses the current delay once; ordinary operations do + // not, so background traffic cannot continuously defeat backoff. this.terminal = false; this.keepAliveRequested = true; - await this.ensureConnected(); + if (this.reconnectTimeout !== null) { + window.clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + try { + await this.ensureConnected(); + this.reconnectWaiters.settle(); + } catch (error) { + this.reconnectWaiters.settle( + this.normalizeRelayError(error, "Relay reconnect failed."), + ); + throw error; + } } subscribeToReconnects(listener: () => void) { @@ -508,9 +487,15 @@ export class RelayClient { return; } - if (this.reconnectTimeout) { - window.clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; + if ( + shouldWaitForScheduledReconnect({ + hasPendingReconnect: this.reconnectTimeout !== null, + }) + ) { + // The reconnect coordinator owns outage pacing. Query, publish, and + // subscription callers must wait for its scheduled attempt instead of + // clearing the timer and creating an immediate reconnect storm. + return this.waitForScheduledReconnect(); } const connectPromise = this.connect(); @@ -964,6 +949,13 @@ export class RelayClient { } } + private waitForScheduledReconnect(): Promise { + if (this.reconnectTimeout === null) { + return this.ensureConnected(); + } + return this.reconnectWaiters.wait(); + } + private scheduleReconnect() { if ( !shouldScheduleReconnect({ @@ -989,9 +981,14 @@ export class RelayClient { this.reconnectTimeout = window.setTimeout(() => { this.reconnectTimeout = null; - void this.ensureConnected().catch(() => { - this.scheduleReconnect(); - }); + void this.ensureConnected() + .then(() => this.reconnectWaiters.settle()) + .catch((error) => { + this.reconnectWaiters.settle( + this.normalizeRelayError(error, "Relay reconnect failed."), + ); + this.scheduleReconnect(); + }); }, delay); } @@ -1049,6 +1046,9 @@ export class RelayClient { window.clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } + if (options?.reconnect === false) { + this.reconnectWaiters.settle(error); + } if (this.wsId !== null) { void closeWebSocket(this.wsId, "connection reset"); diff --git a/desktop/src/shared/api/relayReconnectPolicy.test.mjs b/desktop/src/shared/api/relayReconnectPolicy.test.mjs index 6f375fb43..e6856ede1 100644 --- a/desktop/src/shared/api/relayReconnectPolicy.test.mjs +++ b/desktop/src/shared/api/relayReconnectPolicy.test.mjs @@ -6,6 +6,7 @@ import { isWebSocketClose, shouldRefuseConnect, shouldScheduleReconnect, + shouldWaitForScheduledReconnect, } from "./relayReconnectPolicy.ts"; // The "happy" baseline that *should* schedule a reconnect: not terminal, @@ -79,6 +80,17 @@ test("keep-alive alone is enough to schedule", () => { ); }); +test("ordinary operations wait for a scheduled reconnect instead of bypassing backoff", () => { + assert.equal( + shouldWaitForScheduledReconnect({ hasPendingReconnect: true }), + true, + ); + assert.equal( + shouldWaitForScheduledReconnect({ hasPendingReconnect: false }), + false, + ); +}); + test("shouldRefuseConnect mirrors terminal", () => { assert.equal(shouldRefuseConnect({ terminal: false }), false); assert.equal(shouldRefuseConnect({ terminal: true }), true); diff --git a/desktop/src/shared/api/relayReconnectPolicy.ts b/desktop/src/shared/api/relayReconnectPolicy.ts index a2cf35821..00d8e412b 100644 --- a/desktop/src/shared/api/relayReconnectPolicy.ts +++ b/desktop/src/shared/api/relayReconnectPolicy.ts @@ -37,6 +37,12 @@ export function shouldScheduleReconnect(inputs: RelayReconnectInputs): boolean { return true; } +export function shouldWaitForScheduledReconnect(inputs: { + hasPendingReconnect: boolean; +}): boolean { + return inputs.hasPendingReconnect; +} + /** Whether `ensureConnected()` should refuse with a terminal error. */ export function shouldRefuseConnect(inputs: { terminal: boolean }): boolean { return inputs.terminal; diff --git a/desktop/src/shared/api/relayReconnectWaiters.test.mjs b/desktop/src/shared/api/relayReconnectWaiters.test.mjs new file mode 100644 index 000000000..ddf324452 --- /dev/null +++ b/desktop/src/shared/api/relayReconnectWaiters.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { RelayReconnectWaiters } from "./relayReconnectWaiters.ts"; + +test("settle releases every operation waiting on a successful reconnect", async () => { + const waiters = new RelayReconnectWaiters(); + const first = waiters.wait(); + const second = waiters.wait(); + + waiters.settle(); + + await Promise.all([first, second]); +}); + +test("settle rejects every operation after a failed reconnect", async () => { + const waiters = new RelayReconnectWaiters(); + const first = waiters.wait(); + const second = waiters.wait(); + const error = new Error("relay unavailable"); + + waiters.settle(error); + + await assert.rejects(first, error); + await assert.rejects(second, error); +}); diff --git a/desktop/src/shared/api/relayReconnectWaiters.ts b/desktop/src/shared/api/relayReconnectWaiters.ts new file mode 100644 index 000000000..06ad1daa3 --- /dev/null +++ b/desktop/src/shared/api/relayReconnectWaiters.ts @@ -0,0 +1,21 @@ +export class RelayReconnectWaiters { + private waiters = new Set<{ + resolve: () => void; + reject: (error: Error) => void; + }>(); + + wait(): Promise { + return new Promise((resolve, reject) => { + this.waiters.add({ resolve, reject }); + }); + } + + settle(error?: Error) { + const waiters = [...this.waiters]; + this.waiters.clear(); + for (const waiter of waiters) { + if (error) waiter.reject(error); + else waiter.resolve(); + } + } +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4b1900496..07eaa7790 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1108,6 +1108,11 @@ declare global { __BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void; __BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number; __BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?: () => number; + __BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__?: ( + unavailable: boolean, + ) => void; + __BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => number[]; + __BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => void; __BUZZ_E2E_SET_MESH__?: (mesh: { admitted?: boolean; models?: Array<{ id: string; name: string | null }>; @@ -2820,6 +2825,8 @@ const mockReminderEvents: RelayEvent[] = []; const mockPersonaEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); +let mockWebsocketUnavailable = false; +const relayWebsocketConnectAttemptStarts: number[] = []; let mockWebsocketSendMutexWedged = false; let mockClosedChannelLiveSubscription = false; const realSockets = new Map(); @@ -8932,6 +8939,7 @@ async function resolveGetEvent( } async function connectRealSocket(args: { url?: string; onMessage: unknown }) { + relayWebsocketConnectAttemptStarts.push(Date.now()); const wsId = nextSocketId++; const ws = new WebSocket(args.url ?? DEFAULT_RELAY_WS_URL); const handler = resolveHandler(args.onMessage); @@ -8960,6 +8968,10 @@ async function connectRealSocket(args: { url?: string; onMessage: unknown }) { } async function connectMockSocket(args: { onMessage: unknown }) { + relayWebsocketConnectAttemptStarts.push(Date.now()); + if (mockWebsocketUnavailable) { + throw new Error("mock relay unavailable"); + } const connectError = getConfig()?.mock?.websocketConnectErrors?.shift(); if (connectError) { throw new Error(connectError); @@ -9405,6 +9417,8 @@ export function maybeInstallE2eTauriMocks() { } mockClosedChannelLiveSubscription = false; + mockWebsocketUnavailable = false; + relayWebsocketConnectAttemptStarts.length = 0; mockGlobalAgentConfig = config.mock?.globalAgentConfig ? { ...config.mock.globalAgentConfig } : null; @@ -9628,6 +9642,16 @@ export function maybeInstallE2eTauriMocks() { } return sockets.length; }; + window.__BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__ = (unavailable) => { + mockWebsocketUnavailable = unavailable; + if (unavailable) relayWebsocketConnectAttemptStarts.length = 0; + }; + window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => [ + ...relayWebsocketConnectAttemptStarts, + ]; + window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => { + relayWebsocketConnectAttemptStarts.length = 0; + }; // Tests vary mesh admission and models to exercise provider discovery and // the managed-agent start preflight. window.__BUZZ_E2E_SET_MESH__ = (mesh) => { diff --git a/desktop/tests/e2e/helpers/twoRelayHarness.ts b/desktop/tests/e2e/helpers/twoRelayHarness.ts index 98acd8639..43eb45841 100644 --- a/desktop/tests/e2e/helpers/twoRelayHarness.ts +++ b/desktop/tests/e2e/helpers/twoRelayHarness.ts @@ -162,6 +162,7 @@ export class TwoRelayHarness { BUZZ_METRICS_PORT: String(relay.ports.metrics), BUZZ_REQUIRE_AUTH_TOKEN: "false", BUZZ_RECONCILE_CHANNELS: "true", + BUZZ_AUTO_MIGRATE: "true", }); await this.waitForHealth(relay, child); } diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 6240cca0b..67ce725da 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -49,6 +49,31 @@ async function restartMockWebsockets(page: import("@playwright/test").Page) { expect(restarted).toBeGreaterThan(0); } +async function setMockWebsocketUnavailable( + page: import("@playwright/test").Page, + unavailable: boolean, +) { + await page.evaluate((value) => { + const setUnavailable = window.__BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__; + if (!setUnavailable) { + throw new Error("E2E websocket availability seam is not installed."); + } + setUnavailable(value); + }, unavailable); +} + +async function getMockWebsocketConnectAttempts( + page: import("@playwright/test").Page, +) { + return page.evaluate(() => { + const getAttempts = window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__; + if (!getAttempts) { + throw new Error("E2E websocket attempt seam is not installed."); + } + return getAttempts(); + }); +} + async function emitMockMessages( page: import("@playwright/test").Page, messages: Array<{ content: string; createdAt: number }>, @@ -121,6 +146,55 @@ test("failed initial relay dial retries automatically", async ({ page }) => { await expect(page.getByTestId("channel-general")).toBeVisible(); }); +test("routine traffic cannot bypass outage backoff and recovery stays automatic", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("channel-general")).toBeVisible(); + + await setMockWebsocketUnavailable(page, true); + await disconnectMockWebsockets(page); + + // Exercise the production query path throughout the outage. Before the + // coordinator fix, each rejected query called ensureConnected(), cancelled + // the scheduled timer, and dialed immediately. The fixed session keeps these + // callers behind its single jittered exponential-backoff attempt. + await page.evaluate(async () => { + const deadline = Date.now() + 4_200; + while (Date.now() < deadline) { + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + await new Promise((resolve) => window.setTimeout(resolve, 100)); + } + }); + + const attempts = await getMockWebsocketConnectAttempts(page); + expect(attempts.length).toBeGreaterThanOrEqual(2); + expect(attempts.length).toBeLessThanOrEqual(3); + for (let index = 1; index < attempts.length; index += 1) { + expect(attempts[index] - attempts[index - 1]).toBeGreaterThanOrEqual(700); + } + + await setMockWebsocketUnavailable(page, false); + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { timeout: 10_000 }, + ) + .toBe("connected"); + + const afterRecovery = `automatic outage recovery ${Date.now()}`; + await emitMockMessages(page, [ + { content: afterRecovery, createdAt: Math.floor(Date.now() / 1_000) }, + ]); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("message-timeline")).toContainText( + afterRecovery, + ); +}); + test("service restart close resets accumulated backoff", async ({ page }) => { await installMockBridge(page, { websocketConnectErrors: ["down 1", "down 2", "down 3"], diff --git a/desktop/tests/e2e/relay-restart.live.spec.ts b/desktop/tests/e2e/relay-restart.live.spec.ts index 057ef46f5..f3c80e69a 100644 --- a/desktop/tests/e2e/relay-restart.live.spec.ts +++ b/desktop/tests/e2e/relay-restart.live.spec.ts @@ -1,8 +1,13 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + import { expect, test, type Page } from "@playwright/test"; -import { installBridge } from "../helpers/bridge"; +import { installBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { TwoRelayHarness, type RelaySpec } from "./helpers/twoRelayHarness"; +const exec = promisify(execFile); + // Live gate: boots a REAL buzz-relay process, points the app at it, SIGTERMs // the relay mid-session, restarts it on the same port, and asserts the client // converges back to "connected". This proves the full restart story end to @@ -20,6 +25,55 @@ function required(name: string, value: string | undefined): string { return value; } +async function runCli(args: string[], relayUrl: string, privateKey: string) { + const binary = required("BUZZ_E2E_CLI_BIN", process.env.BUZZ_E2E_CLI_BIN); + const { stdout } = await exec(binary, args, { + cwd: "..", + env: { + ...process.env, + BUZZ_AUTH_TAG: "", + BUZZ_PRIVATE_KEY: privateKey, + BUZZ_RELAY_URL: relayUrl, + }, + }); + return stdout; +} + +async function seedLiveChannel(relayUrl: string) { + const name = `reconnect-live-${process.pid}`; + const created = JSON.parse( + await runCli( + [ + "channels", + "create", + "--name", + name, + "--type", + "stream", + "--visibility", + "open", + ], + relayUrl, + TEST_IDENTITIES.alice.privateKey, + ), + ) as { channel_id: string }; + await runCli( + [ + "channels", + "add-member", + "--channel", + created.channel_id, + "--pubkey", + TEST_IDENTITIES.tyler.pubkey, + "--role", + "member", + ], + relayUrl, + TEST_IDENTITIES.alice.privateKey, + ); + return { id: created.channel_id, name }; +} + async function connectionState(page: Page): Promise { return page.evaluate(() => { const win = window as Window & { @@ -29,13 +83,61 @@ async function connectionState(page: Page): Promise { }); } +async function exerciseBackgroundTraffic(page: Page, durationMs: number) { + await page.evaluate(async (duration) => { + const deadline = Date.now() + duration; + while (Date.now() < deadline) { + void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + await new Promise((resolve) => window.setTimeout(resolve, 100)); + } + }, durationMs); +} + +async function resetConnectAttempts(page: Page) { + await page.evaluate(() => { + window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?.(); + }); +} + +async function assertConnectAttemptsArePaced(page: Page) { + const attempts = await page.evaluate( + () => window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?.() ?? [], + ); + expect(attempts.length).toBeGreaterThanOrEqual(2); + expect(attempts.length).toBeLessThanOrEqual(4); + for (let index = 1; index < attempts.length; index += 1) { + expect(attempts[index] - attempts[index - 1]).toBeGreaterThanOrEqual(700); + } +} + +async function proveLiveDelivery( + page: Page, + relayUrl: string, + channel: { id: string; name: string }, + label: string, +) { + await page.getByTestId(`channel-${channel.name}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channel.name); + const message = `${label} ${Date.now()}`; + await runCli( + ["messages", "send", "--channel", channel.id, "--content", message], + relayUrl, + TEST_IDENTITIES.alice.privateKey, + ); + await expect(page.getByTestId("message-timeline")).toContainText(message, { + timeout: 30_000, + }); +} + test.describe("relay restart live gate", () => { test.skip(!enabled, "set BUZZ_E2E_RELAY_RESTART=1 to run live gate"); test("client reconnects after the relay is SIGTERMed and restarted", async ({ page, }) => { - test.setTimeout(180_000); + test.setTimeout(240_000); const portBase = 26_000 + (process.pid % 3_000); const spec: RelaySpec = { name: "relay-restart", @@ -56,6 +158,8 @@ test.describe("relay restart live gate", () => { await harness.startRelays(); const relayHttpUrl = `http://127.0.0.1:${spec.ports.main}`; + const channel = await test.step("seed live channel and membership", () => + seedLiveChannel(relayHttpUrl)); await installBridge(page, { mode: "relay", user: "tyler", @@ -64,28 +168,68 @@ test.describe("relay restart live gate", () => { }); await page.goto("/"); - // Baseline: the app converges to a live authenticated session. - await expect - .poll(() => connectionState(page), { timeout: 60_000 }) - .toBe("connected"); + // Baseline: the app converges to a live authenticated session and sees + // the channel created for this fresh database. + await test.step("wait for initial authenticated connection", async () => { + await expect + .poll(() => connectionState(page), { timeout: 60_000 }) + .toBe("connected"); + await expect(page.getByTestId(`channel-${channel.name}`)).toBeVisible({ + timeout: 30_000, + }); + }); // Roll the pod. Graceful drain: readiness 503 → 5s grace → 1012 close // broadcast → process exit. The client must observe the close (not a // silent stall) and start retrying. + await resetConnectAttempts(page); await harness.terminateRelayGracefully(spec.name); await expect .poll(() => connectionState(page), { timeout: 30_000 }) .not.toBe("connected"); + // Keep the real relay unavailable across several reconnect windows while + // ordinary app traffic continues. This is the production-shaped race: + // background queries must not bypass the session coordinator's backoff. + await exerciseBackgroundTraffic(page, 8_000); + await expect.poll(() => connectionState(page)).not.toBe("connected"); + await assertConnectAttemptsArePaced(page); + // Bring the "new pod" up on the same address, exactly like a k8s // restart behind a stable service endpoint. await harness.restartRelay(spec.name); // The client's retry loop must find the fresh relay and converge back - // to connected without any user interaction. + // to connected without any user interaction, then prove that AUTH and + // live-subscription replay finished by receiving an event published by + // a second identity through the real CLI/relay boundary. await expect .poll(() => connectionState(page), { timeout: 60_000 }) .toBe("connected"); + await proveLiveDelivery( + page, + relayHttpUrl, + channel, + "first automatic recovery", + ); + + // Flap the fresh pod once more. A second recovery catches stale timer, + // waiter, generation, and subscription state that a single cycle cannot. + await harness.terminateRelayGracefully(spec.name); + await expect + .poll(() => connectionState(page), { timeout: 30_000 }) + .not.toBe("connected"); + await exerciseBackgroundTraffic(page, 4_000); + await harness.restartRelay(spec.name); + await expect + .poll(() => connectionState(page), { timeout: 60_000 }) + .toBe("connected"); + await proveLiveDelivery( + page, + relayHttpUrl, + channel, + "second automatic recovery", + ); } catch (error) { console.error(await harness.logs()); throw error; From 1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 12:27:56 -0600 Subject: [PATCH 13/15] feat(release): make desktop releases immutable (#3568) ## Summary - add a manual desktop release preparer that regenerates one version-only candidate from current `origin/main` - validate deterministic complete changelog accounting, candidate authorship, allowed files, exact-head approval, required checks, and two-parent merge topology before tagging the reviewed candidate - move desktop tags/releases from `v*` to `desktop-v*` while preserving relay, chart, push-chart, and mobile behavior - stage all four platform outputs in Actions artifacts and grant GitHub release write access only to one final all-platform-gated publisher - publish the versioned release only after complete artifact assembly; update stable `latest.json` last; never promote prereleases or published rebuild outputs ## Safety properties - desktop tags point to the reviewed candidate SHA, not the merge commit - release builds remain tag-bound and reverify tag == checked-out HEAD - one final writer fails closed on artifact basename collisions - per-tag concurrency serializes publication without cancellation - published reruns do not replace immutable versioned assets or promote signatures from a rebuild - candidate branches use an explicit remote OID lease when regenerated ## Validation - `scripts/test-desktop-release-candidate.sh` - `scripts/test-release-ref-contract.sh` - `scripts/test-mobile-release-contract.sh` - changed workflow YAML parsing (Ruby Psych) - changed shell syntax (`bash -n`) - `git diff --check` - push hooks: branch-skew, Rust workspace tests (1,853 passed), desktop Tauri tests (3 passed) ## Coordinated companion - squareup/buzz-releases#79 updates the manually entered desktop source-tag contract to stable-only `desktop-v*` - merge the private contract companion before the first namespaced desktop release ## Rollout blockers (no settings changed here) Before the first candidate/release: 1. enable merge commits in repository settings 2. allow `merge` in ruleset `13596885` 3. require approval after the last push in ruleset `13596885` 4. include `refs/tags/desktop-v*` explicitly in release ruleset `14378754` 5. prove the non-publishing candidate/merge/tag/artifact validation path before any production release Do not test the old workflow with a prerelease: it can still mutate the production rolling updater release. --------- Signed-off-by: Wes Co-authored-by: Carl --- .../auto-tag-on-release-pr-merge.yml | 43 ++- .github/workflows/ci.yml | 2 + .github/workflows/prepare-desktop-release.yml | 38 +++ .github/workflows/release.yml | 318 ++++++++---------- Justfile | 4 +- RELEASING.md | 23 +- scripts/desktop_release.py | 214 ++++++++++++ scripts/prepare-desktop-release.sh | 83 +++++ scripts/required-check-succeeded.jq | 14 + scripts/review-decision-approved.jq | 1 + scripts/test-desktop-release-candidate.sh | 84 +++++ scripts/test-release-ref-contract.sh | 75 ++++- scripts/verify-desktop-release-merge.sh | 62 ++++ 13 files changed, 763 insertions(+), 198 deletions(-) create mode 100644 .github/workflows/prepare-desktop-release.yml create mode 100755 scripts/desktop_release.py create mode 100755 scripts/prepare-desktop-release.sh create mode 100644 scripts/required-check-succeeded.jq create mode 100644 scripts/review-decision-approved.jq create mode 100755 scripts/test-desktop-release-candidate.sh create mode 100755 scripts/verify-desktop-release-merge.sh diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index db34fddc2..a69eafb40 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -4,7 +4,7 @@ name: Auto-tag on Release PR Merge # prefix; the main chart lane also auto-detects a Chart.yaml version bump so # a chart feature PR can publish its own new version when merged: # -# version-bump/ → tag v → release.yml (desktop app) +# version-bump/ → tag desktop-v → release.yml (desktop app) # relay-release/ → tag relay-v → docker.yml (relay image) # chart-release/ → tag chart-v → helm-chart.yml (main helm chart) # push-chart-release/ → tag push-chart-v → push-gateway-helm-chart.yml @@ -35,6 +35,11 @@ permissions: jobs: auto-tag: + permissions: + contents: read + pull-requests: read + checks: read + statuses: read if: > github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository @@ -57,7 +62,7 @@ jobs: case "$BRANCH" in version-bump/*) VERSION="${BRANCH#version-bump/}" - TAG_PREFIX="v" ;; + TAG_PREFIX="desktop-v" ;; relay-release/*) VERSION="${BRANCH#relay-release/}" TAG_PREFIX="relay-v" ;; @@ -85,9 +90,34 @@ jobs: { echo "enabled=true" echo "tag=${TAG_PREFIX}${VERSION}" + if [[ "$TAG_PREFIX" == desktop-v ]]; then + echo "target_sha=${{ github.event.pull_request.head.sha }}" + echo "desktop=true" + else + echo "target_sha=$GITHUB_SHA" + echo "desktop=false" + fi } >> "$GITHUB_OUTPUT" echo "Tagging ${TAG_PREFIX}${VERSION}" + + - name: Verify immutable reviewed desktop candidate + if: steps.release.outputs.desktop == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.release.outputs.tag }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_PUSHER: ${{ github.event.pull_request.head.user.login }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + VERSION="${VERSION#desktop-v}" + export VERSION + scripts/verify-desktop-release-merge.sh + - name: Create release tagger token if: steps.release.outputs.enabled == 'true' id: release-tagger @@ -102,21 +132,22 @@ jobs: env: GH_TOKEN: ${{ steps.release-tagger.outputs.token }} TAG: ${{ steps.release.outputs.tag }} + TARGET_SHA: ${{ steps.release.outputs.target_sha }} run: | set -euo pipefail # Check gh's exit status, not its output. A missing ref returns a 404 # JSON body on stdout, which must not be mistaken for an existing tag. if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" - if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then - echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation" + if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then + echo "Tag $TAG already exists at $TARGET_SHA — skipping tag creation" exit 0 else - echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $GITHUB_SHA)" + echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $TARGET_SHA)" exit 1 fi fi gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$TAG" \ - -f sha="$GITHUB_SHA" \ + -f sha="$TARGET_SHA" \ --silent diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4826d985..59d63f28d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,8 @@ jobs: - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh + - name: Desktop release candidate contract + run: scripts/test-desktop-release-candidate.sh - name: Mobile release contract run: | scripts/test-mobile-release-contract.sh diff --git a/.github/workflows/prepare-desktop-release.yml b/.github/workflows/prepare-desktop-release.yml new file mode 100644 index 000000000..7cc480b93 --- /dev/null +++ b/.github/workflows/prepare-desktop-release.yml @@ -0,0 +1,38 @@ +name: Prepare Desktop Release + +on: + workflow_dispatch: + inputs: + version: + description: Semver to prepare (for example 0.5.1) + required: true + +env: + RELEASE_AUTOMATION_NAME: Carl + RELEASE_AUTOMATION_EMAIL: c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz + +jobs: + prepare: + if: github.repository == 'block/buzz' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Create short-lived release preparer token + id: preparer + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }} + private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + token: ${{ steps.preparer.outputs.token }} + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Prepare immutable candidate and open or update PR + env: + GH_TOKEN: ${{ steps.preparer.outputs.token }} + VERSION: ${{ inputs.version }} + run: scripts/prepare-desktop-release.sh "$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b87e9c8c0..07951ef81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,13 @@ name: Release +concurrency: + group: desktop-release-${{ github.ref }} + cancel-in-progress: false + on: push: tags: - - 'v[0-9]*' - workflow_dispatch: - inputs: - version: - description: "Semver version matching the v-prefixed dispatch tag" - required: true + - 'desktop-v[0-9]*' jobs: # Shared setup: verify the immutable release tag, determine the version, and @@ -19,23 +18,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: write + contents: read outputs: version: ${{ steps.version.outputs.version }} source_sha: ${{ steps.source.outputs.source_sha }} steps: - name: Determine version id: version - env: - EVENT_NAME: ${{ github.event_name }} - INPUT_VERSION: ${{ inputs.version }} - run: | - if [[ "$EVENT_NAME" == "push" ]]; then - VERSION="${GITHUB_REF_NAME#v}" - else - VERSION="$INPUT_VERSION" - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT" - name: Validate version env: @@ -56,42 +46,9 @@ jobs: env: VERSION: ${{ steps.version.outputs.version }} run: | - scripts/verify-release-ref.sh v "$VERSION" + scripts/verify-release-ref.sh desktop-v "$VERSION" echo "source_sha=$(git rev-parse 'HEAD^{commit}')" >> "$GITHUB_OUTPUT" - - name: Create versioned GitHub release - env: - VERSION: ${{ steps.version.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - RELEASE_SHA=$(git rev-parse HEAD) - NOTES="" - if [[ -f CHANGELOG.md ]]; then - NOTES=$(awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found && !/^\$/" CHANGELOG.md) - fi - if [[ -z "$NOTES" ]]; then - NOTES="Buzz Desktop v${VERSION}" - fi - PRERELEASE_FLAGS=() - if [[ "$VERSION" =~ -(test|alpha|beta|rc)([.-]|$) ]]; then - PRERELEASE_FLAGS=(--prerelease --latest=false) - fi - gh release create "v${VERSION}" \ - --target "$RELEASE_SHA" \ - --title "Buzz Desktop v${VERSION}" \ - --notes "$NOTES" \ - "${PRERELEASE_FLAGS[@]}" - - - name: Create rolling auto-update release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create buzz-desktop-latest \ - --prerelease \ - --title "Buzz Desktop Auto-Update" \ - --notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \ - 2>/dev/null || true - release: name: Release if: github.repository == 'block/buzz' @@ -99,7 +56,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -114,7 +71,7 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -272,13 +229,19 @@ jobs: fi echo "dmg=$DMG" >> "$GITHUB_OUTPUT" - # Find the updater .tar.gz and .sig + # Find the updater .tar.gz and .sig. Give each architecture a unique + # release basename before artifacts are merged by the final writer. ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1) SIG="${ARCHIVE}.sig" if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" exit 1 fi + RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_aarch64.app.tar.gz" + mv "$ARCHIVE" "$RENAMED" + mv "$SIG" "${RENAMED}.sig" + ARCHIVE="$RENAMED" + SIG="${RENAMED}.sig" echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT" @@ -289,23 +252,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload arm64 DMG to versioned GitHub release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.artifacts.outputs.dmg }} - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Apple Silicon release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-macos-arm64 + if-no-files-found: error + path: | + ${{ steps.artifacts.outputs.dmg }} + ${{ steps.artifacts.outputs.archive }} + ${{ steps.artifacts.outputs.sig }} release-macos-x64: name: Release macOS (Intel) @@ -314,7 +269,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -330,7 +285,7 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -443,6 +398,11 @@ jobs: echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" exit 1 fi + RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_x64.app.tar.gz" + mv "$ARCHIVE" "$RENAMED" + mv "$SIG" "${RENAMED}.sig" + ARCHIVE="$RENAMED" + SIG="${RENAMED}.sig" echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT" @@ -453,23 +413,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Intel DMG to versioned GitHub release - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.unsigned.outputs.dmg }} - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Intel macOS release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-macos-x64 + if-no-files-found: error + path: | + ${{ steps.unsigned.outputs.dmg }} + ${{ steps.artifacts.outputs.archive }} + ${{ steps.artifacts.outputs.sig }} release-linux: name: Release Linux @@ -480,7 +432,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read env: # AppImage tools (linuxdeploy, appimagetool) are themselves AppImages. # Containers lack FUSE, so we must use the extract-and-run fallback. @@ -555,7 +507,7 @@ jobs: - name: Verify tag-bound release source env: VERSION: ${{ needs.setup.outputs.version }} - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -689,29 +641,16 @@ jobs: SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} # NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux) - - name: Upload Linux artifacts to versioned GitHub release - env: - VERSION: ${{ needs.setup.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEB_PATH: ${{ steps.linux-artifacts.outputs.deb }} - APPIMAGE_PATH: ${{ steps.linux-artifacts.outputs.appimage }} - run: | - gh release upload "v$VERSION" \ - "$DEB_PATH" \ - "$APPIMAGE_PATH" \ - --clobber - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.linux-artifacts.outputs.archive }} - SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} + - name: Stage Linux release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-linux-x64 + if-no-files-found: error + path: | + ${{ steps.linux-artifacts.outputs.deb }} + ${{ steps.linux-artifacts.outputs.appimage }} + ${{ steps.linux-artifacts.outputs.archive }} + ${{ steps.linux-artifacts.outputs.sig }} release-windows: name: Release Windows @@ -719,7 +658,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} sig: ${{ steps.read-sig.outputs.sig }} @@ -735,7 +674,7 @@ jobs: - name: Verify tag-bound release source shell: bash - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: @@ -745,7 +684,7 @@ jobs: with: node-version: 24.14.1 # Disable dependency caching: a writable cache in this release workflow - # (contents: write, feeds a signed installer) is a poisoning vector. pnpm + # (contents: read, feeds a signed installer) is a poisoning vector. pnpm # install runs uncached below. package-manager-cache: false @@ -827,25 +766,14 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Windows installer to versioned GitHub release - shell: bash - run: gh release upload "v${VERSION}" "$EXE_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - EXE_PATH: ${{ steps.artifacts.outputs.exe }} - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - shell: bash - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Windows release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-windows-x64 + if-no-files-found: error + path: | + ${{ steps.artifacts.outputs.exe }} + ${{ steps.artifacts.outputs.sig }} assemble-manifest: name: Assemble multi-platform latest.json @@ -853,7 +781,11 @@ jobs: if: | always() && needs.setup.result == 'success' && - github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) + needs.release.result == 'success' && + needs.release-macos-x64.result == 'success' && + needs.release-linux.result == 'success' && + needs.release-windows.result == 'success' && + github.ref == format('refs/tags/desktop-v{0}', needs.setup.outputs.version) runs-on: ubuntu-latest needs: [setup, release, release-macos-x64, release-linux, release-windows] timeout-minutes: 10 @@ -870,7 +802,26 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" + + - name: Download staged release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-release-* + path: staged-by-platform + + - name: Flatten staged artifacts without basename collisions + run: | + set -euo pipefail + mkdir staged + while IFS= read -r -d '' file; do + name="$(basename "$file")" + [[ ! -e "staged/$name" ]] || { + echo "::error::release artifact basename collision: $name" + exit 1 + } + cp "$file" "staged/$name" + done < <(find staged-by-platform -type f -print0) - name: Write signature files env: @@ -899,7 +850,7 @@ jobs: write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX" write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN" - - name: Verify archive URLs are accessible + - name: Verify draft release has every updater archive env: RESULT_ARM64: ${{ needs.release.result }} RESULT_X64: ${{ needs.release-macos-x64.result }} @@ -911,39 +862,19 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" - ARCHIVES=() - - add_archive() { - local result="$1" platform="$2" archive="$3" - if [[ "$result" == "success" ]]; then - [[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; } - ARCHIVES+=("$archive") - fi - } - - add_archive "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64" - add_archive "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64" - add_archive "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX" - add_archive "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN" - - for name in "${ARCHIVES[@]}"; do - echo "Checking $BASE/$name ..." - success=false - for attempt in 1 2 3; do - if curl -fsI "$BASE/$name" > /dev/null 2>&1; then - success=true - break - fi - echo "Attempt $attempt failed for $name, retrying in 10s..." - sleep 10 - done - if [ "$success" != "true" ]; then - echo "::error::Archive not accessible after 3 attempts: $BASE/$name" - exit 1 + assets=$(find staged -type f -exec basename {} \;) + for spec in \ + "$RESULT_ARM64:$ARCHIVE_ARM64" \ + "$RESULT_X64:$ARCHIVE_X64" \ + "$RESULT_LINUX:$ARCHIVE_LINUX" \ + "$RESULT_WIN:$ARCHIVE_WIN"; do + result="${spec%%:*}" + archive="${spec#*:}" + if [[ "$result" == success ]]; then + [[ -n "$archive" ]] || { echo "::error::successful platform has no archive"; exit 1; } + grep -Fxq "$archive" <<<"$assets" || { echo "::error::draft release missing $archive"; exit 1; } fi done - echo "All archive URLs verified." - name: Generate unified latest.json env: @@ -957,7 +888,7 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" + BASE="https://github.com/block/buzz/releases/download/desktop-v${VERSION}" TRIPLES=() add_triple() { @@ -977,6 +908,45 @@ jobs: bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json cat latest.json - - name: Upload latest.json to rolling release + - name: Create or verify versioned draft run: | - gh release upload buzz-desktop-latest latest.json --clobber + set -euo pipefail + NOTES_FILE="${RUNNER_TEMP}/release-notes.md" + awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found" CHANGELOG.md > "$NOTES_FILE" + [[ -s "$NOTES_FILE" ]] || { echo "::error::missing non-empty changelog block for v${VERSION}"; exit 1; } + PRERELEASE_FLAGS=() + if [[ "$VERSION" == *-* ]]; then + PRERELEASE_FLAGS=(--prerelease --latest=false) + fi + if gh release view "desktop-v${VERSION}" >/dev/null 2>&1; then + EXISTING_SHA=$(gh release view "desktop-v${VERSION}" --json targetCommitish --jq .targetCommitish) + IS_DRAFT=$(gh release view "desktop-v${VERSION}" --json isDraft --jq .isDraft) + [[ "$EXISTING_SHA" == "${{ needs.setup.outputs.source_sha }}" ]] || { + echo "::error::existing release targets $EXISTING_SHA, not the immutable source"; exit 1; + } + if [[ "$IS_DRAFT" != true ]]; then + echo "already_published=true" >> "$GITHUB_ENV" + fi + else + gh release create "desktop-v${VERSION}" \ + --draft \ + --target "${{ needs.setup.outputs.source_sha }}" \ + --title "Buzz Desktop v${VERSION}" \ + --notes-file "$NOTES_FILE" \ + "${PRERELEASE_FLAGS[@]}" + fi + + - name: Upload complete artifact set to versioned draft + if: env.already_published != 'true' + run: | + mapfile -t files < <(find staged -type f -print) + [[ "${#files[@]}" -gt 0 ]] || { echo "::error::no staged release artifacts"; exit 1; } + gh release upload "desktop-v${VERSION}" "${files[@]}" --clobber + + - name: Publish complete versioned release + if: env.already_published != 'true' + run: gh release edit "desktop-v${VERSION}" --draft=false + + - name: Upload latest.json to rolling release last + if: ${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }} + run: gh release upload buzz-desktop-latest latest.json --clobber diff --git a/Justfile b/Justfile index a2fa408e7..2d76f1a7b 100644 --- a/Justfile +++ b/Justfile @@ -725,7 +725,7 @@ bump-relay-version version: cargo update -p buzz-relay echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock" -# Open or update the desktop release PR (signed desktop app) +# Open or update the desktop release PR from an immutable origin/main snapshot release-desktop *ARGS: #!/usr/bin/env bash set -euo pipefail @@ -735,7 +735,7 @@ release-desktop *ARGS: else VERSION="$ARG" fi - just _release-pr desktop "$VERSION" + scripts/prepare-desktop-release.sh "$VERSION" # Open or update the relay release PR (ghcr.io/block/buzz image) release-relay *ARGS: diff --git a/RELEASING.md b/RELEASING.md index 063b813e2..45f0f8638 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`: | Lane | Entry point | Artifact | |------|-------------|----------| -| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) | +| Desktop | `Prepare Desktop Release` / `just release-desktop` | Signed desktop app (macOS/Linux) | | Relay | `just release-relay` | `ghcr.io/block/buzz` container image | | Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity | @@ -31,7 +31,7 @@ just release-relay 0.4.0 scripts/mobile-release.sh candidate 0.5.0 ``` -Desktop and relay releases use metadata PRs. Mobile does not. Each +Desktop uses an immutable generated candidate PR; relay continues using its metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record. There is no mobile release branch, stable mobile tag alias, finalization step, or mobile GitHub Release. @@ -42,12 +42,11 @@ or mobile GitHub Release. ### Desktop -1. **`just release-desktop`** runs locally on `main`, creates or updates a - `version-bump/` PR, bumps the desktop manifests, regenerates - lockfiles, and updates `CHANGELOG.md`. -2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `v`. -3. **The tag triggers `release.yml`.** It builds, signs, notarizes, and - publishes the desktop app for macOS and Linux. +1. Run **Prepare Desktop Release** with a version (or `just release-desktop `). Automation records current `origin/main`, regenerates `version-bump/` as one deterministic candidate commit, and opens or updates the PR. +2. Review the full-SHA changelog, CI, recorded base, and candidate SHA. Any regeneration creates a new head and requires fresh approval. +3. Merge with **Create a merge commit**. Squash and rebase are invalid for desktop release PRs. +4. `auto-tag-on-release-pr-merge` proves that merge parent 2 is the exact approved candidate, then tags that candidate `desktop-v`. +5. The tag triggers `release.yml`. It creates a draft, builds and stages every platform, publishes the complete versioned release, and updates the rolling updater manifest last for stable versions. ### Relay @@ -147,8 +146,8 @@ for distributable builds or builds from an immutable release tag. ## Manual Release Retry The **Release** workflow's manual dispatch is only a retry mechanism for an -existing immutable `v` tag. Select that tag in the ref picker and -provide the matching semver version without the `v` prefix. It cannot build +existing immutable `desktop-v` tag. Select that tag in the ref picker and +provide the matching semver version without the `desktop-v` prefix. It cannot build from `main` or another caller-selected source ref. Mobile intentionally has no branch or arbitrary-ref fallback. The private @@ -171,7 +170,7 @@ for the private pipeline contract. Desktop publishes two GitHub releases: -1. **`v`**: the user-facing release with installers. +1. **`desktop-v`**: the user-facing release with installers. 2. **`buzz-desktop-latest`**: the rolling auto-updater release. Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts @@ -186,7 +185,7 @@ The release workflow builds **two separate macOS DMGs**: Apple Silicon (`darwin-aarch64`, the `release` job) and Intel (`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and `.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to -the same `v` release. Intel users download the `_x64.dmg`. +the same `desktop-v` release. Intel users download the `_x64.dmg`. The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`, which strips infra libraries over-bundled by linuxdeploy (they crash on diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py new file mode 100755 index 000000000..d6518b26b --- /dev/null +++ b/scripts/desktop_release.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Generate and validate immutable desktop release candidates.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CHANGELOG = ROOT / "CHANGELOG.md" +METADATA = ROOT / ".release" / "desktop-candidate.json" +SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$") +DESKTOP_PATHS = ( + "desktop/", + "crates/buzz-core/", + "crates/buzz-persona/", + "crates/buzz-sdk/", + "crates/buzz-agent/", + "crates/buzz-media/", +) +CANDIDATE_FILES = { + ".release/desktop-candidate.json", + "CHANGELOG.md", + "desktop/package.json", + "desktop/src-tauri/tauri.conf.json", + "desktop/src-tauri/Cargo.toml", + "desktop/src-tauri/Cargo.lock", + "pnpm-lock.yaml", +} +REQUIRED_CANDIDATE_FILES = { + ".release/desktop-candidate.json", + "CHANGELOG.md", + "desktop/package.json", + "desktop/src-tauri/tauri.conf.json", + "desktop/src-tauri/Cargo.toml", +} + + +def git(*args: str) -> str: + return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() + + +def commit_list(range_spec: str, paths: tuple[str, ...] | None = None) -> list[dict[str, str]]: + args = ["log", range_spec, "--no-merges", "--format=%H%x00%s"] + if paths: + args += ["--", *paths] + out = git(*args) + if not out: + return [] + return [dict(zip(("sha", "subject"), line.split("\0", 1))) for line in out.splitlines()] + + +def stable_tags(base_sha: str) -> list[tuple[int, str, str]]: + tags: list[tuple[int, str, str]] = [] + for tag in git("tag", "--merged", base_sha, "--list").splitlines(): + if not re.fullmatch(r"(?:desktop-)?v[0-9]+\.[0-9]+\.[0-9]+", tag): + continue + sha = git("rev-list", "-n", "1", tag) + distance = int(git("rev-list", "--count", f"{sha}..{base_sha}")) + tags.append((distance, tag, sha)) + return tags + + +def previous_tag(base_sha: str) -> str: + tags = stable_tags(base_sha) + if not tags: + return "" + min_distance = min(item[0] for item in tags) + nearest = [item for item in tags if item[0] == min_distance] + commits = {item[2] for item in nearest} + if len(commits) != 1: + detail = ", ".join(f"{tag}@{sha}" for _, tag, sha in nearest) + raise SystemExit(f"ambiguous previous desktop release tags: {detail}") + # During migration, prefer the namespaced tag when aliases share a commit. + nearest.sort(key=lambda item: (not item[1].startswith("desktop-v"), item[1])) + return nearest[0][1] + + +def bullet(commit: dict[str, str], repo: str) -> str: + sha, subject = commit["sha"], commit["subject"] + short = sha[:12] + pr_match = re.search(r" \(#([0-9]+)\)$", subject) + if pr_match: + pr = pr_match.group(1) + subject = subject[: pr_match.start()] + return f"- {subject} ([#{pr}](https://github.com/{repo}/pull/{pr})) ([`{sha}`](https://github.com/{repo}/commit/{sha}))" + return f"- {subject} ([`{sha}`](https://github.com/{repo}/commit/{sha}))" + + +def expected(base_sha: str, previous: str) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + # With no prior desktop tag, account for the repository's root commit too. + # A ``root..base`` range silently drops that first commit. + range_spec = f"{previous}..{base_sha}" if previous else base_sha + all_commits = commit_list(range_spec) + relevant_shas = {c["sha"] for c in commit_list(range_spec, DESKTOP_PATHS)} + relevant = [c for c in all_commits if c["sha"] in relevant_shas] + other = [c for c in all_commits if c["sha"] not in relevant_shas] + return relevant, other + + +def render(version: str, base_sha: str, previous: str, repo: str) -> tuple[str, list[str]]: + relevant, other = expected(base_sha, previous) + lines = [f"## v{version}", "", "### Desktop and shared changes", ""] + lines += [bullet(c, repo) for c in relevant] or ["- None"] + lines += ["", "### Other repository changes", ""] + lines += [bullet(c, repo) for c in other] or ["- None"] + compare_start = previous or git("rev-list", "--max-parents=0", base_sha).splitlines()[0] + lines += ["", f"[Compare {compare_start}...desktop-v{version}](https://github.com/{repo}/compare/{compare_start}...desktop-v{version})"] + return "\n".join(lines) + "\n", [c["sha"] for c in relevant + other] + + +def generate(args: argparse.Namespace) -> None: + if not SEMVER.fullmatch(args.version): + raise SystemExit(f"invalid semver: {args.version}") + base_sha = git("rev-parse", args.base) + previous = previous_tag(base_sha) + repo = args.repo or re.sub(r".*github\.com[:/]", "", git("remote", "get-url", "origin")).removesuffix(".git") + block, commits = render(args.version, base_sha, previous, repo) + old = CHANGELOG.read_text() if CHANGELOG.exists() else "# Changelog\n" + if not old.startswith("# Changelog"): + raise SystemExit("CHANGELOG.md must begin with '# Changelog'") + remainder = old.split("\n", 1)[1].lstrip("\n") if "\n" in old else "" + CHANGELOG.write_text(f"# Changelog\n\n{block}\n{remainder}") + METADATA.parent.mkdir(parents=True, exist_ok=True) + METADATA.write_text(json.dumps({ + "schema": 1, + "version": args.version, + "base_sha": base_sha, + "previous_tag": previous or None, + "tag": f"desktop-v{args.version}", + "commit_count": len(commits), + }, indent=2) + "\n") + + +def validate(args: argparse.Namespace) -> None: + data = json.loads(METADATA.read_text()) + version = args.version or data["version"] + if data != {**data, "version": version}: + raise SystemExit("candidate version does not match metadata") + if data["tag"] != f"desktop-v{version}": + raise SystemExit("candidate tag does not match version") + candidate = git("rev-parse", args.candidate) + parents = git("show", "-s", "--format=%P", candidate).split() + if len(parents) != 1 or parents[0] != data["base_sha"]: + raise SystemExit("candidate must be one commit directly above recorded base_sha") + changed = set(git("diff-tree", "--no-commit-id", "--name-only", "-r", candidate).splitlines()) + unexpected = changed - CANDIDATE_FILES + missing = REQUIRED_CANDIDATE_FILES - changed + if unexpected or missing: + detail = [] + if unexpected: + detail.append(f"unexpected files: {', '.join(sorted(unexpected))}") + if missing: + detail.append(f"missing required files: {', '.join(sorted(missing))}") + raise SystemExit("candidate is not version-only (" + "; ".join(detail) + ")") + previous = data["previous_tag"] or "" + actual_previous = previous_tag(data["base_sha"]) + if previous != actual_previous: + raise SystemExit( + f"recorded previous tag {previous or ''} does not match " + f"nearest release tag {actual_previous or ''}" + ) + repo = args.repo or "block/buzz" + expected_block, shas = render(version, data["base_sha"], previous, repo) + text = CHANGELOG.read_text() + blocks = re.findall(rf"(?ms)^## v{re.escape(version)}\n.*?(?=^## v|\Z)", text) + if len(blocks) != 1: + raise SystemExit(f"expected exactly one changelog block for v{version}") + if blocks[0].rstrip() != expected_block.rstrip(): + raise SystemExit("changelog block is not deterministic for recorded candidate base") + found = re.findall(r"\[`([0-9a-f]{40})`\]", blocks[0]) + if len(found) != len(set(found)) or set(found) != set(shas) or len(found) != data["commit_count"]: + raise SystemExit("changelog does not account for every expected non-merge commit exactly once") + manifests = { + ROOT / "desktop/package.json": json.loads((ROOT / "desktop/package.json").read_text())["version"], + ROOT / "desktop/src-tauri/tauri.conf.json": json.loads((ROOT / "desktop/src-tauri/tauri.conf.json").read_text())["version"], + } + cargo = re.search(r'(?m)^version = "([^"]+)"', (ROOT / "desktop/src-tauri/Cargo.toml").read_text()) + manifests[ROOT / "desktop/src-tauri/Cargo.toml"] = cargo.group(1) if cargo else "" + bad = [str(path.relative_to(ROOT)) for path, value in manifests.items() if value != version] + if bad: + raise SystemExit(f"version mismatch in: {', '.join(bad)}") + author = git("show", "-s", "--format=%an <%ae>", candidate) + body = git("show", "-s", "--format=%B", candidate) + if author != "Wes ": + raise SystemExit(f"unexpected candidate author: {author}") + if "Signed-off-by: Wes " not in body: + raise SystemExit("candidate is missing Wes Signed-off-by trailer") + if not re.search(r"(?m)^Co-authored-by: .+ <.+>$", body): + raise SystemExit("candidate is missing automation Co-authored-by trailer") + print(f"validated immutable desktop candidate {candidate} for desktop-v{version}") + + +def main() -> None: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + gen = sub.add_parser("generate") + gen.add_argument("version") + gen.add_argument("--base", required=True) + gen.add_argument("--repo") + val = sub.add_parser("validate") + val.add_argument("--candidate", default="HEAD") + val.add_argument("--version") + val.add_argument("--repo") + args = parser.parse_args() + generate(args) if args.command == "generate" else validate(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh new file mode 100755 index 000000000..c477a84cd --- /dev/null +++ b/scripts/prepare-desktop-release.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +version="${1:-}" +mode="${2:-publish}" +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { + echo "usage: $0 [publish|validate-only]" >&2 + exit 1 +} + +remote="${RELEASE_REMOTE:-origin}" +git fetch "$remote" refs/heads/main:refs/remotes/origin/main --no-tags +git fetch "$remote" '+refs/tags/v*:refs/tags/v*' '+refs/tags/desktop-v*:refs/tags/desktop-v*' +base_sha="$(git rev-parse refs/remotes/origin/main)" +branch="version-bump/$version" + +remote_branch="refs/heads/$branch" +remote_oid="" +if remote_oid="$(git ls-remote "$remote" "$remote_branch" | awk '{print $1}')" && [[ -n "$remote_oid" ]]; then + git fetch "$remote" "$remote_branch:refs/remotes/origin/$branch" +fi + +git checkout -B "$branch" "$base_sha" +just bump-desktop-version "$version" +scripts/desktop_release.py generate "$version" --base "$base_sha" --repo block/buzz + +git add \ + .release/desktop-candidate.json \ + CHANGELOG.md \ + desktop/package.json \ + desktop/src-tauri/tauri.conf.json \ + desktop/src-tauri/Cargo.toml \ + desktop/src-tauri/Cargo.lock \ + pnpm-lock.yaml + +agent_name="${RELEASE_AUTOMATION_NAME:-${AGENT_NAME:-Release Automation}}" +agent_email="${RELEASE_AUTOMATION_EMAIL:-${AGENT_EMAIL:-release-automation@users.noreply.github.com}}" +msg="$(mktemp)" +trap 'rm -f "$msg"' EXIT +cat >"$msg" < +EOF +git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com' \ + commit -s -F "$msg" +scripts/desktop_release.py validate --candidate HEAD --version "$version" --repo block/buzz + +candidate_sha="$(git rev-parse HEAD)" +previous_tag="$(python3 -c 'import json; print(json.load(open(".release/desktop-candidate.json"))["previous_tag"] or "initial")')" +printf 'base_sha=%s\ncandidate_sha=%s\nprevious_tag=%s\ntag=desktop-v%s\n' \ + "$base_sha" "$candidate_sha" "$previous_tag" "$version" + +if [[ "$mode" == validate-only ]]; then + exit 0 +fi +[[ "$mode" == publish ]] || { echo "unknown mode: $mode" >&2; exit 1; } +if [[ -n "$remote_oid" ]]; then + git push --force-with-lease="$remote_branch:$remote_oid" "$remote" "HEAD:$remote_branch" +else + git push --force-with-lease="$remote_branch:" "$remote" "HEAD:$remote_branch" +fi + +body="$(mktemp)" +trap 'rm -f "$msg" "$body"' EXIT +cat >"$body" < "$tmp/desktop/package.json" +printf '{"version":"1.0.0"}\n' > "$tmp/desktop/src-tauri/tauri.conf.json" +printf '[package]\nversion = "1.0.0"\n' > "$tmp/desktop/src-tauri/Cargo.toml" +echo '# Changelog' > "$tmp/CHANGELOG.md" +echo first > "$tmp/desktop/feature" +git -C "$tmp" add . +git -C "$tmp" commit -qm 'feat: first desktop change' +git -C "$tmp" -c tag.gpgSign=false tag v1.0.0 +echo second >> "$tmp/desktop/feature" +git -C "$tmp" commit -qam 'fix: desktop fix' +echo policy > "$tmp/POLICY.md" +git -C "$tmp" add POLICY.md +git -C "$tmp" commit -qm 'docs: repository policy' +base=$(git -C "$tmp" rev-parse HEAD) +( + cd "$tmp" + scripts/desktop_release.py generate 1.0.1 --base "$base" --repo block/buzz + python3 - <<'PY' +import json +for path in ('desktop/package.json', 'desktop/src-tauri/tauri.conf.json'): + data=json.load(open(path)); data['version']='1.0.1'; open(path,'w').write(json.dumps(data)+'\n') +p='desktop/src-tauri/Cargo.toml'; open(p,'w').write('[package]\nversion = "1.0.1"\n') +PY + rm -f msg + git add . + cat >msg <<'EOF' +chore(release): release Buzz Desktop version 1.0.1 + +Co-authored-by: Test Automation +EOF + git -c user.name=Wes -c user.email=wesbillman@users.noreply.github.com commit -q -s -F msg + rm msg + scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz + grep -Fq '### Other repository changes' CHANGELOG.md + grep -Fq "$(git rev-parse HEAD~1)" CHANGELOG.md + grep -Fq "$(git rev-parse HEAD~2)" CHANGELOG.md + + # Metadata cannot lie about the prior release boundary. + cp .release/desktop-candidate.json metadata.json + python3 - <<'PY' +import json +p='.release/desktop-candidate.json'; d=json.load(open(p)); d['previous_tag']=None; open(p,'w').write(json.dumps(d)+'\n') +PY + if scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted a forged previous release tag" >&2 + exit 1 + fi + mv metadata.json .release/desktop-candidate.json +) + +# An initial release must account for the root commit, not silently omit it. +initial=$(mktemp -d) +cp "$repo_root/scripts/desktop_release.py" "$initial/desktop_release.py" +git -C "$initial" init -q +git -C "$initial" config user.name test +git -C "$initial" config user.email test@example.com +mkdir -p "$initial/scripts" "$initial/desktop/src-tauri" +mv "$initial/desktop_release.py" "$initial/scripts/desktop_release.py" +printf '{"version":"0.1.0"}\n' > "$initial/desktop/package.json" +printf '{"version":"0.1.0"}\n' > "$initial/desktop/src-tauri/tauri.conf.json" +printf '[package]\nversion = "0.1.0"\n' > "$initial/desktop/src-tauri/Cargo.toml" +printf '# Changelog\n' > "$initial/CHANGELOG.md" +echo root > "$initial/ROOT.md" +git -C "$initial" add . +git -C "$initial" commit -qm 'feat: root release content' +root_sha=$(git -C "$initial" rev-parse HEAD) +(cd "$initial" && scripts/desktop_release.py generate 0.1.0 --base "$root_sha" --repo block/buzz) +grep -Fq "$root_sha" "$initial/CHANGELOG.md" +rm -rf "$initial" + +echo "desktop release candidate contract passed" diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 0d810819d..bd4eb7527 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -12,16 +12,16 @@ git -C "$tmp" config user.email test@example.com echo first >"$tmp/file" git -C "$tmp" add file git -C "$tmp" commit -qm first -git -C "$tmp" tag -m "desktop release" v1.2.3 +git -C "$tmp" tag -m "desktop release" desktop-v1.2.3 ( cd "$tmp" - GITHUB_REF=refs/tags/v1.2.3 "$verify" v 1.2.3 + GITHUB_REF=refs/tags/desktop-v1.2.3 "$verify" desktop-v 1.2.3 ) if ( cd "$tmp" - GITHUB_REF=refs/heads/main "$verify" v 1.2.3 + GITHUB_REF=refs/heads/main "$verify" desktop-v 1.2.3 ); then echo "branch-backed desktop release was accepted" >&2 exit 1 @@ -31,7 +31,7 @@ echo second >>"$tmp/file" git -C "$tmp" commit -qam second if ( cd "$tmp" - GITHUB_REF=refs/tags/v1.2.3 "$verify" v 1.2.3 + GITHUB_REF=refs/tags/desktop-v1.2.3 "$verify" desktop-v 1.2.3 ); then echo "release accepted HEAD after the tag commit" >&2 exit 1 @@ -61,6 +61,73 @@ grep -q 'private-key:.*secrets\.BUZZ_RELEASE_TAGGER_PRIVATE_KEY' "$auto_tag" grep -q 'permission-contents: write' "$auto_tag" grep -q 'GH_TOKEN:.*steps\.release-tagger\.outputs\.token' "$auto_tag" grep -Fq 'git/refs' "$auto_tag" +grep -Fq 'TAG_PREFIX="desktop-v"' "$auto_tag" +grep -Fq 'target_sha=${{ github.event.pull_request.head.sha }}' "$auto_tag" +grep -Fq 'scripts/verify-desktop-release-merge.sh' "$auto_tag" +review_filter="$repo_root/scripts/review-decision-approved.jq" +for fixture in \ + '{"reviewDecision":"CHANGES_REQUESTED"}' \ + '{"reviewDecision":"REVIEW_REQUIRED"}' \ + '{"reviewDecision":null}' \ + '{}'; do + if jq -e -f "$review_filter" <<<"$fixture" >/dev/null; then + echo "review-decision filter accepted non-approved fixture: $fixture" >&2 + exit 1 + fi +done +jq -e -f "$review_filter" >/dev/null <<'JSON' || { +{"reviewDecision":"APPROVED"} +JSON + echo "review-decision filter rejected approved GraphQL response" >&2 + exit 1 +} +required_check_filter="$repo_root/scripts/required-check-succeeded.jq" +check_fixture() { + local expected="$1" conclusion="$2" status="${3:-completed}" + local payload + payload=$(jq -n --arg status "$status" --arg conclusion "$conclusion" '{check_runs: [{name: "Web", status: $status, conclusion: $conclusion, started_at: "2026-01-01T00:00:00Z"}]}') + if jq -e --arg name Web -f "$required_check_filter" <<<"[$payload]" >/dev/null; then + actual=pass + else + actual=fail + fi + [[ "$actual" == "$expected" ]] || { + echo "required-check filter: expected $conclusion/$status to $expected" >&2 + exit 1 + } +} +check_fixture pass success +check_fixture pass skipped +check_fixture pass neutral +check_fixture fail failure +check_fixture fail success in_progress +# A newer failure must not be hidden by an older successful run of the same check. +jq -e --arg name Web -f "$required_check_filter" >/dev/null <<'JSON' && { +[{"check_runs":[ + {"name":"Web","status":"completed","conclusion":"success","started_at":"2026-01-01T00:00:00Z"}, + {"name":"Web","status":"completed","conclusion":"failure","started_at":"2026-01-02T00:00:00Z"} +]}] +JSON + echo "required-check filter accepted a stale pass over a newer failure" >&2 + exit 1 +} +release_workflow="$repo_root/.github/workflows/release.yml" +[[ "$(grep -c 'contents: write' "$release_workflow")" -eq 1 ]] || { + echo "desktop release must have exactly one GitHub contents writer" >&2; exit 1; +} +grep -Fq "needs.release.result == 'success'" "$release_workflow" +grep -Fq "needs.release-macos-x64.result == 'success'" "$release_workflow" +grep -Fq "needs.release-linux.result == 'success'" "$release_workflow" +grep -Fq "needs.release-windows.result == 'success'" "$release_workflow" +grep -Fq "refs/tags/desktop-v{0}" "$release_workflow" +grep -Fq "if: \${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }}" "$release_workflow" +grep -Fq 'group: desktop-release-${{ github.ref }}' "$release_workflow" +grep -Fq 'cancel-in-progress: false' "$release_workflow" +grep -Fq 'release artifact basename collision' "$release_workflow" +[[ "$(grep -c 'gh release upload' "$release_workflow")" -eq 2 ]] || { + echo "only the final writer may upload versioned and rolling release assets" >&2; exit 1; +} +grep -Fq 'if: env.already_published' "$release_workflow" grep -Fq 'if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then' "$auto_tag" if grep -F 'git/ref/tags/$TAG' "$auto_tag" | grep -Fq '|| true'; then echo "auto-tag ignores a failed tag lookup, so a 404 body can look like an existing tag" >&2 diff --git a/scripts/verify-desktop-release-merge.sh b/scripts/verify-desktop-release-merge.sh new file mode 100755 index 000000000..17bf2f4dc --- /dev/null +++ b/scripts/verify-desktop-release-merge.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PR_HEAD_SHA:?}" +: "${MERGE_SHA:?}" +: "${VERSION:?}" +: "${PR_NUMBER:?}" +: "${GH_TOKEN:?}" + +required_checks=( + "Desktop E2E Integration" + "Desktop" + "Rust Lint" + "Security" + "Unit Tests" + "Windows Rust (x86_64-pc-windows-msvc)" + "Mobile" + "Web" + "Backend Integration (relay e2e)" + "Desktop E2E Relay" + "Relay E2E" + "Desktop Build (macOS)" + "DCO Check" +) + +expected_branch="version-bump/$VERSION" +[[ "${PR_HEAD_REF:-}" == "$expected_branch" ]] || { echo "unexpected release branch" >&2; exit 1; } +[[ "${PR_BASE_REF:-}" == main ]] || { echo "desktop release must target main" >&2; exit 1; } +[[ "${PR_HEAD_REPO:-}" == "$GITHUB_REPOSITORY" ]] || { echo "desktop release must be internal" >&2; exit 1; } + +git fetch origin "$MERGE_SHA" "$PR_HEAD_SHA" refs/heads/main:refs/remotes/origin/main --no-tags +mapfile -t parents < <(git show -s --format='%P' "$MERGE_SHA" | tr ' ' '\n') +[[ "${#parents[@]}" -eq 2 ]] || { echo "desktop release was not merged with a true merge commit" >&2; exit 1; } +[[ "${parents[1]}" == "$PR_HEAD_SHA" ]] || { echo "merge parent 2 is not the reviewed candidate" >&2; exit 1; } +git merge-base --is-ancestor "$PR_HEAD_SHA" origin/main || { echo "candidate is not reachable from current main" >&2; exit 1; } + +git checkout --detach "$PR_HEAD_SHA" +scripts/desktop_release.py validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY" + +review=$(gh api graphql -f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" -F number="$PR_NUMBER" --jq '.data.repository.pullRequest') +jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null || { + echo "pull request effective review decision is not APPROVED" >&2 + exit 1 +} +reviews="$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100")" +valid_approvals="$(jq --arg sha "$PR_HEAD_SHA" '[.[] | select(.state == "APPROVED" and .commit_id == $sha and (.author_association == "MEMBER" or .author_association == "OWNER" or .author_association == "COLLABORATOR"))] | length' <<<"$reviews")" +[[ "$valid_approvals" -gt 0 ]] || { echo "candidate lacks an exact-head approval from a repository member or collaborator" >&2; exit 1; } + +checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?per_page=100")" +for required in "${required_checks[@]}"; do + jq -e --arg name "$required" -f scripts/required-check-succeeded.jq <<<"$checks" >/dev/null || { + echo "required check is missing or unsuccessful: $required" >&2 + exit 1 + } +done +status="$(gh api "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/status")" +jq -e '(.total_count == 0) or (.state == "success")' <<<"$status" >/dev/null || { + echo "candidate has a failing or pending combined commit status" >&2 + exit 1 +} + +echo "verified reviewed desktop candidate $PR_HEAD_SHA at merge $MERGE_SHA" From f48f3f055fdd6030d3832f615f8c0d8e5a81261a Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 30 Jul 2026 19:36:30 +0100 Subject: [PATCH 14/15] Fix video reviews in thread replies (#3719) ## Summary - Show video review comments when a video is opened from a thread reply. - Reuse review-context construction across timeline and thread views. ## Validation - `pnpm run build:e2e && pnpm exec playwright test tests/e2e/video-attachment.spec.ts --project smoke --grep "video replies in threads open the review comments view"` - `pnpm test` --------- Signed-off-by: kenny lopez --- .../src/features/channels/ui/ChannelPane.tsx | 34 +++++----- .../features/channels/ui/ChannelPane.types.ts | 1 + .../features/channels/ui/ChannelScreen.tsx | 2 + .../messages/lib/independentThreadPanel.ts | 16 ++--- .../messages/lib/videoReviewContext.test.mjs | 28 +++++++++ .../messages/lib/videoReviewContext.ts | 45 +++++++++++++ .../messages/ui/MessageThreadPanel.tsx | 11 +++- .../messages/ui/TimelineMessageList.tsx | 47 ++++---------- desktop/tests/e2e/video-attachment.spec.ts | 63 +++++++++++++++++-- 9 files changed, 182 insertions(+), 65 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 92fa172ff..70877875f 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -21,10 +21,7 @@ import { getDmHuddleMemberPubkeys, hasOtherDmParticipant, } from "@/features/channels/lib/dmHuddleMembers"; -import { - buildVideoReviewCommentsByRootId, - buildVideoReviewContextForMessage, -} from "@/features/messages/lib/videoReviewContext"; +import { buildVideoReviewContextsByMessageId } from "@/features/messages/lib/videoReviewContext"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; @@ -150,6 +147,7 @@ export const ChannelPane = React.memo(function ChannelPane({ profilePanelTab, profilePanelView, targetMessageId, + threadAllMessages, threadHeadMessage, threadMessages, threadMessagesPending = false, @@ -472,25 +470,26 @@ export const ChannelPane = React.memo(function ChannelPane({ threadHeadMessage, threadMessages, }); - const videoReviewCommentsByRootId = React.useMemo( - () => buildVideoReviewCommentsByRootId(messages), - [messages], - ); const activeVideoReviewCommentSender = activeChannel?.archivedAt ? undefined : onSendVideoReviewComment; - const threadHeadVideoReviewContext = React.useMemo(() => { - if (!threadHeadMessage) { - return undefined; + const threadVideoReviewContextsByMessageId = React.useMemo(() => { + const messagesById = new Map( + messages.map((message) => [message.id, message]), + ); + if (threadHeadMessage) { + messagesById.set(threadHeadMessage.id, threadHeadMessage); + } + for (const message of threadAllMessages) { + messagesById.set(message.id, message); } - return buildVideoReviewContextForMessage({ + return buildVideoReviewContextsByMessageId({ channelId: activeChannel?.id ?? null, channelName: activeChannel?.name, channelType: activeChannel?.channelType ?? null, - comments: videoReviewCommentsByRootId.get(threadHeadMessage.id) ?? [], isSendingVideoReviewComment: isSending, - message: threadHeadMessage, + messages: [...messagesById.values()], onSendVideoReviewComment: activeVideoReviewCommentSender, onToggleReaction, profiles, @@ -499,10 +498,11 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel, activeVideoReviewCommentSender, isSending, + messages, onToggleReaction, profiles, + threadAllMessages, threadHeadMessage, - videoReviewCommentsByRootId, ]); const isOverlay = useIsThreadPanelOverlay(); @@ -876,7 +876,9 @@ export const ChannelPane = React.memo(function ChannelPane({ scrollTargetHighlights={!layoutScrollTargetId} scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId} threadHead={threadHeadMessage} - threadHeadVideoReviewContext={threadHeadVideoReviewContext} + videoReviewContextsByMessageId={ + threadVideoReviewContextsByMessageId + } widthPx={threadPanelWidthPx} threadReplies={threadMessages} threadRepliesPending={threadMessagesPending} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 5a27d85c4..7257d8cd5 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -151,6 +151,7 @@ export type ChannelPaneProps = { profilePanelTab: ProfilePanelTab; profilePanelView: ProfilePanelView; threadHeadMessage: TimelineMessage | null; + threadAllMessages: TimelineMessage[]; threadMessages: MainTimelineEntry[]; threadMessagesPending?: boolean; threadPanelWidthPx: number; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 52b3ae7fb..7b750daa4 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -691,6 +691,7 @@ export function ChannelScreen({ channelManagementOpen, ); const displayedThreadHeadMessage = threadPanelData.threadHead; + const displayedThreadAllMessages = threadPanelData.messages; const displayedThreadMessages = threadPanelData.visibleReplies; const displayedThreadReplyTargetMessage = threadPanelData.replyTargetMessage; const displayedThreadFirstUnreadReplyId = displayedThreadHeadMessage @@ -944,6 +945,7 @@ export function ChannelScreen({ firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} targetMessageId={mainTimelineTargetMessageId} + threadAllMessages={displayedThreadAllMessages} threadHeadMessage={displayedThreadHeadMessage} threadMessages={displayedThreadMessages} threadMessagesPending={threadRepliesQuery.isPending} diff --git a/desktop/src/features/messages/lib/independentThreadPanel.ts b/desktop/src/features/messages/lib/independentThreadPanel.ts index 4562928d8..7652c2508 100644 --- a/desktop/src/features/messages/lib/independentThreadPanel.ts +++ b/desktop/src/features/messages/lib/independentThreadPanel.ts @@ -11,16 +11,18 @@ export function buildIndependentThreadPanel( ...formatArgs: Tail> ) { if (!rootId) { - return buildThreadPanelData([], null, replyTargetId, expandedReplyIds); + return { + ...buildThreadPanelData([], null, replyTargetId, expandedReplyIds), + messages: [], + }; } const head = channelEvents.find((event) => event.id === rootId); const events = head ? [head, ...replyEvents] : replyEvents; - return buildThreadPanelData( - formatTimelineMessages(events, ...formatArgs), - rootId, - replyTargetId, - expandedReplyIds, - ); + const messages = formatTimelineMessages(events, ...formatArgs); + return { + ...buildThreadPanelData(messages, rootId, replyTargetId, expandedReplyIds), + messages, + }; } type Tail = T extends readonly [ diff --git a/desktop/src/features/messages/lib/videoReviewContext.test.mjs b/desktop/src/features/messages/lib/videoReviewContext.test.mjs index de35d53ce..8ecb5f579 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.test.mjs +++ b/desktop/src/features/messages/lib/videoReviewContext.test.mjs @@ -5,6 +5,7 @@ import { buildVideoReviewCommentsByRootId, buildVideoReviewCommentsForRoot, buildVideoReviewContextForMessage, + buildVideoReviewContextsByMessageId, hasVideoAttachment, } from "./videoReviewContext.ts"; @@ -209,3 +210,30 @@ test("buildVideoReviewContextForMessage posts against the source video", async ( }, ]); }); + +test("buildVideoReviewContextsByMessageId includes video replies", () => { + const root = message({ id: "root", body: "Review request" }); + const videoReply = message({ + id: "video-reply", + body: "![video](https://relay/media/a.mp4)", + parentId: root.id, + rootId: root.id, + }); + const comment = message({ + id: "comment", + body: "[00:01] tighten this", + parentId: videoReply.id, + rootId: root.id, + }); + + const contexts = buildVideoReviewContextsByMessageId({ + channelId: "channel", + messages: [root, videoReply, comment], + }); + + assert.deepEqual([...contexts.keys()], [videoReply.id]); + assert.deepEqual( + contexts.get(videoReply.id)?.comments.map((item) => item.id), + [comment.id], + ); +}); diff --git a/desktop/src/features/messages/lib/videoReviewContext.ts b/desktop/src/features/messages/lib/videoReviewContext.ts index 8d0798db4..f605952f5 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.ts +++ b/desktop/src/features/messages/lib/videoReviewContext.ts @@ -148,3 +148,48 @@ export function buildVideoReviewContextForMessage({ rootEventId: message.id, }; } + +export function buildVideoReviewContextsByMessageId({ + channelId, + channelName, + channelType, + isSendingVideoReviewComment = false, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, +}: { + channelId?: string | null; + channelName?: string; + channelType?: ChannelType | null; + isSendingVideoReviewComment?: boolean; + messages: TimelineMessage[]; + onSendVideoReviewComment?: SendVideoReviewComment; + onToggleReaction?: ToggleMessageReaction; + profiles?: UserProfileLookup; +}): ReadonlyMap { + const contexts = new Map(); + if (!messages.some(hasVideoAttachment)) { + return contexts; + } + + const commentsByRootId = buildVideoReviewCommentsByRootId(messages); + for (const message of messages) { + const context = buildVideoReviewContextForMessage({ + channelId, + channelName, + channelType, + comments: commentsByRootId.get(message.id) ?? [], + isSendingVideoReviewComment, + message, + onSendVideoReviewComment, + onToggleReaction, + profiles, + }); + if (context) { + contexts.set(message.id, context); + } + } + + return contexts; +} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 08a57fa4c..6234af22d 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -108,7 +108,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { threadUnreadCount?: number; threadReplyUnreadCounts?: ReadonlyMap; threadTypingPubkeys: string[]; - threadHeadVideoReviewContext?: VideoReviewContext; + videoReviewContextsByMessageId?: ReadonlyMap; activityAccessoryContent?: React.ReactNode; activityAccessoryVisible: boolean; widthPx: number; @@ -221,7 +221,7 @@ export function MessageThreadPanel({ scrollTargetId, scrollTargetHighlights = true, threadHead, - threadHeadVideoReviewContext, + videoReviewContextsByMessageId, threadReplies, threadRepliesPending = false, threadUnreadCount, @@ -600,7 +600,9 @@ export function MessageThreadPanel({ } profiles={profiles} showDepthGuides={shouldShowThreadBranchGuides} - videoReviewContext={threadHeadVideoReviewContext} + videoReviewContext={videoReviewContextsByMessageId?.get( + threadHead.id, + )} />
@@ -756,6 +758,9 @@ export function MessageThreadPanel({ onToggleReaction={onToggleReaction} profiles={profiles} showDepthGuides={shouldShowThreadBranchGuides} + videoReviewContext={videoReviewContextsByMessageId?.get( + entry.message.id, + )} /> {entry.summary ? ( - messages.some(hasVideoAttachment) - ? buildVideoReviewCommentsByRootId(messages) - : new Map(), - [messages], - ); // Contexts are memoized per message id so MessageRow/Markdown memo // comparisons hold across unrelated timeline re-renders (typing // indicators, presence updates) — a fresh context object per render would // defeat the memo and re-render every video message on every pass. const videoReviewContextById = React.useMemo(() => { - const contexts = new Map< - string, - NonNullable> - >(); - for (const message of messages) { - const comments = reviewCommentsByRootId.get(message.id) ?? []; - const context = buildVideoReviewContextForMessage({ - channelId, - channelName, - channelType, - comments, - isSendingVideoReviewComment, - message, - onSendVideoReviewComment, - onToggleReaction, - profiles, - }); - if (context) { - contexts.set(message.id, context); - } - } - return contexts; + return buildVideoReviewContextsByMessageId({ + channelId, + channelName, + channelType, + isSendingVideoReviewComment, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, + }); }, [ channelId, channelName, @@ -213,7 +191,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onSendVideoReviewComment, onToggleReaction, profiles, - reviewCommentsByRootId, ]); // The flattened item stream, memoized on the entries and the unread boundary diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index 8f4bb0aa4..458689a5e 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -53,25 +53,31 @@ function emitMockMessage( page: Page, channelName: string, content: string, - options: { extraTags?: string[][] } = {}, + options: { extraTags?: string[][]; parentEventId?: string } = {}, ) { return page.evaluate( - ({ channelName, content, extraTags }) => { + ({ channelName, content, extraTags, parentEventId }) => { const emit = ( window as Window & { __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { channelName: string; content: string; extraTags?: string[][]; + parentEventId?: string; }) => unknown; } ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; if (!emit) { throw new Error("Mock message emitter is unavailable."); } - emit({ channelName, content, extraTags }); + return emit({ channelName, content, extraTags, parentEventId }); + }, + { + channelName, + content, + extraTags: options.extraTags, + parentEventId: options.parentEventId, }, - { channelName, content, extraTags: options.extraTags }, ); } @@ -765,6 +771,55 @@ test("video upload previews use poster frames and inline videos open review mode ).toContainText("Color pass looks right"); }); +test("video replies in threads open the review comments view", async ({ + page, +}) => { + await installVideoReviewHarness(page); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + const root = (await emitMockMessage( + page, + "general", + "Can you review this cut?", + )) as { id: string }; + const videoReply = (await emitMockMessage( + page, + "general", + `![video](${VIDEO_URL})`, + { + parentEventId: root.id, + }, + )) as { id: string }; + await emitMockMessage(page, "general", "[00:01] Tighten this transition.", { + parentEventId: videoReply.id, + }); + + const threadSummary = page.locator(`[data-thread-head-id="${root.id}"]`); + await expect(threadSummary).toBeVisible(); + await threadSummary.click(); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadReplies = threadPanel.getByTestId("message-thread-replies"); + const reviewButton = threadReplies.getByRole("button", { + name: "Open video review", + }); + await expect(reviewButton).toBeVisible(); + await reviewButton.click(); + + const reviewDialog = page.getByTestId("video-review-dialog"); + await expect( + reviewDialog.getByTestId("video-review-comments-panel"), + ).toBeVisible(); + await expect(reviewDialog.getByTestId("message-composer")).toBeVisible(); + await expect(reviewDialog.getByTestId("video-review-comments")).toContainText( + "Tighten this transition.", + ); +}); + test("narrow inline videos hide playback speed control", async ({ page }) => { await installVideoReviewHarness(page); From 6e419b9f1c873549a7b40996970e0da7352adafb Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 30 Jul 2026 19:39:12 +0100 Subject: [PATCH 15/15] Tighten continuation message rows (#3724) ## Summary - use uniform 4px top and bottom padding for continuation rows - keep continuation timestamps top-aligned and remove the thread-only minimum-height gutter - raise continuation hover actions by 12px - align virtualized row estimates with the compact layout ## Validation - `pnpm test` (3,782 tests via pre-push) - `pnpm check` - desktop snapshots ## Screenshots ### Mention-chip continuation ![Mention-chip continuation](https://raw.githubusercontent.com/block/buzz/85b88763ef8147f3376c9bf794bc0973a0211a57/pr-3724--thread-continuation.png) ### Emoji continuation ![Emoji continuation](https://raw.githubusercontent.com/block/buzz/85b88763ef8147f3376c9bf794bc0973a0211a57/pr-3724--channel-continuation.png) --------- Signed-off-by: kenny lopez --- .../src/features/messages/lib/rowHeightEstimate.test.mjs | 7 +++++++ desktop/src/features/messages/lib/rowHeightEstimate.ts | 4 ++-- desktop/src/features/messages/ui/MessageRow.tsx | 8 +++++--- desktop/tests/e2e/messaging.spec.ts | 4 +++- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs index dc33da4f8..f17a53c66 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs +++ b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs @@ -23,6 +23,13 @@ test("estimateRowHeight: short text is near the floor", () => { assert.ok(h >= 60 && h < 120, `expected small, got ${h}`); }); +test("estimateRowHeight: continuation reserves its uniform padding", () => { + const h = estimateRowHeight(msg({ body: "hello" }), { + isContinuation: true, + }); + assert.equal(h, 28); +}); + test("estimateRowHeight: many lines reserve more", () => { const tall = estimateRowHeight( msg({ body: Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n") }), diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index f2fb26816..acefae95d 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -26,13 +26,13 @@ const TEXT_LINE_HEIGHT = 20; const CODE_LINE_HEIGHT = 19; const CHARS_PER_LINE = 64; // rough wrap width at the timeline column const ROW_CHROME = 26; // author/time header + denser row padding -const CONTINUATION_ROW_CHROME = 8; // dense row padding only; header/avatar are hidden +const CONTINUATION_ROW_CHROME = 8; // uniform py-1 padding; header/avatar are hidden const MEDIA_BLOCK_MARGIN_TOP = 4; // image/video blocks use mt-1 in markdown const REACTION_ROW = 24; const PREVIEW_CARD = 70; const MESSAGE_ITEM_BOTTOM_PADDING = 10; // TimelineMessageList pb-2.5 const MIN_ESTIMATE = 60; // never reserve less than the old flat floor -const CONTINUATION_MIN_ESTIMATE = 34; +const CONTINUATION_MIN_ESTIMATE = 28; function mediaHeightFromDim(dim: string | undefined): number { const dimensions = dimensionsFromDim(dim); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 069232fde..688b5d5f0 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -433,8 +433,8 @@ export const MessageRow = React.memo(