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