Skip to main content

iris_chat_core/
lib.rs

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