@steerable/agent-shell-tauri 0.6.51 → 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.51",
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/host.rs CHANGED
@@ -9,7 +9,7 @@ use std::io::{BufRead, BufReader, Write};
9
9
  use std::path::{Path, PathBuf};
10
10
  use std::process::{Command, Stdio};
11
11
  use std::sync::mpsc;
12
- use std::sync::Mutex;
12
+ use std::sync::{Arc, Mutex};
13
13
  use std::thread;
14
14
  use std::time::{Duration, Instant};
15
15
  use tauri::{AppHandle, Manager};
@@ -35,6 +35,34 @@ fn write_diag(dir: &Path, name: &str, body: &str) {
35
35
  let _ = std::fs::write(dir.join(name), body);
36
36
  }
37
37
 
38
+ fn remember_line(recent: &Mutex<Vec<String>>, line: &str) {
39
+ let Ok(mut lines) = recent.lock() else {
40
+ return;
41
+ };
42
+ if lines.len() == 40 {
43
+ lines.remove(0);
44
+ }
45
+ lines.push(line.to_string());
46
+ }
47
+
48
+ fn format_host_exit(reason: &str, recent: &Mutex<Vec<String>>) -> String {
49
+ let detail = recent.lock().ok().and_then(|lines| {
50
+ lines
51
+ .iter()
52
+ .rev()
53
+ .find(|line| {
54
+ line.contains("failed to start")
55
+ || line.contains("StoreAlreadyOwnedError")
56
+ || line.contains("web build not found")
57
+ })
58
+ .cloned()
59
+ });
60
+ match detail {
61
+ Some(line) => format!("{reason}: {line}"),
62
+ None => reason.to_string(),
63
+ }
64
+ }
65
+
38
66
  fn append_diag(dir: &Path, name: &str, line: &str) {
39
67
  if let Ok(mut file) = std::fs::OpenOptions::new()
40
68
  .create(true)
@@ -137,12 +165,16 @@ impl HostProcess {
137
165
  .ok_or_else(|| "Node host stderr was not piped".to_string())?;
138
166
 
139
167
  let (ready_tx, ready_rx) = mpsc::channel();
168
+ let (stderr_done_tx, stderr_done_rx) = mpsc::channel();
169
+ let recent = Arc::new(Mutex::new(Vec::<String>::new()));
140
170
  let stdout_dir = user_data.clone();
171
+ let stdout_recent = Arc::clone(&recent);
141
172
  thread::spawn(move || {
142
173
  for line in BufReader::new(stdout).lines() {
143
174
  match line {
144
175
  Ok(line) => {
145
176
  println!("[node-host] {line}");
177
+ remember_line(&stdout_recent, &line);
146
178
  append_diag(&stdout_dir, "host-node.log", &line);
147
179
  if let Some(record) = line.strip_prefix(READY_PREFIX) {
148
180
  let parsed = serde_json::from_str::<ReadyRecord>(record)
@@ -163,11 +195,14 @@ impl HostProcess {
163
195
  }
164
196
  });
165
197
  let stderr_dir = user_data.clone();
198
+ let stderr_recent = Arc::clone(&recent);
166
199
  thread::spawn(move || {
167
200
  for line in BufReader::new(stderr).lines().map_while(Result::ok) {
168
201
  eprintln!("[node-host] {line}");
202
+ remember_line(&stderr_recent, &line);
169
203
  append_diag(&stderr_dir, "host-node.log", &line);
170
204
  }
205
+ let _ = stderr_done_tx.send(());
171
206
  });
172
207
 
173
208
  let started = Instant::now();
@@ -175,19 +210,28 @@ impl HostProcess {
175
210
  match ready_rx.recv_timeout(Duration::from_millis(100)) {
176
211
  Ok(result) => break result?,
177
212
  Err(mpsc::RecvTimeoutError::Disconnected) => {
213
+ let _ = stderr_done_rx.recv_timeout(Duration::from_millis(500));
178
214
  let _ = child.kill();
179
- return Err("Node host exited before reporting readiness".to_string());
215
+ return Err(format_host_exit(
216
+ "Node host exited before reporting readiness",
217
+ &recent,
218
+ ));
180
219
  }
181
220
  Err(mpsc::RecvTimeoutError::Timeout) => {}
182
221
  }
183
222
  if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
184
- return Err(format!(
185
- "Node host exited before reporting readiness: {status}"
223
+ let _ = stderr_done_rx.recv_timeout(Duration::from_millis(500));
224
+ return Err(format_host_exit(
225
+ &format!("Node host exited before reporting readiness: {status}"),
226
+ &recent,
186
227
  ));
187
228
  }
188
229
  if started.elapsed() >= START_TIMEOUT {
189
230
  let _ = child.kill();
190
- return Err("Node host did not become ready within 30 seconds".to_string());
231
+ return Err(format_host_exit(
232
+ "Node host did not become ready within 30 seconds",
233
+ &recent,
234
+ ));
191
235
  }
192
236
  };
193
237
  write_diag(
package/src/lib.rs CHANGED
@@ -1,18 +1,23 @@
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::{
11
13
  python_runner_cancel, python_runner_download, python_runner_pick_local, python_runner_restart,
12
14
  python_runner_snapshot, python_runner_use_default, python_runner_use_local,
13
15
  };
16
+ use std::io::Write;
14
17
  use std::path::PathBuf;
18
+ use std::sync::atomic::{AtomicBool, Ordering};
15
19
  use std::thread;
20
+ use std::time::Duration;
16
21
  use tauri::{
17
22
  menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder},
18
23
  Emitter, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent,
@@ -100,6 +105,89 @@ fn install_menu(app: &tauri::App) -> tauri::Result<()> {
100
105
  Ok(())
101
106
  }
102
107
 
108
+ static REVEAL_WHEN_READY: AtomicBool = AtomicBool::new(false);
109
+ static INSTANCE_PING: AtomicBool = AtomicBool::new(false);
110
+
111
+ /// Shows the running desktop window.
112
+ ///
113
+ /// Closing the macOS window hides it. `set_focus` then does nothing until
114
+ /// `show` has run on the main thread, so the activation is posted again
115
+ /// after that show.
116
+ fn reveal_main_window(app: &tauri::AppHandle) {
117
+ if app.get_webview_window("main").is_none() {
118
+ REVEAL_WHEN_READY.store(true, Ordering::SeqCst);
119
+ return;
120
+ }
121
+ focus_main_window(app);
122
+ let later = app.clone();
123
+ let _ = app.run_on_main_thread(move || focus_main_window(&later));
124
+ }
125
+
126
+ fn focus_main_window(app: &tauri::AppHandle) {
127
+ let Some(window) = app.get_webview_window("main") else {
128
+ return;
129
+ };
130
+ let _ = window.unminimize();
131
+ let _ = window.show();
132
+ let _ = window.set_focus();
133
+ }
134
+
135
+ fn is_database_in_use(error: &str) -> bool {
136
+ error.contains("StoreAlreadyOwnedError") || error.contains("store already owned")
137
+ }
138
+
139
+ fn database_in_use_message(product_name: &str) -> String {
140
+ format!(
141
+ "再打开一次{product_name}时,会回到已经打开的窗口。\n\n\
142
+ 这次没有回到那个窗口,是因为本地数据库正被另一个进程占用。\
143
+ 如果终端里还在跑 pnpm dev:bs,先在那个终端按 Ctrl+C 停掉,再重新打开{product_name}。\n\n\
144
+ 重新启动后的开发服务会使用单独的数据目录,之后可以和桌面版同时开。"
145
+ )
146
+ }
147
+
148
+ /// Asks an already running desktop app to reveal its window.
149
+ ///
150
+ /// A ping that this process receives itself means the database is held by
151
+ /// something other than a second copy of the app.
152
+ fn wake_other_desktop_app(app: &tauri::AppHandle) -> bool {
153
+ #[cfg(unix)]
154
+ {
155
+ INSTANCE_PING.store(false, Ordering::SeqCst);
156
+ if ping_single_instance(&app.config().identifier).is_err() {
157
+ return false;
158
+ }
159
+ for _ in 0..10 {
160
+ if INSTANCE_PING.load(Ordering::SeqCst) {
161
+ return false;
162
+ }
163
+ thread::sleep(Duration::from_millis(20));
164
+ }
165
+ true
166
+ }
167
+ #[cfg(not(unix))]
168
+ {
169
+ let _ = app;
170
+ false
171
+ }
172
+ }
173
+
174
+ #[cfg(unix)]
175
+ fn ping_single_instance(identifier: &str) -> std::io::Result<()> {
176
+ let socket = PathBuf::from(format!(
177
+ "/tmp/{}_si.sock",
178
+ identifier.replace(['.', '-'], "_")
179
+ ));
180
+ let mut stream = std::os::unix::net::UnixStream::connect(socket)?;
181
+ stream.set_write_timeout(Some(Duration::from_millis(200)))?;
182
+ let cwd = std::env::current_dir().unwrap_or_default();
183
+ let args = std::env::args().collect::<Vec<_>>().join("\0");
184
+ stream.write_all(cwd.to_string_lossy().as_bytes())?;
185
+ stream.write_all(b"\0\0")?;
186
+ stream.write_all(args.as_bytes())?;
187
+ stream.flush()?;
188
+ Ok(())
189
+ }
190
+
103
191
  /// Runs a Tauri desktop shell around the shared Node HostRuntime.
104
192
  pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
105
193
  let product_name = config.product_name.clone();
@@ -107,11 +195,8 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
107
195
  .manage(update::UpdateState::default())
108
196
  .manage(python_runner::PythonRunnerState::default())
109
197
  .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
110
- if let Some(window) = app.get_webview_window("main") {
111
- let _ = window.show();
112
- let _ = window.unminimize();
113
- let _ = window.set_focus();
114
- }
198
+ INSTANCE_PING.store(true, Ordering::SeqCst);
199
+ reveal_main_window(app);
115
200
  }))
116
201
  .plugin(tauri_plugin_dialog::init())
117
202
  .plugin(tauri_plugin_process::init())
@@ -120,6 +205,7 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
120
205
  host_select_directory,
121
206
  host_save_text_file,
122
207
  host_capture_screenshot,
208
+ host_read_clipboard,
123
209
  host_read_clipboard_text,
124
210
  app_release_snapshot,
125
211
  app_release_check,
@@ -139,12 +225,30 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
139
225
  let (host, url) = match HostProcess::spawn(&handle, &config) {
140
226
  Ok(started) => started,
141
227
  Err(error) => {
228
+ if is_database_in_use(&error) && wake_other_desktop_app(&handle) {
229
+ handle.exit(0);
230
+ return;
231
+ }
232
+ let database_in_use = is_database_in_use(&error);
233
+ let message = if database_in_use {
234
+ database_in_use_message(&config.product_name)
235
+ } else {
236
+ error
237
+ };
142
238
  let exit_handle = handle.clone();
143
239
  handle
144
240
  .dialog()
145
- .message(error)
146
- .title(format!("{} 无法启动", config.product_name))
147
- .kind(MessageDialogKind::Error)
241
+ .message(message)
242
+ .title(if database_in_use {
243
+ format!("{} 无法再开一个", config.product_name)
244
+ } else {
245
+ format!("{} 无法启动", config.product_name)
246
+ })
247
+ .kind(if database_in_use {
248
+ MessageDialogKind::Warning
249
+ } else {
250
+ MessageDialogKind::Error
251
+ })
148
252
  .show(move |_| exit_handle.exit(1));
149
253
  return;
150
254
  }
@@ -170,6 +274,9 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
170
274
  .show(move |_| exit_handle.exit(1));
171
275
  return;
172
276
  }
277
+ if REVEAL_WHEN_READY.swap(false, Ordering::SeqCst) {
278
+ focus_main_window(&handle);
279
+ }
173
280
  python_runner::maybe_prompt(&handle);
174
281
  update::start(&handle);
175
282
  });
@@ -204,12 +311,7 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
204
311
  app.exit(0);
205
312
  }
206
313
  #[cfg(target_os = "macos")]
207
- RunEvent::Reopen { .. } => {
208
- if let Some(window) = app.get_webview_window("main") {
209
- let _ = window.show();
210
- let _ = window.set_focus();
211
- }
212
- }
314
+ RunEvent::Reopen { .. } => reveal_main_window(app),
213
315
  _ => {}
214
316
  });
215
317
  }