@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.
- package/package.json +1 -1
- package/src/host.rs +49 -5
- package/src/lib.rs +113 -14
package/package.json
CHANGED
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(
|
|
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
|
-
|
|
185
|
-
|
|
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(
|
|
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
|
-
|
|
111
|
-
|
|
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(
|
|
146
|
-
.title(
|
|
147
|
-
|
|
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
|
}
|