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