1mod actions;
2mod core;
3pub mod desktop_nearby;
4pub mod image_proxy;
5pub mod local_relay;
6pub mod perflog;
7mod qr;
8mod state;
9mod updates;
10
11use std::any::Any;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::{Arc, RwLock};
14use std::thread;
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16use std::{panic, panic::AssertUnwindSafe};
17
18use flume::{Receiver, Sender};
19
20pub use actions::AppAction;
21pub use qr::*;
22pub use state::*;
23pub use updates::*;
24
25use crate::core::AppCore;
26
27uniffi::setup_scaffolding!();
28
29#[uniffi::export(callback_interface)]
30pub trait AppReconciler: Send + Sync + 'static {
31 fn reconcile(&self, update: AppUpdate);
32}
33
34#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
35pub struct DesktopNearbyPeerSnapshot {
36 pub id: String,
37 pub name: String,
38 pub owner_pubkey_hex: Option<String>,
39 pub picture_url: Option<String>,
40 pub profile_event_id: Option<String>,
41 pub last_seen_secs: u64,
42}
43
44#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
45pub struct DesktopNearbySnapshot {
46 pub visible: bool,
47 pub status: String,
48 pub peers: Vec<DesktopNearbyPeerSnapshot>,
49}
50
51#[uniffi::export(callback_interface)]
52pub trait DesktopNearbyObserver: Send + Sync + 'static {
53 fn desktop_nearby_changed(&self, snapshot: DesktopNearbySnapshot);
54}
55
56#[derive(uniffi::Object)]
57pub struct FfiApp {
58 core_tx: Sender<CoreMsg>,
59 update_rx: Receiver<AppUpdate>,
60 listening: AtomicBool,
61 shared_state: Arc<RwLock<AppState>>,
62}
63
64#[derive(uniffi::Object)]
65pub struct FfiDesktopNearby {
66 service: Arc<desktop_nearby::DesktopNearbyService>,
67}
68
69#[uniffi::export]
70impl FfiApp {
71 #[uniffi::constructor]
72 pub fn new(data_dir: String, _keychain_group: String, _app_version: String) -> Arc<Self> {
73 let (update_tx, update_rx) = flume::unbounded();
74 let (core_tx, core_rx) = flume::unbounded();
75 let shared_state = Arc::new(RwLock::new(AppState::empty()));
76
77 let core_tx_for_thread = core_tx.clone();
78 let shared_for_thread = shared_state.clone();
79 let update_tx_for_error = update_tx.clone();
80 match AppCore::try_new(update_tx, core_tx_for_thread, data_dir, shared_for_thread) {
81 Ok(mut core) => {
82 let spawn_result =
83 thread::Builder::new()
84 .name("iris-core".to_string())
85 .spawn(move || {
86 while let Ok(first) = core_rx.recv() {
92 let mut batch = Vec::with_capacity(8);
93 batch.push(first);
94 while let Ok(next) = core_rx.try_recv() {
95 batch.push(next);
96 }
97 let batch_size = batch.len();
98 let t0 = crate::perflog::now_ms();
99 crate::perflog!("core.batch.start size={batch_size}");
100 match catch_core_batch(|| {
101 handle_core_batch_responsive(&mut core, batch)
102 }) {
103 Ok(true) => {}
104 Ok(false) => break,
105 Err(error) => {
106 core.mark_core_panic(error);
107 break;
108 }
109 }
110 crate::perflog!(
111 "core.batch.end size={batch_size} elapsed_ms={}",
112 crate::perflog::now_ms().saturating_sub(t0)
113 );
114 }
115 });
116 if let Err(error) = spawn_result {
117 let mut state = AppState::empty();
118 state.toast = Some(format!("Iris could not start: {error}"));
119 state.rev = 1;
120 match shared_state.write() {
121 Ok(mut slot) => *slot = state.clone(),
122 Err(poison) => *poison.into_inner() = state.clone(),
123 }
124 let _ = update_tx_for_error.send(AppUpdate::FullState(state));
125 }
126 }
127 Err(error) => {
128 let mut state = AppState::empty();
129 state.toast = Some(error.to_string());
130 state.rev = 1;
131 match shared_state.write() {
132 Ok(mut slot) => *slot = state.clone(),
133 Err(poison) => *poison.into_inner() = state.clone(),
134 }
135 let _ = update_tx_for_error.send(AppUpdate::FullState(state));
136 }
137 }
138
139 Arc::new(Self {
140 core_tx,
141 update_rx,
142 listening: AtomicBool::new(false),
143 shared_state,
144 })
145 }
146
147 pub fn state(&self) -> AppState {
148 ffi_or("ffiapp.state", ffi_failure_state(), || {
149 match self.shared_state.read() {
150 Ok(slot) => slot.clone(),
151 Err(poison) => poison.into_inner().clone(),
152 }
153 })
154 }
155
156 pub fn dispatch(&self, action: AppAction) {
157 ffi_or("ffiapp.dispatch", (), || {
158 crate::perflog!("ffi.dispatch action={:?}", std::mem::discriminant(&action));
159 let _ = self.core_tx.send(CoreMsg::Action(action));
160 })
161 }
162
163 pub fn ingest_nearby_event_json(&self, event_json: String) -> bool {
164 self.ingest_nearby_event_json_with_transport(event_json, String::new())
165 }
166
167 pub fn ingest_nearby_event_json_with_transport(
168 &self,
169 event_json: String,
170 transport: String,
171 ) -> bool {
172 ffi_or("ffiapp.ingest_nearby_event_json", false, || {
173 let event = match serde_json::from_str::<nostr_sdk::prelude::Event>(&event_json) {
174 Ok(event) => event,
175 Err(_) => return false,
176 };
177 if event.verify().is_err() {
178 return false;
179 }
180 self.core_tx
181 .send(CoreMsg::Internal(Box::new(InternalEvent::NearbyEvent {
182 event,
183 transport,
184 })))
185 .is_ok()
186 })
187 }
188
189 pub fn build_nearby_presence_event_json(
190 &self,
191 peer_id: String,
192 my_nonce: String,
193 their_nonce: String,
194 profile_event_id: String,
195 ) -> String {
196 ffi_or(
197 "ffiapp.build_nearby_presence_event_json",
198 String::new(),
199 || {
200 let (reply_tx, reply_rx) = flume::bounded(1);
201 if self
202 .core_tx
203 .send(CoreMsg::BuildNearbyPresenceEvent {
204 peer_id,
205 my_nonce,
206 their_nonce,
207 profile_event_id,
208 reply_tx,
209 })
210 .is_err()
211 {
212 return String::new();
213 }
214 reply_rx
215 .recv_timeout(Duration::from_secs(2))
216 .unwrap_or_default()
217 },
218 )
219 }
220
221 pub fn verify_nearby_presence_event_json(
222 &self,
223 event_json: String,
224 peer_id: String,
225 my_nonce: String,
226 their_nonce: String,
227 ) -> String {
228 ffi_or(
229 "ffiapp.verify_nearby_presence_event_json",
230 String::new(),
231 || verify_nearby_presence_event_json(&event_json, &peer_id, &my_nonce, &their_nonce),
232 )
233 }
234
235 pub fn nearby_encode_frame(&self, envelope_json: String) -> Vec<u8> {
236 ffi_or("ffiapp.nearby_encode_frame", Vec::new(), || {
237 nostr_double_ratchet_runtime::encode_nearby_frame_json(&envelope_json)
238 .unwrap_or_default()
239 })
240 }
241
242 pub fn nearby_decode_frame(&self, frame: Vec<u8>) -> String {
243 ffi_or("ffiapp.nearby_decode_frame", String::new(), || {
244 nostr_double_ratchet_runtime::decode_nearby_frame_json(&frame).unwrap_or_default()
245 })
246 }
247
248 pub fn nearby_frame_body_len_from_header(&self, header: Vec<u8>) -> i32 {
249 ffi_or("ffiapp.nearby_frame_body_len_from_header", -1, || {
250 nostr_double_ratchet_runtime::nearby_frame_body_len_from_header(&header)
251 .and_then(|len| i32::try_from(len).ok())
252 .unwrap_or(-1)
253 })
254 }
255
256 pub fn export_support_bundle_json(&self) -> String {
257 ffi_or(
258 "ffiapp.export_support_bundle_json",
259 "{}".to_string(),
260 || {
261 let (reply_tx, reply_rx) = flume::bounded(1);
262 if self
263 .core_tx
264 .send(CoreMsg::ExportSupportBundle(reply_tx))
265 .is_err()
266 {
267 return "{}".to_string();
268 }
269 reply_rx
270 .recv_timeout(Duration::from_secs(2))
271 .unwrap_or_else(|_| "{}".to_string())
272 },
273 )
274 }
275
276 pub fn prepare_for_suspend(&self) {
277 ffi_or("ffiapp.prepare_for_suspend", (), || {
278 let (reply_tx, reply_rx) = flume::bounded(1);
279 if self
280 .core_tx
281 .send(CoreMsg::PrepareForSuspend(reply_tx))
282 .is_err()
283 {
284 return;
285 }
286 let _ = reply_rx.recv_timeout(Duration::from_secs(2));
287 })
288 }
289
290 pub fn shutdown(&self) {
291 ffi_or("ffiapp.shutdown", (), || {
292 let (reply_tx, reply_rx) = flume::bounded(1);
293 if self
294 .core_tx
295 .send(CoreMsg::Shutdown(Some(reply_tx)))
296 .is_err()
297 {
298 return;
299 }
300 let _ = reply_rx.recv_timeout(Duration::from_secs(2));
301 })
302 }
303
304 pub fn listen_for_updates(&self, reconciler: Box<dyn AppReconciler>) {
305 if self
306 .listening
307 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
308 .is_err()
309 {
310 return;
311 }
312
313 let update_rx = self.update_rx.clone();
314 let spawn_result = thread::Builder::new()
315 .name("iris-updates".to_string())
316 .spawn(move || {
317 while let Ok(first) = update_rx.recv() {
329 let mut latest_full_state: Option<AppUpdate> = None;
330 let mut sidecar: Vec<AppUpdate> = Vec::new();
331 let process =
332 |update: AppUpdate,
333 latest: &mut Option<AppUpdate>,
334 side: &mut Vec<AppUpdate>| match update {
335 full @ AppUpdate::FullState(_) => *latest = Some(full),
336 other => side.push(other),
337 };
338 process(first, &mut latest_full_state, &mut sidecar);
339 while let Ok(next) = update_rx.try_recv() {
340 process(next, &mut latest_full_state, &mut sidecar);
341 }
342 for update in sidecar.into_iter().chain(latest_full_state) {
343 let kind = match &update {
344 AppUpdate::FullState(_) => "FullState",
345 AppUpdate::PersistAccountBundle { .. } => "PersistAccountBundle",
346 AppUpdate::NearbyPublishedEvent { .. } => "NearbyPublishedEvent",
347 };
348 let t0 = crate::perflog::now_ms();
349 crate::perflog!("reconcile.start kind={kind}");
350 if panic::catch_unwind(AssertUnwindSafe(|| reconciler.reconcile(update)))
351 .is_err()
352 {
353 crate::perflog!("reconcile.failed kind={kind}");
354 continue;
355 }
356 crate::perflog!(
357 "reconcile.end kind={kind} elapsed_ms={}",
358 crate::perflog::now_ms().saturating_sub(t0)
359 );
360 }
361 }
362 });
363 if let Err(error) = spawn_result {
364 crate::perflog!("updates.spawn.failed error={error}");
365 self.listening.store(false, Ordering::SeqCst);
366 }
367 }
368}
369
370#[uniffi::export]
371impl FfiDesktopNearby {
372 #[uniffi::constructor]
373 pub fn new(app: Arc<FfiApp>, observer: Box<dyn DesktopNearbyObserver>) -> Arc<Self> {
374 Arc::new(Self {
375 service: desktop_nearby::DesktopNearbyService::new(app, observer.into()),
376 })
377 }
378
379 pub fn start(&self, local_name: String) {
380 self.service.start(local_name);
381 }
382
383 pub fn stop(&self) {
384 self.service.stop();
385 }
386
387 pub fn snapshot(&self) -> DesktopNearbySnapshot {
388 self.service.snapshot()
389 }
390
391 pub fn publish(&self, event_id: String, kind: u32, created_at_secs: u64, event_json: String) {
392 self.service
393 .publish(event_id, kind, created_at_secs, event_json);
394 }
395}
396
397fn handle_core_batch_responsive(core: &mut AppCore, messages: Vec<CoreMsg>) -> bool {
398 if messages.len() <= 1 || !messages.iter().any(is_foreground_core_msg) {
399 return core.handle_messages(messages);
400 }
401
402 let mut foreground = Vec::new();
403 let mut background = Vec::new();
404 for message in messages {
405 if is_foreground_core_msg(&message) {
406 foreground.push(message);
407 } else {
408 background.push(message);
409 }
410 }
411
412 for message in foreground {
413 if !core.handle_message(message) {
414 return false;
415 }
416 }
417 background.is_empty() || core.handle_messages(background)
418}
419
420fn catch_core_batch<F>(f: F) -> Result<bool, String>
421where
422 F: FnOnce() -> bool,
423{
424 panic::catch_unwind(AssertUnwindSafe(f)).map_err(panic_payload_to_string)
425}
426
427fn ffi_or<T, F>(label: &'static str, fallback: T, f: F) -> T
428where
429 F: FnOnce() -> T,
430{
431 match panic::catch_unwind(AssertUnwindSafe(f)) {
432 Ok(value) => value,
433 Err(payload) => {
434 crate::perflog!(
435 "ffi.panic label={label} detail={}",
436 panic_payload_to_string(payload)
437 );
438 fallback
439 }
440 }
441}
442
443fn ffi_failure_state() -> AppState {
444 let mut state = AppState::empty();
445 state.toast = Some("Iris needs restart. Copy support bundle in Settings.".to_string());
446 state
447}
448
449fn suppressed_mobile_push_resolution() -> MobilePushNotificationResolution {
450 MobilePushNotificationResolution {
451 should_show: false,
452 title: String::new(),
453 body: String::new(),
454 payload_json: "{}".to_string(),
455 }
456}
457
458fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
459 if let Some(message) = payload.downcast_ref::<&str>() {
460 (*message).to_string()
461 } else if let Some(message) = payload.downcast_ref::<String>() {
462 message.clone()
463 } else {
464 "unknown panic".to_string()
465 }
466}
467
468fn is_foreground_core_msg(message: &CoreMsg) -> bool {
469 !matches!(message, CoreMsg::Internal(_))
470}
471
472fn verify_nearby_presence_event_json(
473 event_json: &str,
474 peer_id: &str,
475 my_nonce: &str,
476 their_nonce: &str,
477) -> String {
478 let Ok(event) = serde_json::from_str::<nostr_sdk::prelude::Event>(event_json) else {
479 return String::new();
480 };
481 if event.verify().is_err() || event.kind.as_u16() != crate::core::NEARBY_PRESENCE_KIND {
482 return String::new();
483 }
484 let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
485 return String::new();
486 };
487 let get = |key: &str| {
488 content
489 .get(key)
490 .and_then(|value| value.as_str())
491 .unwrap_or("")
492 };
493 let transport = get("transport");
494 if get("protocol") != "iris-nearby-v1"
495 || !(transport == "ble" || transport == "nearby" || transport == "lan")
496 || get("peer_id") != peer_id.trim()
497 || get("my_nonce") != their_nonce.trim()
498 || get("their_nonce") != my_nonce.trim()
499 {
500 return String::new();
501 }
502
503 let now = SystemTime::now()
504 .duration_since(UNIX_EPOCH)
505 .unwrap_or_default()
506 .as_secs();
507 let expires_at = content
508 .get("expires_at")
509 .and_then(|value| value.as_u64())
510 .unwrap_or(0);
511 let created_at = event.created_at.as_secs();
512 if expires_at < now
513 || expires_at > now.saturating_add(300)
514 || created_at.saturating_add(300) < now
515 || created_at > now.saturating_add(300)
516 {
517 return String::new();
518 }
519
520 let profile_event_id = get("profile_event_id");
521 let profile_event_id = if profile_event_id.len() == 64 {
522 profile_event_id
523 } else {
524 ""
525 };
526 serde_json::json!({
527 "owner_pubkey_hex": event.pubkey.to_hex(),
528 "profile_event_id": profile_event_id,
529 })
530 .to_string()
531}
532
533impl Drop for FfiApp {
534 fn drop(&mut self) {
535 let _ = self.core_tx.send(CoreMsg::Shutdown(None));
536 }
537}
538
539#[uniffi::export]
540pub fn normalize_peer_input(input: String) -> String {
541 ffi_or("normalize_peer_input", String::new(), || {
542 crate::core::normalize_peer_input_for_display(&input)
543 })
544}
545
546#[uniffi::export]
547pub fn is_valid_peer_input(input: String) -> bool {
548 ffi_or("is_valid_peer_input", false, || {
549 crate::core::parse_peer_input(&input).is_ok()
550 })
551}
552
553#[uniffi::export]
558pub fn peer_input_to_hex(input: String) -> String {
559 ffi_or("peer_input_to_hex", String::new(), || {
560 let normalized = crate::core::normalize_peer_input_for_display(&input);
561 match nostr::PublicKey::parse(&normalized) {
562 Ok(pubkey) => pubkey.to_hex(),
563 Err(_) => String::new(),
564 }
565 })
566}
567
568#[uniffi::export]
571pub fn peer_input_to_npub(input: String) -> String {
572 ffi_or("peer_input_to_npub", String::new(), || {
573 use nostr::nips::nip19::ToBech32;
574 let normalized = crate::core::normalize_peer_input_for_display(&input);
575 match nostr::PublicKey::parse(&normalized) {
576 Ok(pubkey) => pubkey.to_bech32().unwrap_or(normalized),
577 Err(_) => normalized,
578 }
579 })
580}
581
582#[uniffi::export]
583pub fn build_summary() -> String {
584 ffi_or("build_summary", String::new(), crate::core::build_summary)
585}
586
587#[uniffi::export]
588pub fn relay_set_id() -> String {
589 ffi_or("relay_set_id", String::new(), || {
590 crate::core::relay_set_id().to_string()
591 })
592}
593
594#[uniffi::export]
595pub fn proxied_image_url(
596 original_src: String,
597 preferences: PreferencesSnapshot,
598 width: Option<u32>,
599 height: Option<u32>,
600 square: bool,
601) -> String {
602 ffi_or("proxied_image_url", original_src.clone(), || {
603 image_proxy::proxied_image_url(&original_src, &preferences, width, height, square)
604 })
605}
606
607#[uniffi::export]
608pub fn is_trusted_test_build() -> bool {
609 ffi_or(
610 "is_trusted_test_build",
611 false,
612 crate::core::trusted_test_build_flag,
613 )
614}
615
616#[uniffi::export]
621pub fn app_version() -> String {
622 crate::core::app_version_string().to_string()
623}
624
625#[uniffi::export]
626pub fn resolve_mobile_push_notification_payload(
627 raw_payload_json: String,
628) -> MobilePushNotificationResolution {
629 ffi_or(
630 "resolve_mobile_push_notification_payload",
631 suppressed_mobile_push_resolution(),
632 || crate::core::resolve_mobile_push_notification(raw_payload_json),
633 )
634}
635
636#[uniffi::export]
642pub fn decrypt_mobile_push_notification_payload(
643 data_dir: String,
644 owner_pubkey_hex: String,
645 device_nsec: String,
646 raw_payload_json: String,
647) -> MobilePushNotificationResolution {
648 ffi_or(
649 "decrypt_mobile_push_notification_payload",
650 suppressed_mobile_push_resolution(),
651 || {
652 crate::core::decrypt_mobile_push_notification(
653 data_dir,
654 owner_pubkey_hex,
655 device_nsec,
656 raw_payload_json,
657 )
658 },
659 )
660}
661
662#[uniffi::export]
663pub fn resolve_mobile_push_subscription_server_url(
664 platform_key: String,
665 is_release: bool,
666 override_url: Option<String>,
667) -> String {
668 ffi_or(
669 "resolve_mobile_push_subscription_server_url",
670 String::new(),
671 || crate::core::resolve_mobile_push_server_url(platform_key, is_release, override_url),
672 )
673}
674
675#[uniffi::export]
676pub fn mobile_push_subscription_id_key(platform_key: String) -> String {
677 ffi_or("mobile_push_subscription_id_key", String::new(), || {
678 crate::core::mobile_push_stored_subscription_id_key(platform_key)
679 })
680}
681
682#[uniffi::export]
683pub fn build_mobile_push_list_subscriptions_request(
684 owner_nsec: String,
685 platform_key: String,
686 is_release: bool,
687 server_url_override: Option<String>,
688) -> Option<MobilePushSubscriptionRequest> {
689 ffi_or("build_mobile_push_list_subscriptions_request", None, || {
690 crate::core::build_mobile_push_list_subscriptions_request(
691 owner_nsec,
692 platform_key,
693 is_release,
694 server_url_override,
695 )
696 })
697}
698
699#[uniffi::export]
700#[allow(clippy::too_many_arguments)]
701pub fn build_mobile_push_create_subscription_request(
702 owner_nsec: String,
703 platform_key: String,
704 push_token: String,
705 apns_topic: Option<String>,
706 message_author_pubkeys: Vec<String>,
707 invite_response_pubkeys: Vec<String>,
708 is_release: bool,
709 server_url_override: Option<String>,
710) -> Option<MobilePushSubscriptionRequest> {
711 ffi_or(
712 "build_mobile_push_create_subscription_request",
713 None,
714 || {
715 crate::core::build_mobile_push_create_subscription_request(
716 owner_nsec,
717 platform_key,
718 push_token,
719 apns_topic,
720 message_author_pubkeys,
721 invite_response_pubkeys,
722 is_release,
723 server_url_override,
724 )
725 },
726 )
727}
728
729#[uniffi::export]
730#[allow(clippy::too_many_arguments)]
731pub fn build_mobile_push_update_subscription_request(
732 owner_nsec: String,
733 subscription_id: String,
734 platform_key: String,
735 push_token: String,
736 apns_topic: Option<String>,
737 message_author_pubkeys: Vec<String>,
738 invite_response_pubkeys: Vec<String>,
739 is_release: bool,
740 server_url_override: Option<String>,
741) -> Option<MobilePushSubscriptionRequest> {
742 ffi_or(
743 "build_mobile_push_update_subscription_request",
744 None,
745 || {
746 crate::core::build_mobile_push_update_subscription_request(
747 owner_nsec,
748 subscription_id,
749 platform_key,
750 push_token,
751 apns_topic,
752 message_author_pubkeys,
753 invite_response_pubkeys,
754 is_release,
755 server_url_override,
756 )
757 },
758 )
759}
760
761#[uniffi::export]
762pub fn build_mobile_push_delete_subscription_request(
763 owner_nsec: String,
764 subscription_id: String,
765 platform_key: String,
766 is_release: bool,
767 server_url_override: Option<String>,
768) -> Option<MobilePushSubscriptionRequest> {
769 ffi_or(
770 "build_mobile_push_delete_subscription_request",
771 None,
772 || {
773 crate::core::build_mobile_push_delete_subscription_request(
774 owner_nsec,
775 subscription_id,
776 platform_key,
777 is_release,
778 server_url_override,
779 )
780 },
781 )
782}
783
784#[cfg(test)]
785mod ffi_hardening_tests {
786 use super::*;
787
788 #[test]
789 fn ffi_guard_returns_fallback_after_panic() {
790 let value = ffi_or("test.panic", 42, || -> i32 {
791 panic!("ffi boom");
792 });
793
794 assert_eq!(value, 42);
795 }
796
797 #[test]
798 fn core_batch_guard_converts_panic_to_error() {
799 let result = catch_core_batch(|| -> bool {
800 panic!("batch boom");
801 });
802
803 assert_eq!(result, Err("batch boom".to_string()));
804 }
805
806 #[test]
807 fn core_batch_guard_preserves_success_result() {
808 assert_eq!(catch_core_batch(|| true), Ok(true));
809 assert_eq!(catch_core_batch(|| false), Ok(false));
810 }
811}