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 { .. } | AppAction::RestoreAccountBundle { .. } => {
245 self.set_restore_action(Some(action.clone()));
246 }
247 AppAction::Logout => self.set_restore_action(None),
248 _ => {}
249 }
250 }
251
252 fn remember_update(&self, update: &AppUpdate) {
253 if let AppUpdate::PersistAccountBundle {
254 owner_nsec,
255 owner_pubkey_hex,
256 device_nsec,
257 ..
258 } = update
259 {
260 self.set_restore_action(Some(AppAction::RestoreAccountBundle {
261 owner_nsec: owner_nsec.clone(),
262 owner_pubkey_hex: owner_pubkey_hex.clone(),
263 device_nsec: device_nsec.clone(),
264 }));
265 }
266 }
267
268 fn restore_action(&self) -> Option<AppAction> {
269 match self.restore_action.read() {
270 Ok(action) => action.clone(),
271 Err(poison) => poison.into_inner().clone(),
272 }
273 }
274
275 fn mark_panic(&self, detail: String) -> u64 {
276 match self.last_panic.write() {
277 Ok(mut slot) => *slot = Some(detail),
278 Err(poison) => *poison.into_inner() = Some(detail),
279 }
280 self.restart_count.fetch_add(1, Ordering::Relaxed) + 1
281 }
282
283 fn restart_count(&self) -> u64 {
284 self.restart_count.load(Ordering::Relaxed)
285 }
286
287 fn last_panic(&self) -> Option<String> {
288 match self.last_panic.read() {
289 Ok(slot) => slot.clone(),
290 Err(poison) => poison.into_inner().clone(),
291 }
292 }
293
294 fn set_restore_action(&self, action: Option<AppAction>) {
295 match self.restore_action.write() {
296 Ok(mut slot) => *slot = action,
297 Err(poison) => *poison.into_inner() = action,
298 }
299 }
300}
301
302#[uniffi::export]
303impl FfiApp {
304 #[uniffi::constructor]
305 pub fn new(data_dir: String, _keychain_group: String, _app_version: String) -> Arc<Self> {
306 match panic::catch_unwind(AssertUnwindSafe(|| new_ffi_app_inner(data_dir))) {
307 Ok(app) => app,
308 Err(payload) => ffi_app_failure(format!(
309 "Iris could not start: {}",
310 panic_payload_to_string(payload)
311 )),
312 }
313 }
314
315 pub fn state(&self) -> AppState {
316 self.perf.state.fetch_add(1, Ordering::Relaxed);
317 ffi_or("ffiapp.state", ffi_failure_state(), || {
318 match self.shared_state.read() {
319 Ok(slot) => slot.clone(),
320 Err(poison) => poison.into_inner().clone(),
321 }
322 })
323 }
324
325 pub fn dispatch(&self, action: AppAction) {
326 self.perf.dispatch.fetch_add(1, Ordering::Relaxed);
327 ffi_or("ffiapp.dispatch", (), || {
328 crate::perflog!("ffi.dispatch action={:?}", std::mem::discriminant(&action));
329 self.recovery.remember_action(&action);
330 let _ = self.foreground_tx.send(CoreMsg::Action(action));
331 })
332 }
333
334 pub fn perf_counters(&self) -> FfiPerfCountersSnapshot {
340 FfiPerfCountersSnapshot {
341 state: self.perf.state.load(Ordering::Relaxed),
342 dispatch: self.perf.dispatch.load(Ordering::Relaxed),
343 search: self.perf.search.load(Ordering::Relaxed),
344 export_support_bundle_json: self
345 .perf
346 .export_support_bundle_json
347 .load(Ordering::Relaxed),
348 peer_profile_debug: self.perf.peer_profile_debug.load(Ordering::Relaxed),
349 mutual_groups: self.perf.mutual_groups.load(Ordering::Relaxed),
350 prepare_for_suspend: self.perf.prepare_for_suspend.load(Ordering::Relaxed),
351 }
352 }
353
354 pub fn core_perf_counters(&self) -> CorePerfCountersSnapshot {
360 ffi_or(
361 "ffiapp.core_perf_counters",
362 CorePerfCountersSnapshot::default(),
363 || {
364 let (reply_tx, reply_rx) = flume::bounded(1);
365 if self
366 .foreground_tx
367 .send(CoreMsg::CorePerfCounters(reply_tx))
368 .is_err()
369 {
370 return CorePerfCountersSnapshot::default();
371 }
372 match reply_rx.recv_timeout(Duration::from_secs(2)) {
373 Ok(snapshot) => CorePerfCountersSnapshot {
374 debug_snapshot_builds: snapshot.debug_snapshot_builds,
375 },
376 Err(_) => CorePerfCountersSnapshot::default(),
377 }
378 },
379 )
380 }
381
382 pub fn search(
389 &self,
390 query: String,
391 scope_chat_id: Option<String>,
392 limit: u32,
393 ) -> SearchResultSnapshot {
394 self.perf.search.fetch_add(1, Ordering::Relaxed);
395 ffi_or(
396 "ffiapp.search",
397 SearchResultSnapshot::empty(query.clone(), scope_chat_id.clone()),
398 || {
399 let trimmed = query.trim();
400 if trimmed.is_empty() {
401 return SearchResultSnapshot::empty(query.clone(), scope_chat_id.clone());
402 }
403 let limit = limit.max(1) as usize;
404 let state_snapshot = match self.shared_state.read() {
405 Ok(slot) => slot.clone(),
406 Err(poison) => poison.into_inner().clone(),
407 };
408 let (contacts, groups) = if scope_chat_id.is_some() {
409 (Vec::new(), Vec::new())
410 } else {
411 filter_threads_for_search(&state_snapshot.chat_list, trimmed)
412 };
413 let shared_db = self.shared_db_snapshot();
414 let messages = match shared_db.as_ref() {
415 Some(shared) => match shared.lock() {
416 Ok(conn) => crate::core::search_messages_fts(
417 &conn,
418 trimmed,
419 scope_chat_id.as_deref(),
420 limit,
421 )
422 .unwrap_or_default(),
423 Err(poison) => crate::core::search_messages_fts(
424 &poison.into_inner(),
425 trimmed,
426 scope_chat_id.as_deref(),
427 limit,
428 )
429 .unwrap_or_default(),
430 },
431 None => Vec::new(),
432 };
433 let enriched = enrich_message_hits(messages, &state_snapshot.chat_list);
434 let shortcut = if scope_chat_id.is_none() {
439 chat_input_shortcut(trimmed)
440 } else {
441 None
442 };
443 SearchResultSnapshot {
444 query,
445 scope_chat_id,
446 contacts,
447 groups,
448 messages: enriched,
449 shortcut,
450 }
451 },
452 )
453 }
454
455 pub fn chat_snapshot(&self, chat_id: String, limit: u32) -> Option<CurrentChatSnapshot> {
461 ffi_or("ffiapp.chat_snapshot", None, || {
462 let state_snapshot = match self.shared_state.read() {
463 Ok(slot) => slot.clone(),
464 Err(poison) => poison.into_inner().clone(),
465 };
466 crate::core::chat_snapshot_from_state_and_db(
467 &state_snapshot,
468 self.shared_db_snapshot().as_ref(),
469 &chat_id,
470 limit.max(1) as usize,
471 )
472 })
473 }
474
475 pub fn chat_snapshot_before(
476 &self,
477 chat_id: String,
478 before_message_id: String,
479 limit: u32,
480 ) -> Option<CurrentChatSnapshot> {
481 ffi_or("ffiapp.chat_snapshot_before", None, || {
482 let state_snapshot = match self.shared_state.read() {
483 Ok(slot) => slot.clone(),
484 Err(poison) => poison.into_inner().clone(),
485 };
486 crate::core::chat_snapshot_before_from_state_and_db(
487 &state_snapshot,
488 self.shared_db_snapshot().as_ref(),
489 &chat_id,
490 &before_message_id,
491 limit.max(1) as usize,
492 )
493 })
494 }
495
496 pub fn chat_snapshot_around_message(
497 &self,
498 chat_id: String,
499 message_id: String,
500 before_limit: u32,
501 after_limit: u32,
502 ) -> Option<CurrentChatSnapshot> {
503 ffi_or("ffiapp.chat_snapshot_around_message", None, || {
504 let state_snapshot = match self.shared_state.read() {
505 Ok(slot) => slot.clone(),
506 Err(poison) => poison.into_inner().clone(),
507 };
508 crate::core::chat_snapshot_around_message_from_state_and_db(
509 &state_snapshot,
510 self.shared_db_snapshot().as_ref(),
511 &chat_id,
512 &message_id,
513 before_limit as usize,
514 after_limit as usize,
515 )
516 })
517 }
518
519 pub fn export_support_bundle_json(&self) -> String {
520 self.perf
521 .export_support_bundle_json
522 .fetch_add(1, Ordering::Relaxed);
523 ffi_or(
524 "ffiapp.export_support_bundle_json",
525 self.support_bundle_json_with_ffi_diagnostics("{}".to_string(), true),
526 || {
527 let (reply_tx, reply_rx) = flume::bounded(1);
528 if self
529 .foreground_tx
530 .send(CoreMsg::ExportSupportBundle(reply_tx))
531 .is_err()
532 {
533 return self.support_bundle_json_with_ffi_diagnostics("{}".to_string(), true);
534 }
535 match reply_rx.recv_timeout(SUPPORT_BUNDLE_REPLY_TIMEOUT) {
536 Ok(json) => self.support_bundle_json_with_ffi_diagnostics(json, false),
537 Err(_) => self.support_bundle_json_with_ffi_diagnostics("{}".to_string(), true),
538 }
539 },
540 )
541 }
542
543 pub fn peer_profile_debug(&self, owner_input: String) -> Option<PeerProfileDebugSnapshot> {
544 self.perf.peer_profile_debug.fetch_add(1, Ordering::Relaxed);
545 ffi_or("ffiapp.peer_profile_debug", None, || {
546 let (reply_tx, reply_rx) = flume::bounded(1);
547 if self
548 .foreground_tx
549 .send(CoreMsg::PeerProfileDebug {
550 owner_input,
551 reply_tx,
552 })
553 .is_err()
554 {
555 return None;
556 }
557 reply_rx.recv_timeout(Duration::from_secs(2)).ok().flatten()
558 })
559 }
560
561 pub fn mutual_groups(&self, owner_input: String) -> MutualGroupsSnapshot {
562 self.perf.mutual_groups.fetch_add(1, Ordering::Relaxed);
563 ffi_or(
564 "ffiapp.mutual_groups",
565 MutualGroupsSnapshot::default(),
566 || {
567 let (reply_tx, reply_rx) = flume::bounded(1);
568 if self
569 .foreground_tx
570 .send(CoreMsg::MutualGroups {
571 owner_input,
572 reply_tx,
573 })
574 .is_err()
575 {
576 return MutualGroupsSnapshot::default();
577 }
578 reply_rx
579 .recv_timeout(Duration::from_secs(2))
580 .unwrap_or_default()
581 },
582 )
583 }
584
585 pub fn prepare_for_suspend(&self) {
586 self.perf
587 .prepare_for_suspend
588 .fetch_add(1, Ordering::Relaxed);
589 ffi_or("ffiapp.prepare_for_suspend", (), || {
590 let (reply_tx, reply_rx) = flume::bounded(1);
591 if self
592 .foreground_tx
593 .send(CoreMsg::PrepareForSuspend(reply_tx))
594 .is_err()
595 {
596 return;
597 }
598 let _ = reply_rx.recv_timeout(Duration::from_secs(2));
599 })
600 }
601
602 pub fn shutdown(&self) {
603 ffi_or("ffiapp.shutdown", (), || {
604 let (reply_tx, reply_rx) = flume::bounded(1);
605 if self
606 .foreground_tx
607 .send(CoreMsg::Shutdown(Some(reply_tx)))
608 .is_err()
609 {
610 return;
611 }
612 let _ = reply_rx.recv_timeout(Duration::from_secs(2));
613 })
614 }
615
616 fn support_bundle_json_with_ffi_diagnostics(
617 &self,
618 rust_json: String,
619 core_support_bundle_timed_out: bool,
620 ) -> String {
621 let mut object = serde_json::from_str::<serde_json::Value>(&rust_json)
622 .ok()
623 .and_then(|value| value.as_object().cloned())
624 .unwrap_or_default();
625 let now_ms = crate::perflog::now_ms();
626 let last_started_at_ms = self
627 .queue_metrics
628 .last_batch_started_at_ms
629 .load(Ordering::Relaxed);
630 let last_finished_at_ms = self
631 .queue_metrics
632 .last_batch_finished_at_ms
633 .load(Ordering::Relaxed);
634 let batch_active = self.queue_metrics.batch_active.load(Ordering::Acquire);
635 let active_batch_age_ms = if batch_active && last_started_at_ms > 0 {
636 Some(now_ms.saturating_sub(last_started_at_ms))
637 } else {
638 None
639 };
640 let last_batch_started_ago_ms = if last_started_at_ms > 0 {
641 Some(now_ms.saturating_sub(last_started_at_ms))
642 } else {
643 None
644 };
645 let last_batch_finished_ago_ms = if last_finished_at_ms > 0 {
646 Some(now_ms.saturating_sub(last_finished_at_ms))
647 } else {
648 None
649 };
650 object.insert(
651 "ffi_queue".to_string(),
652 serde_json::json!({
653 "core_support_bundle_timed_out": core_support_bundle_timed_out,
654 "foreground_pending": self.foreground_rx.len(),
655 "background_pending": self.background_rx.len(),
656 "foreground_processed": self.queue_metrics.foreground_processed.load(Ordering::Relaxed),
657 "background_processed": self.queue_metrics.background_processed.load(Ordering::Relaxed),
658 "batch_active": batch_active,
659 "active_batch_age_ms": active_batch_age_ms,
660 "last_batch_started_ago_ms": last_batch_started_ago_ms,
661 "last_batch_finished_ago_ms": last_batch_finished_ago_ms,
662 "last_batch_size": self.queue_metrics.last_batch_size.load(Ordering::Relaxed),
663 "last_batch_foreground_count": self.queue_metrics.last_batch_foreground_count.load(Ordering::Relaxed),
664 "last_batch_background_count": self.queue_metrics.last_batch_background_count.load(Ordering::Relaxed),
665 "core_restarts": self.recovery.restart_count(),
666 "last_core_panic": self.recovery.last_panic(),
667 "has_cached_restore_action": self.recovery.restore_action().is_some(),
668 }),
669 );
670 serde_json::Value::Object(object).to_string()
671 }
672
673 pub fn listen_for_updates(&self, reconciler: Box<dyn AppReconciler>) {
674 if self
675 .listening
676 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
677 .is_err()
678 {
679 return;
680 }
681
682 let update_rx = self.update_rx.clone();
683 let recovery = self.recovery.clone();
684 let spawn_result = thread::Builder::new()
685 .name("iris-updates".to_string())
686 .spawn(move || {
687 while let Ok(first) = update_rx.recv() {
699 let mut latest_full_state: Option<AppUpdate> = None;
700 let mut before_full_state: Vec<AppUpdate> = Vec::new();
701 let mut after_full_state: Vec<AppUpdate> = Vec::new();
702 let process = |update: AppUpdate,
703 latest: &mut Option<AppUpdate>,
704 before: &mut Vec<AppUpdate>,
705 after: &mut Vec<AppUpdate>| {
706 recovery.remember_update(&update);
707 enqueue_update_for_delivery(update, latest, before, after);
708 };
709 process(
710 first,
711 &mut latest_full_state,
712 &mut before_full_state,
713 &mut after_full_state,
714 );
715 while let Ok(next) = update_rx.try_recv() {
716 process(
717 next,
718 &mut latest_full_state,
719 &mut before_full_state,
720 &mut after_full_state,
721 );
722 }
723 for update in before_full_state
724 .into_iter()
725 .chain(latest_full_state)
726 .chain(after_full_state)
727 {
728 let kind = match &update {
729 AppUpdate::FullState(_) => "FullState",
730 AppUpdate::PersistAccountBundle { .. } => "PersistAccountBundle",
731 AppUpdate::NearbyPublishedEvent { .. } => "NearbyPublishedEvent",
732 AppUpdate::NearbyPeersChanged { .. } => "NearbyPeersChanged",
733 };
734 let t0 = crate::perflog::now_ms();
735 crate::perflog!("reconcile.start kind={kind}");
736 if panic::catch_unwind(AssertUnwindSafe(|| reconciler.reconcile(update)))
737 .is_err()
738 {
739 crate::perflog!("reconcile.failed kind={kind}");
740 continue;
741 }
742 crate::perflog!(
743 "reconcile.end kind={kind} elapsed_ms={}",
744 crate::perflog::now_ms().saturating_sub(t0)
745 );
746 }
747 }
748 });
749 if let Err(error) = spawn_result {
750 crate::perflog!("updates.spawn.failed error={error}");
751 self.listening.store(false, Ordering::SeqCst);
752 }
753 }
754}
755
756impl FfiApp {
757 fn shared_db_snapshot(&self) -> Option<crate::core::SharedConnection> {
758 match self.shared_db.read() {
759 Ok(slot) => slot.clone(),
760 Err(poison) => poison.into_inner().clone(),
761 }
762 }
763}
764
765fn new_ffi_app_inner(data_dir: String) -> Arc<FfiApp> {
766 let (update_tx, update_rx) = flume::unbounded();
767 let (foreground_tx, foreground_rx) = flume::unbounded();
768 let (background_tx, background_rx) = flume::unbounded();
769 let shared_state = Arc::new(RwLock::new(AppState::empty()));
770 let queue_metrics = Arc::new(CoreQueueMetrics::default());
771 let recovery = Arc::new(CoreRecoveryState::default());
772 let shared_db = Arc::new(RwLock::new(None));
773
774 let update_tx_for_error = update_tx.clone();
775 match AppCore::try_new_with_priority_sender(
776 update_tx.clone(),
777 background_tx.clone(),
778 foreground_tx.clone(),
779 data_dir.clone(),
780 shared_state.clone(),
781 ) {
782 Ok(core) => {
783 set_shared_db(&shared_db, Some(core.shared_db()));
784 let spawn_result = spawn_core_supervisor(
785 core,
786 CoreSupervisor {
787 data_dir,
788 update_tx: update_tx.clone(),
789 core_sender: background_tx.clone(),
790 priority_sender: foreground_tx.clone(),
791 foreground_rx: foreground_rx.clone(),
792 background_rx: background_rx.clone(),
793 shared_state: shared_state.clone(),
794 shared_db: shared_db.clone(),
795 queue_metrics: queue_metrics.clone(),
796 recovery: recovery.clone(),
797 },
798 );
799 if let Err(error) = spawn_result {
800 publish_core_failure_state(
801 &shared_state,
802 &update_tx_for_error,
803 format!("Iris could not start: {error}"),
804 );
805 }
806 }
807 Err(error) => {
808 publish_core_failure_state(&shared_state, &update_tx_for_error, error.to_string());
809 }
810 }
811
812 Arc::new(FfiApp {
813 foreground_tx,
814 foreground_rx,
815 background_tx,
816 background_rx,
817 update_rx,
818 listening: AtomicBool::new(false),
819 shared_state,
820 shared_db,
821 perf: FfiPerfCounters::default(),
822 queue_metrics,
823 recovery,
824 })
825}
826
827fn ffi_app_failure(message: String) -> Arc<FfiApp> {
828 let (_update_tx, update_rx) = flume::unbounded();
829 let (foreground_tx, foreground_rx) = flume::unbounded();
830 let (background_tx, background_rx) = flume::unbounded();
831 let mut state = AppState::empty();
832 state.toast = Some(message);
833 state.rev = 1;
834 let shared_state = Arc::new(RwLock::new(state));
835 Arc::new(FfiApp {
836 foreground_tx,
837 foreground_rx,
838 background_tx,
839 background_rx,
840 update_rx,
841 listening: AtomicBool::new(false),
842 shared_state,
843 shared_db: Arc::new(RwLock::new(None)),
844 perf: FfiPerfCounters::default(),
845 queue_metrics: Arc::new(CoreQueueMetrics::default()),
846 recovery: Arc::new(CoreRecoveryState::default()),
847 })
848}
849
850struct CoreSupervisor {
851 data_dir: String,
852 update_tx: Sender<AppUpdate>,
853 core_sender: Sender<CoreMsg>,
854 priority_sender: Sender<CoreMsg>,
855 foreground_rx: Receiver<CoreMsg>,
856 background_rx: Receiver<CoreMsg>,
857 shared_state: Arc<RwLock<AppState>>,
858 shared_db: Arc<RwLock<Option<crate::core::SharedConnection>>>,
859 queue_metrics: Arc<CoreQueueMetrics>,
860 recovery: Arc<CoreRecoveryState>,
861}
862
863fn spawn_core_supervisor(
864 core: AppCore,
865 supervisor: CoreSupervisor,
866) -> std::io::Result<thread::JoinHandle<()>> {
867 thread::Builder::new()
868 .name("iris-core".to_string())
869 .spawn(move || {
870 let mut core_slot = Some(core);
871 while let Ok(batch) =
876 recv_core_batch(&supervisor.foreground_rx, &supervisor.background_rx)
877 {
878 let batch_size = batch.len();
879 let foreground_count = batch
880 .iter()
881 .filter(|msg| is_foreground_core_msg(msg))
882 .count() as u64;
883 let background_count = batch_size as u64 - foreground_count;
884 supervisor.queue_metrics.mark_batch_start(
885 batch_size as u64,
886 foreground_count,
887 background_count,
888 );
889 let t0 = crate::perflog::now_ms();
890 crate::perflog!("core.batch.start size={batch_size}");
891 let result = match core_slot.as_mut() {
892 Some(core) => catch_core_batch(|| handle_core_batch_responsive(core, batch)),
893 None => break,
894 };
895 let elapsed_ms = crate::perflog::now_ms().saturating_sub(t0);
896 supervisor
897 .queue_metrics
898 .mark_batch_finished(foreground_count, background_count);
899 match result {
900 Ok(true) => {
901 crate::perflog!(
902 "core.batch.end size={batch_size} elapsed_ms={elapsed_ms}"
903 );
904 }
905 Ok(false) => {
906 crate::perflog!(
907 "core.batch.end size={batch_size} elapsed_ms={elapsed_ms} result=shutdown"
908 );
909 break;
910 }
911 Err(error) => {
912 if let Some(mut failed_core) = core_slot.take() {
913 failed_core.record_core_panic(error.clone());
914 }
915 crate::perflog!(
916 "core.batch.end size={batch_size} elapsed_ms={elapsed_ms} result=panic"
917 );
918 match recover_core_after_panic(&supervisor, error) {
919 Some(core) => core_slot = Some(core),
920 None => break,
921 }
922 }
923 }
924 }
925 })
926}
927
928fn recover_core_after_panic(supervisor: &CoreSupervisor, detail: String) -> Option<AppCore> {
929 let restart_count = supervisor.recovery.mark_panic(detail);
930 crate::perflog!("core.supervisor.restart count={restart_count}");
931 set_shared_db(&supervisor.shared_db, None);
932
933 let mut core = match AppCore::try_new_with_priority_sender(
934 supervisor.update_tx.clone(),
935 supervisor.core_sender.clone(),
936 supervisor.priority_sender.clone(),
937 supervisor.data_dir.clone(),
938 supervisor.shared_state.clone(),
939 ) {
940 Ok(core) => core,
941 Err(error) => {
942 crate::perflog!("core.supervisor.restart.failed count={restart_count} error={error}");
943 publish_core_failure_state(
944 &supervisor.shared_state,
945 &supervisor.update_tx,
946 CORE_RESTART_TOAST.to_string(),
947 );
948 return None;
949 }
950 };
951
952 set_shared_db(&supervisor.shared_db, Some(core.shared_db()));
953 if let Some(action) = supervisor.recovery.restore_action() {
954 crate::perflog!(
955 "core.supervisor.restore action={:?}",
956 std::mem::discriminant(&action)
957 );
958 match catch_core_batch(|| core.handle_messages(vec![CoreMsg::Action(action)])) {
959 Ok(true) => {}
960 Ok(false) => {
961 publish_core_failure_state(
962 &supervisor.shared_state,
963 &supervisor.update_tx,
964 CORE_RESTART_TOAST.to_string(),
965 );
966 return None;
967 }
968 Err(error) => {
969 core.mark_core_panic(format!("core recovery restore panic: {error}"));
970 return None;
971 }
972 }
973 }
974
975 crate::perflog!("core.supervisor.recovered count={restart_count}");
976 Some(core)
977}
978
979fn set_shared_db(
980 shared_db: &Arc<RwLock<Option<crate::core::SharedConnection>>>,
981 value: Option<crate::core::SharedConnection>,
982) {
983 match shared_db.write() {
984 Ok(mut slot) => *slot = value,
985 Err(poison) => *poison.into_inner() = value,
986 }
987}
988
989fn publish_core_failure_state(
990 shared_state: &Arc<RwLock<AppState>>,
991 update_tx: &Sender<AppUpdate>,
992 message: String,
993) {
994 let mut state = match shared_state.read() {
995 Ok(slot) => slot.clone(),
996 Err(poison) => poison.into_inner().clone(),
997 };
998 state.toast = Some(message);
999 state.rev = state.rev.saturating_add(1).max(1);
1000 match shared_state.write() {
1001 Ok(mut slot) => *slot = state.clone(),
1002 Err(poison) => *poison.into_inner() = state.clone(),
1003 }
1004 let _ = update_tx.send(AppUpdate::FullState(state));
1005}
1006
1007const CORE_FOREGROUND_BATCH_LIMIT: usize = 64;
1008const CORE_BACKGROUND_BATCH_LIMIT: usize = 16;
1009
1010fn recv_core_batch(
1011 foreground_rx: &Receiver<CoreMsg>,
1012 background_rx: &Receiver<CoreMsg>,
1013) -> Result<Vec<CoreMsg>, flume::RecvError> {
1014 if let Some(batch) = try_recv_core_batch(foreground_rx, background_rx) {
1015 return Ok(batch);
1016 }
1017
1018 let (is_foreground, first) = flume::Selector::new()
1019 .recv(foreground_rx, |result| result.map(|msg| (true, msg)))
1020 .recv(background_rx, |result| result.map(|msg| (false, msg)))
1021 .wait()?;
1022 Ok(drain_core_batch_after_first(
1023 is_foreground,
1024 first,
1025 foreground_rx,
1026 background_rx,
1027 ))
1028}
1029
1030fn try_recv_core_batch(
1031 foreground_rx: &Receiver<CoreMsg>,
1032 background_rx: &Receiver<CoreMsg>,
1033) -> Option<Vec<CoreMsg>> {
1034 if let Ok(first) = foreground_rx.try_recv() {
1035 return Some(drain_core_batch_after_first(
1036 true,
1037 first,
1038 foreground_rx,
1039 background_rx,
1040 ));
1041 }
1042 background_rx
1043 .try_recv()
1044 .ok()
1045 .map(|first| drain_core_batch_after_first(false, first, foreground_rx, background_rx))
1046}
1047
1048fn drain_core_batch_after_first(
1049 is_foreground: bool,
1050 first: CoreMsg,
1051 foreground_rx: &Receiver<CoreMsg>,
1052 background_rx: &Receiver<CoreMsg>,
1053) -> Vec<CoreMsg> {
1054 let mut batch = Vec::with_capacity(if is_foreground {
1055 CORE_FOREGROUND_BATCH_LIMIT
1056 } else {
1057 CORE_BACKGROUND_BATCH_LIMIT
1058 });
1059 batch.push(first);
1060 drain_foreground_messages(&mut batch, foreground_rx);
1061 if batch.iter().any(is_foreground_core_msg) {
1062 return batch;
1063 }
1064
1065 while batch.len() < CORE_BACKGROUND_BATCH_LIMIT {
1066 let Ok(next) = background_rx.try_recv() else {
1067 break;
1068 };
1069 batch.push(next);
1070 drain_foreground_messages(&mut batch, foreground_rx);
1071 if batch.iter().any(is_foreground_core_msg) {
1072 break;
1073 }
1074 }
1075 batch
1076}
1077
1078fn drain_foreground_messages(batch: &mut Vec<CoreMsg>, foreground_rx: &Receiver<CoreMsg>) {
1079 while batch.len() < CORE_FOREGROUND_BATCH_LIMIT {
1080 let Ok(next) = foreground_rx.try_recv() else {
1081 break;
1082 };
1083 batch.push(next);
1084 }
1085}
1086
1087fn handle_core_batch_responsive(core: &mut AppCore, messages: Vec<CoreMsg>) -> bool {
1088 if messages.len() <= 1 || !messages.iter().any(is_foreground_core_msg) {
1089 return core.handle_messages(messages);
1090 }
1091
1092 let mut foreground = Vec::new();
1093 let mut background = Vec::new();
1094 for message in messages {
1095 if is_foreground_core_msg(&message) {
1096 foreground.push(message);
1097 } else {
1098 background.push(message);
1099 }
1100 }
1101
1102 for message in foreground {
1107 if !core.handle_messages(vec![message]) {
1108 return false;
1109 }
1110 }
1111 background.is_empty() || core.handle_messages(background)
1112}
1113
1114fn catch_core_batch<F>(f: F) -> Result<bool, String>
1115where
1116 F: FnOnce() -> bool,
1117{
1118 panic::catch_unwind(AssertUnwindSafe(f)).map_err(panic_payload_to_string)
1119}
1120
1121fn ffi_or<T, F>(label: &'static str, fallback: T, f: F) -> T
1122where
1123 F: FnOnce() -> T,
1124{
1125 match panic::catch_unwind(AssertUnwindSafe(f)) {
1126 Ok(value) => value,
1127 Err(payload) => {
1128 crate::perflog!(
1129 "ffi.panic label={label} detail={}",
1130 panic_payload_to_string(payload)
1131 );
1132 fallback
1133 }
1134 }
1135}
1136
1137fn ffi_failure_state() -> AppState {
1138 let mut state = AppState::empty();
1139 state.toast = Some("Iris needs restart. Copy support bundle in Settings.".to_string());
1140 state
1141}
1142
1143fn suppressed_mobile_push_resolution() -> MobilePushNotificationResolution {
1144 MobilePushNotificationResolution {
1145 should_show: false,
1146 title: String::new(),
1147 body: String::new(),
1148 payload_json: "{}".to_string(),
1149 }
1150}
1151
1152fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
1153 if let Some(message) = payload.downcast_ref::<&str>() {
1154 (*message).to_string()
1155 } else if let Some(message) = payload.downcast_ref::<String>() {
1156 message.clone()
1157 } else {
1158 "unknown panic".to_string()
1159 }
1160}
1161
1162fn is_foreground_core_msg(message: &CoreMsg) -> bool {
1163 !matches!(message, CoreMsg::Internal(_))
1164}
1165
1166#[cfg(test)]
1167mod core_queue_tests;
1168
1169fn filter_threads_for_search(
1170 chat_list: &[ChatThreadSnapshot],
1171 query: &str,
1172) -> (Vec<ChatThreadSnapshot>, Vec<ChatThreadSnapshot>) {
1173 let needle = query.to_lowercase();
1174 let mut contacts = Vec::new();
1175 let mut groups = Vec::new();
1176 for chat in chat_list {
1177 if !thread_matches_query(chat, &needle) {
1178 continue;
1179 }
1180 match chat.kind {
1181 ChatKind::Direct => contacts.push(chat.clone()),
1182 ChatKind::Group => groups.push(chat.clone()),
1183 }
1184 }
1185 (contacts, groups)
1186}
1187
1188fn thread_matches_query(chat: &ChatThreadSnapshot, needle_lower: &str) -> bool {
1189 let candidates: [&str; 7] = [
1190 &chat.display_name,
1191 chat.nickname.as_deref().unwrap_or(""),
1192 chat.profile_name.as_deref().unwrap_or(""),
1193 chat.about.as_deref().unwrap_or(""),
1194 chat.subtitle.as_deref().unwrap_or(""),
1195 &chat.draft,
1196 &chat.chat_id,
1197 ];
1198 candidates
1199 .iter()
1200 .any(|field| field.to_lowercase().contains(needle_lower))
1201}
1202
1203fn enrich_message_hits(
1204 hits: Vec<crate::core::PersistedMessageSearchHit>,
1205 chat_list: &[ChatThreadSnapshot],
1206) -> Vec<MessageSearchHit> {
1207 use std::collections::HashMap;
1208 let lookup: HashMap<&str, &ChatThreadSnapshot> = chat_list
1209 .iter()
1210 .map(|chat| (chat.chat_id.as_str(), chat))
1211 .collect();
1212 hits.into_iter()
1213 .map(|hit| {
1214 let parent = lookup.get(hit.chat_id.as_str());
1215 let display_name = parent
1216 .map(|chat| chat.display_name.clone())
1217 .unwrap_or_else(|| short_chat_label(&hit.chat_id));
1218 let picture_url = parent.and_then(|chat| chat.picture_url.clone());
1219 let kind = parent
1220 .map(|chat| chat.kind.clone())
1221 .unwrap_or(ChatKind::Direct);
1222 MessageSearchHit {
1223 chat_id: hit.chat_id,
1224 message_id: hit.message_id,
1225 chat_display_name: display_name,
1226 chat_picture_url: picture_url,
1227 chat_kind: kind,
1228 author_pubkey: hit.author,
1229 body: hit.body,
1230 is_outgoing: hit.is_outgoing,
1231 created_at_secs: hit.created_at_secs,
1232 }
1233 })
1234 .collect()
1235}
1236
1237fn short_chat_label(chat_id: &str) -> String {
1238 let trimmed = chat_id.trim();
1239 if trimmed.len() > 12 {
1240 format!("{}…", &trimmed[..12])
1241 } else {
1242 trimmed.to_string()
1243 }
1244}
1245
1246impl Drop for FfiApp {
1247 fn drop(&mut self) {
1248 let _ = self.foreground_tx.send(CoreMsg::Shutdown(None));
1249 }
1250}
1251
1252#[uniffi::export]
1253pub fn normalize_peer_input(input: String) -> String {
1254 ffi_or("normalize_peer_input", String::new(), || {
1255 crate::core::normalize_peer_input_for_display(&input)
1256 })
1257}
1258
1259#[uniffi::export]
1260pub fn is_valid_peer_input(input: String) -> bool {
1261 ffi_or("is_valid_peer_input", false, || {
1262 crate::core::parse_peer_input(&input).is_ok()
1263 })
1264}
1265
1266#[uniffi::export]
1272pub fn classify_chat_input(input: String) -> Option<ChatInputShortcut> {
1273 ffi_or("classify_chat_input", None, || chat_input_shortcut(&input))
1274}
1275
1276fn chat_input_shortcut(raw: &str) -> Option<ChatInputShortcut> {
1277 let trimmed = raw.trim();
1278 if trimmed.is_empty() {
1279 return None;
1280 }
1281 let lower = trimmed.to_lowercase();
1282 if lower.contains("://") && lower.contains('#') {
1283 return Some(ChatInputShortcut::Invite {
1284 invite_input: trimmed.to_string(),
1285 display: short_invite_display(trimmed),
1286 });
1287 }
1288 if crate::core::parse_peer_input(trimmed).is_ok() {
1289 use nostr::nips::nip19::ToBech32;
1290 let normalized = crate::core::normalize_peer_input_for_display(trimmed);
1291 if let Ok(pubkey) = nostr::PublicKey::parse(&normalized) {
1292 let npub = pubkey.to_bech32().unwrap_or_else(|_| normalized.clone());
1293 let display = short_npub_display(&npub);
1294 return Some(ChatInputShortcut::DirectPeer {
1295 peer_input: normalized,
1296 display,
1297 npub,
1298 pubkey_hex: pubkey.to_hex(),
1299 });
1300 }
1301 }
1302 None
1303}
1304
1305fn short_npub_display(npub: &str) -> String {
1306 if npub.len() > 16 {
1307 format!("{}…{}", &npub[..10], &npub[npub.len() - 4..])
1308 } else {
1309 npub.to_string()
1310 }
1311}
1312
1313fn short_invite_display(invite: &str) -> String {
1314 let after_scheme = invite
1318 .split_once("://")
1319 .map(|(_, rest)| rest)
1320 .unwrap_or(invite);
1321 if after_scheme.len() > 32 {
1322 format!("{}…", &after_scheme[..32])
1323 } else {
1324 after_scheme.to_string()
1325 }
1326}
1327
1328#[uniffi::export]
1333pub fn peer_input_to_hex(input: String) -> String {
1334 ffi_or("peer_input_to_hex", String::new(), || {
1335 let normalized = crate::core::normalize_peer_input_for_display(&input);
1336 match nostr::PublicKey::parse(&normalized) {
1337 Ok(pubkey) => pubkey.to_hex(),
1338 Err(_) => String::new(),
1339 }
1340 })
1341}
1342
1343#[uniffi::export]
1346pub fn peer_input_to_npub(input: String) -> String {
1347 ffi_or("peer_input_to_npub", String::new(), || {
1348 use nostr::nips::nip19::ToBech32;
1349 let normalized = crate::core::normalize_peer_input_for_display(&input);
1350 match nostr::PublicKey::parse(&normalized) {
1351 Ok(pubkey) => pubkey.to_bech32().unwrap_or(normalized),
1352 Err(_) => normalized,
1353 }
1354 })
1355}
1356
1357#[uniffi::export]
1358pub fn build_summary() -> String {
1359 ffi_or("build_summary", String::new(), crate::core::build_summary)
1360}
1361
1362#[uniffi::export]
1363pub fn relay_set_id() -> String {
1364 ffi_or("relay_set_id", String::new(), || {
1365 crate::core::relay_set_id().to_string()
1366 })
1367}
1368
1369#[uniffi::export]
1370pub fn proxied_image_url(
1371 original_src: String,
1372 preferences: PreferencesSnapshot,
1373 width: Option<u32>,
1374 height: Option<u32>,
1375 square: bool,
1376) -> String {
1377 ffi_or("proxied_image_url", original_src.clone(), || {
1378 image_proxy::proxied_image_url(&original_src, &preferences, width, height, square)
1379 })
1380}
1381
1382#[uniffi::export]
1383pub fn is_trusted_test_build() -> bool {
1384 ffi_or(
1385 "is_trusted_test_build",
1386 false,
1387 crate::core::trusted_test_build_flag,
1388 )
1389}
1390
1391#[uniffi::export]
1396pub fn app_version() -> String {
1397 crate::core::app_version_string().to_string()
1398}
1399
1400#[uniffi::export]
1401pub fn resolve_mobile_push_notification_payload(
1402 raw_payload_json: String,
1403) -> MobilePushNotificationResolution {
1404 ffi_or(
1405 "resolve_mobile_push_notification_payload",
1406 suppressed_mobile_push_resolution(),
1407 || crate::core::resolve_mobile_push_notification(raw_payload_json),
1408 )
1409}
1410
1411#[uniffi::export]
1417pub fn decrypt_mobile_push_notification_payload(
1418 data_dir: String,
1419 owner_pubkey_hex: String,
1420 device_nsec: String,
1421 raw_payload_json: String,
1422) -> MobilePushNotificationResolution {
1423 ffi_or(
1424 "decrypt_mobile_push_notification_payload",
1425 suppressed_mobile_push_resolution(),
1426 || {
1427 crate::core::decrypt_mobile_push_notification(
1428 data_dir,
1429 owner_pubkey_hex,
1430 device_nsec,
1431 raw_payload_json,
1432 )
1433 },
1434 )
1435}
1436
1437pub use crate::core::notifications::NotificationCandidate;
1438
1439#[uniffi::export]
1446pub fn decide_pending_notifications(
1447 previous_chats: Vec<ChatThreadSnapshot>,
1448 next_chats: Vec<ChatThreadSnapshot>,
1449 preferences: PreferencesSnapshot,
1450 app_foreground: bool,
1451 open_chat_id: Option<String>,
1452) -> Vec<NotificationCandidate> {
1453 crate::core::notifications::decide_notifications(
1454 &previous_chats,
1455 &next_chats,
1456 &preferences,
1457 app_foreground,
1458 open_chat_id.as_deref(),
1459 )
1460}
1461
1462#[uniffi::export]
1466pub fn router_open_chat_id(router: Router) -> Option<String> {
1467 crate::core::notifications::active_chat_id(&router)
1468}
1469
1470#[uniffi::export]
1471pub fn resolve_mobile_push_subscription_server_url(
1472 platform_key: String,
1473 is_release: bool,
1474 override_url: Option<String>,
1475) -> String {
1476 ffi_or(
1477 "resolve_mobile_push_subscription_server_url",
1478 String::new(),
1479 || crate::core::resolve_mobile_push_server_url(platform_key, is_release, override_url),
1480 )
1481}
1482
1483#[uniffi::export]
1484pub fn mobile_push_subscription_id_key(platform_key: String) -> String {
1485 ffi_or("mobile_push_subscription_id_key", String::new(), || {
1486 crate::core::mobile_push_stored_subscription_id_key(platform_key)
1487 })
1488}
1489
1490#[uniffi::export]
1491pub fn build_mobile_push_list_subscriptions_request(
1492 owner_nsec: String,
1493 platform_key: String,
1494 is_release: bool,
1495 server_url_override: Option<String>,
1496) -> Option<MobilePushSubscriptionRequest> {
1497 ffi_or("build_mobile_push_list_subscriptions_request", None, || {
1498 crate::core::build_mobile_push_list_subscriptions_request(
1499 owner_nsec,
1500 platform_key,
1501 is_release,
1502 server_url_override,
1503 )
1504 })
1505}
1506
1507#[uniffi::export]
1508#[allow(clippy::too_many_arguments)]
1509pub fn build_mobile_push_create_subscription_request(
1510 owner_nsec: String,
1511 platform_key: String,
1512 push_token: String,
1513 apns_topic: Option<String>,
1514 message_author_pubkeys: Vec<String>,
1515 invite_response_pubkeys: Vec<String>,
1516 is_release: bool,
1517 server_url_override: Option<String>,
1518) -> Option<MobilePushSubscriptionRequest> {
1519 ffi_or(
1520 "build_mobile_push_create_subscription_request",
1521 None,
1522 || {
1523 crate::core::build_mobile_push_create_subscription_request(
1524 owner_nsec,
1525 platform_key,
1526 push_token,
1527 apns_topic,
1528 message_author_pubkeys,
1529 invite_response_pubkeys,
1530 is_release,
1531 server_url_override,
1532 )
1533 },
1534 )
1535}
1536
1537#[uniffi::export]
1538#[allow(clippy::too_many_arguments)]
1539pub fn build_mobile_push_update_subscription_request(
1540 owner_nsec: String,
1541 subscription_id: String,
1542 platform_key: String,
1543 push_token: String,
1544 apns_topic: Option<String>,
1545 message_author_pubkeys: Vec<String>,
1546 invite_response_pubkeys: Vec<String>,
1547 is_release: bool,
1548 server_url_override: Option<String>,
1549) -> Option<MobilePushSubscriptionRequest> {
1550 ffi_or(
1551 "build_mobile_push_update_subscription_request",
1552 None,
1553 || {
1554 crate::core::build_mobile_push_update_subscription_request(
1555 owner_nsec,
1556 subscription_id,
1557 platform_key,
1558 push_token,
1559 apns_topic,
1560 message_author_pubkeys,
1561 invite_response_pubkeys,
1562 is_release,
1563 server_url_override,
1564 )
1565 },
1566 )
1567}
1568
1569#[uniffi::export]
1570pub fn build_mobile_push_delete_subscription_request(
1571 owner_nsec: String,
1572 subscription_id: String,
1573 platform_key: String,
1574 is_release: bool,
1575 server_url_override: Option<String>,
1576) -> Option<MobilePushSubscriptionRequest> {
1577 ffi_or(
1578 "build_mobile_push_delete_subscription_request",
1579 None,
1580 || {
1581 crate::core::build_mobile_push_delete_subscription_request(
1582 owner_nsec,
1583 subscription_id,
1584 platform_key,
1585 is_release,
1586 server_url_override,
1587 )
1588 },
1589 )
1590}
1591
1592#[cfg(test)]
1593mod ffi_hardening_tests {
1594 use super::*;
1595
1596 #[test]
1597 fn ffi_guard_returns_fallback_after_panic() {
1598 let value = ffi_or("test.panic", 42, || -> i32 {
1599 panic!("ffi boom");
1600 });
1601
1602 assert_eq!(value, 42);
1603 }
1604
1605 #[test]
1606 fn core_batch_guard_converts_panic_to_error() {
1607 let result = catch_core_batch(|| -> bool {
1608 panic!("batch boom");
1609 });
1610
1611 assert_eq!(result, Err("batch boom".to_string()));
1612 }
1613
1614 #[test]
1615 fn core_batch_guard_preserves_success_result() {
1616 assert_eq!(catch_core_batch(|| true), Ok(true));
1617 assert_eq!(catch_core_batch(|| false), Ok(false));
1618 }
1619
1620 #[test]
1621 fn recovery_state_tracks_restore_action_and_logout() {
1622 let recovery = CoreRecoveryState::default();
1623 recovery.remember_action(&AppAction::RestoreSession {
1624 owner_nsec: "secret".to_string(),
1625 });
1626
1627 match recovery.restore_action() {
1628 Some(AppAction::RestoreSession { owner_nsec }) => assert_eq!(owner_nsec, "secret"),
1629 other => panic!("unexpected restore action: {other:?}"),
1630 }
1631
1632 recovery.remember_action(&AppAction::Logout);
1633 assert!(recovery.restore_action().is_none());
1634 }
1635
1636 #[test]
1637 fn recovery_state_tracks_persisted_account_bundle() {
1638 let recovery = CoreRecoveryState::default();
1639 recovery.remember_update(&AppUpdate::PersistAccountBundle {
1640 rev: 7,
1641 owner_nsec: None,
1642 owner_pubkey_hex: "owner".to_string(),
1643 device_nsec: "device-secret".to_string(),
1644 });
1645
1646 match recovery.restore_action() {
1647 Some(AppAction::RestoreAccountBundle {
1648 owner_nsec,
1649 owner_pubkey_hex,
1650 device_nsec,
1651 }) => {
1652 assert_eq!(owner_nsec, None);
1653 assert_eq!(owner_pubkey_hex, "owner");
1654 assert_eq!(device_nsec, "device-secret");
1655 }
1656 other => panic!("unexpected restore action: {other:?}"),
1657 }
1658 }
1659
1660 #[test]
1661 fn nearby_published_events_wait_behind_latest_state_in_drained_batch() {
1662 let mut latest_full_state = None;
1663 let mut before_full_state = Vec::new();
1664 let mut after_full_state = Vec::new();
1665
1666 updates::enqueue_update_for_delivery(
1667 AppUpdate::NearbyPublishedEvent {
1668 event_id: "a".repeat(64),
1669 kind: 14,
1670 created_at_secs: 1,
1671 event_json: "{}".to_string(),
1672 },
1673 &mut latest_full_state,
1674 &mut before_full_state,
1675 &mut after_full_state,
1676 );
1677 let mut stale = AppState::empty();
1678 stale.rev = 1;
1679 updates::enqueue_update_for_delivery(
1680 AppUpdate::FullState(stale),
1681 &mut latest_full_state,
1682 &mut before_full_state,
1683 &mut after_full_state,
1684 );
1685 enqueue_update_for_delivery(
1686 AppUpdate::PersistAccountBundle {
1687 rev: 2,
1688 owner_nsec: None,
1689 owner_pubkey_hex: "owner".to_string(),
1690 device_nsec: "device".to_string(),
1691 },
1692 &mut latest_full_state,
1693 &mut before_full_state,
1694 &mut after_full_state,
1695 );
1696 let mut latest = AppState::empty();
1697 latest.rev = 3;
1698 enqueue_update_for_delivery(
1699 AppUpdate::FullState(latest),
1700 &mut latest_full_state,
1701 &mut before_full_state,
1702 &mut after_full_state,
1703 );
1704
1705 let order = before_full_state
1706 .into_iter()
1707 .chain(latest_full_state)
1708 .chain(after_full_state)
1709 .map(|update| match update {
1710 AppUpdate::PersistAccountBundle { .. } => "persist".to_string(),
1711 AppUpdate::FullState(state) => format!("state:{}", state.rev),
1712 AppUpdate::NearbyPublishedEvent { .. } => "nearby".to_string(),
1713 AppUpdate::NearbyPeersChanged { .. } => "nearby-peers".to_string(),
1714 })
1715 .collect::<Vec<_>>();
1716
1717 assert_eq!(order, vec!["persist", "state:3", "nearby"]);
1718 }
1719
1720 #[test]
1721 fn core_supervisor_recovers_after_batch_panic() {
1722 let temp_dir = tempfile::TempDir::new().expect("temp dir");
1723 let app = new_ffi_app_inner(temp_dir.path().to_string_lossy().to_string());
1724
1725 app.foreground_tx
1726 .send(CoreMsg::PanicForTest)
1727 .expect("send test panic");
1728
1729 for _ in 0..40 {
1730 if app.recovery.restart_count() > 0 {
1731 break;
1732 }
1733 thread::sleep(Duration::from_millis(25));
1734 }
1735 assert_eq!(app.recovery.restart_count(), 1);
1736
1737 let (reply_tx, reply_rx) = flume::bounded(1);
1738 app.foreground_tx
1739 .send(CoreMsg::CorePerfCounters(reply_tx))
1740 .expect("send post-recovery request");
1741 assert!(reply_rx.recv_timeout(Duration::from_secs(2)).is_ok());
1742
1743 app.shutdown();
1744 }
1745}