use std::ffi::OsString;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand, ValueEnum};
use iris_chat_core::{
AppAction, AppReconciler, AppState, AppUpdate, ChatKind, ChatMessageSnapshot,
ChatThreadSnapshot, CurrentChatSnapshot, DeliveryState, DesktopNearbyObserver,
DesktopNearbySnapshot, DeviceAuthorizationState, FfiApp, FfiDesktopNearby,
GroupDetailsSnapshot,
};
use serde::Serialize;
use serde_json::{json, Value};
mod iris_account_file;
mod iris_cli_run;
mod iris_contacts;
mod iris_service;
mod iris_service_transport;
use iris_cli_run::run;
mod iris_message_actions;
use iris_message_actions::handle_message_top_command;
mod iris_message_rows;
mod iris_updater;
mod iris_wait_helpers;
use iris_message_rows::{
latest_message_keys, latest_outgoing_message_row, open_existing_db, stream_new_messages,
};
use iris_updater::{run_iris_update, UpdateCommands};
use iris_wait_helpers::{
has_delivery_blocking_protocol_work, has_pending_relay_transport_publishes,
has_pending_runtime_publishes,
};
use nostr::PublicKey;
const TOP_LEVEL_HELP: &str = "\
Account:
login Use a secret key
restore Use a secret key
logout Forget this device
whoami Show this profile
account Profile tools
Messages:
chat Chat tools
send Send a message
read Show messages
edit Edit your message
edit-history Show message versions
delete Delete a message (--everyone to retract your own)
seen Mark messages seen
react React to a message
typing Send typing status
receipt Send delivery status
search Search messages
tail Show recent messages
listen Watch for messages
Contacts:
contact Saved names, private favorites, public follows
Groups:
group Group chat tools
Invites and Devices:
invite Invite someone
link Link this device
Message Servers:
relay Message server tools
Maintenance:
state Show local state
sync Sync now
privacy Privacy tools
service Keep this profile online
update Update iris
help Print help";
const CLI_APP_DIR_NAME: &str = "Iris Chat CLI";
#[derive(Parser)]
#[command(name = "iris")]
#[command(version = env!("IRIS_APP_VERSION"))]
#[command(about = "Iris Chat command line client")]
#[command(after_help = TOP_LEVEL_HELP)]
#[command(help_template = "\
{before-help}{about-with-newline}
{usage-heading} {usage}{after-help}
Options:
{options}")]
struct Cli {
#[arg(short, long, global = true)]
json: bool,
#[arg(long, global = true, env = "IRIS_DATA_DIR")]
data_dir: Option<PathBuf>,
#[arg(long, global = true)]
no_background_sync: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(flatten)]
Account(AccountTopCommands),
#[command(flatten)]
Messages(MessageTopCommands),
#[command(subcommand)]
Contact(iris_contacts::ContactCommands),
#[command(flatten)]
Groups(GroupTopCommands),
#[command(flatten)]
InvitesAndDevices(InviteDeviceTopCommands),
#[command(flatten)]
MessageServers(MessageServerTopCommands),
#[command(subcommand)]
Privacy(PrivacyCommands),
#[command(flatten)]
Maintenance(MaintenanceTopCommands),
}
#[derive(Subcommand)]
enum AccountTopCommands {
Login {
secret_key: String,
},
Restore {
secret_key: String,
},
Logout,
Whoami,
#[command(subcommand)]
Account(AccountCommands),
}
#[derive(Subcommand)]
enum MessageTopCommands {
Edit {
chat: String,
message_id: String,
text: String,
},
EditHistory {
chat: String,
message_id: String,
},
Delete {
chat: String,
message_id: String,
#[arg(long)]
everyone: bool,
},
#[command(subcommand)]
Chat(ChatCommands),
Send {
chat: String,
message: String,
#[arg(long)]
ttl: Option<u64>,
#[arg(long, value_name = "UNIX_SECONDS")]
expires_at: Option<u64>,
},
Read {
chat: String,
#[arg(short, long, default_value_t = 50)]
limit: usize,
},
Seen {
chat: String,
message_ids: Vec<String>,
},
React {
chat: String,
message_id: String,
emoji: String,
},
Typing {
chat: String,
#[arg(long)]
stop: bool,
},
Receipt {
chat: String,
receipt_type: String,
message_ids: Vec<String>,
},
Search {
query: String,
#[arg(short, long, default_value_t = 50)]
limit: usize,
},
Tail {
#[arg(short, long, default_value_t = 20)]
limit: usize,
#[arg(short, long)]
follow: bool,
#[arg(short, long)]
chat: Option<String>,
#[arg(long, default_value_t = 1000)]
interval_ms: u64,
},
Listen {
#[arg(short, long)]
chat: Option<String>,
#[arg(long, default_value_t = 1000)]
interval_ms: u64,
#[arg(long)]
nearby_lan: bool,
},
}
#[derive(Subcommand)]
enum GroupTopCommands {
#[command(subcommand)]
Group(GroupCommands),
}
#[derive(Subcommand)]
enum InviteDeviceTopCommands {
#[command(subcommand)]
Invite(InviteCommands),
#[command(subcommand)]
Link(LinkCommands),
}
#[derive(Subcommand)]
enum MessageServerTopCommands {
#[command(subcommand)]
Relay(RelayCommands),
}
#[derive(Subcommand)]
enum MaintenanceTopCommands {
#[command(subcommand)]
Service(iris_service::ServiceCommands),
State,
Debug {
#[arg(long, default_value_t = 0)]
wait_ms: u64,
},
Sync {
#[arg(long, default_value_t = 1500)]
wait_ms: u64,
},
#[command(subcommand)]
Update(UpdateCommands),
}
#[derive(Subcommand)]
enum AccountCommands {
Create {
#[arg(short, long, default_value = "")]
name: String,
},
Restore {
secret_key: String,
},
Bundle,
Profile {
#[arg(long)]
name: Option<String>,
#[arg(long)]
picture_url: Option<String>,
#[arg(long)]
about: Option<String>,
},
}
#[derive(Subcommand)]
enum ChatCommands {
List,
Create {
user_id: String,
},
Open {
chat: String,
},
Read {
chat: String,
#[arg(short, long, default_value_t = 50)]
limit: usize,
},
Send {
chat: String,
message: String,
#[arg(long)]
ttl: Option<u64>,
#[arg(long, value_name = "UNIX_SECONDS")]
expires_at: Option<u64>,
},
Seen {
chat: String,
message_ids: Vec<String>,
},
Delete {
chat: String,
},
Ttl {
chat: String,
seconds: Option<u64>,
},
Mute {
chat: String,
muted: bool,
},
MentionNotifications {
chat: String,
enabled: Option<bool>,
},
}
#[derive(Subcommand)]
enum InviteCommands {
Create {
#[arg(long)]
full: bool,
},
Group {
group: String,
#[arg(long)]
max_uses: Option<u32>,
#[arg(long, default_value_t = 2592000)]
expires_in: u64,
},
Revoke {
group: String,
invite_id: String,
},
Accept {
invite: String,
},
}
#[derive(Subcommand)]
enum LinkCommands {
Create,
Accept {
invite: String,
#[arg(long)]
include_message_history: bool,
},
}
#[derive(Subcommand)]
enum GroupCommands {
Create {
name: String,
members: Vec<String>,
},
List,
Show {
group: String,
},
Send {
group: String,
message: String,
},
Read {
group: String,
#[arg(short, long, default_value_t = 50)]
limit: usize,
},
Add {
group: String,
members: Vec<String>,
},
Remove {
group: String,
members: Vec<String>,
},
AddAdmin {
group: String,
member: String,
},
RemoveAdmin {
group: String,
member: String,
},
Rename {
group: String,
name: String,
},
React {
group: String,
message_id: String,
emoji: String,
},
Delete {
group: String,
},
}
#[derive(Subcommand)]
enum RelayCommands {
List,
Add { url: String },
Remove { url: String },
Set { urls: Vec<String> },
Reset,
}
#[derive(Subcommand)]
enum PrivacyCommands {
MessageDeletion { mode: UnknownUsersMode },
UnknownUsers { mode: UnknownUsersMode },
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum UnknownUsersMode {
Allow,
Block,
}
#[derive(Clone, Debug, Serialize, serde::Deserialize)]
struct AccountBundle {
owner_nsec: Option<String>,
owner_pubkey_hex: String,
device_nsec: String,
}
#[derive(Default)]
struct CliReconciler {
data_dir: PathBuf,
updates: Mutex<Vec<AppUpdate>>,
}
struct ReconcilerHandle(Arc<CliReconciler>);
impl AppReconciler for ReconcilerHandle {
fn reconcile(&self, update: AppUpdate) {
if let AppUpdate::PersistAccountBundle {
owner_nsec,
owner_pubkey_hex,
device_nsec,
..
} = &update
{
let bundle = AccountBundle {
owner_nsec: owner_nsec.clone(),
owner_pubkey_hex: owner_pubkey_hex.clone(),
device_nsec: device_nsec.clone(),
};
let _ = write_account_bundle(&self.0.data_dir, &bundle);
}
if let Ok(mut updates) = self.0.updates.lock() {
updates.push(update);
}
}
}
struct CliApp {
app: Arc<FfiApp>,
reconciler: Arc<CliReconciler>,
}
struct CliNearbyObserver;
impl DesktopNearbyObserver for CliNearbyObserver {
fn desktop_nearby_changed(&self, _snapshot: DesktopNearbySnapshot) {}
}
#[derive(Serialize)]
struct Envelope<T: Serialize> {
status: &'static str,
command: String,
data: T,
}
#[derive(Serialize)]
struct ErrorEnvelope {
status: &'static str,
error: String,
}
fn main() {
let cli = Cli::parse();
let json_output = cli.json;
if let Err(error) = run(cli) {
if json_output {
println!(
"{}",
serde_json::to_string(&ErrorEnvelope {
status: "error",
error: format!("{error:#}"),
})
.unwrap()
);
} else {
eprintln!("{error:#}");
}
std::process::exit(1);
}
}
impl CliApp {
fn open(data_dir: &Path) -> Result<Self> {
let app = FfiApp::new(
data_dir.to_string_lossy().to_string(),
String::new(),
env!("IRIS_APP_VERSION").to_string(),
);
let reconciler = Arc::new(CliReconciler {
data_dir: data_dir.to_path_buf(),
updates: Mutex::new(Vec::new()),
});
app.listen_for_updates(Box::new(ReconcilerHandle(reconciler.clone())));
fail_on_toast(&app.state())?;
let cli_app = Self { app, reconciler };
if let Some(bundle) = read_account_bundle(data_dir)? {
cli_app.dispatch_and_wait(
AppAction::RestoreAccountBundle {
owner_nsec: bundle.owner_nsec,
owner_pubkey_hex: bundle.owner_pubkey_hex,
device_nsec: bundle.device_nsec,
},
Duration::from_secs(3),
)?;
cli_app.wait_for_restored_account(Duration::from_secs(6))?;
}
Ok(cli_app)
}
fn dispatch_and_wait(&self, action: AppAction, timeout: Duration) -> Result<AppState> {
let before = self.app.state().rev;
self.app.dispatch(action);
self.wait_for_quiet_after(before, timeout, false)
}
fn dispatch_and_wait_network(&self, action: AppAction, timeout: Duration) -> Result<AppState> {
let before = self.app.state().rev;
self.app.dispatch(action);
self.wait_for_quiet_after(before, timeout, true)
}
fn wait_for_network_runtime_ready(
&self,
timeout: Duration,
require_protocol_idle: bool,
) -> Result<AppState> {
let started = Instant::now();
let settle_for = if timeout <= Duration::from_millis(750) {
Duration::ZERO
} else {
Duration::from_millis(750)
};
let mut ready_since: Option<Instant> = None;
while started.elapsed() < timeout {
let state = self.app.state();
fail_on_toast(&state)?;
if self.network_runtime_ready(&state, require_protocol_idle) {
let since = *ready_since.get_or_insert_with(Instant::now);
if since.elapsed() >= settle_for {
return Ok(state);
}
} else {
ready_since = None;
}
thread::sleep(Duration::from_millis(100));
}
anyhow::bail!(
"Timed out waiting for message-server sync. support={}",
self.app.export_support_bundle_json()
)
}
fn network_runtime_ready(&self, state: &AppState, require_protocol_idle: bool) -> bool {
if is_busy(state) {
return false;
}
if require_protocol_idle && state.busy.syncing_network {
return false;
}
let Ok(bundle) = serde_json::from_str::<Value>(&self.app.export_support_bundle_json())
else {
return false;
};
let bundle_relay_count = bundle
.get("relay_urls")
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or_else(|| state.preferences.nostr_relay_urls.len());
if state.preferences.nostr_relay_urls.is_empty() && bundle_relay_count == 0 {
return true;
}
if require_protocol_idle && has_pending_runtime_publishes(state) {
return false;
}
if require_protocol_idle && has_pending_relay_transport_publishes(&bundle) {
return false;
}
if state.preferences.nostr_relay_urls.is_empty() {
return true;
}
let relay_connected = bundle
.pointer("/relay_transport/connected_relay_count")
.and_then(Value::as_u64)
.unwrap_or(0)
> 0;
let subscription = bundle
.get("protocol_subscription")
.cloned()
.unwrap_or(Value::Null);
if !relay_connected {
return false;
}
let refresh_in_flight = subscription
.get("refresh_in_flight")
.and_then(Value::as_bool)
.unwrap_or(false);
let refresh_dirty = subscription
.get("refresh_dirty")
.and_then(Value::as_bool)
.unwrap_or(false);
let desired = subscription
.get("desired_plan")
.cloned()
.unwrap_or(Value::Null);
let applied = subscription
.get("applied_plan")
.cloned()
.unwrap_or(Value::Null);
let applying = subscription
.get("applying_plan")
.cloned()
.unwrap_or(Value::Null);
if require_protocol_idle && state.account.is_some() && desired.is_null() {
return false;
}
if !require_protocol_idle {
return desired.is_null() || desired == applied || desired == applying;
}
if refresh_in_flight || refresh_dirty || desired != applied {
return false;
}
let protocol_fetch_in_flight = subscription
.get("protocol_fetch_in_flight")
.and_then(Value::as_bool)
.unwrap_or(false);
let author_backfill_in_flight = subscription
.get("author_backfill_in_flight")
.and_then(Value::as_u64)
.unwrap_or(0);
if protocol_fetch_in_flight || author_backfill_in_flight > 0 {
return false;
}
if require_protocol_idle {
if has_delivery_blocking_protocol_work(&bundle) {
return false;
}
}
true
}
fn wait_for_quiet_after(
&self,
before_rev: u64,
timeout: Duration,
include_network_sync: bool,
) -> Result<AppState> {
let started = Instant::now();
let mut saw_change = false;
let mut last_rev = before_rev;
let mut stable_since = Instant::now();
while started.elapsed() < timeout {
let state = self.app.state();
if state.rev != last_rev {
saw_change = state.rev != before_rev || saw_change;
last_rev = state.rev;
stable_since = Instant::now();
}
let busy = if include_network_sync {
is_busy_or_syncing(&state)
} else {
is_busy(&state)
};
if saw_change && !busy && stable_since.elapsed() >= Duration::from_millis(80) {
return Ok(state);
}
thread::sleep(Duration::from_millis(20));
}
Ok(self.app.state())
}
fn wait_for_update_count(&self, before_count: usize, timeout: Duration) {
let started = Instant::now();
while started.elapsed() < timeout {
let count = self
.reconciler
.updates
.lock()
.map(|updates| updates.len())
.unwrap_or(before_count);
if count > before_count {
return;
}
thread::sleep(Duration::from_millis(20));
}
}
fn wait_for_current_chat(
&self,
expected_chat_id: &str,
timeout: Duration,
) -> Result<CurrentChatSnapshot> {
let started = Instant::now();
while started.elapsed() < timeout {
let state = self.app.state();
fail_on_toast(&state)?;
if let Some(chat) = state.current_chat {
if chat.chat_id.eq_ignore_ascii_case(expected_chat_id) {
return Ok(chat);
}
}
thread::sleep(Duration::from_millis(20));
}
anyhow::bail!("Timed out opening chat {expected_chat_id}.")
}
fn wait_for_restored_account(&self, timeout: Duration) -> Result<AppState> {
let started = Instant::now();
while started.elapsed() < timeout {
let state = self.app.state();
fail_on_toast(&state)?;
if state.account.is_some() && !state.busy.restoring_session {
return Ok(state);
}
thread::sleep(Duration::from_millis(20));
}
let state = self.app.state();
fail_on_toast(&state)?;
anyhow::bail!("Timed out restoring account bundle.")
}
}
fn handle_command(cli: &CliApp, data_dir: &Path, command: Commands) -> Result<Value> {
match command {
Commands::Account(command) => handle_account_top_command(cli, data_dir, command),
Commands::Messages(command) => handle_message_top_command(cli, command),
Commands::Contact(command) => iris_contacts::handle(cli, command),
Commands::Groups(GroupTopCommands::Group(command)) => handle_group_command(cli, command),
Commands::InvitesAndDevices(command) => handle_invite_device_command(cli, command),
Commands::MessageServers(MessageServerTopCommands::Relay(command)) => {
handle_relay_command(cli, data_dir, command)
}
Commands::Privacy(command) => handle_privacy_command(cli, command),
Commands::Maintenance(command) => handle_maintenance_command(cli, command),
}
}
fn handle_account_top_command(
cli: &CliApp,
data_dir: &Path,
command: AccountTopCommands,
) -> Result<Value> {
match command {
AccountTopCommands::Login { secret_key } | AccountTopCommands::Restore { secret_key } => {
restore_account(cli, &secret_key)
}
AccountTopCommands::Logout => {
remove_account_bundle(data_dir)?;
cli.dispatch_and_wait(AppAction::Logout, Duration::from_secs(2))?;
Ok(json!({ "logged_out": true }))
}
AccountTopCommands::Whoami => Ok(account_json(&require_account(&cli.app.state())?)),
AccountTopCommands::Account(command) => handle_account_command(cli, data_dir, command),
}
}
fn handle_account_command(
cli: &CliApp,
data_dir: &Path,
command: AccountCommands,
) -> Result<Value> {
match command {
AccountCommands::Create { name } => {
cli.dispatch_and_wait_network(
AppAction::CreateAccount { name },
Duration::from_secs(8),
)?;
let _ = cli.wait_for_network_runtime_ready(Duration::from_secs(10), true)?;
let state = cli.app.state();
fail_on_toast(&state)?;
Ok(account_json(&require_account(&state)?))
}
AccountCommands::Restore { secret_key } => restore_account(cli, &secret_key),
AccountCommands::Profile {
name,
picture_url,
about,
} => {
let current = require_account(&cli.app.state())?;
let editing = name.is_some() || picture_url.is_some() || about.is_some();
if editing {
let state = cli.dispatch_and_wait(
AppAction::UpdateProfileMetadata {
name: name.unwrap_or(current.display_name),
picture_url: picture_url.or(current.picture_url),
about: about.or(current.about),
},
Duration::from_secs(3),
)?;
fail_on_toast(&state)?;
}
let account = require_account(&cli.app.state())?;
Ok(json!({
"profile": {
"name": account.display_name,
"picture_url": account.picture_url,
"about": account.about,
},
"edited": editing,
"local_save": if editing { "requested" } else { "not_requested" },
"network_publication": "not_verified",
}))
}
AccountCommands::Bundle => {
let bundle = read_account_bundle(data_dir)?.context("No saved account bundle.")?;
Ok(json!({
"owner_pubkey_hex": bundle.owner_pubkey_hex,
"has_owner_secret": bundle.owner_nsec.is_some(),
"has_device_secret": !bundle.device_nsec.is_empty(),
}))
}
}
}
fn handle_chat_command(cli: &CliApp, command: ChatCommands) -> Result<Value> {
match command {
ChatCommands::List => Ok(json!({ "chats": chat_list_json(&cli.app.state()) })),
ChatCommands::Create { user_id } => create_chat(cli, &user_id),
ChatCommands::Open { chat } => {
read_chat(cli, &chat).map(|chat| chat_json(&chat, usize::MAX))
}
ChatCommands::Read { chat, limit } => {
read_chat(cli, &chat).map(|chat| chat_json(&chat, limit))
}
ChatCommands::Send {
chat,
message,
ttl,
expires_at,
} => {
let expires_at = message_expiration(ttl, expires_at)?;
send_message(cli, &chat, &message, expires_at)
}
ChatCommands::Seen { chat, message_ids } => mark_seen(cli, &chat, message_ids),
ChatCommands::MentionNotifications { chat, enabled } => {
iris_message_actions::mention_notifications(cli, &chat, enabled)
}
ChatCommands::Delete { chat } => {
let chat_id = resolve_chat_id(&cli.app.state(), &chat)?;
cli.dispatch_and_wait(
AppAction::DeleteChat {
chat_id: chat_id.clone(),
},
Duration::from_secs(2),
)?;
Ok(json!({ "chat_id": chat_id, "deleted": true }))
}
ChatCommands::Ttl { chat, seconds } => {
let chat_id = resolve_chat_id(&cli.app.state(), &chat)?;
cli.dispatch_and_wait(
AppAction::SetChatMessageTtl {
chat_id: chat_id.clone(),
ttl_seconds: seconds,
},
Duration::from_secs(2),
)?;
Ok(json!({ "chat_id": chat_id, "message_ttl_seconds": seconds }))
}
ChatCommands::Mute { chat, muted } => {
let chat_id = resolve_chat_id(&cli.app.state(), &chat)?;
cli.dispatch_and_wait(
AppAction::SetChatMuted {
chat_id: chat_id.clone(),
muted,
},
Duration::from_secs(2),
)?;
Ok(json!({ "chat_id": chat_id, "muted": muted }))
}
}
}
include!("iris_invites.rs");
fn handle_group_command(cli: &CliApp, command: GroupCommands) -> Result<Value> {
match command {
GroupCommands::Create { name, members } => {
cli.dispatch_and_wait_network(
AppAction::CreateGroup {
name,
member_inputs: members,
},
Duration::from_secs(8),
)?;
let _ = cli.wait_for_network_runtime_ready(Duration::from_secs(8), true)?;
let state = cli.app.state();
fail_on_toast(&state)?;
Ok(json!({
"groups": group_list_json(&state),
"current_chat": state.current_chat.as_ref().map(chat_summary_json),
}))
}
GroupCommands::List => Ok(json!({ "groups": group_list_json(&cli.app.state()) })),
GroupCommands::Show { group } => show_group(cli, &group),
GroupCommands::Send { group, message } => {
send_message(cli, &normalize_group_chat(&group), &message, None)
}
GroupCommands::Read { group, limit } => {
read_chat(cli, &normalize_group_chat(&group)).map(|chat| chat_json(&chat, limit))
}
GroupCommands::Add { group, members } => {
let group_id = resolve_group_id(&cli.app.state(), &group)?;
cli.dispatch_and_wait(
AppAction::AddGroupMembers {
group_id: group_id.clone(),
member_inputs: members,
},
Duration::from_secs(3),
)?;
show_group(cli, &group_id)
}
GroupCommands::Remove { group, members } => {
let group_id = resolve_group_id(&cli.app.state(), &group)?;
if members.is_empty() {
anyhow::bail!("At least one member is required.");
}
for member in members {
let owner_pubkey_hex = owner_input_to_hex(&member)?;
cli.dispatch_and_wait(
AppAction::RemoveGroupMember {
group_id: group_id.clone(),
owner_pubkey_hex,
},
Duration::from_secs(3),
)?;
}
show_group(cli, &group_id)
}
GroupCommands::AddAdmin { group, member } => set_group_admin(cli, &group, &member, true),
GroupCommands::RemoveAdmin { group, member } => {
set_group_admin(cli, &group, &member, false)
}
GroupCommands::Rename { group, name } => {
let group_id = resolve_group_id(&cli.app.state(), &group)?;
cli.dispatch_and_wait(
AppAction::UpdateGroupName {
group_id: group_id.clone(),
name,
},
Duration::from_secs(3),
)?;
show_group(cli, &group_id)
}
GroupCommands::React {
group,
message_id,
emoji,
} => react(cli, &normalize_group_chat(&group), &message_id, &emoji),
GroupCommands::Delete { group } => {
let group_id = resolve_group_id(&cli.app.state(), &group)?;
let chat_id = normalize_group_chat(&group_id);
cli.dispatch_and_wait(
AppAction::DeleteChat {
chat_id: chat_id.clone(),
},
Duration::from_secs(2),
)?;
Ok(json!({ "chat_id": chat_id, "group_id": group_id, "deleted": true }))
}
}
}
fn handle_relay_command(cli: &CliApp, data_dir: &Path, command: RelayCommands) -> Result<Value> {
match command {
RelayCommands::List => {
Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
}
RelayCommands::Add { url } => {
cli.dispatch_and_wait(
AppAction::AddNostrRelay { relay_url: url },
Duration::from_secs(2),
)?;
wait_after_relay_change(cli, data_dir)?;
Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
}
RelayCommands::Remove { url } => {
cli.dispatch_and_wait(
AppAction::RemoveNostrRelay { relay_url: url },
Duration::from_secs(2),
)?;
wait_after_relay_change(cli, data_dir)?;
Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
}
RelayCommands::Reset => {
cli.dispatch_and_wait(AppAction::ResetNostrRelays, Duration::from_secs(2))?;
wait_after_relay_change(cli, data_dir)?;
Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
}
RelayCommands::Set { urls } => {
cli.dispatch_and_wait(
AppAction::SetNostrRelays { relay_urls: urls },
Duration::from_secs(3),
)?;
wait_after_relay_change(cli, data_dir)?;
Ok(json!({ "message_servers": cli.app.state().preferences.nostr_relay_urls }))
}
}
}
fn wait_after_relay_change(cli: &CliApp, data_dir: &Path) -> Result<()> {
let mut state = cli.app.state();
if state.account.is_none() && read_account_bundle(data_dir)?.is_some() {
state = cli.wait_for_restored_account(Duration::from_secs(5))?;
}
if state.account.is_some() && !state.preferences.nostr_relay_urls.is_empty() {
let _ = cli.wait_for_network_runtime_ready(Duration::from_secs(12), true);
}
Ok(())
}
fn handle_privacy_command(cli: &CliApp, command: PrivacyCommands) -> Result<Value> {
match command {
PrivacyCommands::MessageDeletion { mode } => {
let enabled = matches!(mode, UnknownUsersMode::Allow);
cli.dispatch_and_wait(
AppAction::SetAllowMessageDeletionByOthers { enabled },
Duration::from_secs(2),
)?;
Ok(
json!({"allow_message_deletion_by_others":cli.app.state().preferences.allow_message_deletion_by_others}),
)
}
PrivacyCommands::UnknownUsers { mode } => {
let accept = matches!(mode, UnknownUsersMode::Allow);
cli.dispatch_and_wait(
AppAction::SetAcceptUnknownDirectMessages { enabled: accept },
Duration::from_secs(2),
)?;
Ok(json!({
"unknown_users": if accept { "allow" } else { "block" },
"accept_unknown_direct_messages": cli.app.state().preferences.accept_unknown_direct_messages,
}))
}
}
}
fn handle_maintenance_command(cli: &CliApp, command: MaintenanceTopCommands) -> Result<Value> {
match command {
MaintenanceTopCommands::Service(_) => {
anyhow::bail!("Service commands require the service entry point")
}
MaintenanceTopCommands::State => Ok(state_json(&cli.app.state())),
MaintenanceTopCommands::Debug { wait_ms } => {
if wait_ms > 0 {
let timeout = Duration::from_millis(wait_ms.max(100));
let _ = cli.dispatch_and_wait_network(AppAction::AppForegrounded, timeout)?;
let _ = cli.wait_for_network_runtime_ready(timeout, true)?;
}
serde_json::from_str(&cli.app.export_support_bundle_json())
.context("parse support bundle json")
}
MaintenanceTopCommands::Sync { wait_ms } => {
let timeout = Duration::from_millis(wait_ms.max(100));
let _ = cli.dispatch_and_wait_network(AppAction::AppForegrounded, timeout)?;
let state = cli.wait_for_network_runtime_ready(timeout, true)?;
Ok(state_json(&state))
}
MaintenanceTopCommands::Update(cmd) => {
run_iris_update(cmd, false)?;
Ok(json!({ "ok": true }))
}
}
}
fn restore_account(cli: &CliApp, secret_key: &str) -> Result<Value> {
let before = cli
.reconciler
.updates
.lock()
.map(|updates| updates.len())
.unwrap_or(0);
cli.dispatch_and_wait(
AppAction::RestoreSession {
owner_nsec: secret_key.to_string(),
},
Duration::from_secs(4),
)?;
cli.wait_for_update_count(before, Duration::from_secs(2));
let state = cli.app.state();
fail_on_toast(&state)?;
Ok(account_json(&require_account(&state)?))
}
fn create_chat(cli: &CliApp, user_id: &str) -> Result<Value> {
let chat_id = owner_input_to_hex(user_id)?;
cli.dispatch_and_wait(
AppAction::CreateChat {
peer_input: user_id.to_string(),
},
Duration::from_secs(3),
)?;
let chat = cli.wait_for_current_chat(&chat_id, Duration::from_secs(3))?;
Ok(chat_json(&chat, usize::MAX))
}
fn read_chat(cli: &CliApp, chat: &str) -> Result<CurrentChatSnapshot> {
let chat_id = chat_action_input(&cli.app.state(), chat);
cli.app
.chat_snapshot(chat_id, 80)
.context("Chat not found.")
}
fn send_message(
cli: &CliApp,
chat: &str,
message: &str,
expires_at_secs: Option<u64>,
) -> Result<Value> {
let chat_id = chat_action_input(&cli.app.state(), chat);
let action = if let Some(expires_at_secs) = expires_at_secs {
AppAction::SendDisappearingMessage {
chat_id: chat_id.clone(),
text: message.to_string(),
expires_at_secs,
}
} else {
AppAction::SendMessage {
chat_id: chat_id.clone(),
text: message.to_string(),
}
};
let state = cli.dispatch_and_wait(action, Duration::from_secs(8))?;
fail_on_toast(&state)?;
let started = Instant::now();
while started.elapsed() < Duration::from_secs(30) {
let state = cli.app.state();
fail_on_toast(&state)?;
if let Some(current) = state
.current_chat
.as_ref()
.filter(|current| current.chat_id == chat_id)
{
if let Some(sent) = current
.messages
.iter()
.rev()
.find(|item| item.is_outgoing && item.body == message)
.cloned()
{
wait_after_send_network_idle(cli)?;
return Ok(message_json(&sent));
}
}
if let Some(sent) =
latest_outgoing_message_row(&cli.reconciler.data_dir, &chat_id, message)?
{
wait_after_send_network_idle(cli)?;
return Ok(sent);
}
thread::sleep(Duration::from_millis(50));
}
let sent = latest_outgoing_message_row(&cli.reconciler.data_dir, &chat_id, message)?
.context("Message was not added to the chat.")?;
wait_after_send_network_idle(cli)?;
Ok(sent)
}
fn wait_after_send_network_idle(cli: &CliApp) -> Result<()> {
if !cli.app.state().preferences.nostr_relay_urls.is_empty() {
let _ = cli.wait_for_network_runtime_ready(Duration::from_secs(8), false)?;
}
Ok(())
}
fn react(cli: &CliApp, chat: &str, message_id: &str, emoji: &str) -> Result<Value> {
let chat_id = chat_action_input(&cli.app.state(), chat);
cli.dispatch_and_wait(
AppAction::ToggleReaction {
chat_id: chat_id.clone(),
message_id: message_id.to_string(),
emoji: emoji.to_string(),
},
Duration::from_secs(2),
)?;
let current = read_chat(cli, &chat_id)?;
let message = current
.messages
.iter()
.find(|message| message.id == message_id)
.context("Message not found.")?;
Ok(message_json(message))
}
fn typing(cli: &CliApp, chat: &str, stop: bool) -> Result<Value> {
let chat_id = chat_action_input(&cli.app.state(), chat);
cli.dispatch_and_wait(
AppAction::SetTypingIndicatorsEnabled { enabled: true },
Duration::from_secs(1),
)?;
let action = if stop {
AppAction::StopTyping {
chat_id: chat_id.clone(),
}
} else {
AppAction::SendTyping {
chat_id: chat_id.clone(),
}
};
cli.dispatch_and_wait(action, Duration::from_secs(2))?;
Ok(json!({ "chat_id": chat_id, "typing": !stop }))
}
fn receipt(
cli: &CliApp,
chat: &str,
receipt_type: &str,
message_ids: Vec<String>,
) -> Result<Value> {
let receipt_type = receipt_type.trim().to_ascii_lowercase();
if receipt_type != "delivered" && receipt_type != "seen" {
anyhow::bail!("Receipt type must be delivered or seen.");
}
if receipt_type == "seen" {
return mark_seen(cli, chat, message_ids);
}
let chat_id = chat_action_input(&cli.app.state(), chat);
cli.dispatch_and_wait(
AppAction::SendReceipt {
chat_id: chat_id.clone(),
receipt_type: receipt_type.clone(),
message_ids: message_ids.clone(),
},
Duration::from_secs(2),
)?;
Ok(json!({
"chat_id": chat_id,
"receipt_type": receipt_type,
"message_ids": message_ids,
}))
}
fn mark_seen(cli: &CliApp, chat: &str, message_ids: Vec<String>) -> Result<Value> {
let current = read_chat(cli, chat)?;
let ids = if message_ids.is_empty() {
current
.messages
.iter()
.filter(|message| !message.is_outgoing)
.map(|message| message.id.clone())
.collect()
} else {
message_ids
};
cli.dispatch_and_wait(
AppAction::MarkMessagesSeen {
chat_id: current.chat_id.clone(),
message_ids: ids.clone(),
},
Duration::from_secs(2),
)?;
Ok(json!({ "chat_id": current.chat_id, "message_ids": ids }))
}
fn message_expiration(ttl: Option<u64>, expires_at: Option<u64>) -> Result<Option<u64>> {
match (ttl, expires_at) {
(Some(_), Some(_)) => anyhow::bail!("Use either --ttl or --expires-at, not both."),
(Some(ttl), None) if ttl > 0 => Ok(Some(now_secs()?.saturating_add(ttl))),
(Some(_), None) => Ok(None),
(None, Some(expires_at)) if expires_at > 0 => Ok(Some(expires_at)),
(None, Some(_)) | (None, None) => Ok(None),
}
}
fn now_secs() -> Result<u64> {
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
}
fn show_group(cli: &CliApp, group: &str) -> Result<Value> {
let group_id = resolve_group_id(&cli.app.state(), group)?;
cli.dispatch_and_wait(
AppAction::PushScreen {
screen: iris_chat_core::Screen::GroupDetails {
group_id: group_id.clone(),
},
},
Duration::from_secs(2),
)?;
let state = cli.app.state();
let details = state.group_details.context("No group details available.")?;
Ok(group_json(&details))
}
fn set_group_admin(cli: &CliApp, group: &str, member: &str, is_admin: bool) -> Result<Value> {
let group_id = resolve_group_id(&cli.app.state(), group)?;
let owner_pubkey_hex = owner_input_to_hex(member)?;
cli.dispatch_and_wait(
AppAction::SetGroupAdmin {
group_id: group_id.clone(),
owner_pubkey_hex,
is_admin,
},
Duration::from_secs(3),
)?;
show_group(cli, &group_id)
}
fn owner_input_to_hex(input: &str) -> Result<String> {
let hex = iris_chat_core::peer_input_to_hex(input.to_string());
if hex.is_empty() {
anyhow::bail!("Invalid user ID: {input}");
}
Ok(hex)
}
fn search_messages(data_dir: &Path, query: &str, limit: usize) -> Result<Value> {
let conn = open_existing_db(data_dir)?;
let pattern = format!("%{query}%");
let mut stmt = conn.prepare(
"SELECT chat_id, id, body, is_outgoing, created_at_secs, delivery
FROM messages
WHERE body LIKE ?1
ORDER BY created_at_secs DESC, rowid DESC
LIMIT ?2",
)?;
let rows = stmt.query_map((&pattern, limit as i64), |row| {
Ok(json!({
"chat_id": row.get::<_, String>(0)?,
"id": row.get::<_, String>(1)?,
"body": row.get::<_, String>(2)?,
"is_outgoing": row.get::<_, i64>(3)? != 0,
"created_at_secs": row.get::<_, i64>(4)?,
"delivery": row.get::<_, String>(5)?,
}))
})?;
let mut messages = Vec::new();
for row in rows {
messages.push(row?);
}
Ok(json!({ "messages": messages }))
}
fn tail_messages(data_dir: &Path, limit: usize, chat: Option<&str>) -> Result<Value> {
let conn = open_existing_db(data_dir)?;
let sql = match chat {
Some(_) => {
"SELECT chat_id, id, body, is_outgoing, created_at_secs, delivery
FROM messages
WHERE chat_id = ?1
ORDER BY created_at_secs DESC, rowid DESC
LIMIT ?2"
}
None => {
"SELECT chat_id, id, body, is_outgoing, created_at_secs, delivery
FROM messages
ORDER BY created_at_secs DESC, rowid DESC
LIMIT ?1"
}
};
let mut stmt = conn.prepare(sql)?;
let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<Value> {
Ok(json!({
"chat_id": row.get::<_, String>(0)?,
"id": row.get::<_, String>(1)?,
"body": row.get::<_, String>(2)?,
"is_outgoing": row.get::<_, i64>(3)? != 0,
"created_at_secs": row.get::<_, i64>(4)?,
"delivery": row.get::<_, String>(5)?,
}))
};
let mut messages = Vec::new();
match chat {
Some(chat_id) => {
let rows = stmt.query_map((chat_id, limit as i64), map_row)?;
for row in rows {
messages.push(row?);
}
}
None => {
let rows = stmt.query_map([limit as i64], map_row)?;
for row in rows {
messages.push(row?);
}
}
}
messages.reverse();
Ok(json!({ "messages": messages }))
}
fn listen(data_dir: &Path, chat: Option<&str>, interval_ms: u64, nearby_lan: bool) -> Result<()> {
let interval = Duration::from_millis(interval_ms.max(100));
let cli = CliApp::open(data_dir)?;
let state = cli.app.state();
fail_on_toast(&state)?;
let owner = require_account(&state)?.public_key_hex;
let chat_filter = normalize_chat_filter(&state, chat);
let mut seen = latest_message_keys(data_dir, chat_filter.as_deref(), &owner)?;
let _nearby = if nearby_lan {
let service = FfiDesktopNearby::new(cli.app.clone(), Box::new(CliNearbyObserver));
let name = state
.account
.as_ref()
.map(|account| account.display_name.trim())
.filter(|name| !name.is_empty())
.unwrap_or("Iris")
.to_string();
service.start(name);
Some(service)
} else {
None
};
let _ = cli.dispatch_and_wait_network(AppAction::AppForegrounded, Duration::from_secs(8))?;
let state = cli.wait_for_network_runtime_ready(Duration::from_secs(55), true)?;
fail_on_toast(&state)?;
print_stream_envelope(
"listen",
json!({
"ready": true,
"chat": chat_filter.clone(),
"network": true,
"nearby_lan": nearby_lan,
}),
)?;
loop {
thread::sleep(interval);
stream_new_messages(data_dir, chat_filter.as_deref(), &mut seen, &owner)?;
}
}
fn follow_messages(
data_dir: &Path,
chat: Option<&str>,
interval_ms: u64,
command: &str,
) -> Result<()> {
let interval = Duration::from_millis(interval_ms.max(100));
let owner = read_account_bundle(data_dir)?
.ok_or_else(|| anyhow::anyhow!("Create or restore a profile first."))?
.owner_pubkey_hex;
let mut seen = latest_message_keys(data_dir, chat, &owner)?;
print_stream_envelope(
command,
json!({ "ready": true, "chat": chat, "network": false }),
)?;
loop {
thread::sleep(interval);
stream_new_messages(data_dir, chat, &mut seen, &owner)?;
}
}
fn print_stream_envelope(command: &str, data: Value) -> Result<()> {
println!(
"{}",
serde_json::to_string(&Envelope {
status: "ok",
command: command.to_string(),
data,
})?
);
std::io::stdout().flush()?;
Ok(())
}
fn normalize_chat_filter(state: &AppState, chat: Option<&str>) -> Option<String> {
chat.map(|input| chat_action_input(state, input))
}
fn print_output(json_output: bool, command: &str, data: Value) -> Result<()> {
if json_output {
println!(
"{}",
serde_json::to_string(&Envelope {
status: "ok",
command: command.to_string(),
data,
})?
);
} else if data.is_object() || data.is_array() {
println!("{}", serde_json::to_string_pretty(&data)?);
} else {
println!("{data}");
}
Ok(())
}
fn should_spawn_background_sync(state: &AppState, data: &Value) -> bool {
if data.get("private_contact_sync").and_then(Value::as_str) == Some("queued") {
return true;
}
let public_follow_pending = data.get("contact_identity").is_some()
&& data.get("network_publication").and_then(Value::as_str) == Some("not_verified");
let message_pending = data
.get("is_outgoing")
.and_then(Value::as_bool)
.unwrap_or(false)
&& data.get("id").and_then(Value::as_str).is_some()
&& data
.get("delivery")
.and_then(Value::as_str)
.is_some_and(|delivery| matches!(delivery, "queued" | "pending"));
!state.preferences.nostr_relay_urls.is_empty() && (public_follow_pending || message_pending)
}
fn spawn_background_sync(data_dir: &Path) {
let Ok(exe) = std::env::current_exe() else {
return;
};
#[cfg(unix)]
{
let script = r#"sleep 0.2
for _ in 1 2 3; do
"$1" --data-dir "$2" sync --wait-ms 8000 >/dev/null 2>&1 && exit 0
sleep 0.5
done
exit 0
"#;
let _ = std::process::Command::new("sh")
.arg("-c")
.arg(script)
.arg("iris-background-sync")
.arg(exe)
.arg(data_dir)
.env("IRIS_CLI_BACKGROUND_SYNC", "1")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
#[cfg(not(unix))]
let _ = std::process::Command::new(exe)
.arg("--data-dir")
.arg(data_dir)
.args(["sync", "--wait-ms", "8000"])
.env("IRIS_CLI_BACKGROUND_SYNC", "1")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
fn command_name(command: &Commands) -> &'static str {
match command {
Commands::Account(command) => match command {
AccountTopCommands::Login { .. } => "login",
AccountTopCommands::Restore { .. } => "restore",
AccountTopCommands::Logout => "logout",
AccountTopCommands::Whoami => "whoami",
AccountTopCommands::Account(_) => "account",
},
Commands::Messages(command) => match command {
MessageTopCommands::Edit { .. } => "edit",
MessageTopCommands::EditHistory { .. } => "edit-history",
MessageTopCommands::Delete { .. } => "delete",
MessageTopCommands::Chat(_) => "chat",
MessageTopCommands::Send { .. } => "send",
MessageTopCommands::Read { .. } => "read",
MessageTopCommands::Seen { .. } => "seen",
MessageTopCommands::React { .. } => "react",
MessageTopCommands::Typing { .. } => "typing",
MessageTopCommands::Receipt { .. } => "receipt",
MessageTopCommands::Search { .. } => "search",
MessageTopCommands::Tail { .. } => "tail",
MessageTopCommands::Listen { .. } => "listen",
},
Commands::Contact(_) => "contact",
Commands::Groups(_) => "group",
Commands::InvitesAndDevices(command) => match command {
InviteDeviceTopCommands::Invite(_) => "invite",
InviteDeviceTopCommands::Link(_) => "link",
},
Commands::MessageServers(_) => "relay",
Commands::Privacy(_) => "privacy",
Commands::Maintenance(command) => match command {
MaintenanceTopCommands::Service(_) => "service",
MaintenanceTopCommands::State => "state",
MaintenanceTopCommands::Debug { .. } => "debug",
MaintenanceTopCommands::Sync { .. } => "sync",
MaintenanceTopCommands::Update(_) => "update",
},
}
}
fn account_json(account: &iris_chat_core::AccountSnapshot) -> Value {
json!({
"user_id": account.public_key_hex,
"npub": account.npub,
"name": account.display_name,
"device_id": account.device_public_key_hex,
"device_npub": account.device_npub,
"has_secret_key": account.has_owner_signing_authority,
"device_state": authorization_state(&account.authorization_state),
})
}
fn state_json(state: &AppState) -> Value {
json!({
"account": state.account.as_ref().map(account_json),
"chats": chat_list_json(state),
"groups": group_list_json(state),
"current_chat": state.current_chat.as_ref().map(|chat| chat_json(chat, usize::MAX)),
"message_servers": state.preferences.nostr_relay_urls,
"accept_unknown_direct_messages": state.preferences.accept_unknown_direct_messages,
"toast": state.toast,
})
}
fn chat_list_json(state: &AppState) -> Vec<Value> {
state.chat_list.iter().map(thread_json).collect()
}
fn group_list_json(state: &AppState) -> Vec<Value> {
state
.chat_list
.iter()
.filter(|thread| matches!(thread.kind, ChatKind::Group))
.map(thread_json)
.collect()
}
fn thread_json(thread: &ChatThreadSnapshot) -> Value {
json!({
"chat_id": thread.chat_id,
"kind": chat_kind(&thread.kind),
"name": thread.display_name,
"subtitle": thread.subtitle,
"member_count": thread.member_count,
"last_message": thread.last_message_preview,
"last_message_at_secs": thread.last_message_at_secs,
"unread_count": thread.unread_count,
"muted": thread.is_muted,
"pinned": thread.is_pinned,
"social_connection": thread.social_connection.as_ref().map(iris_contacts::connection_json),
})
}
fn chat_summary_json(chat: &CurrentChatSnapshot) -> Value {
json!({
"chat_id": chat.chat_id,
"kind": chat_kind(&chat.kind),
"name": chat.display_name,
"group_id": chat.group_id,
"member_count": chat.member_count,
"message_count": chat.messages.len(),
"contact_identity": chat.contact_identity.as_ref().map(iris_contacts::identity_json),
"social_connection": chat.social_connection.as_ref().map(iris_contacts::connection_json),
"message_ttl_seconds": chat.message_ttl_seconds,
"muted": chat.is_muted,
})
}
fn chat_json(chat: &CurrentChatSnapshot, limit: usize) -> Value {
let skip = chat.messages.len().saturating_sub(limit);
json!({
"chat": chat_summary_json(chat),
"messages": chat.messages.iter().skip(skip).map(message_json).collect::<Vec<_>>(),
})
}
fn message_json(message: &ChatMessageSnapshot) -> Value {
json!({
"id": message.id,
"chat_id": message.chat_id,
"author": message.author,
"body": message.body,
"deleted_for_everyone": message.deleted_for_everyone,
"edit_history": message.edit_history,
"is_outgoing": message.is_outgoing,
"created_at_secs": message.created_at_secs,
"expires_at_secs": message.expires_at_secs,
"delivery": delivery(&message.delivery),
"source_event_id": message.source_event_id,
"recipient_deliveries": message.recipient_deliveries.iter().map(|item| {
json!({
"owner_pubkey_hex": item.owner_pubkey_hex,
"delivery": delivery(&item.delivery),
"updated_at_secs": item.updated_at_secs,
})
}).collect::<Vec<_>>(),
"delivery_trace": {
"outer_event_ids": message.delivery_trace.outer_event_ids.clone(),
"pending_relay_event_ids": message.delivery_trace.pending_relay_event_ids.clone(),
"queued_protocol_targets": message.delivery_trace.queued_protocol_targets.clone(),
"transport_channels": message.delivery_trace.transport_channels.clone(),
"last_transport_error": message.delivery_trace.last_transport_error.clone(),
},
"attachments": message.attachments.iter().map(|item| {
json!({
"filename": item.filename,
"nhash": item.nhash,
"url": item.htree_url,
})
}).collect::<Vec<_>>(),
"reactions": message.reactions.iter().map(|item| {
json!({
"emoji": item.emoji,
"count": item.count,
"reacted_by_me": item.reacted_by_me,
})
}).collect::<Vec<_>>(),
})
}
fn group_json(group: &GroupDetailsSnapshot) -> Value {
json!({
"group_id": group.group_id,
"name": group.name,
"picture_url": group.picture_url,
"can_manage": group.can_manage,
"muted": group.is_muted,
"revision": group.revision,
"members": group.members.iter().map(|member| {
json!({
"user_id": member.owner_pubkey_hex,
"name": member.display_name,
"npub": member.npub,
"admin": member.is_admin,
"creator": member.is_creator,
"me": member.is_local_owner,
})
}).collect::<Vec<_>>(),
})
}
fn require_account(state: &AppState) -> Result<iris_chat_core::AccountSnapshot> {
state
.account
.clone()
.context("Create or restore a profile first.")
}
fn fail_on_toast(state: &AppState) -> Result<()> {
fail_on_toast_except(state, &[])
}
fn fail_on_toast_except(state: &AppState, allowed: &[&str]) -> Result<()> {
if let Some(toast) = &state.toast {
if allowed.iter().any(|allowed| toast == allowed) {
return Ok(());
}
anyhow::bail!(toast.clone());
}
Ok(())
}
fn chat_action_input(state: &AppState, input: &str) -> String {
resolve_chat_id(state, input)
.or_else(|_| normalize_direct_chat_input(input))
.unwrap_or_else(|_| input.to_string())
}
fn normalize_direct_chat_input(input: &str) -> Result<String> {
Ok(PublicKey::parse(input.trim())?.to_hex())
}
fn resolve_chat_id(state: &AppState, input: &str) -> Result<String> {
if let Some(chat) = &state.current_chat {
if chat.chat_id == input || chat.group_id.as_deref() == Some(input) {
return Ok(chat.chat_id.clone());
}
}
state
.chat_list
.iter()
.find(|thread| {
thread.chat_id == input
|| thread.display_name.eq_ignore_ascii_case(input)
|| thread.subtitle.as_deref() == Some(input)
})
.map(|thread| thread.chat_id.clone())
.with_context(|| format!("Chat not found: {input}"))
}
fn resolve_group_id(state: &AppState, input: &str) -> Result<String> {
if let Some(group) = &state.group_details {
if group.group_id == input || group.name.eq_ignore_ascii_case(input) {
return Ok(group.group_id.clone());
}
}
state
.chat_list
.iter()
.find(|thread| {
matches!(thread.kind, ChatKind::Group)
&& (thread.chat_id == input
|| thread.chat_id == normalize_group_chat(input)
|| thread.display_name.eq_ignore_ascii_case(input))
})
.map(|thread| {
thread
.chat_id
.strip_prefix("group:")
.unwrap_or(&thread.chat_id)
.to_string()
})
.with_context(|| format!("Group not found: {input}"))
}
fn normalize_group_chat(group: &str) -> String {
if group.starts_with("group:") {
group.to_string()
} else {
format!("group:{group}")
}
}
fn is_busy(state: &AppState) -> bool {
let busy = &state.busy;
busy.creating_account
|| busy.restoring_session
|| busy.linking_device
|| busy.creating_chat
|| busy.creating_group
|| busy.sending_message
|| busy.updating_roster
|| busy.updating_group
|| busy.creating_invite
|| busy.accepting_invite
|| busy.uploading_attachment
}
fn is_busy_or_syncing(state: &AppState) -> bool {
is_busy(state) || state.busy.syncing_network || has_pending_runtime_publishes(state)
}
fn chat_kind(kind: &ChatKind) -> &'static str {
match kind {
ChatKind::Direct => "direct",
ChatKind::Group => "group",
}
}
fn delivery(delivery: &DeliveryState) -> &'static str {
match delivery {
DeliveryState::Queued => "queued",
DeliveryState::Pending => "pending",
DeliveryState::Sent => "sent",
DeliveryState::Received => "received",
DeliveryState::Seen => "seen",
DeliveryState::Failed => "failed",
}
}
fn authorization_state(state: &DeviceAuthorizationState) -> &'static str {
match state {
DeviceAuthorizationState::Authorized => "authorized",
DeviceAuthorizationState::AwaitingApproval => "awaiting_approval",
DeviceAuthorizationState::Revoked => "revoked",
}
}
fn account_bundle_path(data_dir: &Path) -> PathBuf {
data_dir.join("cli-account.json")
}
fn read_account_bundle(data_dir: &Path) -> Result<Option<AccountBundle>> {
let path = account_bundle_path(data_dir);
ensure_private_data_dir(data_dir)?;
secure_account_bundle_file(&path)?;
if !path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read account bundle {}", path.display()))?;
Ok(Some(serde_json::from_str(&raw)?))
}
fn write_account_bundle(data_dir: &Path, bundle: &AccountBundle) -> Result<()> {
ensure_private_data_dir(data_dir)?;
let path = account_bundle_path(data_dir);
secure_account_bundle_file(&path)?;
iris_account_file::replace(&path, &serde_json::to_vec_pretty(bundle)?)
.with_context(|| format!("write account bundle {}", path.display()))
}
fn remove_account_bundle(data_dir: &Path) -> Result<()> {
let path = account_bundle_path(data_dir);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error).with_context(|| format!("remove account bundle {}", path.display()));
}
}
if read_account_bundle(data_dir)?.is_some() {
anyhow::bail!("account bundle still present after delete");
}
Ok(())
}
fn default_data_dir() -> PathBuf {
default_data_dir_from_env(current_default_data_dir_platform(), |key| {
std::env::var_os(key)
})
}
#[derive(Clone, Copy)]
enum DefaultDataDirPlatform {
#[cfg(any(target_os = "macos", test))]
Macos,
#[cfg(any(windows, test))]
Windows,
#[cfg(any(not(any(target_os = "macos", windows)), test))]
Xdg,
}
fn current_default_data_dir_platform() -> DefaultDataDirPlatform {
#[cfg(target_os = "macos")]
{
DefaultDataDirPlatform::Macos
}
#[cfg(windows)]
{
DefaultDataDirPlatform::Windows
}
#[cfg(all(unix, not(target_os = "macos")))]
{
DefaultDataDirPlatform::Xdg
}
#[cfg(not(any(unix, windows)))]
{
DefaultDataDirPlatform::Xdg
}
}
fn default_data_dir_from_env(
platform: DefaultDataDirPlatform,
mut env: impl FnMut(&str) -> Option<OsString>,
) -> PathBuf {
let fallback = || PathBuf::from(".iris-chat");
match platform {
#[cfg(any(target_os = "macos", test))]
DefaultDataDirPlatform::Macos => env_path(&mut env, "HOME")
.map(|home| {
home.join("Library")
.join("Application Support")
.join(CLI_APP_DIR_NAME)
})
.unwrap_or_else(fallback),
#[cfg(any(windows, test))]
DefaultDataDirPlatform::Windows => env_path(&mut env, "APPDATA")
.or_else(|| env_path(&mut env, "LOCALAPPDATA"))
.or_else(|| {
env_path(&mut env, "USERPROFILE").map(|home| home.join("AppData").join("Roaming"))
})
.map(|base| base.join(CLI_APP_DIR_NAME))
.unwrap_or_else(fallback),
#[cfg(any(not(any(target_os = "macos", windows)), test))]
DefaultDataDirPlatform::Xdg => env_path(&mut env, "XDG_DATA_HOME")
.or_else(|| env_path(&mut env, "HOME").map(|home| home.join(".local").join("share")))
.map(|base| base.join(CLI_APP_DIR_NAME))
.unwrap_or_else(fallback),
}
}
fn env_path(env: &mut impl FnMut(&str) -> Option<OsString>, key: &str) -> Option<PathBuf> {
env(key)
.filter(|value| !value.as_os_str().is_empty())
.map(PathBuf::from)
}
#[cfg(unix)]
fn ensure_private_data_dir(data_dir: &Path) -> Result<()> {
if data_dir.exists() {
if !data_dir.is_dir() {
anyhow::bail!("data dir {} is not a directory", data_dir.display());
}
} else {
if let Some(parent) = data_dir
.parent()
.filter(|path| !path.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)
.with_context(|| format!("create data dir parent {}", parent.display()))?;
}
let mut builder = std::fs::DirBuilder::new();
builder.mode(0o700);
match builder.create(data_dir) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => {
return Err(error)
.with_context(|| format!("create data dir {}", data_dir.display()));
}
}
}
set_unix_mode(data_dir, 0o700, "secure data dir")
}
#[cfg(not(unix))]
fn ensure_private_data_dir(data_dir: &Path) -> Result<()> {
std::fs::create_dir_all(data_dir)
.with_context(|| format!("create data dir {}", data_dir.display()))
}
#[cfg(unix)]
fn secure_account_bundle_file(path: &Path) -> Result<()> {
if path.exists() {
set_unix_mode(path, 0o600, "secure account bundle")?;
}
Ok(())
}
#[cfg(not(unix))]
fn secure_account_bundle_file(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn set_unix_mode(path: &Path, mode: u32, action: &str) -> Result<()> {
match std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).with_context(|| format!("{action} {}", path.display())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_data_dir_uses_xdg_data_home_on_linux() {
let path = default_data_dir_from_env(DefaultDataDirPlatform::Xdg, |key| match key {
"XDG_DATA_HOME" => Some(OsString::from("/tmp/iris-xdg")),
"HOME" => Some(OsString::from("/tmp/iris-home")),
_ => None,
});
assert_eq!(path, PathBuf::from("/tmp/iris-xdg").join(CLI_APP_DIR_NAME));
}
#[test]
fn default_data_dir_falls_back_to_local_share_on_linux() {
let path = default_data_dir_from_env(DefaultDataDirPlatform::Xdg, |key| match key {
"HOME" => Some(OsString::from("/tmp/iris-home")),
_ => None,
});
assert_eq!(
path,
PathBuf::from("/tmp/iris-home")
.join(".local")
.join("share")
.join(CLI_APP_DIR_NAME)
);
}
#[test]
fn default_data_dir_uses_application_support_on_macos() {
let path = default_data_dir_from_env(DefaultDataDirPlatform::Macos, |key| match key {
"HOME" => Some(OsString::from("/tmp/iris-home")),
_ => None,
});
assert_eq!(
path,
PathBuf::from("/tmp/iris-home")
.join("Library")
.join("Application Support")
.join(CLI_APP_DIR_NAME)
);
}
#[test]
fn default_data_dir_uses_appdata_on_windows() {
let path = default_data_dir_from_env(DefaultDataDirPlatform::Windows, |key| match key {
"APPDATA" => Some(OsString::from(r"C:\Users\iris\AppData\Roaming")),
"LOCALAPPDATA" => Some(OsString::from(r"C:\Users\iris\AppData\Local")),
_ => None,
});
assert_eq!(
path,
PathBuf::from(r"C:\Users\iris\AppData\Roaming").join(CLI_APP_DIR_NAME)
);
}
#[cfg(unix)]
#[test]
fn account_bundle_permissions_are_private_and_repaired() -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir()?;
let data_dir = temp.path().join(CLI_APP_DIR_NAME);
let bundle = AccountBundle {
owner_nsec: Some("nsec1owner".to_string()),
owner_pubkey_hex: "owner-hex".to_string(),
device_nsec: "nsec1device".to_string(),
};
write_account_bundle(&data_dir, &bundle)?;
let account_path = account_bundle_path(&data_dir);
assert_eq!(mode(&data_dir)?, 0o700);
assert_eq!(mode(&account_path)?, 0o600);
std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o755))?;
std::fs::set_permissions(&account_path, std::fs::Permissions::from_mode(0o644))?;
assert!(read_account_bundle(&data_dir)?.is_some());
assert_eq!(mode(&data_dir)?, 0o700);
assert_eq!(mode(&account_path)?, 0o600);
Ok(())
}
#[cfg(unix)]
fn mode(path: &Path) -> Result<u32> {
use std::os::unix::fs::PermissionsExt;
Ok(std::fs::metadata(path)?.permissions().mode() & 0o777)
}
}