1use crate::action::{Action, ActionId};
7use crate::capability::{CapabilityType, OperationCapability};
8use lazy_static::lazy_static;
9use serde::{Deserialize, Serialize};
10
11#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub struct NotificationId(pub String);
14
15impl NotificationId {
16 pub fn new(id: impl Into<String>) -> Self {
23 Self(id.into())
24 }
25}
26
27#[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#[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#[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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
73pub struct NotificationError {
74 pub code: String,
75 pub message: String,
76}
77
78impl NotificationError {
79 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 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
108pub enum NotificationSound {
109 #[default]
110 Default,
111 Silent,
112 Named(String),
113}
114
115#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
117pub enum NotificationSchedule {
118 #[default]
119 Immediate,
120 AtUnixMillis(u64),
121 AfterMillis(u64),
122}
123
124#[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#[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#[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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
158pub struct CancelNotificationRequest {
159 pub id: NotificationId,
160}
161
162#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
164pub struct SetBadgeCountRequest {
165 pub count: Option<u32>,
166}
167
168#[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
178pub enum PushPlatform {
179 Apple,
180 Android,
181 Web,
182 Windows,
183 Other(String),
184}
185
186#[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#[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#[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 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 pub fn cold_start(mut self, cold_start: bool) -> Self {
318 self.cold_start = cold_start;
319 self
320 }
321
322 pub fn source(mut self, source: DeepLinkSource) -> Self {
328 self.source = source;
329 self
330 }
331}
332
333#[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 pub fn new() -> Self {
347 Self::default()
348 }
349
350 pub fn scheme(mut self, scheme: impl Into<String>) -> Self {
355 self.schemes.push(normalize_scheme(scheme.into()));
356 self
357 }
358
359 pub fn domain(mut self, domain: impl Into<String>) -> Self {
365 self.domains.push(normalize_domain(domain.into()));
366 self
367 }
368
369 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 pub fn is_empty(&self) -> bool {
385 self.schemes.is_empty() && self.domains.is_empty() && self.path_prefixes.is_empty()
386 }
387
388 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 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#[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#[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#[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}