@steerable/agent-shell-tauri 0.6.52 → 0.6.55

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/README.md CHANGED
@@ -17,8 +17,10 @@ Packaged products place the complete portable Python sidecar at
17
17
  `python-runtime/<platform>` and the verified egress proxy at
18
18
  `engine/steerable-egress-proxy`. The Python runtime contains the native Rust
19
19
  CoreLoop engine. An optional `engine/python-runner` is only a child interpreter
20
- for `run_code`; it never hosts the sidecar. Products may set `pythonRunner` to `bundle` or `download`
21
- in `product.json`. Download mode starts the product without an interpreter and
20
+ for `run_code`; it never hosts the sidecar. Products may set `pythonRunner` to `sidecar`, `bundle`,
21
+ or `download` in `product.json`. Sidecar mode runs `run_code` with the packaged sidecar interpreter
22
+ instead of a second CPython; the child stays confined by the `run_code` sandbox, but it can import the
23
+ sidecar's installed packages. Download mode starts the product without an interpreter and
22
24
  lets the settings UI install one in the background. The default
23
25
  python-build-standalone archive is checked against the packaged SHA-256; users
24
26
  may instead choose an unverified custom archive URL or an existing Python 3.9+
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.55",
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/host.rs CHANGED
@@ -347,6 +347,25 @@ impl HostPaths {
347
347
  let Some(python_runner_mode) = product.python_runner.as_deref() else {
348
348
  return Ok(None);
349
349
  };
350
+ if python_runner_mode == "sidecar" {
351
+ let runner = self
352
+ .sidecar_python
353
+ .clone()
354
+ .ok_or_else(|| "packaged Python sidecar runtime is missing".to_string())?;
355
+ python_runner::configure(
356
+ app,
357
+ RunnerSetup {
358
+ user_data: user_data.to_path_buf(),
359
+ node: self.node.clone(),
360
+ engine_dir: self.engine_dir.clone(),
361
+ lock: None,
362
+ target_name: platform_tag().into(),
363
+ supported: false,
364
+ active_runner: Some(runner.clone()),
365
+ },
366
+ );
367
+ return Ok(Some(runner));
368
+ }
350
369
  let lock: PythonRunnerLock = serde_json::from_str(
351
370
  &std::fs::read_to_string(engine_dir.join("python-runner-lock.json"))
352
371
  .map_err(|error| error.to_string())?,
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,