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

View File

@@ -0,0 +1,132 @@
use crate::api::MyceliumClient;
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
/// Destination of an outgoing message: either a fully resolved overlay IPv6
/// or the recipient's public key.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MessageDestination {
#[serde(rename = "ip")]
Ip(String),
#[serde(rename = "pk")]
PublicKey(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PushMessageBody {
pub dst: MessageDestination,
/// base64-encoded topic bytes (≤ 340 chars).
pub topic: String,
/// base64-encoded payload bytes.
pub payload: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PushMessageReceipt {
/// 16-char hex id assigned by the daemon.
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IncomingMessage {
pub id: String,
pub src_ip: String,
pub src_pk: String,
pub dst_ip: String,
pub dst_pk: String,
pub topic: String,
pub payload: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageStatus {
/// Pass-through of the daemon's response. We deliberately keep it as a
/// JSON Value because the upstream schema isn't pinned in the spec and
/// fields can be added between releases.
#[serde(flatten)]
pub raw: serde_json::Value,
}
impl MyceliumClient {
pub async fn send_message(&self, body: &PushMessageBody) -> AppResult<PushMessageReceipt> {
let resp = self
.http()
.post(self.url("/messages"))
.json(body)
.send()
.await?;
Self::parse(resp).await
}
/// Long-poll the daemon for an incoming message. `timeout` is seconds and
/// must be ≥ 0; the daemon returns 204/empty when nothing arrives within
/// the window. Caller is responsible for swallowing the resulting Err.
pub async fn pop_message(
&self,
peek: bool,
timeout: u64,
topic: Option<&str>,
) -> AppResult<Option<IncomingMessage>> {
let mut req = self.http().get(self.url("/messages"));
// The daemon expects query params; we hand-build to avoid url crate.
let mut q: Vec<(&str, String)> =
vec![("peek", peek.to_string()), ("timeout", timeout.to_string())];
if let Some(t) = topic {
q.push(("topic", t.to_string()));
}
req = req.query(&q);
// The long-poll can run nearly as long as `timeout`; loosen the
// client-default request timeout for this single call.
req = req.timeout(std::time::Duration::from_secs(
timeout.saturating_add(5).max(15),
));
let resp = req.send().await?;
let status = resp.status();
if status == reqwest::StatusCode::NO_CONTENT {
return Ok(None);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(AppError::DaemonStatus {
status: status.as_u16(),
body,
});
}
// Some implementations also return 200 with empty body to signal
// "nothing to read". Try to parse and tolerate empty.
let bytes = resp.bytes().await?;
if bytes.is_empty() {
return Ok(None);
}
let msg: IncomingMessage = serde_json::from_slice(&bytes)
.map_err(|e| AppError::Other(format!("failed to parse incoming message: {e}")))?;
Ok(Some(msg))
}
pub async fn reply_message(
&self,
id: &str,
body: &PushMessageBody,
) -> AppResult<PushMessageReceipt> {
let resp = self
.http()
.post(self.url(&format!("/messages/reply/{id}")))
.json(body)
.send()
.await?;
Self::parse(resp).await
}
pub async fn message_status(&self, id: &str) -> AppResult<MessageStatus> {
let resp = self
.http()
.get(self.url(&format!("/messages/status/{id}")))
.send()
.await?;
Self::parse(resp).await
}
}

View File

@@ -1,6 +1,9 @@
pub mod admin;
pub mod messages;
pub mod peers;
pub mod pubkey;
pub mod routes;
pub mod topics;
use crate::error::{AppError, AppResult};
use reqwest::{Client, Response};

View File

@@ -93,13 +93,9 @@ fn url_encode_path_segment(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'a'..=b'z'
| b'A'..=b'Z'
| b'0'..=b'9'
| b'-'
| b'_'
| b'.'
| b'~' => out.push(b as char),
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}

View File

@@ -0,0 +1,21 @@
use crate::api::MyceliumClient;
use crate::error::AppResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PubkeyLookup {
#[serde(rename = "NodePubKey")]
pub node_pub_key: String,
}
impl MyceliumClient {
/// Resolve the public key of an overlay node from its IPv6 address.
pub async fn lookup_pubkey(&self, ip: &str) -> AppResult<PubkeyLookup> {
let resp = self
.http()
.get(self.url(&format!("/pubkey/{ip}")))
.send()
.await?;
Self::parse(resp).await
}
}

View File

@@ -37,17 +37,29 @@ pub struct RoutesSnapshot {
impl MyceliumClient {
pub async fn routes_selected(&self) -> AppResult<Vec<Route>> {
let r = self.http().get(self.url("/admin/routes/selected")).send().await?;
let r = self
.http()
.get(self.url("/admin/routes/selected"))
.send()
.await?;
Self::parse(r).await
}
pub async fn routes_fallback(&self) -> AppResult<Vec<Route>> {
let r = self.http().get(self.url("/admin/routes/fallback")).send().await?;
let r = self
.http()
.get(self.url("/admin/routes/fallback"))
.send()
.await?;
Self::parse(r).await
}
pub async fn routes_queried(&self) -> AppResult<Vec<QueriedSubnet>> {
let r = self.http().get(self.url("/admin/routes/queried")).send().await?;
let r = self
.http()
.get(self.url("/admin/routes/queried"))
.send()
.await?;
Self::parse(r).await
}

158
src-tauri/src/api/topics.rs Normal file
View File

@@ -0,0 +1,158 @@
use crate::api::MyceliumClient;
use crate::error::AppResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DefaultAction {
pub accept: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceBody {
pub subnet: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ForwardBody {
pub socket_path: String,
}
impl MyceliumClient {
pub async fn topics_default_get(&self) -> AppResult<DefaultAction> {
let resp = self
.http()
.get(self.url("/messages/topics/default"))
.send()
.await?;
Self::parse(resp).await
}
pub async fn topics_default_set(&self, action: &DefaultAction) -> AppResult<()> {
let resp = self
.http()
.put(self.url("/messages/topics/default"))
.json(action)
.send()
.await?;
Self::check_status(resp).await
}
pub async fn topics_list(&self) -> AppResult<Vec<String>> {
let resp = self.http().get(self.url("/messages/topics")).send().await?;
Self::parse(resp).await
}
/// The daemon expects the raw base64 topic as the request body, not
/// wrapped in JSON.
pub async fn topic_add(&self, topic_b64: &str) -> AppResult<()> {
let resp = self
.http()
.post(self.url("/messages/topics"))
.header(reqwest::header::CONTENT_TYPE, "text/plain")
.body(topic_b64.to_string())
.send()
.await?;
Self::check_status(resp).await
}
pub async fn topic_remove(&self, topic_b64: &str) -> AppResult<()> {
let encoded = encode_segment(topic_b64);
let resp = self
.http()
.delete(self.url(&format!("/messages/topics/{encoded}")))
.send()
.await?;
Self::check_status(resp).await
}
pub async fn topic_sources_list(&self, topic_b64: &str) -> AppResult<Vec<String>> {
let encoded = encode_segment(topic_b64);
let resp = self
.http()
.get(self.url(&format!("/messages/topics/{encoded}/sources")))
.send()
.await?;
Self::parse(resp).await
}
pub async fn topic_source_add(&self, topic_b64: &str, subnet: &str) -> AppResult<()> {
let encoded = encode_segment(topic_b64);
let resp = self
.http()
.post(self.url(&format!("/messages/topics/{encoded}/sources")))
.json(&SourceBody {
subnet: subnet.to_string(),
})
.send()
.await?;
Self::check_status(resp).await
}
pub async fn topic_source_remove(&self, topic_b64: &str, subnet: &str) -> AppResult<()> {
let topic = encode_segment(topic_b64);
let sub = encode_segment(subnet);
let resp = self
.http()
.delete(self.url(&format!("/messages/topics/{topic}/sources/{sub}")))
.send()
.await?;
Self::check_status(resp).await
}
/// Returns Ok(None) if no forward is configured (the daemon yields
/// `null`).
pub async fn topic_forward_get(&self, topic_b64: &str) -> AppResult<Option<String>> {
let encoded = encode_segment(topic_b64);
let resp = self
.http()
.get(self.url(&format!("/messages/topics/{encoded}/forward")))
.send()
.await?;
let v: serde_json::Value = Self::parse(resp).await?;
Ok(match v {
serde_json::Value::Null => None,
serde_json::Value::String(s) => Some(s),
other => Some(other.to_string()),
})
}
pub async fn topic_forward_set(&self, topic_b64: &str, socket_path: &str) -> AppResult<()> {
let encoded = encode_segment(topic_b64);
let resp = self
.http()
.put(self.url(&format!("/messages/topics/{encoded}/forward")))
.json(&ForwardBody {
socket_path: socket_path.to_string(),
})
.send()
.await?;
Self::check_status(resp).await
}
pub async fn topic_forward_remove(&self, topic_b64: &str) -> AppResult<()> {
let encoded = encode_segment(topic_b64);
let resp = self
.http()
.delete(self.url(&format!("/messages/topics/{encoded}/forward")))
.send()
.await?;
Self::check_status(resp).await
}
}
/// Percent-encodes path segments. Topics are base64 (which contains `/` and
/// `+` and may end with `=`); subnets carry `/` and `:`. We encode anything
/// outside the unreserved set.
fn encode_segment(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}

View File

@@ -1,6 +1,11 @@
use crate::api::admin::NodeInfo;
use crate::api::messages::{
IncomingMessage, MessageDestination, MessageStatus, PushMessageBody, PushMessageReceipt,
};
use crate::api::peers::{AggregatedStats, PeerInfo};
use crate::api::pubkey::PubkeyLookup;
use crate::api::routes::RoutesSnapshot;
use crate::api::topics::DefaultAction;
use crate::api::MyceliumClient;
use crate::error::{AppError, AppResult};
use crate::sidecar::SidecarConfig;
@@ -99,3 +104,146 @@ pub async fn peers_stats(state: State<'_, AppState>) -> AppResult<AggregatedStat
pub async fn routes_snapshot(state: State<'_, AppState>) -> AppResult<RoutesSnapshot> {
require_client(&state)?.routes_snapshot().await
}
// ─── Messages ────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn send_message(
state: State<'_, AppState>,
destination: MessageDestination,
topic_b64: String,
payload_b64: String,
) -> AppResult<PushMessageReceipt> {
let body = PushMessageBody {
dst: destination,
topic: topic_b64,
payload: payload_b64,
};
require_client(&state)?.send_message(&body).await
}
#[tauri::command]
pub async fn reply_message(
state: State<'_, AppState>,
id: String,
destination: MessageDestination,
topic_b64: String,
payload_b64: String,
) -> AppResult<PushMessageReceipt> {
let body = PushMessageBody {
dst: destination,
topic: topic_b64,
payload: payload_b64,
};
require_client(&state)?.reply_message(&id, &body).await
}
#[tauri::command]
pub async fn message_status(state: State<'_, AppState>, id: String) -> AppResult<MessageStatus> {
require_client(&state)?.message_status(&id).await
}
#[tauri::command]
pub fn inbox_messages(state: State<'_, AppState>) -> Vec<IncomingMessage> {
state.poller.inbox_snapshot()
}
#[tauri::command]
pub fn inbox_clear(state: State<'_, AppState>) {
state.poller.clear_inbox();
}
// ─── Topics ──────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn topics_default_get(state: State<'_, AppState>) -> AppResult<DefaultAction> {
require_client(&state)?.topics_default_get().await
}
#[tauri::command]
pub async fn topics_default_set(state: State<'_, AppState>, accept: bool) -> AppResult<()> {
require_client(&state)?
.topics_default_set(&DefaultAction { accept })
.await
}
#[tauri::command]
pub async fn topics_list(state: State<'_, AppState>) -> AppResult<Vec<String>> {
require_client(&state)?.topics_list().await
}
#[tauri::command]
pub async fn topic_add(state: State<'_, AppState>, topic_b64: String) -> AppResult<()> {
if topic_b64.trim().is_empty() {
return Err(AppError::BadInput("topic must not be empty".into()));
}
require_client(&state)?.topic_add(topic_b64.trim()).await
}
#[tauri::command]
pub async fn topic_remove(state: State<'_, AppState>, topic_b64: String) -> AppResult<()> {
require_client(&state)?.topic_remove(&topic_b64).await
}
#[tauri::command]
pub async fn topic_sources_list(
state: State<'_, AppState>,
topic_b64: String,
) -> AppResult<Vec<String>> {
require_client(&state)?.topic_sources_list(&topic_b64).await
}
#[tauri::command]
pub async fn topic_source_add(
state: State<'_, AppState>,
topic_b64: String,
subnet: String,
) -> AppResult<()> {
require_client(&state)?
.topic_source_add(&topic_b64, &subnet)
.await
}
#[tauri::command]
pub async fn topic_source_remove(
state: State<'_, AppState>,
topic_b64: String,
subnet: String,
) -> AppResult<()> {
require_client(&state)?
.topic_source_remove(&topic_b64, &subnet)
.await
}
#[tauri::command]
pub async fn topic_forward_get(
state: State<'_, AppState>,
topic_b64: String,
) -> AppResult<Option<String>> {
require_client(&state)?.topic_forward_get(&topic_b64).await
}
#[tauri::command]
pub async fn topic_forward_set(
state: State<'_, AppState>,
topic_b64: String,
socket_path: String,
) -> AppResult<()> {
require_client(&state)?
.topic_forward_set(&topic_b64, &socket_path)
.await
}
#[tauri::command]
pub async fn topic_forward_remove(state: State<'_, AppState>, topic_b64: String) -> AppResult<()> {
require_client(&state)?
.topic_forward_remove(&topic_b64)
.await
}
// ─── Pubkey ──────────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn lookup_pubkey(state: State<'_, AppState>, ip: String) -> AppResult<PubkeyLookup> {
require_client(&state)?.lookup_pubkey(&ip).await
}

View File

@@ -40,6 +40,23 @@ pub fn run() {
commands::peer_remove,
commands::peers_stats,
commands::routes_snapshot,
commands::send_message,
commands::reply_message,
commands::message_status,
commands::inbox_messages,
commands::inbox_clear,
commands::topics_default_get,
commands::topics_default_set,
commands::topics_list,
commands::topic_add,
commands::topic_remove,
commands::topic_sources_list,
commands::topic_source_add,
commands::topic_source_remove,
commands::topic_forward_get,
commands::topic_forward_set,
commands::topic_forward_remove,
commands::lookup_pubkey,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

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;
}
}
}
})
}

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { Send, Loader2 } from "lucide-vue-next";
import { useMessagesStore } from "@/stores/messages";
import { utf8ToBase64 } from "@/lib/utils";
import type { MessageDestination } from "@/lib/api";
const messages = useMessagesStore();
const destKind = ref<"ip" | "pk">("ip");
const destValue = ref("");
const topic = ref("");
const payload = ref("");
const submitting = ref(false);
const result = ref<{ kind: "ok" | "err"; text: string } | null>(null);
const canSubmit = computed(
() =>
destValue.value.trim().length > 0 &&
payload.value.length > 0 &&
!submitting.value,
);
async function submit() {
result.value = null;
if (!canSubmit.value) return;
submitting.value = true;
try {
const dest: MessageDestination =
destKind.value === "ip"
? { ip: destValue.value.trim() }
: { pk: destValue.value.trim() };
const topicB64 = topic.value ? utf8ToBase64(topic.value) : "";
const payloadB64 = utf8ToBase64(payload.value);
const receipt = await messages.send(dest, topicB64, payloadB64);
result.value = { kind: "ok", text: `Sent — id ${receipt.id}` };
payload.value = "";
} catch (e) {
result.value = { kind: "err", text: String(e) };
} finally {
submitting.value = false;
}
}
</script>
<template>
<form class="space-y-3 rounded-lg border border-border bg-card p-4" @submit.prevent="submit">
<div class="flex flex-col gap-1">
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Destination
</label>
<div class="flex gap-2">
<select
v-model="destKind"
class="rounded-md border border-input bg-background px-2 py-1.5 text-sm"
>
<option value="ip">IP</option>
<option value="pk">Public key</option>
</select>
<input
v-model="destValue"
type="text"
spellcheck="false"
class="flex-1 rounded-md border border-input bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
:placeholder="destKind === 'ip' ? '503:5478:df06:d79a::1' : 'hex public key'"
/>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Topic (optional, plain text encoded as base64)
</label>
<input
v-model="topic"
type="text"
spellcheck="false"
class="rounded-md border border-input bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="my-topic"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Payload (UTF-8 encoded as base64)
</label>
<textarea
v-model="payload"
rows="4"
class="resize-y rounded-md border border-input bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Hello, mycelium!"
/>
</div>
<div class="flex items-center justify-between">
<p
v-if="result"
class="text-xs"
:class="result.kind === 'ok' ? 'text-emerald-500' : 'text-destructive'"
>
{{ result.text }}
</p>
<span v-else />
<button
type="submit"
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
:disabled="!canSubmit"
>
<Loader2 v-if="submitting" class="h-3.5 w-3.5 animate-spin" />
<Send v-else class="h-3.5 w-3.5" />
Send
</button>
</div>
</form>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { Inbox, Trash2 } from "lucide-vue-next";
import { base64ToUtf8, isPrintableUtf8 } from "@/lib/utils";
import type { IncomingMessage } from "@/lib/api";
defineProps<{
messages: IncomingMessage[];
}>();
defineEmits<{
(e: "clear"): void;
}>();
function topicLabel(t: string): string {
return t ? (isPrintableUtf8(t) ? base64ToUtf8(t) : `b64:${t}`) : "—";
}
function payloadLabel(p: string): string {
return isPrintableUtf8(p) ? base64ToUtf8(p) : `(${atob(p).length} bytes binary)`;
}
function shortIp(addr: string): string {
if (addr.length <= 28) return addr;
return `${addr.slice(0, 12)}${addr.slice(-10)}`;
}
</script>
<template>
<div class="rounded-lg border border-border bg-card">
<header class="flex items-center justify-between border-b border-border px-4 py-2">
<div class="flex items-center gap-2">
<Inbox class="h-4 w-4 text-muted-foreground" />
<h3 class="text-sm font-medium">Inbox</h3>
<span class="text-xs text-muted-foreground">
{{ messages.length }} message{{ messages.length === 1 ? "" : "s" }}
</span>
</div>
<button
v-if="messages.length"
class="rounded p-1 text-muted-foreground hover:bg-secondary hover:text-foreground"
title="Clear inbox"
@click="$emit('clear')"
>
<Trash2 class="h-4 w-4" />
</button>
</header>
<div class="max-h-[55vh] divide-y divide-border overflow-y-auto">
<p v-if="!messages.length" class="px-4 py-6 text-center text-sm text-muted-foreground">
No messages yet.
</p>
<article v-for="m in messages" :key="m.id" class="px-4 py-3 hover:bg-muted/30">
<div class="flex items-baseline justify-between gap-3">
<div class="text-xs text-muted-foreground">
from
<span class="font-mono">{{ shortIp(m.srcIp) }}</span>
<span class="mx-1">·</span>
topic <span class="font-mono">{{ topicLabel(m.topic) }}</span>
</div>
<code class="text-[10px] text-muted-foreground">{{ m.id }}</code>
</div>
<div class="mt-1 whitespace-pre-wrap break-all font-mono text-sm">
{{ payloadLabel(m.payload) }}
</div>
</article>
</div>
</div>
</template>

View File

@@ -74,6 +74,34 @@ export interface RoutesSnapshot {
queried: QueriedSubnet[];
}
// Messages & topics
export type MessageDestination = { ip: string } | { pk: string };
export interface PushMessageReceipt {
id: string;
}
export interface IncomingMessage {
id: string;
srcIp: string;
srcPk: string;
dstIp: string;
dstPk: string;
/** base64-encoded topic bytes */
topic: string;
/** base64-encoded payload bytes */
payload: string;
}
export interface DefaultAction {
accept: boolean;
}
export interface PubkeyLookup {
NodePubKey: string;
}
export const api = {
daemonStatus: () => cmd<DaemonStatus>("daemon_status"),
startDaemon: (config?: SidecarConfig) =>
@@ -88,6 +116,54 @@ export const api = {
peersStats: () => cmd<AggregatedStats>("peers_stats"),
routesSnapshot: () => cmd<RoutesSnapshot>("routes_snapshot"),
sendMessage: (
destination: MessageDestination,
topicB64: string,
payloadB64: string,
) =>
cmd<PushMessageReceipt>("send_message", {
destination,
topicB64,
payloadB64,
}),
replyMessage: (
id: string,
destination: MessageDestination,
topicB64: string,
payloadB64: string,
) =>
cmd<PushMessageReceipt>("reply_message", {
id,
destination,
topicB64,
payloadB64,
}),
messageStatus: (id: string) =>
cmd<Record<string, unknown>>("message_status", { id }),
inboxMessages: () => cmd<IncomingMessage[]>("inbox_messages"),
inboxClear: () => cmd<void>("inbox_clear"),
topicsDefaultGet: () => cmd<DefaultAction>("topics_default_get"),
topicsDefaultSet: (accept: boolean) =>
cmd<void>("topics_default_set", { accept }),
topicsList: () => cmd<string[]>("topics_list"),
topicAdd: (topicB64: string) => cmd<void>("topic_add", { topicB64 }),
topicRemove: (topicB64: string) => cmd<void>("topic_remove", { topicB64 }),
topicSourcesList: (topicB64: string) =>
cmd<string[]>("topic_sources_list", { topicB64 }),
topicSourceAdd: (topicB64: string, subnet: string) =>
cmd<void>("topic_source_add", { topicB64, subnet }),
topicSourceRemove: (topicB64: string, subnet: string) =>
cmd<void>("topic_source_remove", { topicB64, subnet }),
topicForwardGet: (topicB64: string) =>
cmd<string | null>("topic_forward_get", { topicB64 }),
topicForwardSet: (topicB64: string, socketPath: string) =>
cmd<void>("topic_forward_set", { topicB64, socketPath }),
topicForwardRemove: (topicB64: string) =>
cmd<void>("topic_forward_remove", { topicB64 }),
lookupPubkey: (ip: string) => cmd<PubkeyLookup>("lookup_pubkey", { ip }),
};
/** Format the canonical peer endpoint string the API expects. */

View File

@@ -21,3 +21,37 @@ export function shortenIpv6(addr: string): string {
if (addr.length <= 24) return addr;
return `${addr.slice(0, 10)}${addr.slice(-8)}`;
}
// ─── base64 helpers (UTF-8 safe) ─────────────────────────────────────────────
export function utf8ToBase64(s: string): string {
const bytes = new TextEncoder().encode(s);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
export function base64ToUtf8(b64: string): string {
try {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
} catch {
return b64;
}
}
export function isPrintableUtf8(b64: string): boolean {
try {
const bin = atob(b64);
for (let i = 0; i < bin.length; i++) {
const c = bin.charCodeAt(i);
// Reject NUL and most C0 except CR/LF/TAB.
if (c === 0 || (c < 0x20 && c !== 9 && c !== 10 && c !== 13)) return false;
}
return true;
} catch {
return false;
}
}

139
src/stores/messages.ts Normal file
View File

@@ -0,0 +1,139 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { Store as TauriStore } from "@tauri-apps/plugin-store";
import {
api,
type IncomingMessage,
type MessageDestination,
} from "@/lib/api";
import { Events, on } from "@/lib/events";
const STORE_FILE = "outbox.json";
const OUTBOX_KEY = "outbox";
const OUTBOX_CAP = 100;
export interface OutboxEntry {
id: string;
destination: MessageDestination;
topicB64: string;
payloadB64: string;
sentAt: number;
/** "pending" until we resolve message_status, then daemon-reported value. */
status: string;
}
export const useMessagesStore = defineStore("messages", () => {
const inbox = ref<IncomingMessage[]>([]);
const outbox = ref<OutboxEntry[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
let store: TauriStore | null = null;
let unlisten: (() => void) | null = null;
async function ensureStore() {
if (!store) {
store = await TauriStore.load(STORE_FILE);
const saved = (await store.get<OutboxEntry[]>(OUTBOX_KEY)) ?? [];
outbox.value = saved;
}
return store;
}
async function persistOutbox() {
const s = await ensureStore();
await s.set(OUTBOX_KEY, outbox.value.slice(-OUTBOX_CAP));
await s.save();
}
async function bootstrap() {
await ensureStore();
if (!unlisten) {
unlisten = await on<IncomingMessage>(Events.MessageIncoming, (e) => {
// Emitted from src-tauri/src/poller.rs every time the daemon
// long-poll resolves with an inbound message.
inbox.value = [e.payload, ...inbox.value].slice(0, 200);
});
}
}
async function refreshInbox() {
loading.value = true;
error.value = null;
try {
// The poller's ring buffer is the source of truth; the page just
// catches up after navigation.
const list = await api.inboxMessages();
inbox.value = list.slice().reverse(); // newest first
} catch (e) {
error.value = String(e);
} finally {
loading.value = false;
}
}
async function clearInbox() {
await api.inboxClear();
inbox.value = [];
}
async function send(
destination: MessageDestination,
topicB64: string,
payloadB64: string,
) {
const receipt = await api.sendMessage(destination, topicB64, payloadB64);
outbox.value.push({
id: receipt.id,
destination,
topicB64,
payloadB64,
sentAt: Date.now(),
status: "pending",
});
if (outbox.value.length > OUTBOX_CAP) {
outbox.value = outbox.value.slice(-OUTBOX_CAP);
}
await persistOutbox();
return receipt;
}
async function refreshOutboxStatus(id: string) {
try {
const status = await api.messageStatus(id);
const entry = outbox.value.find((o) => o.id === id);
if (entry) {
const v = (status as { state?: unknown }).state;
entry.status = typeof v === "string" ? v : JSON.stringify(status);
await persistOutbox();
}
} catch (e) {
const entry = outbox.value.find((o) => o.id === id);
if (entry) entry.status = `error: ${String(e)}`;
}
}
async function clearOutbox() {
outbox.value = [];
await persistOutbox();
}
function dispose() {
unlisten?.();
unlisten = null;
}
return {
inbox,
outbox,
loading,
error,
bootstrap,
refreshInbox,
clearInbox,
send,
refreshOutboxStatus,
clearOutbox,
dispose,
};
});

89
src/stores/topics.ts Normal file
View File

@@ -0,0 +1,89 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { api, type DefaultAction } from "@/lib/api";
export const useTopicsStore = defineStore("topics", () => {
const defaultAction = ref<DefaultAction>({ accept: true });
const topics = ref<string[]>([]);
const sources = ref<Record<string, string[]>>({});
const forwards = ref<Record<string, string | null>>({});
const loading = ref(false);
const error = ref<string | null>(null);
async function refresh() {
loading.value = true;
error.value = null;
try {
defaultAction.value = await api.topicsDefaultGet();
topics.value = await api.topicsList();
} catch (e) {
error.value = String(e);
} finally {
loading.value = false;
}
}
async function setDefault(accept: boolean) {
await api.topicsDefaultSet(accept);
defaultAction.value = { accept };
}
async function addTopic(topicB64: string) {
await api.topicAdd(topicB64);
if (!topics.value.includes(topicB64)) topics.value.push(topicB64);
}
async function removeTopic(topicB64: string) {
await api.topicRemove(topicB64);
topics.value = topics.value.filter((t) => t !== topicB64);
delete sources.value[topicB64];
delete forwards.value[topicB64];
}
async function refreshSources(topicB64: string) {
sources.value[topicB64] = await api.topicSourcesList(topicB64);
}
async function addSource(topicB64: string, subnet: string) {
await api.topicSourceAdd(topicB64, subnet);
await refreshSources(topicB64);
}
async function removeSource(topicB64: string, subnet: string) {
await api.topicSourceRemove(topicB64, subnet);
await refreshSources(topicB64);
}
async function refreshForward(topicB64: string) {
forwards.value[topicB64] = await api.topicForwardGet(topicB64);
}
async function setForward(topicB64: string, socketPath: string) {
await api.topicForwardSet(topicB64, socketPath);
await refreshForward(topicB64);
}
async function removeForward(topicB64: string) {
await api.topicForwardRemove(topicB64);
forwards.value[topicB64] = null;
}
return {
defaultAction,
topics,
sources,
forwards,
loading,
error,
refresh,
setDefault,
addTopic,
removeTopic,
refreshSources,
addSource,
removeSource,
refreshForward,
setForward,
removeForward,
};
});

View File

@@ -1,5 +1,114 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { Trash2, RefreshCw } from "lucide-vue-next";
import ComposeMessage from "@/components/ComposeMessage.vue";
import MessageList from "@/components/MessageList.vue";
import { useMessagesStore, type OutboxEntry } from "@/stores/messages";
import { useNodeStore } from "@/stores/node";
import { base64ToUtf8, isPrintableUtf8 } from "@/lib/utils";
const messages = useMessagesStore();
const node = useNodeStore();
const { inbox, outbox } = storeToRefs(messages);
const { phase } = storeToRefs(node);
const isReady = computed(() => phase.value === "ready");
onMounted(async () => {
await messages.bootstrap();
if (isReady.value) await messages.refreshInbox();
});
onBeforeUnmount(() => {
messages.dispose();
});
async function clearInbox() {
if (!confirm("Clear all received messages?")) return;
await messages.clearInbox();
}
function destLabel(d: OutboxEntry["destination"]): string {
return "ip" in d ? d.ip : d.pk;
}
function topicShort(t: string): string {
if (!t) return "—";
return isPrintableUtf8(t) ? base64ToUtf8(t) : `b64:${t.slice(0, 12)}`;
}
async function refreshStatus(id: string) {
await messages.refreshOutboxStatus(id);
}
async function clearOutbox() {
if (!confirm("Clear sent message history?")) return;
await messages.clearOutbox();
}
</script>
<template>
<p class="text-sm text-muted-foreground">Messages view wired in P4.</p>
<div class="grid gap-4 lg:grid-cols-2">
<section class="space-y-4">
<ComposeMessage />
<div class="rounded-lg border border-border bg-card">
<header class="flex items-center justify-between border-b border-border px-4 py-2">
<h3 class="text-sm font-medium">Outbox</h3>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{{ outbox.length }}</span>
<button
v-if="outbox.length"
class="rounded p-1 hover:bg-secondary hover:text-foreground"
title="Clear outbox"
@click="clearOutbox"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</header>
<div class="max-h-[35vh] divide-y divide-border overflow-y-auto">
<p
v-if="!outbox.length"
class="px-4 py-6 text-center text-sm text-muted-foreground"
>
No sent messages.
</p>
<article
v-for="entry in outbox"
:key="entry.id"
class="flex items-start gap-3 px-4 py-2 hover:bg-muted/30"
>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2 text-xs">
<code class="text-muted-foreground">{{ entry.id }}</code>
<span class="text-muted-foreground">·</span>
<span>to <span class="font-mono">{{ destLabel(entry.destination) }}</span></span>
</div>
<div class="mt-0.5 truncate text-xs text-muted-foreground">
topic
<span class="font-mono">{{ topicShort(entry.topicB64) }}</span>
</div>
<div class="mt-0.5 text-xs">
status:
<span class="font-mono">{{ entry.status }}</span>
</div>
</div>
<button
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-secondary hover:text-foreground"
title="Refresh status"
@click="refreshStatus(entry.id)"
>
<RefreshCw class="h-3.5 w-3.5" />
</button>
</article>
</div>
</div>
</section>
<section>
<MessageList :messages="inbox" @clear="clearInbox" />
</section>
</div>
</template>

View File

@@ -1,5 +1,262 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { Plus, Trash2, Hash } from "lucide-vue-next";
import { useTopicsStore } from "@/stores/topics";
import { useNodeStore } from "@/stores/node";
import { base64ToUtf8, isPrintableUtf8, utf8ToBase64 } from "@/lib/utils";
const topicsStore = useTopicsStore();
const node = useNodeStore();
const { topics, defaultAction, sources, forwards, error } = storeToRefs(topicsStore);
const { phase } = storeToRefs(node);
const isReady = computed(() => phase.value === "ready");
const newTopic = ref("");
const selected = ref<string | null>(null);
const newSource = ref("");
const newForward = ref("");
onMounted(async () => {
if (isReady.value) await topicsStore.refresh();
});
watch(
() => topics.value,
(list) => {
if (selected.value && !list.includes(selected.value)) selected.value = null;
if (!selected.value && list.length) selected.value = list[0];
},
{ immediate: true },
);
watch(selected, async (t) => {
if (!t) return;
await Promise.all([topicsStore.refreshSources(t), topicsStore.refreshForward(t)]);
});
function topicLabel(t: string): string {
return isPrintableUtf8(t) ? base64ToUtf8(t) : `b64:${t.slice(0, 18)}`;
}
async function addTopic() {
const v = newTopic.value.trim();
if (!v) return;
try {
await topicsStore.addTopic(utf8ToBase64(v));
newTopic.value = "";
} catch (e) {
alert(`Could not add topic: ${e}`);
}
}
async function removeTopic(t: string) {
if (!confirm(`Remove topic "${topicLabel(t)}"?`)) return;
try {
await topicsStore.removeTopic(t);
} catch (e) {
alert(`Could not remove: ${e}`);
}
}
async function addSource() {
if (!selected.value || !newSource.value.trim()) return;
try {
await topicsStore.addSource(selected.value, newSource.value.trim());
newSource.value = "";
} catch (e) {
alert(`Could not add source: ${e}`);
}
}
async function removeSource(subnet: string) {
if (!selected.value) return;
try {
await topicsStore.removeSource(selected.value, subnet);
} catch (e) {
alert(`Could not remove source: ${e}`);
}
}
async function setForward() {
if (!selected.value || !newForward.value.trim()) return;
try {
await topicsStore.setForward(selected.value, newForward.value.trim());
newForward.value = "";
} catch (e) {
alert(`Could not set forward: ${e}`);
}
}
async function clearForward() {
if (!selected.value) return;
if (!confirm("Remove forward socket?")) return;
await topicsStore.removeForward(selected.value);
}
async function toggleDefault() {
await topicsStore.setDefault(!defaultAction.value.accept);
}
</script>
<template>
<p class="text-sm text-muted-foreground">Topics view wired in P4.</p>
<div v-if="!isReady" class="text-sm text-muted-foreground">Daemon offline.</div>
<div v-else class="grid gap-4 lg:grid-cols-[260px_1fr]">
<section class="space-y-3">
<div class="rounded-lg border border-border bg-card p-3">
<div class="text-xs uppercase tracking-wide text-muted-foreground">
Default action
</div>
<button
class="mt-2 w-full rounded-md border border-border px-3 py-2 text-sm hover:bg-secondary"
@click="toggleDefault"
>
{{ defaultAction.accept ? "Accept by default" : "Reject by default" }}
</button>
<p class="mt-2 text-[11px] text-muted-foreground">
Controls how the daemon treats topics that aren't in the whitelist
below.
</p>
</div>
<div class="rounded-lg border border-border bg-card">
<header class="flex items-center justify-between border-b border-border px-3 py-2">
<span class="text-sm font-medium">Whitelisted topics</span>
<span class="text-xs text-muted-foreground">{{ topics.length }}</span>
</header>
<ul class="max-h-[40vh] divide-y divide-border overflow-y-auto">
<li v-if="!topics.length" class="px-3 py-4 text-center text-xs text-muted-foreground">
No topics yet.
</li>
<li
v-for="t in topics"
:key="t"
class="flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-muted/30"
:class="selected === t ? 'bg-secondary text-secondary-foreground' : ''"
@click="selected = t"
>
<Hash class="h-3 w-3 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">
{{ topicLabel(t) }}
</span>
<button
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
@click.stop="removeTopic(t)"
>
<Trash2 class="h-3 w-3" />
</button>
</li>
</ul>
<footer class="flex gap-2 border-t border-border p-3">
<input
v-model="newTopic"
type="text"
placeholder="topic name"
class="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
@keyup.enter="addTopic"
/>
<button
class="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
@click="addTopic"
>
<Plus class="h-3 w-3" /> Add
</button>
</footer>
</div>
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
</section>
<section v-if="selected" class="space-y-4">
<div class="rounded-lg border border-border bg-card p-4">
<div class="text-xs uppercase tracking-wide text-muted-foreground">
Selected topic
</div>
<div class="mt-1 break-all font-mono text-sm">
{{ topicLabel(selected) }}
</div>
<div class="mt-1 break-all text-[11px] text-muted-foreground">
base64: {{ selected }}
</div>
</div>
<div class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-2 text-sm font-medium">
Allowed source subnets
</header>
<ul class="divide-y divide-border">
<li
v-if="!(sources[selected]?.length)"
class="px-4 py-3 text-center text-xs text-muted-foreground"
>
No sources — all subnets allowed for this topic.
</li>
<li
v-for="s in sources[selected] ?? []"
:key="s"
class="flex items-center justify-between px-4 py-2 text-sm hover:bg-muted/30"
>
<span class="font-mono text-xs">{{ s }}</span>
<button
class="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
@click="removeSource(s)"
>
<Trash2 class="h-3 w-3" />
</button>
</li>
</ul>
<footer class="flex gap-2 border-t border-border p-3">
<input
v-model="newSource"
type="text"
placeholder="503:5478:df06:d79a::/64"
class="flex-1 rounded-md border border-input bg-background px-2 py-1 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
@keyup.enter="addSource"
/>
<button
class="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
@click="addSource"
>
<Plus class="h-3 w-3" /> Allow
</button>
</footer>
</div>
<div class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-2 text-sm font-medium">
Forward socket
</header>
<div class="px-4 py-3 text-sm">
<span v-if="forwards[selected]" class="font-mono text-xs">
{{ forwards[selected] }}
</span>
<span v-else class="text-muted-foreground">No forward configured.</span>
</div>
<footer class="flex gap-2 border-t border-border p-3">
<input
v-model="newForward"
type="text"
placeholder="/var/run/myapp.sock"
class="flex-1 rounded-md border border-input bg-background px-2 py-1 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
@keyup.enter="setForward"
/>
<button
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
@click="setForward"
>
Set
</button>
<button
v-if="forwards[selected]"
class="rounded-md border border-border px-2 py-1 text-xs hover:bg-destructive/10 hover:text-destructive"
@click="clearForward"
>
Clear
</button>
</footer>
</div>
</section>
<section v-else class="text-sm text-muted-foreground">
Pick a topic on the left to manage its sources and forward.
</section>
</div>
</template>