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