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