@steerable/agent-shell-tauri 0.6.52 → 0.6.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.toml CHANGED
@@ -26,3 +26,11 @@ tauri-plugin-single-instance = "2.4.5"
26
26
  tauri-plugin-updater = "2.12.0"
27
27
  tokio = { version = "1", features = ["io-util", "macros", "process", "sync", "time"] }
28
28
  url = "2"
29
+
30
+ [target.'cfg(windows)'.dependencies]
31
+ clipboard-win = "5.4.1"
32
+
33
+ [target.'cfg(target_os = "macos")'.dependencies]
34
+ objc2 = "0.6.4"
35
+ objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSPasteboard"] }
36
+ objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSArray", "NSURL", "NSString"] }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-tauri",
3
- "version": "0.6.52",
3
+ "version": "0.6.53",
4
4
  "description": "Reusable Tauri Rust host source for @steerable/agent-shell",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -0,0 +1,206 @@
1
+ //! OS pasteboard reads for the chat composer.
2
+ //!
3
+ //! WKWebView does not deliver copied files or screenshots to the page.
4
+ //! File lists are read on macOS and Windows. Image data is encoded as PNG
5
+ //! on every desktop.
6
+
7
+ use base64::{engine::general_purpose::STANDARD, Engine};
8
+ use image::codecs::png::PngEncoder;
9
+ use image::{ExtendedColorType, ImageEncoder};
10
+ use serde::Serialize;
11
+
12
+ const MAX_CLIPBOARD_IMAGE_BYTES: usize = 32 * 1024 * 1024;
13
+ const MAX_CLIPBOARD_FILES: usize = 100;
14
+
15
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
16
+ #[serde(rename_all = "camelCase")]
17
+ pub struct ClipboardFile {
18
+ pub name: String,
19
+ #[serde(skip_serializing_if = "Option::is_none")]
20
+ pub path: Option<String>,
21
+ #[serde(skip_serializing_if = "Option::is_none")]
22
+ pub data_base64: Option<String>,
23
+ #[serde(skip_serializing_if = "Option::is_none")]
24
+ pub mime: Option<String>,
25
+ }
26
+
27
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
28
+ #[serde(rename_all = "camelCase")]
29
+ pub struct ClipboardContents {
30
+ pub text: String,
31
+ pub files: Vec<ClipboardFile>,
32
+ }
33
+
34
+ /// Files win over text, and text wins over a bare image.
35
+ ///
36
+ /// The file manager copies a path string next to the file list. Returning
37
+ /// that string would also paste the path into the composer. A screenshot
38
+ /// has no text.
39
+ pub fn read_clipboard() -> ClipboardContents {
40
+ let files = clipboard_file_paths()
41
+ .into_iter()
42
+ .take(MAX_CLIPBOARD_FILES)
43
+ .map(|path| ClipboardFile {
44
+ name: file_name(&path),
45
+ path: Some(path),
46
+ data_base64: None,
47
+ mime: None,
48
+ })
49
+ .collect::<Vec<_>>();
50
+ if !files.is_empty() {
51
+ return ClipboardContents {
52
+ text: String::new(),
53
+ files,
54
+ };
55
+ }
56
+ let text = read_clipboard_text();
57
+ if !text.is_empty() {
58
+ return ClipboardContents {
59
+ text,
60
+ files: Vec::new(),
61
+ };
62
+ }
63
+ ClipboardContents {
64
+ text: String::new(),
65
+ files: clipboard_image().into_iter().collect(),
66
+ }
67
+ }
68
+
69
+ pub fn file_name(path: &str) -> String {
70
+ std::path::Path::new(path)
71
+ .file_name()
72
+ .map(|name| name.to_string_lossy().into_owned())
73
+ .filter(|name| !name.is_empty())
74
+ .unwrap_or_else(|| path.to_string())
75
+ }
76
+
77
+ pub fn read_clipboard_text() -> String {
78
+ let Ok(mut clipboard) = arboard::Clipboard::new() else {
79
+ return String::new();
80
+ };
81
+ clipboard.get_text().unwrap_or_default()
82
+ }
83
+
84
+ fn clipboard_image() -> Option<ClipboardFile> {
85
+ let mut clipboard = arboard::Clipboard::new().ok()?;
86
+ let image = clipboard.get_image().ok()?;
87
+ let png = png_from_rgba(image.width, image.height, image.bytes.as_ref())?;
88
+ Some(ClipboardFile {
89
+ name: "image.png".to_string(),
90
+ path: None,
91
+ data_base64: Some(STANDARD.encode(png)),
92
+ mime: Some("image/png".to_string()),
93
+ })
94
+ }
95
+
96
+ pub fn png_from_rgba(width: usize, height: usize, bytes: &[u8]) -> Option<Vec<u8>> {
97
+ let width = u32::try_from(width).ok()?;
98
+ let height = u32::try_from(height).ok()?;
99
+ let expected = (width as usize)
100
+ .checked_mul(height as usize)?
101
+ .checked_mul(4)?;
102
+ if bytes.len() != expected || expected > MAX_CLIPBOARD_IMAGE_BYTES {
103
+ return None;
104
+ }
105
+ let mut png = std::io::Cursor::new(Vec::new());
106
+ PngEncoder::new(&mut png)
107
+ .write_image(bytes, width, height, ExtendedColorType::Rgba8)
108
+ .ok()?;
109
+ let encoded = png.into_inner();
110
+ if encoded.len() > MAX_CLIPBOARD_IMAGE_BYTES {
111
+ return None;
112
+ }
113
+ Some(encoded)
114
+ }
115
+
116
+ fn clipboard_file_paths() -> Vec<String> {
117
+ #[cfg(target_os = "macos")]
118
+ {
119
+ macos_file_paths()
120
+ }
121
+ #[cfg(target_os = "windows")]
122
+ {
123
+ windows_file_paths()
124
+ }
125
+ #[cfg(not(any(target_os = "macos", target_os = "windows")))]
126
+ {
127
+ Vec::new()
128
+ }
129
+ }
130
+
131
+ /// Explorer stores a copied selection as `CF_HDROP`.
132
+ #[cfg(target_os = "windows")]
133
+ fn windows_file_paths() -> Vec<String> {
134
+ let files: Vec<String> =
135
+ clipboard_win::get_clipboard(clipboard_win::formats::FileList).unwrap_or_default();
136
+ files.into_iter().filter(|path| !path.is_empty()).collect()
137
+ }
138
+
139
+ #[cfg(target_os = "macos")]
140
+ fn macos_file_paths() -> Vec<String> {
141
+ use objc2::ClassType;
142
+ use objc2_app_kit::NSPasteboard;
143
+ use objc2_foundation::{NSArray, NSURL};
144
+
145
+ let pasteboard = NSPasteboard::generalPasteboard();
146
+ let classes = NSArray::from_slice(&[NSURL::class()]);
147
+ // `class_array` is `NSURL`. `options` is nil, then non-file URLs are dropped.
148
+ let from_urls = unsafe { pasteboard.readObjectsForClasses_options(&classes, None) }
149
+ .map(|items| {
150
+ items
151
+ .to_vec()
152
+ .into_iter()
153
+ .filter_map(|item| item.downcast::<NSURL>().ok())
154
+ .filter(|url| url.isFileURL())
155
+ .filter_map(|url| url.path())
156
+ .map(|path| path.to_string())
157
+ .filter(|path| !path.is_empty())
158
+ .collect::<Vec<_>>()
159
+ })
160
+ .unwrap_or_default();
161
+ if !from_urls.is_empty() {
162
+ return from_urls;
163
+ }
164
+ legacy_filename_paths(&pasteboard)
165
+ }
166
+
167
+ /// Finder still writes this deprecated list on some copies.
168
+ #[cfg(target_os = "macos")]
169
+ #[allow(deprecated)]
170
+ fn legacy_filename_paths(pasteboard: &objc2_app_kit::NSPasteboard) -> Vec<String> {
171
+ use objc2_app_kit::NSFilenamesPboardType;
172
+ use objc2_foundation::{NSArray, NSString};
173
+
174
+ let Some(list) = pasteboard.propertyListForType(unsafe { NSFilenamesPboardType }) else {
175
+ return Vec::new();
176
+ };
177
+ let Ok(array) = list.downcast::<NSArray>() else {
178
+ return Vec::new();
179
+ };
180
+ array
181
+ .to_vec()
182
+ .into_iter()
183
+ .filter_map(|item| item.downcast::<NSString>().ok())
184
+ .map(|path| path.to_string())
185
+ .filter(|path| !path.is_empty())
186
+ .collect()
187
+ }
188
+
189
+ #[cfg(test)]
190
+ mod tests {
191
+ use super::*;
192
+
193
+ #[test]
194
+ fn file_name_uses_the_last_path_segment() {
195
+ assert_eq!(file_name("/tmp/纪要.docx"), "纪要.docx");
196
+ assert_eq!(file_name("notes.txt"), "notes.txt");
197
+ }
198
+
199
+ #[test]
200
+ fn png_from_rgba_encodes_one_pixel_and_rejects_a_bad_buffer() {
201
+ let png = png_from_rgba(1, 1, &[255, 0, 0, 255]).expect("1x1 rgba encodes");
202
+ assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
203
+ assert!(png_from_rgba(2, 2, &[0, 0, 0, 0]).is_none());
204
+ assert!(png_from_rgba(usize::MAX, usize::MAX, &[]).is_none());
205
+ }
206
+ }
package/src/commands.rs CHANGED
@@ -1,3 +1,4 @@
1
+ use crate::clipboard::{self, ClipboardContents};
1
2
  use arboard::{Clipboard, ImageData};
2
3
  use base64::{engine::general_purpose::STANDARD, Engine};
3
4
  use serde::{Deserialize, Serialize};
@@ -108,12 +109,14 @@ pub async fn host_save_text_file(
108
109
  })
109
110
  }
110
111
 
112
+ #[tauri::command]
113
+ pub fn host_read_clipboard() -> ClipboardContents {
114
+ clipboard::read_clipboard()
115
+ }
116
+
111
117
  #[tauri::command]
112
118
  pub fn host_read_clipboard_text() -> String {
113
- let Ok(mut clipboard) = Clipboard::new() else {
114
- return String::new();
115
- };
116
- clipboard.get_text().unwrap_or_default()
119
+ clipboard::read_clipboard_text()
117
120
  }
118
121
 
119
122
  #[tauri::command]
package/src/lib.rs CHANGED
@@ -1,10 +1,12 @@
1
+ mod clipboard;
1
2
  mod commands;
2
3
  mod host;
3
4
  mod python_runner;
4
5
  mod update;
5
6
 
6
7
  use commands::{
7
- host_capture_screenshot, host_read_clipboard_text, host_save_text_file, host_select_directory,
8
+ host_capture_screenshot, host_read_clipboard, host_read_clipboard_text, host_save_text_file,
9
+ host_select_directory,
8
10
  };
9
11
  use host::HostProcess;
10
12
  use python_runner::{
@@ -203,6 +205,7 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
203
205
  host_select_directory,
204
206
  host_save_text_file,
205
207
  host_capture_screenshot,
208
+ host_read_clipboard,
206
209
  host_read_clipboard_text,
207
210
  app_release_snapshot,
208
211
  app_release_check,