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