Skip to main content

lingxia_provider/
lib.rs

1use std::future::Future;
2use std::pin::Pin;
3
4/// Boxed future type for dyn compatibility.
5pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
6
7/// Error type for provider operations.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ProviderErrorCode {
10    InvalidRequest,
11    NotFound,
12    Network,
13    Timeout,
14    Server,
15    PermissionDenied,
16    Internal,
17}
18
19impl ProviderErrorCode {
20    pub const fn biz_code(self) -> u32 {
21        match self {
22            Self::InvalidRequest => 1002,
23            Self::NotFound => 1003,
24            Self::Network => 5001,
25            Self::Timeout => 5002,
26            Self::Server => 5003,
27            Self::PermissionDenied => 3000,
28            Self::Internal => 1005,
29        }
30    }
31
32    pub const fn as_str(self) -> &'static str {
33        match self {
34            Self::InvalidRequest => "invalid_request",
35            Self::NotFound => "not_found",
36            Self::Network => "network",
37            Self::Timeout => "timeout",
38            Self::Server => "server",
39            Self::PermissionDenied => "permission_denied",
40            Self::Internal => "internal",
41        }
42    }
43}
44
45#[derive(Debug, Clone)]
46pub struct ProviderError {
47    code: ProviderErrorCode,
48    detail: String,
49}
50
51impl ProviderError {
52    pub fn new(code: ProviderErrorCode, detail: impl Into<String>) -> Self {
53        Self {
54            code,
55            detail: detail.into(),
56        }
57    }
58
59    pub fn invalid_request(detail: impl Into<String>) -> Self {
60        Self::new(ProviderErrorCode::InvalidRequest, detail)
61    }
62
63    pub fn not_found(detail: impl Into<String>) -> Self {
64        Self::new(ProviderErrorCode::NotFound, detail)
65    }
66
67    pub fn network(detail: impl Into<String>) -> Self {
68        Self::new(ProviderErrorCode::Network, detail)
69    }
70
71    pub fn timeout(detail: impl Into<String>) -> Self {
72        Self::new(ProviderErrorCode::Timeout, detail)
73    }
74
75    pub fn server(detail: impl Into<String>) -> Self {
76        Self::new(ProviderErrorCode::Server, detail)
77    }
78
79    pub fn permission_denied(detail: impl Into<String>) -> Self {
80        Self::new(ProviderErrorCode::PermissionDenied, detail)
81    }
82
83    pub fn internal(detail: impl Into<String>) -> Self {
84        Self::new(ProviderErrorCode::Internal, detail)
85    }
86
87    pub const fn code(&self) -> ProviderErrorCode {
88        self.code
89    }
90
91    pub const fn biz_code(&self) -> u32 {
92        self.code.biz_code()
93    }
94
95    pub fn detail(&self) -> &str {
96        &self.detail
97    }
98}
99
100impl std::fmt::Display for ProviderError {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        write!(f, "[{}] {}", self.code.as_str(), self.detail)
103    }
104}
105
106impl std::error::Error for ProviderError {}
107
108/// Error type for fingerprint operations.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum FingerprintError {
111    /// Device ID cannot be loaded/generated on current runtime.
112    DeviceIdUnavailable,
113}
114
115impl std::fmt::Display for FingerprintError {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match self {
118            Self::DeviceIdUnavailable => write!(f, "device_id_unavailable"),
119        }
120    }
121}
122
123impl std::error::Error for FingerprintError {}
124
125/// Trait for device fingerprint.
126pub trait FingerprintProvider: Send + Sync + 'static {
127    /// Get the device fingerprint ID.
128    fn get_fingerprint(&self) -> Result<String, FingerprintError> {
129        Err(FingerprintError::DeviceIdUnavailable)
130    }
131}
132
133/// Trait for push token binding.
134pub trait PushNotificationProvider: Send + Sync + 'static {
135    /// Bind push token to cloud side.
136    fn bind_push_token<'a>(&'a self, _token: String) -> BoxFuture<'a, Result<(), ProviderError>> {
137        Box::pin(async { Ok(()) })
138    }
139}
140
141/// Server-owned lifecycle state of an lxapp.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
143pub enum LxAppStatus {
144    /// The registry reported nothing for this app — an older server, an app it
145    /// does not know, or a check that never reached it.
146    #[default]
147    Unknown,
148    Published,
149    /// Temporarily unavailable while the operator works on it. Must not open,
150    /// but says something different to the user than `Suspended` does: one is
151    /// "come back later", the other is "this is not yours to open".
152    Maintain,
153    /// No longer offered. An already-installed copy keeps working.
154    Delisted,
155    /// Blocked by the operator. Must not open, installed or not.
156    Suspended,
157}
158
159impl LxAppStatus {
160    pub const fn as_str(self) -> &'static str {
161        match self {
162            Self::Unknown => "unknown",
163            Self::Published => "published",
164            Self::Maintain => "maintain",
165            Self::Delisted => "delisted",
166            Self::Suspended => "suspended",
167        }
168    }
169
170    /// Unrecognized values read as `Unknown` so a newer server cannot brick an
171    /// older client by inventing a state it never blocks on.
172    ///
173    /// Case- and whitespace-insensitive: `Unknown` does not block, so a server
174    /// sending `"Suspended"` against a case-sensitive match would degrade in
175    /// the unsafe direction on the one field that gates opening.
176    pub fn from_str_lossy(value: &str) -> Self {
177        match value.trim().to_ascii_lowercase().as_str() {
178            "published" => Self::Published,
179            "maintain" => Self::Maintain,
180            "delisted" => Self::Delisted,
181            "suspended" => Self::Suspended,
182            _ => Self::Unknown,
183        }
184    }
185
186    /// Whether opening must be refused.
187    ///
188    /// `Delisted` does not: it means the app is no longer offered, while an
189    /// installed copy keeps working. `Maintain` does, because the operator has
190    /// taken it down on purpose and a half-working app is worse than a clear
191    /// message.
192    pub const fn blocks_open(self) -> bool {
193        matches!(self, Self::Suspended | Self::Maintain)
194    }
195}
196
197impl std::fmt::Display for LxAppStatus {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        f.write_str(self.as_str())
200    }
201}
202
203/// The registry's record for one lxapp: the facts the server owns.
204///
205/// Deliberately carries nothing about a *package* — version, url, checksum,
206/// `minRuntimeVersion` all belong to `UpdatePackageInfo` and travel the update
207/// path. Server-owned facts in, package facts out; the two must never become
208/// two answers to the same question.
209/// The request names the app; this describes it. Fill in what the registry
210/// knows and leave the rest — `..Default::default()` covers it.
211#[derive(Debug, Clone, Default)]
212pub struct LxAppRegistryInfo {
213    /// Display name as the backend defined it.
214    pub name: Option<String>,
215    pub description: Option<String>,
216    /// Where the icon lives. Also the cache key: the client re-fetches when
217    /// this changes and not otherwise, so a server that edits the artwork
218    /// behind a stable URL will never be picked up. Change the URL — a content
219    /// path, or a version query — when the image changes.
220    pub icon_url: Option<String>,
221    pub status: LxAppStatus,
222    /// What this app may reach and do. `None` is the answer for every app the
223    /// registry has no policy for: public network and every privilege class.
224    /// Only an explicit `Some` restricts anything.
225    pub permissions: Option<LxAppPermissions>,
226}
227
228/// Grant for one app. Built through constructors, not a struct literal.
229///
230/// Each field is independent. `None` means the registry does not constrain that
231/// half (public network / every privilege class). `Some` is an allowlist: empty
232/// denies that half, `["*"]` allows it all. Fill in only the half you have a
233/// policy for.
234#[derive(Debug, Clone, Default, PartialEq, Eq)]
235#[non_exhaustive]
236pub struct LxAppPermissions {
237    pub network: Option<LxAppNetworkPermission>,
238    pub privileges: Option<LxAppPrivilegePermission>,
239}
240
241impl LxAppPermissions {
242    /// Unconstrained: public network and every privilege class.
243    pub fn all() -> Self {
244        Self::default()
245    }
246
247    /// Restrict network to these hosts. Privileges stay unconstrained.
248    pub fn network(trusted_domains: impl IntoIterator<Item = impl Into<String>>) -> Self {
249        Self {
250            network: Some(LxAppNetworkPermission::new(trusted_domains)),
251            privileges: None,
252        }
253    }
254
255    /// Restrict privileges to these classes. Network stays unconstrained.
256    pub fn privileges(granted: impl IntoIterator<Item = impl Into<String>>) -> Self {
257        Self {
258            network: None,
259            privileges: Some(LxAppPrivilegePermission::new(granted)),
260        }
261    }
262
263    pub fn with_network(
264        mut self,
265        trusted_domains: impl IntoIterator<Item = impl Into<String>>,
266    ) -> Self {
267        self.network = Some(LxAppNetworkPermission::new(trusted_domains));
268        self
269    }
270
271    pub fn with_privileges(mut self, granted: impl IntoIterator<Item = impl Into<String>>) -> Self {
272        self.privileges = Some(LxAppPrivilegePermission::new(granted));
273        self
274    }
275}
276
277/// Approved hosts, without scheme, port or path. `*` allows every public host;
278/// `*.example.com` matches subdomains. The runtime still blocks non-public
279/// addresses outside a dev session.
280#[derive(Debug, Clone, Default, PartialEq, Eq)]
281#[non_exhaustive]
282pub struct LxAppNetworkPermission {
283    pub trusted_domains: Vec<String>,
284}
285
286impl LxAppNetworkPermission {
287    pub fn new(trusted_domains: impl IntoIterator<Item = impl Into<String>>) -> Self {
288        Self {
289            trusted_domains: trusted_domains.into_iter().map(Into::into).collect(),
290        }
291    }
292}
293
294/// Approved privilege ids (`downloads`, `process`, `automation`, `host`, …).
295/// `*` allows every class.
296#[derive(Debug, Clone, Default, PartialEq, Eq)]
297#[non_exhaustive]
298pub struct LxAppPrivilegePermission {
299    pub granted: Vec<String>,
300}
301
302impl LxAppPrivilegePermission {
303    pub fn new(granted: impl IntoIterator<Item = impl Into<String>>) -> Self {
304        Self {
305            granted: granted.into_iter().map(Into::into).collect(),
306        }
307    }
308}
309
310/// Lookup of registry records, separate from `UpdateProvider` on purpose: an
311/// app's name, icon, status, and permissions change without any package
312/// changing, and the update path is gated (OTA-managed only, deduped,
313/// force-update aware) in ways that would silently strand them.
314///
315/// One record answers what the app *is* and what it *may do*, because both are
316/// facts the server owns about an app id and both are wanted at the same
317/// moment — opening it. A second round trip for permissions alone would buy
318/// nothing.
319pub trait LxAppRegistryProvider: Send + Sync + 'static {
320    /// Resolve one app's registry record.
321    ///
322    /// `name` and `description` are the strings the backend stored. The client
323    /// does not send a locale; localization, if any, is a server concern.
324    ///
325    /// `Ok(None)` means the registry does not know the app (HTTP 404). That is
326    /// a negative listing, not a transport failure, and it does not restrict
327    /// the app — nothing but an explicit `permissions` grant does. An error or
328    /// a timeout leaves the app on its last known grant, or unrestricted if it
329    /// never had one; an unreachable registry is not evidence of anything.
330    ///
331    /// Scope remote lookups to the host account/tenant; an app id alone is not
332    /// an authorization credential.
333    fn fetch_registry_info<'a>(
334        &'a self,
335        _app: LxAppRegistryRequest<'a>,
336    ) -> BoxFuture<'a, Result<Option<LxAppRegistryInfo>, ProviderError>> {
337        Box::pin(async { Ok(None) })
338    }
339}
340
341/// Which app the runtime is asking about. Later inputs arrive here rather than
342/// as a new method, so match on the fields you need.
343#[derive(Debug, Clone, Copy)]
344#[non_exhaustive]
345pub struct LxAppRegistryRequest<'a> {
346    pub appid: &'a str,
347    /// Answer per channel: a draft of an app id is not the app the
348    /// release grant was written for.
349    pub channel: LxAppChannel,
350}
351
352impl<'a> LxAppRegistryRequest<'a> {
353    pub fn new(appid: &'a str, channel: LxAppChannel) -> Self {
354        Self { appid, channel }
355    }
356}
357
358/// The channel a build was published on.
359///
360/// An enum rather than the wire string: an implementor that misspells a channel
361/// in a `match` would answer "not my app", which restricts nothing — a typo
362/// must not widen an app.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
364pub enum LxAppChannel {
365    #[default]
366    Release,
367    Draft,
368}
369
370impl LxAppChannel {
371    pub const fn as_str(self) -> &'static str {
372        match self {
373            Self::Release => "release",
374            Self::Draft => "draft",
375        }
376    }
377}
378
379impl std::fmt::Display for LxAppChannel {
380    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381        f.write_str(self.as_str())
382    }
383}
384
385#[cfg(test)]
386mod registry_tests {
387    use super::LxAppStatus;
388
389    #[test]
390    fn only_the_states_that_mean_do_not_open_block() {
391        // Two states block, and they say different things to a user: one is
392        // "come back later", the other is "this is not yours to open".
393        assert!(LxAppStatus::Suspended.blocks_open());
394        assert!(LxAppStatus::Maintain.blocks_open());
395        // Delisted is not offered any more, but an installed copy keeps working.
396        assert!(!LxAppStatus::Delisted.blocks_open());
397        assert!(!LxAppStatus::Published.blocks_open());
398        // An unrecognized state must never lock a user out.
399        assert!(!LxAppStatus::Unknown.blocks_open());
400        assert_eq!(
401            LxAppStatus::from_str_lossy("maintain"),
402            LxAppStatus::Maintain
403        );
404    }
405
406    #[test]
407    fn status_parsing_is_case_insensitive_because_unknown_never_blocks() {
408        assert_eq!(
409            LxAppStatus::from_str_lossy("suspended"),
410            LxAppStatus::Suspended
411        );
412        assert_eq!(
413            LxAppStatus::from_str_lossy("Suspended"),
414            LxAppStatus::Suspended
415        );
416        assert_eq!(
417            LxAppStatus::from_str_lossy(" SUSPENDED "),
418            LxAppStatus::Suspended
419        );
420        assert!(LxAppStatus::from_str_lossy("SUSPENDED").blocks_open());
421
422        assert_eq!(
423            LxAppStatus::from_str_lossy("Delisted"),
424            LxAppStatus::Delisted
425        );
426        assert_eq!(LxAppStatus::from_str_lossy(""), LxAppStatus::Unknown);
427        assert_eq!(LxAppStatus::from_str_lossy("retired"), LxAppStatus::Unknown);
428    }
429}