mod actions;
mod direct_file_destination;
mod direct_file_smoke;
mod direct_files;
pub use direct_file_destination::*;
pub use direct_file_smoke::run_direct_file_transfer_smoke;
pub use direct_files::*;
mod call_audio;
mod desktop_call;
mod device_names;
pub use call_audio::{CallAudioCodec, CallAudioError};
pub use desktop_call::*;
pub mod contact_memory;
mod core;
mod ffi_lifecycle;
use ffi_lifecycle::{ffi_app_failure, new_ffi_app_inner};
mod desktop_nearby;
mod desktop_update;
mod emoji;
mod mentions;
pub use mentions::*;
mod fips_ble_ffi;
mod forwarding;
pub mod image_proxy;
pub mod invite_record;
pub mod local_relay;
pub mod perflog;
mod person_name;
pub use person_name::{present_person_name, PersonNamePresentation};
mod user_profile;
pub use user_profile::UserProfileSnapshot;
#[cfg(test)]
mod private_contact_sync;
mod private_contact_sync_v2;
mod qr;
mod search;
mod state;
mod test_fixtures;
#[doc(hidden)]
pub mod update_announcements;
pub mod update_policy;
mod updates;
use std::any::Any;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
use std::time::Duration;
use std::{panic, panic::AssertUnwindSafe};
use flume::{Receiver, Sender};
pub use actions::AppAction;
pub use core::validate_account_storage;
pub use core::{download_hashtree_attachment, download_hashtree_attachment_with_limit};
#[cfg(feature = "stack-fixture")]
#[doc(hidden)]
pub mod stack_mesh_fixture;
pub use desktop_nearby::*;
pub use desktop_update::*;
pub use emoji::*;
pub use fips_ble_ffi::*;
pub use forwarding::format_forwarded_message;
pub use qr::*;
pub use state::*;
pub use test_fixtures::*;
pub use update_policy::UpdateAutoCheckPolicy;
pub use updates::*;
use crate::core::AppCore;
uniffi::setup_scaffolding!();
pub(crate) const CORE_RESTART_TOAST: &str = "Iris needs restart. Copy support bundle in Settings.";
const SUPPORT_BUNDLE_REPLY_TIMEOUT: Duration = Duration::from_secs(8);
#[uniffi::export(callback_interface)]
pub trait AppReconciler: Send + Sync + 'static {
fn reconcile(&self, update: AppUpdate);
}
#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
pub enum FipsBleCommand {
Listen {
request_id: u64,
preferred_psm: u16,
},
StopListening,
StartAdvertising {
request_id: u64,
bootstrap: Vec<u8>,
},
StopAdvertising {
request_id: u64,
},
StartScanning {
request_id: u64,
},
StopScanning,
Connect {
request_id: u64,
peer_token: String,
psm: u16,
},
Write {
request_id: u64,
connection_id: u64,
bytes: Vec<u8>,
},
Close {
connection_id: u64,
},
}
#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
pub enum FipsBleEvent {
Listening {
request_id: u64,
psm: u16,
},
AdvertisingStarted {
request_id: u64,
},
AdvertisingStopped {
request_id: u64,
},
ScanningStarted {
request_id: u64,
},
PeerDiscovered {
peer_token: String,
bootstrap: Vec<u8>,
},
Connected {
request_id: u64,
connection_id: u64,
peer_token: String,
send_segment_mtu: u16,
receive_segment_mtu: u16,
},
IncomingConnection {
connection_id: u64,
peer_token: String,
send_segment_mtu: u16,
receive_segment_mtu: u16,
},
BytesReceived {
connection_id: u64,
bytes: Vec<u8>,
},
WriteCompleted {
request_id: u64,
},
Disconnected {
connection_id: u64,
reason: Option<String>,
},
Failed {
request_id: u64,
message: String,
},
}
#[derive(Default, Debug)]
pub(crate) struct FfiPerfCounters {
pub state: AtomicU64,
pub dispatch: AtomicU64,
pub search: AtomicU64,
pub export_support_bundle_json: AtomicU64,
pub peer_profile_debug: AtomicU64,
pub mutual_groups: AtomicU64,
pub prepare_for_suspend: AtomicU64,
}
#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq, Default)]
pub struct FfiPerfCountersSnapshot {
pub state: u64,
pub dispatch: u64,
pub search: u64,
pub export_support_bundle_json: u64,
pub peer_profile_debug: u64,
pub mutual_groups: u64,
pub prepare_for_suspend: u64,
}
#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq, Default)]
pub struct CorePerfCountersSnapshot {
pub debug_snapshot_builds: u64,
}
#[derive(uniffi::Object)]
pub struct FfiApp {
foreground_tx: Sender<CoreMsg>,
foreground_rx: Receiver<CoreMsg>,
background_tx: Sender<CoreMsg>,
background_rx: Receiver<CoreMsg>,
update_rx: Receiver<AppUpdate>,
listening: AtomicBool,
shared_state: Arc<RwLock<AppState>>,
shared_user_profiles: user_profile::SharedUserProfiles,
shared_db: Arc<RwLock<Option<FfiDatabase>>>,
core_worker: Mutex<Option<thread::JoinHandle<()>>>,
perf: FfiPerfCounters,
queue_metrics: Arc<CoreQueueMetrics>,
recovery: Arc<CoreRecoveryState>,
}
struct FfiDatabase {
writer: crate::core::SharedConnection,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
path: Option<std::path::PathBuf>,
}
impl FfiDatabase {
fn new(writer: crate::core::SharedConnection) -> Self {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
let path = writer.lock().ok().and_then(|conn| {
conn.path()
.filter(|path| !path.is_empty())
.map(std::path::PathBuf::from)
});
Self {
writer,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
path,
}
}
fn chat_reader(&self) -> Option<crate::core::SharedConnection> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(path) = &self.path {
let conn = rusqlite::Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
| rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.ok()?;
conn.busy_timeout(Duration::ZERO).ok()?;
conn.pragma_update(None, "query_only", true).ok()?;
return Some(Arc::new(Mutex::new(conn)));
}
Some(self.writer.clone())
}
}
#[derive(Default, Debug)]
struct CoreQueueMetrics {
foreground_processed: AtomicU64,
background_processed: AtomicU64,
batch_active: AtomicBool,
last_batch_started_at_ms: AtomicU64,
last_batch_finished_at_ms: AtomicU64,
last_batch_size: AtomicU64,
last_batch_foreground_count: AtomicU64,
last_batch_background_count: AtomicU64,
}
impl CoreQueueMetrics {
fn mark_batch_start(&self, size: u64, foreground: u64, background: u64) {
self.last_batch_started_at_ms
.store(crate::perflog::now_ms(), Ordering::Relaxed);
self.last_batch_size.store(size, Ordering::Relaxed);
self.last_batch_foreground_count
.store(foreground, Ordering::Relaxed);
self.last_batch_background_count
.store(background, Ordering::Relaxed);
self.batch_active.store(true, Ordering::Release);
}
fn mark_batch_finished(&self, foreground: u64, background: u64) {
self.foreground_processed
.fetch_add(foreground, Ordering::Relaxed);
self.background_processed
.fetch_add(background, Ordering::Relaxed);
self.last_batch_finished_at_ms
.store(crate::perflog::now_ms(), Ordering::Relaxed);
self.batch_active.store(false, Ordering::Release);
}
}
#[derive(Default, Debug)]
struct CoreRecoveryState {
restore_action: RwLock<Option<AppAction>>,
restart_count: AtomicU64,
last_panic: RwLock<Option<String>>,
}
impl CoreRecoveryState {
fn remember_action(&self, action: &AppAction) {
match action {
AppAction::RestoreSession { .. }
| AppAction::RestoreAccountBundle { .. }
| AppAction::RestorePendingDeviceLink { .. } => {
self.set_restore_action(Some(action.clone()));
}
AppAction::Logout => self.set_restore_action(None),
_ => {}
}
}
fn remember_update(&self, update: &AppUpdate) {
match update {
AppUpdate::PersistAccountBundle {
owner_nsec,
owner_pubkey_hex,
device_nsec,
..
} => self.set_restore_action(Some(AppAction::RestoreAccountBundle {
owner_nsec: owner_nsec.clone(),
owner_pubkey_hex: owner_pubkey_hex.clone(),
device_nsec: device_nsec.clone(),
})),
AppUpdate::PersistPendingDeviceLink {
device_nsec,
approval_bootstrap_json,
} => self.set_restore_action(Some(AppAction::RestorePendingDeviceLink {
device_nsec: device_nsec.clone(),
approval_bootstrap_json: approval_bootstrap_json.clone(),
})),
AppUpdate::ClearPendingDeviceLink => self.set_restore_action(None),
_ => {}
}
}
fn restore_action(&self) -> Option<AppAction> {
match self.restore_action.read() {
Ok(action) => action.clone(),
Err(poison) => poison.into_inner().clone(),
}
}
fn mark_panic(&self, detail: String) -> u64 {
match self.last_panic.write() {
Ok(mut slot) => *slot = Some(detail),
Err(poison) => *poison.into_inner() = Some(detail),
}
self.restart_count.fetch_add(1, Ordering::Relaxed) + 1
}
fn restart_count(&self) -> u64 {
self.restart_count.load(Ordering::Relaxed)
}
fn last_panic(&self) -> Option<String> {
match self.last_panic.read() {
Ok(slot) => slot.clone(),
Err(poison) => poison.into_inner().clone(),
}
}
fn set_restore_action(&self, action: Option<AppAction>) {
match self.restore_action.write() {
Ok(mut slot) => *slot = action,
Err(poison) => *poison.into_inner() = action,
}
}
}
#[uniffi::export]
impl FfiApp {
#[uniffi::constructor]
pub fn new(data_dir: String, _keychain_group: String, _app_version: String) -> Arc<Self> {
match panic::catch_unwind(AssertUnwindSafe(|| new_ffi_app_inner(data_dir))) {
Ok(app) => app,
Err(payload) => ffi_app_failure(format!(
"Iris could not start: {}",
panic_payload_to_string(payload)
)),
}
}
pub fn state(&self) -> AppState {
self.perf.state.fetch_add(1, Ordering::Relaxed);
ffi_or("ffiapp.state", ffi_failure_state(), || {
match self.shared_state.read() {
Ok(slot) => slot.clone(),
Err(poison) => poison.into_inner().clone(),
}
})
}
pub fn user_profile(&self, owner_pubkey_hex: String) -> UserProfileSnapshot {
let owner = nostr::PublicKey::parse(&owner_pubkey_hex)
.map(|key| key.to_hex())
.unwrap_or(owner_pubkey_hex);
self.shared_user_profiles
.read()
.unwrap_or_else(|e| e.into_inner())
.get(&owner)
.cloned()
.unwrap_or_else(|| UserProfileSnapshot::unknown(owner))
}
pub fn accept_direct_files(
&self,
chat_id: String,
transfer_id: String,
destination: Arc<dyn DirectFileDestination>,
) {
let _ = self.foreground_tx.send(CoreMsg::AcceptDirectFiles {
chat_id,
transfer_id,
destination,
});
}
pub fn dispatch(&self, action: AppAction) {
self.perf.dispatch.fetch_add(1, Ordering::Relaxed);
ffi_or("ffiapp.dispatch", (), || {
crate::perflog!("ffi.dispatch action={:?}", std::mem::discriminant(&action));
if matches!(action, AppAction::SendCallMedia { .. }) && self.foreground_tx.len() >= 24 {
return;
}
self.recovery.remember_action(&action);
match &action {
AppAction::OpenChat { .. } => {
crate::perflog::event("foreground.enqueue.OpenChat");
}
AppAction::SendMessage { .. } => {
crate::perflog::event("foreground.enqueue.SendMessage");
}
_ => {}
}
let _ = self.foreground_tx.send(CoreMsg::Action(action));
})
}
pub fn perf_counters(&self) -> FfiPerfCountersSnapshot {
FfiPerfCountersSnapshot {
state: self.perf.state.load(Ordering::Relaxed),
dispatch: self.perf.dispatch.load(Ordering::Relaxed),
search: self.perf.search.load(Ordering::Relaxed),
export_support_bundle_json: self
.perf
.export_support_bundle_json
.load(Ordering::Relaxed),
peer_profile_debug: self.perf.peer_profile_debug.load(Ordering::Relaxed),
mutual_groups: self.perf.mutual_groups.load(Ordering::Relaxed),
prepare_for_suspend: self.perf.prepare_for_suspend.load(Ordering::Relaxed),
}
}
pub fn core_perf_counters(&self) -> CorePerfCountersSnapshot {
ffi_or(
"ffiapp.core_perf_counters",
CorePerfCountersSnapshot::default(),
|| {
let (reply_tx, reply_rx) = flume::bounded(1);
if self
.foreground_tx
.send(CoreMsg::CorePerfCounters(reply_tx))
.is_err()
{
return CorePerfCountersSnapshot::default();
}
match reply_rx.recv_timeout(Duration::from_secs(2)) {
Ok(snapshot) => CorePerfCountersSnapshot {
debug_snapshot_builds: snapshot.debug_snapshot_builds,
},
Err(_) => CorePerfCountersSnapshot::default(),
}
},
)
}
pub fn search(
&self,
query: String,
scope_chat_id: Option<String>,
limit: u32,
) -> SearchResultSnapshot {
self.perf.search.fetch_add(1, Ordering::Relaxed);
ffi_or(
"ffiapp.search",
SearchResultSnapshot::empty(query.clone(), scope_chat_id.clone()),
|| {
let trimmed = query.trim();
if scope_chat_id.is_none() {
let _ = self.background_tx.send(CoreMsg::Internal(Box::new(
InternalEvent::ProfileSearchRequested {
query: trimmed.to_string(),
},
)));
}
if trimmed.is_empty() {
return SearchResultSnapshot::empty(query.clone(), scope_chat_id.clone());
}
let limit = limit.max(1) as usize;
let (mut chat_list, blocked_owner_pubkeys, account) = {
let state = self
.shared_state
.read()
.unwrap_or_else(|poison| poison.into_inner());
(
state.chat_list.clone(),
state.preferences.blocked_owner_pubkeys.clone(),
state.account.clone(),
)
};
let current_owner_hex = account
.as_ref()
.map(|account| account.public_key_hex.to_ascii_lowercase());
if scope_chat_id.is_none() {
if let Some(account) = &account {
search::include_note_to_self(&mut chat_list, account);
}
}
let (contacts, groups) = if scope_chat_id.is_some() {
(Vec::new(), Vec::new())
} else {
search::filter_threads_for_search(&chat_list, trimmed)
};
let shared_db = self.shared_db_read();
let mut excluded_people = chat_list
.iter()
.filter(|chat| chat.kind == ChatKind::Direct)
.map(|chat| chat.chat_id.to_ascii_lowercase())
.collect::<std::collections::HashSet<_>>();
excluded_people.extend(
blocked_owner_pubkeys
.iter()
.map(|owner| owner.to_ascii_lowercase()),
);
if let Some(owner) = ¤t_owner_hex {
excluded_people.insert(owner.clone());
}
let query_db = |conn: &rusqlite::Connection| {
let messages = crate::core::search_messages_fts(
conn,
trimmed,
scope_chat_id.as_deref(),
limit,
)
.unwrap_or_default();
let people = if scope_chat_id.is_none() {
crate::core::search_people(
conn,
trimmed,
&excluded_people,
current_owner_hex.as_deref(),
)
.unwrap_or_default()
.into_iter()
.take(limit)
.collect()
} else {
Vec::new()
};
(messages, people)
};
let (messages, people) = match shared_db.as_ref() {
Some(shared) => match shared.writer.lock() {
Ok(conn) => query_db(&conn),
Err(poison) => query_db(&poison.into_inner()),
},
None => (Vec::new(), Vec::new()),
};
let enriched = enrich_message_hits(messages, &chat_list, account.as_ref());
let shortcut = if scope_chat_id.is_none() {
chat_input_shortcut(trimmed)
} else {
None
};
SearchResultSnapshot {
query,
scope_chat_id,
people,
contacts,
groups,
messages: enriched,
shortcut,
}
},
)
}
pub fn chat_snapshot(&self, chat_id: String, limit: u32) -> Option<CurrentChatSnapshot> {
ffi_or("ffiapp.chat_snapshot", None, || {
let state_snapshot = self.chat_read_state(&chat_id, Some(limit.max(1) as usize));
if state_snapshot
.current_chat
.as_ref()
.is_some_and(|chat| chat.chat_id == chat_id.trim())
{
return crate::core::chat_snapshot_from_state_and_db(
&state_snapshot,
None,
&chat_id,
limit.max(1) as usize,
);
}
let shared_db = self.shared_db_read();
let reader = match shared_db.as_ref() {
Some(database) => Some(database.chat_reader()?),
None => None,
};
crate::core::chat_snapshot_from_state_and_db(
&state_snapshot,
reader.as_ref(),
&chat_id,
limit.max(1) as usize,
)
})
}
pub fn chat_snapshot_before(
&self,
chat_id: String,
before_message_id: String,
limit: u32,
) -> Option<CurrentChatSnapshot> {
ffi_or("ffiapp.chat_snapshot_before", None, || {
let state_snapshot = self.chat_read_state(&chat_id, None);
let shared_db = self.shared_db_read();
let reader = shared_db.as_ref()?.chat_reader()?;
crate::core::chat_snapshot_before_from_state_and_db(
&state_snapshot,
Some(&reader),
&chat_id,
&before_message_id,
limit.max(1) as usize,
)
})
}
pub fn chat_snapshot_around_message(
&self,
chat_id: String,
message_id: String,
before_limit: u32,
after_limit: u32,
) -> Option<CurrentChatSnapshot> {
ffi_or("ffiapp.chat_snapshot_around_message", None, || {
let state_snapshot = self.chat_read_state(&chat_id, None);
let shared_db = self.shared_db_read();
let reader = shared_db.as_ref()?.chat_reader()?;
crate::core::chat_snapshot_around_message_from_state_and_db(
&state_snapshot,
Some(&reader),
&chat_id,
&message_id,
before_limit as usize,
after_limit as usize,
)
})
}
pub fn export_support_bundle_json(&self) -> String {
self.perf
.export_support_bundle_json
.fetch_add(1, Ordering::Relaxed);
ffi_or(
"ffiapp.export_support_bundle_json",
self.support_bundle_json_with_ffi_diagnostics("{}".to_string(), true),
|| {
let (reply_tx, reply_rx) = flume::bounded(1);
if self
.foreground_tx
.send(CoreMsg::ExportSupportBundle(reply_tx))
.is_err()
{
return self.support_bundle_json_with_ffi_diagnostics("{}".to_string(), true);
}
match reply_rx.recv_timeout(SUPPORT_BUNDLE_REPLY_TIMEOUT) {
Ok(json) => self.support_bundle_json_with_ffi_diagnostics(json, false),
Err(_) => self.support_bundle_json_with_ffi_diagnostics("{}".to_string(), true),
}
},
)
}
pub fn peer_profile_debug(&self, owner_input: String) -> Option<PeerProfileDebugSnapshot> {
self.perf.peer_profile_debug.fetch_add(1, Ordering::Relaxed);
ffi_or("ffiapp.peer_profile_debug", None, || {
let (reply_tx, reply_rx) = flume::bounded(1);
if self
.foreground_tx
.send(CoreMsg::PeerProfileDebug {
owner_input,
reply_tx,
})
.is_err()
{
return None;
}
reply_rx.recv_timeout(Duration::from_secs(2)).ok().flatten()
})
}
pub fn mutual_groups(&self, owner_input: String) -> MutualGroupsSnapshot {
self.perf.mutual_groups.fetch_add(1, Ordering::Relaxed);
ffi_or(
"ffiapp.mutual_groups",
MutualGroupsSnapshot::default(),
|| {
let (reply_tx, reply_rx) = flume::bounded(1);
if self
.foreground_tx
.send(CoreMsg::MutualGroups {
owner_input,
reply_tx,
})
.is_err()
{
return MutualGroupsSnapshot::default();
}
reply_rx
.recv_timeout(Duration::from_secs(2))
.unwrap_or_default()
},
)
}
pub fn prepare_for_suspend(&self) {
self.prepare_for_suspend_inner(cfg!(target_os = "ios"));
}
pub fn shutdown(&self) {
self.shutdown_inner(cfg!(any(target_os = "ios", target_os = "macos")));
}
pub fn shutdown_and_wait(&self) {
self.shutdown_inner(true);
}
fn support_bundle_json_with_ffi_diagnostics(
&self,
rust_json: String,
core_support_bundle_timed_out: bool,
) -> String {
let mut object = serde_json::from_str::<serde_json::Value>(&rust_json)
.ok()
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
let now_ms = crate::perflog::now_ms();
let last_started_at_ms = self
.queue_metrics
.last_batch_started_at_ms
.load(Ordering::Relaxed);
let last_finished_at_ms = self
.queue_metrics
.last_batch_finished_at_ms
.load(Ordering::Relaxed);
let batch_active = self.queue_metrics.batch_active.load(Ordering::Acquire);
let active_batch_age_ms = if batch_active && last_started_at_ms > 0 {
Some(now_ms.saturating_sub(last_started_at_ms))
} else {
None
};
let last_batch_started_ago_ms = if last_started_at_ms > 0 {
Some(now_ms.saturating_sub(last_started_at_ms))
} else {
None
};
let last_batch_finished_ago_ms = if last_finished_at_ms > 0 {
Some(now_ms.saturating_sub(last_finished_at_ms))
} else {
None
};
object.insert(
"ffi_queue".to_string(),
serde_json::json!({
"core_support_bundle_timed_out": core_support_bundle_timed_out,
"foreground_pending": self.foreground_rx.len(),
"background_pending": self.background_rx.len(),
"foreground_processed": self.queue_metrics.foreground_processed.load(Ordering::Relaxed),
"background_processed": self.queue_metrics.background_processed.load(Ordering::Relaxed),
"batch_active": batch_active,
"active_batch_age_ms": active_batch_age_ms,
"last_batch_started_ago_ms": last_batch_started_ago_ms,
"last_batch_finished_ago_ms": last_batch_finished_ago_ms,
"last_batch_size": self.queue_metrics.last_batch_size.load(Ordering::Relaxed),
"last_batch_foreground_count": self.queue_metrics.last_batch_foreground_count.load(Ordering::Relaxed),
"last_batch_background_count": self.queue_metrics.last_batch_background_count.load(Ordering::Relaxed),
"core_restarts": self.recovery.restart_count(),
"last_core_panic": self.recovery.last_panic(),
"has_cached_restore_action": self.recovery.restore_action().is_some(),
}),
);
serde_json::Value::Object(object).to_string()
}
pub fn listen_for_updates(&self, reconciler: Box<dyn AppReconciler>) {
if self
.listening
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
let update_rx = self.update_rx.clone();
let recovery = self.recovery.clone();
let spawn_result = thread::Builder::new()
.name("iris-updates".to_string())
.spawn(move || {
while let Ok(first) = update_rx.recv() {
let mut latest_full_state: Option<AppUpdate> = None;
let mut before_full_state: Vec<AppUpdate> = Vec::new();
let mut after_full_state: Vec<AppUpdate> = Vec::new();
let process = |update: AppUpdate,
latest: &mut Option<AppUpdate>,
before: &mut Vec<AppUpdate>,
after: &mut Vec<AppUpdate>| {
recovery.remember_update(&update);
enqueue_update_for_delivery(update, latest, before, after);
};
process(
first,
&mut latest_full_state,
&mut before_full_state,
&mut after_full_state,
);
while let Ok(next) = update_rx.try_recv() {
process(
next,
&mut latest_full_state,
&mut before_full_state,
&mut after_full_state,
);
}
for update in before_full_state
.into_iter()
.chain(latest_full_state)
.chain(after_full_state)
{
let kind = match &update {
AppUpdate::CallMedia { .. } => "CallMedia",
AppUpdate::FullState(_) => "FullState",
AppUpdate::PersistAccountBundle { .. } => "PersistAccountBundle",
AppUpdate::PersistPendingDeviceLink { .. } => {
"PersistPendingDeviceLink"
}
AppUpdate::ClearPendingDeviceLink => "ClearPendingDeviceLink",
AppUpdate::NearbyPublishedEvent { .. } => "NearbyPublishedEvent",
AppUpdate::NearbyPeersChanged { .. } => "NearbyPeersChanged",
AppUpdate::SignerLoginSignEvent { .. } => "SignerLoginSignEvent",
};
let t0 = crate::perflog::now_ms();
crate::perflog!("reconcile.start kind={kind}");
if panic::catch_unwind(AssertUnwindSafe(|| reconciler.reconcile(update)))
.is_err()
{
crate::perflog!("reconcile.failed kind={kind}");
continue;
}
crate::perflog!(
"reconcile.end kind={kind} elapsed_ms={}",
crate::perflog::now_ms().saturating_sub(t0)
);
}
}
});
if let Err(error) = spawn_result {
crate::perflog!("updates.spawn.failed error={error}");
self.listening.store(false, Ordering::SeqCst);
}
}
}
impl FfiApp {
fn shutdown_inner(&self, wait_for_completion: bool) {
ffi_or("ffiapp.shutdown", (), || {
let mut worker = self
.core_worker
.lock()
.unwrap_or_else(|poison| poison.into_inner());
if worker.is_none() {
return;
}
let (reply_tx, reply_rx) = flume::bounded(1);
let _ = self.foreground_tx.send(CoreMsg::Shutdown(Some(reply_tx)));
if wait_for_completion {
if let Some(worker) = worker.take() {
let _ = worker.join();
}
} else {
let _ = reply_rx.recv_timeout(Duration::from_secs(2));
}
})
}
fn chat_read_state(&self, chat_id: &str, latest_limit: Option<usize>) -> AppState {
let state = self
.shared_state
.read()
.unwrap_or_else(|poison| poison.into_inner());
crate::core::chat_read_state(&state, chat_id, latest_limit)
}
fn prepare_for_suspend_inner(&self, wait_for_completion: bool) {
self.perf
.prepare_for_suspend
.fetch_add(1, Ordering::Relaxed);
ffi_or("ffiapp.prepare_for_suspend", (), || {
if self.shared_db_read().is_none() {
return;
}
let (reply_tx, reply_rx) = flume::bounded(1);
if self
.foreground_tx
.send(CoreMsg::PrepareForSuspend(reply_tx))
.is_err()
{
return;
}
if wait_for_completion {
let _ = reply_rx.recv();
} else {
let _ = reply_rx.recv_timeout(Duration::from_secs(2));
}
})
}
fn shared_db_read(&self) -> std::sync::RwLockReadGuard<'_, Option<FfiDatabase>> {
self.shared_db
.read()
.unwrap_or_else(|poison| poison.into_inner())
}
}
struct CoreSupervisor {
data_dir: String,
update_tx: Sender<AppUpdate>,
core_sender: Sender<CoreMsg>,
priority_sender: Sender<CoreMsg>,
foreground_rx: Receiver<CoreMsg>,
background_rx: Receiver<CoreMsg>,
shared_state: Arc<RwLock<AppState>>,
shared_db: Arc<RwLock<Option<FfiDatabase>>>,
shared_user_profiles: user_profile::SharedUserProfiles,
queue_metrics: Arc<CoreQueueMetrics>,
recovery: Arc<CoreRecoveryState>,
}
fn spawn_core_supervisor(
core: AppCore,
supervisor: CoreSupervisor,
) -> std::io::Result<thread::JoinHandle<()>> {
thread::Builder::new()
.name("iris-core".to_string())
.spawn(move || {
crate::core::prewarm_default_social_graph();
let mut core_slot = Some(core);
while let Ok(batch) =
recv_core_batch(&supervisor.foreground_rx, &supervisor.background_rx)
{
let batch_size = batch.len();
let foreground_count = batch
.iter()
.filter(|msg| is_foreground_core_msg(msg))
.count() as u64;
let background_count = batch_size as u64 - foreground_count;
supervisor.queue_metrics.mark_batch_start(
batch_size as u64,
foreground_count,
background_count,
);
let t0 = crate::perflog::now_ms();
crate::perflog!("core.batch.start size={batch_size}");
let result = match core_slot.as_mut() {
Some(core) => catch_core_batch(|| handle_core_batch_responsive(core, batch)),
None => break,
};
let elapsed_ms = crate::perflog::now_ms().saturating_sub(t0);
supervisor
.queue_metrics
.mark_batch_finished(foreground_count, background_count);
match result {
Ok(true) => {
crate::perflog!(
"core.batch.end size={batch_size} elapsed_ms={elapsed_ms}"
);
}
Ok(false) => {
crate::perflog!(
"core.batch.end size={batch_size} elapsed_ms={elapsed_ms} result=shutdown"
);
break;
}
Err(error) => {
if let Some(mut failed_core) = core_slot.take() {
failed_core.record_core_panic(error.clone());
}
crate::perflog!(
"core.batch.end size={batch_size} elapsed_ms={elapsed_ms} result=panic"
);
match recover_core_after_panic(&supervisor, error) {
Some(core) => core_slot = Some(core),
None => break,
}
}
}
}
set_shared_db(&supervisor.shared_db, None);
supervisor.shared_user_profiles.write().unwrap_or_else(|e| e.into_inner()).clear();
drop(core_slot);
})
}
fn recover_core_after_panic(supervisor: &CoreSupervisor, detail: String) -> Option<AppCore> {
let restart_count = supervisor.recovery.mark_panic(detail);
crate::perflog!("core.supervisor.restart count={restart_count}");
set_shared_db(&supervisor.shared_db, None);
supervisor
.shared_user_profiles
.write()
.unwrap_or_else(|e| e.into_inner())
.clear();
let mut core = match AppCore::try_new_with_priority_sender(
supervisor.update_tx.clone(),
supervisor.core_sender.clone(),
supervisor.priority_sender.clone(),
supervisor.data_dir.clone(),
supervisor.shared_state.clone(),
) {
Ok(core) => core,
Err(error) => {
crate::perflog!("core.supervisor.restart.failed count={restart_count} error={error}");
publish_core_failure_state(
&supervisor.shared_state,
&supervisor.update_tx,
CORE_RESTART_TOAST.to_string(),
);
return None;
}
};
core.set_shared_user_profiles(Arc::clone(&supervisor.shared_user_profiles));
set_shared_db(&supervisor.shared_db, Some(core.shared_db()));
if let Some(action) = supervisor.recovery.restore_action() {
crate::perflog!(
"core.supervisor.restore action={:?}",
std::mem::discriminant(&action)
);
match catch_core_batch(|| core.handle_messages(vec![CoreMsg::Action(action)])) {
Ok(true) => {}
Ok(false) => {
publish_core_failure_state(
&supervisor.shared_state,
&supervisor.update_tx,
CORE_RESTART_TOAST.to_string(),
);
return None;
}
Err(error) => {
core.mark_core_panic(format!("core recovery restore panic: {error}"));
return None;
}
}
}
crate::perflog!("core.supervisor.recovered count={restart_count}");
Some(core)
}
fn set_shared_db(
shared_db: &Arc<RwLock<Option<FfiDatabase>>>,
value: Option<crate::core::SharedConnection>,
) {
let value = value.map(FfiDatabase::new);
match shared_db.write() {
Ok(mut slot) => *slot = value,
Err(poison) => *poison.into_inner() = value,
}
}
fn publish_core_failure_state(
shared_state: &Arc<RwLock<AppState>>,
update_tx: &Sender<AppUpdate>,
message: String,
) {
let mut state = match shared_state.read() {
Ok(slot) => slot.clone(),
Err(poison) => poison.into_inner().clone(),
};
state.toast = Some(message);
state.rev = state.rev.saturating_add(1).max(1);
match shared_state.write() {
Ok(mut slot) => *slot = state.clone(),
Err(poison) => *poison.into_inner() = state.clone(),
}
let _ = update_tx.send(AppUpdate::FullState(state));
}
const CORE_FOREGROUND_BATCH_LIMIT: usize = 64;
const CORE_BACKGROUND_BATCH_LIMIT: usize = 16;
fn recv_core_batch(
foreground_rx: &Receiver<CoreMsg>,
background_rx: &Receiver<CoreMsg>,
) -> Result<Vec<CoreMsg>, flume::RecvError> {
if let Some(batch) = try_recv_core_batch(foreground_rx, background_rx) {
return Ok(batch);
}
let (is_foreground, first) = flume::Selector::new()
.recv(foreground_rx, |result| result.map(|msg| (true, msg)))
.recv(background_rx, |result| result.map(|msg| (false, msg)))
.wait()?;
Ok(drain_core_batch_after_first(
is_foreground,
first,
foreground_rx,
background_rx,
))
}
fn try_recv_core_batch(
foreground_rx: &Receiver<CoreMsg>,
background_rx: &Receiver<CoreMsg>,
) -> Option<Vec<CoreMsg>> {
if let Ok(first) = foreground_rx.try_recv() {
return Some(drain_core_batch_after_first(
true,
first,
foreground_rx,
background_rx,
));
}
background_rx
.try_recv()
.ok()
.map(|first| drain_core_batch_after_first(false, first, foreground_rx, background_rx))
}
fn drain_core_batch_after_first(
is_foreground: bool,
first: CoreMsg,
foreground_rx: &Receiver<CoreMsg>,
background_rx: &Receiver<CoreMsg>,
) -> Vec<CoreMsg> {
let mut batch = Vec::with_capacity(if is_foreground {
CORE_FOREGROUND_BATCH_LIMIT
} else {
CORE_BACKGROUND_BATCH_LIMIT
});
batch.push(first);
drain_foreground_messages(&mut batch, foreground_rx);
if batch.iter().any(is_foreground_core_msg) {
return batch;
}
while batch.len() < CORE_BACKGROUND_BATCH_LIMIT {
let Ok(next) = background_rx.try_recv() else {
break;
};
batch.push(next);
drain_foreground_messages(&mut batch, foreground_rx);
if batch.iter().any(is_foreground_core_msg) {
break;
}
}
batch
}
fn drain_foreground_messages(batch: &mut Vec<CoreMsg>, foreground_rx: &Receiver<CoreMsg>) {
while batch.len() < CORE_FOREGROUND_BATCH_LIMIT {
let Ok(next) = foreground_rx.try_recv() else {
break;
};
batch.push(next);
}
}
fn handle_core_batch_responsive(core: &mut AppCore, messages: Vec<CoreMsg>) -> bool {
if messages.len() <= 1 || !messages.iter().any(is_foreground_core_msg) {
return core.handle_messages(messages);
}
let mut foreground = Vec::new();
let mut background = Vec::new();
for message in messages {
if is_foreground_core_msg(&message) {
foreground.push(message);
} else {
background.push(message);
}
}
for message in foreground {
if !core.handle_messages(vec![message]) {
return false;
}
}
background.is_empty() || core.handle_messages(background)
}
fn catch_core_batch<F>(f: F) -> Result<bool, String>
where
F: FnOnce() -> bool,
{
panic::catch_unwind(AssertUnwindSafe(f)).map_err(panic_payload_to_string)
}
fn ffi_or<T, F>(label: &'static str, fallback: T, f: F) -> T
where
F: FnOnce() -> T,
{
match panic::catch_unwind(AssertUnwindSafe(f)) {
Ok(value) => value,
Err(payload) => {
crate::perflog!(
"ffi.panic label={label} detail={}",
panic_payload_to_string(payload)
);
fallback
}
}
}
fn ffi_failure_state() -> AppState {
let mut state = AppState::empty();
state.toast = Some("Iris needs restart. Copy support bundle in Settings.".to_string());
state
}
fn suppressed_mobile_push_resolution() -> MobilePushNotificationResolution {
MobilePushNotificationResolution {
should_show: false,
title: String::new(),
body: String::new(),
payload_json: "{}".to_string(),
}
}
fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_string()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"unknown panic".to_string()
}
}
fn is_foreground_core_msg(message: &CoreMsg) -> bool {
!matches!(message, CoreMsg::Internal(_))
}
#[cfg(test)]
mod core_queue_tests;
#[cfg(test)]
mod ffi_lifecycle_tests;
fn enrich_message_hits(
hits: Vec<crate::core::PersistedMessageSearchHit>,
chat_list: &[ChatThreadSnapshot],
account: Option<&AccountSnapshot>,
) -> Vec<MessageSearchHit> {
use std::collections::HashMap;
let lookup: HashMap<&str, &ChatThreadSnapshot> = chat_list
.iter()
.map(|chat| (chat.chat_id.as_str(), chat))
.collect();
hits.into_iter()
.map(|hit| {
let parent = lookup.get(hit.chat_id.as_str());
let display_name = parent
.map(|chat| chat.display_name.clone())
.unwrap_or_else(|| short_chat_label(&hit.chat_id));
let picture_url = parent.and_then(|chat| chat.picture_url.clone());
let kind = parent
.map(|chat| chat.kind.clone())
.unwrap_or(ChatKind::Direct);
let author_pubkey = hit.author_owner_pubkey_hex.or_else(|| {
if hit.kind != ChatMessageKind::User {
None
} else if hit.is_outgoing {
account.map(|account| account.public_key_hex.clone())
} else {
parent
.filter(|chat| chat.kind == ChatKind::Direct)
.map(|chat| chat.chat_id.clone())
}
});
let local_author = account.filter(|account| {
author_pubkey.as_deref() == Some(account.public_key_hex.as_str())
});
let direct_author = parent.filter(|chat| {
chat.kind == ChatKind::Direct
&& author_pubkey.as_deref() == Some(chat.chat_id.as_str())
});
let author_display_name = local_author
.map(|account| account.display_name.clone())
.or_else(|| direct_author.map(|chat| chat.display_name.clone()))
.unwrap_or(hit.author);
let author_picture_url = local_author
.and_then(|account| account.picture_url.clone())
.or(hit.author_picture_url)
.or_else(|| direct_author.and_then(|chat| chat.picture_url.clone()));
MessageSearchHit {
chat_id: hit.chat_id,
message_id: hit.message_id,
chat_display_name: display_name,
chat_picture_url: picture_url,
chat_kind: kind,
author_pubkey: author_pubkey.unwrap_or_default(),
author_display_name,
author_picture_url,
body: hit.body,
is_outgoing: hit.is_outgoing,
created_at_secs: hit.created_at_secs,
}
})
.collect()
}
fn short_chat_label(chat_id: &str) -> String {
let trimmed = chat_id.trim();
if trimmed.len() > 12 {
format!("{}…", &trimmed[..12])
} else {
trimmed.to_string()
}
}
impl Drop for FfiApp {
fn drop(&mut self) {
let _ = self.foreground_tx.send(CoreMsg::Shutdown(None));
}
}
#[uniffi::export]
pub fn normalize_peer_input(input: String) -> String {
ffi_or("normalize_peer_input", String::new(), || {
crate::core::normalize_peer_input_for_display(&input)
})
}
#[uniffi::export]
pub fn is_valid_peer_input(input: String) -> bool {
ffi_or("is_valid_peer_input", false, || {
crate::core::parse_peer_input(&input).is_ok()
})
}
#[uniffi::export]
pub fn classify_chat_input(input: String) -> Option<ChatInputShortcut> {
ffi_or("classify_chat_input", None, || chat_input_shortcut(&input))
}
fn chat_input_shortcut(raw: &str) -> Option<ChatInputShortcut> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let lower = trimmed.to_lowercase();
if lower.contains("://") && lower.contains('#') {
return Some(ChatInputShortcut::Invite {
invite_input: trimmed.to_string(),
display: short_invite_display(trimmed),
});
}
if crate::core::parse_peer_input(trimmed).is_ok() {
use nostr::nips::nip19::ToBech32;
let normalized = crate::core::normalize_peer_input_for_display(trimmed);
if let Ok(pubkey) = nostr::PublicKey::parse(&normalized) {
let npub = pubkey.to_bech32().unwrap_or_else(|_| normalized.clone());
let display = crate::core::fallback_profile_name_for_identity(&pubkey.to_hex());
return Some(ChatInputShortcut::DirectPeer {
peer_input: normalized,
display,
npub,
pubkey_hex: pubkey.to_hex(),
});
}
}
None
}
fn short_invite_display(invite: &str) -> String {
let after_scheme = invite
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(invite);
if after_scheme.len() > 32 {
format!("{}…", &after_scheme[..32])
} else {
after_scheme.to_string()
}
}
#[uniffi::export]
pub fn peer_input_to_hex(input: String) -> String {
ffi_or("peer_input_to_hex", String::new(), || {
let normalized = crate::core::normalize_peer_input_for_display(&input);
match nostr::PublicKey::parse(&normalized) {
Ok(pubkey) => pubkey.to_hex(),
Err(_) => String::new(),
}
})
}
#[uniffi::export]
pub fn peer_input_to_npub(input: String) -> String {
ffi_or("peer_input_to_npub", String::new(), || {
use nostr::nips::nip19::ToBech32;
let normalized = crate::core::normalize_peer_input_for_display(&input);
match nostr::PublicKey::parse(&normalized) {
Ok(pubkey) => pubkey.to_bech32().unwrap_or(normalized),
Err(_) => normalized,
}
})
}
#[uniffi::export]
pub fn build_summary() -> String {
ffi_or("build_summary", String::new(), crate::core::build_summary)
}
#[uniffi::export]
pub fn relay_set_id() -> String {
ffi_or("relay_set_id", String::new(), || {
crate::core::relay_set_id().to_string()
})
}
#[uniffi::export]
pub fn proxied_image_url(
original_src: String,
preferences: PreferencesSnapshot,
width: Option<u32>,
height: Option<u32>,
square: bool,
) -> String {
ffi_or("proxied_image_url", String::new(), || {
image_proxy::proxied_image_url(&original_src, &preferences, width, height, square)
})
}
#[uniffi::export]
pub fn image_load_urls(
original_src: String,
preferences: PreferencesSnapshot,
width: Option<u32>,
height: Option<u32>,
square: bool,
) -> Vec<String> {
ffi_or("image_load_urls", Vec::new(), || {
image_proxy::image_load_urls(&original_src, &preferences, width, height, square)
})
}
#[uniffi::export]
pub fn is_trusted_test_build() -> bool {
ffi_or(
"is_trusted_test_build",
false,
crate::core::trusted_test_build_flag,
)
}
#[uniffi::export]
pub fn app_version() -> String {
crate::core::app_version_string().to_string()
}
#[uniffi::export]
pub fn resolve_mobile_push_notification_payload(
raw_payload_json: String,
) -> MobilePushNotificationResolution {
ffi_or(
"resolve_mobile_push_notification_payload",
suppressed_mobile_push_resolution(),
|| crate::core::resolve_mobile_push_notification(raw_payload_json),
)
}
#[uniffi::export]
pub fn decrypt_mobile_push_notification_payload(
data_dir: String,
owner_pubkey_hex: String,
device_nsec: String,
raw_payload_json: String,
) -> MobilePushNotificationResolution {
ffi_or(
"decrypt_mobile_push_notification_payload",
suppressed_mobile_push_resolution(),
|| {
crate::core::decrypt_mobile_push_notification(
data_dir,
owner_pubkey_hex,
device_nsec,
raw_payload_json,
)
},
)
}
#[uniffi::export]
pub fn read_mobile_push_notification_indexes(
data_dir: String,
owner_pubkey_hex: String,
device_nsec: String,
payloads: Vec<String>,
) -> Vec<u64> {
ffi_or("read_mobile_push_notification_indexes", Vec::new(), || {
crate::core::read_mobile_push_notification_indexes(
data_dir,
owner_pubkey_hex,
device_nsec,
payloads,
)
})
}
pub use crate::core::desktop_read_notifications::DesktopNotificationMessage;
pub use crate::core::notifications::NotificationCandidate;
#[uniffi::export]
pub fn read_desktop_notification_indexes(
data_dir: String,
owner_pubkey_hex: String,
messages: Vec<DesktopNotificationMessage>,
) -> Vec<u64> {
ffi_or("read_desktop_notification_indexes", Vec::new(), || {
crate::core::desktop_read_notifications::read_desktop_notification_indexes(
data_dir,
owner_pubkey_hex,
messages,
)
})
}
#[uniffi::export]
pub fn decide_pending_notifications(
previous_chats: Vec<ChatThreadSnapshot>,
next_chats: Vec<ChatThreadSnapshot>,
preferences: PreferencesSnapshot,
app_foreground: bool,
open_chat_id: Option<String>,
) -> Vec<NotificationCandidate> {
crate::core::notifications::decide_notifications(
&previous_chats,
&next_chats,
&preferences,
app_foreground,
open_chat_id.as_deref(),
)
}
#[uniffi::export]
pub fn router_open_chat_id(router: Router) -> Option<String> {
crate::core::notifications::active_chat_id(&router)
}
#[uniffi::export]
pub fn resolve_mobile_push_subscription_server_url(
platform_key: String,
is_release: bool,
override_url: Option<String>,
) -> String {
ffi_or(
"resolve_mobile_push_subscription_server_url",
String::new(),
|| crate::core::resolve_mobile_push_server_url(platform_key, is_release, override_url),
)
}
#[uniffi::export]
pub fn mobile_push_subscription_id_key(platform_key: String) -> String {
ffi_or("mobile_push_subscription_id_key", String::new(), || {
crate::core::mobile_push_stored_subscription_id_key(platform_key)
})
}
#[uniffi::export]
pub fn build_mobile_push_list_subscriptions_request(
owner_nsec: String,
platform_key: String,
is_release: bool,
server_url_override: Option<String>,
) -> Option<MobilePushSubscriptionRequest> {
ffi_or("build_mobile_push_list_subscriptions_request", None, || {
crate::core::build_mobile_push_list_subscriptions_request(
owner_nsec,
platform_key,
is_release,
server_url_override,
)
})
}
#[uniffi::export]
#[allow(clippy::too_many_arguments)]
pub fn build_mobile_push_create_subscription_request(
owner_nsec: String,
platform_key: String,
push_token: String,
apns_topic: Option<String>,
message_author_pubkeys: Vec<String>,
background_message_author_pubkeys: Vec<String>,
invite_response_pubkeys: Vec<String>,
is_release: bool,
server_url_override: Option<String>,
delayed_message_authors: Vec<MobilePushDelayedAuthor>,
) -> Option<MobilePushSubscriptionRequest> {
ffi_or(
"build_mobile_push_create_subscription_request",
None,
|| {
crate::core::build_mobile_push_create_subscription_request(
owner_nsec,
platform_key,
push_token,
apns_topic,
message_author_pubkeys,
background_message_author_pubkeys,
invite_response_pubkeys,
is_release,
server_url_override,
delayed_message_authors,
)
},
)
}
#[uniffi::export]
#[allow(clippy::too_many_arguments)]
pub fn build_mobile_push_update_subscription_request(
owner_nsec: String,
subscription_id: String,
platform_key: String,
push_token: String,
apns_topic: Option<String>,
message_author_pubkeys: Vec<String>,
background_message_author_pubkeys: Vec<String>,
invite_response_pubkeys: Vec<String>,
is_release: bool,
server_url_override: Option<String>,
delayed_message_authors: Vec<MobilePushDelayedAuthor>,
) -> Option<MobilePushSubscriptionRequest> {
ffi_or(
"build_mobile_push_update_subscription_request",
None,
|| {
crate::core::build_mobile_push_update_subscription_request(
owner_nsec,
subscription_id,
platform_key,
push_token,
apns_topic,
message_author_pubkeys,
background_message_author_pubkeys,
invite_response_pubkeys,
is_release,
server_url_override,
delayed_message_authors,
)
},
)
}
#[uniffi::export]
pub fn build_mobile_push_delete_subscription_request(
owner_nsec: String,
subscription_id: String,
platform_key: String,
is_release: bool,
server_url_override: Option<String>,
) -> Option<MobilePushSubscriptionRequest> {
ffi_or(
"build_mobile_push_delete_subscription_request",
None,
|| {
crate::core::build_mobile_push_delete_subscription_request(
owner_nsec,
subscription_id,
platform_key,
is_release,
server_url_override,
)
},
)
}
#[cfg(test)]
#[path = "ffi_hardening_tests.rs"]
mod ffi_hardening_tests;
#[uniffi::export]
#[allow(clippy::too_many_arguments)]
pub fn build_call_push_subscription_request(
owner_nsec: String,
device_pubkey_hex: String,
subscription_id: Option<String>,
platform_key: String,
push_token: String,
apns_topic: Option<String>,
is_release: bool,
server_url_override: Option<String>,
) -> Option<MobilePushSubscriptionRequest> {
ffi_or("build_call_push_subscription_request", None, || {
crate::core::build_call_push_subscription_request(
owner_nsec,
device_pubkey_hex,
subscription_id,
platform_key,
push_token,
apns_topic,
is_release,
server_url_override,
)
})
}
#[uniffi::export]
pub fn resolve_call_push_invite(
data_dir: String,
device_nsec: String,
payload_json: String,
) -> Option<CallSnapshot> {
ffi_or("resolve_call_push_invite", None, || {
crate::core::resolve_call_push_invite(data_dir, device_nsec, payload_json)
})
}
#[uniffi::export]
pub fn mobile_push_request_without_timed_filters(
mut request: MobilePushSubscriptionRequest,
) -> Option<MobilePushSubscriptionRequest> {
let mut body: serde_json::Value = serde_json::from_str(request.body_json.as_deref()?).ok()?;
let filters = body.get("filters")?.as_array()?;
if !filters.iter().any(|filter| filter.get("since").is_some()) {
return None;
}
let mut remaining: Vec<_> = filters
.iter()
.filter(|filter| filter.get("since").is_none())
.cloned()
.collect();
if remaining.is_empty() {
remaining.push(serde_json::json!({"kinds": [1060], "authors": []}));
}
let primary_filter = remaining.first()?.clone();
let object = body.as_object_mut()?;
object.insert("filter".into(), primary_filter);
object.insert("filters".into(), serde_json::Value::Array(remaining));
request.body_json = Some(body.to_string());
Some(request)
}