Skip to main content

iris_chat_core/
lib.rs

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