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 shutdown(&self) {
277 ffi_or("ffiapp.shutdown", (), || {
278 let (reply_tx, reply_rx) = flume::bounded(1);
279 if self
280 .core_tx
281 .send(CoreMsg::Shutdown(Some(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 listen_for_updates(&self, reconciler: Box<dyn AppReconciler>) {
291 if self
292 .listening
293 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
294 .is_err()
295 {
296 return;
297 }
298
299 let update_rx = self.update_rx.clone();
300 let spawn_result = thread::Builder::new()
301 .name("iris-updates".to_string())
302 .spawn(move || {
303 while let Ok(first) = update_rx.recv() {
315 let mut latest_full_state: Option<AppUpdate> = None;
316 let mut sidecar: Vec<AppUpdate> = Vec::new();
317 let process =
318 |update: AppUpdate,
319 latest: &mut Option<AppUpdate>,
320 side: &mut Vec<AppUpdate>| match update {
321 full @ AppUpdate::FullState(_) => *latest = Some(full),
322 other => side.push(other),
323 };
324 process(first, &mut latest_full_state, &mut sidecar);
325 while let Ok(next) = update_rx.try_recv() {
326 process(next, &mut latest_full_state, &mut sidecar);
327 }
328 for update in sidecar.into_iter().chain(latest_full_state) {
329 let kind = match &update {
330 AppUpdate::FullState(_) => "FullState",
331 AppUpdate::PersistAccountBundle { .. } => "PersistAccountBundle",
332 AppUpdate::NearbyPublishedEvent { .. } => "NearbyPublishedEvent",
333 };
334 let t0 = crate::perflog::now_ms();
335 crate::perflog!("reconcile.start kind={kind}");
336 if panic::catch_unwind(AssertUnwindSafe(|| reconciler.reconcile(update)))
337 .is_err()
338 {
339 crate::perflog!("reconcile.failed kind={kind}");
340 continue;
341 }
342 crate::perflog!(
343 "reconcile.end kind={kind} elapsed_ms={}",
344 crate::perflog::now_ms().saturating_sub(t0)
345 );
346 }
347 }
348 });
349 if let Err(error) = spawn_result {
350 crate::perflog!("updates.spawn.failed error={error}");
351 self.listening.store(false, Ordering::SeqCst);
352 }
353 }
354}
355
356#[uniffi::export]
357impl FfiDesktopNearby {
358 #[uniffi::constructor]
359 pub fn new(app: Arc<FfiApp>, observer: Box<dyn DesktopNearbyObserver>) -> Arc<Self> {
360 Arc::new(Self {
361 service: desktop_nearby::DesktopNearbyService::new(app, observer.into()),
362 })
363 }
364
365 pub fn start(&self, local_name: String) {
366 self.service.start(local_name);
367 }
368
369 pub fn stop(&self) {
370 self.service.stop();
371 }
372
373 pub fn snapshot(&self) -> DesktopNearbySnapshot {
374 self.service.snapshot()
375 }
376
377 pub fn publish(&self, event_id: String, kind: u32, created_at_secs: u64, event_json: String) {
378 self.service
379 .publish(event_id, kind, created_at_secs, event_json);
380 }
381}
382
383fn handle_core_batch_responsive(core: &mut AppCore, messages: Vec<CoreMsg>) -> bool {
384 if messages.len() <= 1 || !messages.iter().any(is_foreground_core_msg) {
385 return core.handle_messages(messages);
386 }
387
388 let mut foreground = Vec::new();
389 let mut background = Vec::new();
390 for message in messages {
391 if is_foreground_core_msg(&message) {
392 foreground.push(message);
393 } else {
394 background.push(message);
395 }
396 }
397
398 for message in foreground {
399 if !core.handle_message(message) {
400 return false;
401 }
402 }
403 background.is_empty() || core.handle_messages(background)
404}
405
406fn catch_core_batch<F>(f: F) -> Result<bool, String>
407where
408 F: FnOnce() -> bool,
409{
410 panic::catch_unwind(AssertUnwindSafe(f)).map_err(panic_payload_to_string)
411}
412
413fn ffi_or<T, F>(label: &'static str, fallback: T, f: F) -> T
414where
415 F: FnOnce() -> T,
416{
417 match panic::catch_unwind(AssertUnwindSafe(f)) {
418 Ok(value) => value,
419 Err(payload) => {
420 crate::perflog!(
421 "ffi.panic label={label} detail={}",
422 panic_payload_to_string(payload)
423 );
424 fallback
425 }
426 }
427}
428
429fn ffi_failure_state() -> AppState {
430 let mut state = AppState::empty();
431 state.toast = Some("Iris needs restart. Copy support bundle in Settings.".to_string());
432 state
433}
434
435fn suppressed_mobile_push_resolution() -> MobilePushNotificationResolution {
436 MobilePushNotificationResolution {
437 should_show: false,
438 title: String::new(),
439 body: String::new(),
440 payload_json: "{}".to_string(),
441 }
442}
443
444fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
445 if let Some(message) = payload.downcast_ref::<&str>() {
446 (*message).to_string()
447 } else if let Some(message) = payload.downcast_ref::<String>() {
448 message.clone()
449 } else {
450 "unknown panic".to_string()
451 }
452}
453
454fn is_foreground_core_msg(message: &CoreMsg) -> bool {
455 !matches!(message, CoreMsg::Internal(_))
456}
457
458fn verify_nearby_presence_event_json(
459 event_json: &str,
460 peer_id: &str,
461 my_nonce: &str,
462 their_nonce: &str,
463) -> String {
464 let Ok(event) = serde_json::from_str::<nostr_sdk::prelude::Event>(event_json) else {
465 return String::new();
466 };
467 if event.verify().is_err() || event.kind.as_u16() != crate::core::NEARBY_PRESENCE_KIND {
468 return String::new();
469 }
470 let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
471 return String::new();
472 };
473 let get = |key: &str| {
474 content
475 .get(key)
476 .and_then(|value| value.as_str())
477 .unwrap_or("")
478 };
479 let transport = get("transport");
480 if get("protocol") != "iris-nearby-v1"
481 || !(transport == "ble" || transport == "nearby" || transport == "lan")
482 || get("peer_id") != peer_id.trim()
483 || get("my_nonce") != their_nonce.trim()
484 || get("their_nonce") != my_nonce.trim()
485 {
486 return String::new();
487 }
488
489 let now = SystemTime::now()
490 .duration_since(UNIX_EPOCH)
491 .unwrap_or_default()
492 .as_secs();
493 let expires_at = content
494 .get("expires_at")
495 .and_then(|value| value.as_u64())
496 .unwrap_or(0);
497 let created_at = event.created_at.as_secs();
498 if expires_at < now
499 || expires_at > now.saturating_add(300)
500 || created_at.saturating_add(300) < now
501 || created_at > now.saturating_add(300)
502 {
503 return String::new();
504 }
505
506 let profile_event_id = get("profile_event_id");
507 let profile_event_id = if profile_event_id.len() == 64 {
508 profile_event_id
509 } else {
510 ""
511 };
512 serde_json::json!({
513 "owner_pubkey_hex": event.pubkey.to_hex(),
514 "profile_event_id": profile_event_id,
515 })
516 .to_string()
517}
518
519impl Drop for FfiApp {
520 fn drop(&mut self) {
521 let _ = self.core_tx.send(CoreMsg::Shutdown(None));
522 }
523}
524
525#[uniffi::export]
526pub fn normalize_peer_input(input: String) -> String {
527 ffi_or("normalize_peer_input", String::new(), || {
528 crate::core::normalize_peer_input_for_display(&input)
529 })
530}
531
532#[uniffi::export]
533pub fn is_valid_peer_input(input: String) -> bool {
534 ffi_or("is_valid_peer_input", false, || {
535 crate::core::parse_peer_input(&input).is_ok()
536 })
537}
538
539#[uniffi::export]
544pub fn peer_input_to_hex(input: String) -> String {
545 ffi_or("peer_input_to_hex", String::new(), || {
546 let normalized = crate::core::normalize_peer_input_for_display(&input);
547 match nostr::PublicKey::parse(&normalized) {
548 Ok(pubkey) => pubkey.to_hex(),
549 Err(_) => String::new(),
550 }
551 })
552}
553
554#[uniffi::export]
557pub fn peer_input_to_npub(input: String) -> String {
558 ffi_or("peer_input_to_npub", String::new(), || {
559 use nostr::nips::nip19::ToBech32;
560 let normalized = crate::core::normalize_peer_input_for_display(&input);
561 match nostr::PublicKey::parse(&normalized) {
562 Ok(pubkey) => pubkey.to_bech32().unwrap_or(normalized),
563 Err(_) => normalized,
564 }
565 })
566}
567
568#[uniffi::export]
569pub fn build_summary() -> String {
570 ffi_or("build_summary", String::new(), crate::core::build_summary)
571}
572
573#[uniffi::export]
574pub fn relay_set_id() -> String {
575 ffi_or("relay_set_id", String::new(), || {
576 crate::core::relay_set_id().to_string()
577 })
578}
579
580#[uniffi::export]
581pub fn proxied_image_url(
582 original_src: String,
583 preferences: PreferencesSnapshot,
584 width: Option<u32>,
585 height: Option<u32>,
586 square: bool,
587) -> String {
588 ffi_or("proxied_image_url", original_src.clone(), || {
589 image_proxy::proxied_image_url(&original_src, &preferences, width, height, square)
590 })
591}
592
593#[uniffi::export]
594pub fn is_trusted_test_build() -> bool {
595 ffi_or(
596 "is_trusted_test_build",
597 false,
598 crate::core::trusted_test_build_flag,
599 )
600}
601
602#[uniffi::export]
603pub fn resolve_mobile_push_notification_payload(
604 raw_payload_json: String,
605) -> MobilePushNotificationResolution {
606 ffi_or(
607 "resolve_mobile_push_notification_payload",
608 suppressed_mobile_push_resolution(),
609 || crate::core::resolve_mobile_push_notification(raw_payload_json),
610 )
611}
612
613#[uniffi::export]
619pub fn decrypt_mobile_push_notification_payload(
620 data_dir: String,
621 owner_pubkey_hex: String,
622 device_nsec: String,
623 raw_payload_json: String,
624) -> MobilePushNotificationResolution {
625 ffi_or(
626 "decrypt_mobile_push_notification_payload",
627 suppressed_mobile_push_resolution(),
628 || {
629 crate::core::decrypt_mobile_push_notification(
630 data_dir,
631 owner_pubkey_hex,
632 device_nsec,
633 raw_payload_json,
634 )
635 },
636 )
637}
638
639#[uniffi::export]
640pub fn resolve_mobile_push_subscription_server_url(
641 platform_key: String,
642 is_release: bool,
643 override_url: Option<String>,
644) -> String {
645 ffi_or(
646 "resolve_mobile_push_subscription_server_url",
647 String::new(),
648 || crate::core::resolve_mobile_push_server_url(platform_key, is_release, override_url),
649 )
650}
651
652#[uniffi::export]
653pub fn mobile_push_subscription_id_key(platform_key: String) -> String {
654 ffi_or("mobile_push_subscription_id_key", String::new(), || {
655 crate::core::mobile_push_stored_subscription_id_key(platform_key)
656 })
657}
658
659#[uniffi::export]
660pub fn build_mobile_push_list_subscriptions_request(
661 owner_nsec: String,
662 platform_key: String,
663 is_release: bool,
664 server_url_override: Option<String>,
665) -> Option<MobilePushSubscriptionRequest> {
666 ffi_or("build_mobile_push_list_subscriptions_request", None, || {
667 crate::core::build_mobile_push_list_subscriptions_request(
668 owner_nsec,
669 platform_key,
670 is_release,
671 server_url_override,
672 )
673 })
674}
675
676#[uniffi::export]
677#[allow(clippy::too_many_arguments)]
678pub fn build_mobile_push_create_subscription_request(
679 owner_nsec: String,
680 platform_key: String,
681 push_token: String,
682 apns_topic: Option<String>,
683 message_author_pubkeys: Vec<String>,
684 invite_response_pubkeys: Vec<String>,
685 is_release: bool,
686 server_url_override: Option<String>,
687) -> Option<MobilePushSubscriptionRequest> {
688 ffi_or(
689 "build_mobile_push_create_subscription_request",
690 None,
691 || {
692 crate::core::build_mobile_push_create_subscription_request(
693 owner_nsec,
694 platform_key,
695 push_token,
696 apns_topic,
697 message_author_pubkeys,
698 invite_response_pubkeys,
699 is_release,
700 server_url_override,
701 )
702 },
703 )
704}
705
706#[uniffi::export]
707#[allow(clippy::too_many_arguments)]
708pub fn build_mobile_push_update_subscription_request(
709 owner_nsec: String,
710 subscription_id: String,
711 platform_key: String,
712 push_token: String,
713 apns_topic: Option<String>,
714 message_author_pubkeys: Vec<String>,
715 invite_response_pubkeys: Vec<String>,
716 is_release: bool,
717 server_url_override: Option<String>,
718) -> Option<MobilePushSubscriptionRequest> {
719 ffi_or(
720 "build_mobile_push_update_subscription_request",
721 None,
722 || {
723 crate::core::build_mobile_push_update_subscription_request(
724 owner_nsec,
725 subscription_id,
726 platform_key,
727 push_token,
728 apns_topic,
729 message_author_pubkeys,
730 invite_response_pubkeys,
731 is_release,
732 server_url_override,
733 )
734 },
735 )
736}
737
738#[uniffi::export]
739pub fn build_mobile_push_delete_subscription_request(
740 owner_nsec: String,
741 subscription_id: String,
742 platform_key: String,
743 is_release: bool,
744 server_url_override: Option<String>,
745) -> Option<MobilePushSubscriptionRequest> {
746 ffi_or(
747 "build_mobile_push_delete_subscription_request",
748 None,
749 || {
750 crate::core::build_mobile_push_delete_subscription_request(
751 owner_nsec,
752 subscription_id,
753 platform_key,
754 is_release,
755 server_url_override,
756 )
757 },
758 )
759}
760
761#[cfg(test)]
762mod ffi_hardening_tests {
763 use super::*;
764
765 #[test]
766 fn ffi_guard_returns_fallback_after_panic() {
767 let value = ffi_or("test.panic", 42, || -> i32 {
768 panic!("ffi boom");
769 });
770
771 assert_eq!(value, 42);
772 }
773
774 #[test]
775 fn core_batch_guard_converts_panic_to_error() {
776 let result = catch_core_batch(|| -> bool {
777 panic!("batch boom");
778 });
779
780 assert_eq!(result, Err("batch boom".to_string()));
781 }
782
783 #[test]
784 fn core_batch_guard_preserves_success_result() {
785 assert_eq!(catch_core_batch(|| true), Ok(true));
786 assert_eq!(catch_core_batch(|| false), Ok(false));
787 }
788}