Skip to main content

lingxia_platform/traits/
app_runtime.rs

1use std::io::Read;
2use std::path::{Path, PathBuf};
3
4use crate::AssetFileEntry;
5use crate::error::PlatformError;
6
7use super::PlatformFuture;
8use super::clipboard::ClipboardService;
9use super::device::{Device, DeviceHardware};
10use super::file::FileService;
11use super::location::Location;
12use super::media_interaction::{MediaInteraction, MediaKind};
13use super::media_runtime::MediaRuntime;
14use super::network::Network;
15use super::secure_store::SecureStore;
16use super::share::ShareService;
17use super::ui::{SurfacePresenter, UIUpdate, UserFeedback};
18use super::update::UpdateService;
19use super::wifi::Wifi;
20
21/// The envelope an activation token travels in when the OS gives us one
22/// opaque string and hands it back on tap: a Windows toast's `launch`, a
23/// HarmonyOS reminder's `uri`, an Android tap Intent's data. Apple needs none
24/// — a `userInfo` key is already unambiguous.
25///
26/// The prefix says "this payload is ours"; `v1` is what a later envelope
27/// change branches on. Deliberately not `https`, so it can never reach the
28/// App Link parser and demand a configured product host, and not a registered
29/// URL scheme — nothing outside this process routes on it.
30///
31/// The Kotlin and ArkTS SDKs spell this out again; this is the normative form.
32pub const ACTIVATION_ENVELOPE: &str = "lxnotify:v1:";
33
34/// Wrap a token for a payload slot that only takes a string.
35pub fn wrap_activation(token: &str) -> String {
36    format!("{ACTIVATION_ENVELOPE}{token}")
37}
38
39/// The token a payload carries, or `None` when the payload is not ours.
40pub fn unwrap_activation(payload: &str) -> Option<&str> {
41    payload
42        .trim()
43        .strip_prefix(ACTIVATION_ENVELOPE)
44        .filter(|token| !token.is_empty())
45}
46
47/// One local notification to post or replace.
48#[derive(Debug, Clone)]
49pub struct LocalNotificationShow {
50    pub id: String,
51    pub title: String,
52    pub body: String,
53    /// Opaque single-use token the OS payload carries and hands back on tap.
54    /// The target itself lives in the host's intent store, so no platform's
55    /// payload limit constrains it and no business parameter reaches a launch
56    /// command line. Never empty — an `activate` target has one too.
57    pub activation_token: String,
58    pub deliver_at_ms: Option<u64>,
59    pub silent: bool,
60}
61
62/// One button on a desktop banner.
63#[derive(Debug, Clone)]
64pub struct DesktopBannerAction {
65    pub id: String,
66    pub label: String,
67    pub style: DesktopBannerActionStyle,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum DesktopBannerActionStyle {
72    Default,
73    Primary,
74    Destructive,
75}
76
77impl DesktopBannerActionStyle {
78    pub fn as_str(self) -> &'static str {
79        match self {
80            Self::Default => "default",
81            Self::Primary => "primary",
82            Self::Destructive => "destructive",
83        }
84    }
85
86    pub fn parse(value: &str) -> Option<Self> {
87        match value {
88            "default" => Some(Self::Default),
89            "primary" => Some(Self::Primary),
90            "destructive" => Some(Self::Destructive),
91            _ => None,
92        }
93    }
94}
95
96/// Card chrome. `System` follows the OS; a hex color is a solid fill.
97#[derive(Debug, Clone, PartialEq, Eq, Default)]
98pub enum DesktopBannerBackground {
99    #[default]
100    System,
101    Light,
102    Dark,
103    Color {
104        r: u8,
105        g: u8,
106        b: u8,
107        a: u8,
108    },
109}
110
111/// One desktop banner to present. The caller waits until it resolves.
112#[derive(Debug, Clone)]
113pub struct DesktopBannerShow {
114    pub id: String,
115    pub title: String,
116    pub body: String,
117    pub actions: Vec<DesktopBannerAction>,
118    /// `None` means wait until a button, dismiss, or replace.
119    pub timeout_ms: Option<u64>,
120    pub background: DesktopBannerBackground,
121}
122
123/// How a desktop banner finished. Failures to present are errors, not this.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum DesktopBannerOutcome {
126    Action { id: String, action: String },
127    Dismissed { id: String },
128    TimedOut { id: String },
129    Replaced { id: String },
130}
131
132impl DesktopBannerOutcome {
133    pub fn id(&self) -> &str {
134        match self {
135            Self::Action { id, .. }
136            | Self::Dismissed { id }
137            | Self::TimedOut { id }
138            | Self::Replaced { id } => id,
139        }
140    }
141
142    pub fn reason(&self) -> Option<&'static str> {
143        match self {
144            Self::Action { .. } => None,
145            Self::Dismissed { .. } => Some("dismissed"),
146            Self::TimedOut { .. } => Some("timeout"),
147            Self::Replaced { .. } => Some("replaced"),
148        }
149    }
150}
151
152/// What `notification_show` did with the request.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum LocalNotificationStatus {
155    /// Handed to the OS for display now. Not a receipt that anyone read it.
156    Posted,
157    /// Queued with the OS for `deliver_at_ms`.
158    Scheduled,
159    /// Immediate show while the product is frontmost: nothing was posted.
160    Suppressed,
161}
162
163impl LocalNotificationStatus {
164    pub fn as_str(self) -> &'static str {
165        match self {
166            Self::Posted => "posted",
167            Self::Scheduled => "scheduled",
168            Self::Suppressed => "suppressed",
169        }
170    }
171
172    /// Parse the status word a native bridge returned.
173    pub fn from_native(value: &str) -> Option<Self> {
174        match value {
175            "posted" => Some(Self::Posted),
176            "scheduled" => Some(Self::Scheduled),
177            "suppressed" => Some(Self::Suppressed),
178            _ => None,
179        }
180    }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum AnimationType {
185    None = 0,
186    Forward = 1,
187    Backward = 2,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
191pub enum LxAppOpenMode {
192    #[default]
193    Normal = 0,
194    Panel = 1,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum OpenUrlTarget {
199    External = 0,
200    SelfTarget = 1,
201    /// Open a new browser tab unconditionally (skips "navigate current tab" heuristic).
202    NewBrowserTab = 2,
203    /// Open in the compact in-app browser as an API-managed aside tab. It uses
204    /// the one-row toolbar without address editing or user tab creation.
205    AsideBrowser = 3,
206}
207
208impl OpenUrlTarget {
209    pub fn parse(raw: Option<&str>) -> Self {
210        match raw.map(|v| v.trim().to_ascii_lowercase()) {
211            Some(v) if v == "self" => Self::SelfTarget,
212            Some(v) if v == "new_browser_tab" => Self::NewBrowserTab,
213            Some(v) if v == "aside" => Self::AsideBrowser,
214            Some(v) if v == "external" => Self::External,
215            Some(v) => {
216                log::warn!("Invalid openURL target='{}', fallback to external", v);
217                Self::External
218            }
219            None => Self::External,
220        }
221    }
222}
223
224#[derive(Debug, Clone)]
225pub struct OpenUrlRequest {
226    pub owner_appid: String,
227    pub owner_session_id: u64,
228    pub url: String,
229    pub target: OpenUrlTarget,
230    /// When true, the host should create the in-app tab before returning and
231    /// report its id. Fire-and-forget callers (new-window, navigation) leave
232    /// this false so the work can hop off a WebView UI thread.
233    pub want_tab_id: bool,
234}
235
236/// Outcome of [`AppRuntime::open_url`]. `tab_id` is set when the host named
237/// the tab it opened; `None` means the browser chrome owns the strip.
238#[derive(Debug, Clone, Default, PartialEq, Eq)]
239pub struct OpenUrlResult {
240    pub tab_id: Option<String>,
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum BuiltinBrowserPage {
245    Downloads = 1,
246}
247
248impl From<i32> for AnimationType {
249    fn from(value: i32) -> Self {
250        match value {
251            1 => AnimationType::Forward,
252            2 => AnimationType::Backward,
253            _ => AnimationType::None,
254        }
255    }
256}
257
258pub trait AppRuntime:
259    Send
260    + Sync
261    + MediaInteraction
262    + MediaRuntime
263    + Network
264    + SurfacePresenter
265    + ClipboardService
266    + Device
267    + DeviceHardware
268    + SecureStore
269    + ShareService
270    + FileService
271    + Location
272    + UIUpdate
273    + UpdateService
274    + UserFeedback
275    + Wifi
276    + 'static
277{
278    /// Reads an asset file as a streaming reader.
279    fn read_asset<'a>(&'a self, path: &str) -> Result<Box<dyn Read + 'a>, PlatformError>;
280
281    /// Iterates over files in an asset directory.
282    fn asset_dir_iter<'a>(
283        &'a self,
284        asset_dir: &str,
285    ) -> Box<dyn Iterator<Item = Result<AssetFileEntry<'a>, PlatformError>> + 'a>;
286
287    /// Returns the app's data directory path.
288    fn app_data_dir(&self) -> PathBuf;
289
290    /// Returns the app's cache directory path.
291    fn app_cache_dir(&self) -> PathBuf;
292
293    /// Obtains the application identifier.
294    fn get_app_identifier(&self) -> Result<String, PlatformError>;
295
296    /// Copies media from the system album to a local file.
297    fn copy_album_media_to_file(
298        &self,
299        uri: &str,
300        dest_path: &Path,
301        kind: MediaKind,
302    ) -> Result<(), PlatformError> {
303        MediaRuntime::copy_album_media_to_file(self, uri, dest_path, kind)
304    }
305
306    /// Returns the current system locale.
307    fn get_system_locale(&self) -> &str;
308
309    /// Show the UI container for the given LxApp and route.
310    /// `webtag` is the page instance's full webview tag; page tags are
311    /// per-instance, so shells must not reconstruct them from the route.
312    /// Platforms whose containers resolve through the runtime by path may
313    /// ignore it.
314    fn show_lxapp(
315        &self,
316        appid: String,
317        title: String,
318        path: String,
319        webtag: String,
320        session_id: u64,
321        open_mode: LxAppOpenMode,
322        panel_id: String,
323    ) -> Result<(), PlatformError>;
324
325    /// Notify the desktop skin that the next layout publication is an explicit
326    /// request to put this lxapp in front. Most skins reconcile directly from
327    /// the layout plan; Windows uses the intent to replace a browser cover
328    /// without treating unrelated resize/aside publications as activations.
329    fn request_lxapp_main_activation(&self, _appid: &str) {}
330
331    /// Hide the UI container for the given LxApp (does not destroy its runtime state).
332    fn hide_lxapp(&self, appid: String, session_id: u64) -> Result<(), PlatformError>;
333
334    /// Exits the host app.
335    fn exit(&self) -> Result<(), PlatformError>;
336
337    // Tray / badge chrome. These are cosmetic enhancements, so platforms that
338    // lack the chrome (e.g. no menu-bar tray on mobile) no-op rather than error —
339    // portable code can call them unconditionally. A supporting platform returns
340    // Err only on genuine failure.
341
342    /// Set the tray (menu-bar / system-tray) badge. Desktop only; no-op elsewhere.
343    /// `Ok(false)` means there was nothing to paint on — no tray at all, or a
344    /// status item the product has not shown. Only a malfunction is an `Err`,
345    /// so a caller never needs to catch "this platform has no such chrome".
346    fn set_tray_badge(&self, _text: &str) -> Result<bool, PlatformError> {
347        Ok(false)
348    }
349
350    /// Set the tray icon (a resource path). Desktop only; no-op elsewhere.
351    fn set_tray_icon(&self, _icon: &str) -> Result<(), PlatformError> {
352        Ok(())
353    }
354
355    /// Replace the resolved shell sidebar action render list. Desktop skins only
356    /// render presentation metadata and report stable ids.
357    fn set_shell_sidebar_actions(
358        &self,
359        _items: &[lingxia_shell::ResolvedShellSidebarAction],
360    ) -> Result<(), PlatformError> {
361        Ok(())
362    }
363
364    /// Replace the ordered mixed user Pin list. Platform skins resolve visual
365    /// metadata only; target identity and the eight-item limit are shell-owned.
366    fn set_shell_pins(&self, _items: &[lingxia_shell::ShellPin]) -> Result<(), PlatformError> {
367        Ok(())
368    }
369
370    /// Show or hide the shell's "an AI assistant is in control" indicator.
371    /// Its Stop button calls [`crate::request_control_session_stop`]. Desktop
372    /// shells only; no-op elsewhere.
373    fn set_control_session_indicator(&self, _active: bool) -> Result<(), PlatformError> {
374        Ok(())
375    }
376
377    /// Set the tray title (text beside the icon, macOS). Desktop only; no-op elsewhere.
378    fn set_tray_title(&self, _text: &str) -> Result<(), PlatformError> {
379        Ok(())
380    }
381
382    /// Set the app-icon badge: dock (macOS) / taskbar (Windows) / launcher icon
383    /// (iOS, Android). No-op on platforms where it is not yet wired.
384    /// `Ok(false)` means there was nothing to paint on. See [`Self::set_tray_badge`].
385    fn set_app_badge(&self, _text: &str) -> Result<bool, PlatformError> {
386        Ok(false)
387    }
388
389    /// Whether the app is registered to launch at system startup. Only reached
390    /// on macOS/Windows — `lx.host.autostart` is not registered elsewhere — so
391    /// the default is an error, not a no-op: a false answer here would be a lie.
392    fn autostart_is_enabled(&self) -> Result<bool, PlatformError> {
393        Err(PlatformError::NotSupported("autostart".to_string()))
394    }
395
396    /// Register or unregister the app as a per-user startup item.
397    fn autostart_set_enabled(&self, _enabled: bool) -> Result<(), PlatformError> {
398        Err(PlatformError::NotSupported("autostart".to_string()))
399    }
400
401    /// Current OS permission without prompting: `"granted"`, `"denied"`, or
402    /// `"default"` (not yet asked).
403    fn notification_permission(&self) -> Result<String, PlatformError> {
404        Err(PlatformError::NotSupported("notification".to_string()))
405    }
406
407    /// Prompt where the OS has a prompt. `"granted"` or `"denied"`; an
408    /// unanswered prompt is an error, never a status.
409    fn notification_request_permission(&self) -> Result<String, PlatformError> {
410        Err(PlatformError::NotSupported("notification".to_string()))
411    }
412
413    /// Upsert a local notification: anything pending or delivered under `id`
414    /// is replaced first, on every path. `deliver_at_ms` is epoch
415    /// milliseconds; `None` or a time that is not in the future means now.
416    ///
417    /// A deployment shape that cannot hand `activation_token` back on tap
418    /// fails here instead of posting a notification whose tap loses its
419    /// target.
420    fn notification_show(
421        &self,
422        _request: &LocalNotificationShow,
423    ) -> Result<LocalNotificationStatus, PlatformError> {
424        Err(PlatformError::NotSupported("notification".to_string()))
425    }
426
427    fn notification_cancel(&self, _id: &str) -> Result<(), PlatformError> {
428        Err(PlatformError::NotSupported("notification".to_string()))
429    }
430
431    fn notification_cancel_all(&self) -> Result<(), PlatformError> {
432        Err(PlatformError::NotSupported("notification".to_string()))
433    }
434
435    /// Present a desktop banner and block until it resolves. Desktop only.
436    fn banner_show(
437        &self,
438        _request: &DesktopBannerShow,
439    ) -> Result<DesktopBannerOutcome, PlatformError> {
440        Err(PlatformError::NotSupported("banner".to_string()))
441    }
442
443    /// Dismiss a visible or queued banner. Unknown ids are fine.
444    fn banner_dismiss(&self, _id: &str) -> Result<(), PlatformError> {
445        Err(PlatformError::NotSupported("banner".to_string()))
446    }
447
448    /// Replace the tray dropdown menu. `items_json` is a JSON array of
449    /// `{ label?, separator?, enabled?, checked? }`. Item clicks are delivered
450    /// back to JS by index. Desktop only; no-op elsewhere.
451    fn set_tray_menu(&self, _items_json: &str) -> Result<(), PlatformError> {
452        Ok(())
453    }
454
455    /// Show or hide the tray status item itself. Desktop only; no-op elsewhere.
456    fn set_tray_visible(&self, _visible: bool) -> Result<(), PlatformError> {
457        Ok(())
458    }
459
460    /// When intercepting, a left-click on the tray is delivered only to JS
461    /// (`lx.tray.onClick`) and does not run the tray's configured surface action.
462    /// Desktop only; no-op elsewhere.
463    fn set_tray_click_intercept(&self, _intercept: bool) -> Result<(), PlatformError> {
464        Ok(())
465    }
466
467    /// Navigates within the given LxApp using an animation.
468    /// `webtag` is the destination page instance's full webview tag; page
469    /// tags are per-instance, so shells must not reconstruct them from the
470    /// route. Platforms whose containers resolve through the runtime by path
471    /// may ignore it.
472    fn navigate(
473        &self,
474        appid: String,
475        path: String,
476        webtag: String,
477        animation_type: AnimationType,
478    ) -> Result<(), PlatformError>;
479
480    /// Opens the given URL according to the host policy for the requested target.
481    fn open_url(&self, req: OpenUrlRequest) -> Result<OpenUrlResult, PlatformError>;
482
483    /// Close a tab previously named by [`Self::open_url`]. Platforms that
484    /// cannot name tabs leave this unimplemented — the JS handle then reports
485    /// `scope: 'group'` and never calls it.
486    fn close_browser_tab(&self, _tab_id: &str) -> Result<(), PlatformError> {
487        Err(PlatformError::NotSupported("browser tab".to_string()))
488    }
489
490    /// Bring a tab previously named by [`Self::open_url`] to the front.
491    fn activate_browser_tab(&self, _tab_id: String) -> PlatformFuture {
492        Box::pin(async { Err(PlatformError::NotSupported("browser tab".to_string())) })
493    }
494
495    fn open_builtin_browser_page(&self, _page: BuiltinBrowserPage) -> Result<(), PlatformError> {
496        Err(PlatformError::NotSupported(
497            "built-in browser pages".to_string(),
498        ))
499    }
500}
501
502#[cfg(test)]
503mod envelope_tests {
504    use super::{ACTIVATION_ENVELOPE, unwrap_activation, wrap_activation};
505
506    #[test]
507    fn the_envelope_round_trips_and_rejects_anything_else() {
508        assert_eq!(unwrap_activation(&wrap_activation("abc")), Some("abc"));
509        assert_eq!(unwrap_activation("https://example.com/x"), None);
510        assert_eq!(unwrap_activation(ACTIVATION_ENVELOPE), None);
511        assert_eq!(unwrap_activation(""), None);
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::OpenUrlTarget;
518
519    #[test]
520    fn parse_supports_new_browser_tab() {
521        assert_eq!(
522            OpenUrlTarget::parse(Some("new_browser_tab")),
523            OpenUrlTarget::NewBrowserTab
524        );
525    }
526
527    #[test]
528    fn parse_unknown_falls_back_to_external() {
529        assert_eq!(
530            OpenUrlTarget::parse(Some("foobar")),
531            OpenUrlTarget::External
532        );
533    }
534}