@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 +8 -0
- package/package.json +1 -1
- package/src/clipboard.rs +206 -0
- package/src/commands.rs +7 -4
- package/src/lib.rs +4 -1
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
package/src/clipboard.rs
ADDED
|
@@ -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
|
-
|
|
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,
|
|
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,
|