@steerable/agent-shell-tauri 0.6.47 → 0.6.49

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.
@@ -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
+ }