fix(desktop): route text copies through native clipboard (#2054)

Signed-off-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Bradley Axen
2026-07-18 00:01:24 +00:00
committed by GitHub
co-authored by npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n
parent 738d456367
commit 9f82850959
21 changed files with 100 additions and 64 deletions
@@ -0,0 +1,36 @@
use std::sync::Mutex;
use tauri::Manager;
/// App-lifetime clipboard ownership keeps copied data available on Linux and
/// serializes access on Windows. All operations still run on Tauri's main
/// thread for macOS/AppKit safety.
pub struct ClipboardState(Mutex<Option<arboard::Clipboard>>);
impl ClipboardState {
pub fn new() -> Self {
Self(Mutex::new(None))
}
pub fn release(&self) {
if let Ok(mut clipboard) = self.0.lock() {
clipboard.take();
}
}
}
pub fn with_clipboard<T>(
app: &tauri::AppHandle,
operation: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
) -> Result<T, String> {
let state = app.state::<ClipboardState>();
let mut stored = state
.0
.lock()
.map_err(|_| "clipboard state lock poisoned".to_string())?;
if stored.is_none() {
*stored = Some(arboard::Clipboard::new().map_err(|e| format!("clipboard error: {e}"))?);
}
operation(stored.as_mut().expect("clipboard initialized"))
.map_err(|e| format!("clipboard error: {e}"))
}
@@ -3,6 +3,7 @@ use sha2::{Digest, Sha256};
use tauri::State;
use crate::app_state::AppState;
use crate::commands::clipboard::with_clipboard;
use crate::commands::export_util::save_bytes_with_dialog;
use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth, sanitize_filename};
use crate::commands::{
@@ -201,18 +202,15 @@ pub async fn copy_image_to_clipboard(
// arboard requires main-thread access on macOS. Use a sync channel so the
// async command can await the result.
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
let clipboard_app = app.clone();
app.run_on_main_thread(move || {
let result = arboard::Clipboard::new()
.map_err(|e| format!("clipboard error: {e}"))
.and_then(|mut clipboard| {
clipboard
.set_image(arboard::ImageData {
width,
height,
bytes: std::borrow::Cow::Owned(raw),
})
.map_err(|e| format!("clipboard error: {e}"))
});
let result = with_clipboard(&clipboard_app, |clipboard| {
clipboard.set_image(arboard::ImageData {
width,
height,
bytes: std::borrow::Cow::Owned(raw),
})
});
// Ignore send errors — the receiver dropped only if the command was
// cancelled, in which case nobody is waiting for the result.
let _ = tx.send(result);
@@ -235,20 +233,15 @@ pub async fn copy_text_to_clipboard(
app: tauri::AppHandle,
) -> Result<(), String> {
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
let clipboard_app = app.clone();
app.run_on_main_thread(move || {
let result = arboard::Clipboard::new()
.map_err(|e| format!("clipboard error: {e}"))
.and_then(|mut clipboard| {
if let Some(html) = html {
clipboard
.set_html(html, Some(text))
.map_err(|e| format!("clipboard error: {e}"))
} else {
clipboard
.set_text(text)
.map_err(|e| format!("clipboard error: {e}"))
}
});
let result = with_clipboard(&clipboard_app, |clipboard| {
if let Some(html) = html {
clipboard.set_html(html, Some(text))
} else {
clipboard.set_text(text)
}
});
let _ = tx.send(result);
})
.map_err(|e| format!("main thread dispatch failed: {e}"))?;
+2
View File
@@ -12,6 +12,7 @@ mod canvas;
mod channel_templates;
mod channel_window;
mod channels;
mod clipboard;
mod dms;
mod engrams;
mod export_util;
@@ -61,6 +62,7 @@ pub use canvas::*;
pub use channel_templates::*;
pub use channel_window::*;
pub use channels::*;
pub use clipboard::*;
pub use dms::*;
pub use engrams::*;
pub use global_agent_config::*;
+3 -3
View File
@@ -1,7 +1,5 @@
// Deep async call chains (mesh ensure→download→start under Tauri command
// futures) exceed the default query depth when computing layouts.
// Deep async call chains under Tauri command futures exceed the default query depth when computing layouts.
#![recursion_limit = "256"]
mod app_state;
mod archive;
mod commands;
@@ -450,6 +448,7 @@ pub fn run() {
});
})
.manage(build_app_state())
.manage(ClipboardState::new())
.manage(PendingCommunityDeepLinks::default())
.manage(commands::pairing::PairingHandle::new())
.setup(move |app| {
@@ -980,6 +979,7 @@ pub fn run() {
}
RunEvent::Exit => {
shut_down_app(app_handle, &run_shutdown_done);
app_handle.state::<ClipboardState>().release();
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
if restart_requested.load(Ordering::SeqCst) {
@@ -84,6 +84,7 @@ import {
ToggleRow,
} from "./ChannelManagementSheetRows";
import { ChannelManagementModerationActions } from "./ChannelManagementModerationActions";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
type ChannelManagementSheetProps = {
channel: Channel | null;
@@ -788,9 +789,9 @@ function ChannelManagementPanelContent({
icon={Copy}
label="Copy ID"
onClick={() => {
void navigator.clipboard
.writeText(resolvedChannel.id)
.then(() => toast.success("Copied channel ID"));
void writeTextToClipboard(resolvedChannel.id).then(() =>
toast.success("Copied channel ID"),
);
}}
testId="channel-management-copy-id-action"
/>
@@ -12,6 +12,7 @@ import { toast } from "sonner";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Switch } from "@/shared/ui/switch";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
function getChannelIcon(channelType: Channel["channelType"]): LucideIcon {
if (channelType === "forum") {
@@ -126,7 +127,7 @@ export function CopyFieldRow({
testId?: string;
}) {
async function handleCopy() {
await navigator.clipboard.writeText(value);
await writeTextToClipboard(value);
toast.success(`Copied ${label.toLowerCase()}`);
}
+2 -1
View File
@@ -18,6 +18,7 @@ import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
import { cn } from "@/shared/lib/cn";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { Button } from "@/shared/ui/button";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
type ChatHeaderProps = {
actions?: React.ReactNode;
@@ -104,7 +105,7 @@ export function ChatHeader({
if (!value) return;
try {
await navigator.clipboard.writeText(value);
await writeTextToClipboard(value);
toast.success("Channel name copied");
} catch {
toast.error("Failed to copy channel name");
@@ -23,6 +23,7 @@ import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { OnboardingChrome } from "@/features/onboarding/ui/OnboardingChrome";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
type WelcomeSetupPage = "welcome" | "join" | "invite";
type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
@@ -190,7 +191,7 @@ export function WelcomeSetup({
className="h-10 w-10 shrink-0 text-muted-foreground hover:text-foreground"
disabled={!npub}
onClick={() => {
void navigator.clipboard.writeText(npub).then(() => {
void writeTextToClipboard(npub).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
});
@@ -14,6 +14,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
const TTL_OPTIONS: { label: string; value: number }[] = [
{ label: "1 day", value: 24 * 60 * 60 },
@@ -72,7 +73,7 @@ export function InviteLinkSection() {
async function handleCopy() {
if (!invite) return;
try {
await navigator.clipboard.writeText(invite.url);
await writeTextToClipboard(invite.url);
setCopied(true);
toast.success("Invite link copied");
setTimeout(() => setCopied(false), 2000);
@@ -9,6 +9,7 @@ import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { InviteRedeemForm } from "./InviteRedeemForm";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
type MembershipDeniedProps = {
/** The relay that denied membership — used as the target for bare-code invites. */
@@ -53,7 +54,7 @@ export function MembershipDenied({
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(npub);
await writeTextToClipboard(npub);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
@@ -1,6 +1,7 @@
import { Check, Copy, Eye, EyeOff } from "lucide-react";
import * as React from "react";
import { Button } from "@/shared/ui/button";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
type NsecMaskedDisplayProps = {
nsec: string;
@@ -43,7 +44,7 @@ export function NsecMaskedDisplay({
}
async function handleCopy() {
await navigator.clipboard.writeText(nsec);
await writeTextToClipboard(nsec);
setIsCopied(true);
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setIsCopied(false), 2000);
@@ -16,6 +16,7 @@ import { cn } from "@/shared/lib/cn";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
const COPY_SUCCESS_MESSAGE =
"Signed response copied. Paste it into the Buzz admin console.";
@@ -103,7 +104,7 @@ function formatError(error: unknown): string {
async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
await writeTextToClipboard(text);
return true;
} catch (error) {
console.warn("copy signed nostr binding response failed:", error);
@@ -2,6 +2,7 @@ import { Check, Copy } from "lucide-react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
/** Icon button that copies arbitrary text with a brief check feedback. */
export function CopyTextButton({
@@ -15,7 +16,7 @@ export function CopyTextButton({
}) {
const [copied, setCopied] = React.useState(false);
const handleCopy = React.useCallback(() => {
void navigator.clipboard.writeText(text).then(() => {
void writeTextToClipboard(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2_000);
});
@@ -17,6 +17,7 @@ import {
toggleNoteIdInSet,
} from "@/features/pulse/lib/noteActions";
import type { UserNote } from "@/shared/api/socialTypes";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
export type PulseNoteActions = {
isReplySending: boolean;
@@ -144,7 +145,7 @@ export function usePulseNoteActions({
const share = React.useCallback(async (note: UserNote) => {
try {
await navigator.clipboard.writeText(buildNoteShareUri(note));
await writeTextToClipboard(buildNoteShareUri(note));
toast.success("Copied note link");
} catch {
toast.error("Failed to copy note link");
@@ -28,6 +28,7 @@ import {
} from "@/shared/ui/dialog";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
type PairingStep =
| "generating"
@@ -168,7 +169,7 @@ function PairingDialog({
async function handleCopy() {
if (!qrUri) return;
await navigator.clipboard.writeText(qrUri);
await writeTextToClipboard(qrUri);
toast.success("Copied to clipboard");
}
@@ -36,6 +36,7 @@ import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
import { Textarea } from "@/shared/ui/textarea";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
type ProfileSettingsCardProps = {
currentPubkey?: string;
@@ -85,7 +86,7 @@ function IdentityRow({
className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-muted px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-testid={`copy-${testId}`}
onClick={async () => {
await navigator.clipboard.writeText(copyValue);
await writeTextToClipboard(copyValue);
toast.success("Copied to clipboard");
}}
title={`Copy ${label}`}
@@ -21,6 +21,7 @@ import { cn } from "@/shared/lib/cn";
import { getInitials } from "@/shared/lib/initials";
import { isMacPlatform } from "@/shared/lib/platform";
import { useIsFullscreen } from "@/shared/lib/useIsFullscreen";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
type CommunityRailProps = {
communities: Community[];
@@ -223,7 +224,7 @@ export function CommunityRail({
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
void navigator.clipboard.writeText(community.relayUrl);
void writeTextToClipboard(community.relayUrl);
}}
>
<Link2 className="h-4 w-4" />
+9 -7
View File
@@ -1,16 +1,18 @@
import { toast } from "sonner";
/**
* Copy plain text to the clipboard with a success toast, surfacing
* `writeText` rejections (permissions, unfocused document) as an error
* toast instead of an unhandled rejection.
*/
import { copyTextToSystemClipboard } from "@/shared/api/tauriMedia";
/** Write plain text through the native clipboard integration. */
export async function writeTextToClipboard(text: string): Promise<void> {
await copyTextToSystemClipboard(text);
}
/** Copy plain text and show standard success/error feedback. */
export function copyTextToClipboard(
text: string,
successMessage = "Copied to clipboard",
) {
void navigator.clipboard
.writeText(text)
void writeTextToClipboard(text)
.then(() => {
toast.success(successMessage);
})
+4 -5
View File
@@ -1,3 +1,5 @@
import { copyTextToSystemClipboard } from "@/shared/api/tauriMedia";
const BUZZ_CODE_BLOCK_ATTRIBUTE = "data-buzz-code-block";
function escapeHtml(value: string) {
@@ -15,13 +17,10 @@ function createBuzzCodeBlockHtml(code: string) {
export async function copyCodeBlockToClipboard(code: string) {
const clipboard = navigator.clipboard;
if (!clipboard) {
throw new Error("Clipboard API is unavailable");
}
if (
typeof ClipboardItem !== "undefined" &&
typeof clipboard.write === "function"
typeof clipboard?.write === "function"
) {
try {
await clipboard.write([
@@ -38,7 +37,7 @@ export async function copyCodeBlockToClipboard(code: string) {
}
}
await clipboard.writeText(code);
await copyTextToSystemClipboard(code);
}
export function getBuzzCodeBlockClipboardText(
+1 -1
View File
@@ -1388,7 +1388,7 @@ test.describe("inbox stable-conversation regressions", () => {
// be truly centered rather than clamped at the scroll container floor.
for (let i = 1; i <= 5; i++) {
const reply = emit({
emit({
channelName: "general",
content: `Reaction-drift later reply ${i} — provides content below the selected message for a non-clamped center.`,
parentEventId: fetchRoot.id,
-9
View File
@@ -46,15 +46,6 @@ type LatencyReport = {
longtaskTotal: number;
};
function quantile(sorted: number[], q: number): number {
if (sorted.length === 0) return 0;
const index = Math.min(
sorted.length - 1,
Math.floor(q * (sorted.length - 1)),
);
return sorted[index];
}
async function resetWindowMetrics(page: import("@playwright/test").Page) {
await page.evaluate(() => {
const store = window as unknown as {