1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
//! Read an image from the system clipboard by shelling out to the
//! platform's clipboard tool. PNG only (v1 design); no Cargo clipboard
//! dependency. The user must have the tool installed — a missing tool
//! or a clipboard without an image yields `None` (graceful), so the
//! paste binding can fall back to plain text.
//!
//! - macOS: `osascript` reads the clipboard as `«class PNGf»` and writes
//! it to a temp file — ships with every macOS, no `brew install pngpaste`
//! - Linux/Wayland: `wl-paste -t image/png`
//! - Linux/X11: `xclip -selection clipboard -t image/png -o`
//! - Windows: PowerShell + `System.Windows.Forms` clipboard, saved as PNG
//! to a temp file (no native clipboard CLI ships with Windows).
#[cfg(any(unix, windows))]
use std::process::Command;
/// Hard cap on a pasted image (20 MiB). Matches the design doc and
/// keeps a giant screenshot from blowing the transcript.
pub const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
/// A clipboard image: raw PNG bytes + MIME type. `media_type` is
/// always `"image/png"` in v1.
pub struct ClipImage {
pub bytes: Vec<u8>,
pub media_type: &'static str,
}
/// Read a PNG from the clipboard. Returns `None` if no image is
/// present, the platform tool is missing, or the payload exceeds
/// [`MAX_IMAGE_BYTES`].
pub fn read_clipboard_image() -> Option<ClipImage> {
let bytes = read_png_bytes()?;
if !is_within_size_limit(&bytes) {
return None;
}
Some(ClipImage {
bytes,
media_type: "image/png",
})
}
/// True iff `bytes` is non-empty and within the 20 MiB cap. Split out
/// so the bound is unit-testable without a real clipboard.
pub fn is_within_size_limit(bytes: &[u8]) -> bool {
!bytes.is_empty() && bytes.len() <= MAX_IMAGE_BYTES
}
#[cfg(target_os = "macos")]
fn read_png_bytes() -> Option<Vec<u8>> {
// AppleScript coerces the clipboard to PNG (`«class PNGf»`) and
// writes it to a temp file, which we read and remove. `osascript`
// is a system binary present on every macOS, so there's nothing to
// install (unlike `pngpaste`). The clipboard is coerced *before* the
// file is opened, so a clipboard with no image errors out before any
// temp file is created; any non-zero exit => None (text-paste
// fallback).
let path = std::env::temp_dir().join(format!(
"dirge-clip-{}.png",
crate::agent::runner::uuid_v4_simple()
));
// `path` is embedded in an AppleScript string literal; escape the two
// chars that are special there. A UUID temp path contains neither,
// but escape defensively since the id still reaches the script.
let as_path = path.to_str()?.replace('\\', "\\\\").replace('"', "\\\"");
let status = Command::new("osascript")
.args([
"-e",
"set thePng to (the clipboard as «class PNGf»)",
"-e",
&format!("set fh to open for access (POSIX file \"{as_path}\") with write permission"),
"-e",
"set eof fh to 0",
"-e",
"write thePng to fh",
"-e",
"close access fh",
])
.status()
.ok()?;
if !status.success() {
let _ = std::fs::remove_file(&path);
return None;
}
let bytes = std::fs::read(&path).ok();
let _ = std::fs::remove_file(&path);
bytes
}
#[cfg(all(unix, not(target_os = "macos")))]
fn read_png_bytes() -> Option<Vec<u8>> {
// Wayland first (newer), then X11.
if let Some(b) = capture_stdout(&["wl-paste", "-t", "image/png"]) {
return Some(b);
}
capture_stdout(&["xclip", "-selection", "clipboard", "-t", "image/png", "-o"])
}
#[cfg(all(unix, not(target_os = "macos")))]
fn capture_stdout(cmd: &[&str]) -> Option<Vec<u8>> {
let out = Command::new(cmd[0]).args(&cmd[1..]).output().ok()?;
if !out.status.success() {
return None;
}
Some(out.stdout)
}
#[cfg(windows)]
fn read_png_bytes() -> Option<Vec<u8>> {
// Windows ships no clipboard CLI, so shell out to PowerShell + the
// .NET `System.Windows.Forms.Clipboard`. `-STA` is required: OLE
// clipboard access throws from an MTA thread. `GetImage()` returns
// null when no image is present (=> None, text-paste fallback).
let path = std::env::temp_dir().join(format!(
"dirge-clip-{}.png",
crate::agent::runner::uuid_v4_simple()
));
// Embed the path in a PowerShell single-quoted string; the only
// special char there is `'` itself (escaped by doubling).
let ps_path = path.to_str()?.replace('\'', "''");
let script = format!(
"Add-Type -AssemblyName System.Windows.Forms; \
Add-Type -AssemblyName System.Drawing; \
$i = [System.Windows.Forms.Clipboard]::GetImage(); \
if (-not $i) {{ exit 1 }}; \
$i.Save('{ps_path}', [System.Drawing.Imaging.ImageFormat]::Png); \
exit 0"
);
let status = Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-STA",
"-Command",
script.as_str(),
])
.status()
.ok()?;
if !status.success() {
let _ = std::fs::remove_file(&path);
return None;
}
let bytes = std::fs::read(&path).ok();
let _ = std::fs::remove_file(&path);
bytes
}
#[cfg(not(any(target_os = "macos", unix, windows)))]
fn read_png_bytes() -> Option<Vec<u8>> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_is_rejected() {
assert!(!is_within_size_limit(&[]));
}
#[test]
fn small_payload_accepted() {
assert!(is_within_size_limit(&[1, 2, 3]));
}
#[test]
fn exactly_at_cap_accepted() {
let bytes = vec![0u8; MAX_IMAGE_BYTES];
assert!(is_within_size_limit(&bytes));
}
#[test]
fn over_cap_rejected() {
let bytes = vec![0u8; MAX_IMAGE_BYTES + 1];
assert!(!is_within_size_limit(&bytes));
}
}