Skip to main content

lingxia_update/
lib.rs

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
49/// Default lxapp channel for this host, derived from the host env:
50/// `dev` → `draft`, `prod` → `release`. An open can pass an explicit
51/// channel to override; the client does not forbid `draft` on a prod
52/// host — the registry decides per-channel access.
53///
54/// Host self-update does **not** carry a channel: the host talks to the
55/// server for its env.
56pub 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/// A semantic version representation (`major.minor.patch`) shared by update policy
87/// and lxapp metadata persistence.
88#[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    /// Stable routing key for dedupe, metrics, and diagnostics.
246    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    /// The lxapp's `minRuntime` as the check-update response reported it; an
269    /// early, unsigned hint. The archive's own `lxapp.json` is the gate that counts.
270    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    /// Whether this package should replace what is already installed.
287    ///
288    /// `release` compares versions only. `draft` also treats a
289    /// same-version package as an update when `checksum_sha256` differs, so a
290    /// republish does not need a version bump. A draft install with no
291    /// stored checksum is treated as different so the first OTA after a
292    /// bundled/sideload install still picks up a same-version republish.
293    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
338/// Refuses `target_name@version` when this runtime is below its floor.
339pub 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
395/// Update contract shared by app and lxapp update implementations.
396pub trait UpdateProvider: Send + Sync + 'static {
397    /// Returns `Some(package)` when an update package exists and `None` when the target
398    /// is already up to date or no matching package is available.
399    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        // A pre-release host sorts below its own line's `M.m.0` floor.
424        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}