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