@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 +3 -1
- package/README.md +11 -7
- package/package.json +1 -1
- package/src/commands.rs +8 -0
- package/src/host.rs +93 -82
- package/src/lib.rs +28 -3
- package/src/python_runner.rs +788 -0
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
|
|
17
|
-
`
|
|
18
|
-
`engine/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
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,
|
|
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(
|
|
262
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
319
|
+
runner
|
|
326
320
|
.is_file()
|
|
327
|
-
.then_some(
|
|
328
|
-
.ok_or_else(|| "bundled Python runner is missing".to_string())
|
|
329
|
-
}
|
|
330
|
-
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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::{
|
|
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
|
+
}
|