Skip to main content

iris_chat_core/
lib.rs

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        let (update_tx, update_rx) = flume::unbounded();
74        let (core_tx, core_rx) = flume::unbounded();
75        let shared_state = Arc::new(RwLock::new(AppState::empty()));
76
77        let core_tx_for_thread = core_tx.clone();
78        let shared_for_thread = shared_state.clone();
79        let update_tx_for_error = update_tx.clone();
80        match AppCore::try_new(update_tx, core_tx_for_thread, data_dir, shared_for_thread) {
81            Ok(mut core) => {
82                let spawn_result =
83                    thread::Builder::new()
84                        .name("iris-core".to_string())
85                        .spawn(move || {
86                            // Drain whatever is already queued and process it as one batch so
87                            // a flurry of relay events + user actions produces a single UI
88                            // update instead of N. Without this, tapping a chat while a
89                            // relay backlog drains can take seconds because OpenChat sits
90                            // behind every queued event and the UI recomposes between each.
91                            while let Ok(first) = core_rx.recv() {
92                                let mut batch = Vec::with_capacity(8);
93                                batch.push(first);
94                                while let Ok(next) = core_rx.try_recv() {
95                                    batch.push(next);
96                                }
97                                let batch_size = batch.len();
98                                let t0 = crate::perflog::now_ms();
99                                crate::perflog!("core.batch.start size={batch_size}");
100                                match catch_core_batch(|| {
101                                    handle_core_batch_responsive(&mut core, batch)
102                                }) {
103                                    Ok(true) => {}
104                                    Ok(false) => break,
105                                    Err(error) => {
106                                        core.mark_core_panic(error);
107                                        break;
108                                    }
109                                }
110                                crate::perflog!(
111                                    "core.batch.end size={batch_size} elapsed_ms={}",
112                                    crate::perflog::now_ms().saturating_sub(t0)
113                                );
114                            }
115                        });
116                if let Err(error) = spawn_result {
117                    let mut state = AppState::empty();
118                    state.toast = Some(format!("Iris could not start: {error}"));
119                    state.rev = 1;
120                    match shared_state.write() {
121                        Ok(mut slot) => *slot = state.clone(),
122                        Err(poison) => *poison.into_inner() = state.clone(),
123                    }
124                    let _ = update_tx_for_error.send(AppUpdate::FullState(state));
125                }
126            }
127            Err(error) => {
128                let mut state = AppState::empty();
129                state.toast = Some(error.to_string());
130                state.rev = 1;
131                match shared_state.write() {
132                    Ok(mut slot) => *slot = state.clone(),
133                    Err(poison) => *poison.into_inner() = state.clone(),
134                }
135                let _ = update_tx_for_error.send(AppUpdate::FullState(state));
136            }
137        }
138
139        Arc::new(Self {
140            core_tx,
141            update_rx,
142            listening: AtomicBool::new(false),
143            shared_state,
144        })
145    }
146
147    pub fn state(&self) -> AppState {
148        ffi_or("ffiapp.state", ffi_failure_state(), || {
149            match self.shared_state.read() {
150                Ok(slot) => slot.clone(),
151                Err(poison) => poison.into_inner().clone(),
152            }
153        })
154    }
155
156    pub fn dispatch(&self, action: AppAction) {
157        ffi_or("ffiapp.dispatch", (), || {
158            crate::perflog!("ffi.dispatch action={:?}", std::mem::discriminant(&action));
159            let _ = self.core_tx.send(CoreMsg::Action(action));
160        })
161    }
162
163    pub fn ingest_nearby_event_json(&self, event_json: String) -> bool {
164        self.ingest_nearby_event_json_with_transport(event_json, String::new())
165    }
166
167    pub fn ingest_nearby_event_json_with_transport(
168        &self,
169        event_json: String,
170        transport: String,
171    ) -> bool {
172        ffi_or("ffiapp.ingest_nearby_event_json", false, || {
173            let event = match serde_json::from_str::<nostr_sdk::prelude::Event>(&event_json) {
174                Ok(event) => event,
175                Err(_) => return false,
176            };
177            if event.verify().is_err() {
178                return false;
179            }
180            self.core_tx
181                .send(CoreMsg::Internal(Box::new(InternalEvent::NearbyEvent {
182                    event,
183                    transport,
184                })))
185                .is_ok()
186        })
187    }
188
189    pub fn build_nearby_presence_event_json(
190        &self,
191        peer_id: String,
192        my_nonce: String,
193        their_nonce: String,
194        profile_event_id: String,
195    ) -> String {
196        ffi_or(
197            "ffiapp.build_nearby_presence_event_json",
198            String::new(),
199            || {
200                let (reply_tx, reply_rx) = flume::bounded(1);
201                if self
202                    .core_tx
203                    .send(CoreMsg::BuildNearbyPresenceEvent {
204                        peer_id,
205                        my_nonce,
206                        their_nonce,
207                        profile_event_id,
208                        reply_tx,
209                    })
210                    .is_err()
211                {
212                    return String::new();
213                }
214                reply_rx
215                    .recv_timeout(Duration::from_secs(2))
216                    .unwrap_or_default()
217            },
218        )
219    }
220
221    pub fn verify_nearby_presence_event_json(
222        &self,
223        event_json: String,
224        peer_id: String,
225        my_nonce: String,
226        their_nonce: String,
227    ) -> String {
228        ffi_or(
229            "ffiapp.verify_nearby_presence_event_json",
230            String::new(),
231            || verify_nearby_presence_event_json(&event_json, &peer_id, &my_nonce, &their_nonce),
232        )
233    }
234
235    pub fn nearby_encode_frame(&self, envelope_json: String) -> Vec<u8> {
236        ffi_or("ffiapp.nearby_encode_frame", Vec::new(), || {
237            nostr_double_ratchet_runtime::encode_nearby_frame_json(&envelope_json)
238                .unwrap_or_default()
239        })
240    }
241
242    pub fn nearby_decode_frame(&self, frame: Vec<u8>) -> String {
243        ffi_or("ffiapp.nearby_decode_frame", String::new(), || {
244            nostr_double_ratchet_runtime::decode_nearby_frame_json(&frame).unwrap_or_default()
245        })
246    }
247
248    pub fn nearby_frame_body_len_from_header(&self, header: Vec<u8>) -> i32 {
249        ffi_or("ffiapp.nearby_frame_body_len_from_header", -1, || {
250            nostr_double_ratchet_runtime::nearby_frame_body_len_from_header(&header)
251                .and_then(|len| i32::try_from(len).ok())
252                .unwrap_or(-1)
253        })
254    }
255
256    pub fn export_support_bundle_json(&self) -> String {
257        ffi_or(
258            "ffiapp.export_support_bundle_json",
259            "{}".to_string(),
260            || {
261                let (reply_tx, reply_rx) = flume::bounded(1);
262                if self
263                    .core_tx
264                    .send(CoreMsg::ExportSupportBundle(reply_tx))
265                    .is_err()
266                {
267                    return "{}".to_string();
268                }
269                reply_rx
270                    .recv_timeout(Duration::from_secs(2))
271                    .unwrap_or_else(|_| "{}".to_string())
272            },
273        )
274    }
275
276    pub fn prepare_for_suspend(&self) {
277        ffi_or("ffiapp.prepare_for_suspend", (), || {
278            let (reply_tx, reply_rx) = flume::bounded(1);
279            if self
280                .core_tx
281                .send(CoreMsg::PrepareForSuspend(reply_tx))
282                .is_err()
283            {
284                return;
285            }
286            let _ = reply_rx.recv_timeout(Duration::from_secs(2));
287        })
288    }
289
290    pub fn shutdown(&self) {
291        ffi_or("ffiapp.shutdown", (), || {
292            let (reply_tx, reply_rx) = flume::bounded(1);
293            if self
294                .core_tx
295                .send(CoreMsg::Shutdown(Some(reply_tx)))
296                .is_err()
297            {
298                return;
299            }
300            let _ = reply_rx.recv_timeout(Duration::from_secs(2));
301        })
302    }
303
304    pub fn listen_for_updates(&self, reconciler: Box<dyn AppReconciler>) {
305        if self
306            .listening
307            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
308            .is_err()
309        {
310            return;
311        }
312
313        let update_rx = self.update_rx.clone();
314        let spawn_result = thread::Builder::new()
315            .name("iris-updates".to_string())
316            .spawn(move || {
317                // Drain queued updates and deliver the latest FullState only.
318                // The shell side already discards FullStates with stale `rev`,
319                // but the JNI marshal of an AppState is itself ~20-30 ms and
320                // each push triggers a full Compose recomposition (~400 ms on
321                // Android debug). When the core emits a tight burst of 3-4
322                // updates (OpenChat → SyncComplete → FetchCatchUpEvents → …)
323                // the UI keeps re-rendering for seconds even though only the
324                // final state mattered.
325                //
326                // PersistAccountBundle is a side-effect (key persistence), not
327                // a UI update, so we never collapse those — every one must run.
328                while let Ok(first) = update_rx.recv() {
329                    let mut latest_full_state: Option<AppUpdate> = None;
330                    let mut sidecar: Vec<AppUpdate> = Vec::new();
331                    let process =
332                        |update: AppUpdate,
333                         latest: &mut Option<AppUpdate>,
334                         side: &mut Vec<AppUpdate>| match update {
335                            full @ AppUpdate::FullState(_) => *latest = Some(full),
336                            other => side.push(other),
337                        };
338                    process(first, &mut latest_full_state, &mut sidecar);
339                    while let Ok(next) = update_rx.try_recv() {
340                        process(next, &mut latest_full_state, &mut sidecar);
341                    }
342                    for update in sidecar.into_iter().chain(latest_full_state) {
343                        let kind = match &update {
344                            AppUpdate::FullState(_) => "FullState",
345                            AppUpdate::PersistAccountBundle { .. } => "PersistAccountBundle",
346                            AppUpdate::NearbyPublishedEvent { .. } => "NearbyPublishedEvent",
347                        };
348                        let t0 = crate::perflog::now_ms();
349                        crate::perflog!("reconcile.start kind={kind}");
350                        if panic::catch_unwind(AssertUnwindSafe(|| reconciler.reconcile(update)))
351                            .is_err()
352                        {
353                            crate::perflog!("reconcile.failed kind={kind}");
354                            continue;
355                        }
356                        crate::perflog!(
357                            "reconcile.end kind={kind} elapsed_ms={}",
358                            crate::perflog::now_ms().saturating_sub(t0)
359                        );
360                    }
361                }
362            });
363        if let Err(error) = spawn_result {
364            crate::perflog!("updates.spawn.failed error={error}");
365            self.listening.store(false, Ordering::SeqCst);
366        }
367    }
368}
369
370#[uniffi::export]
371impl FfiDesktopNearby {
372    #[uniffi::constructor]
373    pub fn new(app: Arc<FfiApp>, observer: Box<dyn DesktopNearbyObserver>) -> Arc<Self> {
374        Arc::new(Self {
375            service: desktop_nearby::DesktopNearbyService::new(app, observer.into()),
376        })
377    }
378
379    pub fn start(&self, local_name: String) {
380        self.service.start(local_name);
381    }
382
383    pub fn stop(&self) {
384        self.service.stop();
385    }
386
387    pub fn snapshot(&self) -> DesktopNearbySnapshot {
388        self.service.snapshot()
389    }
390
391    pub fn publish(&self, event_id: String, kind: u32, created_at_secs: u64, event_json: String) {
392        self.service
393            .publish(event_id, kind, created_at_secs, event_json);
394    }
395}
396
397fn handle_core_batch_responsive(core: &mut AppCore, messages: Vec<CoreMsg>) -> bool {
398    if messages.len() <= 1 || !messages.iter().any(is_foreground_core_msg) {
399        return core.handle_messages(messages);
400    }
401
402    let mut foreground = Vec::new();
403    let mut background = Vec::new();
404    for message in messages {
405        if is_foreground_core_msg(&message) {
406            foreground.push(message);
407        } else {
408            background.push(message);
409        }
410    }
411
412    for message in foreground {
413        if !core.handle_message(message) {
414            return false;
415        }
416    }
417    background.is_empty() || core.handle_messages(background)
418}
419
420fn catch_core_batch<F>(f: F) -> Result<bool, String>
421where
422    F: FnOnce() -> bool,
423{
424    panic::catch_unwind(AssertUnwindSafe(f)).map_err(panic_payload_to_string)
425}
426
427fn ffi_or<T, F>(label: &'static str, fallback: T, f: F) -> T
428where
429    F: FnOnce() -> T,
430{
431    match panic::catch_unwind(AssertUnwindSafe(f)) {
432        Ok(value) => value,
433        Err(payload) => {
434            crate::perflog!(
435                "ffi.panic label={label} detail={}",
436                panic_payload_to_string(payload)
437            );
438            fallback
439        }
440    }
441}
442
443fn ffi_failure_state() -> AppState {
444    let mut state = AppState::empty();
445    state.toast = Some("Iris needs restart. Copy support bundle in Settings.".to_string());
446    state
447}
448
449fn suppressed_mobile_push_resolution() -> MobilePushNotificationResolution {
450    MobilePushNotificationResolution {
451        should_show: false,
452        title: String::new(),
453        body: String::new(),
454        payload_json: "{}".to_string(),
455    }
456}
457
458fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
459    if let Some(message) = payload.downcast_ref::<&str>() {
460        (*message).to_string()
461    } else if let Some(message) = payload.downcast_ref::<String>() {
462        message.clone()
463    } else {
464        "unknown panic".to_string()
465    }
466}
467
468fn is_foreground_core_msg(message: &CoreMsg) -> bool {
469    !matches!(message, CoreMsg::Internal(_))
470}
471
472fn verify_nearby_presence_event_json(
473    event_json: &str,
474    peer_id: &str,
475    my_nonce: &str,
476    their_nonce: &str,
477) -> String {
478    let Ok(event) = serde_json::from_str::<nostr_sdk::prelude::Event>(event_json) else {
479        return String::new();
480    };
481    if event.verify().is_err() || event.kind.as_u16() != crate::core::NEARBY_PRESENCE_KIND {
482        return String::new();
483    }
484    let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
485        return String::new();
486    };
487    let get = |key: &str| {
488        content
489            .get(key)
490            .and_then(|value| value.as_str())
491            .unwrap_or("")
492    };
493    let transport = get("transport");
494    if get("protocol") != "iris-nearby-v1"
495        || !(transport == "ble" || transport == "nearby" || transport == "lan")
496        || get("peer_id") != peer_id.trim()
497        || get("my_nonce") != their_nonce.trim()
498        || get("their_nonce") != my_nonce.trim()
499    {
500        return String::new();
501    }
502
503    let now = SystemTime::now()
504        .duration_since(UNIX_EPOCH)
505        .unwrap_or_default()
506        .as_secs();
507    let expires_at = content
508        .get("expires_at")
509        .and_then(|value| value.as_u64())
510        .unwrap_or(0);
511    let created_at = event.created_at.as_secs();
512    if expires_at < now
513        || expires_at > now.saturating_add(300)
514        || created_at.saturating_add(300) < now
515        || created_at > now.saturating_add(300)
516    {
517        return String::new();
518    }
519
520    let profile_event_id = get("profile_event_id");
521    let profile_event_id = if profile_event_id.len() == 64 {
522        profile_event_id
523    } else {
524        ""
525    };
526    serde_json::json!({
527        "owner_pubkey_hex": event.pubkey.to_hex(),
528        "profile_event_id": profile_event_id,
529    })
530    .to_string()
531}
532
533impl Drop for FfiApp {
534    fn drop(&mut self) {
535        let _ = self.core_tx.send(CoreMsg::Shutdown(None));
536    }
537}
538
539#[uniffi::export]
540pub fn normalize_peer_input(input: String) -> String {
541    ffi_or("normalize_peer_input", String::new(), || {
542        crate::core::normalize_peer_input_for_display(&input)
543    })
544}
545
546#[uniffi::export]
547pub fn is_valid_peer_input(input: String) -> bool {
548    ffi_or("is_valid_peer_input", false, || {
549        crate::core::parse_peer_input(&input).is_ok()
550    })
551}
552
553/// Convert any pubkey-shaped input (hex, npub, nprofile, …) to its
554/// canonical lowercase-hex form. The empty string is returned when the
555/// input can't be parsed as a public key — callers expecting hex
556/// downstream can short-circuit on that.
557#[uniffi::export]
558pub fn peer_input_to_hex(input: String) -> String {
559    ffi_or("peer_input_to_hex", String::new(), || {
560        let normalized = crate::core::normalize_peer_input_for_display(&input);
561        match nostr::PublicKey::parse(&normalized) {
562            Ok(pubkey) => pubkey.to_hex(),
563            Err(_) => String::new(),
564        }
565    })
566}
567
568/// Convert any pubkey-shaped input (hex, npub, nprofile, …) to its npub form.
569/// Returns the original string when it can't be parsed as a public key.
570#[uniffi::export]
571pub fn peer_input_to_npub(input: String) -> String {
572    ffi_or("peer_input_to_npub", String::new(), || {
573        use nostr::nips::nip19::ToBech32;
574        let normalized = crate::core::normalize_peer_input_for_display(&input);
575        match nostr::PublicKey::parse(&normalized) {
576            Ok(pubkey) => pubkey.to_bech32().unwrap_or(normalized),
577            Err(_) => normalized,
578        }
579    })
580}
581
582#[uniffi::export]
583pub fn build_summary() -> String {
584    ffi_or("build_summary", String::new(), crate::core::build_summary)
585}
586
587#[uniffi::export]
588pub fn relay_set_id() -> String {
589    ffi_or("relay_set_id", String::new(), || {
590        crate::core::relay_set_id().to_string()
591    })
592}
593
594#[uniffi::export]
595pub fn proxied_image_url(
596    original_src: String,
597    preferences: PreferencesSnapshot,
598    width: Option<u32>,
599    height: Option<u32>,
600    square: bool,
601) -> String {
602    ffi_or("proxied_image_url", original_src.clone(), || {
603        image_proxy::proxied_image_url(&original_src, &preferences, width, height, square)
604    })
605}
606
607#[uniffi::export]
608pub fn is_trusted_test_build() -> bool {
609    ffi_or(
610        "is_trusted_test_build",
611        false,
612        crate::core::trusted_test_build_flag,
613    )
614}
615
616/// Marketing version baked in at build time from `IRIS_APP_VERSION_NAME`
617/// (or `IRIS_APP_VERSION`), falling back to the crate semver. Use this
618/// instead of `env!("CARGO_PKG_VERSION")` so UI/release artifacts agree
619/// on a single version string.
620#[uniffi::export]
621pub fn app_version() -> String {
622    crate::core::app_version_string().to_string()
623}
624
625#[uniffi::export]
626pub fn resolve_mobile_push_notification_payload(
627    raw_payload_json: String,
628) -> MobilePushNotificationResolution {
629    ffi_or(
630        "resolve_mobile_push_notification_payload",
631        suppressed_mobile_push_resolution(),
632        || crate::core::resolve_mobile_push_notification(raw_payload_json),
633    )
634}
635
636/// Decrypt a notification payload against the persisted double-ratchet
637/// state under `data_dir`. Use from the FCM service (Android) or
638/// Notification Service Extension (iOS) where there's no live `FfiApp`.
639/// Falls back to the generic resolver when keys, payload, or storage
640/// are unavailable so the user still gets *some* notification.
641#[uniffi::export]
642pub fn decrypt_mobile_push_notification_payload(
643    data_dir: String,
644    owner_pubkey_hex: String,
645    device_nsec: String,
646    raw_payload_json: String,
647) -> MobilePushNotificationResolution {
648    ffi_or(
649        "decrypt_mobile_push_notification_payload",
650        suppressed_mobile_push_resolution(),
651        || {
652            crate::core::decrypt_mobile_push_notification(
653                data_dir,
654                owner_pubkey_hex,
655                device_nsec,
656                raw_payload_json,
657            )
658        },
659    )
660}
661
662#[uniffi::export]
663pub fn resolve_mobile_push_subscription_server_url(
664    platform_key: String,
665    is_release: bool,
666    override_url: Option<String>,
667) -> String {
668    ffi_or(
669        "resolve_mobile_push_subscription_server_url",
670        String::new(),
671        || crate::core::resolve_mobile_push_server_url(platform_key, is_release, override_url),
672    )
673}
674
675#[uniffi::export]
676pub fn mobile_push_subscription_id_key(platform_key: String) -> String {
677    ffi_or("mobile_push_subscription_id_key", String::new(), || {
678        crate::core::mobile_push_stored_subscription_id_key(platform_key)
679    })
680}
681
682#[uniffi::export]
683pub fn build_mobile_push_list_subscriptions_request(
684    owner_nsec: String,
685    platform_key: String,
686    is_release: bool,
687    server_url_override: Option<String>,
688) -> Option<MobilePushSubscriptionRequest> {
689    ffi_or("build_mobile_push_list_subscriptions_request", None, || {
690        crate::core::build_mobile_push_list_subscriptions_request(
691            owner_nsec,
692            platform_key,
693            is_release,
694            server_url_override,
695        )
696    })
697}
698
699#[uniffi::export]
700#[allow(clippy::too_many_arguments)]
701pub fn build_mobile_push_create_subscription_request(
702    owner_nsec: String,
703    platform_key: String,
704    push_token: String,
705    apns_topic: Option<String>,
706    message_author_pubkeys: Vec<String>,
707    invite_response_pubkeys: Vec<String>,
708    is_release: bool,
709    server_url_override: Option<String>,
710) -> Option<MobilePushSubscriptionRequest> {
711    ffi_or(
712        "build_mobile_push_create_subscription_request",
713        None,
714        || {
715            crate::core::build_mobile_push_create_subscription_request(
716                owner_nsec,
717                platform_key,
718                push_token,
719                apns_topic,
720                message_author_pubkeys,
721                invite_response_pubkeys,
722                is_release,
723                server_url_override,
724            )
725        },
726    )
727}
728
729#[uniffi::export]
730#[allow(clippy::too_many_arguments)]
731pub fn build_mobile_push_update_subscription_request(
732    owner_nsec: String,
733    subscription_id: String,
734    platform_key: String,
735    push_token: String,
736    apns_topic: Option<String>,
737    message_author_pubkeys: Vec<String>,
738    invite_response_pubkeys: Vec<String>,
739    is_release: bool,
740    server_url_override: Option<String>,
741) -> Option<MobilePushSubscriptionRequest> {
742    ffi_or(
743        "build_mobile_push_update_subscription_request",
744        None,
745        || {
746            crate::core::build_mobile_push_update_subscription_request(
747                owner_nsec,
748                subscription_id,
749                platform_key,
750                push_token,
751                apns_topic,
752                message_author_pubkeys,
753                invite_response_pubkeys,
754                is_release,
755                server_url_override,
756            )
757        },
758    )
759}
760
761#[uniffi::export]
762pub fn build_mobile_push_delete_subscription_request(
763    owner_nsec: String,
764    subscription_id: String,
765    platform_key: String,
766    is_release: bool,
767    server_url_override: Option<String>,
768) -> Option<MobilePushSubscriptionRequest> {
769    ffi_or(
770        "build_mobile_push_delete_subscription_request",
771        None,
772        || {
773            crate::core::build_mobile_push_delete_subscription_request(
774                owner_nsec,
775                subscription_id,
776                platform_key,
777                is_release,
778                server_url_override,
779            )
780        },
781    )
782}
783
784#[cfg(test)]
785mod ffi_hardening_tests {
786    use super::*;
787
788    #[test]
789    fn ffi_guard_returns_fallback_after_panic() {
790        let value = ffi_or("test.panic", 42, || -> i32 {
791            panic!("ffi boom");
792        });
793
794        assert_eq!(value, 42);
795    }
796
797    #[test]
798    fn core_batch_guard_converts_panic_to_error() {
799        let result = catch_core_batch(|| -> bool {
800            panic!("batch boom");
801        });
802
803        assert_eq!(result, Err("batch boom".to_string()));
804    }
805
806    #[test]
807    fn core_batch_guard_preserves_success_result() {
808        assert_eq!(catch_core_batch(|| true), Ok(true));
809        assert_eq!(catch_core_batch(|| false), Ok(false));
810    }
811}