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