P4: messages, topics, pubkey

Backend
- api/messages.rs covers send/pop/reply/status with an externally
  tagged MessageDestination enum that matches the daemon's
  {ip|pk: ...} body shape; pop_message uses an inflated request
  timeout to outlast the long-poll window
- api/topics.rs implements default action, topic CRUD, sources
  whitelist, and forward-socket get/set/remove. POST /topics ships
  the raw base64 string as the body (not JSON); path segments are
  percent-encoded inline (topics contain '/' and '+')
- api/pubkey.rs resolves an overlay IPv6 to a hex public key
- poller spawns a third long-poll loop on /messages?peek=false
  that fans every inbound message into a 200-deep ring buffer and
  emits messages://incoming for the UI

Frontend
- messages store: live inbox via the event, persisted outbox via
  tauri-plugin-store keyed under outbox.json
- ComposeMessage form: ip/pk toggle, optional UTF-8 topic and
  payload that get base64-encoded with a TextEncoder-based helper
- MessageList renders printable payloads decoded; binary payloads
  fall back to a "(N bytes binary)" hint
- Topics view: split layout with whitelist on the left, per-topic
  sources/forward editor on the right; default-action toggle is
  surfaced at the top
This commit is contained in:
syoul
2026-04-25 23:10:21 +02:00
parent 95e7cb4bd3
commit f28d0e1338
17 changed files with 1449 additions and 22 deletions
+62 -8
View File
@@ -1,6 +1,8 @@
use crate::api::messages::IncomingMessage;
use crate::api::peers;
use crate::sidecar::SidecarHandle;
use parking_lot::Mutex;
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
@@ -9,10 +11,15 @@ use tracing::warn;
const PEERS_INTERVAL: Duration = Duration::from_secs(3);
const ROUTES_INTERVAL: Duration = Duration::from_secs(5);
const INBOX_LONG_POLL_SECS: u64 = 30;
const INBOX_RETRY_BACKOFF: Duration = Duration::from_secs(2);
const INBOX_CAPACITY: usize = 200;
pub struct Poller {
peers_handle: Mutex<Option<JoinHandle<()>>>,
routes_handle: Mutex<Option<JoinHandle<()>>>,
inbox_handle: Mutex<Option<JoinHandle<()>>>,
inbox: Mutex<VecDeque<IncomingMessage>>,
}
impl Poller {
@@ -20,30 +27,49 @@ impl Poller {
Arc::new(Self {
peers_handle: Mutex::new(None),
routes_handle: Mutex::new(None),
inbox_handle: Mutex::new(None),
inbox: Mutex::new(VecDeque::with_capacity(INBOX_CAPACITY)),
})
}
/// Spawn the two background loops. Cancels any previously-running tasks
/// so consecutive `start_daemon` calls don't leak handles.
pub fn start(self: &Arc<Self>, app: AppHandle, sidecar: Arc<SidecarHandle>) {
self.stop();
*self.peers_handle.lock() = Some(spawn_peers_loop(app.clone(), Arc::clone(&sidecar)));
*self.routes_handle.lock() = Some(spawn_routes_loop(app, sidecar));
*self.routes_handle.lock() = Some(spawn_routes_loop(app.clone(), Arc::clone(&sidecar)));
*self.inbox_handle.lock() = Some(spawn_inbox_loop(
app,
Arc::clone(&sidecar),
Arc::clone(self),
));
}
pub fn stop(&self) {
if let Some(h) = self.peers_handle.lock().take() {
h.abort();
for slot in [&self.peers_handle, &self.routes_handle, &self.inbox_handle] {
if let Some(h) = slot.lock().take() {
h.abort();
}
}
if let Some(h) = self.routes_handle.lock().take() {
h.abort();
}
pub fn inbox_snapshot(&self) -> Vec<IncomingMessage> {
self.inbox.lock().iter().cloned().collect()
}
pub fn clear_inbox(&self) {
self.inbox.lock().clear();
}
fn push_inbox(&self, msg: IncomingMessage) {
let mut buf = self.inbox.lock();
if buf.len() >= INBOX_CAPACITY {
buf.pop_front();
}
buf.push_back(msg);
}
}
fn spawn_peers_loop(app: AppHandle, sidecar: Arc<SidecarHandle>) -> JoinHandle<()> {
tokio::spawn(async move {
// Tick once immediately so the UI doesn't wait the full interval.
let mut first = true;
loop {
if !first {
@@ -87,3 +113,31 @@ fn spawn_routes_loop(app: AppHandle, sidecar: Arc<SidecarHandle>) -> JoinHandle<
}
})
}
fn spawn_inbox_loop(
app: AppHandle,
sidecar: Arc<SidecarHandle>,
me: Arc<Poller>,
) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
let Some(client) = sidecar.client() else {
break;
};
// Each iteration is a fresh long-poll. The daemon answers as
// soon as a message arrives, or returns an empty body / 204
// when the timeout window elapses.
match client.pop_message(false, INBOX_LONG_POLL_SECS, None).await {
Ok(Some(msg)) => {
me.push_inbox(msg.clone());
let _ = app.emit("messages://incoming", &msg);
}
Ok(None) => {} // window expired, loop
Err(e) => {
warn!(error = %e, "inbox: pop_message failed");
tokio::time::sleep(INBOX_RETRY_BACKOFF).await;
}
}
}
})
}