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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::*;
Expand Down
52 changes: 52 additions & 0 deletions desktop/src-tauri/src/commands/qr_download.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>, 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<bool, String> {
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());
}
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 47 additions & 2 deletions desktop/src/features/community-members/ui/InviteLinkSection.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 },
Expand Down Expand Up @@ -48,6 +54,12 @@ export function InviteLinkSection() {
expiresAt: number;
} | null>(null);
const [copied, setCopied] = React.useState(false);
const [qrMenu, setQrMenu] = React.useState<MediaContextMenuPosition | null>(
null,
);
const qrRef = React.useRef<HTMLCanvasElement | null>(null);
const closeQrMenu = React.useCallback(() => setQrMenu(null), []);
useDismissMediaContextMenu(Boolean(qrMenu), closeQrMenu);

const ttlLabel =
TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days";
Expand Down Expand Up @@ -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 (
<div className="space-y-1.5">
<span className="text-sm font-medium">Invite link</span>
Expand Down Expand Up @@ -160,10 +186,17 @@ export function InviteLinkSection() {
{invite ? (
<div className="flex flex-col gap-3 rounded-md border border-border/70 bg-background/70 p-3 sm:flex-row sm:items-center">
<div className="shrink-0 self-center rounded-md bg-white p-2 text-black">
<QRCodeSVG
<QRCodeCanvas
ref={qrRef}
aria-label="Invite QR code"
data-testid="invite-link-qr-code"
level="M"
onContextMenuCapture={(event) => {
event.preventDefault();
event.stopPropagation();
event.nativeEvent.stopImmediatePropagation();
setQrMenu({ x: event.clientX, y: event.clientY });
}}
size={128}
value={invite.url}
/>
Expand All @@ -185,6 +218,18 @@ export function InviteLinkSection() {
until it expires.
</p>
)}
{qrMenu ? (
<MediaContextMenu
dataAttributes={["data-invite-qr-context-menu"]}
items={[
{
label: "Download image",
onSelect: () => void handleDownloadQr(),
},
]}
position={qrMenu}
/>
) : null}
</div>
);
}
1 change: 1 addition & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions desktop/tests/e2e/invite-qr-download.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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,/);
});
Loading