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