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