ItsGoin v0.3.2 — Decentralized social media network
No central server, user-owned data, reverse-chronological feed. Rust core + Tauri desktop + Android app + plain HTML/CSS/JS frontend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
800388cda4
146 changed files with 53227 additions and 0 deletions
16
crates/cli/Cargo.toml
Normal file
16
crates/cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "itsgoin-cli"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "itsgoin"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
itsgoin-core = { path = "../core" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
hex = "0.4"
|
||||
920
crates/cli/src/main.rs
Normal file
920
crates/cli/src/main.rs
Normal file
|
|
@ -0,0 +1,920 @@
|
|||
use std::io::{self, BufRead, Write};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use itsgoin_core::node::Node;
|
||||
use itsgoin_core::types::DeviceProfile;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,iroh=warn,swarm_discovery=warn".parse().unwrap()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Parse flags before anything else
|
||||
let mut data_dir = String::from("./itsgoin-data");
|
||||
let mut import_key: Option<String> = None;
|
||||
let mut bind_addr: Option<SocketAddr> = None;
|
||||
let mut daemon = false;
|
||||
let mut mobile = false;
|
||||
let mut web_port: Option<u16> = None;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--import-key" => {
|
||||
if i + 1 >= args.len() {
|
||||
eprintln!("Error: --import-key requires a 64-char hex key argument");
|
||||
std::process::exit(1);
|
||||
}
|
||||
import_key = Some(args[i + 1].clone());
|
||||
i += 2;
|
||||
}
|
||||
"--bind" => {
|
||||
if i + 1 >= args.len() {
|
||||
eprintln!("Error: --bind requires an address argument (e.g. 0.0.0.0:4433)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
bind_addr = Some(args[i + 1].parse().unwrap_or_else(|e| {
|
||||
eprintln!("Error: invalid bind address '{}': {}", args[i + 1], e);
|
||||
std::process::exit(1);
|
||||
}));
|
||||
i += 2;
|
||||
}
|
||||
"--daemon" => {
|
||||
daemon = true;
|
||||
i += 1;
|
||||
}
|
||||
"--mobile" => {
|
||||
mobile = true;
|
||||
i += 1;
|
||||
}
|
||||
"--web" => {
|
||||
if i + 1 >= args.len() {
|
||||
eprintln!("Error: --web requires a port argument (e.g. --web 8080)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
web_port = Some(args[i + 1].parse().unwrap_or_else(|e| {
|
||||
eprintln!("Error: invalid web port '{}': {}", args[i + 1], e);
|
||||
std::process::exit(1);
|
||||
}));
|
||||
i += 2;
|
||||
}
|
||||
_ => {
|
||||
if data_dir == "./itsgoin-data" {
|
||||
data_dir = args[i].clone();
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle --import-key before opening the node
|
||||
if let Some(hex_key) = import_key {
|
||||
println!("Importing identity key into {}...", data_dir);
|
||||
match Node::import_identity(Path::new(&data_dir), &hex_key) {
|
||||
Ok(()) => println!("Identity key imported successfully."),
|
||||
Err(e) => {
|
||||
eprintln!("Error importing key: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let profile = if mobile { DeviceProfile::Mobile } else { DeviceProfile::Desktop };
|
||||
println!("Starting ItsGoin node (data: {}, profile: {:?})...", data_dir, profile);
|
||||
let node = Arc::new(Node::open_with_bind(&data_dir, bind_addr, profile).await?);
|
||||
|
||||
// Wait briefly for address resolution
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
let node_id_hex = hex::encode(node.node_id);
|
||||
let addr = node.endpoint_addr();
|
||||
let sockets: Vec<_> = addr.ip_addrs().collect();
|
||||
|
||||
// Show our display name if set
|
||||
let my_name = node.get_display_name(&node.node_id).await.unwrap_or(None);
|
||||
let name_display = my_name.as_deref().unwrap_or("(not set)");
|
||||
|
||||
println!();
|
||||
println!("========================================");
|
||||
println!(" ItsGoin node running");
|
||||
println!(" Name: {}", name_display);
|
||||
println!(" Node ID: {}", node_id_hex);
|
||||
for sock in &sockets {
|
||||
println!(" Listen: {}", sock);
|
||||
}
|
||||
println!("========================================");
|
||||
// Print a connect string others can paste
|
||||
if let Some(sock) = sockets.first() {
|
||||
println!(" Share this to connect:");
|
||||
println!(" connect {}@{}", node_id_hex, sock);
|
||||
}
|
||||
println!();
|
||||
println!("Commands:");
|
||||
println!(" post <text> Create a public post");
|
||||
println!(" post-to <target> <text> Encrypted post (circle name, node_id, or 'friends')");
|
||||
println!(" feed Show your feed");
|
||||
println!(" posts Show all known posts");
|
||||
println!(" follow <node_id> Follow a node");
|
||||
println!(" unfollow <node_id> Unfollow a node");
|
||||
println!(" follows List followed nodes");
|
||||
println!(" connect <id>@<ip:port> Connect to a peer and sync");
|
||||
println!(" connect <node_id> Connect via address resolution");
|
||||
println!(" sync Sync with all known peers");
|
||||
println!(" peers List known peers");
|
||||
println!(" circles List circles");
|
||||
println!(" create-circle <name> Create a circle");
|
||||
println!(" delete-circle <name> Delete a circle");
|
||||
println!(" add-to-circle <circle> <node_id> Add member to circle");
|
||||
println!(" remove-from-circle <circle> <id> Remove member from circle");
|
||||
println!(" delete <post_id_hex> Delete one of your posts");
|
||||
println!(" revoke <id> <node_id> [mode] Revoke access (mode: sync|reencrypt)");
|
||||
println!(" revoke-circle <circle> <nid> [m] Revoke circle access for a node");
|
||||
println!(" redundancy Show replica counts for your posts");
|
||||
println!(" audience List audience members");
|
||||
println!(" audience-request <node_id> Request to join peer's audience");
|
||||
println!(" audience-pending Show pending audience requests");
|
||||
println!(" audience-approve <node_id> Approve audience request");
|
||||
println!(" audience-remove <node_id> Remove from audience");
|
||||
println!(" worm <node_id> Worm lookup (find peer beyond 3-hop map)");
|
||||
println!(" connections Show mesh connections");
|
||||
println!(" social-routes Show social routing cache");
|
||||
println!(" name <display_name> Set your display name");
|
||||
println!(" stats Show node stats");
|
||||
println!(" export-key Export identity key (KEEP SECRET)");
|
||||
println!(" id Show this node's ID");
|
||||
println!(" quit Shut down");
|
||||
println!();
|
||||
|
||||
// Start background tasks (v2: mesh connections)
|
||||
let _accept_handle = node.start_accept_loop();
|
||||
let _pull_handle = node.start_pull_cycle(300); // 5 min pull cycle
|
||||
let _diff_handle = node.start_diff_cycle(120); // 2 min routing diff
|
||||
let _rebalance_handle = node.start_rebalance_cycle(600); // 10 min rebalance
|
||||
let _growth_handle = node.start_growth_loop(); // reactive mesh growth
|
||||
let _recovery_handle = node.start_recovery_loop(); // reactive anchor reconnect on mesh loss
|
||||
let _checkin_handle = node.start_social_checkin_cycle(3600); // 1 hour social checkin
|
||||
let _anchor_handle = node.start_anchor_register_cycle(600); // 10 min anchor register
|
||||
let _upnp_handle = node.start_upnp_renewal_cycle(); // UPnP lease renewal (if mapped)
|
||||
let _upnp_tcp_handle = node.start_upnp_tcp_renewal_cycle(); // UPnP TCP lease renewal
|
||||
let _http_handle = node.start_http_server(); // HTTP post delivery (if publicly reachable)
|
||||
let _bootstrap_check = node.start_bootstrap_connectivity_check(); // 24h isolation check
|
||||
let _web_handle = if let Some(wp) = web_port {
|
||||
Some(node.start_web_handler(wp))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if daemon {
|
||||
println!("Running as daemon. Press Ctrl+C to stop.");
|
||||
tokio::signal::ctrl_c().await?;
|
||||
println!("Shutting down...");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Interactive command loop
|
||||
let stdin = io::stdin();
|
||||
let reader = stdin.lock();
|
||||
|
||||
print!("> ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let line = line.trim().to_string();
|
||||
|
||||
if line.is_empty() {
|
||||
print!("> ");
|
||||
io::stdout().flush()?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.splitn(2, ' ').collect();
|
||||
let cmd = parts[0];
|
||||
let arg = parts.get(1).map(|s| s.trim());
|
||||
|
||||
match cmd {
|
||||
"post" => {
|
||||
if let Some(text) = arg {
|
||||
match node.create_post(text.to_string()).await {
|
||||
Ok((id, _post)) => {
|
||||
println!("Posted! ID: {}", hex::encode(id));
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("Usage: post <text>");
|
||||
}
|
||||
}
|
||||
|
||||
"feed" => match node.get_feed().await {
|
||||
Ok(posts) => {
|
||||
if posts.is_empty() {
|
||||
println!("(feed is empty - follow some nodes first)");
|
||||
}
|
||||
for (id, post, vis, decrypted) in posts {
|
||||
print_post(&id, &post, &vis, decrypted.as_deref(), &node).await;
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
},
|
||||
|
||||
"posts" => match node.get_all_posts().await {
|
||||
Ok(posts) => {
|
||||
if posts.is_empty() {
|
||||
println!("(no posts yet)");
|
||||
}
|
||||
for (id, post, vis, decrypted) in posts {
|
||||
print_post(&id, &post, &vis, decrypted.as_deref(), &node).await;
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
},
|
||||
|
||||
"follow" => {
|
||||
if let Some(id_hex) = arg {
|
||||
match itsgoin_core::parse_node_id_hex(id_hex) {
|
||||
Ok(nid) => {
|
||||
node.follow(&nid).await?;
|
||||
println!("Following {}", &id_hex[..16.min(id_hex.len())]);
|
||||
}
|
||||
Err(e) => println!("Invalid node ID: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("Usage: follow <node_id_hex>");
|
||||
}
|
||||
}
|
||||
|
||||
"unfollow" => {
|
||||
if let Some(id_hex) = arg {
|
||||
match itsgoin_core::parse_node_id_hex(id_hex) {
|
||||
Ok(nid) => {
|
||||
node.unfollow(&nid).await?;
|
||||
println!("Unfollowed {}", &id_hex[..16.min(id_hex.len())]);
|
||||
}
|
||||
Err(e) => println!("Invalid node ID: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("Usage: unfollow <node_id_hex>");
|
||||
}
|
||||
}
|
||||
|
||||
"follows" => match node.list_follows().await {
|
||||
Ok(follows) => {
|
||||
if follows.is_empty() {
|
||||
println!("(not following anyone)");
|
||||
}
|
||||
for nid in follows {
|
||||
let name = node.get_display_name(&nid).await.unwrap_or(None);
|
||||
let label = if nid == node.node_id { " (you)" } else { "" };
|
||||
if let Some(name) = name {
|
||||
println!(" {} ({}){}", name, &hex::encode(nid)[..12], label);
|
||||
} else {
|
||||
println!(" {}{}", hex::encode(nid), label);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
},
|
||||
|
||||
"connect" => {
|
||||
if let Some(addr_str) = arg {
|
||||
if addr_str.contains('@') {
|
||||
// Full connect string: node_id@ip:port
|
||||
match itsgoin_core::parse_connect_string(addr_str) {
|
||||
Ok((nid, endpoint_addr)) => {
|
||||
println!("Connecting and syncing...");
|
||||
node.add_peer(nid).await?;
|
||||
node.follow(&nid).await?;
|
||||
match node.sync_with_addr(endpoint_addr).await {
|
||||
Ok(()) => {
|
||||
let name = node.get_display_name(&nid).await.unwrap_or(None);
|
||||
if let Some(name) = name {
|
||||
println!("Sync complete with {}!", name);
|
||||
} else {
|
||||
println!("Sync complete!");
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Sync failed: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Invalid address: {}", e),
|
||||
}
|
||||
} else {
|
||||
// Node ID only: use address resolution (N2/N3 + worm)
|
||||
match itsgoin_core::parse_node_id_hex(addr_str) {
|
||||
Ok(nid) => {
|
||||
println!("Resolving address...");
|
||||
node.add_peer(nid).await?;
|
||||
node.follow(&nid).await?;
|
||||
match node.sync_with(nid).await {
|
||||
Ok(()) => {
|
||||
let name = node.get_display_name(&nid).await.unwrap_or(None);
|
||||
if let Some(name) = name {
|
||||
println!("Connected to {}!", name);
|
||||
} else {
|
||||
println!("Connected!");
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Address resolution failed: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Invalid node ID: {}", e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Usage: connect <node_id@ip:port> or connect <node_id>");
|
||||
}
|
||||
}
|
||||
|
||||
"sync" => {
|
||||
println!("Syncing with all known peers...");
|
||||
match node.sync_all().await {
|
||||
Ok(()) => println!("Sync complete!"),
|
||||
Err(e) => println!("Sync error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
"post-to" => {
|
||||
if let Some(rest) = arg {
|
||||
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
||||
if parts.len() < 2 {
|
||||
println!("Usage: post-to <target> <text>");
|
||||
println!(" target: 'friends', circle name, or node_id hex");
|
||||
} else {
|
||||
let target = parts[0];
|
||||
let text = parts[1];
|
||||
let intent = if target == "friends" {
|
||||
itsgoin_core::types::VisibilityIntent::Friends
|
||||
} else if let Ok(nid) = itsgoin_core::parse_node_id_hex(target) {
|
||||
itsgoin_core::types::VisibilityIntent::Direct(vec![nid])
|
||||
} else {
|
||||
itsgoin_core::types::VisibilityIntent::Circle(target.to_string())
|
||||
};
|
||||
match node
|
||||
.create_post_with_visibility(text.to_string(), intent, vec![])
|
||||
.await
|
||||
{
|
||||
Ok((id, _post, _vis)) => {
|
||||
println!("Encrypted post! ID: {}", hex::encode(id));
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Usage: post-to <target> <text>");
|
||||
}
|
||||
}
|
||||
|
||||
"circles" => match node.list_circles().await {
|
||||
Ok(circles) => {
|
||||
if circles.is_empty() {
|
||||
println!("(no circles)");
|
||||
}
|
||||
for c in circles {
|
||||
println!(
|
||||
" {} ({} members, created {})",
|
||||
c.name,
|
||||
c.members.len(),
|
||||
chrono_lite(c.created_at / 1000)
|
||||
);
|
||||
for m in &c.members {
|
||||
let name = node.get_display_name(m).await.unwrap_or(None);
|
||||
let label = name.unwrap_or_else(|| hex::encode(m)[..12].to_string());
|
||||
println!(" - {}", label);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
},
|
||||
|
||||
"create-circle" => {
|
||||
if let Some(name) = arg {
|
||||
match node.create_circle(name.to_string()).await {
|
||||
Ok(()) => println!("Circle '{}' created", name),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("Usage: create-circle <name>");
|
||||
}
|
||||
}
|
||||
|
||||
"delete-circle" => {
|
||||
if let Some(name) = arg {
|
||||
match node.delete_circle(name.to_string()).await {
|
||||
Ok(()) => println!("Circle '{}' deleted", name),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("Usage: delete-circle <name>");
|
||||
}
|
||||
}
|
||||
|
||||
"add-to-circle" => {
|
||||
if let Some(rest) = arg {
|
||||
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
||||
if parts.len() < 2 {
|
||||
println!("Usage: add-to-circle <circle_name> <node_id_hex>");
|
||||
} else {
|
||||
match itsgoin_core::parse_node_id_hex(parts[1]) {
|
||||
Ok(nid) => {
|
||||
match node.add_to_circle(parts[0].to_string(), nid).await {
|
||||
Ok(()) => println!("Added to circle '{}'", parts[0]),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Invalid node ID: {}", e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Usage: add-to-circle <circle_name> <node_id_hex>");
|
||||
}
|
||||
}
|
||||
|
||||
"remove-from-circle" => {
|
||||
if let Some(rest) = arg {
|
||||
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
||||
if parts.len() < 2 {
|
||||
println!("Usage: remove-from-circle <circle_name> <node_id_hex>");
|
||||
} else {
|
||||
match itsgoin_core::parse_node_id_hex(parts[1]) {
|
||||
Ok(nid) => {
|
||||
match node.remove_from_circle(parts[0].to_string(), nid).await {
|
||||
Ok(()) => println!("Removed from circle '{}'", parts[0]),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Invalid node ID: {}", e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Usage: remove-from-circle <circle_name> <node_id_hex>");
|
||||
}
|
||||
}
|
||||
|
||||
"delete" => {
|
||||
if let Some(id_hex) = arg {
|
||||
match itsgoin_core::parse_node_id_hex(id_hex) {
|
||||
Ok(post_id) => match node.delete_post(&post_id).await {
|
||||
Ok(()) => println!("Post deleted: {}", &id_hex[..16.min(id_hex.len())]),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
},
|
||||
Err(e) => println!("Invalid post ID: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("Usage: delete <post_id_hex>");
|
||||
}
|
||||
}
|
||||
|
||||
"revoke" => {
|
||||
if let Some(rest) = arg {
|
||||
let parts: Vec<&str> = rest.splitn(3, ' ').collect();
|
||||
if parts.len() < 2 {
|
||||
println!("Usage: revoke <post_id_hex> <node_id_hex> [sync|reencrypt]");
|
||||
} else {
|
||||
let mode = match parts.get(2).unwrap_or(&"sync") {
|
||||
&"reencrypt" => itsgoin_core::types::RevocationMode::ReEncrypt,
|
||||
_ => itsgoin_core::types::RevocationMode::SyncAccessList,
|
||||
};
|
||||
match (
|
||||
itsgoin_core::parse_node_id_hex(parts[0]),
|
||||
itsgoin_core::parse_node_id_hex(parts[1]),
|
||||
) {
|
||||
(Ok(post_id), Ok(node_id)) => {
|
||||
match node.revoke_post_access(&post_id, &node_id, mode).await {
|
||||
Ok(Some(new_id)) => {
|
||||
println!("Re-encrypted. New post ID: {}", hex::encode(new_id));
|
||||
}
|
||||
Ok(None) => println!("Access revoked (sync mode)"),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
(Err(e), _) => println!("Invalid post ID: {}", e),
|
||||
(_, Err(e)) => println!("Invalid node ID: {}", e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Usage: revoke <post_id_hex> <node_id_hex> [sync|reencrypt]");
|
||||
}
|
||||
}
|
||||
|
||||
"revoke-circle" => {
|
||||
if let Some(rest) = arg {
|
||||
let parts: Vec<&str> = rest.splitn(3, ' ').collect();
|
||||
if parts.len() < 2 {
|
||||
println!("Usage: revoke-circle <circle_name> <node_id_hex> [sync|reencrypt]");
|
||||
} else {
|
||||
let mode = match parts.get(2).unwrap_or(&"sync") {
|
||||
&"reencrypt" => itsgoin_core::types::RevocationMode::ReEncrypt,
|
||||
_ => itsgoin_core::types::RevocationMode::SyncAccessList,
|
||||
};
|
||||
match itsgoin_core::parse_node_id_hex(parts[1]) {
|
||||
Ok(node_id) => {
|
||||
match node.revoke_circle_access(parts[0], &node_id, mode).await {
|
||||
Ok(count) => println!("Revoked access on {} posts", count),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Invalid node ID: {}", e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Usage: revoke-circle <circle_name> <node_id_hex> [sync|reencrypt]");
|
||||
}
|
||||
}
|
||||
|
||||
"redundancy" => {
|
||||
match node.get_redundancy_summary().await {
|
||||
Ok((total, zero, one, two_plus)) => {
|
||||
println!("Redundancy for your {} authored posts:", total);
|
||||
println!(" No replicas: {} posts", zero);
|
||||
println!(" 1 replica: {} posts", one);
|
||||
println!(" 2+ replicas: {} posts", two_plus);
|
||||
if zero > 0 {
|
||||
println!(" WARNING: {} posts have no peer replicas!", zero);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
"peers" => match node.list_peer_records().await {
|
||||
Ok(records) => {
|
||||
if records.is_empty() {
|
||||
println!("(no known peers)");
|
||||
}
|
||||
for rec in records {
|
||||
let name = node.get_display_name(&rec.node_id).await.unwrap_or(None);
|
||||
let id_short = &hex::encode(rec.node_id)[..12];
|
||||
let label = if let Some(name) = name {
|
||||
format!("{} ({})", name, id_short)
|
||||
} else {
|
||||
hex::encode(rec.node_id)
|
||||
};
|
||||
let anchor = if rec.is_anchor { " [anchor]" } else { "" };
|
||||
let addrs = if rec.addresses.is_empty() {
|
||||
String::from("(mDNS only)")
|
||||
} else {
|
||||
rec.addresses.iter().map(|a| a.to_string()).collect::<Vec<_>>().join(", ")
|
||||
};
|
||||
let intro = if let Some(ib) = rec.introduced_by {
|
||||
let ib_name = node.get_display_name(&ib).await.unwrap_or(None);
|
||||
format!(
|
||||
" via {}",
|
||||
ib_name.unwrap_or_else(|| hex::encode(ib)[..12].to_string())
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
println!(" {}{} [{}]{}", label, anchor, addrs, intro);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
},
|
||||
|
||||
"name" => {
|
||||
if let Some(display_name) = arg {
|
||||
match node.set_profile(display_name.to_string(), String::new()).await {
|
||||
Ok(profile) => {
|
||||
println!("Display name set to: {}", profile.display_name);
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
} else {
|
||||
// Show current name
|
||||
match node.my_profile().await {
|
||||
Ok(Some(profile)) => println!("Your name: {}", profile.display_name),
|
||||
Ok(None) => println!("No display name set. Usage: name <display_name>"),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"stats" => match node.stats().await {
|
||||
Ok(stats) => {
|
||||
println!("Posts: {}", stats.post_count);
|
||||
println!("Peers: {}", stats.peer_count);
|
||||
println!("Following: {}", stats.follow_count);
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
},
|
||||
|
||||
"export-key" => {
|
||||
match node.export_identity_hex() {
|
||||
Ok(hex_key) => {
|
||||
println!("WARNING: This is your SECRET identity key. Anyone with");
|
||||
println!("this key can impersonate you. Keep it safe!");
|
||||
println!();
|
||||
println!("{}", hex_key);
|
||||
println!();
|
||||
println!("To import on another device, use:");
|
||||
println!(" itsgoin <data_dir> --import-key {}", hex_key);
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
"id" => {
|
||||
let addr = node.endpoint_addr();
|
||||
let sockets: Vec<_> = addr.ip_addrs().collect();
|
||||
println!("Node ID: {}", node_id_hex);
|
||||
for sock in &sockets {
|
||||
if let Some(first_sock) = sockets.first() {
|
||||
println!("Connect: {}@{}", node_id_hex, first_sock);
|
||||
break;
|
||||
}
|
||||
println!("Listen: {}", sock);
|
||||
}
|
||||
}
|
||||
|
||||
"audience" => {
|
||||
match node.list_audience_members().await {
|
||||
Ok(members) => {
|
||||
if members.is_empty() {
|
||||
println!("(no audience members)");
|
||||
} else {
|
||||
println!("Audience members ({}):", members.len());
|
||||
for nid in members {
|
||||
let name = node.get_display_name(&nid).await.unwrap_or(None);
|
||||
let label = name.unwrap_or_else(|| hex::encode(&nid)[..12].to_string());
|
||||
println!(" {}", label);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
"audience-request" => {
|
||||
if let Some(id_hex) = arg {
|
||||
match itsgoin_core::parse_node_id_hex(id_hex) {
|
||||
Ok(nid) => {
|
||||
match node.request_audience(&nid).await {
|
||||
Ok(()) => println!("Audience request sent"),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Invalid node ID: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("Usage: audience-request <node_id_hex>");
|
||||
}
|
||||
}
|
||||
|
||||
"audience-pending" => {
|
||||
use itsgoin_core::types::{AudienceDirection, AudienceStatus};
|
||||
match node.list_audience(AudienceDirection::Inbound, Some(AudienceStatus::Pending)).await {
|
||||
Ok(records) => {
|
||||
if records.is_empty() {
|
||||
println!("(no pending audience requests)");
|
||||
} else {
|
||||
println!("Pending audience requests ({}):", records.len());
|
||||
for rec in records {
|
||||
let name = node.get_display_name(&rec.node_id).await.unwrap_or(None);
|
||||
let label = name.unwrap_or_else(|| hex::encode(&rec.node_id)[..12].to_string());
|
||||
println!(" {}", label);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
"audience-approve" => {
|
||||
if let Some(id_hex) = arg {
|
||||
match itsgoin_core::parse_node_id_hex(id_hex) {
|
||||
Ok(nid) => {
|
||||
match node.approve_audience(&nid).await {
|
||||
Ok(()) => println!("Approved audience member"),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Invalid node ID: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("Usage: audience-approve <node_id_hex>");
|
||||
}
|
||||
}
|
||||
|
||||
"audience-remove" => {
|
||||
if let Some(id_hex) = arg {
|
||||
match itsgoin_core::parse_node_id_hex(id_hex) {
|
||||
Ok(nid) => {
|
||||
match node.remove_audience(&nid).await {
|
||||
Ok(()) => println!("Removed from audience"),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Invalid node ID: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("Usage: audience-remove <node_id_hex>");
|
||||
}
|
||||
}
|
||||
|
||||
"worm" => {
|
||||
if let Some(id_hex) = arg {
|
||||
match itsgoin_core::parse_node_id_hex(id_hex) {
|
||||
Ok(nid) => {
|
||||
println!("Worm lookup for {}...", &id_hex[..16.min(id_hex.len())]);
|
||||
let start = std::time::Instant::now();
|
||||
match node.worm_lookup(&nid).await {
|
||||
Ok(Some(wr)) => {
|
||||
let elapsed = start.elapsed();
|
||||
if wr.node_id == nid {
|
||||
println!("Found! ({:.1}ms)", elapsed.as_secs_f64() * 1000.0);
|
||||
} else {
|
||||
println!("Found via recent peer {}! ({:.1}ms)",
|
||||
&hex::encode(wr.node_id)[..12],
|
||||
elapsed.as_secs_f64() * 1000.0);
|
||||
}
|
||||
if wr.addresses.is_empty() {
|
||||
println!(" (no address resolved)");
|
||||
} else {
|
||||
for addr in &wr.addresses {
|
||||
println!(" Address: {}", addr);
|
||||
}
|
||||
}
|
||||
println!(" Reporter: {}", &hex::encode(wr.reporter)[..12]);
|
||||
}
|
||||
Ok(None) => {
|
||||
let elapsed = start.elapsed();
|
||||
println!("Not found ({:.1}ms)", elapsed.as_secs_f64() * 1000.0);
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Invalid node ID: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("Usage: worm <node_id_hex>");
|
||||
}
|
||||
}
|
||||
|
||||
"connections" => {
|
||||
let conns = node.list_connections().await;
|
||||
if conns.is_empty() {
|
||||
println!("(no mesh connections)");
|
||||
} else {
|
||||
println!("Mesh connections ({}):", conns.len());
|
||||
for (nid, slot_kind, connected_at) in conns {
|
||||
let name = node.get_display_name(&nid).await.unwrap_or(None);
|
||||
let id_short = &hex::encode(nid)[..12];
|
||||
let label = name.map(|n| format!("{} ({})", n, id_short))
|
||||
.unwrap_or_else(|| hex::encode(nid));
|
||||
let duration_secs = {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
(now.saturating_sub(connected_at)) / 1000
|
||||
};
|
||||
println!(" {} [{:?}] connected {}s ago", label, slot_kind, duration_secs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"social-routes" => {
|
||||
match node.list_social_routes().await {
|
||||
Ok(routes) => {
|
||||
if routes.is_empty() {
|
||||
println!("(no social routes)");
|
||||
} else {
|
||||
println!("Social routes ({}):", routes.len());
|
||||
for r in routes {
|
||||
let name = node.get_display_name(&r.node_id).await.unwrap_or(None);
|
||||
let id_short = &hex::encode(r.node_id)[..12];
|
||||
let label = name.map(|n| format!("{} ({})", n, id_short))
|
||||
.unwrap_or_else(|| hex::encode(r.node_id));
|
||||
let addrs = if r.addresses.is_empty() {
|
||||
String::from("(no addr)")
|
||||
} else {
|
||||
r.addresses.iter().map(|a| a.to_string()).collect::<Vec<_>>().join(", ")
|
||||
};
|
||||
println!(" {} [{:?}] {} [{}] via {:?} peers:{}",
|
||||
label, r.relation, r.status, addrs,
|
||||
r.reach_method, r.peer_addresses.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
"quit" | "exit" | "q" => {
|
||||
println!("Shutting down...");
|
||||
break;
|
||||
}
|
||||
|
||||
_ => {
|
||||
println!("Unknown command: {}", cmd);
|
||||
}
|
||||
}
|
||||
|
||||
print!("> ");
|
||||
io::stdout().flush()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn print_post(
|
||||
id: &[u8; 32],
|
||||
post: &itsgoin_core::types::Post,
|
||||
vis: &itsgoin_core::types::PostVisibility,
|
||||
decrypted: Option<&str>,
|
||||
node: &Node,
|
||||
) {
|
||||
let author_hex = hex::encode(post.author);
|
||||
let author_short = &author_hex[..12];
|
||||
let is_me = &post.author == &node.node_id;
|
||||
|
||||
let author_name = node.get_display_name(&post.author).await.unwrap_or(None);
|
||||
let author_label = match (author_name, is_me) {
|
||||
(Some(name), true) => format!("{} (you)", name),
|
||||
(Some(name), false) => name,
|
||||
(None, true) => format!("{} (you)", author_short),
|
||||
(None, false) => author_short.to_string(),
|
||||
};
|
||||
|
||||
let vis_label = match vis {
|
||||
itsgoin_core::types::PostVisibility::Public => String::new(),
|
||||
itsgoin_core::types::PostVisibility::Encrypted { recipients } => {
|
||||
format!(" [encrypted, {} recipients]", recipients.len())
|
||||
}
|
||||
itsgoin_core::types::PostVisibility::GroupEncrypted { epoch, .. } => {
|
||||
format!(" [group-encrypted, epoch {}]", epoch)
|
||||
}
|
||||
};
|
||||
|
||||
let ts = post.timestamp_ms / 1000;
|
||||
let datetime = chrono_lite(ts);
|
||||
|
||||
let display_content = match vis {
|
||||
itsgoin_core::types::PostVisibility::Public => post.content.as_str().to_string(),
|
||||
itsgoin_core::types::PostVisibility::Encrypted { .. }
|
||||
| itsgoin_core::types::PostVisibility::GroupEncrypted { .. } => match decrypted {
|
||||
Some(text) => text.to_string(),
|
||||
None => "(encrypted)".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
println!("---");
|
||||
println!(" {} | {}{}", author_label, datetime, vis_label);
|
||||
println!(" {}", display_content);
|
||||
println!(" id: {}", &hex::encode(id)[..16]);
|
||||
}
|
||||
|
||||
/// Minimal timestamp formatting without pulling in chrono
|
||||
fn chrono_lite(unix_secs: u64) -> String {
|
||||
let secs_per_min = 60u64;
|
||||
let secs_per_hour = 3600u64;
|
||||
let secs_per_day = 86400u64;
|
||||
|
||||
let days_since_epoch = unix_secs / secs_per_day;
|
||||
let time_of_day = unix_secs % secs_per_day;
|
||||
|
||||
let hours = time_of_day / secs_per_hour;
|
||||
let minutes = (time_of_day % secs_per_hour) / secs_per_min;
|
||||
|
||||
let mut year = 1970u64;
|
||||
let mut remaining_days = days_since_epoch;
|
||||
loop {
|
||||
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
||||
if remaining_days < days_in_year {
|
||||
break;
|
||||
}
|
||||
remaining_days -= days_in_year;
|
||||
year += 1;
|
||||
}
|
||||
let month_days: Vec<u64> = if is_leap(year) {
|
||||
vec![31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
} else {
|
||||
vec![31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
};
|
||||
let mut month = 1u64;
|
||||
for &d in &month_days {
|
||||
if remaining_days < d {
|
||||
break;
|
||||
}
|
||||
remaining_days -= d;
|
||||
month += 1;
|
||||
}
|
||||
let day = remaining_days + 1;
|
||||
|
||||
format!(
|
||||
"{:04}-{:02}-{:02} {:02}:{:02} UTC",
|
||||
year, month, day, hours, minutes
|
||||
)
|
||||
}
|
||||
|
||||
fn is_leap(year: u64) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue