Skip to main content

lingxia_update/
lib.rs

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
54/// Default lxapp channel for this host, derived from the host env:
55/// `dev` → `draft`, `prod` → `release`. An open can pass an explicit
56/// channel to override; the client does not forbid `draft` on a prod
57/// host — the registry decides per-channel access.
58///
59/// Host self-update does **not** carry a channel: the host talks to the
60/// server for its env.
61pub 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/// A semantic version representation (`major.minor.patch`) shared by update policy
94/// and lxapp metadata persistence.
95#[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    /// Stable routing key for dedupe, metrics, and diagnostics.
253    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    /// Whether this package should replace what is already installed.
293    ///
294    /// `release` / `preview` compare versions only. `draft` also treats a
295    /// same-version package as an update when `checksum_sha256` differs, so a
296    /// republish does not need a version bump. A draft install with no
297    /// stored checksum is treated as different so the first OTA after a
298    /// bundled/sideload install still picks up a same-version republish.
299    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
390/// Update contract shared by app and lxapp update implementations.
391pub trait UpdateProvider: Send + Sync + 'static {
392    /// Returns `Some(package)` when an update package exists and `None` when the target
393    /// is already up to date or no matching package is available.
394    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}