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 test_fixtures;
10mod updates;
11
12use std::any::Any;
13use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
14use std::sync::{Arc, RwLock};
15use std::thread;
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17use std::{panic, panic::AssertUnwindSafe};
18
19use flume::{Receiver, Sender};
20
21pub use actions::AppAction;
22pub use qr::*;
23pub use state::*;
24pub use test_fixtures::*;
25pub use updates::*;
26
27use crate::core::AppCore;
28
29uniffi::setup_scaffolding!();
30
31pub(crate) const CORE_RESTART_TOAST: &str = "Iris needs restart. Copy support bundle in Settings.";
32
33#[uniffi::export(callback_interface)]
34pub trait AppReconciler: Send + Sync + 'static {
35    fn reconcile(&self, update: AppUpdate);
36}
37
38#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
39pub struct DesktopNearbyPeerSnapshot {
40    pub id: String,
41    pub name: String,
42    pub owner_pubkey_hex: Option<String>,
43    pub picture_url: Option<String>,
44    pub profile_event_id: Option<String>,
45    pub last_seen_secs: u64,
46}
47
48#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
49pub struct DesktopNearbySnapshot {
50    pub visible: bool,
51    pub status: String,
52    pub peers: Vec<DesktopNearbyPeerSnapshot>,
53}
54
55#[uniffi::export(callback_interface)]
56pub trait DesktopNearbyObserver: Send + Sync + 'static {
57    fn desktop_nearby_changed(&self, snapshot: DesktopNearbySnapshot);
58}
59
60/// Per-FFI-method call counters that feed the release-gate budget
61/// tests. Every `FfiApp::*` entry point bumps the matching atomic at
62/// the top of the call so a misbehaving shell that re-enters the
63/// core in a hot loop shows up as an obvious counter spike.
64///
65/// We track FFI surface area (not internal core counters) because
66/// the categorical heat bugs we hit have all been "the shell
67/// re-evaluated something and called us N times instead of once" —
68/// e.g. the iOS chat-list search re-firing on every body re-eval.
69/// Adding finer-grained counters inside the core is a future move
70/// if needed; the FFI line is what we can confidently budget today
71/// because each entry corresponds to one observable shell action.
72#[derive(Default, Debug)]
73pub(crate) struct FfiPerfCounters {
74    pub state: AtomicU64,
75    pub dispatch: AtomicU64,
76    pub search: AtomicU64,
77    pub ingest_nearby_event_json: AtomicU64,
78    pub export_support_bundle_json: AtomicU64,
79    pub peer_profile_debug: AtomicU64,
80    pub mutual_groups: AtomicU64,
81    pub prepare_for_suspend: AtomicU64,
82}
83
84#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq, Default)]
85pub struct FfiPerfCountersSnapshot {
86    pub state: u64,
87    pub dispatch: u64,
88    pub search: u64,
89    pub ingest_nearby_event_json: u64,
90    pub export_support_bundle_json: u64,
91    pub peer_profile_debug: u64,
92    pub mutual_groups: u64,
93    pub prepare_for_suspend: u64,
94}
95
96/// Core-internal hot-loop counters. FFI surface counters can only
97/// catch shells that re-enter the core in a loop — the
98/// build_runtime_debug_snapshot regression that caused the macOS CPU
99/// loop was entirely internal (relay events fanned out through
100/// `persist_best_effort_inner` → `persist_debug_snapshot_best_effort`
101/// → full SessionManager clone × N known users). The release-gate
102/// budget tests assert on this snapshot too so the next time core
103/// work explodes per event, CI catches it before a device does.
104#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq, Default)]
105pub struct CorePerfCountersSnapshot {
106    pub debug_snapshot_builds: u64,
107}
108
109#[derive(uniffi::Object)]
110pub struct FfiApp {
111    foreground_tx: Sender<CoreMsg>,
112    foreground_rx: Receiver<CoreMsg>,
113    background_tx: Sender<CoreMsg>,
114    background_rx: Receiver<CoreMsg>,
115    update_rx: Receiver<AppUpdate>,
116    listening: AtomicBool,
117    shared_state: Arc<RwLock<AppState>>,
118    /// Shared SQLite handle used by direct read FFI calls. The core
119    /// supervisor swaps this when it recreates `AppCore` after a panic.
120    shared_db: Arc<RwLock<Option<crate::core::SharedConnection>>>,
121    perf: FfiPerfCounters,
122    queue_metrics: Arc<CoreQueueMetrics>,
123    recovery: Arc<CoreRecoveryState>,
124}
125
126#[derive(Default, Debug)]
127struct CoreQueueMetrics {
128    foreground_processed: AtomicU64,
129    background_processed: AtomicU64,
130    batch_active: AtomicBool,
131    last_batch_started_at_ms: AtomicU64,
132    last_batch_finished_at_ms: AtomicU64,
133    last_batch_size: AtomicU64,
134    last_batch_foreground_count: AtomicU64,
135    last_batch_background_count: AtomicU64,
136}
137
138impl CoreQueueMetrics {
139    fn mark_batch_start(&self, size: u64, foreground: u64, background: u64) {
140        self.last_batch_started_at_ms
141            .store(crate::perflog::now_ms(), Ordering::Relaxed);
142        self.last_batch_size.store(size, Ordering::Relaxed);
143        self.last_batch_foreground_count
144            .store(foreground, Ordering::Relaxed);
145        self.last_batch_background_count
146            .store(background, Ordering::Relaxed);
147        self.batch_active.store(true, Ordering::Release);
148    }
149
150    fn mark_batch_finished(&self, foreground: u64, background: u64) {
151        self.foreground_processed
152            .fetch_add(foreground, Ordering::Relaxed);
153        self.background_processed
154            .fetch_add(background, Ordering::Relaxed);
155        self.last_batch_finished_at_ms
156            .store(crate::perflog::now_ms(), Ordering::Relaxed);
157        self.batch_active.store(false, Ordering::Release);
158    }
159}
160
161#[derive(Default, Debug)]
162struct CoreRecoveryState {
163    restore_action: RwLock<Option<AppAction>>,
164    restart_count: AtomicU64,
165    last_panic: RwLock<Option<String>>,
166}
167
168impl CoreRecoveryState {
169    fn remember_action(&self, action: &AppAction) {
170        match action {
171            AppAction::RestoreSession { .. } | AppAction::RestoreAccountBundle { .. } => {
172                self.set_restore_action(Some(action.clone()));
173            }
174            AppAction::Logout => self.set_restore_action(None),
175            _ => {}
176        }
177    }
178
179    fn remember_update(&self, update: &AppUpdate) {
180        if let AppUpdate::PersistAccountBundle {
181            owner_nsec,
182            owner_pubkey_hex,
183            device_nsec,
184            ..
185        } = update
186        {
187            self.set_restore_action(Some(AppAction::RestoreAccountBundle {
188                owner_nsec: owner_nsec.clone(),
189                owner_pubkey_hex: owner_pubkey_hex.clone(),
190                device_nsec: device_nsec.clone(),
191            }));
192        }
193    }
194
195    fn restore_action(&self) -> Option<AppAction> {
196        match self.restore_action.read() {
197            Ok(action) => action.clone(),
198            Err(poison) => poison.into_inner().clone(),
199        }
200    }
201
202    fn mark_panic(&self, detail: String) -> u64 {
203        match self.last_panic.write() {
204            Ok(mut slot) => *slot = Some(detail),
205            Err(poison) => *poison.into_inner() = Some(detail),
206        }
207        self.restart_count.fetch_add(1, Ordering::Relaxed) + 1
208    }
209
210    fn restart_count(&self) -> u64 {
211        self.restart_count.load(Ordering::Relaxed)
212    }
213
214    fn last_panic(&self) -> Option<String> {
215        match self.last_panic.read() {
216            Ok(slot) => slot.clone(),
217            Err(poison) => poison.into_inner().clone(),
218        }
219    }
220
221    fn set_restore_action(&self, action: Option<AppAction>) {
222        match self.restore_action.write() {
223            Ok(mut slot) => *slot = action,
224            Err(poison) => *poison.into_inner() = action,
225        }
226    }
227}
228
229#[derive(uniffi::Object)]
230pub struct FfiDesktopNearby {
231    service: Arc<desktop_nearby::DesktopNearbyService>,
232}
233
234#[uniffi::export]
235impl FfiApp {
236    #[uniffi::constructor]
237    pub fn new(data_dir: String, _keychain_group: String, _app_version: String) -> Arc<Self> {
238        match panic::catch_unwind(AssertUnwindSafe(|| new_ffi_app_inner(data_dir))) {
239            Ok(app) => app,
240            Err(payload) => ffi_app_failure(format!(
241                "Iris could not start: {}",
242                panic_payload_to_string(payload)
243            )),
244        }
245    }
246
247    pub fn state(&self) -> AppState {
248        self.perf.state.fetch_add(1, Ordering::Relaxed);
249        ffi_or("ffiapp.state", ffi_failure_state(), || {
250            match self.shared_state.read() {
251                Ok(slot) => slot.clone(),
252                Err(poison) => poison.into_inner().clone(),
253            }
254        })
255    }
256
257    pub fn dispatch(&self, action: AppAction) {
258        self.perf.dispatch.fetch_add(1, Ordering::Relaxed);
259        ffi_or("ffiapp.dispatch", (), || {
260            crate::perflog!("ffi.dispatch action={:?}", std::mem::discriminant(&action));
261            self.recovery.remember_action(&action);
262            let _ = self.foreground_tx.send(CoreMsg::Action(action));
263        })
264    }
265
266    /// Snapshot of the per-FFI-method call counts since `FfiApp` was
267    /// created. Used by `tests/perf_budgets.rs` to assert that hot
268    /// shell paths stay within their expected FFI traffic. Reading
269    /// is best-effort `Relaxed` — call counts are advisory not
270    /// transactional.
271    pub fn perf_counters(&self) -> FfiPerfCountersSnapshot {
272        FfiPerfCountersSnapshot {
273            state: self.perf.state.load(Ordering::Relaxed),
274            dispatch: self.perf.dispatch.load(Ordering::Relaxed),
275            search: self.perf.search.load(Ordering::Relaxed),
276            ingest_nearby_event_json: self.perf.ingest_nearby_event_json.load(Ordering::Relaxed),
277            export_support_bundle_json: self
278                .perf
279                .export_support_bundle_json
280                .load(Ordering::Relaxed),
281            peer_profile_debug: self.perf.peer_profile_debug.load(Ordering::Relaxed),
282            mutual_groups: self.perf.mutual_groups.load(Ordering::Relaxed),
283            prepare_for_suspend: self.perf.prepare_for_suspend.load(Ordering::Relaxed),
284        }
285    }
286
287    /// Snapshot of core-internal hot-loop counters. Used by
288    /// `tests/perf_budgets.rs` to budget work that happens entirely
289    /// inside the core thread — the FFI surface counters above can't
290    /// see those. Default snapshot on timeout so a wedged core can't
291    /// pin the test on a perpetual wait.
292    pub fn core_perf_counters(&self) -> CorePerfCountersSnapshot {
293        ffi_or(
294            "ffiapp.core_perf_counters",
295            CorePerfCountersSnapshot::default(),
296            || {
297                let (reply_tx, reply_rx) = flume::bounded(1);
298                if self
299                    .foreground_tx
300                    .send(CoreMsg::CorePerfCounters(reply_tx))
301                    .is_err()
302                {
303                    return CorePerfCountersSnapshot::default();
304                }
305                match reply_rx.recv_timeout(Duration::from_secs(2)) {
306                    Ok(snapshot) => CorePerfCountersSnapshot {
307                        debug_snapshot_builds: snapshot.debug_snapshot_builds,
308                    },
309                    Err(_) => CorePerfCountersSnapshot::default(),
310                }
311            },
312        )
313    }
314
315    /// Grouped Signal-style search: filters the in-memory chat list
316    /// into contacts/groups by display name + subtitle + chat id, and
317    /// runs the SQLite FTS5 index for the messages section. Optional
318    /// `scope_chat_id` restricts message hits to a single thread (the
319    /// "search in this chat" pill in the desktop sidebar). Returns an
320    /// empty snapshot for empty / whitespace queries.
321    pub fn search(
322        &self,
323        query: String,
324        scope_chat_id: Option<String>,
325        limit: u32,
326    ) -> SearchResultSnapshot {
327        self.perf.search.fetch_add(1, Ordering::Relaxed);
328        ffi_or(
329            "ffiapp.search",
330            SearchResultSnapshot::empty(query.clone(), scope_chat_id.clone()),
331            || {
332                let trimmed = query.trim();
333                if trimmed.is_empty() {
334                    return SearchResultSnapshot::empty(query.clone(), scope_chat_id.clone());
335                }
336                let limit = limit.max(1) as usize;
337                let state_snapshot = match self.shared_state.read() {
338                    Ok(slot) => slot.clone(),
339                    Err(poison) => poison.into_inner().clone(),
340                };
341                let (contacts, groups) = if scope_chat_id.is_some() {
342                    (Vec::new(), Vec::new())
343                } else {
344                    filter_threads_for_search(&state_snapshot.chat_list, trimmed)
345                };
346                let shared_db = self.shared_db_snapshot();
347                let messages = match shared_db.as_ref() {
348                    Some(shared) => match shared.lock() {
349                        Ok(conn) => crate::core::search_messages_fts(
350                            &conn,
351                            trimmed,
352                            scope_chat_id.as_deref(),
353                            limit,
354                        )
355                        .unwrap_or_default(),
356                        Err(poison) => crate::core::search_messages_fts(
357                            &poison.into_inner(),
358                            trimmed,
359                            scope_chat_id.as_deref(),
360                            limit,
361                        )
362                        .unwrap_or_default(),
363                    },
364                    None => Vec::new(),
365                };
366                let enriched = enrich_message_hits(messages, &state_snapshot.chat_list);
367                // The shortcut row only makes sense for global search.
368                // Once the user has scoped to a single chat, an npub
369                // paste should still search that chat's messages, not
370                // jump out of the scope.
371                let shortcut = if scope_chat_id.is_none() {
372                    chat_input_shortcut(trimmed)
373                } else {
374                    None
375                };
376                SearchResultSnapshot {
377                    query,
378                    scope_chat_id,
379                    contacts,
380                    groups,
381                    messages: enriched,
382                    shortcut,
383                }
384            },
385        )
386    }
387
388    /// Bounded chat projection for a route-selected chat. Unlike
389    /// `OpenChat`, this is a direct read from the shared state/SQLite
390    /// handle and never waits behind the core action queue. Shells use
391    /// it as the first paint for chat screens; the core still receives
392    /// `OpenChat` for unread clearing, subscriptions, and side effects.
393    pub fn chat_snapshot(&self, chat_id: String, limit: u32) -> Option<CurrentChatSnapshot> {
394        ffi_or("ffiapp.chat_snapshot", None, || {
395            let state_snapshot = match self.shared_state.read() {
396                Ok(slot) => slot.clone(),
397                Err(poison) => poison.into_inner().clone(),
398            };
399            crate::core::chat_snapshot_from_state_and_db(
400                &state_snapshot,
401                self.shared_db_snapshot().as_ref(),
402                &chat_id,
403                limit.max(1) as usize,
404            )
405        })
406    }
407
408    pub fn chat_snapshot_before(
409        &self,
410        chat_id: String,
411        before_message_id: String,
412        limit: u32,
413    ) -> Option<CurrentChatSnapshot> {
414        ffi_or("ffiapp.chat_snapshot_before", None, || {
415            let state_snapshot = match self.shared_state.read() {
416                Ok(slot) => slot.clone(),
417                Err(poison) => poison.into_inner().clone(),
418            };
419            crate::core::chat_snapshot_before_from_state_and_db(
420                &state_snapshot,
421                self.shared_db_snapshot().as_ref(),
422                &chat_id,
423                &before_message_id,
424                limit.max(1) as usize,
425            )
426        })
427    }
428
429    pub fn chat_snapshot_around_message(
430        &self,
431        chat_id: String,
432        message_id: String,
433        before_limit: u32,
434        after_limit: u32,
435    ) -> Option<CurrentChatSnapshot> {
436        ffi_or("ffiapp.chat_snapshot_around_message", None, || {
437            let state_snapshot = match self.shared_state.read() {
438                Ok(slot) => slot.clone(),
439                Err(poison) => poison.into_inner().clone(),
440            };
441            crate::core::chat_snapshot_around_message_from_state_and_db(
442                &state_snapshot,
443                self.shared_db_snapshot().as_ref(),
444                &chat_id,
445                &message_id,
446                before_limit as usize,
447                after_limit as usize,
448            )
449        })
450    }
451
452    pub fn ingest_nearby_event_json(&self, event_json: String) -> bool {
453        self.perf
454            .ingest_nearby_event_json
455            .fetch_add(1, Ordering::Relaxed);
456        self.ingest_nearby_event_json_with_transport(event_json, String::new())
457    }
458
459    pub fn ingest_nearby_event_json_with_transport(
460        &self,
461        event_json: String,
462        transport: String,
463    ) -> bool {
464        ffi_or("ffiapp.ingest_nearby_event_json", false, || {
465            let event = match serde_json::from_str::<nostr_sdk::prelude::Event>(&event_json) {
466                Ok(event) => event,
467                Err(_) => return false,
468            };
469            if event.verify().is_err() {
470                return false;
471            }
472            self.background_tx
473                .send(CoreMsg::Internal(Box::new(InternalEvent::NearbyEvent {
474                    event,
475                    transport,
476                })))
477                .is_ok()
478        })
479    }
480
481    pub fn build_nearby_presence_event_json(
482        &self,
483        peer_id: String,
484        my_nonce: String,
485        their_nonce: String,
486        profile_event_id: String,
487    ) -> String {
488        ffi_or(
489            "ffiapp.build_nearby_presence_event_json",
490            String::new(),
491            || {
492                let (reply_tx, reply_rx) = flume::bounded(1);
493                if self
494                    .background_tx
495                    .send(CoreMsg::BuildNearbyPresenceEvent {
496                        peer_id,
497                        my_nonce,
498                        their_nonce,
499                        profile_event_id,
500                        reply_tx,
501                    })
502                    .is_err()
503                {
504                    return String::new();
505                }
506                reply_rx
507                    .recv_timeout(Duration::from_secs(2))
508                    .unwrap_or_default()
509            },
510        )
511    }
512
513    pub fn verify_nearby_presence_event_json(
514        &self,
515        event_json: String,
516        peer_id: String,
517        my_nonce: String,
518        their_nonce: String,
519    ) -> String {
520        ffi_or(
521            "ffiapp.verify_nearby_presence_event_json",
522            String::new(),
523            || verify_nearby_presence_event_json(&event_json, &peer_id, &my_nonce, &their_nonce),
524        )
525    }
526
527    pub fn nearby_encode_frame(&self, envelope_json: String) -> Vec<u8> {
528        ffi_or("ffiapp.nearby_encode_frame", Vec::new(), || {
529            nostr_double_ratchet_runtime::encode_nearby_frame_json(&envelope_json)
530                .unwrap_or_default()
531        })
532    }
533
534    pub fn nearby_decode_frame(&self, frame: Vec<u8>) -> String {
535        ffi_or("ffiapp.nearby_decode_frame", String::new(), || {
536            nostr_double_ratchet_runtime::decode_nearby_frame_json(&frame).unwrap_or_default()
537        })
538    }
539
540    pub fn nearby_frame_body_len_from_header(&self, header: Vec<u8>) -> i32 {
541        ffi_or("ffiapp.nearby_frame_body_len_from_header", -1, || {
542            nostr_double_ratchet_runtime::nearby_frame_body_len_from_header(&header)
543                .and_then(|len| i32::try_from(len).ok())
544                .unwrap_or(-1)
545        })
546    }
547
548    pub fn export_support_bundle_json(&self) -> String {
549        self.perf
550            .export_support_bundle_json
551            .fetch_add(1, Ordering::Relaxed);
552        ffi_or(
553            "ffiapp.export_support_bundle_json",
554            self.support_bundle_json_with_ffi_diagnostics("{}".to_string(), true),
555            || {
556                let (reply_tx, reply_rx) = flume::bounded(1);
557                if self
558                    .foreground_tx
559                    .send(CoreMsg::ExportSupportBundle(reply_tx))
560                    .is_err()
561                {
562                    return self.support_bundle_json_with_ffi_diagnostics("{}".to_string(), true);
563                }
564                match reply_rx.recv_timeout(Duration::from_secs(2)) {
565                    Ok(json) => self.support_bundle_json_with_ffi_diagnostics(json, false),
566                    Err(_) => self.support_bundle_json_with_ffi_diagnostics("{}".to_string(), true),
567                }
568            },
569        )
570    }
571
572    pub fn peer_profile_debug(&self, owner_input: String) -> Option<PeerProfileDebugSnapshot> {
573        self.perf.peer_profile_debug.fetch_add(1, Ordering::Relaxed);
574        ffi_or("ffiapp.peer_profile_debug", None, || {
575            let (reply_tx, reply_rx) = flume::bounded(1);
576            if self
577                .foreground_tx
578                .send(CoreMsg::PeerProfileDebug {
579                    owner_input,
580                    reply_tx,
581                })
582                .is_err()
583            {
584                return None;
585            }
586            reply_rx.recv_timeout(Duration::from_secs(2)).ok().flatten()
587        })
588    }
589
590    pub fn mutual_groups(&self, owner_input: String) -> MutualGroupsSnapshot {
591        self.perf.mutual_groups.fetch_add(1, Ordering::Relaxed);
592        ffi_or(
593            "ffiapp.mutual_groups",
594            MutualGroupsSnapshot::default(),
595            || {
596                let (reply_tx, reply_rx) = flume::bounded(1);
597                if self
598                    .foreground_tx
599                    .send(CoreMsg::MutualGroups {
600                        owner_input,
601                        reply_tx,
602                    })
603                    .is_err()
604                {
605                    return MutualGroupsSnapshot::default();
606                }
607                reply_rx
608                    .recv_timeout(Duration::from_secs(2))
609                    .unwrap_or_default()
610            },
611        )
612    }
613
614    pub fn prepare_for_suspend(&self) {
615        self.perf
616            .prepare_for_suspend
617            .fetch_add(1, Ordering::Relaxed);
618        ffi_or("ffiapp.prepare_for_suspend", (), || {
619            let (reply_tx, reply_rx) = flume::bounded(1);
620            if self
621                .foreground_tx
622                .send(CoreMsg::PrepareForSuspend(reply_tx))
623                .is_err()
624            {
625                return;
626            }
627            let _ = reply_rx.recv_timeout(Duration::from_secs(2));
628        })
629    }
630
631    pub fn shutdown(&self) {
632        ffi_or("ffiapp.shutdown", (), || {
633            let (reply_tx, reply_rx) = flume::bounded(1);
634            if self
635                .foreground_tx
636                .send(CoreMsg::Shutdown(Some(reply_tx)))
637                .is_err()
638            {
639                return;
640            }
641            let _ = reply_rx.recv_timeout(Duration::from_secs(2));
642        })
643    }
644
645    fn support_bundle_json_with_ffi_diagnostics(
646        &self,
647        rust_json: String,
648        core_support_bundle_timed_out: bool,
649    ) -> String {
650        let mut object = serde_json::from_str::<serde_json::Value>(&rust_json)
651            .ok()
652            .and_then(|value| value.as_object().cloned())
653            .unwrap_or_default();
654        let now_ms = crate::perflog::now_ms();
655        let last_started_at_ms = self
656            .queue_metrics
657            .last_batch_started_at_ms
658            .load(Ordering::Relaxed);
659        let last_finished_at_ms = self
660            .queue_metrics
661            .last_batch_finished_at_ms
662            .load(Ordering::Relaxed);
663        let batch_active = self.queue_metrics.batch_active.load(Ordering::Acquire);
664        let active_batch_age_ms = if batch_active && last_started_at_ms > 0 {
665            Some(now_ms.saturating_sub(last_started_at_ms))
666        } else {
667            None
668        };
669        let last_batch_started_ago_ms = if last_started_at_ms > 0 {
670            Some(now_ms.saturating_sub(last_started_at_ms))
671        } else {
672            None
673        };
674        let last_batch_finished_ago_ms = if last_finished_at_ms > 0 {
675            Some(now_ms.saturating_sub(last_finished_at_ms))
676        } else {
677            None
678        };
679        object.insert(
680            "ffi_queue".to_string(),
681            serde_json::json!({
682                "core_support_bundle_timed_out": core_support_bundle_timed_out,
683                "foreground_pending": self.foreground_rx.len(),
684                "background_pending": self.background_rx.len(),
685                "foreground_processed": self.queue_metrics.foreground_processed.load(Ordering::Relaxed),
686                "background_processed": self.queue_metrics.background_processed.load(Ordering::Relaxed),
687                "batch_active": batch_active,
688                "active_batch_age_ms": active_batch_age_ms,
689                "last_batch_started_ago_ms": last_batch_started_ago_ms,
690                "last_batch_finished_ago_ms": last_batch_finished_ago_ms,
691                "last_batch_size": self.queue_metrics.last_batch_size.load(Ordering::Relaxed),
692                "last_batch_foreground_count": self.queue_metrics.last_batch_foreground_count.load(Ordering::Relaxed),
693                "last_batch_background_count": self.queue_metrics.last_batch_background_count.load(Ordering::Relaxed),
694                "core_restarts": self.recovery.restart_count(),
695                "last_core_panic": self.recovery.last_panic(),
696                "has_cached_restore_action": self.recovery.restore_action().is_some(),
697            }),
698        );
699        serde_json::Value::Object(object).to_string()
700    }
701
702    pub fn listen_for_updates(&self, reconciler: Box<dyn AppReconciler>) {
703        if self
704            .listening
705            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
706            .is_err()
707        {
708            return;
709        }
710
711        let update_rx = self.update_rx.clone();
712        let recovery = self.recovery.clone();
713        let spawn_result = thread::Builder::new()
714            .name("iris-updates".to_string())
715            .spawn(move || {
716                // Drain queued updates and deliver the latest FullState only.
717                // The shell side already discards FullStates with stale `rev`,
718                // but the JNI marshal of an AppState is itself ~20-30 ms and
719                // each push triggers a full Compose recomposition (~400 ms on
720                // Android debug). When the core emits a tight burst of 3-4
721                // updates (OpenChat → SyncComplete → FetchCatchUpEvents → …)
722                // the UI keeps re-rendering for seconds even though only the
723                // final state mattered.
724                //
725                // PersistAccountBundle is a side-effect (key persistence), not
726                // a UI update, so we never collapse those — every one must run.
727                while let Ok(first) = update_rx.recv() {
728                    let mut latest_full_state: Option<AppUpdate> = None;
729                    let mut sidecar: Vec<AppUpdate> = Vec::new();
730                    let process = |update: AppUpdate,
731                                   latest: &mut Option<AppUpdate>,
732                                   side: &mut Vec<AppUpdate>| {
733                        recovery.remember_update(&update);
734                        match update {
735                            full @ AppUpdate::FullState(_) => *latest = Some(full),
736                            other => side.push(other),
737                        }
738                    };
739                    process(first, &mut latest_full_state, &mut sidecar);
740                    while let Ok(next) = update_rx.try_recv() {
741                        process(next, &mut latest_full_state, &mut sidecar);
742                    }
743                    for update in sidecar.into_iter().chain(latest_full_state) {
744                        let kind = match &update {
745                            AppUpdate::FullState(_) => "FullState",
746                            AppUpdate::PersistAccountBundle { .. } => "PersistAccountBundle",
747                            AppUpdate::NearbyPublishedEvent { .. } => "NearbyPublishedEvent",
748                        };
749                        let t0 = crate::perflog::now_ms();
750                        crate::perflog!("reconcile.start kind={kind}");
751                        if panic::catch_unwind(AssertUnwindSafe(|| reconciler.reconcile(update)))
752                            .is_err()
753                        {
754                            crate::perflog!("reconcile.failed kind={kind}");
755                            continue;
756                        }
757                        crate::perflog!(
758                            "reconcile.end kind={kind} elapsed_ms={}",
759                            crate::perflog::now_ms().saturating_sub(t0)
760                        );
761                    }
762                }
763            });
764        if let Err(error) = spawn_result {
765            crate::perflog!("updates.spawn.failed error={error}");
766            self.listening.store(false, Ordering::SeqCst);
767        }
768    }
769}
770
771impl FfiApp {
772    fn shared_db_snapshot(&self) -> Option<crate::core::SharedConnection> {
773        match self.shared_db.read() {
774            Ok(slot) => slot.clone(),
775            Err(poison) => poison.into_inner().clone(),
776        }
777    }
778}
779
780#[uniffi::export]
781impl FfiDesktopNearby {
782    #[uniffi::constructor]
783    pub fn new(app: Arc<FfiApp>, observer: Box<dyn DesktopNearbyObserver>) -> Arc<Self> {
784        Arc::new(Self {
785            service: desktop_nearby::DesktopNearbyService::new(app, observer.into()),
786        })
787    }
788
789    pub fn start(&self, local_name: String) {
790        self.service.start(local_name);
791    }
792
793    pub fn stop(&self) {
794        self.service.stop();
795    }
796
797    pub fn snapshot(&self) -> DesktopNearbySnapshot {
798        self.service.snapshot()
799    }
800
801    pub fn publish(&self, event_id: String, kind: u32, created_at_secs: u64, event_json: String) {
802        self.service
803            .publish(event_id, kind, created_at_secs, event_json);
804    }
805}
806
807fn new_ffi_app_inner(data_dir: String) -> Arc<FfiApp> {
808    let (update_tx, update_rx) = flume::unbounded();
809    let (foreground_tx, foreground_rx) = flume::unbounded();
810    let (background_tx, background_rx) = flume::unbounded();
811    let shared_state = Arc::new(RwLock::new(AppState::empty()));
812    let queue_metrics = Arc::new(CoreQueueMetrics::default());
813    let recovery = Arc::new(CoreRecoveryState::default());
814    let shared_db = Arc::new(RwLock::new(None));
815
816    let update_tx_for_error = update_tx.clone();
817    match AppCore::try_new(
818        update_tx.clone(),
819        background_tx.clone(),
820        data_dir.clone(),
821        shared_state.clone(),
822    ) {
823        Ok(core) => {
824            set_shared_db(&shared_db, Some(core.shared_db()));
825            let spawn_result = spawn_core_supervisor(
826                core,
827                CoreSupervisor {
828                    data_dir,
829                    update_tx: update_tx.clone(),
830                    core_sender: background_tx.clone(),
831                    foreground_rx: foreground_rx.clone(),
832                    background_rx: background_rx.clone(),
833                    shared_state: shared_state.clone(),
834                    shared_db: shared_db.clone(),
835                    queue_metrics: queue_metrics.clone(),
836                    recovery: recovery.clone(),
837                },
838            );
839            if let Err(error) = spawn_result {
840                publish_core_failure_state(
841                    &shared_state,
842                    &update_tx_for_error,
843                    format!("Iris could not start: {error}"),
844                );
845            }
846        }
847        Err(error) => {
848            publish_core_failure_state(&shared_state, &update_tx_for_error, error.to_string());
849        }
850    }
851
852    Arc::new(FfiApp {
853        foreground_tx,
854        foreground_rx,
855        background_tx,
856        background_rx,
857        update_rx,
858        listening: AtomicBool::new(false),
859        shared_state,
860        shared_db,
861        perf: FfiPerfCounters::default(),
862        queue_metrics,
863        recovery,
864    })
865}
866
867fn ffi_app_failure(message: String) -> Arc<FfiApp> {
868    let (_update_tx, update_rx) = flume::unbounded();
869    let (foreground_tx, foreground_rx) = flume::unbounded();
870    let (background_tx, background_rx) = flume::unbounded();
871    let mut state = AppState::empty();
872    state.toast = Some(message);
873    state.rev = 1;
874    let shared_state = Arc::new(RwLock::new(state));
875    Arc::new(FfiApp {
876        foreground_tx,
877        foreground_rx,
878        background_tx,
879        background_rx,
880        update_rx,
881        listening: AtomicBool::new(false),
882        shared_state,
883        shared_db: Arc::new(RwLock::new(None)),
884        perf: FfiPerfCounters::default(),
885        queue_metrics: Arc::new(CoreQueueMetrics::default()),
886        recovery: Arc::new(CoreRecoveryState::default()),
887    })
888}
889
890struct CoreSupervisor {
891    data_dir: String,
892    update_tx: Sender<AppUpdate>,
893    core_sender: Sender<CoreMsg>,
894    foreground_rx: Receiver<CoreMsg>,
895    background_rx: Receiver<CoreMsg>,
896    shared_state: Arc<RwLock<AppState>>,
897    shared_db: Arc<RwLock<Option<crate::core::SharedConnection>>>,
898    queue_metrics: Arc<CoreQueueMetrics>,
899    recovery: Arc<CoreRecoveryState>,
900}
901
902fn spawn_core_supervisor(
903    core: AppCore,
904    supervisor: CoreSupervisor,
905) -> std::io::Result<thread::JoinHandle<()>> {
906    thread::Builder::new()
907        .name("iris-core".to_string())
908        .spawn(move || {
909            let mut core_slot = Some(core);
910            // User actions and synchronous shell requests must not sit behind
911            // relay/nearby backlog. The core keeps internal work on a
912            // background queue and drains it in bounded chunks between
913            // foreground batches.
914            while let Ok(batch) =
915                recv_core_batch(&supervisor.foreground_rx, &supervisor.background_rx)
916            {
917                let batch_size = batch.len();
918                let foreground_count = batch
919                    .iter()
920                    .filter(|msg| is_foreground_core_msg(msg))
921                    .count() as u64;
922                let background_count = batch_size as u64 - foreground_count;
923                supervisor.queue_metrics.mark_batch_start(
924                    batch_size as u64,
925                    foreground_count,
926                    background_count,
927                );
928                let t0 = crate::perflog::now_ms();
929                crate::perflog!("core.batch.start size={batch_size}");
930                let result = match core_slot.as_mut() {
931                    Some(core) => catch_core_batch(|| handle_core_batch_responsive(core, batch)),
932                    None => break,
933                };
934                let elapsed_ms = crate::perflog::now_ms().saturating_sub(t0);
935                supervisor
936                    .queue_metrics
937                    .mark_batch_finished(foreground_count, background_count);
938                match result {
939                    Ok(true) => {
940                        crate::perflog!(
941                            "core.batch.end size={batch_size} elapsed_ms={elapsed_ms}"
942                        );
943                    }
944                    Ok(false) => {
945                        crate::perflog!(
946                            "core.batch.end size={batch_size} elapsed_ms={elapsed_ms} result=shutdown"
947                        );
948                        break;
949                    }
950                    Err(error) => {
951                        if let Some(mut failed_core) = core_slot.take() {
952                            failed_core.record_core_panic(error.clone());
953                        }
954                        crate::perflog!(
955                            "core.batch.end size={batch_size} elapsed_ms={elapsed_ms} result=panic"
956                        );
957                        match recover_core_after_panic(&supervisor, error) {
958                            Some(core) => core_slot = Some(core),
959                            None => break,
960                        }
961                    }
962                }
963            }
964        })
965}
966
967fn recover_core_after_panic(supervisor: &CoreSupervisor, detail: String) -> Option<AppCore> {
968    let restart_count = supervisor.recovery.mark_panic(detail);
969    crate::perflog!("core.supervisor.restart count={restart_count}");
970    set_shared_db(&supervisor.shared_db, None);
971
972    let mut core = match AppCore::try_new(
973        supervisor.update_tx.clone(),
974        supervisor.core_sender.clone(),
975        supervisor.data_dir.clone(),
976        supervisor.shared_state.clone(),
977    ) {
978        Ok(core) => core,
979        Err(error) => {
980            crate::perflog!("core.supervisor.restart.failed count={restart_count} error={error}");
981            publish_core_failure_state(
982                &supervisor.shared_state,
983                &supervisor.update_tx,
984                CORE_RESTART_TOAST.to_string(),
985            );
986            return None;
987        }
988    };
989
990    set_shared_db(&supervisor.shared_db, Some(core.shared_db()));
991    if let Some(action) = supervisor.recovery.restore_action() {
992        crate::perflog!(
993            "core.supervisor.restore action={:?}",
994            std::mem::discriminant(&action)
995        );
996        match catch_core_batch(|| core.handle_message(CoreMsg::Action(action))) {
997            Ok(true) => {}
998            Ok(false) => {
999                publish_core_failure_state(
1000                    &supervisor.shared_state,
1001                    &supervisor.update_tx,
1002                    CORE_RESTART_TOAST.to_string(),
1003                );
1004                return None;
1005            }
1006            Err(error) => {
1007                core.mark_core_panic(format!("core recovery restore panic: {error}"));
1008                return None;
1009            }
1010        }
1011    }
1012
1013    crate::perflog!("core.supervisor.recovered count={restart_count}");
1014    Some(core)
1015}
1016
1017fn set_shared_db(
1018    shared_db: &Arc<RwLock<Option<crate::core::SharedConnection>>>,
1019    value: Option<crate::core::SharedConnection>,
1020) {
1021    match shared_db.write() {
1022        Ok(mut slot) => *slot = value,
1023        Err(poison) => *poison.into_inner() = value,
1024    }
1025}
1026
1027fn publish_core_failure_state(
1028    shared_state: &Arc<RwLock<AppState>>,
1029    update_tx: &Sender<AppUpdate>,
1030    message: String,
1031) {
1032    let mut state = match shared_state.read() {
1033        Ok(slot) => slot.clone(),
1034        Err(poison) => poison.into_inner().clone(),
1035    };
1036    state.toast = Some(message);
1037    state.rev = state.rev.saturating_add(1).max(1);
1038    match shared_state.write() {
1039        Ok(mut slot) => *slot = state.clone(),
1040        Err(poison) => *poison.into_inner() = state.clone(),
1041    }
1042    let _ = update_tx.send(AppUpdate::FullState(state));
1043}
1044
1045const CORE_FOREGROUND_BATCH_LIMIT: usize = 64;
1046const CORE_BACKGROUND_BATCH_LIMIT: usize = 16;
1047
1048fn recv_core_batch(
1049    foreground_rx: &Receiver<CoreMsg>,
1050    background_rx: &Receiver<CoreMsg>,
1051) -> Result<Vec<CoreMsg>, flume::RecvError> {
1052    if let Some(batch) = try_recv_core_batch(foreground_rx, background_rx) {
1053        return Ok(batch);
1054    }
1055
1056    let (is_foreground, first) = flume::Selector::new()
1057        .recv(foreground_rx, |result| result.map(|msg| (true, msg)))
1058        .recv(background_rx, |result| result.map(|msg| (false, msg)))
1059        .wait()?;
1060    Ok(drain_core_batch_after_first(
1061        is_foreground,
1062        first,
1063        foreground_rx,
1064        background_rx,
1065    ))
1066}
1067
1068fn try_recv_core_batch(
1069    foreground_rx: &Receiver<CoreMsg>,
1070    background_rx: &Receiver<CoreMsg>,
1071) -> Option<Vec<CoreMsg>> {
1072    if let Ok(first) = foreground_rx.try_recv() {
1073        return Some(drain_core_batch_after_first(
1074            true,
1075            first,
1076            foreground_rx,
1077            background_rx,
1078        ));
1079    }
1080    background_rx
1081        .try_recv()
1082        .ok()
1083        .map(|first| drain_core_batch_after_first(false, first, foreground_rx, background_rx))
1084}
1085
1086fn drain_core_batch_after_first(
1087    is_foreground: bool,
1088    first: CoreMsg,
1089    foreground_rx: &Receiver<CoreMsg>,
1090    background_rx: &Receiver<CoreMsg>,
1091) -> Vec<CoreMsg> {
1092    let mut batch = Vec::with_capacity(if is_foreground {
1093        CORE_FOREGROUND_BATCH_LIMIT
1094    } else {
1095        CORE_BACKGROUND_BATCH_LIMIT
1096    });
1097    batch.push(first);
1098    drain_foreground_messages(&mut batch, foreground_rx);
1099    if batch.iter().any(is_foreground_core_msg) {
1100        return batch;
1101    }
1102
1103    while batch.len() < CORE_BACKGROUND_BATCH_LIMIT {
1104        let Ok(next) = background_rx.try_recv() else {
1105            break;
1106        };
1107        batch.push(next);
1108        drain_foreground_messages(&mut batch, foreground_rx);
1109        if batch.iter().any(is_foreground_core_msg) {
1110            break;
1111        }
1112    }
1113    batch
1114}
1115
1116fn drain_foreground_messages(batch: &mut Vec<CoreMsg>, foreground_rx: &Receiver<CoreMsg>) {
1117    while batch.len() < CORE_FOREGROUND_BATCH_LIMIT {
1118        let Ok(next) = foreground_rx.try_recv() else {
1119            break;
1120        };
1121        batch.push(next);
1122    }
1123}
1124
1125fn handle_core_batch_responsive(core: &mut AppCore, messages: Vec<CoreMsg>) -> bool {
1126    if messages.len() <= 1 || !messages.iter().any(is_foreground_core_msg) {
1127        return core.handle_messages(messages);
1128    }
1129
1130    let mut foreground = Vec::new();
1131    let mut background = Vec::new();
1132    for message in messages {
1133        if is_foreground_core_msg(&message) {
1134            foreground.push(message);
1135        } else {
1136            background.push(message);
1137        }
1138    }
1139
1140    for message in foreground {
1141        if !core.handle_message(message) {
1142            return false;
1143        }
1144    }
1145    background.is_empty() || core.handle_messages(background)
1146}
1147
1148fn catch_core_batch<F>(f: F) -> Result<bool, String>
1149where
1150    F: FnOnce() -> bool,
1151{
1152    panic::catch_unwind(AssertUnwindSafe(f)).map_err(panic_payload_to_string)
1153}
1154
1155fn ffi_or<T, F>(label: &'static str, fallback: T, f: F) -> T
1156where
1157    F: FnOnce() -> T,
1158{
1159    match panic::catch_unwind(AssertUnwindSafe(f)) {
1160        Ok(value) => value,
1161        Err(payload) => {
1162            crate::perflog!(
1163                "ffi.panic label={label} detail={}",
1164                panic_payload_to_string(payload)
1165            );
1166            fallback
1167        }
1168    }
1169}
1170
1171fn ffi_failure_state() -> AppState {
1172    let mut state = AppState::empty();
1173    state.toast = Some("Iris needs restart. Copy support bundle in Settings.".to_string());
1174    state
1175}
1176
1177fn suppressed_mobile_push_resolution() -> MobilePushNotificationResolution {
1178    MobilePushNotificationResolution {
1179        should_show: false,
1180        title: String::new(),
1181        body: String::new(),
1182        payload_json: "{}".to_string(),
1183    }
1184}
1185
1186fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
1187    if let Some(message) = payload.downcast_ref::<&str>() {
1188        (*message).to_string()
1189    } else if let Some(message) = payload.downcast_ref::<String>() {
1190        message.clone()
1191    } else {
1192        "unknown panic".to_string()
1193    }
1194}
1195
1196fn is_foreground_core_msg(message: &CoreMsg) -> bool {
1197    !matches!(message, CoreMsg::Internal(_))
1198}
1199
1200#[cfg(test)]
1201mod core_queue_tests {
1202    use super::*;
1203
1204    fn background_msg(index: usize) -> CoreMsg {
1205        CoreMsg::Internal(Box::new(InternalEvent::DebugLog {
1206            category: "test.background".to_string(),
1207            detail: index.to_string(),
1208        }))
1209    }
1210
1211    #[test]
1212    fn foreground_queue_preempts_background_backlog() {
1213        let (foreground_tx, foreground_rx) = flume::unbounded();
1214        let (background_tx, background_rx) = flume::unbounded();
1215        for index in 0..100 {
1216            background_tx.send(background_msg(index)).unwrap();
1217        }
1218        foreground_tx
1219            .send(CoreMsg::Action(AppAction::NavigateBack))
1220            .unwrap();
1221
1222        let batch = recv_core_batch(&foreground_rx, &background_rx).unwrap();
1223
1224        assert!(matches!(
1225            batch.first(),
1226            Some(CoreMsg::Action(AppAction::NavigateBack))
1227        ));
1228        assert!(
1229            batch.iter().all(is_foreground_core_msg),
1230            "foreground work should not be bundled behind background backlog"
1231        );
1232    }
1233
1234    #[test]
1235    fn background_queue_drains_in_bounded_chunks() {
1236        let (_foreground_tx, foreground_rx) = flume::unbounded();
1237        let (background_tx, background_rx) = flume::unbounded();
1238        for index in 0..100 {
1239            background_tx.send(background_msg(index)).unwrap();
1240        }
1241
1242        let batch = recv_core_batch(&foreground_rx, &background_rx).unwrap();
1243
1244        assert_eq!(batch.len(), CORE_BACKGROUND_BATCH_LIMIT);
1245        assert!(batch.iter().all(|msg| !is_foreground_core_msg(msg)));
1246    }
1247
1248    #[test]
1249    fn route_chat_snapshot_uses_chat_list_without_core_queue() {
1250        let state = build_large_test_app_state(80, 20, 1_200);
1251        let chat_id = state.chat_list[10].chat_id.clone();
1252
1253        let snapshot =
1254            crate::core::chat_snapshot_from_state_and_db(&state, None, &chat_id, 80).unwrap();
1255
1256        assert_eq!(snapshot.chat_id, chat_id);
1257        assert_eq!(snapshot.display_name, state.chat_list[10].display_name);
1258        assert!(snapshot.messages.is_empty());
1259    }
1260
1261    #[test]
1262    fn route_chat_snapshot_requires_account() {
1263        let mut state = build_large_test_app_state(80, 20, 1_200);
1264        state.account = None;
1265        let chat_id = state.chat_list[10].chat_id.clone();
1266
1267        assert!(crate::core::chat_snapshot_from_state_and_db(&state, None, &chat_id, 80).is_none());
1268    }
1269}
1270
1271fn filter_threads_for_search(
1272    chat_list: &[ChatThreadSnapshot],
1273    query: &str,
1274) -> (Vec<ChatThreadSnapshot>, Vec<ChatThreadSnapshot>) {
1275    let needle = query.to_lowercase();
1276    let mut contacts = Vec::new();
1277    let mut groups = Vec::new();
1278    for chat in chat_list {
1279        if !thread_matches_query(chat, &needle) {
1280            continue;
1281        }
1282        match chat.kind {
1283            ChatKind::Direct => contacts.push(chat.clone()),
1284            ChatKind::Group => groups.push(chat.clone()),
1285        }
1286    }
1287    (contacts, groups)
1288}
1289
1290fn thread_matches_query(chat: &ChatThreadSnapshot, needle_lower: &str) -> bool {
1291    let candidates: [&str; 4] = [
1292        &chat.display_name,
1293        chat.subtitle.as_deref().unwrap_or(""),
1294        &chat.draft,
1295        &chat.chat_id,
1296    ];
1297    candidates
1298        .iter()
1299        .any(|field| field.to_lowercase().contains(needle_lower))
1300}
1301
1302fn enrich_message_hits(
1303    hits: Vec<crate::core::PersistedMessageSearchHit>,
1304    chat_list: &[ChatThreadSnapshot],
1305) -> Vec<MessageSearchHit> {
1306    use std::collections::HashMap;
1307    let lookup: HashMap<&str, &ChatThreadSnapshot> = chat_list
1308        .iter()
1309        .map(|chat| (chat.chat_id.as_str(), chat))
1310        .collect();
1311    hits.into_iter()
1312        .map(|hit| {
1313            let parent = lookup.get(hit.chat_id.as_str());
1314            let display_name = parent
1315                .map(|chat| chat.display_name.clone())
1316                .unwrap_or_else(|| short_chat_label(&hit.chat_id));
1317            let picture_url = parent.and_then(|chat| chat.picture_url.clone());
1318            let kind = parent
1319                .map(|chat| chat.kind.clone())
1320                .unwrap_or(ChatKind::Direct);
1321            MessageSearchHit {
1322                chat_id: hit.chat_id,
1323                message_id: hit.message_id,
1324                chat_display_name: display_name,
1325                chat_picture_url: picture_url,
1326                chat_kind: kind,
1327                author_pubkey: hit.author,
1328                body: hit.body,
1329                is_outgoing: hit.is_outgoing,
1330                created_at_secs: hit.created_at_secs,
1331            }
1332        })
1333        .collect()
1334}
1335
1336fn short_chat_label(chat_id: &str) -> String {
1337    let trimmed = chat_id.trim();
1338    if trimmed.len() > 12 {
1339        format!("{}…", &trimmed[..12])
1340    } else {
1341        trimmed.to_string()
1342    }
1343}
1344
1345fn verify_nearby_presence_event_json(
1346    event_json: &str,
1347    peer_id: &str,
1348    my_nonce: &str,
1349    their_nonce: &str,
1350) -> String {
1351    let Ok(event) = serde_json::from_str::<nostr_sdk::prelude::Event>(event_json) else {
1352        return String::new();
1353    };
1354    if event.verify().is_err() || event.kind.as_u16() != crate::core::NEARBY_PRESENCE_KIND {
1355        return String::new();
1356    }
1357    let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
1358        return String::new();
1359    };
1360    let get = |key: &str| {
1361        content
1362            .get(key)
1363            .and_then(|value| value.as_str())
1364            .unwrap_or("")
1365    };
1366    let transport = get("transport");
1367    if get("protocol") != "iris-nearby-v1"
1368        || !(transport == "ble" || transport == "nearby" || transport == "lan")
1369        || get("peer_id") != peer_id.trim()
1370        || get("my_nonce") != their_nonce.trim()
1371        || get("their_nonce") != my_nonce.trim()
1372    {
1373        return String::new();
1374    }
1375
1376    let now = SystemTime::now()
1377        .duration_since(UNIX_EPOCH)
1378        .unwrap_or_default()
1379        .as_secs();
1380    let expires_at = content
1381        .get("expires_at")
1382        .and_then(|value| value.as_u64())
1383        .unwrap_or(0);
1384    let created_at = event.created_at.as_secs();
1385    if expires_at < now
1386        || expires_at > now.saturating_add(300)
1387        || created_at.saturating_add(300) < now
1388        || created_at > now.saturating_add(300)
1389    {
1390        return String::new();
1391    }
1392
1393    let profile_event_id = get("profile_event_id");
1394    let profile_event_id = if profile_event_id.len() == 64 {
1395        profile_event_id
1396    } else {
1397        ""
1398    };
1399    serde_json::json!({
1400        "owner_pubkey_hex": event.pubkey.to_hex(),
1401        "profile_event_id": profile_event_id,
1402    })
1403    .to_string()
1404}
1405
1406impl Drop for FfiApp {
1407    fn drop(&mut self) {
1408        let _ = self.foreground_tx.send(CoreMsg::Shutdown(None));
1409    }
1410}
1411
1412#[uniffi::export]
1413pub fn normalize_peer_input(input: String) -> String {
1414    ffi_or("normalize_peer_input", String::new(), || {
1415        crate::core::normalize_peer_input_for_display(&input)
1416    })
1417}
1418
1419#[uniffi::export]
1420pub fn is_valid_peer_input(input: String) -> bool {
1421    ffi_or("is_valid_peer_input", false, || {
1422        crate::core::parse_peer_input(&input).is_ok()
1423    })
1424}
1425
1426/// Single source of truth for "is this typed text an npub or an
1427/// invite URL?". Used by the New Chat paste field, the chat-list
1428/// search bar, and the deep-link handler so all three branches agree
1429/// on what counts as a chat-opening shortcut. Returns `None` for
1430/// regular search-style text.
1431#[uniffi::export]
1432pub fn classify_chat_input(input: String) -> Option<ChatInputShortcut> {
1433    ffi_or("classify_chat_input", None, || chat_input_shortcut(&input))
1434}
1435
1436fn chat_input_shortcut(raw: &str) -> Option<ChatInputShortcut> {
1437    let trimmed = raw.trim();
1438    if trimmed.is_empty() {
1439        return None;
1440    }
1441    let lower = trimmed.to_lowercase();
1442    if lower.contains("://") && lower.contains('#') {
1443        return Some(ChatInputShortcut::Invite {
1444            invite_input: trimmed.to_string(),
1445            display: short_invite_display(trimmed),
1446        });
1447    }
1448    if crate::core::parse_peer_input(trimmed).is_ok() {
1449        use nostr::nips::nip19::ToBech32;
1450        let normalized = crate::core::normalize_peer_input_for_display(trimmed);
1451        if let Ok(pubkey) = nostr::PublicKey::parse(&normalized) {
1452            let npub = pubkey.to_bech32().unwrap_or_else(|_| normalized.clone());
1453            let display = short_npub_display(&npub);
1454            return Some(ChatInputShortcut::DirectPeer {
1455                peer_input: normalized,
1456                display,
1457                npub,
1458                pubkey_hex: pubkey.to_hex(),
1459            });
1460        }
1461    }
1462    None
1463}
1464
1465fn short_npub_display(npub: &str) -> String {
1466    if npub.len() > 16 {
1467        format!("{}…{}", &npub[..10], &npub[npub.len() - 4..])
1468    } else {
1469        npub.to_string()
1470    }
1471}
1472
1473fn short_invite_display(invite: &str) -> String {
1474    // Strip the scheme + host so the row reads "iris.to/invite/…" not
1475    // a 120-char URL. We don't try to parse the bech32 payload; the
1476    // visible host is enough context for the user.
1477    let after_scheme = invite
1478        .split_once("://")
1479        .map(|(_, rest)| rest)
1480        .unwrap_or(invite);
1481    if after_scheme.len() > 32 {
1482        format!("{}…", &after_scheme[..32])
1483    } else {
1484        after_scheme.to_string()
1485    }
1486}
1487
1488/// Convert any pubkey-shaped input (hex, npub, nprofile, …) to its
1489/// canonical lowercase-hex form. The empty string is returned when the
1490/// input can't be parsed as a public key — callers expecting hex
1491/// downstream can short-circuit on that.
1492#[uniffi::export]
1493pub fn peer_input_to_hex(input: String) -> String {
1494    ffi_or("peer_input_to_hex", String::new(), || {
1495        let normalized = crate::core::normalize_peer_input_for_display(&input);
1496        match nostr::PublicKey::parse(&normalized) {
1497            Ok(pubkey) => pubkey.to_hex(),
1498            Err(_) => String::new(),
1499        }
1500    })
1501}
1502
1503/// Convert any pubkey-shaped input (hex, npub, nprofile, …) to its npub form.
1504/// Returns the original string when it can't be parsed as a public key.
1505#[uniffi::export]
1506pub fn peer_input_to_npub(input: String) -> String {
1507    ffi_or("peer_input_to_npub", String::new(), || {
1508        use nostr::nips::nip19::ToBech32;
1509        let normalized = crate::core::normalize_peer_input_for_display(&input);
1510        match nostr::PublicKey::parse(&normalized) {
1511            Ok(pubkey) => pubkey.to_bech32().unwrap_or(normalized),
1512            Err(_) => normalized,
1513        }
1514    })
1515}
1516
1517#[uniffi::export]
1518pub fn build_summary() -> String {
1519    ffi_or("build_summary", String::new(), crate::core::build_summary)
1520}
1521
1522#[uniffi::export]
1523pub fn relay_set_id() -> String {
1524    ffi_or("relay_set_id", String::new(), || {
1525        crate::core::relay_set_id().to_string()
1526    })
1527}
1528
1529#[uniffi::export]
1530pub fn proxied_image_url(
1531    original_src: String,
1532    preferences: PreferencesSnapshot,
1533    width: Option<u32>,
1534    height: Option<u32>,
1535    square: bool,
1536) -> String {
1537    ffi_or("proxied_image_url", original_src.clone(), || {
1538        image_proxy::proxied_image_url(&original_src, &preferences, width, height, square)
1539    })
1540}
1541
1542#[uniffi::export]
1543pub fn is_trusted_test_build() -> bool {
1544    ffi_or(
1545        "is_trusted_test_build",
1546        false,
1547        crate::core::trusted_test_build_flag,
1548    )
1549}
1550
1551/// Marketing version baked in at build time from `IRIS_APP_VERSION_NAME`
1552/// (or `IRIS_APP_VERSION`), falling back to the crate semver. Use this
1553/// instead of `env!("CARGO_PKG_VERSION")` so UI/release artifacts agree
1554/// on a single version string.
1555#[uniffi::export]
1556pub fn app_version() -> String {
1557    crate::core::app_version_string().to_string()
1558}
1559
1560#[uniffi::export]
1561pub fn resolve_mobile_push_notification_payload(
1562    raw_payload_json: String,
1563) -> MobilePushNotificationResolution {
1564    ffi_or(
1565        "resolve_mobile_push_notification_payload",
1566        suppressed_mobile_push_resolution(),
1567        || crate::core::resolve_mobile_push_notification(raw_payload_json),
1568    )
1569}
1570
1571/// Decrypt a notification payload against the persisted double-ratchet
1572/// state under `data_dir`. Use from the FCM service (Android) or
1573/// Notification Service Extension (iOS) where there's no live `FfiApp`.
1574/// Falls back to the generic resolver when keys, payload, or storage
1575/// are unavailable so the user still gets *some* notification.
1576#[uniffi::export]
1577pub fn decrypt_mobile_push_notification_payload(
1578    data_dir: String,
1579    owner_pubkey_hex: String,
1580    device_nsec: String,
1581    raw_payload_json: String,
1582) -> MobilePushNotificationResolution {
1583    ffi_or(
1584        "decrypt_mobile_push_notification_payload",
1585        suppressed_mobile_push_resolution(),
1586        || {
1587            crate::core::decrypt_mobile_push_notification(
1588                data_dir,
1589                owner_pubkey_hex,
1590                device_nsec,
1591                raw_payload_json,
1592            )
1593        },
1594    )
1595}
1596
1597#[uniffi::export]
1598pub fn resolve_mobile_push_subscription_server_url(
1599    platform_key: String,
1600    is_release: bool,
1601    override_url: Option<String>,
1602) -> String {
1603    ffi_or(
1604        "resolve_mobile_push_subscription_server_url",
1605        String::new(),
1606        || crate::core::resolve_mobile_push_server_url(platform_key, is_release, override_url),
1607    )
1608}
1609
1610#[uniffi::export]
1611pub fn mobile_push_subscription_id_key(platform_key: String) -> String {
1612    ffi_or("mobile_push_subscription_id_key", String::new(), || {
1613        crate::core::mobile_push_stored_subscription_id_key(platform_key)
1614    })
1615}
1616
1617#[uniffi::export]
1618pub fn build_mobile_push_list_subscriptions_request(
1619    owner_nsec: String,
1620    platform_key: String,
1621    is_release: bool,
1622    server_url_override: Option<String>,
1623) -> Option<MobilePushSubscriptionRequest> {
1624    ffi_or("build_mobile_push_list_subscriptions_request", None, || {
1625        crate::core::build_mobile_push_list_subscriptions_request(
1626            owner_nsec,
1627            platform_key,
1628            is_release,
1629            server_url_override,
1630        )
1631    })
1632}
1633
1634#[uniffi::export]
1635#[allow(clippy::too_many_arguments)]
1636pub fn build_mobile_push_create_subscription_request(
1637    owner_nsec: String,
1638    platform_key: String,
1639    push_token: String,
1640    apns_topic: Option<String>,
1641    message_author_pubkeys: Vec<String>,
1642    invite_response_pubkeys: Vec<String>,
1643    is_release: bool,
1644    server_url_override: Option<String>,
1645) -> Option<MobilePushSubscriptionRequest> {
1646    ffi_or(
1647        "build_mobile_push_create_subscription_request",
1648        None,
1649        || {
1650            crate::core::build_mobile_push_create_subscription_request(
1651                owner_nsec,
1652                platform_key,
1653                push_token,
1654                apns_topic,
1655                message_author_pubkeys,
1656                invite_response_pubkeys,
1657                is_release,
1658                server_url_override,
1659            )
1660        },
1661    )
1662}
1663
1664#[uniffi::export]
1665#[allow(clippy::too_many_arguments)]
1666pub fn build_mobile_push_update_subscription_request(
1667    owner_nsec: String,
1668    subscription_id: String,
1669    platform_key: String,
1670    push_token: String,
1671    apns_topic: Option<String>,
1672    message_author_pubkeys: Vec<String>,
1673    invite_response_pubkeys: Vec<String>,
1674    is_release: bool,
1675    server_url_override: Option<String>,
1676) -> Option<MobilePushSubscriptionRequest> {
1677    ffi_or(
1678        "build_mobile_push_update_subscription_request",
1679        None,
1680        || {
1681            crate::core::build_mobile_push_update_subscription_request(
1682                owner_nsec,
1683                subscription_id,
1684                platform_key,
1685                push_token,
1686                apns_topic,
1687                message_author_pubkeys,
1688                invite_response_pubkeys,
1689                is_release,
1690                server_url_override,
1691            )
1692        },
1693    )
1694}
1695
1696#[uniffi::export]
1697pub fn build_mobile_push_delete_subscription_request(
1698    owner_nsec: String,
1699    subscription_id: String,
1700    platform_key: String,
1701    is_release: bool,
1702    server_url_override: Option<String>,
1703) -> Option<MobilePushSubscriptionRequest> {
1704    ffi_or(
1705        "build_mobile_push_delete_subscription_request",
1706        None,
1707        || {
1708            crate::core::build_mobile_push_delete_subscription_request(
1709                owner_nsec,
1710                subscription_id,
1711                platform_key,
1712                is_release,
1713                server_url_override,
1714            )
1715        },
1716    )
1717}
1718
1719#[cfg(test)]
1720mod ffi_hardening_tests {
1721    use super::*;
1722
1723    #[test]
1724    fn ffi_guard_returns_fallback_after_panic() {
1725        let value = ffi_or("test.panic", 42, || -> i32 {
1726            panic!("ffi boom");
1727        });
1728
1729        assert_eq!(value, 42);
1730    }
1731
1732    #[test]
1733    fn core_batch_guard_converts_panic_to_error() {
1734        let result = catch_core_batch(|| -> bool {
1735            panic!("batch boom");
1736        });
1737
1738        assert_eq!(result, Err("batch boom".to_string()));
1739    }
1740
1741    #[test]
1742    fn core_batch_guard_preserves_success_result() {
1743        assert_eq!(catch_core_batch(|| true), Ok(true));
1744        assert_eq!(catch_core_batch(|| false), Ok(false));
1745    }
1746
1747    #[test]
1748    fn recovery_state_tracks_restore_action_and_logout() {
1749        let recovery = CoreRecoveryState::default();
1750        recovery.remember_action(&AppAction::RestoreSession {
1751            owner_nsec: "secret".to_string(),
1752        });
1753
1754        match recovery.restore_action() {
1755            Some(AppAction::RestoreSession { owner_nsec }) => assert_eq!(owner_nsec, "secret"),
1756            other => panic!("unexpected restore action: {other:?}"),
1757        }
1758
1759        recovery.remember_action(&AppAction::Logout);
1760        assert!(recovery.restore_action().is_none());
1761    }
1762
1763    #[test]
1764    fn recovery_state_tracks_persisted_account_bundle() {
1765        let recovery = CoreRecoveryState::default();
1766        recovery.remember_update(&AppUpdate::PersistAccountBundle {
1767            rev: 7,
1768            owner_nsec: None,
1769            owner_pubkey_hex: "owner".to_string(),
1770            device_nsec: "device-secret".to_string(),
1771        });
1772
1773        match recovery.restore_action() {
1774            Some(AppAction::RestoreAccountBundle {
1775                owner_nsec,
1776                owner_pubkey_hex,
1777                device_nsec,
1778            }) => {
1779                assert_eq!(owner_nsec, None);
1780                assert_eq!(owner_pubkey_hex, "owner");
1781                assert_eq!(device_nsec, "device-secret");
1782            }
1783            other => panic!("unexpected restore action: {other:?}"),
1784        }
1785    }
1786
1787    #[test]
1788    fn core_supervisor_recovers_after_batch_panic() {
1789        let temp_dir = tempfile::TempDir::new().expect("temp dir");
1790        let app = new_ffi_app_inner(temp_dir.path().to_string_lossy().to_string());
1791
1792        app.foreground_tx
1793            .send(CoreMsg::PanicForTest)
1794            .expect("send test panic");
1795
1796        for _ in 0..40 {
1797            if app.recovery.restart_count() > 0 {
1798                break;
1799            }
1800            thread::sleep(Duration::from_millis(25));
1801        }
1802        assert_eq!(app.recovery.restart_count(), 1);
1803
1804        let (reply_tx, reply_rx) = flume::bounded(1);
1805        app.foreground_tx
1806            .send(CoreMsg::CorePerfCounters(reply_tx))
1807            .expect("send post-recovery request");
1808        assert!(reply_rx.recv_timeout(Duration::from_secs(2)).is_ok());
1809
1810        app.shutdown();
1811    }
1812}