bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

commit 890eda228fb01fa3513924acda1bcd398d78733c
parent 691dce96f6ef52194dc75749457b58fcc7335167
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date:   Wed, 18 Feb 2026 15:11:52 -0700

stream installer pack, mobile ux roadmap, other light bugfixes

Diffstat:
M.gitignore | 1+
MCLEAN_INSTALL/.gitignore | 1+
ACLEAN_INSTALL/docs/STREAM_PACK.md | 30++++++++++++++++++++++++++++++
MCLEAN_INSTALL/package.json | 6+++++-
MCLEAN_INSTALL/plugins_dev/directory-publisher/client.js | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCLEAN_INSTALL/plugins_dev/directory-publisher/server.js | 8+++++---
MCLEAN_INSTALL/plugins_dev/directory-server/client.js | 290++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCLEAN_INSTALL/plugins_dev/directory-server/server.js | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MCLEAN_INSTALL/public/app.js | 124++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCLEAN_INSTALL/public/index.html | 2+-
ACLEAN_INSTALL/scripts/stream-pack-down.js | 25+++++++++++++++++++++++++
ACLEAN_INSTALL/scripts/stream-pack-init.js | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACLEAN_INSTALL/scripts/stream-pack-status.js | 25+++++++++++++++++++++++++
ACLEAN_INSTALL/scripts/stream-pack-up.js | 25+++++++++++++++++++++++++
Adocs/MOBILE_UX.md | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/STREAM_PACK.md | 33+++++++++++++++++++++++++++++++++
Mdocs/UI_RACK_LAYOUT.md | 6++++++
Mpackage.json | 7++++++-
Mplugins_dev/directory-publisher/client.js | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mplugins_dev/directory-publisher/server.js | 8+++++---
Mplugins_dev/directory-server/client.js | 290++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mplugins_dev/directory-server/server.js | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpublic/app.js | 124++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpublic/index.html | 2+-
Ascripts/stream-pack-detect-ip.js | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/stream-pack-down.js | 25+++++++++++++++++++++++++
Ascripts/stream-pack-init.js | 317+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/stream-pack-status.js | 25+++++++++++++++++++++++++
Ascripts/stream-pack-up.js | 28++++++++++++++++++++++++++++
29 files changed, 2263 insertions(+), 24 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -24,6 +24,7 @@ tmp_server_err.log tmp_server_*.log launcher-ui.crash.log Hives +stream_pack/ # Keep the data folder in git, but never commit runtime contents data/* diff --git a/CLEAN_INSTALL/.gitignore b/CLEAN_INSTALL/.gitignore @@ -10,3 +10,4 @@ data/reports.json data/sessions.json data/uploads/ .env +stream_pack/ diff --git a/CLEAN_INSTALL/docs/STREAM_PACK.md b/CLEAN_INSTALL/docs/STREAM_PACK.md @@ -0,0 +1,30 @@ +# Stream Pack (optional, dedicated servers) + +Bzl core is designed to run without any domain name. **Streaming** (game/screen share + voice) is different: it needs a real-time media server and **HTTPS**. + +This repo ships an **optional “Stream Pack”** that you can install on dedicated servers. It runs: +- **LiveKit** (SFU) for scalable WebRTC (one streamer, many viewers) +- **coturn** for NAT traversal reliability + +Core Bzl remains unchanged: if you don’t install Stream Pack, everything still works (just no streaming). + +## Quick start + +1. Decide a hostname for streaming (example): `stream.yourdomain.com` +2. Create DNS A record to your server (set to **DNS-only**, not proxied). +3. Generate the pack: + +```bash +node scripts/stream-pack-init.js --domain=stream.yourdomain.com --email=you@yourdomain.com +``` + +4. Edit `stream_pack/.env` and set `TURN_EXTERNAL_IP` to your public server IP. +5. Add `stream_pack/Caddyfile.snippet` to your Caddy config and reload. +6. Open firewall ports listed in `stream_pack/README.md`. +7. Start it: + +```bash +cd stream_pack +docker compose up -d +``` + diff --git a/CLEAN_INSTALL/package.json b/CLEAN_INSTALL/package.json @@ -17,7 +17,11 @@ "backup-data": "node scripts/backup-data.js", "restore-data": "node scripts/restore-data.js", "build:plugin:directory-server": "node scripts/build-directory-server-plugin.js", - "build:plugin:directory-publisher": "node scripts/build-directory-publisher-plugin.js" + "build:plugin:directory-publisher": "node scripts/build-directory-publisher-plugin.js", + "stream:init": "node scripts/stream-pack-init.js", + "stream:up": "node scripts/stream-pack-up.js", + "stream:down": "node scripts/stream-pack-down.js", + "stream:status": "node scripts/stream-pack-status.js" }, "dependencies": { "adm-zip": "^0.5.16", diff --git a/CLEAN_INSTALL/plugins_dev/directory-publisher/client.js b/CLEAN_INSTALL/plugins_dev/directory-publisher/client.js @@ -1,5 +1,120 @@ window.BzlPluginHost.register("directory-publisher", (ctx) => { ctx.devLog("info", "directory-publisher client loaded"); - // UI draft: config + publish button will be added once the directory UX is finalized. -}); + let mountEl = null; + let config = null; + let lastResult = null; + + const el = (tag, props = {}, children = []) => { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(props || {})) { + if (k === "className") node.className = String(v || ""); + else if (k === "text") node.textContent = String(v ?? ""); + else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2).toLowerCase(), v); + else if (v === false || v == null) continue; + else node.setAttribute(k, String(v)); + } + for (const c of children) node.appendChild(c); + return node; + }; + + const safe = (v) => String(v ?? ""); + + function render() { + if (!mountEl) return; + mountEl.innerHTML = ""; + + const c = config && typeof config === "object" ? config : {}; + const inst = c.instance && typeof c.instance === "object" ? c.instance : {}; + + const directoryUrl = el("input", { value: safe(c.directoryUrl), placeholder: "https://chat.bzl.one" }); + const token = el("input", { value: safe(c.token), placeholder: "Directory token (shared secret)" }); + + const id = el("input", { value: safe(inst.id), placeholder: "instance id (e.g. temple)" }); + const url = el("input", { value: safe(inst.url), placeholder: "https://your.instance" }); + const name = el("input", { value: safe(inst.name), placeholder: "Display name" }); + const description = el("input", { value: safe(inst.description), placeholder: "Short description" }); + const bzlVersion = el("input", { value: safe(inst.bzlVersion), placeholder: "bzl version (optional)" }); + const requiresReg = el("input", { type: "checkbox", checked: inst.requiresRegistrationCode ? "checked" : null }); + + const statusText = + lastResult && typeof lastResult === "object" + ? lastResult.ok + ? `Published (HTTP ${lastResult.status || 200})` + : `Publish failed: ${safe(lastResult.error || lastResult.body || `HTTP ${lastResult.status || 0}`)}` + : ""; + + const status = statusText ? el("div", { className: lastResult?.ok ? "good small" : "bad small", text: statusText, style: "margin-top:10px" }) : null; + + const saveBtn = el("button", { + type: "button", + className: "primary", + text: "Save", + onclick: () => { + ctx.send("setConfig", { + config: { + directoryUrl: safe(directoryUrl.value).trim(), + token: safe(token.value).trim(), + instance: { + id: safe(id.value).trim(), + url: safe(url.value).trim(), + name: safe(name.value).trim(), + description: safe(description.value).trim(), + bzlVersion: safe(bzlVersion.value).trim(), + requiresRegistrationCode: Boolean(requiresReg.checked), + }, + }, + }); + }, + }); + + const publishBtn = el("button", { type: "button", className: "ghost", text: "Publish now", onclick: () => ctx.send("publishNow", {}) }); + + mountEl.appendChild( + el("div", { className: "panel", style: "padding:12px" }, [ + el("div", { text: "Directory publisher", style: "font-weight:700; margin-bottom:8px" }), + el("div", { className: "muted small", text: "Announces this instance to a directory (owner-only)." }), + el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [ + el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Directory URL" }), directoryUrl]), + ]), + el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Token" }), token])] ), + el("div", { className: "muted small", text: "Instance metadata", style: "margin-top:14px" }), + el("div", { className: "row", style: "gap:10px; margin-top:8px" }, [ + el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Instance id" }), id]), + el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Instance URL" }), url]), + ]), + el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [ + el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Name" }), name]), + el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Bzl version" }), bzlVersion]), + ]), + el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Description" }), description])] ), + el("label", { className: "row small", style: "gap:10px; align-items:center; margin-top:10px" }, [requiresReg, el("span", { text: "Requires registration code" })]), + el("div", { className: "row", style: "gap:10px; margin-top:14px" }, [saveBtn, publishBtn]), + ...(status ? [status] : []), + ]) + ); + } + + ctx.on("config", (msg) => { + config = msg?.config || null; + render(); + }); + ctx.on("configSaved", () => { + ctx.toast("Saved", "Directory publisher config saved."); + }); + ctx.on("result", (msg) => { + lastResult = msg || null; + render(); + }); + + ctx.ui.registerModTab({ + id: "directory", + title: "Directory publish", + ownerOnly: true, + render(mount) { + mountEl = mount; + render(); + ctx.send("getConfig", {}); + }, + }); +}); diff --git a/CLEAN_INSTALL/plugins_dev/directory-publisher/server.js b/CLEAN_INSTALL/plugins_dev/directory-publisher/server.js @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const http = require("http"); const https = require("https"); const CONFIG_PATH = path.join(__dirname, "config.json"); @@ -39,12 +40,14 @@ function postJson(targetUrl, token, payload) { return; } + const transport = u.protocol === "http:" ? http : https; + const port = u.port ? Number(u.port) : u.protocol === "http:" ? 80 : 443; const body = Buffer.from(JSON.stringify(payload), "utf8"); - const req = https.request( + const req = transport.request( { method: "POST", hostname: u.hostname, - port: u.port || 443, + port, path: u.pathname + u.search, headers: { "Content-Type": "application/json", @@ -105,4 +108,3 @@ module.exports = function init(api) { api.log("info", "directory-publisher loaded"); }; - diff --git a/CLEAN_INSTALL/plugins_dev/directory-server/client.js b/CLEAN_INSTALL/plugins_dev/directory-server/client.js @@ -1,4 +1,292 @@ window.BzlPluginHost.register("directory-server", (ctx) => { ctx.devLog("info", "directory-server client loaded"); -}); + const pluginId = ctx.id; + const apiBase = `/api/plugins/${encodeURIComponent(pluginId)}`; + + let panelMount = null; + let modMount = null; + let lastList = []; + let lastConfig = null; + let lastEntries = []; + + const el = (tag, props = {}, children = []) => { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(props || {})) { + if (k === "className") node.className = String(v || ""); + else if (k === "text") node.textContent = String(v ?? ""); + else if (k === "html") node.innerHTML = String(v ?? ""); + else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2).toLowerCase(), v); + else if (v === false || v == null) continue; + else node.setAttribute(k, String(v)); + } + for (const c of children) node.appendChild(c); + return node; + }; + + const fmtTime = (t) => { + const n = Number(t || 0); + if (!n) return "-"; + try { + return new Date(n).toLocaleString(); + } catch { + return String(n); + } + }; + + const getHost = (rawUrl) => { + try { + return new URL(String(rawUrl || "")).hostname || ""; + } catch { + return ""; + } + }; + + async function loadPublicList() { + try { + const r = await fetch(`${apiBase}/list`, { cache: "no-store" }); + const j = await r.json(); + if (!j?.ok) throw new Error(j?.error || "Failed to load list"); + lastList = Array.isArray(j.entries) ? j.entries : []; + } catch (e) { + ctx.devLog("warn", "directory list fetch failed", { error: e?.message || String(e) }); + lastList = []; + } + renderDirectoryPanel(); + } + + function renderDirectoryPanel() { + if (!panelMount) return; + panelMount.innerHTML = ""; + + const top = el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px; margin-bottom:10px" }, [ + el("div", { className: "row", style: "gap:10px; align-items:center" }, [ + el("div", { text: "Directory", style: "font-weight:700" }), + el("div", { className: "muted small", text: `${lastList.length} instance${lastList.length === 1 ? "" : "s"}` }), + ]), + el("div", { className: "row", style: "gap:8px; align-items:center" }, [ + el("button", { + type: "button", + className: "ghost", + text: "Refresh", + onclick: () => loadPublicList(), + }), + ]), + ]); + + const list = el("div", { style: "display:flex; flex-direction:column; gap:10px" }); + if (!lastList.length) { + list.appendChild(el("div", { className: "muted", text: "No directory entries yet." })); + } else { + for (const entry of lastList) { + const inst = entry?.instance || {}; + const name = String(inst.name || inst.id || "Instance").slice(0, 80); + const url = String(inst.url || ""); + const desc = String(inst.description || "").slice(0, 240); + const hives = Array.isArray(entry?.publicHives) ? entry.publicHives : []; + + const card = el("div", { className: "panel", style: "padding:12px" }); + card.appendChild( + el("div", { className: "row", style: "justify-content:space-between; gap:10px; align-items:flex-start" }, [ + el("div", {}, [ + el("div", { text: name, style: "font-weight:700; margin-bottom:2px" }), + el("div", { className: "muted small", text: url }), + ]), + el("a", { className: "ghost smallBtn", href: url || "#", target: "_blank", rel: "noreferrer", text: "Open" }), + ]) + ); + if (desc) card.appendChild(el("div", { className: "small", text: desc, style: "margin-top:8px" })); + card.appendChild(el("div", { className: "muted small", text: `Last seen: ${fmtTime(entry?.lastSeenAt)}`, style: "margin-top:8px" })); + + if (hives.length) { + const ul = el("ul", { style: "margin:8px 0 0 18px" }); + for (const h of hives.slice(0, 10)) { + const li = el("li", { className: "small" }); + const a = el("a", { href: String(h?.url || "#"), target: "_blank", rel: "noreferrer", text: String(h?.title || "Hive") }); + li.appendChild(a); + const hd = String(h?.description || "").trim(); + if (hd) li.appendChild(el("span", { className: "muted", text: ` — ${hd}` })); + ul.appendChild(li); + } + card.appendChild(el("div", { className: "muted small", text: "Public hives:", style: "margin-top:10px" })); + card.appendChild(ul); + } + + list.appendChild(card); + } + } + + panelMount.appendChild(top); + panelMount.appendChild(list); + } + + function renderMod() { + if (!modMount) return; + modMount.innerHTML = ""; + + const tokenSet = Boolean(lastConfig?.tokenSet); + const hiddenIds = Array.isArray(lastConfig?.hiddenIds) ? lastConfig.hiddenIds : []; + const blockedHosts = Array.isArray(lastConfig?.blockedHosts) ? lastConfig.blockedHosts : []; + + const tokenInput = el("input", { + placeholder: tokenSet ? "Token is set (enter to replace)" : "Set directory token (shared secret)", + style: "flex:1", + }); + const saveBtn = el("button", { + type: "button", + className: "primary", + text: "Save token", + onclick: () => { + const token = String(tokenInput.value || "").trim(); + ctx.send("setToken", { token }); + tokenInput.value = ""; + }, + }); + + modMount.appendChild( + el("div", { className: "panel", style: "padding:12px; margin-bottom:12px" }, [ + el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px" }, [ + el("div", {}, [el("div", { text: "Directory settings", style: "font-weight:700" }), el("div", { className: "muted small", text: tokenSet ? "Token: set" : "Token: not set" })]), + el("button", { type: "button", className: "ghost", text: "Refresh", onclick: () => ctx.send("getConfig", {}) }), + ]), + el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [tokenInput, saveBtn]), + ]) + ); + + const blockedWrap = el("div", { className: "panel", style: "padding:12px; margin-bottom:12px" }); + blockedWrap.appendChild(el("div", { text: "Blocked hosts", style: "font-weight:700; margin-bottom:6px" })); + if (!blockedHosts.length) blockedWrap.appendChild(el("div", { className: "muted small", text: "None." })); + else { + const ul = el("ul", { style: "margin:0 0 0 18px" }); + for (const host of blockedHosts.slice(0, 200)) { + const li = el("li", { className: "small" }); + li.appendChild(el("span", { text: host })); + li.appendChild( + el("button", { + type: "button", + className: "ghost smallBtn", + text: "Unblock", + style: "margin-left:10px", + onclick: () => ctx.send("setBlockedHost", { host, blocked: false }), + }) + ); + ul.appendChild(li); + } + blockedWrap.appendChild(ul); + } + modMount.appendChild(blockedWrap); + + const entriesWrap = el("div", { className: "panel", style: "padding:12px" }); + entriesWrap.appendChild( + el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px; margin-bottom:8px" }, [ + el("div", { text: "Entries", style: "font-weight:700" }), + el("button", { type: "button", className: "ghost", text: "Refresh", onclick: () => ctx.send("getEntries", {}) }), + ]) + ); + + if (!lastEntries.length) { + entriesWrap.appendChild(el("div", { className: "muted small", text: "No entries yet." })); + } else { + const list = el("div", { style: "display:flex; flex-direction:column; gap:10px" }); + for (const entry of lastEntries) { + const inst = entry?.instance || {}; + const id = String(inst.id || "").trim().toLowerCase(); + const name = String(inst.name || inst.id || "Instance").slice(0, 60); + const url = String(inst.url || ""); + const host = getHost(url).toLowerCase(); + const isHidden = Boolean(id && hiddenIds.includes(id)); + const isBlocked = Boolean(host && blockedHosts.includes(host)); + + const row = el("div", { className: "row", style: "gap:10px; align-items:flex-start; justify-content:space-between" }); + const left = el("div", { style: "flex:1; min-width:0" }, [ + el("div", { text: name, style: "font-weight:700" }), + el("div", { className: "muted small", text: url }), + el("div", { className: "muted small", text: `Last seen: ${fmtTime(entry?.lastSeenAt)}` }), + ]); + + const controls = el("div", { className: "row", style: "gap:8px; align-items:center; flex-wrap:wrap; justify-content:flex-end" }); + const hideCb = el("input", { + type: "checkbox", + checked: isHidden ? "checked" : null, + onchange: (e) => ctx.send("setHidden", { id, hidden: Boolean(e?.target?.checked) }), + }); + controls.appendChild(el("label", { className: "row small", style: "gap:8px; align-items:center" }, [hideCb, el("span", { text: "Hide" })])); + if (host) { + controls.appendChild( + el("button", { + type: "button", + className: "ghost", + text: isBlocked ? "Blocked" : "Block host", + onclick: () => ctx.send("setBlockedHost", { host, blocked: true }), + disabled: isBlocked ? "disabled" : null, + }) + ); + } + controls.appendChild( + el("button", { + type: "button", + className: "ghost", + text: "Delete", + onclick: () => { + if (!confirm(`Delete entry "${name}"?`)) return; + ctx.send("deleteEntry", { id }); + }, + }) + ); + + row.appendChild(left); + row.appendChild(controls); + list.appendChild(el("div", { style: "border-top:1px solid rgba(255,255,255,0.06); padding-top:10px" }, [row])); + } + entriesWrap.appendChild(list); + } + + modMount.appendChild(entriesWrap); + } + + ctx.on("updated", () => { + loadPublicList(); + if (ctx.getRole() === "owner") ctx.send("getEntries", {}); + }); + ctx.on("config", (msg) => { + lastConfig = msg && typeof msg === "object" ? msg : null; + renderMod(); + }); + ctx.on("entries", (msg) => { + lastEntries = Array.isArray(msg?.entries) ? msg.entries : []; + renderMod(); + }); + ctx.on("configUpdated", () => { + if (ctx.getRole() === "owner") { + ctx.send("getConfig", {}); + ctx.send("getEntries", {}); + } + }); + + ctx.ui.registerPanel({ + id: "directory", + title: "Directory", + icon: "📡", + defaultRack: "main", + role: "primary", + render(mount) { + panelMount = mount; + loadPublicList(); + return () => { + if (panelMount === mount) panelMount = null; + }; + }, + }); + + ctx.ui.registerModTab({ + id: "directory", + title: "Directory feed", + ownerOnly: true, + render(mount) { + modMount = mount; + renderMod(); + ctx.send("getConfig", {}); + ctx.send("getEntries", {}); + }, + }); +}); diff --git a/CLEAN_INSTALL/plugins_dev/directory-server/server.js b/CLEAN_INSTALL/plugins_dev/directory-server/server.js @@ -65,10 +65,23 @@ function sanitizePublicHives(payload) { } module.exports = function init(api) { - const config = readJson(CONFIG_PATH, { token: "" }); + const config = readJson(CONFIG_PATH, { token: "", hiddenIds: [], blockedHosts: [] }); const state = readJson(STATE_PATH, { version: 1, entries: {} }); const entries = new Map(Object.entries(state.entries || {})); + const normalizeId = (s) => String(s || "").trim().toLowerCase(); + const normalizeHost = (rawUrl) => { + try { + const u = new URL(String(rawUrl || "")); + return String(u.hostname || "").trim().toLowerCase(); + } catch { + return ""; + } + }; + + config.hiddenIds = Array.isArray(config.hiddenIds) ? config.hiddenIds.map(normalizeId).filter(Boolean) : []; + config.blockedHosts = Array.isArray(config.blockedHosts) ? config.blockedHosts.map((h) => String(h || "").trim().toLowerCase()).filter(Boolean) : []; + const persist = () => { const out = { version: 1, entries: Object.fromEntries(entries) }; writeJson(STATE_PATH, out); @@ -76,7 +89,13 @@ module.exports = function init(api) { api.registerWs("getConfig", (ws) => { if (ws?.user?.role !== "owner") return; - api.sendToUsers([ws.user.username], { type: "plugin:directory-server:config", tokenSet: Boolean(config.token) }); + api.sendToUsers([ws.user.username], { + type: "plugin:directory-server:config", + tokenSet: Boolean(config.token), + hiddenIds: config.hiddenIds.slice(0, 500), + blockedHosts: config.blockedHosts.slice(0, 500), + entryCount: entries.size + }); }); api.registerWs("setToken", (ws, msg) => { @@ -87,9 +106,70 @@ module.exports = function init(api) { api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) }); }); + api.registerWs("getEntries", (ws) => { + if (ws?.user?.role !== "owner") return; + const list = Array.from(entries.values()) + .map((e) => { + const host = normalizeHost(e?.instance?.url); + const id = normalizeId(e?.instance?.id); + return { + ...e, + host, + hidden: Boolean(id && config.hiddenIds.includes(id)), + blocked: Boolean(host && config.blockedHosts.includes(host)) + }; + }) + .sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0)); + api.sendToUsers([ws.user.username], { type: "plugin:directory-server:entries", entries: list }); + }); + + api.registerWs("setHidden", (ws, msg) => { + if (ws?.user?.role !== "owner") return; + const id = normalizeId(msg?.id); + const hidden = Boolean(msg?.hidden); + if (!id) return; + const set = new Set(config.hiddenIds); + if (hidden) set.add(id); + else set.delete(id); + config.hiddenIds = Array.from(set.values()).sort(); + writeJson(CONFIG_PATH, config); + api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) }); + }); + + api.registerWs("setBlockedHost", (ws, msg) => { + if (ws?.user?.role !== "owner") return; + const host = String(msg?.host || "").trim().toLowerCase(); + const blocked = Boolean(msg?.blocked); + if (!host) return; + const set = new Set(config.blockedHosts); + if (blocked) set.add(host); + else set.delete(host); + config.blockedHosts = Array.from(set.values()).sort(); + writeJson(CONFIG_PATH, config); + api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) }); + }); + + api.registerWs("deleteEntry", (ws, msg) => { + if (ws?.user?.role !== "owner") return; + const id = normalizeId(msg?.id); + if (!id) return; + entries.delete(id); + persist(); + api.broadcast({ type: "plugin:directory-server:updated", id, deleted: true }); + }); + api.registerHttp("GET", "/list", (_req, res, ctx) => { + const hidden = new Set(config.hiddenIds); + const blocked = new Set(config.blockedHosts); const list = Array.from(entries.values()) .map((e) => e) + .filter((e) => { + const id = normalizeId(e?.instance?.id); + if (id && hidden.has(id)) return false; + const host = normalizeHost(e?.instance?.url); + if (host && blocked.has(host)) return false; + return true; + }) .sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0)); ctx.sendJson(200, { ok: true, entries: list }); res.end(); @@ -120,4 +200,3 @@ module.exports = function init(api) { api.log("info", "directory-server loaded", { http: ["/announce", "/list"] }); }; - diff --git a/CLEAN_INSTALL/public/app.js b/CLEAN_INSTALL/public/app.js @@ -4163,12 +4163,17 @@ function sendDevLog(level, scope, message, data) { window.bzlDevLog = sendDevLog; +// Plugin event handlers: pluginId -> eventName -> Set<fn(msg)> +const pluginClientHandlers = new Map(); +// Moderation plugin tabs: fullTabId -> { title, ownerOnly, render(mount, api), pluginId } +const modPluginTabs = new Map(); + // Minimal plugin host (client-side). Plugins are trusted by the owner who installs them. // Plugin scripts can call `window.BzlPluginHost.register("pluginId", (ctx) => { ... })`. if (!window.BzlPluginHost) { const pluginInits = new Map(); window.BzlPluginHost = { - apiVersion: 2, + apiVersion: 3, register(pluginId, initFn) { const id = String(pluginId || "").trim().toLowerCase(); if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) throw new Error("Invalid plugin id"); @@ -4181,7 +4186,59 @@ if (!window.BzlPluginHost) { toast, getUser: () => loggedInUser, getRole: () => loggedInRole, + on(eventName, handler) { + const ev = String(eventName || "").trim(); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) throw new Error("Invalid event name"); + if (typeof handler !== "function") throw new Error("handler must be a function"); + let byEvent = pluginClientHandlers.get(id); + if (!byEvent) { + byEvent = new Map(); + pluginClientHandlers.set(id, byEvent); + } + let set = byEvent.get(ev); + if (!set) { + set = new Set(); + byEvent.set(ev, set); + } + set.add(handler); + return () => { + try { + set.delete(handler); + } catch { + // ignore + } + }; + }, ui: { + registerModTab(tabDef) { + const tabId = String(tabDef?.id || id).trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(tabId)) throw new Error("Invalid tab id"); + const title = typeof tabDef?.title === "string" ? tabDef.title.trim().slice(0, 22) : tabId; + const ownerOnly = Boolean(tabDef?.ownerOnly); + const render = tabDef?.render; + if (typeof render !== "function") throw new Error("render must be a function"); + + const fullId = `plugin:${id}:${tabId}`; + modPluginTabs.set(fullId, { title, ownerOnly, render, pluginId: id }); + + const tabsEl = modPanelEl?.querySelector?.(".modTabs"); + if (tabsEl && !tabsEl.querySelector(`[data-modtab="${CSS.escape(fullId)}"]`)) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "ghost"; + btn.textContent = title; + btn.setAttribute("data-modtab", fullId); + btn.dataset.ownerOnly = ownerOnly ? "1" : "0"; + tabsEl.appendChild(btn); + } + + // If the tab isn't visible for this user, don't allow it to become active. + if (ownerOnly && loggedInRole !== "owner" && modTab === fullId) { + modTab = "server"; + renderModPanel(); + } + return true; + }, registerPanel(panelDef) { const panelId = String(panelDef?.id || id).trim().toLowerCase(); if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(panelId)) throw new Error("Invalid panel id"); @@ -5546,6 +5603,49 @@ function renderModPanel() { const on = btn.getAttribute("data-modtab") === modTab; btn.classList.toggle("primary", on); btn.classList.toggle("ghost", !on); + // Owner-only plugin tabs should not show for non-owners. + const ownerOnly = btn.dataset.ownerOnly === "1"; + btn.classList.toggle("hidden", Boolean(ownerOnly && loggedInRole !== "owner")); + } + + // Plugin-provided moderation tabs (render into modBody). + if (modPluginTabs.has(modTab)) { + const def = modPluginTabs.get(modTab); + if (def?.ownerOnly && loggedInRole !== "owner") { + modTab = "server"; + renderModPanel(); + return; + } + modBodyEl.innerHTML = ` + <div class="modCard"> + <div class="modRowTop"><div><b>${escapeHtml(def?.title || "Plugin")}</b></div></div> + <div id="modPluginMount" class="modActions"></div> + </div> + `; + const mount = modBodyEl.querySelector("#modPluginMount"); + if (mount) { + const api = { + toast, + send: (eventName, payload) => { + const ev = String(eventName || "").trim(); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; + const wsRef = window.__bzlWs; + if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; + const msg = payload && typeof payload === "object" ? payload : {}; + wsRef.send(JSON.stringify({ ...msg, type: `plugin:${def.pluginId}:${ev}` })); + return true; + }, + getUser: () => loggedInUser, + getRole: () => loggedInRole, + }; + try { + def.render(mount, api); + } catch (e) { + mount.textContent = "Failed to render plugin tab."; + console.warn(`Plugin tab render failed (${modTab}):`, e?.message || e); + } + } + return; } if (modTab === "server") { @@ -8698,6 +8798,28 @@ function onWsMessage(evt) { return; } + // Generic plugin event dispatch: `plugin:<pluginId>:<eventName>` + // (Maps has some core-handled messages below; for other plugins, dispatch + stop.) + if (typeof msg.type === "string") { + const m = msg.type.match(/^plugin:([a-z0-9][a-z0-9_.-]{0,31}):([a-zA-Z0-9][a-zA-Z0-9_.-]{0,63})$/); + if (m) { + const pluginId = String(m[1] || "").toLowerCase(); + const ev = String(m[2] || ""); + const byEvent = pluginClientHandlers.get(pluginId); + const set = byEvent ? byEvent.get(ev) : null; + if (set && set.size) { + for (const fn of Array.from(set)) { + try { + fn(msg); + } catch (e) { + console.warn(`Plugin handler failed (${pluginId}:${ev}):`, e?.message || e); + } + } + } + if (pluginId !== "maps") return; + } + } + if (msg.type === "plugin:maps:joinOk") { const map = msg.map && typeof msg.map === "object" ? msg.map : null; const mapId = map && typeof map.id === "string" ? map.id.trim().toLowerCase() : ""; diff --git a/CLEAN_INSTALL/public/index.html b/CLEAN_INSTALL/public/index.html @@ -534,6 +534,6 @@ </div> <div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div> - <script src="/app.js?v=120"></script> + <script src="/app.js?v=121"></script> </body> </html> diff --git a/CLEAN_INSTALL/scripts/stream-pack-down.js b/CLEAN_INSTALL/scripts/stream-pack-down.js @@ -0,0 +1,25 @@ +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.join(__dirname, ".."); +const PACK_DIR = path.join(ROOT, "stream_pack"); + +function run(cmd, args) { + return spawnSync(cmd, args, { stdio: "inherit", cwd: PACK_DIR }); +} + +function main() { + if (!fs.existsSync(path.join(PACK_DIR, "docker-compose.yml"))) { + console.error("[stream-pack] Missing stream_pack/docker-compose.yml."); + process.exit(1); + } + + let r = run("docker", ["compose", "down"]); + if (r.status === 0) return; + r = run("docker-compose", ["down"]); + process.exit(r.status || 1); +} + +main(); + diff --git a/CLEAN_INSTALL/scripts/stream-pack-init.js b/CLEAN_INSTALL/scripts/stream-pack-init.js @@ -0,0 +1,197 @@ +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const ROOT = path.join(__dirname, ".."); +const PACK_DIR = path.join(ROOT, "stream_pack"); + +function log(msg) { + console.log(`[stream-pack] ${msg}`); +} + +function fail(msg) { + console.error(`[stream-pack] ERROR: ${msg}`); + process.exit(1); +} + +function parseArgs() { + const args = process.argv.slice(2); + const out = {}; + for (const a of args) { + const m = String(a || "").match(/^--([^=]+)=(.*)$/); + if (m) out[m[1]] = m[2]; + } + return out; +} + +function randToken(bytes) { + return crypto.randomBytes(bytes).toString("base64url"); +} + +function normalizeDomain(s) { + const v = String(s || "").trim().toLowerCase(); + if (!v) return ""; + if (!/^[a-z0-9.-]+$/.test(v)) return ""; + if (!v.includes(".")) return ""; + return v; +} + +function writeFileAlways(filePath, content) { + fs.writeFileSync(filePath, content, "utf8"); +} + +function readEnvFile(envPath) { + try { + const raw = fs.readFileSync(envPath, "utf8"); + const out = {}; + for (const line of raw.split(/\r?\n/)) { + const s = line.trim(); + if (!s || s.startsWith("#")) continue; + const m = s.match(/^([A-Z0-9_]+)=(.*)$/); + if (!m) continue; + let v = m[2] || ""; + v = v.trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + out[m[1]] = v; + } + return out; + } catch { + return {}; + } +} + +function main() { + const args = parseArgs(); + const domain = normalizeDomain(args.domain); + const email = String(args.email || "").trim(); + + if (!domain) { + fail('Missing/invalid `--domain=...` (example: --domain=stream.example.com)'); + } + if (!email || !email.includes("@")) { + fail('Missing/invalid `--email=...` (needed for HTTPS certs; example: --email=you@example.com)'); + } + + fs.mkdirSync(PACK_DIR, { recursive: true }); + + const envPath = path.join(PACK_DIR, ".env"); + const existing = readEnvFile(envPath); + + const API_KEY = existing.LIVEKIT_API_KEY || randToken(8); + const API_SECRET = existing.LIVEKIT_API_SECRET || randToken(24); + const TURN_USER = existing.TURN_USER || "bzl"; + const TURN_PASS = existing.TURN_PASS || randToken(18); + const TURN_REALM = existing.TURN_REALM || domain; + const TURN_EXTERNAL_IP = existing.TURN_EXTERNAL_IP || ""; + const RTC_UDP_START = existing.RTC_UDP_START || "50000"; + const RTC_UDP_END = existing.RTC_UDP_END || "50100"; + const TURN_RELAY_START = existing.TURN_RELAY_START || "49160"; + const TURN_RELAY_END = existing.TURN_RELAY_END || "49200"; + + const lines = [ + "# Stream Pack (optional)", + "#", + "# This stack is separate from core Bzl. Core stays runnable without any domain.", + "# Streaming requires HTTPS and a public server.", + "", + `STREAM_DOMAIN=${domain}`, + `STREAM_EMAIL=${email}`, + "", + "# LiveKit auth (Bzl will mint JWTs with these)", + `LIVEKIT_API_KEY=${API_KEY}`, + `LIVEKIT_API_SECRET=${API_SECRET}`, + "", + "# TURN (coturn) credentials for LiveKit clients", + `TURN_USER=${TURN_USER}`, + `TURN_PASS=${TURN_PASS}`, + `TURN_REALM=${TURN_REALM}`, + `TURN_EXTERNAL_IP=${TURN_EXTERNAL_IP}`, + "", + "# WebRTC UDP port range exposed by livekit-server (keep small for firewall sanity)", + `RTC_UDP_START=${RTC_UDP_START}`, + `RTC_UDP_END=${RTC_UDP_END}`, + "", + "# TURN relay UDP ports (coturn will allocate from this range)", + `TURN_RELAY_START=${TURN_RELAY_START}`, + `TURN_RELAY_END=${TURN_RELAY_END}`, + "" + ]; + writeFileAlways(envPath, lines.join("\n")); + log(`Wrote ${path.relative(ROOT, envPath)}`); + + const livekitYamlPath = path.join(PACK_DIR, "livekit.yaml"); + const livekitYaml = `port: 7880 +log_level: info + +rtc: + tcp_port: 7881 + port_range_start: ${RTC_UDP_START} + port_range_end: ${RTC_UDP_END} + use_external_ip: true + turn_servers: + - host: ${domain} + port: 3478 + protocol: udp + username: ${TURN_USER} + credential: ${TURN_PASS} + +keys: + ${API_KEY}: ${API_SECRET} +`; + writeFileAlways(livekitYamlPath, livekitYaml); + log(`Wrote ${path.relative(ROOT, livekitYamlPath)}`); + + const composePath = path.join(PACK_DIR, "docker-compose.yml"); + const composeBody = `services: + livekit: + image: livekit/livekit-server:latest + container_name: bzl_livekit + restart: unless-stopped + command: --config /etc/livekit.yaml + volumes: + - ./livekit.yaml:/etc/livekit.yaml:ro + ports: + - "127.0.0.1:7880:7880" + - "7881:7881" + - "${RTC_UDP_START}-${RTC_UDP_END}:${RTC_UDP_START}-${RTC_UDP_END}/udp" + + turn: + image: coturn/coturn:latest + container_name: bzl_turn + restart: unless-stopped + network_mode: host + command: + - -n + - --log-file=stdout + - --realm=${TURN_REALM} + - --fingerprint + - --lt-cred-mech + - --no-tls + - --no-dtls + - --no-cli + - --no-multicast-peers + - --no-loopback-peers + - --listening-port=3478 + - --min-port=${TURN_RELAY_START} + - --max-port=${TURN_RELAY_END} + - --user=${TURN_USER}:${TURN_PASS} + - --external-ip=${TURN_EXTERNAL_IP} +`; + writeFileAlways(composePath, composeBody); + log(`Wrote ${path.relative(ROOT, composePath)}`); + + const caddySnippetPath = path.join(PACK_DIR, "Caddyfile.snippet"); + const caddySnippet = `${domain} { + encode zstd gzip + reverse_proxy 127.0.0.1:7880 +} +`; + writeFileAlways(caddySnippetPath, caddySnippet); + log(`Wrote ${path.relative(ROOT, caddySnippetPath)}`); + + log("Done."); + log("Next: edit stream_pack/.env (TURN_EXTERNAL_IP), add the Caddy snippet, open firewall ports, then run `docker compose up -d`."); +} + +main(); + diff --git a/CLEAN_INSTALL/scripts/stream-pack-status.js b/CLEAN_INSTALL/scripts/stream-pack-status.js @@ -0,0 +1,25 @@ +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.join(__dirname, ".."); +const PACK_DIR = path.join(ROOT, "stream_pack"); + +function run(cmd, args) { + return spawnSync(cmd, args, { stdio: "inherit", cwd: PACK_DIR }); +} + +function main() { + if (!fs.existsSync(path.join(PACK_DIR, "docker-compose.yml"))) { + console.error("[stream-pack] Missing stream_pack/docker-compose.yml."); + process.exit(1); + } + + let r = run("docker", ["compose", "ps"]); + if (r.status === 0) return; + r = run("docker-compose", ["ps"]); + process.exit(r.status || 1); +} + +main(); + diff --git a/CLEAN_INSTALL/scripts/stream-pack-up.js b/CLEAN_INSTALL/scripts/stream-pack-up.js @@ -0,0 +1,25 @@ +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.join(__dirname, ".."); +const PACK_DIR = path.join(ROOT, "stream_pack"); + +function run(cmd, args) { + return spawnSync(cmd, args, { stdio: "inherit", cwd: PACK_DIR }); +} + +function main() { + if (!fs.existsSync(path.join(PACK_DIR, "docker-compose.yml"))) { + console.error("[stream-pack] Missing stream_pack/docker-compose.yml. Run `node scripts/stream-pack-init.js ...` first."); + process.exit(1); + } + + let r = run("docker", ["compose", "up", "-d"]); + if (r.status === 0) return; + r = run("docker-compose", ["up", "-d"]); + process.exit(r.status || 1); +} + +main(); + diff --git a/docs/MOBILE_UX.md b/docs/MOBILE_UX.md @@ -0,0 +1,218 @@ +# Mobile UX (Draft) + +This doc proposes a mobile-first UX that stays consistent with Bzl’s modular “everything is a panel” direction, without trying to squeeze desktop racks onto a phone. + +## Goals + +- Mobile feels **intentional** (not a scaled-down desktop). +- Keep the modular mental model: **surfaces are registered modules** (core + plugins). +- Fast access to the few things you use constantly (Hives / Chat / People / Maps). +- Works for both **small phones** and **odd aspect ratios**. +- Supports moderator-only surfaces without breaking non-mod UX. + +## Non-goals (MVP) + +- Full rack / multi-column layout on mobile. +- Drag-and-drop panel layout editing. +- Multi-window / splits. +- Perfect parity with every desktop feature (we’ll ship in phases). + +## Core idea: **Screens**, not racks + +On mobile, the primary constraint isn’t layout; it’s attention and space. + +So mobile becomes a **single active screen** at a time (full-height), plus: + +- A **bottom nav** for pinned screens +- A **More** drawer/sheet for everything else +- **Overlays** (bottom sheet / fullscreen modal) for transient flows + +We still preserve modularity by keeping the registration model the same: + +- Desktop: panels can be docked and arranged in racks +- Mobile: the same panel ids become **screens** or **tools** + +## Terminology + +- **Screen**: a full-height mobile surface (Hives feed, Chat, People, Maps, Library, Instance, Moderation). +- **Tool**: a smaller surface shown as an overlay (Profile, Composer, Plugin Rack). +- **Pinned**: a screen shown in bottom nav. +- **More**: the place you can find and open any available surface. + +## Information architecture + +### Bottom nav (pinned screens) + +Default (non-mod): +- Hives +- Chat +- People +- Maps (if installed) or Library +- More + +Default (mod): +- Hives +- Chat +- Moderation +- People +- More + +Rules: +- Max **4 pinned** + **More**. +- Long-press (or Edit mode) to reorder / replace pinned. +- Pinned screens show optional badges (unread / mention). + +### More sheet + +“More” opens a sheet with: +- A searchable list of **all available surfaces** +- Grouping: + - Core + - Plugins + - Tools (overlays) + - Moderation (mods only) +- Docked/hidden items (desktop hotbar equivalent) live here too on mobile. + +## Interaction model + +### Navigation + +- Tap a pinned icon → opens that screen. +- Tap “More” → opens the surface list sheet. +- Tap a surface in “More” → opens that screen (or tool overlay). + +### Back behavior + +Recommended behavior: +- Hardware back / top-left back: + - If a tool overlay is open → close it. + - Else if the More sheet is open → close it. + - Else → go to previous screen in history (small stack), otherwise do nothing. + +### Chat thread selection + +Mobile should avoid spawning multiple chat instances by default. + +- Tapping “Chat” on a hive post: + - If Chat screen is open and empty → fill it. + - Else → switch Chat screen to the requested thread. +- Advanced: optional “multi-chat” power mode can still exist, but mobile default should be **single chat context**. + +### Transient flows as overlays + +These should not “steal” the whole navigation permanently: + +- **Composer**: bottom sheet on phones; fullscreen on very small viewports. +- **Profile**: fullscreen modal (with its own internal scroll). +- **Plugin Rack**: fullscreen modal or tall bottom sheet depending on content. + +## Layout rules (responsive) + +We should not assume desktop-like columns. + +Mobile containers should: +- Use `min-width: 0` everywhere +- Avoid fixed widths; rely on `%`, `clamp()`, and container constraints +- Prefer vertical stacking and bottom sheets + +## State model (MVP) + +Persist as JSON in `localStorage` (per user/device) under something like `bzl_mobile_layout_v1`: + +```json +{ + "version": 1, + "pinned": ["hives", "chat", "people", "maps"], + "active": "hives", + "history": ["hives", "chat"], + "tools": { + "composerOpen": false, + "profileOpen": false, + "pluginRackOpen": false + } +} +``` + +Notes: +- Missing panels should be ignored safely (plugin removed). +- If a pinned panel isn’t available, substitute a reasonable fallback. +- Moderation only appears if the user can moderate. + +## Mapping desktop panels → mobile behavior + +Recommended defaults: + +- Primary/core screens: + - `hives` → screen + - `chat` → screen + - `people` → screen + - `maps` → screen (if installed) + - `library` → screen + - `instance` → screen (maybe inside More) + - `moderation` → screen (mods only) + +- Tools (overlays): + - `profile` → tool overlay + - `composer` → tool overlay + - `pluginRack` → tool overlay + +## Plugin integration (draft) + +Plugins already register panels; mobile needs one extra hint so we don’t overwhelm “More” or pin tool-like widgets by default. + +Proposed addition to `ctx.ui.registerPanel()` (draft): + +```js +ctx.ui.registerPanel({ + id: "radio", + title: "Radio", + defaultRack: "main", + role: "utility", + mobileHint: "tool" // "screen" | "tool" | "hidden" +}); +``` + +Defaults: +- `role: "primary"` → `mobileHint: "screen"` +- everything else → `mobileHint: "tool"` + +## Phased implementation plan + +### Phase 1: Mobile shell + core screens + +- Add a mobile UI shell: `ScreenHost`, bottom nav, More sheet. +- Implement navigation + persistence (`pinned`, `active`, basic history). +- Hook up the core screens: + - Hives, Chat, People, Instance + - Maps/Library if installed + - Moderation for mods + +Acceptance: +- Works on small phone widths without horizontal overflow. +- Can switch between Hives/Chat/People quickly. + +### Phase 2: Tools as overlays + +- Composer as a bottom sheet / fullscreen modal (viewport-dependent). +- Profile as fullscreen modal. +- Plugin Rack as tool overlay (stacking widgets inside). + +Acceptance: +- Tools open/close cleanly and don’t break navigation. + +### Phase 3: Polishing + badges + shortcuts + +- Unread / mention badges in bottom nav and More list. +- Search in More. +- “Edit pinned” mode. + +Acceptance: +- Users can tailor bottom nav to their usage. + +## Open questions + +- Should mobile share “layout presets”, or is pinned nav personalization enough? +- Should plugin widgets be allowed as pinned screens, or restricted to tools by default? +- Do we want a “focus mode” (hide nav bar while scrolling, reveal on tap)? +- How do we handle Maps + associated chat (global/local) on small screens (tab within chat vs inline on map)? + diff --git a/docs/STREAM_PACK.md b/docs/STREAM_PACK.md @@ -0,0 +1,33 @@ +# Stream Pack (optional, dedicated servers) + +Bzl core is designed to run without any domain name. **Streaming** (game/screen share + voice) is different: it needs a real-time media server and **HTTPS**. + +This repo ships an **optional “Stream Pack”** that you can install on dedicated servers. It runs: +- **LiveKit** (SFU) for scalable WebRTC (one streamer, many viewers) +- **coturn** for NAT traversal reliability + +Core Bzl remains unchanged: if you don’t install Stream Pack, everything still works (just no streaming). + +## Quick start + +1. Decide a hostname for streaming (example): `stream.yourdomain.com` +2. Create DNS A record to your server (set to **DNS-only**, not proxied). +3. Generate the pack: + +```bash +node scripts/stream-pack-init.js --domain=stream.yourdomain.com --email=you@yourdomain.com +``` + +4. Edit `stream_pack/.env` and set `TURN_EXTERNAL_IP` to your public server IP. +5. Add `stream_pack/Caddyfile.snippet` to your Caddy config and reload. +6. Open firewall ports listed in `stream_pack/README.md`. +7. Start it: + +```bash +cd stream_pack +docker compose up -d +``` + +## Notes +- The Stream Pack is infrastructure only. The actual “stream as a post” UX will be implemented as a plugin that mints LiveKit tokens from the Bzl server. + diff --git a/docs/UI_RACK_LAYOUT.md b/docs/UI_RACK_LAYOUT.md @@ -381,3 +381,9 @@ When the rack layout exists: - Should panel layout persist per-device or per-user (server-side)? - How do we expose keyboard shortcuts for focusing panels? - What is the best “default” panel set for first-time users? + +## Mobile (separate model) + +Rack layout is desktop-first. On mobile, we should treat panels as **screens + tools** (single active surface with a bottom nav + “More” sheet), while keeping the same panel registration model underneath. + +See `docs/MOBILE_UX.md`. diff --git a/package.json b/package.json @@ -18,7 +18,12 @@ "restore-data": "node scripts/restore-data.js", "build:clean-install-zip": "node scripts/build-clean-install-zip.js", "build:plugin:directory-server": "node scripts/build-directory-server-plugin.js", - "build:plugin:directory-publisher": "node scripts/build-directory-publisher-plugin.js" + "build:plugin:directory-publisher": "node scripts/build-directory-publisher-plugin.js", + "stream:init": "node scripts/stream-pack-init.js", + "stream:detect-ip": "node scripts/stream-pack-detect-ip.js", + "stream:up": "node scripts/stream-pack-up.js", + "stream:down": "node scripts/stream-pack-down.js", + "stream:status": "node scripts/stream-pack-status.js" }, "dependencies": { "adm-zip": "^0.5.16", diff --git a/plugins_dev/directory-publisher/client.js b/plugins_dev/directory-publisher/client.js @@ -1,5 +1,120 @@ window.BzlPluginHost.register("directory-publisher", (ctx) => { ctx.devLog("info", "directory-publisher client loaded"); - // UI draft: config + publish button will be added once the directory UX is finalized. -}); + let mountEl = null; + let config = null; + let lastResult = null; + + const el = (tag, props = {}, children = []) => { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(props || {})) { + if (k === "className") node.className = String(v || ""); + else if (k === "text") node.textContent = String(v ?? ""); + else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2).toLowerCase(), v); + else if (v === false || v == null) continue; + else node.setAttribute(k, String(v)); + } + for (const c of children) node.appendChild(c); + return node; + }; + + const safe = (v) => String(v ?? ""); + + function render() { + if (!mountEl) return; + mountEl.innerHTML = ""; + + const c = config && typeof config === "object" ? config : {}; + const inst = c.instance && typeof c.instance === "object" ? c.instance : {}; + + const directoryUrl = el("input", { value: safe(c.directoryUrl), placeholder: "https://chat.bzl.one" }); + const token = el("input", { value: safe(c.token), placeholder: "Directory token (shared secret)" }); + + const id = el("input", { value: safe(inst.id), placeholder: "instance id (e.g. temple)" }); + const url = el("input", { value: safe(inst.url), placeholder: "https://your.instance" }); + const name = el("input", { value: safe(inst.name), placeholder: "Display name" }); + const description = el("input", { value: safe(inst.description), placeholder: "Short description" }); + const bzlVersion = el("input", { value: safe(inst.bzlVersion), placeholder: "bzl version (optional)" }); + const requiresReg = el("input", { type: "checkbox", checked: inst.requiresRegistrationCode ? "checked" : null }); + + const statusText = + lastResult && typeof lastResult === "object" + ? lastResult.ok + ? `Published (HTTP ${lastResult.status || 200})` + : `Publish failed: ${safe(lastResult.error || lastResult.body || `HTTP ${lastResult.status || 0}`)}` + : ""; + + const status = statusText ? el("div", { className: lastResult?.ok ? "good small" : "bad small", text: statusText, style: "margin-top:10px" }) : null; + + const saveBtn = el("button", { + type: "button", + className: "primary", + text: "Save", + onclick: () => { + ctx.send("setConfig", { + config: { + directoryUrl: safe(directoryUrl.value).trim(), + token: safe(token.value).trim(), + instance: { + id: safe(id.value).trim(), + url: safe(url.value).trim(), + name: safe(name.value).trim(), + description: safe(description.value).trim(), + bzlVersion: safe(bzlVersion.value).trim(), + requiresRegistrationCode: Boolean(requiresReg.checked), + }, + }, + }); + }, + }); + + const publishBtn = el("button", { type: "button", className: "ghost", text: "Publish now", onclick: () => ctx.send("publishNow", {}) }); + + mountEl.appendChild( + el("div", { className: "panel", style: "padding:12px" }, [ + el("div", { text: "Directory publisher", style: "font-weight:700; margin-bottom:8px" }), + el("div", { className: "muted small", text: "Announces this instance to a directory (owner-only)." }), + el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [ + el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Directory URL" }), directoryUrl]), + ]), + el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Token" }), token])] ), + el("div", { className: "muted small", text: "Instance metadata", style: "margin-top:14px" }), + el("div", { className: "row", style: "gap:10px; margin-top:8px" }, [ + el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Instance id" }), id]), + el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Instance URL" }), url]), + ]), + el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [ + el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Name" }), name]), + el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Bzl version" }), bzlVersion]), + ]), + el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [el("label", { style: "flex:1" }, [el("span", { className: "muted small", text: "Description" }), description])] ), + el("label", { className: "row small", style: "gap:10px; align-items:center; margin-top:10px" }, [requiresReg, el("span", { text: "Requires registration code" })]), + el("div", { className: "row", style: "gap:10px; margin-top:14px" }, [saveBtn, publishBtn]), + ...(status ? [status] : []), + ]) + ); + } + + ctx.on("config", (msg) => { + config = msg?.config || null; + render(); + }); + ctx.on("configSaved", () => { + ctx.toast("Saved", "Directory publisher config saved."); + }); + ctx.on("result", (msg) => { + lastResult = msg || null; + render(); + }); + + ctx.ui.registerModTab({ + id: "directory", + title: "Directory publish", + ownerOnly: true, + render(mount) { + mountEl = mount; + render(); + ctx.send("getConfig", {}); + }, + }); +}); diff --git a/plugins_dev/directory-publisher/server.js b/plugins_dev/directory-publisher/server.js @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const http = require("http"); const https = require("https"); const CONFIG_PATH = path.join(__dirname, "config.json"); @@ -39,12 +40,14 @@ function postJson(targetUrl, token, payload) { return; } + const transport = u.protocol === "http:" ? http : https; + const port = u.port ? Number(u.port) : u.protocol === "http:" ? 80 : 443; const body = Buffer.from(JSON.stringify(payload), "utf8"); - const req = https.request( + const req = transport.request( { method: "POST", hostname: u.hostname, - port: u.port || 443, + port, path: u.pathname + u.search, headers: { "Content-Type": "application/json", @@ -105,4 +108,3 @@ module.exports = function init(api) { api.log("info", "directory-publisher loaded"); }; - diff --git a/plugins_dev/directory-server/client.js b/plugins_dev/directory-server/client.js @@ -1,4 +1,292 @@ window.BzlPluginHost.register("directory-server", (ctx) => { ctx.devLog("info", "directory-server client loaded"); -}); + const pluginId = ctx.id; + const apiBase = `/api/plugins/${encodeURIComponent(pluginId)}`; + + let panelMount = null; + let modMount = null; + let lastList = []; + let lastConfig = null; + let lastEntries = []; + + const el = (tag, props = {}, children = []) => { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(props || {})) { + if (k === "className") node.className = String(v || ""); + else if (k === "text") node.textContent = String(v ?? ""); + else if (k === "html") node.innerHTML = String(v ?? ""); + else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2).toLowerCase(), v); + else if (v === false || v == null) continue; + else node.setAttribute(k, String(v)); + } + for (const c of children) node.appendChild(c); + return node; + }; + + const fmtTime = (t) => { + const n = Number(t || 0); + if (!n) return "-"; + try { + return new Date(n).toLocaleString(); + } catch { + return String(n); + } + }; + + const getHost = (rawUrl) => { + try { + return new URL(String(rawUrl || "")).hostname || ""; + } catch { + return ""; + } + }; + + async function loadPublicList() { + try { + const r = await fetch(`${apiBase}/list`, { cache: "no-store" }); + const j = await r.json(); + if (!j?.ok) throw new Error(j?.error || "Failed to load list"); + lastList = Array.isArray(j.entries) ? j.entries : []; + } catch (e) { + ctx.devLog("warn", "directory list fetch failed", { error: e?.message || String(e) }); + lastList = []; + } + renderDirectoryPanel(); + } + + function renderDirectoryPanel() { + if (!panelMount) return; + panelMount.innerHTML = ""; + + const top = el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px; margin-bottom:10px" }, [ + el("div", { className: "row", style: "gap:10px; align-items:center" }, [ + el("div", { text: "Directory", style: "font-weight:700" }), + el("div", { className: "muted small", text: `${lastList.length} instance${lastList.length === 1 ? "" : "s"}` }), + ]), + el("div", { className: "row", style: "gap:8px; align-items:center" }, [ + el("button", { + type: "button", + className: "ghost", + text: "Refresh", + onclick: () => loadPublicList(), + }), + ]), + ]); + + const list = el("div", { style: "display:flex; flex-direction:column; gap:10px" }); + if (!lastList.length) { + list.appendChild(el("div", { className: "muted", text: "No directory entries yet." })); + } else { + for (const entry of lastList) { + const inst = entry?.instance || {}; + const name = String(inst.name || inst.id || "Instance").slice(0, 80); + const url = String(inst.url || ""); + const desc = String(inst.description || "").slice(0, 240); + const hives = Array.isArray(entry?.publicHives) ? entry.publicHives : []; + + const card = el("div", { className: "panel", style: "padding:12px" }); + card.appendChild( + el("div", { className: "row", style: "justify-content:space-between; gap:10px; align-items:flex-start" }, [ + el("div", {}, [ + el("div", { text: name, style: "font-weight:700; margin-bottom:2px" }), + el("div", { className: "muted small", text: url }), + ]), + el("a", { className: "ghost smallBtn", href: url || "#", target: "_blank", rel: "noreferrer", text: "Open" }), + ]) + ); + if (desc) card.appendChild(el("div", { className: "small", text: desc, style: "margin-top:8px" })); + card.appendChild(el("div", { className: "muted small", text: `Last seen: ${fmtTime(entry?.lastSeenAt)}`, style: "margin-top:8px" })); + + if (hives.length) { + const ul = el("ul", { style: "margin:8px 0 0 18px" }); + for (const h of hives.slice(0, 10)) { + const li = el("li", { className: "small" }); + const a = el("a", { href: String(h?.url || "#"), target: "_blank", rel: "noreferrer", text: String(h?.title || "Hive") }); + li.appendChild(a); + const hd = String(h?.description || "").trim(); + if (hd) li.appendChild(el("span", { className: "muted", text: ` — ${hd}` })); + ul.appendChild(li); + } + card.appendChild(el("div", { className: "muted small", text: "Public hives:", style: "margin-top:10px" })); + card.appendChild(ul); + } + + list.appendChild(card); + } + } + + panelMount.appendChild(top); + panelMount.appendChild(list); + } + + function renderMod() { + if (!modMount) return; + modMount.innerHTML = ""; + + const tokenSet = Boolean(lastConfig?.tokenSet); + const hiddenIds = Array.isArray(lastConfig?.hiddenIds) ? lastConfig.hiddenIds : []; + const blockedHosts = Array.isArray(lastConfig?.blockedHosts) ? lastConfig.blockedHosts : []; + + const tokenInput = el("input", { + placeholder: tokenSet ? "Token is set (enter to replace)" : "Set directory token (shared secret)", + style: "flex:1", + }); + const saveBtn = el("button", { + type: "button", + className: "primary", + text: "Save token", + onclick: () => { + const token = String(tokenInput.value || "").trim(); + ctx.send("setToken", { token }); + tokenInput.value = ""; + }, + }); + + modMount.appendChild( + el("div", { className: "panel", style: "padding:12px; margin-bottom:12px" }, [ + el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px" }, [ + el("div", {}, [el("div", { text: "Directory settings", style: "font-weight:700" }), el("div", { className: "muted small", text: tokenSet ? "Token: set" : "Token: not set" })]), + el("button", { type: "button", className: "ghost", text: "Refresh", onclick: () => ctx.send("getConfig", {}) }), + ]), + el("div", { className: "row", style: "gap:10px; margin-top:10px" }, [tokenInput, saveBtn]), + ]) + ); + + const blockedWrap = el("div", { className: "panel", style: "padding:12px; margin-bottom:12px" }); + blockedWrap.appendChild(el("div", { text: "Blocked hosts", style: "font-weight:700; margin-bottom:6px" })); + if (!blockedHosts.length) blockedWrap.appendChild(el("div", { className: "muted small", text: "None." })); + else { + const ul = el("ul", { style: "margin:0 0 0 18px" }); + for (const host of blockedHosts.slice(0, 200)) { + const li = el("li", { className: "small" }); + li.appendChild(el("span", { text: host })); + li.appendChild( + el("button", { + type: "button", + className: "ghost smallBtn", + text: "Unblock", + style: "margin-left:10px", + onclick: () => ctx.send("setBlockedHost", { host, blocked: false }), + }) + ); + ul.appendChild(li); + } + blockedWrap.appendChild(ul); + } + modMount.appendChild(blockedWrap); + + const entriesWrap = el("div", { className: "panel", style: "padding:12px" }); + entriesWrap.appendChild( + el("div", { className: "row", style: "justify-content:space-between; align-items:center; gap:10px; margin-bottom:8px" }, [ + el("div", { text: "Entries", style: "font-weight:700" }), + el("button", { type: "button", className: "ghost", text: "Refresh", onclick: () => ctx.send("getEntries", {}) }), + ]) + ); + + if (!lastEntries.length) { + entriesWrap.appendChild(el("div", { className: "muted small", text: "No entries yet." })); + } else { + const list = el("div", { style: "display:flex; flex-direction:column; gap:10px" }); + for (const entry of lastEntries) { + const inst = entry?.instance || {}; + const id = String(inst.id || "").trim().toLowerCase(); + const name = String(inst.name || inst.id || "Instance").slice(0, 60); + const url = String(inst.url || ""); + const host = getHost(url).toLowerCase(); + const isHidden = Boolean(id && hiddenIds.includes(id)); + const isBlocked = Boolean(host && blockedHosts.includes(host)); + + const row = el("div", { className: "row", style: "gap:10px; align-items:flex-start; justify-content:space-between" }); + const left = el("div", { style: "flex:1; min-width:0" }, [ + el("div", { text: name, style: "font-weight:700" }), + el("div", { className: "muted small", text: url }), + el("div", { className: "muted small", text: `Last seen: ${fmtTime(entry?.lastSeenAt)}` }), + ]); + + const controls = el("div", { className: "row", style: "gap:8px; align-items:center; flex-wrap:wrap; justify-content:flex-end" }); + const hideCb = el("input", { + type: "checkbox", + checked: isHidden ? "checked" : null, + onchange: (e) => ctx.send("setHidden", { id, hidden: Boolean(e?.target?.checked) }), + }); + controls.appendChild(el("label", { className: "row small", style: "gap:8px; align-items:center" }, [hideCb, el("span", { text: "Hide" })])); + if (host) { + controls.appendChild( + el("button", { + type: "button", + className: isBlocked ? "ghost" : "ghost", + text: isBlocked ? "Blocked" : "Block host", + onclick: () => ctx.send("setBlockedHost", { host, blocked: true }), + disabled: isBlocked ? "disabled" : null, + }) + ); + } + controls.appendChild( + el("button", { + type: "button", + className: "ghost", + text: "Delete", + onclick: () => { + if (!confirm(`Delete entry \"${name}\"?`)) return; + ctx.send("deleteEntry", { id }); + }, + }) + ); + + row.appendChild(left); + row.appendChild(controls); + list.appendChild(el("div", { style: "border-top:1px solid rgba(255,255,255,0.06); padding-top:10px" }, [row])); + } + entriesWrap.appendChild(list); + } + + modMount.appendChild(entriesWrap); + } + + ctx.on("updated", () => { + loadPublicList(); + if (ctx.getRole() === "owner") ctx.send("getEntries", {}); + }); + ctx.on("config", (msg) => { + lastConfig = msg && typeof msg === "object" ? msg : null; + renderMod(); + }); + ctx.on("entries", (msg) => { + lastEntries = Array.isArray(msg?.entries) ? msg.entries : []; + renderMod(); + }); + ctx.on("configUpdated", () => { + if (ctx.getRole() === "owner") { + ctx.send("getConfig", {}); + ctx.send("getEntries", {}); + } + }); + + ctx.ui.registerPanel({ + id: "directory", + title: "Directory", + icon: "📡", + defaultRack: "main", + role: "primary", + render(mount) { + panelMount = mount; + loadPublicList(); + return () => { + if (panelMount === mount) panelMount = null; + }; + }, + }); + + ctx.ui.registerModTab({ + id: "directory", + title: "Directory feed", + ownerOnly: true, + render(mount) { + modMount = mount; + renderMod(); + ctx.send("getConfig", {}); + ctx.send("getEntries", {}); + }, + }); +}); diff --git a/plugins_dev/directory-server/server.js b/plugins_dev/directory-server/server.js @@ -65,10 +65,23 @@ function sanitizePublicHives(payload) { } module.exports = function init(api) { - const config = readJson(CONFIG_PATH, { token: "" }); + const config = readJson(CONFIG_PATH, { token: "", hiddenIds: [], blockedHosts: [] }); const state = readJson(STATE_PATH, { version: 1, entries: {} }); const entries = new Map(Object.entries(state.entries || {})); + const normalizeId = (s) => String(s || "").trim().toLowerCase(); + const normalizeHost = (rawUrl) => { + try { + const u = new URL(String(rawUrl || "")); + return String(u.hostname || "").trim().toLowerCase(); + } catch { + return ""; + } + }; + + config.hiddenIds = Array.isArray(config.hiddenIds) ? config.hiddenIds.map(normalizeId).filter(Boolean) : []; + config.blockedHosts = Array.isArray(config.blockedHosts) ? config.blockedHosts.map((h) => String(h || "").trim().toLowerCase()).filter(Boolean) : []; + const persist = () => { const out = { version: 1, entries: Object.fromEntries(entries) }; writeJson(STATE_PATH, out); @@ -76,7 +89,13 @@ module.exports = function init(api) { api.registerWs("getConfig", (ws) => { if (ws?.user?.role !== "owner") return; - api.sendToUsers([ws.user.username], { type: "plugin:directory-server:config", tokenSet: Boolean(config.token) }); + api.sendToUsers([ws.user.username], { + type: "plugin:directory-server:config", + tokenSet: Boolean(config.token), + hiddenIds: config.hiddenIds.slice(0, 500), + blockedHosts: config.blockedHosts.slice(0, 500), + entryCount: entries.size + }); }); api.registerWs("setToken", (ws, msg) => { @@ -87,9 +106,70 @@ module.exports = function init(api) { api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) }); }); + api.registerWs("getEntries", (ws) => { + if (ws?.user?.role !== "owner") return; + const list = Array.from(entries.values()) + .map((e) => { + const host = normalizeHost(e?.instance?.url); + const id = normalizeId(e?.instance?.id); + return { + ...e, + host, + hidden: Boolean(id && config.hiddenIds.includes(id)), + blocked: Boolean(host && config.blockedHosts.includes(host)) + }; + }) + .sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0)); + api.sendToUsers([ws.user.username], { type: "plugin:directory-server:entries", entries: list }); + }); + + api.registerWs("setHidden", (ws, msg) => { + if (ws?.user?.role !== "owner") return; + const id = normalizeId(msg?.id); + const hidden = Boolean(msg?.hidden); + if (!id) return; + const set = new Set(config.hiddenIds); + if (hidden) set.add(id); + else set.delete(id); + config.hiddenIds = Array.from(set.values()).sort(); + writeJson(CONFIG_PATH, config); + api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) }); + }); + + api.registerWs("setBlockedHost", (ws, msg) => { + if (ws?.user?.role !== "owner") return; + const host = String(msg?.host || "").trim().toLowerCase(); + const blocked = Boolean(msg?.blocked); + if (!host) return; + const set = new Set(config.blockedHosts); + if (blocked) set.add(host); + else set.delete(host); + config.blockedHosts = Array.from(set.values()).sort(); + writeJson(CONFIG_PATH, config); + api.broadcast({ type: "plugin:directory-server:configUpdated", tokenSet: Boolean(config.token) }); + }); + + api.registerWs("deleteEntry", (ws, msg) => { + if (ws?.user?.role !== "owner") return; + const id = normalizeId(msg?.id); + if (!id) return; + entries.delete(id); + persist(); + api.broadcast({ type: "plugin:directory-server:updated", id, deleted: true }); + }); + api.registerHttp("GET", "/list", (_req, res, ctx) => { + const hidden = new Set(config.hiddenIds); + const blocked = new Set(config.blockedHosts); const list = Array.from(entries.values()) .map((e) => e) + .filter((e) => { + const id = normalizeId(e?.instance?.id); + if (id && hidden.has(id)) return false; + const host = normalizeHost(e?.instance?.url); + if (host && blocked.has(host)) return false; + return true; + }) .sort((a, b) => (b.lastSeenAt || 0) - (a.lastSeenAt || 0)); ctx.sendJson(200, { ok: true, entries: list }); res.end(); @@ -120,4 +200,3 @@ module.exports = function init(api) { api.log("info", "directory-server loaded", { http: ["/announce", "/list"] }); }; - diff --git a/public/app.js b/public/app.js @@ -4163,12 +4163,17 @@ function sendDevLog(level, scope, message, data) { window.bzlDevLog = sendDevLog; +// Plugin event handlers: pluginId -> eventName -> Set<fn(msg)> +const pluginClientHandlers = new Map(); +// Moderation plugin tabs: fullTabId -> { title, ownerOnly, render(mount, api), pluginId } +const modPluginTabs = new Map(); + // Minimal plugin host (client-side). Plugins are trusted by the owner who installs them. // Plugin scripts can call `window.BzlPluginHost.register("pluginId", (ctx) => { ... })`. if (!window.BzlPluginHost) { const pluginInits = new Map(); window.BzlPluginHost = { - apiVersion: 2, + apiVersion: 3, register(pluginId, initFn) { const id = String(pluginId || "").trim().toLowerCase(); if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(id)) throw new Error("Invalid plugin id"); @@ -4181,7 +4186,59 @@ if (!window.BzlPluginHost) { toast, getUser: () => loggedInUser, getRole: () => loggedInRole, + on(eventName, handler) { + const ev = String(eventName || "").trim(); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) throw new Error("Invalid event name"); + if (typeof handler !== "function") throw new Error("handler must be a function"); + let byEvent = pluginClientHandlers.get(id); + if (!byEvent) { + byEvent = new Map(); + pluginClientHandlers.set(id, byEvent); + } + let set = byEvent.get(ev); + if (!set) { + set = new Set(); + byEvent.set(ev, set); + } + set.add(handler); + return () => { + try { + set.delete(handler); + } catch { + // ignore + } + }; + }, ui: { + registerModTab(tabDef) { + const tabId = String(tabDef?.id || id).trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(tabId)) throw new Error("Invalid tab id"); + const title = typeof tabDef?.title === "string" ? tabDef.title.trim().slice(0, 22) : tabId; + const ownerOnly = Boolean(tabDef?.ownerOnly); + const render = tabDef?.render; + if (typeof render !== "function") throw new Error("render must be a function"); + + const fullId = `plugin:${id}:${tabId}`; + modPluginTabs.set(fullId, { title, ownerOnly, render, pluginId: id }); + + const tabsEl = modPanelEl?.querySelector?.(".modTabs"); + if (tabsEl && !tabsEl.querySelector(`[data-modtab="${CSS.escape(fullId)}"]`)) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "ghost"; + btn.textContent = title; + btn.setAttribute("data-modtab", fullId); + btn.dataset.ownerOnly = ownerOnly ? "1" : "0"; + tabsEl.appendChild(btn); + } + + // If the tab isn't visible for this user, don't allow it to become active. + if (ownerOnly && loggedInRole !== "owner" && modTab === fullId) { + modTab = "server"; + renderModPanel(); + } + return true; + }, registerPanel(panelDef) { const panelId = String(panelDef?.id || id).trim().toLowerCase(); if (!/^[a-z0-9][a-z0-9_.-]{0,31}$/.test(panelId)) throw new Error("Invalid panel id"); @@ -5546,6 +5603,49 @@ function renderModPanel() { const on = btn.getAttribute("data-modtab") === modTab; btn.classList.toggle("primary", on); btn.classList.toggle("ghost", !on); + // Owner-only plugin tabs should not show for non-owners. + const ownerOnly = btn.dataset.ownerOnly === "1"; + btn.classList.toggle("hidden", Boolean(ownerOnly && loggedInRole !== "owner")); + } + + // Plugin-provided moderation tabs (render into modBody). + if (modPluginTabs.has(modTab)) { + const def = modPluginTabs.get(modTab); + if (def?.ownerOnly && loggedInRole !== "owner") { + modTab = "server"; + renderModPanel(); + return; + } + modBodyEl.innerHTML = ` + <div class="modCard"> + <div class="modRowTop"><div><b>${escapeHtml(def?.title || "Plugin")}</b></div></div> + <div id="modPluginMount" class="modActions"></div> + </div> + `; + const mount = modBodyEl.querySelector("#modPluginMount"); + if (mount) { + const api = { + toast, + send: (eventName, payload) => { + const ev = String(eventName || "").trim(); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(ev)) return false; + const wsRef = window.__bzlWs; + if (!wsRef || wsRef.readyState !== WebSocket.OPEN) return false; + const msg = payload && typeof payload === "object" ? payload : {}; + wsRef.send(JSON.stringify({ ...msg, type: `plugin:${def.pluginId}:${ev}` })); + return true; + }, + getUser: () => loggedInUser, + getRole: () => loggedInRole, + }; + try { + def.render(mount, api); + } catch (e) { + mount.textContent = "Failed to render plugin tab."; + console.warn(`Plugin tab render failed (${modTab}):`, e?.message || e); + } + } + return; } if (modTab === "server") { @@ -8698,6 +8798,28 @@ function onWsMessage(evt) { return; } + // Generic plugin event dispatch: `plugin:<pluginId>:<eventName>` + // (Maps has some core-handled messages below; for other plugins, dispatch + stop.) + if (typeof msg.type === "string") { + const m = msg.type.match(/^plugin:([a-z0-9][a-z0-9_.-]{0,31}):([a-zA-Z0-9][a-zA-Z0-9_.-]{0,63})$/); + if (m) { + const pluginId = String(m[1] || "").toLowerCase(); + const ev = String(m[2] || ""); + const byEvent = pluginClientHandlers.get(pluginId); + const set = byEvent ? byEvent.get(ev) : null; + if (set && set.size) { + for (const fn of Array.from(set)) { + try { + fn(msg); + } catch (e) { + console.warn(`Plugin handler failed (${pluginId}:${ev}):`, e?.message || e); + } + } + } + if (pluginId !== "maps") return; + } + } + if (msg.type === "plugin:maps:joinOk") { const map = msg.map && typeof msg.map === "object" ? msg.map : null; const mapId = map && typeof map.id === "string" ? map.id.trim().toLowerCase() : ""; diff --git a/public/index.html b/public/index.html @@ -534,6 +534,6 @@ </div> <div id="dockHotbar" class="dockHotbar hidden" aria-label="Docked panels"></div> - <script src="/app.js?v=120"></script> + <script src="/app.js?v=121"></script> </body> </html> diff --git a/scripts/stream-pack-detect-ip.js b/scripts/stream-pack-detect-ip.js @@ -0,0 +1,62 @@ +const https = require("https"); + +function getJson(url, { timeoutMs = 2500 } = {}) { + return new Promise((resolve) => { + let settled = false; + const done = (v) => { + if (settled) return; + settled = true; + resolve(v); + }; + try { + const u = new URL(url); + const req = https.request( + { + method: "GET", + protocol: u.protocol, + hostname: u.hostname, + port: u.port, + path: `${u.pathname || "/"}${u.search || ""}`, + headers: { Accept: "application/json", "Cache-Control": "no-store" } + }, + (res) => { + let buf = ""; + res.setEncoding("utf8"); + res.on("data", (c) => (buf += c)); + res.on("end", () => { + try { + done({ ok: true, status: res.statusCode || 0, json: JSON.parse(buf || "{}") }); + } catch { + done({ ok: false, status: res.statusCode || 0 }); + } + }); + } + ); + req.on("error", (e) => done({ ok: false, error: e?.message || String(e) })); + req.setTimeout(timeoutMs, () => { + try { + req.destroy(new Error("timeout")); + } catch { + // ignore + } + done({ ok: false, timeout: true }); + }); + req.end(); + } catch (e) { + done({ ok: false, error: e?.message || String(e) }); + } + }); +} + +async function main() { + const r = await getJson("https://api.ipify.org?format=json"); + const ip = String(r?.json?.ip || "").trim(); + if (!ip) { + console.error("[stream-pack] Failed to detect IP."); + process.exit(1); + } + console.log(ip); +} + +main(); + diff --git a/scripts/stream-pack-down.js b/scripts/stream-pack-down.js @@ -0,0 +1,25 @@ +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.join(__dirname, ".."); +const PACK_DIR = path.join(ROOT, "stream_pack"); + +function run(cmd, args, opts = {}) { + return spawnSync(cmd, args, { stdio: "inherit", cwd: PACK_DIR, ...opts }); +} + +function main() { + if (!fs.existsSync(path.join(PACK_DIR, "docker-compose.yml"))) { + console.error("[stream-pack] Missing stream_pack/docker-compose.yml."); + process.exit(1); + } + + let r = run("docker", ["compose", "down"]); + if (r.status === 0) return; + r = run("docker-compose", ["down"]); + process.exit(r.status || 1); +} + +main(); + diff --git a/scripts/stream-pack-init.js b/scripts/stream-pack-init.js @@ -0,0 +1,317 @@ +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const https = require("https"); + +const ROOT = path.join(__dirname, ".."); +const PACK_DIR = path.join(ROOT, "stream_pack"); + +function log(msg) { + console.log(`[stream-pack] ${msg}`); +} + +function fail(msg) { + console.error(`[stream-pack] ERROR: ${msg}`); + process.exit(1); +} + +function parseArgs() { + const args = process.argv.slice(2); + const out = {}; + for (const a of args) { + const m = String(a || "").match(/^--([^=]+)=(.*)$/); + if (m) out[m[1]] = m[2]; + } + return out; +} + +function randHex(bytes) { + return crypto.randomBytes(bytes).toString("hex"); +} + +function randToken(bytes) { + return crypto.randomBytes(bytes).toString("base64url"); +} + +function normalizeDomain(s) { + const v = String(s || "").trim().toLowerCase(); + if (!v) return ""; + if (!/^[a-z0-9.-]+$/.test(v)) return ""; + if (!v.includes(".")) return ""; + return v; +} + +function getJson(url, { timeoutMs = 2500 } = {}) { + return new Promise((resolve) => { + let settled = false; + const done = (v) => { + if (settled) return; + settled = true; + resolve(v); + }; + try { + const u = new URL(url); + const req = https.request( + { + method: "GET", + protocol: u.protocol, + hostname: u.hostname, + port: u.port, + path: `${u.pathname || "/"}${u.search || ""}`, + headers: { Accept: "application/json", "Cache-Control": "no-store" } + }, + (res) => { + let buf = ""; + res.setEncoding("utf8"); + res.on("data", (c) => (buf += c)); + res.on("end", () => { + try { + done({ ok: true, status: res.statusCode || 0, json: JSON.parse(buf || "{}") }); + } catch { + done({ ok: true, status: res.statusCode || 0, json: {} }); + } + }); + } + ); + req.on("error", (e) => done({ ok: false, error: e?.message || String(e) })); + req.setTimeout(timeoutMs, () => { + try { + req.destroy(new Error("timeout")); + } catch { + // ignore + } + done({ ok: false, timeout: true }); + }); + req.end(); + } catch (e) { + done({ ok: false, error: e?.message || String(e) }); + } + }); +} + +function writeFileIfMissing(filePath, content) { + if (fs.existsSync(filePath)) return false; + fs.writeFileSync(filePath, content, "utf8"); + return true; +} + +function writeFileAlways(filePath, content) { + fs.writeFileSync(filePath, content, "utf8"); +} + +function readEnvFile(envPath) { + try { + const raw = fs.readFileSync(envPath, "utf8"); + const out = {}; + for (const line of raw.split(/\r?\n/)) { + const s = line.trim(); + if (!s || s.startsWith("#")) continue; + const m = s.match(/^([A-Z0-9_]+)=(.*)$/); + if (!m) continue; + let v = m[2] || ""; + v = v.trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); + out[m[1]] = v; + } + return out; + } catch { + return {}; + } +} + +function main() { + const args = parseArgs(); + const domain = normalizeDomain(args.domain); + const email = String(args.email || "").trim(); + + if (!domain) { + fail('Missing/invalid `--domain=...` (example: --domain=stream.example.com)'); + } + if (!email || !email.includes("@")) { + fail('Missing/invalid `--email=...` (needed for HTTPS certs; example: --email=you@example.com)'); + } + + fs.mkdirSync(PACK_DIR, { recursive: true }); + + const envPath = path.join(PACK_DIR, ".env"); + const existing = readEnvFile(envPath); + + const API_KEY = existing.LIVEKIT_API_KEY || randToken(8); + const API_SECRET = existing.LIVEKIT_API_SECRET || randToken(24); + const TURN_USER = existing.TURN_USER || "bzl"; + const TURN_PASS = existing.TURN_PASS || randToken(18); + const TURN_REALM = existing.TURN_REALM || domain; + const TURN_EXTERNAL_IP = existing.TURN_EXTERNAL_IP || ""; + const RTC_UDP_START = existing.RTC_UDP_START || "50000"; + const RTC_UDP_END = existing.RTC_UDP_END || "50100"; + const TURN_RELAY_START = existing.TURN_RELAY_START || "49160"; + const TURN_RELAY_END = existing.TURN_RELAY_END || "49200"; + + const envBody = `# Stream Pack (optional) +# +# This stack is separate from core Bzl. Core stays runnable without any domain. +# Streaming requires HTTPS and a public server. + +STREAM_DOMAIN=${domain} +STREAM_EMAIL=${email} + +# LiveKit auth (Bzl will mint JWTs with these) +LIVEKIT_API_KEY=${API_KEY} +LIVEKIT_API_SECRET=${API_SECRET} + +# TURN (coturn) credentials for LiveKit clients +TURN_USER=${TURN_USER} +TURN_PASS=${TURN_PASS} +TURN_REALM=${TURN_REALM} +TURN_EXTERNAL_IP=${TURN_EXTERNAL_IP} + +# WebRTC UDP port range exposed by livekit-server (keep small for firewall sanity) +RTC_UDP_START=${RTC_UDP_START} +RTC_UDP_END=${RTC_UDP_END} + +# TURN relay UDP ports (coturn will allocate from this range) +TURN_RELAY_START=${TURN_RELAY_START} +TURN_RELAY_END=${TURN_RELAY_END} +`; + + if (!fs.existsSync(envPath)) { + writeFileAlways(envPath, envBody); + log(`Wrote ${path.relative(ROOT, envPath)}`); + } else { + // Keep user edits; only ensure required keys exist. + const merged = { ...existing }; + const ensure = (k, v) => { + if (!merged[k]) merged[k] = v; + }; + ensure("STREAM_DOMAIN", domain); + ensure("STREAM_EMAIL", email); + ensure("LIVEKIT_API_KEY", API_KEY); + ensure("LIVEKIT_API_SECRET", API_SECRET); + ensure("TURN_USER", TURN_USER); + ensure("TURN_PASS", TURN_PASS); + ensure("TURN_REALM", TURN_REALM); + ensure("TURN_EXTERNAL_IP", TURN_EXTERNAL_IP); + ensure("RTC_UDP_START", RTC_UDP_START); + ensure("RTC_UDP_END", RTC_UDP_END); + ensure("TURN_RELAY_START", TURN_RELAY_START); + ensure("TURN_RELAY_END", TURN_RELAY_END); + + const lines = [ + "# Stream Pack (optional)", + "#", + "# This stack is separate from core Bzl. Core stays runnable without any domain.", + "# Streaming requires HTTPS and a public server.", + "", + `STREAM_DOMAIN=${merged.STREAM_DOMAIN}`, + `STREAM_EMAIL=${merged.STREAM_EMAIL}`, + "", + "# LiveKit auth (Bzl will mint JWTs with these)", + `LIVEKIT_API_KEY=${merged.LIVEKIT_API_KEY}`, + `LIVEKIT_API_SECRET=${merged.LIVEKIT_API_SECRET}`, + "", + "# TURN (coturn) credentials for LiveKit clients", + `TURN_USER=${merged.TURN_USER}`, + `TURN_PASS=${merged.TURN_PASS}`, + `TURN_REALM=${merged.TURN_REALM}`, + `TURN_EXTERNAL_IP=${merged.TURN_EXTERNAL_IP || ""}`, + "", + "# WebRTC UDP port range exposed by livekit-server (keep small for firewall sanity)", + `RTC_UDP_START=${merged.RTC_UDP_START}`, + `RTC_UDP_END=${merged.RTC_UDP_END}`, + "", + "# TURN relay UDP ports (coturn will allocate from this range)", + `TURN_RELAY_START=${merged.TURN_RELAY_START}`, + `TURN_RELAY_END=${merged.TURN_RELAY_END}`, + "" + ]; + writeFileAlways(envPath, lines.join("\n")); + log(`Updated ${path.relative(ROOT, envPath)}`); + } + + const livekitYamlPath = path.join(PACK_DIR, "livekit.yaml"); + const livekitYaml = `port: 7880 +log_level: info + +rtc: + tcp_port: 7881 + port_range_start: ${RTC_UDP_START} + port_range_end: ${RTC_UDP_END} + use_external_ip: true + turn_servers: + - host: ${domain} + port: 3478 + protocol: udp + username: ${TURN_USER} + credential: ${TURN_PASS} + +keys: + ${API_KEY}: ${API_SECRET} +`; + writeFileAlways(livekitYamlPath, livekitYaml); + log(`Wrote ${path.relative(ROOT, livekitYamlPath)}`); + + const composePath = path.join(PACK_DIR, "docker-compose.yml"); + const composeBody = `services: + livekit: + image: livekit/livekit-server:latest + container_name: bzl_livekit + restart: unless-stopped + command: --config /etc/livekit.yaml + volumes: + - ./livekit.yaml:/etc/livekit.yaml:ro + ports: + - "127.0.0.1:7880:7880" + - "7881:7881" + - "${RTC_UDP_START}-${RTC_UDP_END}:${RTC_UDP_START}-${RTC_UDP_END}/udp" + + turn: + image: coturn/coturn:latest + container_name: bzl_turn + restart: unless-stopped + network_mode: host + command: + - -n + - --log-file=stdout + - --realm=${TURN_REALM} + - --fingerprint + - --lt-cred-mech + - --no-tls + - --no-dtls + - --no-cli + - --no-multicast-peers + - --no-loopback-peers + - --listening-port=3478 + - --min-port=${TURN_RELAY_START} + - --max-port=${TURN_RELAY_END} + - --user=${TURN_USER}:${TURN_PASS} + - --external-ip=${TURN_EXTERNAL_IP} +`; + writeFileAlways(composePath, composeBody); + log(`Wrote ${path.relative(ROOT, composePath)}`); + + const caddySnippetPath = path.join(PACK_DIR, "Caddyfile.snippet"); + const caddySnippet = `${domain} { + encode zstd gzip + reverse_proxy 127.0.0.1:7880 +} +`; + writeFileAlways(caddySnippetPath, caddySnippet); + log(`Wrote ${path.relative(ROOT, caddySnippetPath)}`); + + const readmePath = path.join(PACK_DIR, "README.md"); + writeFileAlways( + readmePath, + `# Stream Pack (LiveKit + TURN)\n\nThis folder is generated by \`node scripts/stream-pack-init.js\`.\n\n## What this is\n- Optional infrastructure for the future Bzl streaming plugin.\n- Keeps core Bzl launchable without any domain name.\n- Uses LiveKit (SFU) + coturn for NAT traversal.\n\n## Requirements\n- Dedicated server with Docker + Docker Compose\n- HTTPS reverse proxy (Caddy recommended)\n- DNS A record: \`${domain}\` → your server\n - Important: set this record to **DNS-only** (no Cloudflare proxy), otherwise UDP will break.\n\n## 1) Set TURN external IP\nEdit \`.env\` and set:\n\n\`\`\nTURN_EXTERNAL_IP=<your_server_public_ipv4>\n\`\`\n\n## 2) Reverse proxy (HTTPS)\nAdd \`Caddyfile.snippet\` to your Caddy config, then reload Caddy.\n\n## 3) Open firewall ports\nMinimum ports:\n- TCP 80/443 (Caddy)\n- TCP 7881 (LiveKit TCP fallback)\n- UDP \`${RTC_UDP_START}-${RTC_UDP_END}\` (LiveKit media)\n- UDP 3478 (TURN)\n- UDP \`${TURN_RELAY_START}-${TURN_RELAY_END}\` (TURN relays)\n\n## 4) Start services\nFrom this folder:\n\n\`\`\ncd stream_pack\ndocker compose up -d\n\`\`\n\n## Notes\n- LiveKit signaling is kept on localhost:7880 and exposed via Caddy.\n- TURN uses host networking (best reliability).\n` + ); + log(`Wrote ${path.relative(ROOT, readmePath)}`); + + log("Done."); + log("Next: edit stream_pack/.env (TURN_EXTERNAL_IP), add the Caddy snippet, open firewall ports, then run `docker compose up -d`."); + + if (!existing.TURN_EXTERNAL_IP) { + log("Optional: auto-detect public IP:"); + log(" node scripts/stream-pack-detect-ip.js"); + } +} + +main(); diff --git a/scripts/stream-pack-status.js b/scripts/stream-pack-status.js @@ -0,0 +1,25 @@ +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.join(__dirname, ".."); +const PACK_DIR = path.join(ROOT, "stream_pack"); + +function run(cmd, args, opts = {}) { + return spawnSync(cmd, args, { stdio: "inherit", cwd: PACK_DIR, ...opts }); +} + +function main() { + if (!fs.existsSync(path.join(PACK_DIR, "docker-compose.yml"))) { + console.error("[stream-pack] Missing stream_pack/docker-compose.yml."); + process.exit(1); + } + + let r = run("docker", ["compose", "ps"]); + if (r.status === 0) return; + r = run("docker-compose", ["ps"]); + process.exit(r.status || 1); +} + +main(); + diff --git a/scripts/stream-pack-up.js b/scripts/stream-pack-up.js @@ -0,0 +1,28 @@ +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.join(__dirname, ".."); +const PACK_DIR = path.join(ROOT, "stream_pack"); + +function run(cmd, args, opts = {}) { + return spawnSync(cmd, args, { stdio: "inherit", cwd: PACK_DIR, ...opts }); +} + +function main() { + if (!fs.existsSync(path.join(PACK_DIR, "docker-compose.yml"))) { + console.error("[stream-pack] Missing stream_pack/docker-compose.yml. Run `node scripts/stream-pack-init.js ...` first."); + process.exit(1); + } + + // Prefer modern docker compose + let r = run("docker", ["compose", "up", "-d"]); + if (r.status === 0) return; + + // Fallback to legacy docker-compose + r = run("docker-compose", ["up", "-d"]); + process.exit(r.status || 1); +} + +main(); +