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