Skip to main content

fission_core/
platform.rs

1//! Platform services shared by Fission shells.
2//!
3//! Notifications are modelled as typed host capabilities. Deep links and
4//! notification responses are inbound lifecycle actions dispatched by shells.
5
6use crate::action::{Action, ActionId};
7use crate::capability::{CapabilityType, OperationCapability};
8use lazy_static::lazy_static;
9use serde::{Deserialize, Serialize};
10
11/// Stable identifier for a local or scheduled notification.
12#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub struct NotificationId(pub String);
14
15impl NotificationId {
16    /// Creates a stable notification id.
17    ///
18    /// `id` should be stable for the logical notification so future calls can
19    /// replace or cancel the same notification. Prefer product identifiers such
20    /// as `sync-complete` or `message-42` over random values when the app needs
21    /// deterministic replacement behavior.
22    pub fn new(id: impl Into<String>) -> Self {
23        Self(id.into())
24    }
25}
26
27/// Permission state reported by the host notification system.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
29pub enum NotificationPermission {
30    Granted,
31    Denied,
32    Provisional,
33    #[default]
34    NotDetermined,
35    Unsupported,
36}
37
38/// Request sent when an app asks the host to request notification permission.
39#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
40pub struct NotificationPermissionRequest {
41    pub alerts: bool,
42    pub badge: bool,
43    pub sound: bool,
44    pub provisional: bool,
45}
46
47/// Current notification settings known to the host.
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49pub struct NotificationSettings {
50    pub permission: NotificationPermission,
51    pub alerts: bool,
52    pub badge: bool,
53    pub sound: bool,
54    pub scheduling: bool,
55    pub push: bool,
56}
57
58impl Default for NotificationSettings {
59    fn default() -> Self {
60        Self {
61            permission: NotificationPermission::NotDetermined,
62            alerts: false,
63            badge: false,
64            sound: false,
65            scheduling: false,
66            push: false,
67        }
68    }
69}
70
71/// Portable notification error payload returned by hosts.
72#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
73pub struct NotificationError {
74    pub code: String,
75    pub message: String,
76}
77
78impl NotificationError {
79    /// Creates a portable notification error payload.
80    ///
81    /// `code` is the stable reason reducers and tests can match. `message` is a
82    /// human-readable explanation for logs, diagnostics, or a developer-facing
83    /// error surface.
84    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
85        Self {
86            code: code.into(),
87            message: message.into(),
88        }
89    }
90
91    /// Creates the standard unsupported notification error.
92    ///
93    /// `operation` should name the attempted notification operation, such as
94    /// `show`, `schedule`, `register_push`, or `set_badge_count`.
95    pub fn unsupported(operation: impl Into<String>) -> Self {
96        Self::new(
97            "unsupported",
98            format!(
99                "notification operation `{}` is not supported by this host",
100                operation.into()
101            ),
102        )
103    }
104}
105
106/// Notification sound policy.
107#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
108pub enum NotificationSound {
109    #[default]
110    Default,
111    Silent,
112    Named(String),
113}
114
115/// Portable scheduling request.
116#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
117pub enum NotificationSchedule {
118    #[default]
119    Immediate,
120    AtUnixMillis(u64),
121    AfterMillis(u64),
122}
123
124/// Action button exposed by a notification.
125#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
126pub struct NotificationActionButton {
127    pub id: String,
128    pub title: String,
129    pub destructive: bool,
130    pub foreground: bool,
131    pub text_input: bool,
132}
133
134/// Request to show or schedule a notification.
135#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
136pub struct NotificationRequest {
137    pub id: NotificationId,
138    pub title: String,
139    pub body: String,
140    pub subtitle: Option<String>,
141    pub badge: Option<u32>,
142    pub sound: NotificationSound,
143    pub deep_link: Option<String>,
144    pub actions: Vec<NotificationActionButton>,
145    pub schedule: NotificationSchedule,
146}
147
148/// Success payload for show/schedule notification operations.
149#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
150pub struct NotificationReceipt {
151    pub id: NotificationId,
152    pub scheduled: bool,
153    pub delivered: bool,
154}
155
156/// Request to cancel one notification.
157#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
158pub struct CancelNotificationRequest {
159    pub id: NotificationId,
160}
161
162/// Request to set or clear the app badge.
163#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
164pub struct SetBadgeCountRequest {
165    pub count: Option<u32>,
166}
167
168/// Request to register for remote/push notifications.
169#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
170pub struct PushRegistrationRequest {
171    pub app_server_key: Option<String>,
172    pub sender_id: Option<String>,
173    pub topics: Vec<String>,
174}
175
176/// Push provider used by a host registration.
177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
178pub enum PushPlatform {
179    Apple,
180    Android,
181    Web,
182    Windows,
183    Other(String),
184}
185
186/// Push registration returned by the host.
187#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
188pub struct PushRegistration {
189    pub platform: PushPlatform,
190    pub token: String,
191    pub endpoint: Option<String>,
192    pub p256dh_key: Option<String>,
193    pub auth_secret: Option<String>,
194}
195
196pub struct RequestNotificationPermissionCapability;
197impl OperationCapability for RequestNotificationPermissionCapability {
198    type Request = NotificationPermissionRequest;
199    type Ok = NotificationSettings;
200    type Err = NotificationError;
201}
202
203pub struct GetNotificationSettingsCapability;
204impl OperationCapability for GetNotificationSettingsCapability {
205    type Request = ();
206    type Ok = NotificationSettings;
207    type Err = NotificationError;
208}
209
210pub struct ShowNotificationCapability;
211impl OperationCapability for ShowNotificationCapability {
212    type Request = NotificationRequest;
213    type Ok = NotificationReceipt;
214    type Err = NotificationError;
215}
216
217pub struct ScheduleNotificationCapability;
218impl OperationCapability for ScheduleNotificationCapability {
219    type Request = NotificationRequest;
220    type Ok = NotificationReceipt;
221    type Err = NotificationError;
222}
223
224pub struct CancelNotificationCapability;
225impl OperationCapability for CancelNotificationCapability {
226    type Request = CancelNotificationRequest;
227    type Ok = ();
228    type Err = NotificationError;
229}
230
231pub struct CancelAllNotificationsCapability;
232impl OperationCapability for CancelAllNotificationsCapability {
233    type Request = ();
234    type Ok = ();
235    type Err = NotificationError;
236}
237
238pub struct SetBadgeCountCapability;
239impl OperationCapability for SetBadgeCountCapability {
240    type Request = SetBadgeCountRequest;
241    type Ok = ();
242    type Err = NotificationError;
243}
244
245pub struct RegisterPushNotificationsCapability;
246impl OperationCapability for RegisterPushNotificationsCapability {
247    type Request = PushRegistrationRequest;
248    type Ok = PushRegistration;
249    type Err = NotificationError;
250}
251
252pub struct UnregisterPushNotificationsCapability;
253impl OperationCapability for UnregisterPushNotificationsCapability {
254    type Request = ();
255    type Ok = ();
256    type Err = NotificationError;
257}
258
259pub const REQUEST_NOTIFICATION_PERMISSION: CapabilityType<RequestNotificationPermissionCapability> =
260    CapabilityType::new("fission.notifications.request_permission");
261pub const GET_NOTIFICATION_SETTINGS: CapabilityType<GetNotificationSettingsCapability> =
262    CapabilityType::new("fission.notifications.get_settings");
263pub const SHOW_NOTIFICATION: CapabilityType<ShowNotificationCapability> =
264    CapabilityType::new("fission.notifications.show");
265pub const SCHEDULE_NOTIFICATION: CapabilityType<ScheduleNotificationCapability> =
266    CapabilityType::new("fission.notifications.schedule");
267pub const CANCEL_NOTIFICATION: CapabilityType<CancelNotificationCapability> =
268    CapabilityType::new("fission.notifications.cancel");
269pub const CANCEL_ALL_NOTIFICATIONS: CapabilityType<CancelAllNotificationsCapability> =
270    CapabilityType::new("fission.notifications.cancel_all");
271pub const SET_BADGE_COUNT: CapabilityType<SetBadgeCountCapability> =
272    CapabilityType::new("fission.notifications.set_badge_count");
273pub const REGISTER_PUSH_NOTIFICATIONS: CapabilityType<RegisterPushNotificationsCapability> =
274    CapabilityType::new("fission.notifications.register_push");
275pub const UNREGISTER_PUSH_NOTIFICATIONS: CapabilityType<UnregisterPushNotificationsCapability> =
276    CapabilityType::new("fission.notifications.unregister_push");
277
278/// Source that delivered an inbound deep link.
279#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
280pub enum DeepLinkSource {
281    CustomScheme,
282    UniversalLink,
283    AppLink,
284    WebUrl,
285    Notification,
286    External,
287    Unknown,
288}
289
290/// Inbound URL delivered by the host shell.
291#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
292pub struct DeepLink {
293    pub url: String,
294    pub cold_start: bool,
295    pub source: DeepLinkSource,
296}
297
298impl DeepLink {
299    /// Creates an inbound deep link with an unknown source.
300    ///
301    /// `url` is stored exactly as delivered by the host. Use `source` and
302    /// `cold_start` to add shell context when the link source and startup state
303    /// are known.
304    pub fn new(url: impl Into<String>) -> Self {
305        Self {
306            url: url.into(),
307            cold_start: false,
308            source: DeepLinkSource::Unknown,
309        }
310    }
311
312    /// Records whether this link launched the app from a stopped state.
313    ///
314    /// Use `true` when the link was delivered during app startup. Reducers can
315    /// use this to decide whether to replace the initial route or treat the link
316    /// as an in-session navigation request.
317    pub fn cold_start(mut self, cold_start: bool) -> Self {
318        self.cold_start = cold_start;
319        self
320    }
321
322    /// Records how the host classified the inbound link.
323    ///
324    /// The source helps reducers and analytics distinguish custom schemes,
325    /// universal links, app links, web URLs, notification taps, and external
326    /// handoff paths without reparsing platform-specific launch data.
327    pub fn source(mut self, source: DeepLinkSource) -> Self {
328        self.source = source;
329        self
330    }
331}
332
333/// Declarative runtime filter for inbound deep links.
334#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
335pub struct DeepLinkConfig {
336    pub schemes: Vec<String>,
337    pub domains: Vec<String>,
338    pub path_prefixes: Vec<String>,
339}
340
341impl DeepLinkConfig {
342    /// Creates an empty deep-link configuration.
343    ///
344    /// Add schemes, domains, and optional path prefixes before installing it in a
345    /// shell. An empty config intentionally matches no URLs.
346    pub fn new() -> Self {
347        Self::default()
348    }
349
350    /// Allows a custom URL scheme.
351    ///
352    /// `scheme` is normalized by trimming whitespace, removing a trailing colon,
353    /// and lowercasing. Use this for routes such as `myapp://item/123`.
354    pub fn scheme(mut self, scheme: impl Into<String>) -> Self {
355        self.schemes.push(normalize_scheme(scheme.into()));
356        self
357    }
358
359    /// Allows an HTTP or HTTPS domain.
360    ///
361    /// `domain` is normalized by trimming whitespace, removing a trailing dot,
362    /// and lowercasing. Use this for app links, universal links, and web routes
363    /// that should enter the Fission app.
364    pub fn domain(mut self, domain: impl Into<String>) -> Self {
365        self.domains.push(normalize_domain(domain.into()));
366        self
367    }
368
369    /// Restricts accepted links to a path prefix.
370    ///
371    /// Prefixes are normalized to start with `/`. Add one or more prefixes when
372    /// only part of a domain should route into the app, such as `/invite` or
373    /// `/checkout`.
374    pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
375        self.path_prefixes
376            .push(normalize_path_prefix(prefix.into()));
377        self
378    }
379
380    /// Returns `true` when no scheme, domain, or path rule has been configured.
381    ///
382    /// Empty configs match no URLs. This prevents a shell from accidentally
383    /// accepting every external URL before the app has declared its routes.
384    pub fn is_empty(&self) -> bool {
385        self.schemes.is_empty() && self.domains.is_empty() && self.path_prefixes.is_empty()
386    }
387
388    /// Returns whether a URL is accepted by this deep-link configuration.
389    ///
390    /// `url` is parsed as a simple absolute URL. The URL matches when its scheme
391    /// or domain is allowed and, if path prefixes were configured, its path starts
392    /// with one of those prefixes.
393    pub fn matches(&self, url: &str) -> bool {
394        if self.is_empty() {
395            return false;
396        }
397        let Some(parts) = ParsedUrl::parse(url) else {
398            return false;
399        };
400        let scheme_matches = self
401            .schemes
402            .iter()
403            .any(|scheme| scheme == &normalize_scheme(parts.scheme));
404        let domain_matches = parts.host.as_deref().is_some_and(|host| {
405            let host = normalize_domain(host.to_string());
406            self.domains.iter().any(|domain| domain == &host)
407        });
408        let path_matches = self.path_prefixes.is_empty()
409            || self
410                .path_prefixes
411                .iter()
412                .any(|prefix| parts.path.starts_with(prefix));
413
414        (scheme_matches || domain_matches) && path_matches
415    }
416
417    /// Classifies a URL according to this configuration.
418    ///
419    /// Use this in shell code when creating `DeepLinkReceived` actions. The
420    /// result distinguishes configured custom schemes and associated domains from
421    /// ordinary web URLs or external URLs.
422    pub fn source_for(&self, url: &str) -> DeepLinkSource {
423        let Some(parts) = ParsedUrl::parse(url) else {
424            return DeepLinkSource::Unknown;
425        };
426        if parts.scheme == "http" || parts.scheme == "https" {
427            if parts.host.as_deref().is_some_and(|host| {
428                let host = normalize_domain(host.to_string());
429                self.domains.iter().any(|domain| domain == &host)
430            }) {
431                return DeepLinkSource::UniversalLink;
432            }
433            return DeepLinkSource::WebUrl;
434        }
435        if self
436            .schemes
437            .iter()
438            .any(|scheme| scheme == &normalize_scheme(parts.scheme))
439        {
440            DeepLinkSource::CustomScheme
441        } else {
442            DeepLinkSource::External
443        }
444    }
445}
446
447#[derive(Debug)]
448struct ParsedUrl<'a> {
449    scheme: &'a str,
450    host: Option<&'a str>,
451    path: String,
452}
453
454impl<'a> ParsedUrl<'a> {
455    fn parse(url: &'a str) -> Option<Self> {
456        let (scheme, rest) = url.split_once(':')?;
457        if scheme.is_empty() {
458            return None;
459        }
460        let mut host = None;
461        let mut path = String::from("/");
462        if let Some(authority_and_path) = rest.strip_prefix("//") {
463            let authority_end = authority_and_path
464                .find(['/', '?', '#'])
465                .unwrap_or(authority_and_path.len());
466            let authority = &authority_and_path[..authority_end];
467            if !authority.is_empty() {
468                let host_without_userinfo = authority.rsplit('@').next().unwrap_or(authority);
469                let host_without_port = host_without_userinfo
470                    .split_once(':')
471                    .map(|(h, _)| h)
472                    .unwrap_or(host_without_userinfo);
473                if !host_without_port.is_empty() {
474                    host = Some(host_without_port);
475                }
476            }
477            let remainder = &authority_and_path[authority_end..];
478            if remainder.starts_with('/') {
479                path = remainder
480                    .split(['?', '#'])
481                    .next()
482                    .unwrap_or("/")
483                    .to_string();
484            }
485        } else if rest.starts_with('/') {
486            path = rest.split(['?', '#']).next().unwrap_or("/").to_string();
487        }
488        Some(Self { scheme, host, path })
489    }
490}
491
492fn normalize_scheme(value: impl AsRef<str>) -> String {
493    value
494        .as_ref()
495        .trim()
496        .trim_end_matches(':')
497        .to_ascii_lowercase()
498}
499
500fn normalize_domain(value: impl AsRef<str>) -> String {
501    value
502        .as_ref()
503        .trim()
504        .trim_end_matches('.')
505        .to_ascii_lowercase()
506}
507
508fn normalize_path_prefix(value: impl AsRef<str>) -> String {
509    let value = value.as_ref().trim();
510    if value.is_empty() || value == "/" {
511        "/".to_string()
512    } else if value.starts_with('/') {
513        value.to_string()
514    } else {
515        format!("/{value}")
516    }
517}
518
519/// Built-in action dispatched by shells when a deep link is received.
520#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
521pub struct DeepLinkReceived {
522    pub link: DeepLink,
523}
524
525impl Action for DeepLinkReceived {
526    fn static_id() -> ActionId {
527        lazy_static! {
528            static ref ID: ActionId = ActionId::from_name("fission_core::DeepLinkReceived");
529        }
530        *ID
531    }
532}
533
534/// Response from the OS/browser after a user interacted with a notification.
535#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
536pub struct NotificationResponse {
537    pub notification_id: NotificationId,
538    pub action_id: Option<String>,
539    pub deep_link: Option<String>,
540    pub user_text: Option<String>,
541}
542
543/// Built-in action dispatched by shells when a notification response arrives.
544#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
545pub struct NotificationResponseReceived {
546    pub response: NotificationResponse,
547}
548
549impl Action for NotificationResponseReceived {
550    fn static_id() -> ActionId {
551        lazy_static! {
552            static ref ID: ActionId =
553                ActionId::from_name("fission_core::NotificationResponseReceived");
554        }
555        *ID
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn notification_request_round_trips() {
565        let request = NotificationRequest {
566            id: NotificationId::new("sync"),
567            title: "Sync complete".into(),
568            body: "All files are up to date".into(),
569            subtitle: Some("Workspace".into()),
570            badge: Some(2),
571            sound: NotificationSound::Named("done".into()),
572            deep_link: Some("fission://sync/results".into()),
573            actions: vec![NotificationActionButton {
574                id: "open".into(),
575                title: "Open".into(),
576                foreground: true,
577                ..Default::default()
578            }],
579            schedule: NotificationSchedule::AfterMillis(500),
580        };
581
582        let bytes = serde_json::to_vec(&request).unwrap();
583        let decoded: NotificationRequest = serde_json::from_slice(&bytes).unwrap();
584        assert_eq!(decoded, request);
585    }
586
587    #[test]
588    fn deep_link_config_matches_schemes_domains_and_paths() {
589        let config = DeepLinkConfig::new()
590            .scheme("Fission")
591            .domain("Example.COM")
592            .path_prefix("/tasks");
593
594        assert!(config.matches("fission://open/tasks/42"));
595        assert!(config.matches("https://example.com/tasks/42?from=email"));
596        assert!(!config.matches("https://example.com/projects/42"));
597        assert!(!config.matches("other://open/tasks/42"));
598    }
599
600    #[test]
601    fn built_in_actions_round_trip() {
602        let link = DeepLinkReceived {
603            link: DeepLink::new("fission://task/1")
604                .cold_start(true)
605                .source(DeepLinkSource::CustomScheme),
606        };
607        let envelope: crate::ActionEnvelope = link.clone().into();
608        assert_eq!(envelope.id, DeepLinkReceived::static_id());
609        assert_eq!(
610            serde_json::from_slice::<DeepLinkReceived>(&envelope.payload).unwrap(),
611            link
612        );
613
614        let response = NotificationResponseReceived {
615            response: NotificationResponse {
616                notification_id: NotificationId::new("task"),
617                action_id: Some("open".into()),
618                deep_link: Some("fission://task/1".into()),
619                user_text: None,
620            },
621        };
622        let envelope: crate::ActionEnvelope = response.clone().into();
623        assert_eq!(envelope.id, NotificationResponseReceived::static_id());
624        assert_eq!(
625            serde_json::from_slice::<NotificationResponseReceived>(&envelope.payload).unwrap(),
626            response
627        );
628    }
629}