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