1mod app;
2mod error;
3mod lxapp;
4mod signing;
5
6use lingxia_provider::{BoxFuture, ProviderError};
7use serde::{Deserialize, Serialize};
8use std::cmp::Ordering;
9use std::fmt;
10use std::str::FromStr;
11
12pub use app::{
13 AppUpdateApply, AppUpdateEvent, AppUpdateEventReceiver, AppUpdateEventSender, AppUpdateHost,
14 AppUpdateProgressReporter, AppUpdateStage, app_update_scope_key, check_app_update,
15 ensure_app_update_candidate_version, send_app_update_event, send_app_update_failed,
16 subscribe_app_update_events,
17};
18pub use error::UpdateError;
19pub use lxapp::{
20 LxAppUpdateHost, ensure_first_install as ensure_lxapp_first_install,
21 ensure_target_version_ready as ensure_lxapp_target_version_ready, lxapp_update_scope_key,
22 spawn_background_update_check as spawn_lxapp_background_update_check,
23};
24pub use signing::{
25 SignRequest, UpdateAuthentication, UpdateVerifyTarget, archive_sha256_hex,
26 check_update_enabled, compact_manifest, decode_base64url, embedded_update_public_keys,
27 encode_base64url, env_requires_signature, host_requires_signature, host_update_platform,
28 load_signing_seed_file, public_key_base64url, sign_package, sign_package_from_key_file,
29 verify_archive_bytes, verify_checked_update,
30};
31
32#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
33#[serde(rename_all = "lowercase")]
34pub enum Channel {
35 #[default]
36 Release,
37 Draft,
38}
39
40impl From<Channel> for lingxia_provider::LxAppChannel {
41 fn from(channel: Channel) -> Self {
42 match channel {
43 Channel::Release => Self::Release,
44 Channel::Draft => Self::Draft,
45 }
46 }
47}
48
49pub fn default_channel() -> Channel {
57 match lingxia_app_context::env() {
58 lingxia_app_context::AppEnv::Dev => Channel::Draft,
59 lingxia_app_context::AppEnv::Prod => Channel::Release,
60 }
61}
62
63impl Channel {
64 pub fn as_str(self) -> &'static str {
65 match self {
66 Self::Release => "release",
67 Self::Draft => "draft",
68 }
69 }
70
71 pub fn parse(tag: &str) -> Result<Self, String> {
72 match tag.trim() {
73 "release" => Ok(Self::Release),
74 "draft" => Ok(Self::Draft),
75 value => Err(format!("invalid channel: {value}")),
76 }
77 }
78}
79
80impl fmt::Display for Channel {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 f.write_str(self.as_str())
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Version {
90 pub major: u32,
91 pub minor: u32,
92 pub patch: u32,
93}
94
95impl Version {
96 pub fn parse(version_str: &str) -> Result<Self, VersionError> {
97 let parts: Vec<&str> = version_str.split('.').collect();
98 if parts.len() != 3 {
99 return Err(VersionError::InvalidFormat);
100 }
101
102 let major = parts[0]
103 .parse()
104 .map_err(|_| VersionError::InvalidComponent)?;
105 let minor = parts.get(1).map_or(Ok(0), |s| {
106 s.parse().map_err(|_| VersionError::InvalidComponent)
107 })?;
108 let patch = parts.get(2).map_or(Ok(0), |s| {
109 s.parse().map_err(|_| VersionError::InvalidComponent)
110 })?;
111
112 Ok(Self {
113 major,
114 minor,
115 patch,
116 })
117 }
118}
119
120impl FromStr for Version {
121 type Err = VersionError;
122
123 fn from_str(s: &str) -> Result<Self, Self::Err> {
124 Self::parse(s)
125 }
126}
127
128impl fmt::Display for Version {
129 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
131 }
132}
133
134impl PartialOrd for Version {
135 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
136 Some(self.cmp(other))
137 }
138}
139
140impl Ord for Version {
141 fn cmp(&self, other: &Self) -> Ordering {
142 match self.major.cmp(&other.major) {
143 Ordering::Equal => match self.minor.cmp(&other.minor) {
144 Ordering::Equal => self.patch.cmp(&other.patch),
145 ordering => ordering,
146 },
147 ordering => ordering,
148 }
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
153pub enum VersionError {
154 #[error("invalid version format, expected 'major.minor.patch'")]
155 InvalidFormat,
156 #[error("invalid version component, expected unsigned integer")]
157 InvalidComponent,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161pub struct SemanticVersion {
162 pub major: u32,
163 pub minor: u32,
164 pub patch: u32,
165}
166
167impl SemanticVersion {
168 pub fn from_version(version: &Version) -> Self {
169 Self {
170 major: version.major,
171 minor: version.minor,
172 patch: version.patch,
173 }
174 }
175
176 pub fn to_version_string(&self) -> String {
177 format!("{}.{}.{}", self.major, self.minor, self.patch)
178 }
179}
180
181impl fmt::Display for SemanticVersion {
182 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
184 }
185}
186
187#[derive(Clone, Debug, PartialEq, Eq)]
188pub enum LxAppUpdateQuery {
189 Latest { current_version: Option<String> },
190 TargetVersion(String),
191}
192
193impl LxAppUpdateQuery {
194 pub fn latest(current_version: Option<impl Into<String>>) -> Self {
195 Self::Latest {
196 current_version: current_version.map(Into::into),
197 }
198 }
199
200 pub fn target_version(version: impl Into<String>) -> Self {
201 Self::TargetVersion(version.into())
202 }
203}
204
205#[derive(Clone, Debug, PartialEq, Eq)]
206pub enum UpdateTarget {
207 App {
208 current_version: Option<String>,
209 },
210 LxApp {
211 id: String,
212 channel: Channel,
213 query: LxAppUpdateQuery,
214 },
215 Plugin {
216 id: String,
217 version: String,
218 channel: Channel,
219 },
220}
221
222impl UpdateTarget {
223 pub fn app(current_version: Option<impl Into<String>>) -> Self {
224 Self::App {
225 current_version: current_version.map(Into::into),
226 }
227 }
228
229 pub fn lxapp(id: impl Into<String>, channel: Channel, query: LxAppUpdateQuery) -> Self {
230 Self::LxApp {
231 id: id.into(),
232 channel,
233 query,
234 }
235 }
236
237 pub fn plugin(id: impl Into<String>, version: impl Into<String>, channel: Channel) -> Self {
238 Self::Plugin {
239 id: id.into(),
240 version: version.into(),
241 channel,
242 }
243 }
244
245 pub fn scope_key(&self) -> String {
247 match self {
248 Self::App { .. } => "app".to_string(),
249 Self::LxApp { id, channel, .. } => format!("lxapp:{id}@{}", channel.as_str()),
250 Self::Plugin {
251 id,
252 version,
253 channel,
254 } => {
255 format!("plugin:{id}@{version}@{}", channel.as_str())
256 }
257 }
258 }
259}
260
261#[derive(Clone, Debug)]
262pub struct UpdatePackageInfo {
263 pub version: String,
264 pub url: String,
265 pub checksum_sha256: String,
266 pub size: Option<u64>,
267 pub release_notes: Option<Vec<String>>,
268 pub min_runtime: Option<String>,
271 pub authentication: Option<UpdateAuthentication>,
272}
273
274impl UpdatePackageInfo {
275 pub fn should_replace_version(
276 candidate_version: &str,
277 installed_version: Option<&str>,
278 ) -> bool {
279 installed_version != Some(candidate_version)
280 }
281
282 pub fn should_replace_installed_version(&self, installed_version: Option<&str>) -> bool {
283 Self::should_replace_version(&self.version, installed_version)
284 }
285
286 pub fn should_replace(
294 &self,
295 channel: Channel,
296 installed_version: Option<&str>,
297 installed_checksum: Option<&str>,
298 ) -> bool {
299 if channel != Channel::Draft {
300 return Self::should_replace_version(&self.version, installed_version);
301 }
302 if Self::should_replace_version(&self.version, installed_version) {
303 return true;
304 }
305 let Some(server) = normalize_checksum(&self.checksum_sha256) else {
306 return false;
307 };
308 match installed_checksum.and_then(normalize_checksum) {
309 Some(local) => !local.eq_ignore_ascii_case(server),
310 None => true,
311 }
312 }
313
314 pub fn min_runtime_trimmed(&self) -> Option<&str> {
315 self.min_runtime
316 .as_deref()
317 .map(str::trim)
318 .filter(|value| !value.is_empty())
319 }
320
321 pub fn ensure_runtime_compatible(
322 &self,
323 current_runtime_version: &str,
324 target_name: &str,
325 ) -> Result<(), RuntimeCompatibilityError> {
326 match self.min_runtime_trimmed() {
327 Some(required) => ensure_runtime_satisfies(
328 required,
329 current_runtime_version,
330 target_name,
331 &self.version,
332 ),
333 None => Ok(()),
334 }
335 }
336}
337
338pub fn ensure_runtime_satisfies(
340 min_runtime: &str,
341 current_runtime_version: &str,
342 target_name: &str,
343 version: &str,
344) -> Result<(), RuntimeCompatibilityError> {
345 let current = Version::parse(current_runtime_version).map_err(|_| {
346 RuntimeCompatibilityError::InvalidCurrentRuntimeVersion {
347 runtime_version: current_runtime_version.to_string(),
348 }
349 })?;
350 let required =
351 Version::parse(min_runtime).map_err(|_| RuntimeCompatibilityError::InvalidMinRuntime {
352 target: target_name.to_string(),
353 update_version: version.to_string(),
354 runtime_version: min_runtime.to_string(),
355 })?;
356 if current < required {
357 return Err(RuntimeCompatibilityError::RequiresRuntimeUpgrade {
358 target: target_name.to_string(),
359 update_version: version.to_string(),
360 min_runtime: required.to_string(),
361 current_runtime_version: current.to_string(),
362 });
363 }
364 Ok(())
365}
366
367fn normalize_checksum(value: &str) -> Option<&str> {
368 let value = value.trim();
369 if value.is_empty() { None } else { Some(value) }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
373pub enum RuntimeCompatibilityError {
374 #[error("invalid SDK runtime version '{runtime_version}'")]
375 InvalidCurrentRuntimeVersion { runtime_version: String },
376 #[error(
377 "invalid minRuntime '{runtime_version}' from update metadata for {target}@{update_version}"
378 )]
379 InvalidMinRuntime {
380 target: String,
381 update_version: String,
382 runtime_version: String,
383 },
384 #[error(
385 "{target} update {update_version} requires runtime >= {min_runtime}, current SDK runtime is {current_runtime_version}; update host app first"
386 )]
387 RequiresRuntimeUpgrade {
388 target: String,
389 update_version: String,
390 min_runtime: String,
391 current_runtime_version: String,
392 },
393}
394
395pub trait UpdateProvider: Send + Sync + 'static {
397 fn check_update<'a>(
400 &'a self,
401 target: UpdateTarget,
402 ) -> BoxFuture<'a, Result<Option<UpdatePackageInfo>, ProviderError>>;
403}
404
405#[cfg(test)]
406mod tests {
407 use super::{
408 Channel, RuntimeCompatibilityError, UpdatePackageInfo, UpdateTarget, Version,
409 ensure_runtime_satisfies,
410 };
411
412 #[test]
413 fn runtime_floor_is_a_full_semver_comparison() {
414 let check =
415 |required, current| ensure_runtime_satisfies(required, current, "demo", "1.0.0");
416 assert!(check("0.17.0", "0.17.0").is_ok());
417 assert!(check("0.17.0", "0.17.3").is_ok());
418 assert!(check("0.17.0", "1.0.0").is_ok());
419 assert!(matches!(
420 check("0.18.0", "0.17.9"),
421 Err(RuntimeCompatibilityError::RequiresRuntimeUpgrade { .. })
422 ));
423 assert!(check("0.18.0", "0.18.0-rc.1").is_err());
425 assert!(matches!(
426 check("seventeen", "0.17.0"),
427 Err(RuntimeCompatibilityError::InvalidMinRuntime { .. })
428 ));
429 }
430
431 fn package(version: &str, checksum: &str) -> UpdatePackageInfo {
432 UpdatePackageInfo {
433 version: version.to_string(),
434 url: "https://example.test/pkg".to_string(),
435 checksum_sha256: checksum.to_string(),
436 size: None,
437 release_notes: None,
438 min_runtime: None,
439 authentication: None,
440 }
441 }
442
443 #[test]
444 fn channel_parse_accepts_release_and_draft_only() {
445 assert_eq!(Channel::parse("release").unwrap(), Channel::Release);
446 assert_eq!(Channel::parse("draft").unwrap(), Channel::Draft);
447 assert!(Channel::parse("preview").is_err());
448 assert!(Channel::parse("developer").is_err());
449 }
450
451 #[test]
452 fn version_parse_accepts_full_semver_only() {
453 assert!(Version::parse("1.2.3").is_ok());
454 assert!(Version::parse("1").is_err());
455 assert!(Version::parse("1.2").is_err());
456 assert!(Version::parse("1.2.3.4").is_err());
457 }
458
459 #[test]
460 fn release_ignores_checksum_when_version_matches() {
461 let pkg = package("1.0.0", "aaa");
462 assert!(!pkg.should_replace(Channel::Release, Some("1.0.0"), Some("bbb")));
463 assert!(pkg.should_replace(Channel::Release, Some("0.9.0"), Some("aaa")));
464 }
465
466 #[test]
467 fn draft_replaces_same_version_when_checksum_differs() {
468 let pkg = package("1.0.0", "bbb");
469 assert!(pkg.should_replace(Channel::Draft, Some("1.0.0"), Some("aaa")));
470 assert!(!pkg.should_replace(Channel::Draft, Some("1.0.0"), Some("BBB")));
471 assert!(pkg.should_replace(Channel::Draft, Some("1.0.0"), None));
472 assert!(pkg.should_replace(Channel::Draft, Some("0.9.0"), Some("bbb")));
473 }
474
475 #[test]
476 fn plugin_target_carries_the_requested_channel() {
477 let target = UpdateTarget::plugin("plug", "1.0.0", Channel::Draft);
478 assert_eq!(target.scope_key(), "plugin:plug@1.0.0@draft");
479 }
480}