1mod actions;
2mod core;
3pub mod desktop_nearby;
4pub mod image_proxy;
5pub mod local_relay;
6pub mod perflog;
7mod qr;
8mod state;
9mod updates;
10
11use std::any::Any;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::{Arc, RwLock};
14use std::thread;
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16use std::{panic, panic::AssertUnwindSafe};
17
18use flume::{Receiver, Sender};
19
20pub use actions::AppAction;
21pub use qr::*;
22pub use state::*;
23pub use updates::*;
24
25use crate::core::AppCore;
26
27uniffi::setup_scaffolding!();
28
29#[uniffi::export(callback_interface)]
30pub trait AppReconciler: Send + Sync + 'static {
31 fn reconcile(&self, update: AppUpdate);
32}
33
34#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
35pub struct DesktopNearbyPeerSnapshot {
36 pub id: String,
37 pub name: String,
38 pub owner_pubkey_hex: Option<String>,
39 pub picture_url: Option<String>,
40 pub profile_event_id: Option<String>,
41 pub last_seen_secs: u64,
42}
43
44#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
45pub struct DesktopNearbySnapshot {
46 pub visible: bool,
47 pub status: String,
48 pub peers: Vec<DesktopNearbyPeerSnapshot>,
49}
50
51#[uniffi::export(callback_interface)]
52pub trait DesktopNearbyObserver: Send + Sync + 'static {
53 fn desktop_nearby_changed(&self, snapshot: DesktopNearbySnapshot);
54}
55
56#[derive(uniffi::Object)]
57pub struct FfiApp {
58 core_tx: Sender<CoreMsg>,
59 update_rx: Receiver<AppUpdate>,
60 listening: AtomicBool,
61 shared_state: Arc<RwLock<AppState>>,
62}
63
64#[derive(uniffi::Object)]
65pub struct FfiDesktopNearby {
66 service: Arc<desktop_nearby::DesktopNearbyService>,
67}
68
69#[uniffi::export]
70impl FfiApp {
71 #[uniffi::constructor]
72 pub fn new(data_dir: String, _keychain_group: String, _app_version: String) -> Arc<Self> {
73 match panic::catch_unwind(AssertUnwindSafe(|| new_ffi_app_inner(data_dir))) {
74 Ok(app) => app,
75 Err(payload) => ffi_app_failure(format!(
76 "Iris could not start: {}",
77 panic_payload_to_string(payload)
78 )),
79 }
80 }
81
82 pub fn state(&self) -> AppState {
83 ffi_or("ffiapp.state", ffi_failure_state(), || {
84 match self.shared_state.read() {
85 Ok(slot) => slot.clone(),
86 Err(poison) => poison.into_inner().clone(),
87 }
88 })
89 }
90
91 pub fn dispatch(&self, action: AppAction) {
92 ffi_or("ffiapp.dispatch", (), || {
93 crate::perflog!("ffi.dispatch action={:?}", std::mem::discriminant(&action));
94 let _ = self.core_tx.send(CoreMsg::Action(action));
95 })
96 }
97
98 pub fn ingest_nearby_event_json(&self, event_json: String) -> bool {
99 self.ingest_nearby_event_json_with_transport(event_json, String::new())
100 }
101
102 pub fn ingest_nearby_event_json_with_transport(
103 &self,
104 event_json: String,
105 transport: String,
106 ) -> bool {
107 ffi_or("ffiapp.ingest_nearby_event_json", false, || {
108 let event = match serde_json::from_str::<nostr_sdk::prelude::Event>(&event_json) {
109 Ok(event) => event,
110 Err(_) => return false,
111 };
112 if event.verify().is_err() {
113 return false;
114 }
115 self.core_tx
116 .send(CoreMsg::Internal(Box::new(InternalEvent::NearbyEvent {
117 event,
118 transport,
119 })))
120 .is_ok()
121 })
122 }
123
124 pub fn build_nearby_presence_event_json(
125 &self,
126 peer_id: String,
127 my_nonce: String,
128 their_nonce: String,
129 profile_event_id: String,
130 ) -> String {
131 ffi_or(
132 "ffiapp.build_nearby_presence_event_json",
133 String::new(),
134 || {
135 let (reply_tx, reply_rx) = flume::bounded(1);
136 if self
137 .core_tx
138 .send(CoreMsg::BuildNearbyPresenceEvent {
139 peer_id,
140 my_nonce,
141 their_nonce,
142 profile_event_id,
143 reply_tx,
144 })
145 .is_err()
146 {
147 return String::new();
148 }
149 reply_rx
150 .recv_timeout(Duration::from_secs(2))
151 .unwrap_or_default()
152 },
153 )
154 }
155
156 pub fn verify_nearby_presence_event_json(
157 &self,
158 event_json: String,
159 peer_id: String,
160 my_nonce: String,
161 their_nonce: String,
162 ) -> String {
163 ffi_or(
164 "ffiapp.verify_nearby_presence_event_json",
165 String::new(),
166 || verify_nearby_presence_event_json(&event_json, &peer_id, &my_nonce, &their_nonce),
167 )
168 }
169
170 pub fn nearby_encode_frame(&self, envelope_json: String) -> Vec<u8> {
171 ffi_or("ffiapp.nearby_encode_frame", Vec::new(), || {
172 nostr_double_ratchet_runtime::encode_nearby_frame_json(&envelope_json)
173 .unwrap_or_default()
174 })
175 }
176
177 pub fn nearby_decode_frame(&self, frame: Vec<u8>) -> String {
178 ffi_or("ffiapp.nearby_decode_frame", String::new(), || {
179 nostr_double_ratchet_runtime::decode_nearby_frame_json(&frame).unwrap_or_default()
180 })
181 }
182
183 pub fn nearby_frame_body_len_from_header(&self, header: Vec<u8>) -> i32 {
184 ffi_or("ffiapp.nearby_frame_body_len_from_header", -1, || {
185 nostr_double_ratchet_runtime::nearby_frame_body_len_from_header(&header)
186 .and_then(|len| i32::try_from(len).ok())
187 .unwrap_or(-1)
188 })
189 }
190
191 pub fn export_support_bundle_json(&self) -> String {
192 ffi_or(
193 "ffiapp.export_support_bundle_json",
194 "{}".to_string(),
195 || {
196 let (reply_tx, reply_rx) = flume::bounded(1);
197 if self
198 .core_tx
199 .send(CoreMsg::ExportSupportBundle(reply_tx))
200 .is_err()
201 {
202 return "{}".to_string();
203 }
204 reply_rx
205 .recv_timeout(Duration::from_secs(2))
206 .unwrap_or_else(|_| "{}".to_string())
207 },
208 )
209 }
210
211 pub fn peer_profile_debug(&self, owner_input: String) -> Option<PeerProfileDebugSnapshot> {
212 ffi_or("ffiapp.peer_profile_debug", None, || {
213 let (reply_tx, reply_rx) = flume::bounded(1);
214 if self
215 .core_tx
216 .send(CoreMsg::PeerProfileDebug {
217 owner_input,
218 reply_tx,
219 })
220 .is_err()
221 {
222 return None;
223 }
224 reply_rx.recv_timeout(Duration::from_secs(2)).ok().flatten()
225 })
226 }
227
228 pub fn prepare_for_suspend(&self) {
229 ffi_or("ffiapp.prepare_for_suspend", (), || {
230 let (reply_tx, reply_rx) = flume::bounded(1);
231 if self
232 .core_tx
233 .send(CoreMsg::PrepareForSuspend(reply_tx))
234 .is_err()
235 {
236 return;
237 }
238 let _ = reply_rx.recv_timeout(Duration::from_secs(2));
239 })
240 }
241
242 pub fn shutdown(&self) {
243 ffi_or("ffiapp.shutdown", (), || {
244 let (reply_tx, reply_rx) = flume::bounded(1);
245 if self
246 .core_tx
247 .send(CoreMsg::Shutdown(Some(reply_tx)))
248 .is_err()
249 {
250 return;
251 }
252 let _ = reply_rx.recv_timeout(Duration::from_secs(2));
253 })
254 }
255
256 pub fn listen_for_updates(&self, reconciler: Box<dyn AppReconciler>) {
257 if self
258 .listening
259 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
260 .is_err()
261 {
262 return;
263 }
264
265 let update_rx = self.update_rx.clone();
266 let spawn_result = thread::Builder::new()
267 .name("iris-updates".to_string())
268 .spawn(move || {
269 while let Ok(first) = update_rx.recv() {
281 let mut latest_full_state: Option<AppUpdate> = None;
282 let mut sidecar: Vec<AppUpdate> = Vec::new();
283 let process =
284 |update: AppUpdate,
285 latest: &mut Option<AppUpdate>,
286 side: &mut Vec<AppUpdate>| match update {
287 full @ AppUpdate::FullState(_) => *latest = Some(full),
288 other => side.push(other),
289 };
290 process(first, &mut latest_full_state, &mut sidecar);
291 while let Ok(next) = update_rx.try_recv() {
292 process(next, &mut latest_full_state, &mut sidecar);
293 }
294 for update in sidecar.into_iter().chain(latest_full_state) {
295 let kind = match &update {
296 AppUpdate::FullState(_) => "FullState",
297 AppUpdate::PersistAccountBundle { .. } => "PersistAccountBundle",
298 AppUpdate::NearbyPublishedEvent { .. } => "NearbyPublishedEvent",
299 };
300 let t0 = crate::perflog::now_ms();
301 crate::perflog!("reconcile.start kind={kind}");
302 if panic::catch_unwind(AssertUnwindSafe(|| reconciler.reconcile(update)))
303 .is_err()
304 {
305 crate::perflog!("reconcile.failed kind={kind}");
306 continue;
307 }
308 crate::perflog!(
309 "reconcile.end kind={kind} elapsed_ms={}",
310 crate::perflog::now_ms().saturating_sub(t0)
311 );
312 }
313 }
314 });
315 if let Err(error) = spawn_result {
316 crate::perflog!("updates.spawn.failed error={error}");
317 self.listening.store(false, Ordering::SeqCst);
318 }
319 }
320}
321
322#[uniffi::export]
323impl FfiDesktopNearby {
324 #[uniffi::constructor]
325 pub fn new(app: Arc<FfiApp>, observer: Box<dyn DesktopNearbyObserver>) -> Arc<Self> {
326 Arc::new(Self {
327 service: desktop_nearby::DesktopNearbyService::new(app, observer.into()),
328 })
329 }
330
331 pub fn start(&self, local_name: String) {
332 self.service.start(local_name);
333 }
334
335 pub fn stop(&self) {
336 self.service.stop();
337 }
338
339 pub fn snapshot(&self) -> DesktopNearbySnapshot {
340 self.service.snapshot()
341 }
342
343 pub fn publish(&self, event_id: String, kind: u32, created_at_secs: u64, event_json: String) {
344 self.service
345 .publish(event_id, kind, created_at_secs, event_json);
346 }
347}
348
349fn new_ffi_app_inner(data_dir: String) -> Arc<FfiApp> {
350 let (update_tx, update_rx) = flume::unbounded();
351 let (core_tx, core_rx) = flume::unbounded();
352 let shared_state = Arc::new(RwLock::new(AppState::empty()));
353
354 let core_tx_for_thread = core_tx.clone();
355 let shared_for_thread = shared_state.clone();
356 let update_tx_for_error = update_tx.clone();
357 match AppCore::try_new(update_tx, core_tx_for_thread, data_dir, shared_for_thread) {
358 Ok(mut core) => {
359 let spawn_result =
360 thread::Builder::new()
361 .name("iris-core".to_string())
362 .spawn(move || {
363 while let Ok(first) = core_rx.recv() {
369 let mut batch = Vec::with_capacity(8);
370 batch.push(first);
371 while let Ok(next) = core_rx.try_recv() {
372 batch.push(next);
373 }
374 let batch_size = batch.len();
375 let t0 = crate::perflog::now_ms();
376 crate::perflog!("core.batch.start size={batch_size}");
377 match catch_core_batch(|| {
378 handle_core_batch_responsive(&mut core, batch)
379 }) {
380 Ok(true) => {}
381 Ok(false) => break,
382 Err(error) => {
383 core.mark_core_panic(error);
384 break;
385 }
386 }
387 crate::perflog!(
388 "core.batch.end size={batch_size} elapsed_ms={}",
389 crate::perflog::now_ms().saturating_sub(t0)
390 );
391 }
392 });
393 if let Err(error) = spawn_result {
394 let mut state = AppState::empty();
395 state.toast = Some(format!("Iris could not start: {error}"));
396 state.rev = 1;
397 match shared_state.write() {
398 Ok(mut slot) => *slot = state.clone(),
399 Err(poison) => *poison.into_inner() = state.clone(),
400 }
401 let _ = update_tx_for_error.send(AppUpdate::FullState(state));
402 }
403 }
404 Err(error) => {
405 let mut state = AppState::empty();
406 state.toast = Some(error.to_string());
407 state.rev = 1;
408 match shared_state.write() {
409 Ok(mut slot) => *slot = state.clone(),
410 Err(poison) => *poison.into_inner() = state.clone(),
411 }
412 let _ = update_tx_for_error.send(AppUpdate::FullState(state));
413 }
414 }
415
416 Arc::new(FfiApp {
417 core_tx,
418 update_rx,
419 listening: AtomicBool::new(false),
420 shared_state,
421 })
422}
423
424fn ffi_app_failure(message: String) -> Arc<FfiApp> {
425 let (_update_tx, update_rx) = flume::unbounded();
426 let (core_tx, _core_rx) = flume::unbounded();
427 let mut state = AppState::empty();
428 state.toast = Some(message);
429 state.rev = 1;
430 let shared_state = Arc::new(RwLock::new(state));
431 Arc::new(FfiApp {
432 core_tx,
433 update_rx,
434 listening: AtomicBool::new(false),
435 shared_state,
436 })
437}
438
439fn handle_core_batch_responsive(core: &mut AppCore, messages: Vec<CoreMsg>) -> bool {
440 if messages.len() <= 1 || !messages.iter().any(is_foreground_core_msg) {
441 return core.handle_messages(messages);
442 }
443
444 let mut foreground = Vec::new();
445 let mut background = Vec::new();
446 for message in messages {
447 if is_foreground_core_msg(&message) {
448 foreground.push(message);
449 } else {
450 background.push(message);
451 }
452 }
453
454 for message in foreground {
455 if !core.handle_message(message) {
456 return false;
457 }
458 }
459 background.is_empty() || core.handle_messages(background)
460}
461
462fn catch_core_batch<F>(f: F) -> Result<bool, String>
463where
464 F: FnOnce() -> bool,
465{
466 panic::catch_unwind(AssertUnwindSafe(f)).map_err(panic_payload_to_string)
467}
468
469fn ffi_or<T, F>(label: &'static str, fallback: T, f: F) -> T
470where
471 F: FnOnce() -> T,
472{
473 match panic::catch_unwind(AssertUnwindSafe(f)) {
474 Ok(value) => value,
475 Err(payload) => {
476 crate::perflog!(
477 "ffi.panic label={label} detail={}",
478 panic_payload_to_string(payload)
479 );
480 fallback
481 }
482 }
483}
484
485fn ffi_failure_state() -> AppState {
486 let mut state = AppState::empty();
487 state.toast = Some("Iris needs restart. Copy support bundle in Settings.".to_string());
488 state
489}
490
491fn suppressed_mobile_push_resolution() -> MobilePushNotificationResolution {
492 MobilePushNotificationResolution {
493 should_show: false,
494 title: String::new(),
495 body: String::new(),
496 payload_json: "{}".to_string(),
497 }
498}
499
500fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
501 if let Some(message) = payload.downcast_ref::<&str>() {
502 (*message).to_string()
503 } else if let Some(message) = payload.downcast_ref::<String>() {
504 message.clone()
505 } else {
506 "unknown panic".to_string()
507 }
508}
509
510fn is_foreground_core_msg(message: &CoreMsg) -> bool {
511 !matches!(message, CoreMsg::Internal(_))
512}
513
514fn verify_nearby_presence_event_json(
515 event_json: &str,
516 peer_id: &str,
517 my_nonce: &str,
518 their_nonce: &str,
519) -> String {
520 let Ok(event) = serde_json::from_str::<nostr_sdk::prelude::Event>(event_json) else {
521 return String::new();
522 };
523 if event.verify().is_err() || event.kind.as_u16() != crate::core::NEARBY_PRESENCE_KIND {
524 return String::new();
525 }
526 let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
527 return String::new();
528 };
529 let get = |key: &str| {
530 content
531 .get(key)
532 .and_then(|value| value.as_str())
533 .unwrap_or("")
534 };
535 let transport = get("transport");
536 if get("protocol") != "iris-nearby-v1"
537 || !(transport == "ble" || transport == "nearby" || transport == "lan")
538 || get("peer_id") != peer_id.trim()
539 || get("my_nonce") != their_nonce.trim()
540 || get("their_nonce") != my_nonce.trim()
541 {
542 return String::new();
543 }
544
545 let now = SystemTime::now()
546 .duration_since(UNIX_EPOCH)
547 .unwrap_or_default()
548 .as_secs();
549 let expires_at = content
550 .get("expires_at")
551 .and_then(|value| value.as_u64())
552 .unwrap_or(0);
553 let created_at = event.created_at.as_secs();
554 if expires_at < now
555 || expires_at > now.saturating_add(300)
556 || created_at.saturating_add(300) < now
557 || created_at > now.saturating_add(300)
558 {
559 return String::new();
560 }
561
562 let profile_event_id = get("profile_event_id");
563 let profile_event_id = if profile_event_id.len() == 64 {
564 profile_event_id
565 } else {
566 ""
567 };
568 serde_json::json!({
569 "owner_pubkey_hex": event.pubkey.to_hex(),
570 "profile_event_id": profile_event_id,
571 })
572 .to_string()
573}
574
575impl Drop for FfiApp {
576 fn drop(&mut self) {
577 let _ = self.core_tx.send(CoreMsg::Shutdown(None));
578 }
579}
580
581#[uniffi::export]
582pub fn normalize_peer_input(input: String) -> String {
583 ffi_or("normalize_peer_input", String::new(), || {
584 crate::core::normalize_peer_input_for_display(&input)
585 })
586}
587
588#[uniffi::export]
589pub fn is_valid_peer_input(input: String) -> bool {
590 ffi_or("is_valid_peer_input", false, || {
591 crate::core::parse_peer_input(&input).is_ok()
592 })
593}
594
595#[uniffi::export]
600pub fn peer_input_to_hex(input: String) -> String {
601 ffi_or("peer_input_to_hex", String::new(), || {
602 let normalized = crate::core::normalize_peer_input_for_display(&input);
603 match nostr::PublicKey::parse(&normalized) {
604 Ok(pubkey) => pubkey.to_hex(),
605 Err(_) => String::new(),
606 }
607 })
608}
609
610#[uniffi::export]
613pub fn peer_input_to_npub(input: String) -> String {
614 ffi_or("peer_input_to_npub", String::new(), || {
615 use nostr::nips::nip19::ToBech32;
616 let normalized = crate::core::normalize_peer_input_for_display(&input);
617 match nostr::PublicKey::parse(&normalized) {
618 Ok(pubkey) => pubkey.to_bech32().unwrap_or(normalized),
619 Err(_) => normalized,
620 }
621 })
622}
623
624#[uniffi::export]
625pub fn build_summary() -> String {
626 ffi_or("build_summary", String::new(), crate::core::build_summary)
627}
628
629#[uniffi::export]
630pub fn relay_set_id() -> String {
631 ffi_or("relay_set_id", String::new(), || {
632 crate::core::relay_set_id().to_string()
633 })
634}
635
636#[uniffi::export]
637pub fn proxied_image_url(
638 original_src: String,
639 preferences: PreferencesSnapshot,
640 width: Option<u32>,
641 height: Option<u32>,
642 square: bool,
643) -> String {
644 ffi_or("proxied_image_url", original_src.clone(), || {
645 image_proxy::proxied_image_url(&original_src, &preferences, width, height, square)
646 })
647}
648
649#[uniffi::export]
650pub fn is_trusted_test_build() -> bool {
651 ffi_or(
652 "is_trusted_test_build",
653 false,
654 crate::core::trusted_test_build_flag,
655 )
656}
657
658#[uniffi::export]
663pub fn app_version() -> String {
664 crate::core::app_version_string().to_string()
665}
666
667#[uniffi::export]
668pub fn resolve_mobile_push_notification_payload(
669 raw_payload_json: String,
670) -> MobilePushNotificationResolution {
671 ffi_or(
672 "resolve_mobile_push_notification_payload",
673 suppressed_mobile_push_resolution(),
674 || crate::core::resolve_mobile_push_notification(raw_payload_json),
675 )
676}
677
678#[uniffi::export]
684pub fn decrypt_mobile_push_notification_payload(
685 data_dir: String,
686 owner_pubkey_hex: String,
687 device_nsec: String,
688 raw_payload_json: String,
689) -> MobilePushNotificationResolution {
690 ffi_or(
691 "decrypt_mobile_push_notification_payload",
692 suppressed_mobile_push_resolution(),
693 || {
694 crate::core::decrypt_mobile_push_notification(
695 data_dir,
696 owner_pubkey_hex,
697 device_nsec,
698 raw_payload_json,
699 )
700 },
701 )
702}
703
704#[uniffi::export]
705pub fn resolve_mobile_push_subscription_server_url(
706 platform_key: String,
707 is_release: bool,
708 override_url: Option<String>,
709) -> String {
710 ffi_or(
711 "resolve_mobile_push_subscription_server_url",
712 String::new(),
713 || crate::core::resolve_mobile_push_server_url(platform_key, is_release, override_url),
714 )
715}
716
717#[uniffi::export]
718pub fn mobile_push_subscription_id_key(platform_key: String) -> String {
719 ffi_or("mobile_push_subscription_id_key", String::new(), || {
720 crate::core::mobile_push_stored_subscription_id_key(platform_key)
721 })
722}
723
724#[uniffi::export]
725pub fn build_mobile_push_list_subscriptions_request(
726 owner_nsec: String,
727 platform_key: String,
728 is_release: bool,
729 server_url_override: Option<String>,
730) -> Option<MobilePushSubscriptionRequest> {
731 ffi_or("build_mobile_push_list_subscriptions_request", None, || {
732 crate::core::build_mobile_push_list_subscriptions_request(
733 owner_nsec,
734 platform_key,
735 is_release,
736 server_url_override,
737 )
738 })
739}
740
741#[uniffi::export]
742#[allow(clippy::too_many_arguments)]
743pub fn build_mobile_push_create_subscription_request(
744 owner_nsec: String,
745 platform_key: String,
746 push_token: String,
747 apns_topic: Option<String>,
748 message_author_pubkeys: Vec<String>,
749 invite_response_pubkeys: Vec<String>,
750 is_release: bool,
751 server_url_override: Option<String>,
752) -> Option<MobilePushSubscriptionRequest> {
753 ffi_or(
754 "build_mobile_push_create_subscription_request",
755 None,
756 || {
757 crate::core::build_mobile_push_create_subscription_request(
758 owner_nsec,
759 platform_key,
760 push_token,
761 apns_topic,
762 message_author_pubkeys,
763 invite_response_pubkeys,
764 is_release,
765 server_url_override,
766 )
767 },
768 )
769}
770
771#[uniffi::export]
772#[allow(clippy::too_many_arguments)]
773pub fn build_mobile_push_update_subscription_request(
774 owner_nsec: String,
775 subscription_id: String,
776 platform_key: String,
777 push_token: String,
778 apns_topic: Option<String>,
779 message_author_pubkeys: Vec<String>,
780 invite_response_pubkeys: Vec<String>,
781 is_release: bool,
782 server_url_override: Option<String>,
783) -> Option<MobilePushSubscriptionRequest> {
784 ffi_or(
785 "build_mobile_push_update_subscription_request",
786 None,
787 || {
788 crate::core::build_mobile_push_update_subscription_request(
789 owner_nsec,
790 subscription_id,
791 platform_key,
792 push_token,
793 apns_topic,
794 message_author_pubkeys,
795 invite_response_pubkeys,
796 is_release,
797 server_url_override,
798 )
799 },
800 )
801}
802
803#[uniffi::export]
804pub fn build_mobile_push_delete_subscription_request(
805 owner_nsec: String,
806 subscription_id: String,
807 platform_key: String,
808 is_release: bool,
809 server_url_override: Option<String>,
810) -> Option<MobilePushSubscriptionRequest> {
811 ffi_or(
812 "build_mobile_push_delete_subscription_request",
813 None,
814 || {
815 crate::core::build_mobile_push_delete_subscription_request(
816 owner_nsec,
817 subscription_id,
818 platform_key,
819 is_release,
820 server_url_override,
821 )
822 },
823 )
824}
825
826#[cfg(test)]
827mod ffi_hardening_tests {
828 use super::*;
829
830 #[test]
831 fn ffi_guard_returns_fallback_after_panic() {
832 let value = ffi_or("test.panic", 42, || -> i32 {
833 panic!("ffi boom");
834 });
835
836 assert_eq!(value, 42);
837 }
838
839 #[test]
840 fn core_batch_guard_converts_panic_to_error() {
841 let result = catch_core_batch(|| -> bool {
842 panic!("batch boom");
843 });
844
845 assert_eq!(result, Err("batch boom".to_string()));
846 }
847
848 #[test]
849 fn core_batch_guard_preserves_success_result() {
850 assert_eq!(catch_core_batch(|| true), Ok(true));
851 assert_eq!(catch_core_batch(|| false), Ok(false));
852 }
853}