f28d0e1338
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
263 lines
9.0 KiB
Vue
263 lines
9.0 KiB
Vue
<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>
|
|
<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>
|