Skip to main content

iris_chat_core/
lib.rs

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                    // Drain whatever is already queued and process it as one batch so
83                    // a flurry of relay events + user actions produces a single UI
84                    // update instead of N. Without this, tapping a chat while a
85                    // relay backlog drains can take seconds because OpenChat sits
86                    // behind every queued event and the UI recomposes between each.
87                    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            // Drain queued updates and deliver the latest FullState only.
239            // The shell side already discards FullStates with stale `rev`,
240            // but the JNI marshal of an AppState is itself ~20-30 ms and
241            // each push triggers a full Compose recomposition (~400 ms on
242            // Android debug). When the core emits a tight burst of 3-4
243            // updates (OpenChat → SyncComplete → FetchCatchUpEvents → …)
244            // the UI keeps re-rendering for seconds even though only the
245            // final state mattered.
246            //
247            // PersistAccountBundle is a side-effect (key persistence), not
248            // a UI update, so we never collapse those — every one must run.
249            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/// Convert any pubkey-shaped input (hex, npub, nprofile, …) to its
419/// canonical lowercase-hex form. The empty string is returned when the
420/// input can't be parsed as a public key — callers expecting hex
421/// downstream can short-circuit on that.
422#[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/// Convert any pubkey-shaped input (hex, npub, nprofile, …) to its npub form.
432/// Returns the original string when it can't be parsed as a public key.
433#[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/// Decrypt a notification payload against the persisted double-ratchet
477/// state under `data_dir`. Use from the FCM service (Android) or
478/// Notification Service Extension (iOS) where there's no live `FfiApp`.
479/// Falls back to the generic resolver when keys, payload, or storage
480/// are unavailable so the user still gets *some* notification.
481#[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}