@steerable/agent-shell-tauri 0.6.48 → 0.6.50

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
@@ -17,10 +17,12 @@ command-group = "5.0.1"
17
17
  image = { version = "0.25", default-features = false, features = ["png"] }
18
18
  serde = { version = "1", features = ["derive"] }
19
19
  serde_json = "1"
20
+ sha2 = "0.10"
21
+ hex = "0.4"
20
22
  tauri = { version = "2.11.6", features = [] }
21
23
  tauri-plugin-dialog = "2.7.3"
22
24
  tauri-plugin-process = "2.3.1"
23
25
  tauri-plugin-single-instance = "2.4.5"
24
26
  tauri-plugin-updater = "2.12.0"
25
- tokio = { version = "1", features = ["time"] }
27
+ tokio = { version = "1", features = ["io-util", "macros", "process", "sync", "time"] }
26
28
  url = "2"
package/README.md CHANGED
@@ -13,10 +13,14 @@ resource paths, and updates.
13
13
  The crate is source-distributed with the npm package and is not published to
14
14
  crates.io.
15
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`.
16
+ Packaged products place the complete portable Python sidecar at
17
+ `python-runtime/<platform>` and the verified egress proxy at
18
+ `engine/steerable-egress-proxy`. The Python runtime contains the native Rust
19
+ CoreLoop engine. An optional `engine/python-runner` is only a child interpreter
20
+ for `run_code`; it never hosts the sidecar. Products may set `pythonRunner` to `bundle` or `download`
21
+ in `product.json`. Download mode starts the product without an interpreter and
22
+ lets the settings UI install one in the background. The default
23
+ python-build-standalone archive is checked against the packaged SHA-256; users
24
+ may instead choose an unverified custom archive URL or an existing Python 3.9+
25
+ executable. Download progress is emitted to the renderer, and the selected
26
+ interpreter is passed as `STEERABLE_PYTHON` on the next application start.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell-tauri",
3
- "version": "0.6.48",
3
+ "version": "0.6.50",
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/commands.rs CHANGED
@@ -108,6 +108,14 @@ pub async fn host_save_text_file(
108
108
  })
109
109
  }
110
110
 
111
+ #[tauri::command]
112
+ 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()
117
+ }
118
+
111
119
  #[tauri::command]
112
120
  pub async fn host_capture_screenshot(
113
121
  image: ScreenshotImage,
package/src/host.rs CHANGED
@@ -1,3 +1,4 @@
1
+ use crate::python_runner::{self, PythonRunnerLock, RunnerSetup};
1
2
  use crate::DesktopConfig;
2
3
  use command_group::{CommandGroup, GroupChild};
3
4
  #[cfg(unix)]
@@ -12,7 +13,7 @@ use std::sync::Mutex;
12
13
  use std::thread;
13
14
  use std::time::{Duration, Instant};
14
15
  use tauri::{AppHandle, Manager};
15
- use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
16
+ use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
16
17
  use url::Url;
17
18
 
18
19
  const READY_PREFIX: &str = "STEERABLE_HOST_READY ";
@@ -24,22 +25,6 @@ struct ReadyRecord {
24
25
  port: u16,
25
26
  }
26
27
 
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
28
  #[derive(Deserialize)]
44
29
  #[serde(rename_all = "camelCase")]
45
30
  struct ProductRuntime {
@@ -254,26 +239,23 @@ struct HostPaths {
254
239
  app_root: PathBuf,
255
240
  web_dist: PathBuf,
256
241
  engine_dir: Option<PathBuf>,
242
+ sidecar_python: Option<PathBuf>,
257
243
  }
258
244
 
259
245
  impl HostPaths {
260
246
  fn apply_runtime_env(&self, command: &mut Command, python_runner: Option<PathBuf>) {
261
- let Some(engine_dir) = &self.engine_dir else {
262
- return;
263
- };
264
- let rust_sidecar = engine_dir.join(platform_binary("steerable-sidecar"));
265
- if rust_sidecar.exists() {
266
- command
267
- .env("STEERABLE_RUST_SIDECAR", "1")
268
- .env("STEERABLE_RUST_SIDECAR_BIN", rust_sidecar);
269
- }
270
- let egress_proxy = engine_dir.join(platform_binary("steerable-egress-proxy"));
271
- if egress_proxy.exists() {
272
- command.env("STEERABLE_EGRESS_PROXY_BIN", egress_proxy);
247
+ if let Some(sidecar_python) = &self.sidecar_python {
248
+ command.env("STEERABLE_SIDECAR_PYTHON", sidecar_python);
273
249
  }
274
- let win_spawn_helper = engine_dir.join("win-spawn-helper/win-spawn-helper.exe");
275
- if win_spawn_helper.exists() {
276
- command.env("DEEPPATH_WIN_SPAWN_HELPER", win_spawn_helper);
250
+ if let Some(engine_dir) = &self.engine_dir {
251
+ let egress_proxy = engine_dir.join(platform_binary("steerable-egress-proxy"));
252
+ if egress_proxy.exists() {
253
+ command.env("STEERABLE_EGRESS_PROXY_BIN", egress_proxy);
254
+ }
255
+ let win_spawn_helper = engine_dir.join("win-spawn-helper/win-spawn-helper.exe");
256
+ if win_spawn_helper.exists() {
257
+ command.env("DEEPPATH_WIN_SPAWN_HELPER", win_spawn_helper);
258
+ }
277
259
  }
278
260
  if let Some(python_runner) = python_runner {
279
261
  command.env("STEERABLE_PYTHON", python_runner);
@@ -289,6 +271,18 @@ impl HostPaths {
289
271
  if let Some(explicit) = env::var_os("STEERABLE_PYTHON") {
290
272
  let explicit = PathBuf::from(explicit);
291
273
  if explicit.is_absolute() && explicit.is_file() {
274
+ python_runner::configure(
275
+ app,
276
+ RunnerSetup {
277
+ user_data: user_data.to_path_buf(),
278
+ node: self.node.clone(),
279
+ engine_dir: self.engine_dir.clone(),
280
+ lock: None,
281
+ target_name: platform_tag().into(),
282
+ supported: false,
283
+ active_runner: Some(explicit.clone()),
284
+ },
285
+ );
292
286
  return Ok(Some(explicit));
293
287
  }
294
288
  return Err("STEERABLE_PYTHON must name an existing absolute file".into());
@@ -320,62 +314,35 @@ impl HostPaths {
320
314
  .get(target_name)
321
315
  .ok_or_else(|| format!("Python runner does not support {target_name}"))?;
322
316
 
323
- if python_runner_mode == "bundle" {
317
+ let active_runner = if python_runner_mode == "bundle" {
324
318
  let runner = engine_dir.join("python-runner").join(&target.runner);
325
- return runner
319
+ runner
326
320
  .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" {
321
+ .then_some(runner)
322
+ .ok_or_else(|| "bundled Python runner is missing".to_string())?
323
+ } else if python_runner_mode == "download" {
324
+ python_runner::configured_runner(user_data)
325
+ .or_else(|| python_runner::default_runner(user_data, &lock, target_name, target))
326
+ .unwrap_or_default()
327
+ } else {
331
328
  return Err(format!(
332
329
  "unsupported pythonRunner mode {python_runner_mode:?}"
333
330
  ));
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
331
+ };
332
+ let active_runner = (!active_runner.as_os_str().is_empty()).then_some(active_runner);
333
+ python_runner::configure(
334
+ app,
335
+ RunnerSetup {
336
+ user_data: user_data.to_path_buf(),
337
+ node: self.node.clone(),
338
+ engine_dir: self.engine_dir.clone(),
339
+ lock: Some(lock),
340
+ target_name: target_name.into(),
341
+ supported: python_runner_mode == "download",
342
+ active_runner: active_runner.clone(),
343
+ },
366
344
  );
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))
345
+ Ok(active_runner)
379
346
  }
380
347
 
381
348
  fn resolve(app: &AppHandle, config: &DesktopConfig) -> Result<Self, String> {
@@ -398,6 +365,7 @@ impl HostPaths {
398
365
  }),
399
366
  app_root,
400
367
  engine_dir: None,
368
+ sidecar_python: env::var_os("STEERABLE_SIDECAR_PYTHON").map(PathBuf::from),
401
369
  });
402
370
  }
403
371
 
@@ -405,8 +373,29 @@ impl HostPaths {
405
373
  .path()
406
374
  .resource_dir()
407
375
  .map_err(|error| error.to_string())?;
376
+ let resource_dir = node_compatible_path(&resource_dir);
408
377
  let host_root = resource_dir.join("node-host");
409
378
  let app_root = host_root.join("app-dist");
379
+ let python_name = if cfg!(windows) {
380
+ "python.exe"
381
+ } else {
382
+ "python3"
383
+ };
384
+ let python_root = resource_dir.join("python-runtime").join(platform_tag());
385
+ let sidecar_python = [
386
+ python_root.join("python").join(python_name),
387
+ python_root.join("python/bin").join(python_name),
388
+ python_root.join(python_name),
389
+ python_root.join("bin").join(python_name),
390
+ ]
391
+ .into_iter()
392
+ .find(|candidate| candidate.is_file())
393
+ .ok_or_else(|| {
394
+ format!(
395
+ "packaged Python sidecar runtime is missing under {}",
396
+ python_root.display()
397
+ )
398
+ })?;
410
399
  Ok(Self {
411
400
  node: env::var_os("DEEPPATH_TAURI_NODE")
412
401
  .map(PathBuf::from)
@@ -418,6 +407,7 @@ impl HostPaths {
418
407
  web_dist: host_root.join("web-dist"),
419
408
  app_root,
420
409
  engine_dir: Some(resource_dir.join("engine")),
410
+ sidecar_python: Some(sidecar_python),
421
411
  })
422
412
  }
423
413
  }
@@ -452,6 +442,27 @@ fn node_resource_name() -> &'static str {
452
442
  }
453
443
  }
454
444
 
445
+ fn node_compatible_path(path: &Path) -> PathBuf {
446
+ #[cfg(windows)]
447
+ {
448
+ use std::ffi::OsString;
449
+ use std::os::windows::ffi::{OsStrExt, OsStringExt};
450
+
451
+ let wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
452
+ let verbatim_unc = r"\\?\UNC\".encode_utf16().collect::<Vec<_>>();
453
+ if let Some(rest) = wide.strip_prefix(verbatim_unc.as_slice()) {
454
+ let mut normalized = r"\\".encode_utf16().collect::<Vec<_>>();
455
+ normalized.extend_from_slice(rest);
456
+ return PathBuf::from(OsString::from_wide(&normalized));
457
+ }
458
+ let verbatim = r"\\?\".encode_utf16().collect::<Vec<_>>();
459
+ if let Some(rest) = wide.strip_prefix(verbatim.as_slice()) {
460
+ return PathBuf::from(OsString::from_wide(rest));
461
+ }
462
+ }
463
+ path.to_path_buf()
464
+ }
465
+
455
466
  fn spawn_group(command: &mut Command) -> std::io::Result<GroupChild> {
456
467
  #[cfg(windows)]
457
468
  {
package/src/lib.rs CHANGED
@@ -1,9 +1,16 @@
1
1
  mod commands;
2
2
  mod host;
3
+ mod python_runner;
3
4
  mod update;
4
5
 
5
- use commands::{host_capture_screenshot, host_save_text_file, host_select_directory};
6
+ use commands::{
7
+ host_capture_screenshot, host_read_clipboard_text, host_save_text_file, host_select_directory,
8
+ };
6
9
  use host::HostProcess;
10
+ use python_runner::{
11
+ python_runner_cancel, python_runner_download, python_runner_pick_local, python_runner_restart,
12
+ python_runner_snapshot, python_runner_use_default, python_runner_use_local,
13
+ };
7
14
  use std::path::PathBuf;
8
15
  use std::thread;
9
16
  use tauri::{
@@ -54,13 +61,18 @@ fn install_menu(app: &tauri::App) -> tauri::Result<()> {
54
61
  .separator()
55
62
  .item(&quit)
56
63
  .build()?;
64
+ // Predefined Paste delivers an empty clipboard into WKWebView. Own the
65
+ // shortcut and read the pasteboard from the host process instead.
66
+ let paste = MenuItemBuilder::with_id("edit-paste", "粘贴")
67
+ .accelerator("CmdOrCtrl+V")
68
+ .build(app)?;
57
69
  let edit = SubmenuBuilder::new(app, "编辑")
58
70
  .undo()
59
71
  .redo()
60
72
  .separator()
61
73
  .cut()
62
74
  .copy()
63
- .paste()
75
+ .item(&paste)
64
76
  .select_all()
65
77
  .build()?;
66
78
  let view = SubmenuBuilder::new(app, "视图")
@@ -79,6 +91,9 @@ fn install_menu(app: &tauri::App) -> tauri::Result<()> {
79
91
  "open-terminal" => {
80
92
  let _ = app.emit("menu:open-terminal", ());
81
93
  }
94
+ "edit-paste" => {
95
+ let _ = app.emit("menu:paste", ());
96
+ }
82
97
  "app-quit" => app.exit(0),
83
98
  _ => {}
84
99
  });
@@ -90,6 +105,7 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
90
105
  let product_name = config.product_name.clone();
91
106
  let builder = tauri::Builder::default()
92
107
  .manage(update::UpdateState::default())
108
+ .manage(python_runner::PythonRunnerState::default())
93
109
  .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
94
110
  if let Some(window) = app.get_webview_window("main") {
95
111
  let _ = window.show();
@@ -104,9 +120,17 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
104
120
  host_select_directory,
105
121
  host_save_text_file,
106
122
  host_capture_screenshot,
123
+ host_read_clipboard_text,
107
124
  app_release_snapshot,
108
125
  app_release_check,
109
- app_release_install
126
+ app_release_install,
127
+ python_runner_snapshot,
128
+ python_runner_download,
129
+ python_runner_cancel,
130
+ python_runner_pick_local,
131
+ python_runner_use_local,
132
+ python_runner_use_default,
133
+ python_runner_restart
110
134
  ])
111
135
  .setup(move |app| {
112
136
  install_menu(app)?;
@@ -146,6 +170,7 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
146
170
  .show(move |_| exit_handle.exit(1));
147
171
  return;
148
172
  }
173
+ python_runner::maybe_prompt(&handle);
149
174
  update::start(&handle);
150
175
  });
151
176
  Ok(())
@@ -0,0 +1,788 @@
1
+ use serde::{Deserialize, Serialize};
2
+ use sha2::{Digest, Sha256};
3
+ use std::collections::HashMap;
4
+ use std::path::{Path, PathBuf};
5
+ use std::process::Stdio;
6
+ use std::sync::Mutex;
7
+ use tauri::{AppHandle, Emitter, Manager};
8
+ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
9
+ use tokio::io::{AsyncBufReadExt, BufReader};
10
+ use tokio::process::Command;
11
+
12
+ const EVENT: &str = "python-runner-state";
13
+
14
+ #[derive(Clone, Deserialize)]
15
+ #[serde(rename_all = "camelCase")]
16
+ pub(crate) struct PythonRunnerLock {
17
+ pub(crate) version: String,
18
+ pub(crate) python_version: String,
19
+ pub(crate) python_build_standalone_release: String,
20
+ pub(crate) targets: HashMap<String, PythonRunnerTarget>,
21
+ }
22
+
23
+ #[derive(Clone, Deserialize)]
24
+ pub(crate) struct PythonRunnerTarget {
25
+ pub(crate) triple: String,
26
+ pub(crate) sha256: String,
27
+ pub(crate) runner: String,
28
+ }
29
+
30
+ #[derive(Clone, Deserialize, Serialize)]
31
+ #[serde(rename_all = "camelCase")]
32
+ struct RunnerConfig {
33
+ source: String,
34
+ #[serde(skip_serializing_if = "Option::is_none")]
35
+ url: Option<String>,
36
+ #[serde(skip_serializing_if = "Option::is_none")]
37
+ path: Option<PathBuf>,
38
+ runner: PathBuf,
39
+ }
40
+
41
+ #[derive(Clone)]
42
+ struct RunnerContext {
43
+ user_data: PathBuf,
44
+ node: PathBuf,
45
+ engine_dir: PathBuf,
46
+ lock: PythonRunnerLock,
47
+ target_name: String,
48
+ target: PythonRunnerTarget,
49
+ supported: bool,
50
+ }
51
+
52
+ #[derive(Clone, Serialize)]
53
+ #[serde(rename_all = "camelCase")]
54
+ pub struct PythonRunnerSnapshot {
55
+ supported: bool,
56
+ source: String,
57
+ phase: String,
58
+ #[serde(skip_serializing_if = "Option::is_none")]
59
+ percent: Option<u8>,
60
+ #[serde(skip_serializing_if = "Option::is_none")]
61
+ downloaded_bytes: Option<u64>,
62
+ #[serde(skip_serializing_if = "Option::is_none")]
63
+ total_bytes: Option<u64>,
64
+ #[serde(skip_serializing_if = "Option::is_none")]
65
+ message: Option<String>,
66
+ #[serde(skip_serializing_if = "Option::is_none")]
67
+ default_url: Option<String>,
68
+ #[serde(skip_serializing_if = "Option::is_none")]
69
+ configured_url: Option<String>,
70
+ #[serde(skip_serializing_if = "Option::is_none")]
71
+ active_runner: Option<String>,
72
+ #[serde(skip_serializing_if = "Option::is_none")]
73
+ configured_runner: Option<String>,
74
+ restart_required: bool,
75
+ }
76
+
77
+ struct Tracked {
78
+ source: String,
79
+ phase: String,
80
+ percent: Option<u8>,
81
+ downloaded_bytes: Option<u64>,
82
+ total_bytes: Option<u64>,
83
+ message: Option<String>,
84
+ configured_url: Option<String>,
85
+ active_runner: Option<PathBuf>,
86
+ configured_runner: Option<PathBuf>,
87
+ }
88
+
89
+ impl Default for Tracked {
90
+ fn default() -> Self {
91
+ Self {
92
+ source: "default".into(),
93
+ phase: "idle".into(),
94
+ percent: None,
95
+ downloaded_bytes: None,
96
+ total_bytes: None,
97
+ message: None,
98
+ configured_url: None,
99
+ active_runner: None,
100
+ configured_runner: None,
101
+ }
102
+ }
103
+ }
104
+
105
+ pub struct PythonRunnerState {
106
+ context: Mutex<Option<RunnerContext>>,
107
+ tracked: Mutex<Tracked>,
108
+ cancel: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
109
+ gate: tokio::sync::Mutex<()>,
110
+ }
111
+
112
+ pub(crate) struct RunnerSetup {
113
+ pub(crate) user_data: PathBuf,
114
+ pub(crate) node: PathBuf,
115
+ pub(crate) engine_dir: Option<PathBuf>,
116
+ pub(crate) lock: Option<PythonRunnerLock>,
117
+ pub(crate) target_name: String,
118
+ pub(crate) supported: bool,
119
+ pub(crate) active_runner: Option<PathBuf>,
120
+ }
121
+
122
+ impl Default for PythonRunnerState {
123
+ fn default() -> Self {
124
+ Self {
125
+ context: Mutex::new(None),
126
+ tracked: Mutex::new(Tracked::default()),
127
+ cancel: Mutex::new(None),
128
+ gate: tokio::sync::Mutex::new(()),
129
+ }
130
+ }
131
+ }
132
+
133
+ fn config_path(user_data: &Path) -> PathBuf {
134
+ user_data.join("python-runner/config.json")
135
+ }
136
+
137
+ fn read_config(user_data: &Path) -> Option<RunnerConfig> {
138
+ let bytes = std::fs::read(config_path(user_data)).ok()?;
139
+ serde_json::from_slice(&bytes).ok()
140
+ }
141
+
142
+ fn write_config(user_data: &Path, config: &RunnerConfig) -> Result<(), String> {
143
+ let path = config_path(user_data);
144
+ let parent = path
145
+ .parent()
146
+ .ok_or_else(|| "Python runner config has no parent directory".to_string())?;
147
+ std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
148
+ let temporary = path.with_extension("json.tmp");
149
+ let bytes = serde_json::to_vec_pretty(config).map_err(|error| error.to_string())?;
150
+ std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?;
151
+ std::fs::rename(&temporary, &path).map_err(|error| error.to_string())
152
+ }
153
+
154
+ fn default_url(context: &RunnerContext) -> String {
155
+ let filename = format!(
156
+ "cpython-{}+{}-{}-install_only_stripped.tar.gz",
157
+ context.lock.python_version,
158
+ context.lock.python_build_standalone_release,
159
+ context.target.triple
160
+ );
161
+ format!(
162
+ "https://github.com/astral-sh/python-build-standalone/releases/download/{}/{}",
163
+ context.lock.python_build_standalone_release, filename
164
+ )
165
+ }
166
+
167
+ fn default_destination(context: &RunnerContext) -> PathBuf {
168
+ context
169
+ .user_data
170
+ .join("python-runner")
171
+ .join(&context.lock.version)
172
+ .join(&context.target_name)
173
+ }
174
+
175
+ fn custom_destination(context: &RunnerContext, url: &str) -> PathBuf {
176
+ let digest = Sha256::digest(url.as_bytes());
177
+ context.user_data.join("python-runner").join(format!(
178
+ "custom-{}",
179
+ hex::encode(digest).chars().take(12).collect::<String>()
180
+ ))
181
+ }
182
+
183
+ fn runner_string(path: &Path) -> String {
184
+ path.to_string_lossy().into_owned()
185
+ }
186
+
187
+ fn snapshot(app: &AppHandle) -> PythonRunnerSnapshot {
188
+ let state = app.state::<PythonRunnerState>();
189
+ let context = state.context.lock().ok().and_then(|value| value.clone());
190
+ let tracked = state.tracked.lock().ok();
191
+ let (source, phase, percent, downloaded, total, message, configured_url, active, configured) =
192
+ if let Some(tracked) = tracked.as_ref() {
193
+ (
194
+ tracked.source.clone(),
195
+ tracked.phase.clone(),
196
+ tracked.percent,
197
+ tracked.downloaded_bytes,
198
+ tracked.total_bytes,
199
+ tracked.message.clone(),
200
+ tracked.configured_url.clone(),
201
+ tracked.active_runner.clone(),
202
+ tracked.configured_runner.clone(),
203
+ )
204
+ } else {
205
+ (
206
+ "default".into(),
207
+ "error".into(),
208
+ None,
209
+ None,
210
+ None,
211
+ Some("Python runner state is unavailable".into()),
212
+ None,
213
+ None,
214
+ None,
215
+ )
216
+ };
217
+ PythonRunnerSnapshot {
218
+ supported: context.as_ref().is_some_and(|value| value.supported),
219
+ source,
220
+ phase,
221
+ percent,
222
+ downloaded_bytes: downloaded,
223
+ total_bytes: total,
224
+ message,
225
+ default_url: context.as_ref().map(default_url),
226
+ configured_url,
227
+ active_runner: active.as_deref().map(runner_string),
228
+ configured_runner: configured.as_deref().map(runner_string),
229
+ restart_required: active != configured,
230
+ }
231
+ }
232
+
233
+ fn publish(app: &AppHandle) {
234
+ let _ = app.emit(EVENT, snapshot(app));
235
+ }
236
+
237
+ pub(crate) fn configure(app: &AppHandle, setup: RunnerSetup) {
238
+ let state = app.state::<PythonRunnerState>();
239
+ let config = read_config(&setup.user_data);
240
+ let valid_config = config.filter(|value| value.runner.is_absolute() && value.runner.is_file());
241
+ if let (Some(engine_dir), Some(lock)) = (setup.engine_dir, setup.lock) {
242
+ if let Some(target) = lock.targets.get(&setup.target_name).cloned() {
243
+ if let Ok(mut context) = state.context.lock() {
244
+ *context = Some(RunnerContext {
245
+ user_data: setup.user_data,
246
+ node: setup.node,
247
+ engine_dir,
248
+ lock,
249
+ target_name: setup.target_name,
250
+ target,
251
+ supported: setup.supported,
252
+ });
253
+ }
254
+ }
255
+ }
256
+ if let Ok(mut tracked) = state.tracked.lock() {
257
+ tracked.source = valid_config
258
+ .as_ref()
259
+ .map(|value| value.source.clone())
260
+ .unwrap_or_else(|| "default".into());
261
+ tracked.configured_url = valid_config.as_ref().and_then(|value| value.url.clone());
262
+ tracked.configured_runner = valid_config
263
+ .as_ref()
264
+ .map(|value| value.runner.clone())
265
+ .or_else(|| setup.active_runner.clone());
266
+ tracked.active_runner = setup.active_runner;
267
+ };
268
+ }
269
+
270
+ pub(crate) fn configured_runner(user_data: &Path) -> Option<PathBuf> {
271
+ let config = read_config(user_data)?;
272
+ (config.runner.is_absolute() && config.runner.is_file()).then_some(config.runner)
273
+ }
274
+
275
+ pub(crate) fn default_runner(
276
+ user_data: &Path,
277
+ lock: &PythonRunnerLock,
278
+ target_name: &str,
279
+ target: &PythonRunnerTarget,
280
+ ) -> Option<PathBuf> {
281
+ let runner = user_data
282
+ .join("python-runner")
283
+ .join(&lock.version)
284
+ .join(target_name)
285
+ .join(&target.runner);
286
+ runner.is_file().then_some(runner)
287
+ }
288
+
289
+ fn set_phase(
290
+ app: &AppHandle,
291
+ phase: &str,
292
+ percent: Option<u8>,
293
+ downloaded: Option<u64>,
294
+ total: Option<u64>,
295
+ message: Option<String>,
296
+ ) {
297
+ if let Ok(mut tracked) = app.state::<PythonRunnerState>().tracked.lock() {
298
+ tracked.phase = phase.into();
299
+ tracked.percent = percent;
300
+ tracked.downloaded_bytes = downloaded;
301
+ tracked.total_bytes = total;
302
+ tracked.message = message;
303
+ }
304
+ publish(app);
305
+ }
306
+
307
+ fn clip_message(message: impl Into<String>) -> String {
308
+ let message = message.into();
309
+ let mut chars = message.chars();
310
+ let clipped = chars.by_ref().take(180).collect::<String>();
311
+ if chars.next().is_some() {
312
+ format!("{clipped}…")
313
+ } else {
314
+ clipped
315
+ }
316
+ }
317
+
318
+ fn next_download_percent(downloaded: u64, total: Option<u64>, previous: u8) -> Option<u8> {
319
+ let total = total.filter(|value| *value > 0)?;
320
+ let percent = ((downloaded.saturating_mul(100) / total).min(100)) as u8;
321
+ (percent == 100 || percent >= previous.saturating_add(5)).then_some(percent)
322
+ }
323
+
324
+ async fn install(app: AppHandle, custom_url: Option<String>) -> PythonRunnerSnapshot {
325
+ let state = app.state::<PythonRunnerState>();
326
+ let _gate = state.gate.lock().await;
327
+ let context = state.context.lock().ok().and_then(|value| value.clone());
328
+ let Some(context) = context.filter(|value| value.supported) else {
329
+ set_phase(
330
+ &app,
331
+ "error",
332
+ None,
333
+ None,
334
+ None,
335
+ Some("当前产品不支持下载 Python 运行器".into()),
336
+ );
337
+ return snapshot(&app);
338
+ };
339
+ let custom_url = custom_url
340
+ .map(|value| value.trim().to_owned())
341
+ .filter(|value| !value.is_empty());
342
+ let url = custom_url.clone().unwrap_or_else(|| default_url(&context));
343
+ if !url.starts_with("https://") && !url.starts_with("http://") {
344
+ set_phase(
345
+ &app,
346
+ "error",
347
+ None,
348
+ None,
349
+ None,
350
+ Some("下载地址必须以 http:// 或 https:// 开头".into()),
351
+ );
352
+ return snapshot(&app);
353
+ }
354
+ let is_custom = custom_url.is_some();
355
+ let destination = if is_custom {
356
+ custom_destination(&context, &url)
357
+ } else {
358
+ default_destination(&context)
359
+ };
360
+ let runner = destination.join(&context.target.runner);
361
+ set_phase(&app, "downloading", Some(0), Some(0), None, None);
362
+ let installer = context.engine_dir.join("install-python-runner.mjs");
363
+ let mut command = Command::new(&context.node);
364
+ command
365
+ .arg(installer)
366
+ .args(["--url", &url, "--destination"])
367
+ .arg(&destination)
368
+ .args(["--runner", &context.target.runner, "--progress", "true"])
369
+ .stdout(Stdio::piped())
370
+ .stderr(Stdio::piped());
371
+ #[cfg(unix)]
372
+ {
373
+ use std::os::unix::process::CommandExt;
374
+ command.as_std_mut().process_group(0);
375
+ }
376
+ if !is_custom {
377
+ command.args(["--sha256", &context.target.sha256]);
378
+ }
379
+ let mut child = match command.spawn() {
380
+ Ok(child) => child,
381
+ Err(error) => {
382
+ set_phase(
383
+ &app,
384
+ "error",
385
+ None,
386
+ None,
387
+ None,
388
+ Some(clip_message(error.to_string())),
389
+ );
390
+ return snapshot(&app);
391
+ }
392
+ };
393
+ let stdout = child.stdout.take();
394
+ let stderr = child.stderr.take();
395
+ let (cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel();
396
+ if let Ok(mut cancel) = state.cancel.lock() {
397
+ *cancel = Some(cancel_tx);
398
+ }
399
+ let mut lines = stdout.map(|value| BufReader::new(value).lines());
400
+ let mut downloaded = 0;
401
+ let mut total = None;
402
+ let mut last_percent: u8 = 0;
403
+ let status = loop {
404
+ tokio::select! {
405
+ _ = &mut cancel_rx => {
406
+ terminate_download(&mut child).await;
407
+ break None;
408
+ }
409
+ result = child.wait() => break result.ok(),
410
+ line = async {
411
+ match lines.as_mut() {
412
+ Some(lines) => lines.next_line().await,
413
+ None => Ok(None),
414
+ }
415
+ }, if lines.is_some() => {
416
+ match line {
417
+ Ok(Some(line)) => {
418
+ let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
419
+ continue;
420
+ };
421
+ match value.get("type").and_then(|item| item.as_str()) {
422
+ Some("progress") => {
423
+ downloaded = value.get("downloaded").and_then(|item| item.as_u64()).unwrap_or(downloaded);
424
+ total = value.get("total").and_then(|item| item.as_u64()).or(total);
425
+ let percent = next_download_percent(downloaded, total, last_percent);
426
+ if total.is_none() || percent.is_some() {
427
+ if let Some(percent) = percent {
428
+ last_percent = percent;
429
+ }
430
+ set_phase(&app, "downloading", percent, Some(downloaded), total, None);
431
+ }
432
+ }
433
+ Some("phase") => {
434
+ let phase = value.get("phase").and_then(|item| item.as_str()).unwrap_or("downloading");
435
+ set_phase(&app, phase, None, Some(downloaded), total, None);
436
+ }
437
+ _ => {}
438
+ }
439
+ }
440
+ Ok(None) => lines = None,
441
+ Err(error) => {
442
+ eprintln!("[python-runner] progress stream failed: {error}");
443
+ lines = None;
444
+ }
445
+ }
446
+ }
447
+ }
448
+ };
449
+ if let Ok(mut cancel) = state.cancel.lock() {
450
+ *cancel = None;
451
+ }
452
+ let success = status.is_some_and(|value| value.success()) && runner.is_file();
453
+ if !success {
454
+ let message = if status.is_none() {
455
+ "下载已取消".into()
456
+ } else {
457
+ let stderr = match stderr {
458
+ Some(stderr) => {
459
+ let mut reader = BufReader::new(stderr);
460
+ let mut value = String::new();
461
+ let _ = tokio::io::AsyncReadExt::read_to_string(&mut reader, &mut value).await;
462
+ value
463
+ }
464
+ None => String::new(),
465
+ };
466
+ clip_message(if stderr.trim().is_empty() {
467
+ format!("Python runner installer exited with {status:?}")
468
+ } else {
469
+ stderr
470
+ })
471
+ };
472
+ set_phase(
473
+ &app,
474
+ if status.is_none() { "idle" } else { "error" },
475
+ None,
476
+ None,
477
+ None,
478
+ Some(message),
479
+ );
480
+ return snapshot(&app);
481
+ }
482
+ let config = RunnerConfig {
483
+ source: if is_custom {
484
+ "url".into()
485
+ } else {
486
+ "default".into()
487
+ },
488
+ url: is_custom.then_some(url),
489
+ path: None,
490
+ runner: runner.clone(),
491
+ };
492
+ if let Err(error) = write_config(&context.user_data, &config) {
493
+ set_phase(&app, "error", None, None, None, Some(clip_message(error)));
494
+ return snapshot(&app);
495
+ }
496
+ let restart_required = if let Ok(mut tracked) = state.tracked.lock() {
497
+ let restart_required = tracked.active_runner.as_ref() != Some(&runner);
498
+ tracked.source = config.source;
499
+ tracked.configured_url = config.url;
500
+ tracked.configured_runner = Some(runner);
501
+ restart_required
502
+ } else {
503
+ true
504
+ };
505
+ set_phase(
506
+ &app,
507
+ "ready",
508
+ None,
509
+ Some(downloaded),
510
+ total,
511
+ Some(if restart_required {
512
+ "安装完成,重启应用后即可使用 run_code。".into()
513
+ } else {
514
+ "Python 运行器已就绪。".into()
515
+ }),
516
+ );
517
+ snapshot(&app)
518
+ }
519
+
520
+ async fn terminate_download(child: &mut tokio::process::Child) {
521
+ if let Some(pid) = child.id() {
522
+ #[cfg(unix)]
523
+ {
524
+ let _ = Command::new("kill")
525
+ .args(["-TERM", &format!("-{pid}")])
526
+ .status()
527
+ .await;
528
+ }
529
+ #[cfg(windows)]
530
+ {
531
+ let _ = Command::new("taskkill")
532
+ .args(["/PID", &pid.to_string(), "/T", "/F"])
533
+ .status()
534
+ .await;
535
+ }
536
+ }
537
+ let _ = child.kill().await;
538
+ }
539
+
540
+ #[tauri::command]
541
+ pub fn python_runner_snapshot(app: AppHandle) -> PythonRunnerSnapshot {
542
+ snapshot(&app)
543
+ }
544
+
545
+ #[tauri::command]
546
+ pub async fn python_runner_download(app: AppHandle, url: Option<String>) -> PythonRunnerSnapshot {
547
+ install(app, url).await
548
+ }
549
+
550
+ #[tauri::command]
551
+ pub fn python_runner_cancel(app: AppHandle) -> PythonRunnerSnapshot {
552
+ if let Ok(mut cancel) = app.state::<PythonRunnerState>().cancel.lock() {
553
+ if let Some(cancel) = cancel.take() {
554
+ let _ = cancel.send(());
555
+ }
556
+ }
557
+ snapshot(&app)
558
+ }
559
+
560
+ #[tauri::command]
561
+ pub fn python_runner_pick_local(app: AppHandle) -> Option<String> {
562
+ app.dialog()
563
+ .file()
564
+ .set_title("选择 Python 可执行文件")
565
+ .blocking_pick_file()
566
+ .and_then(|value| value.into_path().ok())
567
+ .map(|value| runner_string(&value))
568
+ }
569
+
570
+ async fn validate_local(path: &Path) -> Result<(), String> {
571
+ if !path.is_absolute() || !path.is_file() {
572
+ return Err("Python path must be an existing absolute file".into());
573
+ }
574
+ let output = Command::new(path)
575
+ .args([
576
+ "-c",
577
+ "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')",
578
+ ])
579
+ .output()
580
+ .await
581
+ .map_err(|error| error.to_string())?;
582
+ if !output.status.success() {
583
+ return Err("Selected Python could not be started".into());
584
+ }
585
+ let version = String::from_utf8_lossy(&output.stdout);
586
+ let mut parts = version.trim().split('.');
587
+ let major = parts.next().and_then(|value| value.parse::<u8>().ok());
588
+ let minor = parts.next().and_then(|value| value.parse::<u8>().ok());
589
+ if !matches!((major, minor), (Some(3), Some(9..=u8::MAX))) {
590
+ return Err(format!(
591
+ "Python 3.9 or newer is required; found {}",
592
+ version.trim()
593
+ ));
594
+ }
595
+ Ok(())
596
+ }
597
+
598
+ #[tauri::command]
599
+ pub async fn python_runner_use_local(app: AppHandle, path: String) -> PythonRunnerSnapshot {
600
+ let path = PathBuf::from(path);
601
+ if let Err(error) = validate_local(&path).await {
602
+ set_phase(&app, "error", None, None, None, Some(clip_message(error)));
603
+ return snapshot(&app);
604
+ }
605
+ let context = app
606
+ .state::<PythonRunnerState>()
607
+ .context
608
+ .lock()
609
+ .ok()
610
+ .and_then(|value| value.clone());
611
+ let Some(context) = context else {
612
+ set_phase(
613
+ &app,
614
+ "error",
615
+ None,
616
+ None,
617
+ None,
618
+ Some("Python 运行器不可用".into()),
619
+ );
620
+ return snapshot(&app);
621
+ };
622
+ let config = RunnerConfig {
623
+ source: "local".into(),
624
+ url: None,
625
+ path: Some(path.clone()),
626
+ runner: path.clone(),
627
+ };
628
+ if let Err(error) = write_config(&context.user_data, &config) {
629
+ set_phase(&app, "error", None, None, None, Some(clip_message(error)));
630
+ return snapshot(&app);
631
+ }
632
+ let restart_required = if let Ok(mut tracked) = app.state::<PythonRunnerState>().tracked.lock()
633
+ {
634
+ let restart_required = tracked.active_runner.as_ref() != Some(&path);
635
+ tracked.source = "local".into();
636
+ tracked.configured_url = None;
637
+ tracked.configured_runner = Some(path);
638
+ restart_required
639
+ } else {
640
+ true
641
+ };
642
+ set_phase(
643
+ &app,
644
+ "ready",
645
+ None,
646
+ None,
647
+ None,
648
+ Some(if restart_required {
649
+ "配置已保存,重启应用后即可使用 run_code。".into()
650
+ } else {
651
+ "当前会话已在使用这个 Python 运行器。".into()
652
+ }),
653
+ );
654
+ snapshot(&app)
655
+ }
656
+
657
+ #[tauri::command]
658
+ pub fn python_runner_use_default(app: AppHandle) -> PythonRunnerSnapshot {
659
+ let state = app.state::<PythonRunnerState>();
660
+ let context = state.context.lock().ok().and_then(|value| value.clone());
661
+ let Some(context) = context else {
662
+ return snapshot(&app);
663
+ };
664
+ let runner = default_destination(&context).join(&context.target.runner);
665
+ if runner.is_file() {
666
+ let config = RunnerConfig {
667
+ source: "default".into(),
668
+ url: None,
669
+ path: None,
670
+ runner: runner.clone(),
671
+ };
672
+ if write_config(&context.user_data, &config).is_ok() {
673
+ if let Ok(mut tracked) = state.tracked.lock() {
674
+ let restart_required = tracked.active_runner.as_ref() != Some(&runner);
675
+ tracked.source = "default".into();
676
+ tracked.configured_url = None;
677
+ tracked.configured_runner = Some(runner);
678
+ tracked.phase = "ready".into();
679
+ tracked.message = Some(if restart_required {
680
+ "配置已保存,重启应用后即可使用 run_code。".into()
681
+ } else {
682
+ "当前会话已在使用默认 Python 运行器。".into()
683
+ });
684
+ }
685
+ }
686
+ }
687
+ publish(&app);
688
+ snapshot(&app)
689
+ }
690
+
691
+ #[tauri::command]
692
+ pub fn python_runner_restart(app: AppHandle) {
693
+ app.request_restart();
694
+ }
695
+
696
+ pub fn maybe_prompt(app: &AppHandle) {
697
+ let current = snapshot(app);
698
+ if !current.supported
699
+ || current.configured_runner.is_some()
700
+ || std::env::var_os("CI").is_some()
701
+ || std::env::var("DEEPPATH_PYTHON_RUNNER").ok().as_deref() == Some("skip")
702
+ {
703
+ return;
704
+ }
705
+ let app_for_download = app.clone();
706
+ app.dialog()
707
+ .message(
708
+ "Aroli 可以在后台下载独立的 Python 运行器来启用 run_code。\n\n\
709
+ 下载期间不影响聊天和其他功能,安装完成后重启应用即可使用。",
710
+ )
711
+ .title("安装 Python 代码运行器")
712
+ .buttons(MessageDialogButtons::OkCancelCustom(
713
+ "后台下载".to_string(),
714
+ "稍后".to_string(),
715
+ ))
716
+ .show(move |confirmed| {
717
+ if confirmed {
718
+ tauri::async_runtime::spawn(async move {
719
+ install(app_for_download, None).await;
720
+ });
721
+ }
722
+ });
723
+ }
724
+
725
+ #[cfg(test)]
726
+ mod tests {
727
+ use super::*;
728
+
729
+ #[test]
730
+ fn custom_download_directories_are_stable_and_distinct() {
731
+ let context = RunnerContext {
732
+ user_data: PathBuf::from("/tmp/data"),
733
+ node: PathBuf::new(),
734
+ engine_dir: PathBuf::new(),
735
+ lock: PythonRunnerLock {
736
+ version: "1".into(),
737
+ python_version: "3.12".into(),
738
+ python_build_standalone_release: "20260901".into(),
739
+ targets: HashMap::new(),
740
+ },
741
+ target_name: "test".into(),
742
+ target: PythonRunnerTarget {
743
+ triple: "test".into(),
744
+ sha256: String::new(),
745
+ runner: "python/bin/python3".into(),
746
+ },
747
+ supported: true,
748
+ };
749
+ assert_eq!(
750
+ custom_destination(&context, "https://example.com/a"),
751
+ custom_destination(&context, "https://example.com/a")
752
+ );
753
+ assert_ne!(
754
+ custom_destination(&context, "https://example.com/a"),
755
+ custom_destination(&context, "https://example.com/b")
756
+ );
757
+ }
758
+
759
+ #[test]
760
+ fn reports_download_percent_in_five_percent_steps() {
761
+ assert_eq!(next_download_percent(4, Some(100), 0), None);
762
+ assert_eq!(next_download_percent(5, Some(100), 0), Some(5));
763
+ assert_eq!(next_download_percent(9, Some(100), 5), None);
764
+ assert_eq!(next_download_percent(100, Some(100), 96), Some(100));
765
+ assert_eq!(next_download_percent(10, None, 0), None);
766
+ }
767
+
768
+ #[test]
769
+ fn configured_runner_requires_an_existing_file() {
770
+ let root = std::env::temp_dir().join(format!(
771
+ "steerable-python-runner-test-{}",
772
+ std::process::id()
773
+ ));
774
+ let runner = root.join("python3");
775
+ std::fs::create_dir_all(&root).unwrap();
776
+ let config = RunnerConfig {
777
+ source: "local".into(),
778
+ url: None,
779
+ path: Some(runner.clone()),
780
+ runner: runner.clone(),
781
+ };
782
+ write_config(&root, &config).unwrap();
783
+ assert_eq!(configured_runner(&root), None);
784
+ std::fs::write(&runner, b"python").unwrap();
785
+ assert_eq!(configured_runner(&root), Some(runner));
786
+ std::fs::remove_dir_all(root).unwrap();
787
+ }
788
+ }