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:
+259
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user