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        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                // Drain queued updates and deliver the latest FullState only.
270                // The shell side already discards FullStates with stale `rev`,
271                // but the JNI marshal of an AppState is itself ~20-30 ms and
272                // each push triggers a full Compose recomposition (~400 ms on
273                // Android debug). When the core emits a tight burst of 3-4
274                // updates (OpenChat → SyncComplete → FetchCatchUpEvents → …)
275                // the UI keeps re-rendering for seconds even though only the
276                // final state mattered.
277                //
278                // PersistAccountBundle is a side-effect (key persistence), not
279                // a UI update, so we never collapse those — every one must run.
280                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                        // Drain whatever is already queued and process it as one batch so
364                        // a flurry of relay events + user actions produces a single UI
365                        // update instead of N. Without this, tapping a chat while a
366                        // relay backlog drains can take seconds because OpenChat sits
367                        // behind every queued event and the UI recomposes between each.
368                        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/// Convert any pubkey-shaped input (hex, npub, nprofile, …) to its
596/// canonical lowercase-hex form. The empty string is returned when the
597/// input can't be parsed as a public key — callers expecting hex
598/// downstream can short-circuit on that.
599#[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/// Convert any pubkey-shaped input (hex, npub, nprofile, …) to its npub form.
611/// Returns the original string when it can't be parsed as a public key.
612#[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/// Marketing version baked in at build time from `IRIS_APP_VERSION_NAME`
659/// (or `IRIS_APP_VERSION`), falling back to the crate semver. Use this
660/// instead of `env!("CARGO_PKG_VERSION")` so UI/release artifacts agree
661/// on a single version string.
662#[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/// Decrypt a notification payload against the persisted double-ratchet
679/// state under `data_dir`. Use from the FCM service (Android) or
680/// Notification Service Extension (iOS) where there's no live `FfiApp`.
681/// Falls back to the generic resolver when keys, payload, or storage
682/// are unavailable so the user still gets *some* notification.
683#[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}