@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-tauri",
3
- "version": "0.6.46",
3
+ "version": "0.6.48",
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
@@ -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
- "node/node.exe"
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 (host, url) = match HostProcess::spawn(app.handle(), &config) {
103
- Ok(started) => started,
104
- Err(error) => {
105
- let handle = app.handle().clone();
106
- app.dialog()
107
- .message(error)
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 |_| handle.exit(1));
111
- return Ok(());
146
+ .show(move |_| exit_handle.exit(1));
147
+ return;
112
148
  }
113
- };
114
- app.manage(host);
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 cfg!(debug_assertions) || std::env::var("DEEPPATH_APP_UPDATE").as_deref() == Ok("0") {
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
- if app
47
- .state::<UpdateState>()
48
- .pending
49
- .lock()
50
- .map(|pending| pending.is_some())
51
- .unwrap_or(true)
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
- let _ = app.emit(
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
- let _ = app.emit("app-update-state", serde_json::json!({ "phase": "idle" }));
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
- let _ = app.emit(
75
- "app-update-state",
76
- serde_json::json!({ "phase": "error", "message": error.to_string() }),
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
- let _ = app.emit(
83
- "app-update-state",
84
- serde_json::json!({ "phase": "downloading", "version": version }),
85
- );
86
- let bytes = match update.download(|_chunk, _total| {}, || {}).await {
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
- let _ = app.emit(
91
- "app-update-state",
92
- serde_json::json!({
93
- "phase": "error",
94
- "version": version,
95
- "message": error.to_string()
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
- let _ = app.emit(
105
- "app-update-state",
106
- serde_json::json!({ "phase": "ready", "version": version }),
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
- }