@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
package/src/update.rs
CHANGED
|
@@ -1,18 +1,64 @@
|
|
|
1
|
+
use serde::Serialize;
|
|
1
2
|
use std::sync::Mutex;
|
|
2
3
|
use std::time::Duration;
|
|
3
4
|
use tauri::{AppHandle, Emitter, Manager};
|
|
4
5
|
use tauri_plugin_updater::{Update, UpdaterExt};
|
|
5
6
|
|
|
6
7
|
const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
|
|
8
|
+
const EVENT: &str = "app-update-state";
|
|
9
|
+
|
|
10
|
+
/// 渲染层看到的版本与更新状态。`version` 是当前安装版本。
|
|
11
|
+
#[derive(Clone, Serialize)]
|
|
12
|
+
#[serde(rename_all = "camelCase")]
|
|
13
|
+
pub struct AppReleaseSnapshot {
|
|
14
|
+
version: String,
|
|
15
|
+
enabled: bool,
|
|
16
|
+
phase: String,
|
|
17
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
18
|
+
available_version: Option<String>,
|
|
19
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
20
|
+
percent: Option<u8>,
|
|
21
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
22
|
+
message: Option<String>,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
struct Tracked {
|
|
26
|
+
phase: String,
|
|
27
|
+
available_version: Option<String>,
|
|
28
|
+
percent: Option<u8>,
|
|
29
|
+
message: Option<String>,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
impl Tracked {
|
|
33
|
+
fn idle() -> Self {
|
|
34
|
+
Self {
|
|
35
|
+
phase: "idle".into(),
|
|
36
|
+
available_version: None,
|
|
37
|
+
percent: None,
|
|
38
|
+
message: None,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
7
42
|
|
|
8
43
|
struct PendingUpdate {
|
|
9
44
|
update: Update,
|
|
10
45
|
bytes: Vec<u8>,
|
|
11
46
|
}
|
|
12
47
|
|
|
13
|
-
#[derive(Default)]
|
|
14
48
|
pub struct UpdateState {
|
|
15
49
|
pending: Mutex<Option<PendingUpdate>>,
|
|
50
|
+
tracked: Mutex<Tracked>,
|
|
51
|
+
gate: tokio::sync::Mutex<()>,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
impl Default for UpdateState {
|
|
55
|
+
fn default() -> Self {
|
|
56
|
+
Self {
|
|
57
|
+
pending: Mutex::new(None),
|
|
58
|
+
tracked: Mutex::new(Tracked::idle()),
|
|
59
|
+
gate: tokio::sync::Mutex::new(()),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
16
62
|
}
|
|
17
63
|
|
|
18
64
|
impl UpdateState {
|
|
@@ -29,8 +75,107 @@ impl UpdateState {
|
|
|
29
75
|
}
|
|
30
76
|
}
|
|
31
77
|
|
|
78
|
+
/// 开发构建只检查、不下载、不替换正在跑的程序。
|
|
79
|
+
/// `DEEPPATH_APP_UPDATE=1` 强制打开安装;`=0` 连检查也关掉。
|
|
80
|
+
fn updates_enabled() -> bool {
|
|
81
|
+
std::env::var("DEEPPATH_APP_UPDATE").as_deref() != Ok("0")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn install_allowed() -> bool {
|
|
85
|
+
match std::env::var("DEEPPATH_APP_UPDATE").as_deref() {
|
|
86
|
+
Ok("0") => false,
|
|
87
|
+
Ok("1") => true,
|
|
88
|
+
_ => !cfg!(debug_assertions),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fn clip_message(message: String) -> String {
|
|
93
|
+
const MAX: usize = 180;
|
|
94
|
+
let mut chars = message.chars();
|
|
95
|
+
let clipped: String = chars.by_ref().take(MAX).collect();
|
|
96
|
+
if chars.next().is_some() {
|
|
97
|
+
format!("{clipped}…")
|
|
98
|
+
} else {
|
|
99
|
+
clipped
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fn next_download_percent(downloaded: u64, total: u64, previous: u8) -> Option<u8> {
|
|
104
|
+
if total == 0 {
|
|
105
|
+
return None;
|
|
106
|
+
}
|
|
107
|
+
let percent = ((downloaded.saturating_mul(100)) / total).min(100) as u8;
|
|
108
|
+
if percent != previous && (percent == 100 || percent >= previous.saturating_add(5)) {
|
|
109
|
+
Some(percent)
|
|
110
|
+
} else {
|
|
111
|
+
None
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn snapshot(app: &AppHandle) -> AppReleaseSnapshot {
|
|
116
|
+
let enabled = updates_enabled();
|
|
117
|
+
let state = app.state::<UpdateState>();
|
|
118
|
+
let tracked = state.tracked.lock().ok();
|
|
119
|
+
let (phase, available_version, percent, message) = if let Some(tracked) = tracked.as_ref() {
|
|
120
|
+
(
|
|
121
|
+
if enabled {
|
|
122
|
+
tracked.phase.clone()
|
|
123
|
+
} else {
|
|
124
|
+
"disabled".into()
|
|
125
|
+
},
|
|
126
|
+
if enabled {
|
|
127
|
+
tracked.available_version.clone()
|
|
128
|
+
} else {
|
|
129
|
+
None
|
|
130
|
+
},
|
|
131
|
+
if enabled { tracked.percent } else { None },
|
|
132
|
+
if enabled {
|
|
133
|
+
tracked.message.clone()
|
|
134
|
+
} else {
|
|
135
|
+
None
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
} else if enabled {
|
|
139
|
+
("idle".into(), None, None, None)
|
|
140
|
+
} else {
|
|
141
|
+
("disabled".into(), None, None, None)
|
|
142
|
+
};
|
|
143
|
+
AppReleaseSnapshot {
|
|
144
|
+
version: app.package_info().version.to_string(),
|
|
145
|
+
enabled,
|
|
146
|
+
phase,
|
|
147
|
+
available_version,
|
|
148
|
+
percent,
|
|
149
|
+
message,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
fn publish(
|
|
154
|
+
app: &AppHandle,
|
|
155
|
+
phase: &str,
|
|
156
|
+
available_version: Option<String>,
|
|
157
|
+
percent: Option<u8>,
|
|
158
|
+
message: Option<String>,
|
|
159
|
+
) {
|
|
160
|
+
if let Ok(mut tracked) = app.state::<UpdateState>().tracked.lock() {
|
|
161
|
+
tracked.phase = phase.to_string();
|
|
162
|
+
tracked.available_version = available_version;
|
|
163
|
+
tracked.percent = percent;
|
|
164
|
+
tracked.message = message;
|
|
165
|
+
}
|
|
166
|
+
let _ = app.emit(EVENT, snapshot(app));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fn has_pending(app: &AppHandle) -> bool {
|
|
170
|
+
app.state::<UpdateState>()
|
|
171
|
+
.pending
|
|
172
|
+
.lock()
|
|
173
|
+
.map(|pending| pending.is_some())
|
|
174
|
+
.unwrap_or(true)
|
|
175
|
+
}
|
|
176
|
+
|
|
32
177
|
pub fn start(app: &AppHandle) {
|
|
33
|
-
if
|
|
178
|
+
if !install_allowed() {
|
|
34
179
|
return;
|
|
35
180
|
}
|
|
36
181
|
let app = app.clone();
|
|
@@ -42,58 +187,95 @@ pub fn start(app: &AppHandle) {
|
|
|
42
187
|
});
|
|
43
188
|
}
|
|
44
189
|
|
|
45
|
-
async fn check_once(app: &AppHandle) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
{
|
|
190
|
+
pub async fn check_once(app: &AppHandle) {
|
|
191
|
+
let state = app.state::<UpdateState>();
|
|
192
|
+
let _gate = state.gate.lock().await;
|
|
193
|
+
if !updates_enabled() {
|
|
194
|
+
publish(app, "disabled", None, None, None);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if has_pending(app) {
|
|
53
198
|
return;
|
|
54
199
|
}
|
|
55
|
-
|
|
56
|
-
"app-update-state",
|
|
57
|
-
serde_json::json!({ "phase": "checking" }),
|
|
58
|
-
);
|
|
200
|
+
publish(app, "checking", None, None, None);
|
|
59
201
|
let updater = match app.updater() {
|
|
60
202
|
Ok(updater) => updater,
|
|
61
203
|
Err(error) => {
|
|
62
204
|
eprintln!("[updater] initialization failed: {error}");
|
|
205
|
+
publish(
|
|
206
|
+
app,
|
|
207
|
+
"error",
|
|
208
|
+
None,
|
|
209
|
+
None,
|
|
210
|
+
Some(clip_message(error.to_string())),
|
|
211
|
+
);
|
|
63
212
|
return;
|
|
64
213
|
}
|
|
65
214
|
};
|
|
66
215
|
let update = match updater.check().await {
|
|
67
216
|
Ok(Some(update)) => update,
|
|
68
217
|
Ok(None) => {
|
|
69
|
-
|
|
218
|
+
publish(app, "idle", None, None, Some("已是最新".into()));
|
|
70
219
|
return;
|
|
71
220
|
}
|
|
72
221
|
Err(error) => {
|
|
73
222
|
eprintln!("[updater] check failed: {error}");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
223
|
+
publish(
|
|
224
|
+
app,
|
|
225
|
+
"error",
|
|
226
|
+
None,
|
|
227
|
+
None,
|
|
228
|
+
Some(clip_message(error.to_string())),
|
|
77
229
|
);
|
|
78
230
|
return;
|
|
79
231
|
}
|
|
80
232
|
};
|
|
81
233
|
let version = update.version.clone();
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
234
|
+
if !install_allowed() {
|
|
235
|
+
publish(
|
|
236
|
+
app,
|
|
237
|
+
"idle",
|
|
238
|
+
Some(version.clone()),
|
|
239
|
+
None,
|
|
240
|
+
Some("开发构建不安装更新".into()),
|
|
241
|
+
);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
publish(app, "downloading", Some(version.clone()), Some(0), None);
|
|
245
|
+
let mut downloaded: u64 = 0;
|
|
246
|
+
let mut last_percent: u8 = 0;
|
|
247
|
+
let bytes = match update
|
|
248
|
+
.download(
|
|
249
|
+
|chunk, total| {
|
|
250
|
+
downloaded = downloaded.saturating_add(chunk as u64);
|
|
251
|
+
let Some(total) = total else {
|
|
252
|
+
return;
|
|
253
|
+
};
|
|
254
|
+
let Some(percent) = next_download_percent(downloaded, total, last_percent) else {
|
|
255
|
+
return;
|
|
256
|
+
};
|
|
257
|
+
last_percent = percent;
|
|
258
|
+
publish(
|
|
259
|
+
app,
|
|
260
|
+
"downloading",
|
|
261
|
+
Some(version.clone()),
|
|
262
|
+
Some(percent),
|
|
263
|
+
None,
|
|
264
|
+
);
|
|
265
|
+
},
|
|
266
|
+
|| {},
|
|
267
|
+
)
|
|
268
|
+
.await
|
|
269
|
+
{
|
|
87
270
|
Ok(bytes) => bytes,
|
|
88
271
|
Err(error) => {
|
|
89
272
|
eprintln!("[updater] download failed: {error}");
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}),
|
|
273
|
+
publish(
|
|
274
|
+
app,
|
|
275
|
+
"error",
|
|
276
|
+
Some(version),
|
|
277
|
+
None,
|
|
278
|
+
Some(clip_message(error.to_string())),
|
|
97
279
|
);
|
|
98
280
|
return;
|
|
99
281
|
}
|
|
@@ -101,8 +283,87 @@ async fn check_once(app: &AppHandle) {
|
|
|
101
283
|
if let Ok(mut pending) = app.state::<UpdateState>().pending.lock() {
|
|
102
284
|
*pending = Some(PendingUpdate { update, bytes });
|
|
103
285
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
286
|
+
publish(app, "ready", Some(version), None, None);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#[tauri::command]
|
|
290
|
+
pub fn app_release_snapshot(app: AppHandle) -> AppReleaseSnapshot {
|
|
291
|
+
snapshot(&app)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#[tauri::command]
|
|
295
|
+
pub async fn app_release_check(app: AppHandle) -> AppReleaseSnapshot {
|
|
296
|
+
check_once(&app).await;
|
|
297
|
+
snapshot(&app)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[tauri::command]
|
|
301
|
+
pub async fn app_release_install(app: AppHandle) -> AppReleaseSnapshot {
|
|
302
|
+
if !install_allowed() {
|
|
303
|
+
publish(&app, "error", None, None, Some("开发构建不安装更新".into()));
|
|
304
|
+
return snapshot(&app);
|
|
305
|
+
}
|
|
306
|
+
let pending = app
|
|
307
|
+
.state::<UpdateState>()
|
|
308
|
+
.pending
|
|
309
|
+
.lock()
|
|
310
|
+
.ok()
|
|
311
|
+
.and_then(|mut pending| pending.take());
|
|
312
|
+
let Some(pending) = pending else {
|
|
313
|
+
return snapshot(&app);
|
|
314
|
+
};
|
|
315
|
+
let version = pending.update.version.clone();
|
|
316
|
+
publish(&app, "installing", Some(version.clone()), None, None);
|
|
317
|
+
let joined =
|
|
318
|
+
tauri::async_runtime::spawn_blocking(move || pending.update.install(pending.bytes)).await;
|
|
319
|
+
match joined {
|
|
320
|
+
Ok(Ok(())) => {
|
|
321
|
+
app.request_restart();
|
|
322
|
+
}
|
|
323
|
+
Ok(Err(error)) => {
|
|
324
|
+
eprintln!("[updater] install failed: {error}");
|
|
325
|
+
publish(
|
|
326
|
+
&app,
|
|
327
|
+
"error",
|
|
328
|
+
Some(version),
|
|
329
|
+
None,
|
|
330
|
+
Some(clip_message(error.to_string())),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
Err(error) => {
|
|
334
|
+
publish(
|
|
335
|
+
&app,
|
|
336
|
+
"error",
|
|
337
|
+
Some(version),
|
|
338
|
+
None,
|
|
339
|
+
Some(clip_message(error.to_string())),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
snapshot(&app)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#[cfg(test)]
|
|
347
|
+
mod tests {
|
|
348
|
+
use super::{clip_message, next_download_percent};
|
|
349
|
+
|
|
350
|
+
#[test]
|
|
351
|
+
fn reports_download_percent_in_steps() {
|
|
352
|
+
assert_eq!(next_download_percent(0, 0, 0), None);
|
|
353
|
+
assert_eq!(next_download_percent(4, 100, 0), None);
|
|
354
|
+
assert_eq!(next_download_percent(5, 100, 0), Some(5));
|
|
355
|
+
assert_eq!(next_download_percent(9, 100, 5), None);
|
|
356
|
+
assert_eq!(next_download_percent(96, 100, 90), Some(96));
|
|
357
|
+
assert_eq!(next_download_percent(100, 100, 96), Some(100));
|
|
358
|
+
assert_eq!(next_download_percent(100, 100, 100), None);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#[test]
|
|
362
|
+
fn clips_long_updater_errors() {
|
|
363
|
+
assert_eq!(clip_message("短".into()), "短");
|
|
364
|
+
let long = "错".repeat(200);
|
|
365
|
+
let clipped = clip_message(long);
|
|
366
|
+
assert!(clipped.ends_with('…'));
|
|
367
|
+
assert_eq!(clipped.chars().count(), 181);
|
|
368
|
+
}
|
|
108
369
|
}
|
package/src/host_tests.rs
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
use super::node_compatible_path;
|
|
2
|
-
use std::path::Path;
|
|
3
|
-
|
|
4
|
-
#[cfg(not(windows))]
|
|
5
|
-
#[test]
|
|
6
|
-
fn packaged_path_is_unchanged_off_windows() {
|
|
7
|
-
let path = Path::new("/opt/Aroli/node-host");
|
|
8
|
-
assert_eq!(node_compatible_path(path), path);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
#[cfg(windows)]
|
|
12
|
-
#[test]
|
|
13
|
-
fn strips_windows_verbatim_disk_prefix() {
|
|
14
|
-
assert_eq!(
|
|
15
|
-
node_compatible_path(Path::new(r"\\?\C:\Aroli\node-host")),
|
|
16
|
-
Path::new(r"C:\Aroli\node-host")
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
#[cfg(windows)]
|
|
21
|
-
#[test]
|
|
22
|
-
fn converts_windows_verbatim_unc_prefix() {
|
|
23
|
-
assert_eq!(
|
|
24
|
-
node_compatible_path(Path::new(r"\\?\UNC\server\share\node-host")),
|
|
25
|
-
Path::new(r"\\server\share\node-host")
|
|
26
|
-
);
|
|
27
|
-
}
|