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::sync::atomic::{AtomicBool, Ordering};
12use std::sync::{Arc, RwLock};
13use std::thread;
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15use std::{panic, panic::AssertUnwindSafe};
16
17use flume::{Receiver, Sender};
18
19pub use actions::AppAction;
20pub use qr::*;
21pub use state::*;
22pub use updates::*;
23
24use crate::core::AppCore;
25
26uniffi::setup_scaffolding!();
27
28#[uniffi::export(callback_interface)]
29pub trait AppReconciler: Send + Sync + 'static {
30 fn reconcile(&self, update: AppUpdate);
31}
32
33#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
34pub struct DesktopNearbyPeerSnapshot {
35 pub id: String,
36 pub name: String,
37 pub owner_pubkey_hex: Option<String>,
38 pub picture_url: Option<String>,
39 pub profile_event_id: Option<String>,
40 pub last_seen_secs: u64,
41}
42
43#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
44pub struct DesktopNearbySnapshot {
45 pub visible: bool,
46 pub status: String,
47 pub peers: Vec<DesktopNearbyPeerSnapshot>,
48}
49
50#[uniffi::export(callback_interface)]
51pub trait DesktopNearbyObserver: Send + Sync + 'static {
52 fn desktop_nearby_changed(&self, snapshot: DesktopNearbySnapshot);
53}
54
55#[derive(uniffi::Object)]
56pub struct FfiApp {
57 core_tx: Sender<CoreMsg>,
58 update_rx: Receiver<AppUpdate>,
59 listening: AtomicBool,
60 shared_state: Arc<RwLock<AppState>>,
61}
62
63#[derive(uniffi::Object)]
64pub struct FfiDesktopNearby {
65 service: Arc<desktop_nearby::DesktopNearbyService>,
66}
67
68#[uniffi::export]
69impl FfiApp {
70 #[uniffi::constructor]
71 pub fn new(data_dir: String, _keychain_group: String, _app_version: String) -> Arc<Self> {
72 let (update_tx, update_rx) = flume::unbounded();
73 let (core_tx, core_rx) = flume::unbounded();
74 let shared_state = Arc::new(RwLock::new(AppState::empty()));
75
76 let core_tx_for_thread = core_tx.clone();
77 let shared_for_thread = shared_state.clone();
78 thread::spawn(move || {
79 let mut core = AppCore::new(update_tx, core_tx_for_thread, data_dir, shared_for_thread);
80 while let Ok(first) = core_rx.recv() {
86 let mut batch = Vec::with_capacity(8);
87 batch.push(first);
88 while let Ok(next) = core_rx.try_recv() {
89 batch.push(next);
90 }
91 let batch_size = batch.len();
92 let t0 = crate::perflog::now_ms();
93 crate::perflog!("core.batch.start size={batch_size}");
94 if !handle_core_batch_responsive(&mut core, batch) {
95 break;
96 }
97 crate::perflog!(
98 "core.batch.end size={batch_size} elapsed_ms={}",
99 crate::perflog::now_ms().saturating_sub(t0)
100 );
101 }
102 });
103
104 Arc::new(Self {
105 core_tx,
106 update_rx,
107 listening: AtomicBool::new(false),
108 shared_state,
109 })
110 }
111
112 pub fn state(&self) -> AppState {
113 match self.shared_state.read() {
114 Ok(slot) => slot.clone(),
115 Err(poison) => poison.into_inner().clone(),
116 }
117 }
118
119 pub fn dispatch(&self, action: AppAction) {
120 crate::perflog!("ffi.dispatch action={:?}", std::mem::discriminant(&action));
121 let _ = self.core_tx.send(CoreMsg::Action(action));
122 }
123
124 pub fn ingest_nearby_event_json(&self, event_json: String) -> bool {
125 let event = match serde_json::from_str::<nostr_sdk::prelude::Event>(&event_json) {
126 Ok(event) => event,
127 Err(_) => return false,
128 };
129 if event.verify().is_err() {
130 return false;
131 }
132 self.core_tx
133 .send(CoreMsg::Internal(Box::new(InternalEvent::NearbyEvent(
134 event,
135 ))))
136 .is_ok()
137 }
138
139 pub fn build_nearby_presence_event_json(
140 &self,
141 peer_id: String,
142 my_nonce: String,
143 their_nonce: String,
144 profile_event_id: String,
145 ) -> String {
146 let (reply_tx, reply_rx) = flume::bounded(1);
147 if self
148 .core_tx
149 .send(CoreMsg::BuildNearbyPresenceEvent {
150 peer_id,
151 my_nonce,
152 their_nonce,
153 profile_event_id,
154 reply_tx,
155 })
156 .is_err()
157 {
158 return String::new();
159 }
160 reply_rx
161 .recv_timeout(Duration::from_secs(2))
162 .unwrap_or_default()
163 }
164
165 pub fn verify_nearby_presence_event_json(
166 &self,
167 event_json: String,
168 peer_id: String,
169 my_nonce: String,
170 their_nonce: String,
171 ) -> String {
172 verify_nearby_presence_event_json(&event_json, &peer_id, &my_nonce, &their_nonce)
173 }
174
175 pub fn nearby_encode_frame(&self, envelope_json: String) -> Vec<u8> {
176 nostr_double_ratchet::encode_nearby_frame_json(&envelope_json).unwrap_or_default()
177 }
178
179 pub fn nearby_decode_frame(&self, frame: Vec<u8>) -> String {
180 nostr_double_ratchet::decode_nearby_frame_json(&frame).unwrap_or_default()
181 }
182
183 pub fn nearby_frame_body_len_from_header(&self, header: Vec<u8>) -> i32 {
184 nostr_double_ratchet::nearby_frame_body_len_from_header(&header)
185 .and_then(|len| i32::try_from(len).ok())
186 .unwrap_or(-1)
187 }
188
189 pub fn export_support_bundle_json(&self) -> String {
190 let (reply_tx, reply_rx) = flume::bounded(1);
191 if self
192 .core_tx
193 .send(CoreMsg::ExportSupportBundle(reply_tx))
194 .is_err()
195 {
196 return "{}".to_string();
197 }
198 reply_rx.recv().unwrap_or_else(|_| "{}".to_string())
199 }
200
201 pub fn shutdown(&self) {
202 let (reply_tx, reply_rx) = flume::bounded(1);
203 if self
204 .core_tx
205 .send(CoreMsg::Shutdown(Some(reply_tx)))
206 .is_err()
207 {
208 return;
209 }
210 let _ = reply_rx.recv();
211 }
212
213 pub fn listen_for_updates(&self, reconciler: Box<dyn AppReconciler>) {
214 if self
215 .listening
216 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
217 .is_err()
218 {
219 return;
220 }
221
222 let update_rx = self.update_rx.clone();
223 thread::spawn(move || {
224 while let Ok(first) = update_rx.recv() {
236 let mut latest_full_state: Option<AppUpdate> = None;
237 let mut sidecar: Vec<AppUpdate> = Vec::new();
238 let process = |update: AppUpdate,
239 latest: &mut Option<AppUpdate>,
240 side: &mut Vec<AppUpdate>| match update
241 {
242 full @ AppUpdate::FullState(_) => *latest = Some(full),
243 other => side.push(other),
244 };
245 process(first, &mut latest_full_state, &mut sidecar);
246 while let Ok(next) = update_rx.try_recv() {
247 process(next, &mut latest_full_state, &mut sidecar);
248 }
249 for update in sidecar.into_iter().chain(latest_full_state) {
250 let kind = match &update {
251 AppUpdate::FullState(_) => "FullState",
252 AppUpdate::PersistAccountBundle { .. } => "PersistAccountBundle",
253 AppUpdate::NearbyPublishedEvent { .. } => "NearbyPublishedEvent",
254 };
255 let t0 = crate::perflog::now_ms();
256 crate::perflog!("reconcile.start kind={kind}");
257 if panic::catch_unwind(AssertUnwindSafe(|| reconciler.reconcile(update)))
258 .is_err()
259 {
260 crate::perflog!("reconcile.failed kind={kind}");
261 continue;
262 }
263 crate::perflog!(
264 "reconcile.end kind={kind} elapsed_ms={}",
265 crate::perflog::now_ms().saturating_sub(t0)
266 );
267 }
268 }
269 });
270 }
271}
272
273#[uniffi::export]
274impl FfiDesktopNearby {
275 #[uniffi::constructor]
276 pub fn new(app: Arc<FfiApp>, observer: Box<dyn DesktopNearbyObserver>) -> Arc<Self> {
277 Arc::new(Self {
278 service: desktop_nearby::DesktopNearbyService::new(app, observer.into()),
279 })
280 }
281
282 pub fn start(&self, local_name: String) {
283 self.service.start(local_name);
284 }
285
286 pub fn stop(&self) {
287 self.service.stop();
288 }
289
290 pub fn snapshot(&self) -> DesktopNearbySnapshot {
291 self.service.snapshot()
292 }
293
294 pub fn publish(&self, event_id: String, kind: u32, created_at_secs: u64, event_json: String) {
295 self.service
296 .publish(event_id, kind, created_at_secs, event_json);
297 }
298}
299
300fn handle_core_batch_responsive(core: &mut AppCore, messages: Vec<CoreMsg>) -> bool {
301 if messages.len() <= 1 || !messages.iter().any(is_foreground_core_msg) {
302 return core.handle_messages(messages);
303 }
304
305 let mut foreground = Vec::new();
306 let mut background = Vec::new();
307 for message in messages {
308 if is_foreground_core_msg(&message) {
309 foreground.push(message);
310 } else {
311 background.push(message);
312 }
313 }
314
315 for message in foreground {
316 if !core.handle_message(message) {
317 return false;
318 }
319 }
320 background.is_empty() || core.handle_messages(background)
321}
322
323fn is_foreground_core_msg(message: &CoreMsg) -> bool {
324 !matches!(message, CoreMsg::Internal(_))
325}
326
327fn verify_nearby_presence_event_json(
328 event_json: &str,
329 peer_id: &str,
330 my_nonce: &str,
331 their_nonce: &str,
332) -> String {
333 let Ok(event) = serde_json::from_str::<nostr_sdk::prelude::Event>(event_json) else {
334 return String::new();
335 };
336 if event.verify().is_err() || event.kind.as_u16() != crate::core::NEARBY_PRESENCE_KIND {
337 return String::new();
338 }
339 let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
340 return String::new();
341 };
342 let get = |key: &str| {
343 content
344 .get(key)
345 .and_then(|value| value.as_str())
346 .unwrap_or("")
347 };
348 let transport = get("transport");
349 if get("protocol") != "iris-nearby-v1"
350 || !(transport == "ble" || transport == "nearby" || transport == "lan")
351 || get("peer_id") != peer_id.trim()
352 || get("my_nonce") != their_nonce.trim()
353 || get("their_nonce") != my_nonce.trim()
354 {
355 return String::new();
356 }
357
358 let now = SystemTime::now()
359 .duration_since(UNIX_EPOCH)
360 .unwrap_or_default()
361 .as_secs();
362 let expires_at = content
363 .get("expires_at")
364 .and_then(|value| value.as_u64())
365 .unwrap_or(0);
366 let created_at = event.created_at.as_secs();
367 if expires_at < now
368 || expires_at > now.saturating_add(300)
369 || created_at.saturating_add(300) < now
370 || created_at > now.saturating_add(300)
371 {
372 return String::new();
373 }
374
375 let profile_event_id = get("profile_event_id");
376 let profile_event_id = if profile_event_id.len() == 64 {
377 profile_event_id
378 } else {
379 ""
380 };
381 serde_json::json!({
382 "owner_pubkey_hex": event.pubkey.to_hex(),
383 "profile_event_id": profile_event_id,
384 })
385 .to_string()
386}
387
388impl Drop for FfiApp {
389 fn drop(&mut self) {
390 let _ = self.core_tx.send(CoreMsg::Shutdown(None));
391 }
392}
393
394#[uniffi::export]
395pub fn normalize_peer_input(input: String) -> String {
396 crate::core::normalize_peer_input_for_display(&input)
397}
398
399#[uniffi::export]
400pub fn is_valid_peer_input(input: String) -> bool {
401 crate::core::parse_peer_input(&input).is_ok()
402}
403
404#[uniffi::export]
409pub fn peer_input_to_hex(input: String) -> String {
410 let normalized = crate::core::normalize_peer_input_for_display(&input);
411 match nostr::PublicKey::parse(&normalized) {
412 Ok(pubkey) => pubkey.to_hex(),
413 Err(_) => String::new(),
414 }
415}
416
417#[uniffi::export]
420pub fn peer_input_to_npub(input: String) -> String {
421 use nostr::nips::nip19::ToBech32;
422 let normalized = crate::core::normalize_peer_input_for_display(&input);
423 match nostr::PublicKey::parse(&normalized) {
424 Ok(pubkey) => pubkey.to_bech32().unwrap_or(normalized),
425 Err(_) => normalized,
426 }
427}
428
429#[uniffi::export]
430pub fn build_summary() -> String {
431 crate::core::build_summary()
432}
433
434#[uniffi::export]
435pub fn relay_set_id() -> String {
436 crate::core::relay_set_id().to_string()
437}
438
439#[uniffi::export]
440pub fn proxied_image_url(
441 original_src: String,
442 preferences: PreferencesSnapshot,
443 width: Option<u32>,
444 height: Option<u32>,
445 square: bool,
446) -> String {
447 image_proxy::proxied_image_url(&original_src, &preferences, width, height, square)
448}
449
450#[uniffi::export]
451pub fn is_trusted_test_build() -> bool {
452 crate::core::trusted_test_build_flag()
453}
454
455#[uniffi::export]
456pub fn resolve_mobile_push_notification_payload(
457 raw_payload_json: String,
458) -> MobilePushNotificationResolution {
459 crate::core::resolve_mobile_push_notification(raw_payload_json)
460}
461
462#[uniffi::export]
468pub fn decrypt_mobile_push_notification_payload(
469 data_dir: String,
470 owner_pubkey_hex: String,
471 device_nsec: String,
472 raw_payload_json: String,
473) -> MobilePushNotificationResolution {
474 crate::core::decrypt_mobile_push_notification(
475 data_dir,
476 owner_pubkey_hex,
477 device_nsec,
478 raw_payload_json,
479 )
480}
481
482#[uniffi::export]
483pub fn resolve_mobile_push_subscription_server_url(
484 platform_key: String,
485 is_release: bool,
486 override_url: Option<String>,
487) -> String {
488 crate::core::resolve_mobile_push_server_url(platform_key, is_release, override_url)
489}
490
491#[uniffi::export]
492pub fn mobile_push_subscription_id_key(platform_key: String) -> String {
493 crate::core::mobile_push_stored_subscription_id_key(platform_key)
494}
495
496#[uniffi::export]
497pub fn build_mobile_push_list_subscriptions_request(
498 owner_nsec: String,
499 platform_key: String,
500 is_release: bool,
501 server_url_override: Option<String>,
502) -> Option<MobilePushSubscriptionRequest> {
503 crate::core::build_mobile_push_list_subscriptions_request(
504 owner_nsec,
505 platform_key,
506 is_release,
507 server_url_override,
508 )
509}
510
511#[uniffi::export]
512#[allow(clippy::too_many_arguments)]
513pub fn build_mobile_push_create_subscription_request(
514 owner_nsec: String,
515 platform_key: String,
516 push_token: String,
517 apns_topic: Option<String>,
518 message_author_pubkeys: Vec<String>,
519 invite_response_pubkeys: Vec<String>,
520 is_release: bool,
521 server_url_override: Option<String>,
522) -> Option<MobilePushSubscriptionRequest> {
523 crate::core::build_mobile_push_create_subscription_request(
524 owner_nsec,
525 platform_key,
526 push_token,
527 apns_topic,
528 message_author_pubkeys,
529 invite_response_pubkeys,
530 is_release,
531 server_url_override,
532 )
533}
534
535#[uniffi::export]
536#[allow(clippy::too_many_arguments)]
537pub fn build_mobile_push_update_subscription_request(
538 owner_nsec: String,
539 subscription_id: String,
540 platform_key: String,
541 push_token: String,
542 apns_topic: Option<String>,
543 message_author_pubkeys: Vec<String>,
544 invite_response_pubkeys: Vec<String>,
545 is_release: bool,
546 server_url_override: Option<String>,
547) -> Option<MobilePushSubscriptionRequest> {
548 crate::core::build_mobile_push_update_subscription_request(
549 owner_nsec,
550 subscription_id,
551 platform_key,
552 push_token,
553 apns_topic,
554 message_author_pubkeys,
555 invite_response_pubkeys,
556 is_release,
557 server_url_override,
558 )
559}
560
561#[uniffi::export]
562pub fn build_mobile_push_delete_subscription_request(
563 owner_nsec: String,
564 subscription_id: String,
565 platform_key: String,
566 is_release: bool,
567 server_url_override: Option<String>,
568) -> Option<MobilePushSubscriptionRequest> {
569 crate::core::build_mobile_push_delete_subscription_request(
570 owner_nsec,
571 subscription_id,
572 platform_key,
573 is_release,
574 server_url_override,
575 )
576}