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 ffi_or("ffiapp.ingest_nearby_event_json", false, || {
165 let event = match serde_json::from_str::<nostr_sdk::prelude::Event>(&event_json) {
166 Ok(event) => event,
167 Err(_) => return false,
168 };
169 if event.verify().is_err() {
170 return false;
171 }
172 self.core_tx
173 .send(CoreMsg::Internal(Box::new(InternalEvent::NearbyEvent(
174 event,
175 ))))
176 .is_ok()
177 })
178 }
179
180 pub fn build_nearby_presence_event_json(
181 &self,
182 peer_id: String,
183 my_nonce: String,
184 their_nonce: String,
185 profile_event_id: String,
186 ) -> String {
187 ffi_or(
188 "ffiapp.build_nearby_presence_event_json",
189 String::new(),
190 || {
191 let (reply_tx, reply_rx) = flume::bounded(1);
192 if self
193 .core_tx
194 .send(CoreMsg::BuildNearbyPresenceEvent {
195 peer_id,
196 my_nonce,
197 their_nonce,
198 profile_event_id,
199 reply_tx,
200 })
201 .is_err()
202 {
203 return String::new();
204 }
205 reply_rx
206 .recv_timeout(Duration::from_secs(2))
207 .unwrap_or_default()
208 },
209 )
210 }
211
212 pub fn verify_nearby_presence_event_json(
213 &self,
214 event_json: String,
215 peer_id: String,
216 my_nonce: String,
217 their_nonce: String,
218 ) -> String {
219 ffi_or(
220 "ffiapp.verify_nearby_presence_event_json",
221 String::new(),
222 || verify_nearby_presence_event_json(&event_json, &peer_id, &my_nonce, &their_nonce),
223 )
224 }
225
226 pub fn nearby_encode_frame(&self, envelope_json: String) -> Vec<u8> {
227 ffi_or("ffiapp.nearby_encode_frame", Vec::new(), || {
228 nostr_double_ratchet::encode_nearby_frame_json(&envelope_json).unwrap_or_default()
229 })
230 }
231
232 pub fn nearby_decode_frame(&self, frame: Vec<u8>) -> String {
233 ffi_or("ffiapp.nearby_decode_frame", String::new(), || {
234 nostr_double_ratchet::decode_nearby_frame_json(&frame).unwrap_or_default()
235 })
236 }
237
238 pub fn nearby_frame_body_len_from_header(&self, header: Vec<u8>) -> i32 {
239 ffi_or("ffiapp.nearby_frame_body_len_from_header", -1, || {
240 nostr_double_ratchet::nearby_frame_body_len_from_header(&header)
241 .and_then(|len| i32::try_from(len).ok())
242 .unwrap_or(-1)
243 })
244 }
245
246 pub fn export_support_bundle_json(&self) -> String {
247 ffi_or(
248 "ffiapp.export_support_bundle_json",
249 "{}".to_string(),
250 || {
251 let (reply_tx, reply_rx) = flume::bounded(1);
252 if self
253 .core_tx
254 .send(CoreMsg::ExportSupportBundle(reply_tx))
255 .is_err()
256 {
257 return "{}".to_string();
258 }
259 reply_rx
260 .recv_timeout(Duration::from_secs(2))
261 .unwrap_or_else(|_| "{}".to_string())
262 },
263 )
264 }
265
266 pub fn shutdown(&self) {
267 ffi_or("ffiapp.shutdown", (), || {
268 let (reply_tx, reply_rx) = flume::bounded(1);
269 if self
270 .core_tx
271 .send(CoreMsg::Shutdown(Some(reply_tx)))
272 .is_err()
273 {
274 return;
275 }
276 let _ = reply_rx.recv_timeout(Duration::from_secs(2));
277 })
278 }
279
280 pub fn listen_for_updates(&self, reconciler: Box<dyn AppReconciler>) {
281 if self
282 .listening
283 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
284 .is_err()
285 {
286 return;
287 }
288
289 let update_rx = self.update_rx.clone();
290 let spawn_result = thread::Builder::new()
291 .name("iris-updates".to_string())
292 .spawn(move || {
293 while let Ok(first) = update_rx.recv() {
305 let mut latest_full_state: Option<AppUpdate> = None;
306 let mut sidecar: Vec<AppUpdate> = Vec::new();
307 let process =
308 |update: AppUpdate,
309 latest: &mut Option<AppUpdate>,
310 side: &mut Vec<AppUpdate>| match update {
311 full @ AppUpdate::FullState(_) => *latest = Some(full),
312 other => side.push(other),
313 };
314 process(first, &mut latest_full_state, &mut sidecar);
315 while let Ok(next) = update_rx.try_recv() {
316 process(next, &mut latest_full_state, &mut sidecar);
317 }
318 for update in sidecar.into_iter().chain(latest_full_state) {
319 let kind = match &update {
320 AppUpdate::FullState(_) => "FullState",
321 AppUpdate::PersistAccountBundle { .. } => "PersistAccountBundle",
322 AppUpdate::NearbyPublishedEvent { .. } => "NearbyPublishedEvent",
323 };
324 let t0 = crate::perflog::now_ms();
325 crate::perflog!("reconcile.start kind={kind}");
326 if panic::catch_unwind(AssertUnwindSafe(|| reconciler.reconcile(update)))
327 .is_err()
328 {
329 crate::perflog!("reconcile.failed kind={kind}");
330 continue;
331 }
332 crate::perflog!(
333 "reconcile.end kind={kind} elapsed_ms={}",
334 crate::perflog::now_ms().saturating_sub(t0)
335 );
336 }
337 }
338 });
339 if let Err(error) = spawn_result {
340 crate::perflog!("updates.spawn.failed error={error}");
341 self.listening.store(false, Ordering::SeqCst);
342 }
343 }
344}
345
346#[uniffi::export]
347impl FfiDesktopNearby {
348 #[uniffi::constructor]
349 pub fn new(app: Arc<FfiApp>, observer: Box<dyn DesktopNearbyObserver>) -> Arc<Self> {
350 Arc::new(Self {
351 service: desktop_nearby::DesktopNearbyService::new(app, observer.into()),
352 })
353 }
354
355 pub fn start(&self, local_name: String) {
356 self.service.start(local_name);
357 }
358
359 pub fn stop(&self) {
360 self.service.stop();
361 }
362
363 pub fn snapshot(&self) -> DesktopNearbySnapshot {
364 self.service.snapshot()
365 }
366
367 pub fn publish(&self, event_id: String, kind: u32, created_at_secs: u64, event_json: String) {
368 self.service
369 .publish(event_id, kind, created_at_secs, event_json);
370 }
371}
372
373fn handle_core_batch_responsive(core: &mut AppCore, messages: Vec<CoreMsg>) -> bool {
374 if messages.len() <= 1 || !messages.iter().any(is_foreground_core_msg) {
375 return core.handle_messages(messages);
376 }
377
378 let mut foreground = Vec::new();
379 let mut background = Vec::new();
380 for message in messages {
381 if is_foreground_core_msg(&message) {
382 foreground.push(message);
383 } else {
384 background.push(message);
385 }
386 }
387
388 for message in foreground {
389 if !core.handle_message(message) {
390 return false;
391 }
392 }
393 background.is_empty() || core.handle_messages(background)
394}
395
396fn catch_core_batch<F>(f: F) -> Result<bool, String>
397where
398 F: FnOnce() -> bool,
399{
400 panic::catch_unwind(AssertUnwindSafe(f)).map_err(panic_payload_to_string)
401}
402
403fn ffi_or<T, F>(label: &'static str, fallback: T, f: F) -> T
404where
405 F: FnOnce() -> T,
406{
407 match panic::catch_unwind(AssertUnwindSafe(f)) {
408 Ok(value) => value,
409 Err(payload) => {
410 crate::perflog!(
411 "ffi.panic label={label} detail={}",
412 panic_payload_to_string(payload)
413 );
414 fallback
415 }
416 }
417}
418
419fn ffi_failure_state() -> AppState {
420 let mut state = AppState::empty();
421 state.toast = Some("Iris needs restart. Copy support bundle in Settings.".to_string());
422 state
423}
424
425fn suppressed_mobile_push_resolution() -> MobilePushNotificationResolution {
426 MobilePushNotificationResolution {
427 should_show: false,
428 title: String::new(),
429 body: String::new(),
430 payload_json: "{}".to_string(),
431 }
432}
433
434fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
435 if let Some(message) = payload.downcast_ref::<&str>() {
436 (*message).to_string()
437 } else if let Some(message) = payload.downcast_ref::<String>() {
438 message.clone()
439 } else {
440 "unknown panic".to_string()
441 }
442}
443
444fn is_foreground_core_msg(message: &CoreMsg) -> bool {
445 !matches!(message, CoreMsg::Internal(_))
446}
447
448fn verify_nearby_presence_event_json(
449 event_json: &str,
450 peer_id: &str,
451 my_nonce: &str,
452 their_nonce: &str,
453) -> String {
454 let Ok(event) = serde_json::from_str::<nostr_sdk::prelude::Event>(event_json) else {
455 return String::new();
456 };
457 if event.verify().is_err() || event.kind.as_u16() != crate::core::NEARBY_PRESENCE_KIND {
458 return String::new();
459 }
460 let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
461 return String::new();
462 };
463 let get = |key: &str| {
464 content
465 .get(key)
466 .and_then(|value| value.as_str())
467 .unwrap_or("")
468 };
469 let transport = get("transport");
470 if get("protocol") != "iris-nearby-v1"
471 || !(transport == "ble" || transport == "nearby" || transport == "lan")
472 || get("peer_id") != peer_id.trim()
473 || get("my_nonce") != their_nonce.trim()
474 || get("their_nonce") != my_nonce.trim()
475 {
476 return String::new();
477 }
478
479 let now = SystemTime::now()
480 .duration_since(UNIX_EPOCH)
481 .unwrap_or_default()
482 .as_secs();
483 let expires_at = content
484 .get("expires_at")
485 .and_then(|value| value.as_u64())
486 .unwrap_or(0);
487 let created_at = event.created_at.as_secs();
488 if expires_at < now
489 || expires_at > now.saturating_add(300)
490 || created_at.saturating_add(300) < now
491 || created_at > now.saturating_add(300)
492 {
493 return String::new();
494 }
495
496 let profile_event_id = get("profile_event_id");
497 let profile_event_id = if profile_event_id.len() == 64 {
498 profile_event_id
499 } else {
500 ""
501 };
502 serde_json::json!({
503 "owner_pubkey_hex": event.pubkey.to_hex(),
504 "profile_event_id": profile_event_id,
505 })
506 .to_string()
507}
508
509impl Drop for FfiApp {
510 fn drop(&mut self) {
511 let _ = self.core_tx.send(CoreMsg::Shutdown(None));
512 }
513}
514
515#[uniffi::export]
516pub fn normalize_peer_input(input: String) -> String {
517 ffi_or("normalize_peer_input", String::new(), || {
518 crate::core::normalize_peer_input_for_display(&input)
519 })
520}
521
522#[uniffi::export]
523pub fn is_valid_peer_input(input: String) -> bool {
524 ffi_or("is_valid_peer_input", false, || {
525 crate::core::parse_peer_input(&input).is_ok()
526 })
527}
528
529#[uniffi::export]
534pub fn peer_input_to_hex(input: String) -> String {
535 ffi_or("peer_input_to_hex", String::new(), || {
536 let normalized = crate::core::normalize_peer_input_for_display(&input);
537 match nostr::PublicKey::parse(&normalized) {
538 Ok(pubkey) => pubkey.to_hex(),
539 Err(_) => String::new(),
540 }
541 })
542}
543
544#[uniffi::export]
547pub fn peer_input_to_npub(input: String) -> String {
548 ffi_or("peer_input_to_npub", String::new(), || {
549 use nostr::nips::nip19::ToBech32;
550 let normalized = crate::core::normalize_peer_input_for_display(&input);
551 match nostr::PublicKey::parse(&normalized) {
552 Ok(pubkey) => pubkey.to_bech32().unwrap_or(normalized),
553 Err(_) => normalized,
554 }
555 })
556}
557
558#[uniffi::export]
559pub fn build_summary() -> String {
560 ffi_or("build_summary", String::new(), crate::core::build_summary)
561}
562
563#[uniffi::export]
564pub fn relay_set_id() -> String {
565 ffi_or("relay_set_id", String::new(), || {
566 crate::core::relay_set_id().to_string()
567 })
568}
569
570#[uniffi::export]
571pub fn proxied_image_url(
572 original_src: String,
573 preferences: PreferencesSnapshot,
574 width: Option<u32>,
575 height: Option<u32>,
576 square: bool,
577) -> String {
578 ffi_or("proxied_image_url", original_src.clone(), || {
579 image_proxy::proxied_image_url(&original_src, &preferences, width, height, square)
580 })
581}
582
583#[uniffi::export]
584pub fn is_trusted_test_build() -> bool {
585 ffi_or(
586 "is_trusted_test_build",
587 false,
588 crate::core::trusted_test_build_flag,
589 )
590}
591
592#[uniffi::export]
593pub fn resolve_mobile_push_notification_payload(
594 raw_payload_json: String,
595) -> MobilePushNotificationResolution {
596 ffi_or(
597 "resolve_mobile_push_notification_payload",
598 suppressed_mobile_push_resolution(),
599 || crate::core::resolve_mobile_push_notification(raw_payload_json),
600 )
601}
602
603#[uniffi::export]
609pub fn decrypt_mobile_push_notification_payload(
610 data_dir: String,
611 owner_pubkey_hex: String,
612 device_nsec: String,
613 raw_payload_json: String,
614) -> MobilePushNotificationResolution {
615 ffi_or(
616 "decrypt_mobile_push_notification_payload",
617 suppressed_mobile_push_resolution(),
618 || {
619 crate::core::decrypt_mobile_push_notification(
620 data_dir,
621 owner_pubkey_hex,
622 device_nsec,
623 raw_payload_json,
624 )
625 },
626 )
627}
628
629#[uniffi::export]
630pub fn resolve_mobile_push_subscription_server_url(
631 platform_key: String,
632 is_release: bool,
633 override_url: Option<String>,
634) -> String {
635 ffi_or(
636 "resolve_mobile_push_subscription_server_url",
637 String::new(),
638 || crate::core::resolve_mobile_push_server_url(platform_key, is_release, override_url),
639 )
640}
641
642#[uniffi::export]
643pub fn mobile_push_subscription_id_key(platform_key: String) -> String {
644 ffi_or("mobile_push_subscription_id_key", String::new(), || {
645 crate::core::mobile_push_stored_subscription_id_key(platform_key)
646 })
647}
648
649#[uniffi::export]
650pub fn build_mobile_push_list_subscriptions_request(
651 owner_nsec: String,
652 platform_key: String,
653 is_release: bool,
654 server_url_override: Option<String>,
655) -> Option<MobilePushSubscriptionRequest> {
656 ffi_or("build_mobile_push_list_subscriptions_request", None, || {
657 crate::core::build_mobile_push_list_subscriptions_request(
658 owner_nsec,
659 platform_key,
660 is_release,
661 server_url_override,
662 )
663 })
664}
665
666#[uniffi::export]
667#[allow(clippy::too_many_arguments)]
668pub fn build_mobile_push_create_subscription_request(
669 owner_nsec: String,
670 platform_key: String,
671 push_token: String,
672 apns_topic: Option<String>,
673 message_author_pubkeys: Vec<String>,
674 invite_response_pubkeys: Vec<String>,
675 is_release: bool,
676 server_url_override: Option<String>,
677) -> Option<MobilePushSubscriptionRequest> {
678 ffi_or(
679 "build_mobile_push_create_subscription_request",
680 None,
681 || {
682 crate::core::build_mobile_push_create_subscription_request(
683 owner_nsec,
684 platform_key,
685 push_token,
686 apns_topic,
687 message_author_pubkeys,
688 invite_response_pubkeys,
689 is_release,
690 server_url_override,
691 )
692 },
693 )
694}
695
696#[uniffi::export]
697#[allow(clippy::too_many_arguments)]
698pub fn build_mobile_push_update_subscription_request(
699 owner_nsec: String,
700 subscription_id: String,
701 platform_key: String,
702 push_token: String,
703 apns_topic: Option<String>,
704 message_author_pubkeys: Vec<String>,
705 invite_response_pubkeys: Vec<String>,
706 is_release: bool,
707 server_url_override: Option<String>,
708) -> Option<MobilePushSubscriptionRequest> {
709 ffi_or(
710 "build_mobile_push_update_subscription_request",
711 None,
712 || {
713 crate::core::build_mobile_push_update_subscription_request(
714 owner_nsec,
715 subscription_id,
716 platform_key,
717 push_token,
718 apns_topic,
719 message_author_pubkeys,
720 invite_response_pubkeys,
721 is_release,
722 server_url_override,
723 )
724 },
725 )
726}
727
728#[uniffi::export]
729pub fn build_mobile_push_delete_subscription_request(
730 owner_nsec: String,
731 subscription_id: String,
732 platform_key: String,
733 is_release: bool,
734 server_url_override: Option<String>,
735) -> Option<MobilePushSubscriptionRequest> {
736 ffi_or(
737 "build_mobile_push_delete_subscription_request",
738 None,
739 || {
740 crate::core::build_mobile_push_delete_subscription_request(
741 owner_nsec,
742 subscription_id,
743 platform_key,
744 is_release,
745 server_url_override,
746 )
747 },
748 )
749}
750
751#[cfg(test)]
752mod ffi_hardening_tests {
753 use super::*;
754
755 #[test]
756 fn ffi_guard_returns_fallback_after_panic() {
757 let value = ffi_or("test.panic", 42, || -> i32 {
758 panic!("ffi boom");
759 });
760
761 assert_eq!(value, 42);
762 }
763
764 #[test]
765 fn core_batch_guard_converts_panic_to_error() {
766 let result = catch_core_batch(|| -> bool {
767 panic!("batch boom");
768 });
769
770 assert_eq!(result, Err("batch boom".to_string()));
771 }
772
773 #[test]
774 fn core_batch_guard_preserves_success_result() {
775 assert_eq!(catch_core_batch(|| true), Ok(true));
776 assert_eq!(catch_core_batch(|| false), Ok(false));
777 }
778}