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