Skip to main content

iris_chat_core/
lib.rs

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