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