1mod actions;
2mod core;
3mod desktop_nearby;
4mod desktop_update;
5mod emoji;
6mod fips_ble_ffi;
7pub mod image_proxy;
8pub mod local_relay;
9pub mod perflog;
10mod qr;
11mod state;
12mod test_fixtures;
13#[doc(hidden)]
14pub mod update_announcements;
15pub mod update_policy;
16mod updates;
17
18use std::any::Any;
19use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
20use std::sync::{Arc, RwLock};
21use std::thread;
22use std::time::Duration;
23use std::{panic, panic::AssertUnwindSafe};
24
25use flume::{Receiver, Sender};
26
27pub use actions::AppAction;
28#[cfg(feature = "stack-fixture")]
29#[doc(hidden)]
30pub use core::download_hashtree_attachment;
31pub use desktop_nearby::*;
32pub use desktop_update::*;
33pub use emoji::*;
34pub use fips_ble_ffi::*;
35pub use qr::*;
36pub use state::*;
37pub use test_fixtures::*;
38pub use update_policy::UpdateAutoCheckPolicy;
39pub use updates::*;
40
41use crate::core::AppCore;
42
43uniffi::setup_scaffolding!();
44
45pub(crate) const CORE_RESTART_TOAST: &str = "Iris needs restart. Copy support bundle in Settings.";
46const SUPPORT_BUNDLE_REPLY_TIMEOUT: Duration = Duration::from_secs(8);
47
48#[uniffi::export(callback_interface)]
49pub trait AppReconciler: Send + Sync + 'static {
50 fn reconcile(&self, update: AppUpdate);
51}
52
53#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
54pub enum FipsBleCommand {
55 Listen {
56 request_id: u64,
57 preferred_psm: u16,
58 },
59 StopListening,
60 StartAdvertising {
61 request_id: u64,
62 bootstrap: Vec<u8>,
63 },
64 StopAdvertising {
65 request_id: u64,
66 },
67 StartScanning {
68 request_id: u64,
69 },
70 StopScanning,
71 Connect {
72 request_id: u64,
73 peer_token: String,
74 psm: u16,
75 },
76 Write {
77 request_id: u64,
78 connection_id: u64,
79 bytes: Vec<u8>,
80 },
81 Close {
82 connection_id: u64,
83 },
84}
85
86#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
87pub enum FipsBleEvent {
88 Listening {
89 request_id: u64,
90 psm: u16,
91 },
92 AdvertisingStarted {
93 request_id: u64,
94 },
95 AdvertisingStopped {
96 request_id: u64,
97 },
98 ScanningStarted {
99 request_id: u64,
100 },
101 PeerDiscovered {
102 peer_token: String,
103 bootstrap: Vec<u8>,
104 },
105 Connected {
106 request_id: u64,
107 connection_id: u64,
108 peer_token: String,
109 send_segment_mtu: u16,
110 receive_segment_mtu: u16,
111 },
112 IncomingConnection {
113 connection_id: u64,
114 peer_token: String,
115 send_segment_mtu: u16,
116 receive_segment_mtu: u16,
117 },
118 BytesReceived {
119 connection_id: u64,
120 bytes: Vec<u8>,
121 },
122 WriteCompleted {
123 request_id: u64,
124 },
125 Disconnected {
126 connection_id: u64,
127 reason: Option<String>,
128 },
129 Failed {
130 request_id: u64,
131 message: String,
132 },
133}
134
135#[derive(Default, Debug)]
148pub(crate) struct FfiPerfCounters {
149 pub state: AtomicU64,
150 pub dispatch: AtomicU64,
151 pub search: AtomicU64,
152 pub export_support_bundle_json: AtomicU64,
153 pub peer_profile_debug: AtomicU64,
154 pub mutual_groups: AtomicU64,
155 pub prepare_for_suspend: AtomicU64,
156}
157
158#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq, Default)]
159pub struct FfiPerfCountersSnapshot {
160 pub state: u64,
161 pub dispatch: u64,
162 pub search: u64,
163 pub export_support_bundle_json: u64,
164 pub peer_profile_debug: u64,
165 pub mutual_groups: u64,
166 pub prepare_for_suspend: u64,
167}
168
169#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq, Default)]
178pub struct CorePerfCountersSnapshot {
179 pub debug_snapshot_builds: u64,
180}
181
182#[derive(uniffi::Object)]
183pub struct FfiApp {
184 foreground_tx: Sender<CoreMsg>,
185 foreground_rx: Receiver<CoreMsg>,
186 background_tx: Sender<CoreMsg>,
187 background_rx: Receiver<CoreMsg>,
188 update_rx: Receiver<AppUpdate>,
189 listening: AtomicBool,
190 shared_state: Arc<RwLock<AppState>>,
191 shared_db: Arc<RwLock<Option<crate::core::SharedConnection>>>,
194 perf: FfiPerfCounters,
195 queue_metrics: Arc<CoreQueueMetrics>,
196 recovery: Arc<CoreRecoveryState>,
197}
198
199#[derive(Default, Debug)]
200struct CoreQueueMetrics {
201 foreground_processed: AtomicU64,
202 background_processed: AtomicU64,
203 batch_active: AtomicBool,
204 last_batch_started_at_ms: AtomicU64,
205 last_batch_finished_at_ms: AtomicU64,
206 last_batch_size: AtomicU64,
207 last_batch_foreground_count: AtomicU64,
208 last_batch_background_count: AtomicU64,
209}
210
211impl CoreQueueMetrics {
212 fn mark_batch_start(&self, size: u64, foreground: u64, background: u64) {
213 self.last_batch_started_at_ms
214 .store(crate::perflog::now_ms(), Ordering::Relaxed);
215 self.last_batch_size.store(size, Ordering::Relaxed);
216 self.last_batch_foreground_count
217 .store(foreground, Ordering::Relaxed);
218 self.last_batch_background_count
219 .store(background, Ordering::Relaxed);
220 self.batch_active.store(true, Ordering::Release);
221 }
222
223 fn mark_batch_finished(&self, foreground: u64, background: u64) {
224 self.foreground_processed
225 .fetch_add(foreground, Ordering::Relaxed);
226 self.background_processed
227 .fetch_add(background, Ordering::Relaxed);
228 self.last_batch_finished_at_ms
229 .store(crate::perflog::now_ms(), Ordering::Relaxed);
230 self.batch_active.store(false, Ordering::Release);
231 }
232}
233
234#[derive(Default, Debug)]
235struct CoreRecoveryState {
236 restore_action: RwLock<Option<AppAction>>,
237 restart_count: AtomicU64,
238 last_panic: RwLock<Option<String>>,
239}
240
241impl CoreRecoveryState {
242 fn remember_action(&self, action: &AppAction) {
243 match action {
244 AppAction::RestoreSession { .. }
245 | AppAction::RestoreAccountBundle { .. }
246 | AppAction::RestorePendingDeviceLink { .. } => {
247 self.set_restore_action(Some(action.clone()));
248 }
249 AppAction::Logout => self.set_restore_action(None),
250 _ => {}
251 }
252 }
253
254 fn remember_update(&self, update: &AppUpdate) {
255 match update {
256 AppUpdate::PersistAccountBundle {
257 owner_nsec,
258 owner_pubkey_hex,
259 device_nsec,
260 ..
261 } => self.set_restore_action(Some(AppAction::RestoreAccountBundle {
262 owner_nsec: owner_nsec.clone(),
263 owner_pubkey_hex: owner_pubkey_hex.clone(),
264 device_nsec: device_nsec.clone(),
265 })),
266 AppUpdate::PersistPendingDeviceLink {
267 device_nsec,
268 approval_bootstrap_json,
269 } => self.set_restore_action(Some(AppAction::RestorePendingDeviceLink {
270 device_nsec: device_nsec.clone(),
271 approval_bootstrap_json: approval_bootstrap_json.clone(),
272 })),
273 AppUpdate::ClearPendingDeviceLink => self.set_restore_action(None),
274 _ => {}
275 }
276 }
277
278 fn restore_action(&self) -> Option<AppAction> {
279 match self.restore_action.read() {
280 Ok(action) => action.clone(),
281 Err(poison) => poison.into_inner().clone(),
282 }
283 }
284
285 fn mark_panic(&self, detail: String) -> u64 {
286 match self.last_panic.write() {
287 Ok(mut slot) => *slot = Some(detail),
288 Err(poison) => *poison.into_inner() = Some(detail),
289 }
290 self.restart_count.fetch_add(1, Ordering::Relaxed) + 1
291 }
292
293 fn restart_count(&self) -> u64 {
294 self.restart_count.load(Ordering::Relaxed)
295 }
296
297 fn last_panic(&self) -> Option<String> {
298 match self.last_panic.read() {
299 Ok(slot) => slot.clone(),
300 Err(poison) => poison.into_inner().clone(),
301 }
302 }
303
304 fn set_restore_action(&self, action: Option<AppAction>) {
305 match self.restore_action.write() {
306 Ok(mut slot) => *slot = action,
307 Err(poison) => *poison.into_inner() = action,
308 }
309 }
310}
311
312#[uniffi::export]
313impl FfiApp {
314 #[uniffi::constructor]
315 pub fn new(data_dir: String, _keychain_group: String, _app_version: String) -> Arc<Self> {
316 match panic::catch_unwind(AssertUnwindSafe(|| new_ffi_app_inner(data_dir))) {
317 Ok(app) => app,
318 Err(payload) => ffi_app_failure(format!(
319 "Iris could not start: {}",
320 panic_payload_to_string(payload)
321 )),
322 }
323 }
324
325 pub fn state(&self) -> AppState {
326 self.perf.state.fetch_add(1, Ordering::Relaxed);
327 ffi_or("ffiapp.state", ffi_failure_state(), || {
328 match self.shared_state.read() {
329 Ok(slot) => slot.clone(),
330 Err(poison) => poison.into_inner().clone(),
331 }
332 })
333 }
334
335 pub fn dispatch(&self, action: AppAction) {
336 self.perf.dispatch.fetch_add(1, Ordering::Relaxed);
337 ffi_or("ffiapp.dispatch", (), || {
338 crate::perflog!("ffi.dispatch action={:?}", std::mem::discriminant(&action));
339 self.recovery.remember_action(&action);
340 let _ = self.foreground_tx.send(CoreMsg::Action(action));
341 })
342 }
343
344 pub fn perf_counters(&self) -> FfiPerfCountersSnapshot {
350 FfiPerfCountersSnapshot {
351 state: self.perf.state.load(Ordering::Relaxed),
352 dispatch: self.perf.dispatch.load(Ordering::Relaxed),
353 search: self.perf.search.load(Ordering::Relaxed),
354 export_support_bundle_json: self
355 .perf
356 .export_support_bundle_json
357 .load(Ordering::Relaxed),
358 peer_profile_debug: self.perf.peer_profile_debug.load(Ordering::Relaxed),
359 mutual_groups: self.perf.mutual_groups.load(Ordering::Relaxed),
360 prepare_for_suspend: self.perf.prepare_for_suspend.load(Ordering::Relaxed),
361 }
362 }
363
364 pub fn core_perf_counters(&self) -> CorePerfCountersSnapshot {
370 ffi_or(
371 "ffiapp.core_perf_counters",
372 CorePerfCountersSnapshot::default(),
373 || {
374 let (reply_tx, reply_rx) = flume::bounded(1);
375 if self
376 .foreground_tx
377 .send(CoreMsg::CorePerfCounters(reply_tx))
378 .is_err()
379 {
380 return CorePerfCountersSnapshot::default();
381 }
382 match reply_rx.recv_timeout(Duration::from_secs(2)) {
383 Ok(snapshot) => CorePerfCountersSnapshot {
384 debug_snapshot_builds: snapshot.debug_snapshot_builds,
385 },
386 Err(_) => CorePerfCountersSnapshot::default(),
387 }
388 },
389 )
390 }
391
392 pub fn search(
399 &self,
400 query: String,
401 scope_chat_id: Option<String>,
402 limit: u32,
403 ) -> SearchResultSnapshot {
404 self.perf.search.fetch_add(1, Ordering::Relaxed);
405 ffi_or(
406 "ffiapp.search",
407 SearchResultSnapshot::empty(query.clone(), scope_chat_id.clone()),
408 || {
409 let trimmed = query.trim();
410 if scope_chat_id.is_none() {
411 let _ = self.background_tx.send(CoreMsg::Internal(Box::new(
412 InternalEvent::ProfileSearchRequested {
413 query: trimmed.to_string(),
414 },
415 )));
416 }
417 if trimmed.is_empty() {
418 return SearchResultSnapshot::empty(query.clone(), scope_chat_id.clone());
419 }
420 let limit = limit.max(1) as usize;
421 let state_snapshot = match self.shared_state.read() {
422 Ok(slot) => slot.clone(),
423 Err(poison) => poison.into_inner().clone(),
424 };
425 let (contacts, groups) = if scope_chat_id.is_some() {
426 (Vec::new(), Vec::new())
427 } else {
428 filter_threads_for_search(&state_snapshot.chat_list, trimmed)
429 };
430 let shared_db = self.shared_db_snapshot();
431 let mut excluded_people = state_snapshot
432 .chat_list
433 .iter()
434 .filter(|chat| chat.kind == ChatKind::Direct)
435 .map(|chat| chat.chat_id.to_ascii_lowercase())
436 .collect::<std::collections::HashSet<_>>();
437 excluded_people.extend(
438 state_snapshot
439 .preferences
440 .blocked_owner_pubkeys
441 .iter()
442 .map(|owner| owner.to_ascii_lowercase()),
443 );
444 let current_owner_hex = state_snapshot
445 .account
446 .as_ref()
447 .map(|account| account.public_key_hex.to_ascii_lowercase());
448 if let Some(owner) = ¤t_owner_hex {
449 excluded_people.insert(owner.clone());
450 }
451 let query_db = |conn: &rusqlite::Connection| {
452 let messages = crate::core::search_messages_fts(
453 conn,
454 trimmed,
455 scope_chat_id.as_deref(),
456 limit,
457 )
458 .unwrap_or_default();
459 let people = if scope_chat_id.is_none() {
460 crate::core::search_people(
461 conn,
462 trimmed,
463 &excluded_people,
464 current_owner_hex.as_deref(),
465 )
466 .unwrap_or_default()
467 .into_iter()
468 .take(limit)
469 .collect()
470 } else {
471 Vec::new()
472 };
473 (messages, people)
474 };
475 let (messages, people) = match shared_db.as_ref() {
476 Some(shared) => match shared.lock() {
477 Ok(conn) => query_db(&conn),
478 Err(poison) => query_db(&poison.into_inner()),
479 },
480 None => (Vec::new(), Vec::new()),
481 };
482 let enriched = enrich_message_hits(messages, &state_snapshot.chat_list);
483 let shortcut = if scope_chat_id.is_none() {
488 chat_input_shortcut(trimmed)
489 } else {
490 None
491 };
492 SearchResultSnapshot {
493 query,
494 scope_chat_id,
495 people,
496 contacts,
497 groups,
498 messages: enriched,
499 shortcut,
500 }
501 },
502 )
503 }
504
505 pub fn chat_snapshot(&self, chat_id: String, limit: u32) -> Option<CurrentChatSnapshot> {
511 ffi_or("ffiapp.chat_snapshot", None, || {
512 let state_snapshot = match self.shared_state.read() {
513 Ok(slot) => slot.clone(),
514 Err(poison) => poison.into_inner().clone(),
515 };
516 crate::core::chat_snapshot_from_state_and_db(
517 &state_snapshot,
518 self.shared_db_snapshot().as_ref(),
519 &chat_id,
520 limit.max(1) as usize,
521 )
522 })
523 }
524
525 pub fn chat_snapshot_before(
526 &self,
527 chat_id: String,
528 before_message_id: String,
529 limit: u32,
530 ) -> Option<CurrentChatSnapshot> {
531 ffi_or("ffiapp.chat_snapshot_before", None, || {
532 let state_snapshot = match self.shared_state.read() {
533 Ok(slot) => slot.clone(),
534 Err(poison) => poison.into_inner().clone(),
535 };
536 crate::core::chat_snapshot_before_from_state_and_db(
537 &state_snapshot,
538 self.shared_db_snapshot().as_ref(),
539 &chat_id,
540 &before_message_id,
541 limit.max(1) as usize,
542 )
543 })
544 }
545
546 pub fn chat_snapshot_around_message(
547 &self,
548 chat_id: String,
549 message_id: String,
550 before_limit: u32,
551 after_limit: u32,
552 ) -> Option<CurrentChatSnapshot> {
553 ffi_or("ffiapp.chat_snapshot_around_message", None, || {
554 let state_snapshot = match self.shared_state.read() {
555 Ok(slot) => slot.clone(),
556 Err(poison) => poison.into_inner().clone(),
557 };
558 crate::core::chat_snapshot_around_message_from_state_and_db(
559 &state_snapshot,
560 self.shared_db_snapshot().as_ref(),
561 &chat_id,
562 &message_id,
563 before_limit as usize,
564 after_limit as usize,
565 )
566 })
567 }
568
569 pub fn export_support_bundle_json(&self) -> String {
570 self.perf
571 .export_support_bundle_json
572 .fetch_add(1, Ordering::Relaxed);
573 ffi_or(
574 "ffiapp.export_support_bundle_json",
575 self.support_bundle_json_with_ffi_diagnostics("{}".to_string(), true),
576 || {
577 let (reply_tx, reply_rx) = flume::bounded(1);
578 if self
579 .foreground_tx
580 .send(CoreMsg::ExportSupportBundle(reply_tx))
581 .is_err()
582 {
583 return self.support_bundle_json_with_ffi_diagnostics("{}".to_string(), true);
584 }
585 match reply_rx.recv_timeout(SUPPORT_BUNDLE_REPLY_TIMEOUT) {
586 Ok(json) => self.support_bundle_json_with_ffi_diagnostics(json, false),
587 Err(_) => self.support_bundle_json_with_ffi_diagnostics("{}".to_string(), true),
588 }
589 },
590 )
591 }
592
593 pub fn peer_profile_debug(&self, owner_input: String) -> Option<PeerProfileDebugSnapshot> {
594 self.perf.peer_profile_debug.fetch_add(1, Ordering::Relaxed);
595 ffi_or("ffiapp.peer_profile_debug", None, || {
596 let (reply_tx, reply_rx) = flume::bounded(1);
597 if self
598 .foreground_tx
599 .send(CoreMsg::PeerProfileDebug {
600 owner_input,
601 reply_tx,
602 })
603 .is_err()
604 {
605 return None;
606 }
607 reply_rx.recv_timeout(Duration::from_secs(2)).ok().flatten()
608 })
609 }
610
611 pub fn mutual_groups(&self, owner_input: String) -> MutualGroupsSnapshot {
612 self.perf.mutual_groups.fetch_add(1, Ordering::Relaxed);
613 ffi_or(
614 "ffiapp.mutual_groups",
615 MutualGroupsSnapshot::default(),
616 || {
617 let (reply_tx, reply_rx) = flume::bounded(1);
618 if self
619 .foreground_tx
620 .send(CoreMsg::MutualGroups {
621 owner_input,
622 reply_tx,
623 })
624 .is_err()
625 {
626 return MutualGroupsSnapshot::default();
627 }
628 reply_rx
629 .recv_timeout(Duration::from_secs(2))
630 .unwrap_or_default()
631 },
632 )
633 }
634
635 pub fn prepare_for_suspend(&self) {
636 self.perf
637 .prepare_for_suspend
638 .fetch_add(1, Ordering::Relaxed);
639 ffi_or("ffiapp.prepare_for_suspend", (), || {
640 let (reply_tx, reply_rx) = flume::bounded(1);
641 if self
642 .foreground_tx
643 .send(CoreMsg::PrepareForSuspend(reply_tx))
644 .is_err()
645 {
646 return;
647 }
648 let _ = reply_rx.recv_timeout(Duration::from_secs(2));
649 })
650 }
651
652 pub fn shutdown(&self) {
653 ffi_or("ffiapp.shutdown", (), || {
654 let (reply_tx, reply_rx) = flume::bounded(1);
655 if self
656 .foreground_tx
657 .send(CoreMsg::Shutdown(Some(reply_tx)))
658 .is_err()
659 {
660 return;
661 }
662 let _ = reply_rx.recv_timeout(Duration::from_secs(2));
663 })
664 }
665
666 fn support_bundle_json_with_ffi_diagnostics(
667 &self,
668 rust_json: String,
669 core_support_bundle_timed_out: bool,
670 ) -> String {
671 let mut object = serde_json::from_str::<serde_json::Value>(&rust_json)
672 .ok()
673 .and_then(|value| value.as_object().cloned())
674 .unwrap_or_default();
675 let now_ms = crate::perflog::now_ms();
676 let last_started_at_ms = self
677 .queue_metrics
678 .last_batch_started_at_ms
679 .load(Ordering::Relaxed);
680 let last_finished_at_ms = self
681 .queue_metrics
682 .last_batch_finished_at_ms
683 .load(Ordering::Relaxed);
684 let batch_active = self.queue_metrics.batch_active.load(Ordering::Acquire);
685 let active_batch_age_ms = if batch_active && last_started_at_ms > 0 {
686 Some(now_ms.saturating_sub(last_started_at_ms))
687 } else {
688 None
689 };
690 let last_batch_started_ago_ms = if last_started_at_ms > 0 {
691 Some(now_ms.saturating_sub(last_started_at_ms))
692 } else {
693 None
694 };
695 let last_batch_finished_ago_ms = if last_finished_at_ms > 0 {
696 Some(now_ms.saturating_sub(last_finished_at_ms))
697 } else {
698 None
699 };
700 object.insert(
701 "ffi_queue".to_string(),
702 serde_json::json!({
703 "core_support_bundle_timed_out": core_support_bundle_timed_out,
704 "foreground_pending": self.foreground_rx.len(),
705 "background_pending": self.background_rx.len(),
706 "foreground_processed": self.queue_metrics.foreground_processed.load(Ordering::Relaxed),
707 "background_processed": self.queue_metrics.background_processed.load(Ordering::Relaxed),
708 "batch_active": batch_active,
709 "active_batch_age_ms": active_batch_age_ms,
710 "last_batch_started_ago_ms": last_batch_started_ago_ms,
711 "last_batch_finished_ago_ms": last_batch_finished_ago_ms,
712 "last_batch_size": self.queue_metrics.last_batch_size.load(Ordering::Relaxed),
713 "last_batch_foreground_count": self.queue_metrics.last_batch_foreground_count.load(Ordering::Relaxed),
714 "last_batch_background_count": self.queue_metrics.last_batch_background_count.load(Ordering::Relaxed),
715 "core_restarts": self.recovery.restart_count(),
716 "last_core_panic": self.recovery.last_panic(),
717 "has_cached_restore_action": self.recovery.restore_action().is_some(),
718 }),
719 );
720 serde_json::Value::Object(object).to_string()
721 }
722
723 pub fn listen_for_updates(&self, reconciler: Box<dyn AppReconciler>) {
724 if self
725 .listening
726 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
727 .is_err()
728 {
729 return;
730 }
731
732 let update_rx = self.update_rx.clone();
733 let recovery = self.recovery.clone();
734 let spawn_result = thread::Builder::new()
735 .name("iris-updates".to_string())
736 .spawn(move || {
737 while let Ok(first) = update_rx.recv() {
749 let mut latest_full_state: Option<AppUpdate> = None;
750 let mut before_full_state: Vec<AppUpdate> = Vec::new();
751 let mut after_full_state: Vec<AppUpdate> = Vec::new();
752 let process = |update: AppUpdate,
753 latest: &mut Option<AppUpdate>,
754 before: &mut Vec<AppUpdate>,
755 after: &mut Vec<AppUpdate>| {
756 recovery.remember_update(&update);
757 enqueue_update_for_delivery(update, latest, before, after);
758 };
759 process(
760 first,
761 &mut latest_full_state,
762 &mut before_full_state,
763 &mut after_full_state,
764 );
765 while let Ok(next) = update_rx.try_recv() {
766 process(
767 next,
768 &mut latest_full_state,
769 &mut before_full_state,
770 &mut after_full_state,
771 );
772 }
773 for update in before_full_state
774 .into_iter()
775 .chain(latest_full_state)
776 .chain(after_full_state)
777 {
778 let kind = match &update {
779 AppUpdate::FullState(_) => "FullState",
780 AppUpdate::PersistAccountBundle { .. } => "PersistAccountBundle",
781 AppUpdate::PersistPendingDeviceLink { .. } => {
782 "PersistPendingDeviceLink"
783 }
784 AppUpdate::ClearPendingDeviceLink => "ClearPendingDeviceLink",
785 AppUpdate::NearbyPublishedEvent { .. } => "NearbyPublishedEvent",
786 AppUpdate::NearbyPeersChanged { .. } => "NearbyPeersChanged",
787 };
788 let t0 = crate::perflog::now_ms();
789 crate::perflog!("reconcile.start kind={kind}");
790 if panic::catch_unwind(AssertUnwindSafe(|| reconciler.reconcile(update)))
791 .is_err()
792 {
793 crate::perflog!("reconcile.failed kind={kind}");
794 continue;
795 }
796 crate::perflog!(
797 "reconcile.end kind={kind} elapsed_ms={}",
798 crate::perflog::now_ms().saturating_sub(t0)
799 );
800 }
801 }
802 });
803 if let Err(error) = spawn_result {
804 crate::perflog!("updates.spawn.failed error={error}");
805 self.listening.store(false, Ordering::SeqCst);
806 }
807 }
808}
809
810impl FfiApp {
811 fn shared_db_snapshot(&self) -> Option<crate::core::SharedConnection> {
812 match self.shared_db.read() {
813 Ok(slot) => slot.clone(),
814 Err(poison) => poison.into_inner().clone(),
815 }
816 }
817}
818
819fn new_ffi_app_inner(data_dir: String) -> Arc<FfiApp> {
820 let (update_tx, update_rx) = flume::unbounded();
821 let (foreground_tx, foreground_rx) = flume::unbounded();
822 let (background_tx, background_rx) = flume::unbounded();
823 let shared_state = Arc::new(RwLock::new(AppState::empty()));
824 let queue_metrics = Arc::new(CoreQueueMetrics::default());
825 let recovery = Arc::new(CoreRecoveryState::default());
826 let shared_db = Arc::new(RwLock::new(None));
827
828 let update_tx_for_error = update_tx.clone();
829 match AppCore::try_new_with_priority_sender(
830 update_tx.clone(),
831 background_tx.clone(),
832 foreground_tx.clone(),
833 data_dir.clone(),
834 shared_state.clone(),
835 ) {
836 Ok(core) => {
837 set_shared_db(&shared_db, Some(core.shared_db()));
838 let spawn_result = spawn_core_supervisor(
839 core,
840 CoreSupervisor {
841 data_dir,
842 update_tx: update_tx.clone(),
843 core_sender: background_tx.clone(),
844 priority_sender: foreground_tx.clone(),
845 foreground_rx: foreground_rx.clone(),
846 background_rx: background_rx.clone(),
847 shared_state: shared_state.clone(),
848 shared_db: shared_db.clone(),
849 queue_metrics: queue_metrics.clone(),
850 recovery: recovery.clone(),
851 },
852 );
853 if let Err(error) = spawn_result {
854 publish_core_failure_state(
855 &shared_state,
856 &update_tx_for_error,
857 format!("Iris could not start: {error}"),
858 );
859 }
860 }
861 Err(error) => {
862 publish_core_failure_state(&shared_state, &update_tx_for_error, error.to_string());
863 }
864 }
865
866 Arc::new(FfiApp {
867 foreground_tx,
868 foreground_rx,
869 background_tx,
870 background_rx,
871 update_rx,
872 listening: AtomicBool::new(false),
873 shared_state,
874 shared_db,
875 perf: FfiPerfCounters::default(),
876 queue_metrics,
877 recovery,
878 })
879}
880
881fn ffi_app_failure(message: String) -> Arc<FfiApp> {
882 let (_update_tx, update_rx) = flume::unbounded();
883 let (foreground_tx, foreground_rx) = flume::unbounded();
884 let (background_tx, background_rx) = flume::unbounded();
885 let mut state = AppState::empty();
886 state.toast = Some(message);
887 state.rev = 1;
888 let shared_state = Arc::new(RwLock::new(state));
889 Arc::new(FfiApp {
890 foreground_tx,
891 foreground_rx,
892 background_tx,
893 background_rx,
894 update_rx,
895 listening: AtomicBool::new(false),
896 shared_state,
897 shared_db: Arc::new(RwLock::new(None)),
898 perf: FfiPerfCounters::default(),
899 queue_metrics: Arc::new(CoreQueueMetrics::default()),
900 recovery: Arc::new(CoreRecoveryState::default()),
901 })
902}
903
904struct CoreSupervisor {
905 data_dir: String,
906 update_tx: Sender<AppUpdate>,
907 core_sender: Sender<CoreMsg>,
908 priority_sender: Sender<CoreMsg>,
909 foreground_rx: Receiver<CoreMsg>,
910 background_rx: Receiver<CoreMsg>,
911 shared_state: Arc<RwLock<AppState>>,
912 shared_db: Arc<RwLock<Option<crate::core::SharedConnection>>>,
913 queue_metrics: Arc<CoreQueueMetrics>,
914 recovery: Arc<CoreRecoveryState>,
915}
916
917fn spawn_core_supervisor(
918 core: AppCore,
919 supervisor: CoreSupervisor,
920) -> std::io::Result<thread::JoinHandle<()>> {
921 thread::Builder::new()
922 .name("iris-core".to_string())
923 .spawn(move || {
924 crate::core::prewarm_default_social_graph();
925 let mut core_slot = Some(core);
926 while let Ok(batch) =
931 recv_core_batch(&supervisor.foreground_rx, &supervisor.background_rx)
932 {
933 let batch_size = batch.len();
934 let foreground_count = batch
935 .iter()
936 .filter(|msg| is_foreground_core_msg(msg))
937 .count() as u64;
938 let background_count = batch_size as u64 - foreground_count;
939 supervisor.queue_metrics.mark_batch_start(
940 batch_size as u64,
941 foreground_count,
942 background_count,
943 );
944 let t0 = crate::perflog::now_ms();
945 crate::perflog!("core.batch.start size={batch_size}");
946 let result = match core_slot.as_mut() {
947 Some(core) => catch_core_batch(|| handle_core_batch_responsive(core, batch)),
948 None => break,
949 };
950 let elapsed_ms = crate::perflog::now_ms().saturating_sub(t0);
951 supervisor
952 .queue_metrics
953 .mark_batch_finished(foreground_count, background_count);
954 match result {
955 Ok(true) => {
956 crate::perflog!(
957 "core.batch.end size={batch_size} elapsed_ms={elapsed_ms}"
958 );
959 }
960 Ok(false) => {
961 crate::perflog!(
962 "core.batch.end size={batch_size} elapsed_ms={elapsed_ms} result=shutdown"
963 );
964 break;
965 }
966 Err(error) => {
967 if let Some(mut failed_core) = core_slot.take() {
968 failed_core.record_core_panic(error.clone());
969 }
970 crate::perflog!(
971 "core.batch.end size={batch_size} elapsed_ms={elapsed_ms} result=panic"
972 );
973 match recover_core_after_panic(&supervisor, error) {
974 Some(core) => core_slot = Some(core),
975 None => break,
976 }
977 }
978 }
979 }
980 })
981}
982
983fn recover_core_after_panic(supervisor: &CoreSupervisor, detail: String) -> Option<AppCore> {
984 let restart_count = supervisor.recovery.mark_panic(detail);
985 crate::perflog!("core.supervisor.restart count={restart_count}");
986 set_shared_db(&supervisor.shared_db, None);
987
988 let mut core = match AppCore::try_new_with_priority_sender(
989 supervisor.update_tx.clone(),
990 supervisor.core_sender.clone(),
991 supervisor.priority_sender.clone(),
992 supervisor.data_dir.clone(),
993 supervisor.shared_state.clone(),
994 ) {
995 Ok(core) => core,
996 Err(error) => {
997 crate::perflog!("core.supervisor.restart.failed count={restart_count} error={error}");
998 publish_core_failure_state(
999 &supervisor.shared_state,
1000 &supervisor.update_tx,
1001 CORE_RESTART_TOAST.to_string(),
1002 );
1003 return None;
1004 }
1005 };
1006
1007 set_shared_db(&supervisor.shared_db, Some(core.shared_db()));
1008 if let Some(action) = supervisor.recovery.restore_action() {
1009 crate::perflog!(
1010 "core.supervisor.restore action={:?}",
1011 std::mem::discriminant(&action)
1012 );
1013 match catch_core_batch(|| core.handle_messages(vec![CoreMsg::Action(action)])) {
1014 Ok(true) => {}
1015 Ok(false) => {
1016 publish_core_failure_state(
1017 &supervisor.shared_state,
1018 &supervisor.update_tx,
1019 CORE_RESTART_TOAST.to_string(),
1020 );
1021 return None;
1022 }
1023 Err(error) => {
1024 core.mark_core_panic(format!("core recovery restore panic: {error}"));
1025 return None;
1026 }
1027 }
1028 }
1029
1030 crate::perflog!("core.supervisor.recovered count={restart_count}");
1031 Some(core)
1032}
1033
1034fn set_shared_db(
1035 shared_db: &Arc<RwLock<Option<crate::core::SharedConnection>>>,
1036 value: Option<crate::core::SharedConnection>,
1037) {
1038 match shared_db.write() {
1039 Ok(mut slot) => *slot = value,
1040 Err(poison) => *poison.into_inner() = value,
1041 }
1042}
1043
1044fn publish_core_failure_state(
1045 shared_state: &Arc<RwLock<AppState>>,
1046 update_tx: &Sender<AppUpdate>,
1047 message: String,
1048) {
1049 let mut state = match shared_state.read() {
1050 Ok(slot) => slot.clone(),
1051 Err(poison) => poison.into_inner().clone(),
1052 };
1053 state.toast = Some(message);
1054 state.rev = state.rev.saturating_add(1).max(1);
1055 match shared_state.write() {
1056 Ok(mut slot) => *slot = state.clone(),
1057 Err(poison) => *poison.into_inner() = state.clone(),
1058 }
1059 let _ = update_tx.send(AppUpdate::FullState(state));
1060}
1061
1062const CORE_FOREGROUND_BATCH_LIMIT: usize = 64;
1063const CORE_BACKGROUND_BATCH_LIMIT: usize = 16;
1064
1065fn recv_core_batch(
1066 foreground_rx: &Receiver<CoreMsg>,
1067 background_rx: &Receiver<CoreMsg>,
1068) -> Result<Vec<CoreMsg>, flume::RecvError> {
1069 if let Some(batch) = try_recv_core_batch(foreground_rx, background_rx) {
1070 return Ok(batch);
1071 }
1072
1073 let (is_foreground, first) = flume::Selector::new()
1074 .recv(foreground_rx, |result| result.map(|msg| (true, msg)))
1075 .recv(background_rx, |result| result.map(|msg| (false, msg)))
1076 .wait()?;
1077 Ok(drain_core_batch_after_first(
1078 is_foreground,
1079 first,
1080 foreground_rx,
1081 background_rx,
1082 ))
1083}
1084
1085fn try_recv_core_batch(
1086 foreground_rx: &Receiver<CoreMsg>,
1087 background_rx: &Receiver<CoreMsg>,
1088) -> Option<Vec<CoreMsg>> {
1089 if let Ok(first) = foreground_rx.try_recv() {
1090 return Some(drain_core_batch_after_first(
1091 true,
1092 first,
1093 foreground_rx,
1094 background_rx,
1095 ));
1096 }
1097 background_rx
1098 .try_recv()
1099 .ok()
1100 .map(|first| drain_core_batch_after_first(false, first, foreground_rx, background_rx))
1101}
1102
1103fn drain_core_batch_after_first(
1104 is_foreground: bool,
1105 first: CoreMsg,
1106 foreground_rx: &Receiver<CoreMsg>,
1107 background_rx: &Receiver<CoreMsg>,
1108) -> Vec<CoreMsg> {
1109 let mut batch = Vec::with_capacity(if is_foreground {
1110 CORE_FOREGROUND_BATCH_LIMIT
1111 } else {
1112 CORE_BACKGROUND_BATCH_LIMIT
1113 });
1114 batch.push(first);
1115 drain_foreground_messages(&mut batch, foreground_rx);
1116 if batch.iter().any(is_foreground_core_msg) {
1117 return batch;
1118 }
1119
1120 while batch.len() < CORE_BACKGROUND_BATCH_LIMIT {
1121 let Ok(next) = background_rx.try_recv() else {
1122 break;
1123 };
1124 batch.push(next);
1125 drain_foreground_messages(&mut batch, foreground_rx);
1126 if batch.iter().any(is_foreground_core_msg) {
1127 break;
1128 }
1129 }
1130 batch
1131}
1132
1133fn drain_foreground_messages(batch: &mut Vec<CoreMsg>, foreground_rx: &Receiver<CoreMsg>) {
1134 while batch.len() < CORE_FOREGROUND_BATCH_LIMIT {
1135 let Ok(next) = foreground_rx.try_recv() else {
1136 break;
1137 };
1138 batch.push(next);
1139 }
1140}
1141
1142fn handle_core_batch_responsive(core: &mut AppCore, messages: Vec<CoreMsg>) -> bool {
1143 if messages.len() <= 1 || !messages.iter().any(is_foreground_core_msg) {
1144 return core.handle_messages(messages);
1145 }
1146
1147 let mut foreground = Vec::new();
1148 let mut background = Vec::new();
1149 for message in messages {
1150 if is_foreground_core_msg(&message) {
1151 foreground.push(message);
1152 } else {
1153 background.push(message);
1154 }
1155 }
1156
1157 for message in foreground {
1162 if !core.handle_messages(vec![message]) {
1163 return false;
1164 }
1165 }
1166 background.is_empty() || core.handle_messages(background)
1167}
1168
1169fn catch_core_batch<F>(f: F) -> Result<bool, String>
1170where
1171 F: FnOnce() -> bool,
1172{
1173 panic::catch_unwind(AssertUnwindSafe(f)).map_err(panic_payload_to_string)
1174}
1175
1176fn ffi_or<T, F>(label: &'static str, fallback: T, f: F) -> T
1177where
1178 F: FnOnce() -> T,
1179{
1180 match panic::catch_unwind(AssertUnwindSafe(f)) {
1181 Ok(value) => value,
1182 Err(payload) => {
1183 crate::perflog!(
1184 "ffi.panic label={label} detail={}",
1185 panic_payload_to_string(payload)
1186 );
1187 fallback
1188 }
1189 }
1190}
1191
1192fn ffi_failure_state() -> AppState {
1193 let mut state = AppState::empty();
1194 state.toast = Some("Iris needs restart. Copy support bundle in Settings.".to_string());
1195 state
1196}
1197
1198fn suppressed_mobile_push_resolution() -> MobilePushNotificationResolution {
1199 MobilePushNotificationResolution {
1200 should_show: false,
1201 title: String::new(),
1202 body: String::new(),
1203 payload_json: "{}".to_string(),
1204 }
1205}
1206
1207fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
1208 if let Some(message) = payload.downcast_ref::<&str>() {
1209 (*message).to_string()
1210 } else if let Some(message) = payload.downcast_ref::<String>() {
1211 message.clone()
1212 } else {
1213 "unknown panic".to_string()
1214 }
1215}
1216
1217fn is_foreground_core_msg(message: &CoreMsg) -> bool {
1218 !matches!(message, CoreMsg::Internal(_))
1219}
1220
1221#[cfg(test)]
1222mod core_queue_tests;
1223
1224fn filter_threads_for_search(
1225 chat_list: &[ChatThreadSnapshot],
1226 query: &str,
1227) -> (Vec<ChatThreadSnapshot>, Vec<ChatThreadSnapshot>) {
1228 let needle = query.to_lowercase();
1229 let mut contacts = Vec::new();
1230 let mut groups = Vec::new();
1231 for chat in chat_list {
1232 if !thread_matches_query(chat, &needle) {
1233 continue;
1234 }
1235 match chat.kind {
1236 ChatKind::Direct => contacts.push(chat.clone()),
1237 ChatKind::Group => groups.push(chat.clone()),
1238 }
1239 }
1240 (contacts, groups)
1241}
1242
1243fn thread_matches_query(chat: &ChatThreadSnapshot, needle_lower: &str) -> bool {
1244 let candidates: [&str; 7] = [
1245 &chat.display_name,
1246 chat.nickname.as_deref().unwrap_or(""),
1247 chat.profile_name.as_deref().unwrap_or(""),
1248 chat.about.as_deref().unwrap_or(""),
1249 chat.subtitle.as_deref().unwrap_or(""),
1250 &chat.draft,
1251 &chat.chat_id,
1252 ];
1253 candidates
1254 .iter()
1255 .any(|field| field.to_lowercase().contains(needle_lower))
1256}
1257
1258fn enrich_message_hits(
1259 hits: Vec<crate::core::PersistedMessageSearchHit>,
1260 chat_list: &[ChatThreadSnapshot],
1261) -> Vec<MessageSearchHit> {
1262 use std::collections::HashMap;
1263 let lookup: HashMap<&str, &ChatThreadSnapshot> = chat_list
1264 .iter()
1265 .map(|chat| (chat.chat_id.as_str(), chat))
1266 .collect();
1267 hits.into_iter()
1268 .map(|hit| {
1269 let parent = lookup.get(hit.chat_id.as_str());
1270 let display_name = parent
1271 .map(|chat| chat.display_name.clone())
1272 .unwrap_or_else(|| short_chat_label(&hit.chat_id));
1273 let picture_url = parent.and_then(|chat| chat.picture_url.clone());
1274 let kind = parent
1275 .map(|chat| chat.kind.clone())
1276 .unwrap_or(ChatKind::Direct);
1277 MessageSearchHit {
1278 chat_id: hit.chat_id,
1279 message_id: hit.message_id,
1280 chat_display_name: display_name,
1281 chat_picture_url: picture_url,
1282 chat_kind: kind,
1283 author_pubkey: hit.author,
1284 body: hit.body,
1285 is_outgoing: hit.is_outgoing,
1286 created_at_secs: hit.created_at_secs,
1287 }
1288 })
1289 .collect()
1290}
1291
1292fn short_chat_label(chat_id: &str) -> String {
1293 let trimmed = chat_id.trim();
1294 if trimmed.len() > 12 {
1295 format!("{}…", &trimmed[..12])
1296 } else {
1297 trimmed.to_string()
1298 }
1299}
1300
1301impl Drop for FfiApp {
1302 fn drop(&mut self) {
1303 let _ = self.foreground_tx.send(CoreMsg::Shutdown(None));
1304 }
1305}
1306
1307#[uniffi::export]
1308pub fn normalize_peer_input(input: String) -> String {
1309 ffi_or("normalize_peer_input", String::new(), || {
1310 crate::core::normalize_peer_input_for_display(&input)
1311 })
1312}
1313
1314#[uniffi::export]
1315pub fn is_valid_peer_input(input: String) -> bool {
1316 ffi_or("is_valid_peer_input", false, || {
1317 crate::core::parse_peer_input(&input).is_ok()
1318 })
1319}
1320
1321#[uniffi::export]
1327pub fn classify_chat_input(input: String) -> Option<ChatInputShortcut> {
1328 ffi_or("classify_chat_input", None, || chat_input_shortcut(&input))
1329}
1330
1331fn chat_input_shortcut(raw: &str) -> Option<ChatInputShortcut> {
1332 let trimmed = raw.trim();
1333 if trimmed.is_empty() {
1334 return None;
1335 }
1336 let lower = trimmed.to_lowercase();
1337 if lower.contains("://") && lower.contains('#') {
1338 return Some(ChatInputShortcut::Invite {
1339 invite_input: trimmed.to_string(),
1340 display: short_invite_display(trimmed),
1341 });
1342 }
1343 if crate::core::parse_peer_input(trimmed).is_ok() {
1344 use nostr::nips::nip19::ToBech32;
1345 let normalized = crate::core::normalize_peer_input_for_display(trimmed);
1346 if let Ok(pubkey) = nostr::PublicKey::parse(&normalized) {
1347 let npub = pubkey.to_bech32().unwrap_or_else(|_| normalized.clone());
1348 let display = short_npub_display(&npub);
1349 return Some(ChatInputShortcut::DirectPeer {
1350 peer_input: normalized,
1351 display,
1352 npub,
1353 pubkey_hex: pubkey.to_hex(),
1354 });
1355 }
1356 }
1357 None
1358}
1359
1360fn short_npub_display(npub: &str) -> String {
1361 if npub.len() > 16 {
1362 format!("{}…{}", &npub[..10], &npub[npub.len() - 4..])
1363 } else {
1364 npub.to_string()
1365 }
1366}
1367
1368fn short_invite_display(invite: &str) -> String {
1369 let after_scheme = invite
1373 .split_once("://")
1374 .map(|(_, rest)| rest)
1375 .unwrap_or(invite);
1376 if after_scheme.len() > 32 {
1377 format!("{}…", &after_scheme[..32])
1378 } else {
1379 after_scheme.to_string()
1380 }
1381}
1382
1383#[uniffi::export]
1388pub fn peer_input_to_hex(input: String) -> String {
1389 ffi_or("peer_input_to_hex", String::new(), || {
1390 let normalized = crate::core::normalize_peer_input_for_display(&input);
1391 match nostr::PublicKey::parse(&normalized) {
1392 Ok(pubkey) => pubkey.to_hex(),
1393 Err(_) => String::new(),
1394 }
1395 })
1396}
1397
1398#[uniffi::export]
1401pub fn peer_input_to_npub(input: String) -> String {
1402 ffi_or("peer_input_to_npub", String::new(), || {
1403 use nostr::nips::nip19::ToBech32;
1404 let normalized = crate::core::normalize_peer_input_for_display(&input);
1405 match nostr::PublicKey::parse(&normalized) {
1406 Ok(pubkey) => pubkey.to_bech32().unwrap_or(normalized),
1407 Err(_) => normalized,
1408 }
1409 })
1410}
1411
1412#[uniffi::export]
1413pub fn build_summary() -> String {
1414 ffi_or("build_summary", String::new(), crate::core::build_summary)
1415}
1416
1417#[uniffi::export]
1418pub fn relay_set_id() -> String {
1419 ffi_or("relay_set_id", String::new(), || {
1420 crate::core::relay_set_id().to_string()
1421 })
1422}
1423
1424#[uniffi::export]
1425pub fn proxied_image_url(
1426 original_src: String,
1427 preferences: PreferencesSnapshot,
1428 width: Option<u32>,
1429 height: Option<u32>,
1430 square: bool,
1431) -> String {
1432 ffi_or("proxied_image_url", String::new(), || {
1433 image_proxy::proxied_image_url(&original_src, &preferences, width, height, square)
1434 })
1435}
1436
1437#[uniffi::export]
1438pub fn image_load_urls(
1439 original_src: String,
1440 preferences: PreferencesSnapshot,
1441 width: Option<u32>,
1442 height: Option<u32>,
1443 square: bool,
1444) -> Vec<String> {
1445 ffi_or("image_load_urls", Vec::new(), || {
1446 image_proxy::image_load_urls(&original_src, &preferences, width, height, square)
1447 })
1448}
1449
1450#[uniffi::export]
1451pub fn is_trusted_test_build() -> bool {
1452 ffi_or(
1453 "is_trusted_test_build",
1454 false,
1455 crate::core::trusted_test_build_flag,
1456 )
1457}
1458
1459#[uniffi::export]
1464pub fn app_version() -> String {
1465 crate::core::app_version_string().to_string()
1466}
1467
1468#[uniffi::export]
1469pub fn resolve_mobile_push_notification_payload(
1470 raw_payload_json: String,
1471) -> MobilePushNotificationResolution {
1472 ffi_or(
1473 "resolve_mobile_push_notification_payload",
1474 suppressed_mobile_push_resolution(),
1475 || crate::core::resolve_mobile_push_notification(raw_payload_json),
1476 )
1477}
1478
1479#[uniffi::export]
1485pub fn decrypt_mobile_push_notification_payload(
1486 data_dir: String,
1487 owner_pubkey_hex: String,
1488 device_nsec: String,
1489 raw_payload_json: String,
1490) -> MobilePushNotificationResolution {
1491 ffi_or(
1492 "decrypt_mobile_push_notification_payload",
1493 suppressed_mobile_push_resolution(),
1494 || {
1495 crate::core::decrypt_mobile_push_notification(
1496 data_dir,
1497 owner_pubkey_hex,
1498 device_nsec,
1499 raw_payload_json,
1500 )
1501 },
1502 )
1503}
1504
1505pub use crate::core::notifications::NotificationCandidate;
1506
1507#[uniffi::export]
1514pub fn decide_pending_notifications(
1515 previous_chats: Vec<ChatThreadSnapshot>,
1516 next_chats: Vec<ChatThreadSnapshot>,
1517 preferences: PreferencesSnapshot,
1518 app_foreground: bool,
1519 open_chat_id: Option<String>,
1520) -> Vec<NotificationCandidate> {
1521 crate::core::notifications::decide_notifications(
1522 &previous_chats,
1523 &next_chats,
1524 &preferences,
1525 app_foreground,
1526 open_chat_id.as_deref(),
1527 )
1528}
1529
1530#[uniffi::export]
1534pub fn router_open_chat_id(router: Router) -> Option<String> {
1535 crate::core::notifications::active_chat_id(&router)
1536}
1537
1538#[uniffi::export]
1539pub fn resolve_mobile_push_subscription_server_url(
1540 platform_key: String,
1541 is_release: bool,
1542 override_url: Option<String>,
1543) -> String {
1544 ffi_or(
1545 "resolve_mobile_push_subscription_server_url",
1546 String::new(),
1547 || crate::core::resolve_mobile_push_server_url(platform_key, is_release, override_url),
1548 )
1549}
1550
1551#[uniffi::export]
1552pub fn mobile_push_subscription_id_key(platform_key: String) -> String {
1553 ffi_or("mobile_push_subscription_id_key", String::new(), || {
1554 crate::core::mobile_push_stored_subscription_id_key(platform_key)
1555 })
1556}
1557
1558#[uniffi::export]
1559pub fn build_mobile_push_list_subscriptions_request(
1560 owner_nsec: String,
1561 platform_key: String,
1562 is_release: bool,
1563 server_url_override: Option<String>,
1564) -> Option<MobilePushSubscriptionRequest> {
1565 ffi_or("build_mobile_push_list_subscriptions_request", None, || {
1566 crate::core::build_mobile_push_list_subscriptions_request(
1567 owner_nsec,
1568 platform_key,
1569 is_release,
1570 server_url_override,
1571 )
1572 })
1573}
1574
1575#[uniffi::export]
1576#[allow(clippy::too_many_arguments)]
1577pub fn build_mobile_push_create_subscription_request(
1578 owner_nsec: String,
1579 platform_key: String,
1580 push_token: String,
1581 apns_topic: Option<String>,
1582 message_author_pubkeys: Vec<String>,
1583 invite_response_pubkeys: Vec<String>,
1584 is_release: bool,
1585 server_url_override: Option<String>,
1586) -> Option<MobilePushSubscriptionRequest> {
1587 ffi_or(
1588 "build_mobile_push_create_subscription_request",
1589 None,
1590 || {
1591 crate::core::build_mobile_push_create_subscription_request(
1592 owner_nsec,
1593 platform_key,
1594 push_token,
1595 apns_topic,
1596 message_author_pubkeys,
1597 invite_response_pubkeys,
1598 is_release,
1599 server_url_override,
1600 )
1601 },
1602 )
1603}
1604
1605#[uniffi::export]
1606#[allow(clippy::too_many_arguments)]
1607pub fn build_mobile_push_update_subscription_request(
1608 owner_nsec: String,
1609 subscription_id: String,
1610 platform_key: String,
1611 push_token: String,
1612 apns_topic: Option<String>,
1613 message_author_pubkeys: Vec<String>,
1614 invite_response_pubkeys: Vec<String>,
1615 is_release: bool,
1616 server_url_override: Option<String>,
1617) -> Option<MobilePushSubscriptionRequest> {
1618 ffi_or(
1619 "build_mobile_push_update_subscription_request",
1620 None,
1621 || {
1622 crate::core::build_mobile_push_update_subscription_request(
1623 owner_nsec,
1624 subscription_id,
1625 platform_key,
1626 push_token,
1627 apns_topic,
1628 message_author_pubkeys,
1629 invite_response_pubkeys,
1630 is_release,
1631 server_url_override,
1632 )
1633 },
1634 )
1635}
1636
1637#[uniffi::export]
1638pub fn build_mobile_push_delete_subscription_request(
1639 owner_nsec: String,
1640 subscription_id: String,
1641 platform_key: String,
1642 is_release: bool,
1643 server_url_override: Option<String>,
1644) -> Option<MobilePushSubscriptionRequest> {
1645 ffi_or(
1646 "build_mobile_push_delete_subscription_request",
1647 None,
1648 || {
1649 crate::core::build_mobile_push_delete_subscription_request(
1650 owner_nsec,
1651 subscription_id,
1652 platform_key,
1653 is_release,
1654 server_url_override,
1655 )
1656 },
1657 )
1658}
1659
1660#[cfg(test)]
1661mod ffi_hardening_tests {
1662 use super::*;
1663
1664 #[test]
1665 fn ffi_guard_returns_fallback_after_panic() {
1666 let value = ffi_or("test.panic", 42, || -> i32 {
1667 panic!("ffi boom");
1668 });
1669
1670 assert_eq!(value, 42);
1671 }
1672
1673 #[test]
1674 fn core_batch_guard_converts_panic_to_error() {
1675 let result = catch_core_batch(|| -> bool {
1676 panic!("batch boom");
1677 });
1678
1679 assert_eq!(result, Err("batch boom".to_string()));
1680 }
1681
1682 #[test]
1683 fn core_batch_guard_preserves_success_result() {
1684 assert_eq!(catch_core_batch(|| true), Ok(true));
1685 assert_eq!(catch_core_batch(|| false), Ok(false));
1686 }
1687
1688 #[test]
1689 fn recovery_state_tracks_restore_action_and_logout() {
1690 let recovery = CoreRecoveryState::default();
1691 recovery.remember_action(&AppAction::RestoreSession {
1692 owner_nsec: "secret".to_string(),
1693 });
1694
1695 match recovery.restore_action() {
1696 Some(AppAction::RestoreSession { owner_nsec }) => assert_eq!(owner_nsec, "secret"),
1697 other => panic!("unexpected restore action: {other:?}"),
1698 }
1699
1700 recovery.remember_action(&AppAction::Logout);
1701 assert!(recovery.restore_action().is_none());
1702 }
1703
1704 #[test]
1705 fn recovery_state_tracks_persisted_account_bundle() {
1706 let recovery = CoreRecoveryState::default();
1707 recovery.remember_update(&AppUpdate::PersistAccountBundle {
1708 rev: 7,
1709 owner_nsec: None,
1710 owner_pubkey_hex: "owner".to_string(),
1711 device_nsec: "device-secret".to_string(),
1712 });
1713
1714 match recovery.restore_action() {
1715 Some(AppAction::RestoreAccountBundle {
1716 owner_nsec,
1717 owner_pubkey_hex,
1718 device_nsec,
1719 }) => {
1720 assert_eq!(owner_nsec, None);
1721 assert_eq!(owner_pubkey_hex, "owner");
1722 assert_eq!(device_nsec, "device-secret");
1723 }
1724 other => panic!("unexpected restore action: {other:?}"),
1725 }
1726 }
1727
1728 #[test]
1729 fn recovery_state_tracks_and_clears_pending_device_link() {
1730 let recovery = CoreRecoveryState::default();
1731 recovery.remember_update(&AppUpdate::PersistPendingDeviceLink {
1732 device_nsec: "device-secret".to_string(),
1733 approval_bootstrap_json: "{}".to_string(),
1734 });
1735
1736 assert!(matches!(
1737 recovery.restore_action(),
1738 Some(AppAction::RestorePendingDeviceLink { .. })
1739 ));
1740 recovery.remember_update(&AppUpdate::ClearPendingDeviceLink);
1741 assert!(recovery.restore_action().is_none());
1742 }
1743
1744 #[test]
1745 fn nearby_published_events_wait_behind_latest_state_in_drained_batch() {
1746 let mut latest_full_state = None;
1747 let mut before_full_state = Vec::new();
1748 let mut after_full_state = Vec::new();
1749
1750 updates::enqueue_update_for_delivery(
1751 AppUpdate::NearbyPublishedEvent {
1752 event_id: "a".repeat(64),
1753 kind: 14,
1754 created_at_secs: 1,
1755 event_json: "{}".to_string(),
1756 },
1757 &mut latest_full_state,
1758 &mut before_full_state,
1759 &mut after_full_state,
1760 );
1761 let mut stale = AppState::empty();
1762 stale.rev = 1;
1763 updates::enqueue_update_for_delivery(
1764 AppUpdate::FullState(stale),
1765 &mut latest_full_state,
1766 &mut before_full_state,
1767 &mut after_full_state,
1768 );
1769 enqueue_update_for_delivery(
1770 AppUpdate::PersistAccountBundle {
1771 rev: 2,
1772 owner_nsec: None,
1773 owner_pubkey_hex: "owner".to_string(),
1774 device_nsec: "device".to_string(),
1775 },
1776 &mut latest_full_state,
1777 &mut before_full_state,
1778 &mut after_full_state,
1779 );
1780 let mut latest = AppState::empty();
1781 latest.rev = 3;
1782 enqueue_update_for_delivery(
1783 AppUpdate::FullState(latest),
1784 &mut latest_full_state,
1785 &mut before_full_state,
1786 &mut after_full_state,
1787 );
1788
1789 let order = before_full_state
1790 .into_iter()
1791 .chain(latest_full_state)
1792 .chain(after_full_state)
1793 .map(|update| match update {
1794 AppUpdate::PersistAccountBundle { .. } => "persist".to_string(),
1795 AppUpdate::PersistPendingDeviceLink { .. } => "pending-link".to_string(),
1796 AppUpdate::ClearPendingDeviceLink => "clear-pending-link".to_string(),
1797 AppUpdate::FullState(state) => format!("state:{}", state.rev),
1798 AppUpdate::NearbyPublishedEvent { .. } => "nearby".to_string(),
1799 AppUpdate::NearbyPeersChanged { .. } => "nearby-peers".to_string(),
1800 })
1801 .collect::<Vec<_>>();
1802
1803 assert_eq!(order, vec!["persist", "state:3", "nearby"]);
1804 }
1805
1806 #[test]
1807 fn core_supervisor_recovers_after_batch_panic() {
1808 let temp_dir = tempfile::TempDir::new().expect("temp dir");
1809 let app = new_ffi_app_inner(temp_dir.path().to_string_lossy().to_string());
1810
1811 app.foreground_tx
1812 .send(CoreMsg::PanicForTest)
1813 .expect("send test panic");
1814
1815 for _ in 0..40 {
1816 if app.recovery.restart_count() > 0 {
1817 break;
1818 }
1819 thread::sleep(Duration::from_millis(25));
1820 }
1821 assert_eq!(app.recovery.restart_count(), 1);
1822
1823 let (reply_tx, reply_rx) = flume::bounded(1);
1824 app.foreground_tx
1825 .send(CoreMsg::CorePerfCounters(reply_tx))
1826 .expect("send post-recovery request");
1827 assert!(reply_rx.recv_timeout(Duration::from_secs(2)).is_ok());
1828
1829 app.shutdown();
1830 }
1831}