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