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