Skip to content

Commit 9f82850

Browse files
baxennpub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n
andauthored
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>
1 parent 738d456 commit 9f82850

21 files changed

Lines changed: 100 additions & 64 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use std::sync::Mutex;
2+
3+
use tauri::Manager;
4+
5+
/// App-lifetime clipboard ownership keeps copied data available on Linux and
6+
/// serializes access on Windows. All operations still run on Tauri's main
7+
/// thread for macOS/AppKit safety.
8+
pub struct ClipboardState(Mutex<Option<arboard::Clipboard>>);
9+
10+
impl ClipboardState {
11+
pub fn new() -> Self {
12+
Self(Mutex::new(None))
13+
}
14+
15+
pub fn release(&self) {
16+
if let Ok(mut clipboard) = self.0.lock() {
17+
clipboard.take();
18+
}
19+
}
20+
}
21+
22+
pub fn with_clipboard<T>(
23+
app: &tauri::AppHandle,
24+
operation: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
25+
) -> Result<T, String> {
26+
let state = app.state::<ClipboardState>();
27+
let mut stored = state
28+
.0
29+
.lock()
30+
.map_err(|_| "clipboard state lock poisoned".to_string())?;
31+
if stored.is_none() {
32+
*stored = Some(arboard::Clipboard::new().map_err(|e| format!("clipboard error: {e}"))?);
33+
}
34+
operation(stored.as_mut().expect("clipboard initialized"))
35+
.map_err(|e| format!("clipboard error: {e}"))
36+
}

‎desktop/src-tauri/src/commands/media_download.rs‎

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use sha2::{Digest, Sha256};
33
use tauri::State;
44

55
use crate::app_state::AppState;
6+
use crate::commands::clipboard::with_clipboard;
67
use crate::commands::export_util::save_bytes_with_dialog;
78
use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth, sanitize_filename};
89
use crate::commands::{
@@ -201,18 +202,15 @@ pub async fn copy_image_to_clipboard(
201202
// arboard requires main-thread access on macOS. Use a sync channel so the
202203
// async command can await the result.
203204
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
205+
let clipboard_app = app.clone();
204206
app.run_on_main_thread(move || {
205-
let result = arboard::Clipboard::new()
206-
.map_err(|e| format!("clipboard error: {e}"))
207-
.and_then(|mut clipboard| {
208-
clipboard
209-
.set_image(arboard::ImageData {
210-
width,
211-
height,
212-
bytes: std::borrow::Cow::Owned(raw),
213-
})
214-
.map_err(|e| format!("clipboard error: {e}"))
215-
});
207+
let result = with_clipboard(&clipboard_app, |clipboard| {
208+
clipboard.set_image(arboard::ImageData {
209+
width,
210+
height,
211+
bytes: std::borrow::Cow::Owned(raw),
212+
})
213+
});
216214
// Ignore send errors — the receiver dropped only if the command was
217215
// cancelled, in which case nobody is waiting for the result.
218216
let _ = tx.send(result);
@@ -235,20 +233,15 @@ pub async fn copy_text_to_clipboard(
235233
app: tauri::AppHandle,
236234
) -> Result<(), String> {
237235
let (tx, rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
236+
let clipboard_app = app.clone();
238237
app.run_on_main_thread(move || {
239-
let result = arboard::Clipboard::new()
240-
.map_err(|e| format!("clipboard error: {e}"))
241-
.and_then(|mut clipboard| {
242-
if let Some(html) = html {
243-
clipboard
244-
.set_html(html, Some(text))
245-
.map_err(|e| format!("clipboard error: {e}"))
246-
} else {
247-
clipboard
248-
.set_text(text)
249-
.map_err(|e| format!("clipboard error: {e}"))
250-
}
251-
});
238+
let result = with_clipboard(&clipboard_app, |clipboard| {
239+
if let Some(html) = html {
240+
clipboard.set_html(html, Some(text))
241+
} else {
242+
clipboard.set_text(text)
243+
}
244+
});
252245
let _ = tx.send(result);
253246
})
254247
.map_err(|e| format!("main thread dispatch failed: {e}"))?;

‎desktop/src-tauri/src/commands/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod canvas;
1212
mod channel_templates;
1313
mod channel_window;
1414
mod channels;
15+
mod clipboard;
1516
mod dms;
1617
mod engrams;
1718
mod export_util;
@@ -61,6 +62,7 @@ pub use canvas::*;
6162
pub use channel_templates::*;
6263
pub use channel_window::*;
6364
pub use channels::*;
65+
pub use clipboard::*;
6466
pub use dms::*;
6567
pub use engrams::*;
6668
pub use global_agent_config::*;

‎desktop/src-tauri/src/lib.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
// Deep async call chains (mesh ensure→download→start under Tauri command
2-
// futures) exceed the default query depth when computing layouts.
1+
// Deep async call chains under Tauri command futures exceed the default query depth when computing layouts.
32
#![recursion_limit = "256"]
4-
53
mod app_state;
64
mod archive;
75
mod commands;
@@ -450,6 +448,7 @@ pub fn run() {
450448
});
451449
})
452450
.manage(build_app_state())
451+
.manage(ClipboardState::new())
453452
.manage(PendingCommunityDeepLinks::default())
454453
.manage(commands::pairing::PairingHandle::new())
455454
.setup(move |app| {
@@ -980,6 +979,7 @@ pub fn run() {
980979
}
981980
RunEvent::Exit => {
982981
shut_down_app(app_handle, &run_shutdown_done);
982+
app_handle.state::<ClipboardState>().release();
983983

984984
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
985985
if restart_requested.load(Ordering::SeqCst) {

‎desktop/src/features/channels/ui/ChannelManagementSheet.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import {
8484
ToggleRow,
8585
} from "./ChannelManagementSheetRows";
8686
import { ChannelManagementModerationActions } from "./ChannelManagementModerationActions";
87+
import { writeTextToClipboard } from "@/shared/lib/clipboard";
8788

8889
type ChannelManagementSheetProps = {
8990
channel: Channel | null;
@@ -788,9 +789,9 @@ function ChannelManagementPanelContent({
788789
icon={Copy}
789790
label="Copy ID"
790791
onClick={() => {
791-
void navigator.clipboard
792-
.writeText(resolvedChannel.id)
793-
.then(() => toast.success("Copied channel ID"));
792+
void writeTextToClipboard(resolvedChannel.id).then(() =>
793+
toast.success("Copied channel ID"),
794+
);
794795
}}
795796
testId="channel-management-copy-id-action"
796797
/>

‎desktop/src/features/channels/ui/ChannelManagementSheetRows.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { toast } from "sonner";
1212
import type { Channel } from "@/shared/api/types";
1313
import { cn } from "@/shared/lib/cn";
1414
import { Switch } from "@/shared/ui/switch";
15+
import { writeTextToClipboard } from "@/shared/lib/clipboard";
1516

1617
function getChannelIcon(channelType: Channel["channelType"]): LucideIcon {
1718
if (channelType === "forum") {
@@ -126,7 +127,7 @@ export function CopyFieldRow({
126127
testId?: string;
127128
}) {
128129
async function handleCopy() {
129-
await navigator.clipboard.writeText(value);
130+
await writeTextToClipboard(value);
130131
toast.success(`Copied ${label.toLowerCase()}`);
131132
}
132133

‎desktop/src/features/chat/ui/ChatHeader.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
1818
import { cn } from "@/shared/lib/cn";
1919
import { channelChrome } from "@/shared/layout/chromeLayout";
2020
import { Button } from "@/shared/ui/button";
21+
import { writeTextToClipboard } from "@/shared/lib/clipboard";
2122

2223
type ChatHeaderProps = {
2324
actions?: React.ReactNode;
@@ -104,7 +105,7 @@ export function ChatHeader({
104105
if (!value) return;
105106

106107
try {
107-
await navigator.clipboard.writeText(value);
108+
await writeTextToClipboard(value);
108109
toast.success("Channel name copied");
109110
} catch {
110111
toast.error("Failed to copy channel name");

‎desktop/src/features/communities/ui/WelcomeSetup.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { Button } from "@/shared/ui/button";
2323
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
2424
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
2525
import { OnboardingChrome } from "@/features/onboarding/ui/OnboardingChrome";
26+
import { writeTextToClipboard } from "@/shared/lib/clipboard";
2627

2728
type WelcomeSetupPage = "welcome" | "join" | "invite";
2829
type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
@@ -190,7 +191,7 @@ export function WelcomeSetup({
190191
className="h-10 w-10 shrink-0 text-muted-foreground hover:text-foreground"
191192
disabled={!npub}
192193
onClick={() => {
193-
void navigator.clipboard.writeText(npub).then(() => {
194+
void writeTextToClipboard(npub).then(() => {
194195
setCopied(true);
195196
window.setTimeout(() => setCopied(false), 1500);
196197
});

‎desktop/src/features/community-members/ui/InviteLinkSection.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
DropdownMenuSeparator,
1515
DropdownMenuTrigger,
1616
} from "@/shared/ui/dropdown-menu";
17+
import { writeTextToClipboard } from "@/shared/lib/clipboard";
1718

1819
const TTL_OPTIONS: { label: string; value: number }[] = [
1920
{ label: "1 day", value: 24 * 60 * 60 },
@@ -72,7 +73,7 @@ export function InviteLinkSection() {
7273
async function handleCopy() {
7374
if (!invite) return;
7475
try {
75-
await navigator.clipboard.writeText(invite.url);
76+
await writeTextToClipboard(invite.url);
7677
setCopied(true);
7778
toast.success("Invite link copied");
7879
setTimeout(() => setCopied(false), 2000);

‎desktop/src/features/onboarding/ui/MembershipDenied.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Input } from "@/shared/ui/input";
99
import { Spinner } from "@/shared/ui/spinner";
1010
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
1111
import { InviteRedeemForm } from "./InviteRedeemForm";
12+
import { writeTextToClipboard } from "@/shared/lib/clipboard";
1213

1314
type MembershipDeniedProps = {
1415
/** The relay that denied membership — used as the target for bare-code invites. */
@@ -53,7 +54,7 @@ export function MembershipDenied({
5354

5455
const handleCopy = React.useCallback(async () => {
5556
try {
56-
await navigator.clipboard.writeText(npub);
57+
await writeTextToClipboard(npub);
5758
setCopied(true);
5859
setTimeout(() => setCopied(false), 2000);
5960
} catch {

0 commit comments

Comments
 (0)