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