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