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