@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/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 cfg!(debug_assertions) || std::env::var("DEEPPATH_APP_UPDATE").as_deref() == Ok("0") {
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
- if app
47
- .state::<UpdateState>()
48
- .pending
49
- .lock()
50
- .map(|pending| pending.is_some())
51
- .unwrap_or(true)
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
- let _ = app.emit(
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
- let _ = app.emit("app-update-state", serde_json::json!({ "phase": "idle" }));
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
- let _ = app.emit(
75
- "app-update-state",
76
- serde_json::json!({ "phase": "error", "message": error.to_string() }),
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
- let _ = app.emit(
83
- "app-update-state",
84
- serde_json::json!({ "phase": "downloading", "version": version }),
85
- );
86
- let bytes = match update.download(|_chunk, _total| {}, || {}).await {
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
- let _ = app.emit(
91
- "app-update-state",
92
- serde_json::json!({
93
- "phase": "error",
94
- "version": version,
95
- "message": error.to_string()
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
- let _ = app.emit(
105
- "app-update-state",
106
- serde_json::json!({ "phase": "ready", "version": version }),
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
- }