@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.
- package/Cargo.toml +3 -1
- package/README.md +12 -0
- package/package.json +1 -1
- package/src/commands.rs +8 -0
- package/src/host.rs +203 -44
- package/src/lib.rs +74 -21
- package/src/python_runner.rs +788 -0
- package/src/update.rs +295 -34
- package/src/host_tests.rs +0 -27
|
@@ -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
|
+
}
|