@steerable/agent-shell-tauri 0.6.46 → 0.6.48
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/README.md +8 -0
- package/package.json +1 -1
- package/src/host.rs +201 -53
- package/src/lib.rs +47 -19
- package/src/update.rs +295 -34
- package/src/host_tests.rs +0 -27
package/README.md
CHANGED
|
@@ -12,3 +12,11 @@ resource paths, and updates.
|
|
|
12
12
|
|
|
13
13
|
The crate is source-distributed with the npm package and is not published to
|
|
14
14
|
crates.io.
|
|
15
|
+
|
|
16
|
+
Packaged products place the verified Rust sidecar and egress proxy at
|
|
17
|
+
`engine/steerable-sidecar` and `engine/steerable-egress-proxy`. An optional
|
|
18
|
+
`engine/python-runner` is only a child interpreter for `run_code`; it never
|
|
19
|
+
hosts the sidecar. Products may set `pythonRunner` to `bundle` or `download`
|
|
20
|
+
in `product.json`. Download mode uses the packaged lock and installer,
|
|
21
|
+
verifies SHA-256 before an atomic install under the product data directory,
|
|
22
|
+
and passes the resulting absolute executable as `STEERABLE_PYTHON`.
|
package/package.json
CHANGED
package/src/host.rs
CHANGED
|
@@ -2,9 +2,9 @@ use crate::DesktopConfig;
|
|
|
2
2
|
use command_group::{CommandGroup, GroupChild};
|
|
3
3
|
#[cfg(unix)]
|
|
4
4
|
use command_group::{Signal, UnixChildExt};
|
|
5
|
-
use serde::Deserialize;
|
|
5
|
+
use serde::{Deserialize, Serialize};
|
|
6
6
|
use std::env;
|
|
7
|
-
use std::io::{BufRead, BufReader};
|
|
7
|
+
use std::io::{BufRead, BufReader, Write};
|
|
8
8
|
use std::path::{Path, PathBuf};
|
|
9
9
|
use std::process::{Command, Stdio};
|
|
10
10
|
use std::sync::mpsc;
|
|
@@ -12,23 +12,74 @@ use std::sync::Mutex;
|
|
|
12
12
|
use std::thread;
|
|
13
13
|
use std::time::{Duration, Instant};
|
|
14
14
|
use tauri::{AppHandle, Manager};
|
|
15
|
+
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
|
15
16
|
use url::Url;
|
|
16
17
|
|
|
17
18
|
const READY_PREFIX: &str = "STEERABLE_HOST_READY ";
|
|
18
19
|
const START_TIMEOUT: Duration = Duration::from_secs(30);
|
|
19
20
|
|
|
20
|
-
#[derive(Deserialize)]
|
|
21
|
+
#[derive(Deserialize, Serialize)]
|
|
21
22
|
struct ReadyRecord {
|
|
22
23
|
host: String,
|
|
23
24
|
port: u16,
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
#[derive(Deserialize)]
|
|
28
|
+
#[serde(rename_all = "camelCase")]
|
|
29
|
+
struct PythonRunnerLock {
|
|
30
|
+
version: String,
|
|
31
|
+
python_version: String,
|
|
32
|
+
python_build_standalone_release: String,
|
|
33
|
+
targets: std::collections::HashMap<String, PythonRunnerTarget>,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[derive(Deserialize)]
|
|
37
|
+
struct PythonRunnerTarget {
|
|
38
|
+
triple: String,
|
|
39
|
+
sha256: String,
|
|
40
|
+
runner: String,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#[derive(Deserialize)]
|
|
44
|
+
#[serde(rename_all = "camelCase")]
|
|
45
|
+
struct ProductRuntime {
|
|
46
|
+
python_runner: Option<String>,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fn write_diag(dir: &Path, name: &str, body: &str) {
|
|
50
|
+
let _ = std::fs::write(dir.join(name), body);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fn append_diag(dir: &Path, name: &str, line: &str) {
|
|
54
|
+
if let Ok(mut file) = std::fs::OpenOptions::new()
|
|
55
|
+
.create(true)
|
|
56
|
+
.append(true)
|
|
57
|
+
.open(dir.join(name))
|
|
58
|
+
{
|
|
59
|
+
let _ = writeln!(file, "{line}");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
26
63
|
pub struct HostProcess {
|
|
27
64
|
child: Mutex<Option<GroupChild>>,
|
|
28
65
|
}
|
|
29
66
|
|
|
30
67
|
impl HostProcess {
|
|
31
68
|
pub fn spawn(app: &AppHandle, config: &DesktopConfig) -> Result<(Self, Url), String> {
|
|
69
|
+
match Self::spawn_inner(app, config) {
|
|
70
|
+
Ok(started) => Ok(started),
|
|
71
|
+
Err(error) => {
|
|
72
|
+
if let Some(dir) = env::var_os("DEEPPATH_USER_DATA_DIR") {
|
|
73
|
+
let dir = PathBuf::from(dir);
|
|
74
|
+
let _ = std::fs::create_dir_all(&dir);
|
|
75
|
+
write_diag(&dir, "host-error.txt", &error);
|
|
76
|
+
}
|
|
77
|
+
Err(error)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn spawn_inner(app: &AppHandle, config: &DesktopConfig) -> Result<(Self, Url), String> {
|
|
32
83
|
let paths = HostPaths::resolve(app, config)?;
|
|
33
84
|
let user_data = match env::var_os("DEEPPATH_USER_DATA_DIR") {
|
|
34
85
|
Some(path) => PathBuf::from(path),
|
|
@@ -39,6 +90,30 @@ impl HostProcess {
|
|
|
39
90
|
.join(&config.data_dir_name),
|
|
40
91
|
};
|
|
41
92
|
std::fs::create_dir_all(&user_data).map_err(|error| error.to_string())?;
|
|
93
|
+
let sidecar_tmp = user_data.join("sidecar/tmp");
|
|
94
|
+
std::fs::create_dir_all(&sidecar_tmp).map_err(|error| error.to_string())?;
|
|
95
|
+
let python_runner = paths.resolve_python_runner(app, config, &user_data);
|
|
96
|
+
if let Err(error) = &python_runner {
|
|
97
|
+
eprintln!("[python-runner] {error}; run_code will be unavailable");
|
|
98
|
+
app.dialog()
|
|
99
|
+
.message(format!(
|
|
100
|
+
"Python 运行器安装失败,run_code 本次不可用。\n\n{error}"
|
|
101
|
+
))
|
|
102
|
+
.title("Python 运行器")
|
|
103
|
+
.kind(MessageDialogKind::Error)
|
|
104
|
+
.show(|_| {});
|
|
105
|
+
}
|
|
106
|
+
write_diag(
|
|
107
|
+
&user_data,
|
|
108
|
+
"host-spawn.txt",
|
|
109
|
+
&format!(
|
|
110
|
+
"node={}\nserver={}\napp_root={}\nweb_dist={}\n",
|
|
111
|
+
paths.node.display(),
|
|
112
|
+
paths.server_entry.display(),
|
|
113
|
+
paths.app_root.display(),
|
|
114
|
+
paths.web_dist.display()
|
|
115
|
+
),
|
|
116
|
+
);
|
|
42
117
|
|
|
43
118
|
let mut command = Command::new(&paths.node);
|
|
44
119
|
command
|
|
@@ -49,12 +124,15 @@ impl HostProcess {
|
|
|
49
124
|
.env("DEEPPATH_BS_HOST", "127.0.0.1")
|
|
50
125
|
.env("DEEPPATH_BS_PORT", "0")
|
|
51
126
|
.env("DEEPPATH_WEB_DIST", &paths.web_dist)
|
|
52
|
-
.env("DEEPPATH_USER_DATA_DIR", user_data)
|
|
127
|
+
.env("DEEPPATH_USER_DATA_DIR", &user_data)
|
|
128
|
+
.env("TMPDIR", &sidecar_tmp)
|
|
129
|
+
.env("TMP", &sidecar_tmp)
|
|
130
|
+
.env("TEMP", &sidecar_tmp)
|
|
53
131
|
.env("STEERABLE_HOST_PARENT_PID", std::process::id().to_string())
|
|
54
132
|
.stdin(Stdio::null())
|
|
55
133
|
.stdout(Stdio::piped())
|
|
56
134
|
.stderr(Stdio::piped());
|
|
57
|
-
paths.apply_runtime_env(&mut command);
|
|
135
|
+
paths.apply_runtime_env(&mut command, python_runner.ok().flatten());
|
|
58
136
|
|
|
59
137
|
let mut child = spawn_group(&mut command).map_err(|error| {
|
|
60
138
|
format!(
|
|
@@ -74,11 +152,13 @@ impl HostProcess {
|
|
|
74
152
|
.ok_or_else(|| "Node host stderr was not piped".to_string())?;
|
|
75
153
|
|
|
76
154
|
let (ready_tx, ready_rx) = mpsc::channel();
|
|
155
|
+
let stdout_dir = user_data.clone();
|
|
77
156
|
thread::spawn(move || {
|
|
78
157
|
for line in BufReader::new(stdout).lines() {
|
|
79
158
|
match line {
|
|
80
159
|
Ok(line) => {
|
|
81
160
|
println!("[node-host] {line}");
|
|
161
|
+
append_diag(&stdout_dir, "host-node.log", &line);
|
|
82
162
|
if let Some(record) = line.strip_prefix(READY_PREFIX) {
|
|
83
163
|
let parsed = serde_json::from_str::<ReadyRecord>(record)
|
|
84
164
|
.map_err(|error| error.to_string());
|
|
@@ -86,15 +166,22 @@ impl HostProcess {
|
|
|
86
166
|
}
|
|
87
167
|
}
|
|
88
168
|
Err(error) => {
|
|
169
|
+
append_diag(
|
|
170
|
+
&stdout_dir,
|
|
171
|
+
"host-node.log",
|
|
172
|
+
&format!("stdout error: {error}"),
|
|
173
|
+
);
|
|
89
174
|
let _ = ready_tx.send(Err(error.to_string()));
|
|
90
175
|
break;
|
|
91
176
|
}
|
|
92
177
|
}
|
|
93
178
|
}
|
|
94
179
|
});
|
|
180
|
+
let stderr_dir = user_data.clone();
|
|
95
181
|
thread::spawn(move || {
|
|
96
182
|
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
|
|
97
183
|
eprintln!("[node-host] {line}");
|
|
184
|
+
append_diag(&stderr_dir, "host-node.log", &line);
|
|
98
185
|
}
|
|
99
186
|
});
|
|
100
187
|
|
|
@@ -118,6 +205,11 @@ impl HostProcess {
|
|
|
118
205
|
return Err("Node host did not become ready within 30 seconds".to_string());
|
|
119
206
|
}
|
|
120
207
|
};
|
|
208
|
+
write_diag(
|
|
209
|
+
&user_data,
|
|
210
|
+
"host-ready.json",
|
|
211
|
+
&serde_json::to_string(&ready).map_err(|error| error.to_string())?,
|
|
212
|
+
);
|
|
121
213
|
let url = Url::parse(&format!("http://{}:{}/", ready.host, ready.port))
|
|
122
214
|
.map_err(|error| error.to_string())?;
|
|
123
215
|
Ok((
|
|
@@ -165,7 +257,7 @@ struct HostPaths {
|
|
|
165
257
|
}
|
|
166
258
|
|
|
167
259
|
impl HostPaths {
|
|
168
|
-
fn apply_runtime_env(&self, command: &mut Command) {
|
|
260
|
+
fn apply_runtime_env(&self, command: &mut Command, python_runner: Option<PathBuf>) {
|
|
169
261
|
let Some(engine_dir) = &self.engine_dir else {
|
|
170
262
|
return;
|
|
171
263
|
};
|
|
@@ -179,22 +271,111 @@ impl HostPaths {
|
|
|
179
271
|
if egress_proxy.exists() {
|
|
180
272
|
command.env("STEERABLE_EGRESS_PROXY_BIN", egress_proxy);
|
|
181
273
|
}
|
|
182
|
-
let python =
|
|
183
|
-
engine_dir
|
|
184
|
-
.join("python-runtime")
|
|
185
|
-
.join(platform_tag())
|
|
186
|
-
.join(if cfg!(windows) {
|
|
187
|
-
"python/python.exe"
|
|
188
|
-
} else {
|
|
189
|
-
"python/bin/python3"
|
|
190
|
-
});
|
|
191
|
-
if python.exists() {
|
|
192
|
-
command.env("STEERABLE_SIDECAR_PYTHON", python);
|
|
193
|
-
}
|
|
194
274
|
let win_spawn_helper = engine_dir.join("win-spawn-helper/win-spawn-helper.exe");
|
|
195
275
|
if win_spawn_helper.exists() {
|
|
196
276
|
command.env("DEEPPATH_WIN_SPAWN_HELPER", win_spawn_helper);
|
|
197
277
|
}
|
|
278
|
+
if let Some(python_runner) = python_runner {
|
|
279
|
+
command.env("STEERABLE_PYTHON", python_runner);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
fn resolve_python_runner(
|
|
284
|
+
&self,
|
|
285
|
+
app: &AppHandle,
|
|
286
|
+
config: &DesktopConfig,
|
|
287
|
+
user_data: &Path,
|
|
288
|
+
) -> Result<Option<PathBuf>, String> {
|
|
289
|
+
if let Some(explicit) = env::var_os("STEERABLE_PYTHON") {
|
|
290
|
+
let explicit = PathBuf::from(explicit);
|
|
291
|
+
if explicit.is_absolute() && explicit.is_file() {
|
|
292
|
+
return Ok(Some(explicit));
|
|
293
|
+
}
|
|
294
|
+
return Err("STEERABLE_PYTHON must name an existing absolute file".into());
|
|
295
|
+
}
|
|
296
|
+
let Some(engine_dir) = &self.engine_dir else {
|
|
297
|
+
return Ok(None);
|
|
298
|
+
};
|
|
299
|
+
let product: ProductRuntime = serde_json::from_str(
|
|
300
|
+
&std::fs::read_to_string(
|
|
301
|
+
self.app_root
|
|
302
|
+
.join("products")
|
|
303
|
+
.join(&config.product_id)
|
|
304
|
+
.join("product.json"),
|
|
305
|
+
)
|
|
306
|
+
.map_err(|error| error.to_string())?,
|
|
307
|
+
)
|
|
308
|
+
.map_err(|error| error.to_string())?;
|
|
309
|
+
let Some(python_runner_mode) = product.python_runner.as_deref() else {
|
|
310
|
+
return Ok(None);
|
|
311
|
+
};
|
|
312
|
+
let lock: PythonRunnerLock = serde_json::from_str(
|
|
313
|
+
&std::fs::read_to_string(engine_dir.join("python-runner-lock.json"))
|
|
314
|
+
.map_err(|error| error.to_string())?,
|
|
315
|
+
)
|
|
316
|
+
.map_err(|error| error.to_string())?;
|
|
317
|
+
let target_name = platform_tag();
|
|
318
|
+
let target = lock
|
|
319
|
+
.targets
|
|
320
|
+
.get(target_name)
|
|
321
|
+
.ok_or_else(|| format!("Python runner does not support {target_name}"))?;
|
|
322
|
+
|
|
323
|
+
if python_runner_mode == "bundle" {
|
|
324
|
+
let runner = engine_dir.join("python-runner").join(&target.runner);
|
|
325
|
+
return runner
|
|
326
|
+
.is_file()
|
|
327
|
+
.then_some(Some(runner))
|
|
328
|
+
.ok_or_else(|| "bundled Python runner is missing".to_string());
|
|
329
|
+
}
|
|
330
|
+
if python_runner_mode != "download" {
|
|
331
|
+
return Err(format!(
|
|
332
|
+
"unsupported pythonRunner mode {python_runner_mode:?}"
|
|
333
|
+
));
|
|
334
|
+
}
|
|
335
|
+
let destination = user_data
|
|
336
|
+
.join("python-runner")
|
|
337
|
+
.join(&lock.version)
|
|
338
|
+
.join(target_name);
|
|
339
|
+
let runner = destination.join(&target.runner);
|
|
340
|
+
if runner.exists() {
|
|
341
|
+
return Ok(Some(runner));
|
|
342
|
+
}
|
|
343
|
+
let confirmed = app
|
|
344
|
+
.dialog()
|
|
345
|
+
.message(
|
|
346
|
+
"Aroli 需要下载独立的 Python 运行器才能使用 run_code。\n\n\
|
|
347
|
+
不安装不会影响聊天和其他工具。下载内容会经过 SHA-256 校验。",
|
|
348
|
+
)
|
|
349
|
+
.title("安装 Python 代码运行器")
|
|
350
|
+
.buttons(MessageDialogButtons::OkCancelCustom(
|
|
351
|
+
"下载并安装".to_string(),
|
|
352
|
+
"暂不安装".to_string(),
|
|
353
|
+
))
|
|
354
|
+
.blocking_show();
|
|
355
|
+
if !confirmed {
|
|
356
|
+
return Ok(None);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let filename = format!(
|
|
360
|
+
"cpython-{}+{}-{}-install_only_stripped.tar.gz",
|
|
361
|
+
lock.python_version, lock.python_build_standalone_release, target.triple
|
|
362
|
+
);
|
|
363
|
+
let url = format!(
|
|
364
|
+
"https://github.com/astral-sh/python-build-standalone/releases/download/{}/{}",
|
|
365
|
+
lock.python_build_standalone_release, filename
|
|
366
|
+
);
|
|
367
|
+
let installer = engine_dir.join("install-python-runner.mjs");
|
|
368
|
+
let status = Command::new(&self.node)
|
|
369
|
+
.arg(installer)
|
|
370
|
+
.args(["--url", &url, "--sha256", &target.sha256, "--destination"])
|
|
371
|
+
.arg(&destination)
|
|
372
|
+
.args(["--runner", &target.runner])
|
|
373
|
+
.status()
|
|
374
|
+
.map_err(|error| format!("failed to start Python runner installer: {error}"))?;
|
|
375
|
+
if !status.success() || !runner.exists() {
|
|
376
|
+
return Err(format!("Python runner installer exited with {status}"));
|
|
377
|
+
}
|
|
378
|
+
Ok(Some(runner))
|
|
198
379
|
}
|
|
199
380
|
|
|
200
381
|
fn resolve(app: &AppHandle, config: &DesktopConfig) -> Result<Self, String> {
|
|
@@ -224,20 +405,12 @@ impl HostPaths {
|
|
|
224
405
|
.path()
|
|
225
406
|
.resource_dir()
|
|
226
407
|
.map_err(|error| error.to_string())?;
|
|
227
|
-
let resource_dir = node_compatible_path(&resource_dir);
|
|
228
408
|
let host_root = resource_dir.join("node-host");
|
|
229
409
|
let app_root = host_root.join("app-dist");
|
|
230
|
-
let engine_node = resource_dir.join("engine").join(platform_binary("node"));
|
|
231
410
|
Ok(Self {
|
|
232
411
|
node: env::var_os("DEEPPATH_TAURI_NODE")
|
|
233
412
|
.map(PathBuf::from)
|
|
234
|
-
.unwrap_or_else(||
|
|
235
|
-
if engine_node.exists() {
|
|
236
|
-
engine_node
|
|
237
|
-
} else {
|
|
238
|
-
resource_dir.join(node_resource_name())
|
|
239
|
-
}
|
|
240
|
-
}),
|
|
413
|
+
.unwrap_or_else(|| resource_dir.join(node_resource_name())),
|
|
241
414
|
server_entry: app_root
|
|
242
415
|
.join("products")
|
|
243
416
|
.join(&config.product_id)
|
|
@@ -273,33 +446,12 @@ fn platform_tag() -> &'static str {
|
|
|
273
446
|
|
|
274
447
|
fn node_resource_name() -> &'static str {
|
|
275
448
|
if cfg!(windows) {
|
|
276
|
-
"
|
|
449
|
+
"engine/node.exe"
|
|
277
450
|
} else {
|
|
278
451
|
"node/node"
|
|
279
452
|
}
|
|
280
453
|
}
|
|
281
454
|
|
|
282
|
-
fn node_compatible_path(path: &Path) -> PathBuf {
|
|
283
|
-
#[cfg(windows)]
|
|
284
|
-
{
|
|
285
|
-
use std::ffi::OsString;
|
|
286
|
-
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
|
287
|
-
|
|
288
|
-
let wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
|
|
289
|
-
let verbatim_unc = r"\\?\UNC\".encode_utf16().collect::<Vec<_>>();
|
|
290
|
-
if let Some(rest) = wide.strip_prefix(verbatim_unc.as_slice()) {
|
|
291
|
-
let mut normalized = r"\\".encode_utf16().collect::<Vec<_>>();
|
|
292
|
-
normalized.extend_from_slice(rest);
|
|
293
|
-
return PathBuf::from(OsString::from_wide(&normalized));
|
|
294
|
-
}
|
|
295
|
-
let verbatim = r"\\?\".encode_utf16().collect::<Vec<_>>();
|
|
296
|
-
if let Some(rest) = wide.strip_prefix(verbatim.as_slice()) {
|
|
297
|
-
return PathBuf::from(OsString::from_wide(rest));
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
path.to_path_buf()
|
|
301
|
-
}
|
|
302
|
-
|
|
303
455
|
fn spawn_group(command: &mut Command) -> std::io::Result<GroupChild> {
|
|
304
456
|
#[cfg(windows)]
|
|
305
457
|
{
|
|
@@ -310,7 +462,3 @@ fn spawn_group(command: &mut Command) -> std::io::Result<GroupChild> {
|
|
|
310
462
|
command.group_spawn()
|
|
311
463
|
}
|
|
312
464
|
}
|
|
313
|
-
|
|
314
|
-
#[cfg(test)]
|
|
315
|
-
#[path = "host_tests.rs"]
|
|
316
|
-
mod tests;
|
package/src/lib.rs
CHANGED
|
@@ -5,11 +5,13 @@ mod update;
|
|
|
5
5
|
use commands::{host_capture_screenshot, host_save_text_file, host_select_directory};
|
|
6
6
|
use host::HostProcess;
|
|
7
7
|
use std::path::PathBuf;
|
|
8
|
+
use std::thread;
|
|
8
9
|
use tauri::{
|
|
9
10
|
menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder},
|
|
10
11
|
Emitter, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
|
11
12
|
};
|
|
12
13
|
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
|
14
|
+
use update::{app_release_check, app_release_install, app_release_snapshot};
|
|
13
15
|
|
|
14
16
|
/// Product values required by the reusable desktop host.
|
|
15
17
|
pub struct DesktopConfig {
|
|
@@ -42,10 +44,15 @@ fn install_menu(app: &tauri::App) -> tauri::Result<()> {
|
|
|
42
44
|
let open_terminal = MenuItemBuilder::with_id("open-terminal", "打开终端")
|
|
43
45
|
.accelerator("CmdOrCtrl+T")
|
|
44
46
|
.build(app)?;
|
|
47
|
+
let quit = MenuItemBuilder::with_id("app-quit", "退出")
|
|
48
|
+
.accelerator("CmdOrCtrl+Q")
|
|
49
|
+
.build(app)?;
|
|
45
50
|
let file = SubmenuBuilder::new(app, "文件")
|
|
46
51
|
.item(&new_chat)
|
|
47
52
|
.separator()
|
|
48
53
|
.close_window()
|
|
54
|
+
.separator()
|
|
55
|
+
.item(&quit)
|
|
49
56
|
.build()?;
|
|
50
57
|
let edit = SubmenuBuilder::new(app, "编辑")
|
|
51
58
|
.undo()
|
|
@@ -72,6 +79,7 @@ fn install_menu(app: &tauri::App) -> tauri::Result<()> {
|
|
|
72
79
|
"open-terminal" => {
|
|
73
80
|
let _ = app.emit("menu:open-terminal", ());
|
|
74
81
|
}
|
|
82
|
+
"app-quit" => app.exit(0),
|
|
75
83
|
_ => {}
|
|
76
84
|
});
|
|
77
85
|
Ok(())
|
|
@@ -95,31 +103,51 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
|
|
|
95
103
|
.invoke_handler(tauri::generate_handler![
|
|
96
104
|
host_select_directory,
|
|
97
105
|
host_save_text_file,
|
|
98
|
-
host_capture_screenshot
|
|
106
|
+
host_capture_screenshot,
|
|
107
|
+
app_release_snapshot,
|
|
108
|
+
app_release_check,
|
|
109
|
+
app_release_install
|
|
99
110
|
])
|
|
100
111
|
.setup(move |app| {
|
|
101
112
|
install_menu(app)?;
|
|
102
|
-
let
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
.
|
|
113
|
+
let handle = app.handle().clone();
|
|
114
|
+
thread::spawn(move || {
|
|
115
|
+
let (host, url) = match HostProcess::spawn(&handle, &config) {
|
|
116
|
+
Ok(started) => started,
|
|
117
|
+
Err(error) => {
|
|
118
|
+
let exit_handle = handle.clone();
|
|
119
|
+
handle
|
|
120
|
+
.dialog()
|
|
121
|
+
.message(error)
|
|
122
|
+
.title(format!("{} 无法启动", config.product_name))
|
|
123
|
+
.kind(MessageDialogKind::Error)
|
|
124
|
+
.show(move |_| exit_handle.exit(1));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
handle.manage(host);
|
|
129
|
+
let allowed_origin = url.origin().ascii_serialization();
|
|
130
|
+
if let Err(error) =
|
|
131
|
+
WebviewWindowBuilder::new(&handle, "main", WebviewUrl::External(url))
|
|
132
|
+
.title(&config.product_name)
|
|
133
|
+
.inner_size(1200.0, 800.0)
|
|
134
|
+
.min_inner_size(900.0, 650.0)
|
|
135
|
+
.on_navigation(move |url| {
|
|
136
|
+
url.origin().ascii_serialization() == allowed_origin
|
|
137
|
+
})
|
|
138
|
+
.build()
|
|
139
|
+
{
|
|
140
|
+
let exit_handle = handle.clone();
|
|
141
|
+
handle
|
|
142
|
+
.dialog()
|
|
143
|
+
.message(error.to_string())
|
|
108
144
|
.title(format!("{} 无法启动", config.product_name))
|
|
109
145
|
.kind(MessageDialogKind::Error)
|
|
110
|
-
.show(move |_|
|
|
111
|
-
return
|
|
146
|
+
.show(move |_| exit_handle.exit(1));
|
|
147
|
+
return;
|
|
112
148
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
let allowed_origin = url.origin().ascii_serialization();
|
|
116
|
-
WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url))
|
|
117
|
-
.title(&config.product_name)
|
|
118
|
-
.inner_size(1200.0, 800.0)
|
|
119
|
-
.min_inner_size(900.0, 650.0)
|
|
120
|
-
.on_navigation(move |url| url.origin().ascii_serialization() == allowed_origin)
|
|
121
|
-
.build()?;
|
|
122
|
-
update::start(app.handle());
|
|
149
|
+
update::start(&handle);
|
|
150
|
+
});
|
|
123
151
|
Ok(())
|
|
124
152
|
});
|
|
125
153
|
|
package/src/update.rs
CHANGED
|
@@ -1,18 +1,64 @@
|
|
|
1
|
+
use serde::Serialize;
|
|
1
2
|
use std::sync::Mutex;
|
|
2
3
|
use std::time::Duration;
|
|
3
4
|
use tauri::{AppHandle, Emitter, Manager};
|
|
4
5
|
use tauri_plugin_updater::{Update, UpdaterExt};
|
|
5
6
|
|
|
6
7
|
const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
|
|
8
|
+
const EVENT: &str = "app-update-state";
|
|
9
|
+
|
|
10
|
+
/// 渲染层看到的版本与更新状态。`version` 是当前安装版本。
|
|
11
|
+
#[derive(Clone, Serialize)]
|
|
12
|
+
#[serde(rename_all = "camelCase")]
|
|
13
|
+
pub struct AppReleaseSnapshot {
|
|
14
|
+
version: String,
|
|
15
|
+
enabled: bool,
|
|
16
|
+
phase: String,
|
|
17
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
18
|
+
available_version: Option<String>,
|
|
19
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
20
|
+
percent: Option<u8>,
|
|
21
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
22
|
+
message: Option<String>,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
struct Tracked {
|
|
26
|
+
phase: String,
|
|
27
|
+
available_version: Option<String>,
|
|
28
|
+
percent: Option<u8>,
|
|
29
|
+
message: Option<String>,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
impl Tracked {
|
|
33
|
+
fn idle() -> Self {
|
|
34
|
+
Self {
|
|
35
|
+
phase: "idle".into(),
|
|
36
|
+
available_version: None,
|
|
37
|
+
percent: None,
|
|
38
|
+
message: None,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
7
42
|
|
|
8
43
|
struct PendingUpdate {
|
|
9
44
|
update: Update,
|
|
10
45
|
bytes: Vec<u8>,
|
|
11
46
|
}
|
|
12
47
|
|
|
13
|
-
#[derive(Default)]
|
|
14
48
|
pub struct UpdateState {
|
|
15
49
|
pending: Mutex<Option<PendingUpdate>>,
|
|
50
|
+
tracked: Mutex<Tracked>,
|
|
51
|
+
gate: tokio::sync::Mutex<()>,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
impl Default for UpdateState {
|
|
55
|
+
fn default() -> Self {
|
|
56
|
+
Self {
|
|
57
|
+
pending: Mutex::new(None),
|
|
58
|
+
tracked: Mutex::new(Tracked::idle()),
|
|
59
|
+
gate: tokio::sync::Mutex::new(()),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
16
62
|
}
|
|
17
63
|
|
|
18
64
|
impl UpdateState {
|
|
@@ -29,8 +75,107 @@ impl UpdateState {
|
|
|
29
75
|
}
|
|
30
76
|
}
|
|
31
77
|
|
|
78
|
+
/// 开发构建只检查、不下载、不替换正在跑的程序。
|
|
79
|
+
/// `DEEPPATH_APP_UPDATE=1` 强制打开安装;`=0` 连检查也关掉。
|
|
80
|
+
fn updates_enabled() -> bool {
|
|
81
|
+
std::env::var("DEEPPATH_APP_UPDATE").as_deref() != Ok("0")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn install_allowed() -> bool {
|
|
85
|
+
match std::env::var("DEEPPATH_APP_UPDATE").as_deref() {
|
|
86
|
+
Ok("0") => false,
|
|
87
|
+
Ok("1") => true,
|
|
88
|
+
_ => !cfg!(debug_assertions),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fn clip_message(message: String) -> String {
|
|
93
|
+
const MAX: usize = 180;
|
|
94
|
+
let mut chars = message.chars();
|
|
95
|
+
let clipped: String = chars.by_ref().take(MAX).collect();
|
|
96
|
+
if chars.next().is_some() {
|
|
97
|
+
format!("{clipped}…")
|
|
98
|
+
} else {
|
|
99
|
+
clipped
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fn next_download_percent(downloaded: u64, total: u64, previous: u8) -> Option<u8> {
|
|
104
|
+
if total == 0 {
|
|
105
|
+
return None;
|
|
106
|
+
}
|
|
107
|
+
let percent = ((downloaded.saturating_mul(100)) / total).min(100) as u8;
|
|
108
|
+
if percent != previous && (percent == 100 || percent >= previous.saturating_add(5)) {
|
|
109
|
+
Some(percent)
|
|
110
|
+
} else {
|
|
111
|
+
None
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn snapshot(app: &AppHandle) -> AppReleaseSnapshot {
|
|
116
|
+
let enabled = updates_enabled();
|
|
117
|
+
let state = app.state::<UpdateState>();
|
|
118
|
+
let tracked = state.tracked.lock().ok();
|
|
119
|
+
let (phase, available_version, percent, message) = if let Some(tracked) = tracked.as_ref() {
|
|
120
|
+
(
|
|
121
|
+
if enabled {
|
|
122
|
+
tracked.phase.clone()
|
|
123
|
+
} else {
|
|
124
|
+
"disabled".into()
|
|
125
|
+
},
|
|
126
|
+
if enabled {
|
|
127
|
+
tracked.available_version.clone()
|
|
128
|
+
} else {
|
|
129
|
+
None
|
|
130
|
+
},
|
|
131
|
+
if enabled { tracked.percent } else { None },
|
|
132
|
+
if enabled {
|
|
133
|
+
tracked.message.clone()
|
|
134
|
+
} else {
|
|
135
|
+
None
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
} else if enabled {
|
|
139
|
+
("idle".into(), None, None, None)
|
|
140
|
+
} else {
|
|
141
|
+
("disabled".into(), None, None, None)
|
|
142
|
+
};
|
|
143
|
+
AppReleaseSnapshot {
|
|
144
|
+
version: app.package_info().version.to_string(),
|
|
145
|
+
enabled,
|
|
146
|
+
phase,
|
|
147
|
+
available_version,
|
|
148
|
+
percent,
|
|
149
|
+
message,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
fn publish(
|
|
154
|
+
app: &AppHandle,
|
|
155
|
+
phase: &str,
|
|
156
|
+
available_version: Option<String>,
|
|
157
|
+
percent: Option<u8>,
|
|
158
|
+
message: Option<String>,
|
|
159
|
+
) {
|
|
160
|
+
if let Ok(mut tracked) = app.state::<UpdateState>().tracked.lock() {
|
|
161
|
+
tracked.phase = phase.to_string();
|
|
162
|
+
tracked.available_version = available_version;
|
|
163
|
+
tracked.percent = percent;
|
|
164
|
+
tracked.message = message;
|
|
165
|
+
}
|
|
166
|
+
let _ = app.emit(EVENT, snapshot(app));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fn has_pending(app: &AppHandle) -> bool {
|
|
170
|
+
app.state::<UpdateState>()
|
|
171
|
+
.pending
|
|
172
|
+
.lock()
|
|
173
|
+
.map(|pending| pending.is_some())
|
|
174
|
+
.unwrap_or(true)
|
|
175
|
+
}
|
|
176
|
+
|
|
32
177
|
pub fn start(app: &AppHandle) {
|
|
33
|
-
if
|
|
178
|
+
if !install_allowed() {
|
|
34
179
|
return;
|
|
35
180
|
}
|
|
36
181
|
let app = app.clone();
|
|
@@ -42,58 +187,95 @@ pub fn start(app: &AppHandle) {
|
|
|
42
187
|
});
|
|
43
188
|
}
|
|
44
189
|
|
|
45
|
-
async fn check_once(app: &AppHandle) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
{
|
|
190
|
+
pub async fn check_once(app: &AppHandle) {
|
|
191
|
+
let state = app.state::<UpdateState>();
|
|
192
|
+
let _gate = state.gate.lock().await;
|
|
193
|
+
if !updates_enabled() {
|
|
194
|
+
publish(app, "disabled", None, None, None);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if has_pending(app) {
|
|
53
198
|
return;
|
|
54
199
|
}
|
|
55
|
-
|
|
56
|
-
"app-update-state",
|
|
57
|
-
serde_json::json!({ "phase": "checking" }),
|
|
58
|
-
);
|
|
200
|
+
publish(app, "checking", None, None, None);
|
|
59
201
|
let updater = match app.updater() {
|
|
60
202
|
Ok(updater) => updater,
|
|
61
203
|
Err(error) => {
|
|
62
204
|
eprintln!("[updater] initialization failed: {error}");
|
|
205
|
+
publish(
|
|
206
|
+
app,
|
|
207
|
+
"error",
|
|
208
|
+
None,
|
|
209
|
+
None,
|
|
210
|
+
Some(clip_message(error.to_string())),
|
|
211
|
+
);
|
|
63
212
|
return;
|
|
64
213
|
}
|
|
65
214
|
};
|
|
66
215
|
let update = match updater.check().await {
|
|
67
216
|
Ok(Some(update)) => update,
|
|
68
217
|
Ok(None) => {
|
|
69
|
-
|
|
218
|
+
publish(app, "idle", None, None, Some("已是最新".into()));
|
|
70
219
|
return;
|
|
71
220
|
}
|
|
72
221
|
Err(error) => {
|
|
73
222
|
eprintln!("[updater] check failed: {error}");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
223
|
+
publish(
|
|
224
|
+
app,
|
|
225
|
+
"error",
|
|
226
|
+
None,
|
|
227
|
+
None,
|
|
228
|
+
Some(clip_message(error.to_string())),
|
|
77
229
|
);
|
|
78
230
|
return;
|
|
79
231
|
}
|
|
80
232
|
};
|
|
81
233
|
let version = update.version.clone();
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
234
|
+
if !install_allowed() {
|
|
235
|
+
publish(
|
|
236
|
+
app,
|
|
237
|
+
"idle",
|
|
238
|
+
Some(version.clone()),
|
|
239
|
+
None,
|
|
240
|
+
Some("开发构建不安装更新".into()),
|
|
241
|
+
);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
publish(app, "downloading", Some(version.clone()), Some(0), None);
|
|
245
|
+
let mut downloaded: u64 = 0;
|
|
246
|
+
let mut last_percent: u8 = 0;
|
|
247
|
+
let bytes = match update
|
|
248
|
+
.download(
|
|
249
|
+
|chunk, total| {
|
|
250
|
+
downloaded = downloaded.saturating_add(chunk as u64);
|
|
251
|
+
let Some(total) = total else {
|
|
252
|
+
return;
|
|
253
|
+
};
|
|
254
|
+
let Some(percent) = next_download_percent(downloaded, total, last_percent) else {
|
|
255
|
+
return;
|
|
256
|
+
};
|
|
257
|
+
last_percent = percent;
|
|
258
|
+
publish(
|
|
259
|
+
app,
|
|
260
|
+
"downloading",
|
|
261
|
+
Some(version.clone()),
|
|
262
|
+
Some(percent),
|
|
263
|
+
None,
|
|
264
|
+
);
|
|
265
|
+
},
|
|
266
|
+
|| {},
|
|
267
|
+
)
|
|
268
|
+
.await
|
|
269
|
+
{
|
|
87
270
|
Ok(bytes) => bytes,
|
|
88
271
|
Err(error) => {
|
|
89
272
|
eprintln!("[updater] download failed: {error}");
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}),
|
|
273
|
+
publish(
|
|
274
|
+
app,
|
|
275
|
+
"error",
|
|
276
|
+
Some(version),
|
|
277
|
+
None,
|
|
278
|
+
Some(clip_message(error.to_string())),
|
|
97
279
|
);
|
|
98
280
|
return;
|
|
99
281
|
}
|
|
@@ -101,8 +283,87 @@ async fn check_once(app: &AppHandle) {
|
|
|
101
283
|
if let Ok(mut pending) = app.state::<UpdateState>().pending.lock() {
|
|
102
284
|
*pending = Some(PendingUpdate { update, bytes });
|
|
103
285
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
286
|
+
publish(app, "ready", Some(version), None, None);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#[tauri::command]
|
|
290
|
+
pub fn app_release_snapshot(app: AppHandle) -> AppReleaseSnapshot {
|
|
291
|
+
snapshot(&app)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#[tauri::command]
|
|
295
|
+
pub async fn app_release_check(app: AppHandle) -> AppReleaseSnapshot {
|
|
296
|
+
check_once(&app).await;
|
|
297
|
+
snapshot(&app)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[tauri::command]
|
|
301
|
+
pub async fn app_release_install(app: AppHandle) -> AppReleaseSnapshot {
|
|
302
|
+
if !install_allowed() {
|
|
303
|
+
publish(&app, "error", None, None, Some("开发构建不安装更新".into()));
|
|
304
|
+
return snapshot(&app);
|
|
305
|
+
}
|
|
306
|
+
let pending = app
|
|
307
|
+
.state::<UpdateState>()
|
|
308
|
+
.pending
|
|
309
|
+
.lock()
|
|
310
|
+
.ok()
|
|
311
|
+
.and_then(|mut pending| pending.take());
|
|
312
|
+
let Some(pending) = pending else {
|
|
313
|
+
return snapshot(&app);
|
|
314
|
+
};
|
|
315
|
+
let version = pending.update.version.clone();
|
|
316
|
+
publish(&app, "installing", Some(version.clone()), None, None);
|
|
317
|
+
let joined =
|
|
318
|
+
tauri::async_runtime::spawn_blocking(move || pending.update.install(pending.bytes)).await;
|
|
319
|
+
match joined {
|
|
320
|
+
Ok(Ok(())) => {
|
|
321
|
+
app.request_restart();
|
|
322
|
+
}
|
|
323
|
+
Ok(Err(error)) => {
|
|
324
|
+
eprintln!("[updater] install failed: {error}");
|
|
325
|
+
publish(
|
|
326
|
+
&app,
|
|
327
|
+
"error",
|
|
328
|
+
Some(version),
|
|
329
|
+
None,
|
|
330
|
+
Some(clip_message(error.to_string())),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
Err(error) => {
|
|
334
|
+
publish(
|
|
335
|
+
&app,
|
|
336
|
+
"error",
|
|
337
|
+
Some(version),
|
|
338
|
+
None,
|
|
339
|
+
Some(clip_message(error.to_string())),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
snapshot(&app)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#[cfg(test)]
|
|
347
|
+
mod tests {
|
|
348
|
+
use super::{clip_message, next_download_percent};
|
|
349
|
+
|
|
350
|
+
#[test]
|
|
351
|
+
fn reports_download_percent_in_steps() {
|
|
352
|
+
assert_eq!(next_download_percent(0, 0, 0), None);
|
|
353
|
+
assert_eq!(next_download_percent(4, 100, 0), None);
|
|
354
|
+
assert_eq!(next_download_percent(5, 100, 0), Some(5));
|
|
355
|
+
assert_eq!(next_download_percent(9, 100, 5), None);
|
|
356
|
+
assert_eq!(next_download_percent(96, 100, 90), Some(96));
|
|
357
|
+
assert_eq!(next_download_percent(100, 100, 96), Some(100));
|
|
358
|
+
assert_eq!(next_download_percent(100, 100, 100), None);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#[test]
|
|
362
|
+
fn clips_long_updater_errors() {
|
|
363
|
+
assert_eq!(clip_message("短".into()), "短");
|
|
364
|
+
let long = "错".repeat(200);
|
|
365
|
+
let clipped = clip_message(long);
|
|
366
|
+
assert!(clipped.ends_with('…'));
|
|
367
|
+
assert_eq!(clipped.chars().count(), 181);
|
|
368
|
+
}
|
|
108
369
|
}
|
package/src/host_tests.rs
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
use super::node_compatible_path;
|
|
2
|
-
use std::path::Path;
|
|
3
|
-
|
|
4
|
-
#[cfg(not(windows))]
|
|
5
|
-
#[test]
|
|
6
|
-
fn packaged_path_is_unchanged_off_windows() {
|
|
7
|
-
let path = Path::new("/opt/Aroli/node-host");
|
|
8
|
-
assert_eq!(node_compatible_path(path), path);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
#[cfg(windows)]
|
|
12
|
-
#[test]
|
|
13
|
-
fn strips_windows_verbatim_disk_prefix() {
|
|
14
|
-
assert_eq!(
|
|
15
|
-
node_compatible_path(Path::new(r"\\?\C:\Aroli\node-host")),
|
|
16
|
-
Path::new(r"C:\Aroli\node-host")
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
#[cfg(windows)]
|
|
21
|
-
#[test]
|
|
22
|
-
fn converts_windows_verbatim_unc_prefix() {
|
|
23
|
-
assert_eq!(
|
|
24
|
-
node_compatible_path(Path::new(r"\\?\UNC\server\share\node-host")),
|
|
25
|
-
Path::new(r"\\server\share\node-host")
|
|
26
|
-
);
|
|
27
|
-
}
|