@steerable/agent-shell-tauri 0.6.51 → 0.6.52

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/host.rs +49 -5
  3. package/src/lib.rs +113 -14
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.52",
4
4
  "description": "Reusable Tauri Rust host source for @steerable/agent-shell",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
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
@@ -11,8 +11,11 @@ use python_runner::{
11
11
  python_runner_cancel, python_runner_download, python_runner_pick_local, python_runner_restart,
12
12
  python_runner_snapshot, python_runner_use_default, python_runner_use_local,
13
13
  };
14
+ use std::io::Write;
14
15
  use std::path::PathBuf;
16
+ use std::sync::atomic::{AtomicBool, Ordering};
15
17
  use std::thread;
18
+ use std::time::Duration;
16
19
  use tauri::{
17
20
  menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder},
18
21
  Emitter, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent,
@@ -100,6 +103,89 @@ fn install_menu(app: &tauri::App) -> tauri::Result<()> {
100
103
  Ok(())
101
104
  }
102
105
 
106
+ static REVEAL_WHEN_READY: AtomicBool = AtomicBool::new(false);
107
+ static INSTANCE_PING: AtomicBool = AtomicBool::new(false);
108
+
109
+ /// Shows the running desktop window.
110
+ ///
111
+ /// Closing the macOS window hides it. `set_focus` then does nothing until
112
+ /// `show` has run on the main thread, so the activation is posted again
113
+ /// after that show.
114
+ fn reveal_main_window(app: &tauri::AppHandle) {
115
+ if app.get_webview_window("main").is_none() {
116
+ REVEAL_WHEN_READY.store(true, Ordering::SeqCst);
117
+ return;
118
+ }
119
+ focus_main_window(app);
120
+ let later = app.clone();
121
+ let _ = app.run_on_main_thread(move || focus_main_window(&later));
122
+ }
123
+
124
+ fn focus_main_window(app: &tauri::AppHandle) {
125
+ let Some(window) = app.get_webview_window("main") else {
126
+ return;
127
+ };
128
+ let _ = window.unminimize();
129
+ let _ = window.show();
130
+ let _ = window.set_focus();
131
+ }
132
+
133
+ fn is_database_in_use(error: &str) -> bool {
134
+ error.contains("StoreAlreadyOwnedError") || error.contains("store already owned")
135
+ }
136
+
137
+ fn database_in_use_message(product_name: &str) -> String {
138
+ format!(
139
+ "再打开一次{product_name}时,会回到已经打开的窗口。\n\n\
140
+ 这次没有回到那个窗口,是因为本地数据库正被另一个进程占用。\
141
+ 如果终端里还在跑 pnpm dev:bs,先在那个终端按 Ctrl+C 停掉,再重新打开{product_name}。\n\n\
142
+ 重新启动后的开发服务会使用单独的数据目录,之后可以和桌面版同时开。"
143
+ )
144
+ }
145
+
146
+ /// Asks an already running desktop app to reveal its window.
147
+ ///
148
+ /// A ping that this process receives itself means the database is held by
149
+ /// something other than a second copy of the app.
150
+ fn wake_other_desktop_app(app: &tauri::AppHandle) -> bool {
151
+ #[cfg(unix)]
152
+ {
153
+ INSTANCE_PING.store(false, Ordering::SeqCst);
154
+ if ping_single_instance(&app.config().identifier).is_err() {
155
+ return false;
156
+ }
157
+ for _ in 0..10 {
158
+ if INSTANCE_PING.load(Ordering::SeqCst) {
159
+ return false;
160
+ }
161
+ thread::sleep(Duration::from_millis(20));
162
+ }
163
+ true
164
+ }
165
+ #[cfg(not(unix))]
166
+ {
167
+ let _ = app;
168
+ false
169
+ }
170
+ }
171
+
172
+ #[cfg(unix)]
173
+ fn ping_single_instance(identifier: &str) -> std::io::Result<()> {
174
+ let socket = PathBuf::from(format!(
175
+ "/tmp/{}_si.sock",
176
+ identifier.replace(['.', '-'], "_")
177
+ ));
178
+ let mut stream = std::os::unix::net::UnixStream::connect(socket)?;
179
+ stream.set_write_timeout(Some(Duration::from_millis(200)))?;
180
+ let cwd = std::env::current_dir().unwrap_or_default();
181
+ let args = std::env::args().collect::<Vec<_>>().join("\0");
182
+ stream.write_all(cwd.to_string_lossy().as_bytes())?;
183
+ stream.write_all(b"\0\0")?;
184
+ stream.write_all(args.as_bytes())?;
185
+ stream.flush()?;
186
+ Ok(())
187
+ }
188
+
103
189
  /// Runs a Tauri desktop shell around the shared Node HostRuntime.
104
190
  pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
105
191
  let product_name = config.product_name.clone();
@@ -107,11 +193,8 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
107
193
  .manage(update::UpdateState::default())
108
194
  .manage(python_runner::PythonRunnerState::default())
109
195
  .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
- }
196
+ INSTANCE_PING.store(true, Ordering::SeqCst);
197
+ reveal_main_window(app);
115
198
  }))
116
199
  .plugin(tauri_plugin_dialog::init())
117
200
  .plugin(tauri_plugin_process::init())
@@ -139,12 +222,30 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
139
222
  let (host, url) = match HostProcess::spawn(&handle, &config) {
140
223
  Ok(started) => started,
141
224
  Err(error) => {
225
+ if is_database_in_use(&error) && wake_other_desktop_app(&handle) {
226
+ handle.exit(0);
227
+ return;
228
+ }
229
+ let database_in_use = is_database_in_use(&error);
230
+ let message = if database_in_use {
231
+ database_in_use_message(&config.product_name)
232
+ } else {
233
+ error
234
+ };
142
235
  let exit_handle = handle.clone();
143
236
  handle
144
237
  .dialog()
145
- .message(error)
146
- .title(format!("{} 无法启动", config.product_name))
147
- .kind(MessageDialogKind::Error)
238
+ .message(message)
239
+ .title(if database_in_use {
240
+ format!("{} 无法再开一个", config.product_name)
241
+ } else {
242
+ format!("{} 无法启动", config.product_name)
243
+ })
244
+ .kind(if database_in_use {
245
+ MessageDialogKind::Warning
246
+ } else {
247
+ MessageDialogKind::Error
248
+ })
148
249
  .show(move |_| exit_handle.exit(1));
149
250
  return;
150
251
  }
@@ -170,6 +271,9 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
170
271
  .show(move |_| exit_handle.exit(1));
171
272
  return;
172
273
  }
274
+ if REVEAL_WHEN_READY.swap(false, Ordering::SeqCst) {
275
+ focus_main_window(&handle);
276
+ }
173
277
  python_runner::maybe_prompt(&handle);
174
278
  update::start(&handle);
175
279
  });
@@ -204,12 +308,7 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
204
308
  app.exit(0);
205
309
  }
206
310
  #[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
- }
311
+ RunEvent::Reopen { .. } => reveal_main_window(app),
213
312
  _ => {}
214
313
  });
215
314
  }