diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 37c812c761..9c35447e84 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -101,6 +101,7 @@ export default defineConfig({ "**/channel-sort.spec.ts", "**/identity-lost.spec.ts", "**/deep-link-invite.spec.ts", + "**/invite-qr-download.spec.ts", "**/global-agent-config-screenshots.spec.ts", "**/doctor-states.spec.ts", "**/onboarding-avatar-skip.spec.ts", diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 3de7c68454..49b71db929 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -40,6 +40,7 @@ mod project_git_exec; mod project_git_workflow; mod project_repo_paths; mod project_terminal; +mod qr_download; mod relay_members; mod relay_reconnect; mod social; @@ -88,6 +89,7 @@ pub use project_git::*; pub use project_git_diff::*; pub use project_git_workflow::*; pub use project_terminal::*; +pub use qr_download::*; pub use relay_members::*; pub use relay_reconnect::*; pub use social::*; diff --git a/desktop/src-tauri/src/commands/qr_download.rs b/desktop/src-tauri/src/commands/qr_download.rs new file mode 100644 index 0000000000..74a777b452 --- /dev/null +++ b/desktop/src-tauri/src/commands/qr_download.rs @@ -0,0 +1,52 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; + +use crate::commands::export_util::save_bytes_with_dialog; +use crate::commands::media::sanitize_filename; +use crate::commands::personas::PNG_MAGIC; + +fn decode_png_data_url(data_url: &str) -> Result, String> { + let encoded = data_url + .strip_prefix("data:image/png;base64,") + .ok_or_else(|| "invalid PNG data URL".to_string())?; + let bytes = STANDARD + .decode(encoded.trim()) + .map_err(|_| "invalid PNG data URL".to_string())?; + if !bytes.starts_with(&PNG_MAGIC) { + return Err("invalid PNG data URL".to_string()); + } + Ok(bytes) +} + +/// Save a generated PNG data URL through the native save-file dialog. +#[tauri::command] +pub async fn save_png_data_url( + data_url: String, + filename: String, + app: tauri::AppHandle, +) -> Result { + let bytes = decode_png_data_url(&data_url)?; + let filename = sanitize_filename(&filename); + save_bytes_with_dialog(&app, &filename, "PNG image", &["png"], &bytes).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_png_data_url_accepts_png_payload() { + let encoded = STANDARD.encode([PNG_MAGIC.as_slice(), &[0, 1, 2]].concat()); + let bytes = decode_png_data_url(&format!("data:image/png;base64,{encoded}")).unwrap(); + assert!(bytes.starts_with(&PNG_MAGIC)); + } + + #[test] + fn decode_png_data_url_rejects_wrong_mime_or_bytes() { + let png = STANDARD.encode(PNG_MAGIC); + assert!(decode_png_data_url(&format!("data:image/jpeg;base64,{png}")).is_err()); + + let not_png = STANDARD.encode(b"not a png"); + assert!(decode_png_data_url(&format!("data:image/png;base64,{not_png}")).is_err()); + assert!(decode_png_data_url("data:image/png;base64,not-base64!").is_err()); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6e4b2a81c8..ee323ee896 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -745,6 +745,7 @@ pub fn run() { pick_and_upload_image, upload_media_bytes, download_image, + save_png_data_url, download_file, fetch_media_bytes, copy_image_to_clipboard, diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index 19d73b6d2f..552fa34082 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -1,9 +1,10 @@ import { Check, Copy, Link2 } from "lucide-react"; -import { QRCodeSVG } from "qrcode.react"; +import { QRCodeCanvas } from "qrcode.react"; import * as React from "react"; import { toast } from "sonner"; import { mintInvite } from "@/shared/api/invites"; +import { invokeTauri } from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -15,6 +16,11 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; +import { + MediaContextMenu, + type MediaContextMenuPosition, + useDismissMediaContextMenu, +} from "@/shared/ui/markdown/MediaContextMenu"; const TTL_OPTIONS: { label: string; value: number }[] = [ { label: "1 day", value: 24 * 60 * 60 }, @@ -48,6 +54,12 @@ export function InviteLinkSection() { expiresAt: number; } | null>(null); const [copied, setCopied] = React.useState(false); + const [qrMenu, setQrMenu] = React.useState( + null, + ); + const qrRef = React.useRef(null); + const closeQrMenu = React.useCallback(() => setQrMenu(null), []); + useDismissMediaContextMenu(Boolean(qrMenu), closeQrMenu); const ttlLabel = TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days"; @@ -82,6 +94,20 @@ export function InviteLinkSection() { } } + async function handleDownloadQr() { + closeQrMenu(); + const dataUrl = qrRef.current?.toDataURL("image/png"); + if (!dataUrl) return; + try { + await invokeTauri("save_png_data_url", { + dataUrl, + filename: "buzz-community-invite.png", + }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Download failed"); + } + } + return (
Invite link @@ -160,10 +186,17 @@ export function InviteLinkSection() { {invite ? (
- { + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + setQrMenu({ x: event.clientX, y: event.clientY }); + }} size={128} value={invite.url} /> @@ -185,6 +218,18 @@ export function InviteLinkSection() { until it expires.

)} + {qrMenu ? ( + void handleDownloadQr(), + }, + ]} + position={qrMenu} + /> + ) : null}
); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 79ef67173d..1444ad6fa6 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10150,6 +10150,7 @@ export function maybeInstallE2eTauriMocks() { return buf; } case "download_image": + case "save_png_data_url": case "download_file": // The save dialog can't run headlessly; report a successful save so the // FileCard / image-menu click handlers resolve. Specs assert the diff --git a/desktop/tests/e2e/invite-qr-download.spec.ts b/desktop/tests/e2e/invite-qr-download.spec.ts new file mode 100644 index 0000000000..41f08575df --- /dev/null +++ b/desktop/tests/e2e/invite-qr-download.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); + await page.route("**/api/invites", async (route) => { + await route.fulfill({ + contentType: "application/json", + json: { + code: "qr-download-test", + expires_at: Math.floor(Date.now() / 1000) + 86_400, + url: "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=qr-download-test", + }, + status: 200, + }); + }); +}); + +test("invite QR reuses the media menu and saves its PNG", async ({ page }) => { + await page.goto("/"); + await openSettings(page, "community-members"); + await expect(page.getByTestId("settings-community-members")).toBeVisible(); + + await page.getByTestId("create-invite-link").click(); + const qrCode = page.getByTestId("invite-link-qr-code"); + await expect(qrCode).toBeVisible(); + + await qrCode.click({ button: "right", position: { x: 32, y: 32 } }); + const menu = page.locator("[data-invite-qr-context-menu]"); + await expect(menu).toBeVisible(); + await menu.getByRole("button", { name: "Download image" }).click(); + await expect(menu).not.toBeVisible(); + + const payload = await page.evaluate(() => { + const log = ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: Record | null; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__; + return log?.find(({ command }) => command === "save_png_data_url")?.payload; + }); + + expect(payload?.filename).toBe("buzz-community-invite.png"); + expect(payload?.dataUrl).toMatch(/^data:image\/png;base64,/); +});