Skip to main content

iris_chat_core/
lib.rs

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