diff --git a/plugins/team-relay/index.js b/plugins/team-relay/index.js new file mode 100644 index 0000000..70c7f6c --- /dev/null +++ b/plugins/team-relay/index.js @@ -0,0 +1,243 @@ +// Team Relay — presence + coordinator tools over a GENERIC relay. +// +// The relay (see exbyte-relay/) is a dumb message bus: it routes opaque JSON and +// tracks who's online, and knows nothing about Perforce. ALL the logic lives +// here and runs through the permission-gated `host.team` bridge: +// - every ~4s we announce presence (user, #open files, access level) and drain +// our inbox in one /sync round-trip; +// - a coordinator (admin/super) can ask a teammate's client to shelve its open +// work to the server — the teammate's client does the shelve, nothing is +// submitted, and their files stay checked out (shelve is non-destructive). +// +// Relay URL/token/room are configured in the Team tab and stored per-plugin — +// nothing is hardcoded into the app. + +export function activate(host) { + const team = host.team; + if (!team) { host.log('no team bridge — plugin.json needs the "team" permission'); return; } + + const S = host.storage; + if (!S.get("room")) S.set("room", "team"); + const getCfg = () => ({ + url: (S.get("relayUrl") || "").trim().replace(/\/+$/, ""), + token: (S.get("relayToken") || "").trim(), + room: (S.get("room") || "team").trim() || "team", + }); + + const me = team.whoami() || {}; + const myId = me.client || me.user || "unknown"; + let access = ""; + team.maxAccess().then((a) => { access = String(a || "").toLowerCase(); updateAll(); }).catch(() => {}); + const isCoordinator = () => access === "super" || access === "admin"; + + let members = []; + let status = "idle"; // idle | ok | error + let statusMsg = ""; + const updaters = new Set(); + const updateAll = () => updaters.forEach((fn) => { try { fn(); } catch (_) {} }); + + // ---- relay I/O (backend HTTP pipe → no CORS, any http(s) URL) -------------- + async function post(path, bodyObj) { + const c = getCfg(); + if (!c.url) throw new Error("Relay URL not set"); + const txt = await team.relay(c.url + path, "POST", JSON.stringify(bodyObj), c.token); + return JSON.parse(txt || "{}"); + } + async function send(to, type, payload) { + const c = getCfg(); + await post("/send", { room: c.room, to, from: myId, type, payload: payload || {} }); + } + + // ---- inbound message handling --------------------------------------------- + async function handle(msg) { + const from = msg.from || "A teammate"; + if (msg.type === "shelve-request") { + host.flash(from + " is shelving your open work to the server…"); + host.notify("Team Relay", from + " is shelving your open work to the server…"); + try { + const n = await team.openCount(); + if (!n) { await send(from, "shelve-result", { ok: true, change: "", files: 0 }); return; } + const r = await team.shelveOpen("Remote shelve requested by " + from + " via Team Relay"); + host.notify("Team Relay", "Your open work was shelved as CL " + r.change + " (" + r.files + " file(s)). Your files stay checked out."); + await send(from, "shelve-result", { ok: true, change: r.change, files: r.files }); + } catch (e) { + host.flash("Remote shelve failed: " + e, true); + await send(from, "shelve-result", { ok: false, error: String(e) }); + } + } else if (msg.type === "shelve-result") { + const p = msg.payload || {}; + if (p.ok && p.change) host.flash(from + ": shelved " + p.files + " file(s) as CL " + p.change + " — find it under Pending / Shelved."); + else if (p.ok) host.flash(from + ": nothing open to shelve."); + else host.flash(from + ": shelve failed — " + p.error, true); + } + } + + // ---- presence loop (one global timer, survives plugin reloads) ------------ + let busy = false; + async function tick() { + if (busy) return; + busy = true; + try { + const c = getCfg(); + if (!c.url) { status = "idle"; statusMsg = "Set the relay URL below."; return; } + const openCount = await team.openCount().catch(() => 0); + const res = await post("/sync", { + room: c.room, id: myId, name: me.user || myId, + meta: { client: myId, user: me.user || "", openCount, access }, + }); + members = Array.isArray(res.members) ? res.members : []; + status = "ok"; + statusMsg = members.length + (members.length === 1 ? " online" : " online"); + for (const m of (Array.isArray(res.messages) ? res.messages : [])) await handle(m); + } catch (e) { + status = "error"; + statusMsg = String(e).replace(/^Error:\s*/, ""); + } finally { + busy = false; + updateAll(); + } + } + // Single presence loop even if the plugin is reloaded (blob module re-imported). + if (typeof window !== "undefined") { + if (window.__exbyteTeamRelayTimer) clearInterval(window.__exbyteTeamRelayTimer); + window.__exbyteTeamRelayTimer = setInterval(tick, 4000); + } + tick(); + + // ---- UI helpers ------------------------------------------------------------ + function styleBtn(b, primary) { + b.style.cssText = + "padding:4px 10px;border-radius:6px;cursor:pointer;white-space:nowrap;font:12px var(--sans,system-ui,sans-serif);" + + (primary + ? "background:var(--accent,#7c6ef6);color:#fff;border:1px solid var(--accent,#7c6ef6)" + : "background:transparent;color:var(--muted,#9a9aa8);border:1px solid var(--border,#33333d)"); + if (b.disabled) { b.style.opacity = "0.5"; b.style.cursor = "default"; } + } + + function buildSettings() { + const wrap = document.createElement("div"); + wrap.style.cssText = + "display:flex;flex-direction:column;gap:6px;padding:9px;border:1px solid var(--border,#26262e);border-radius:8px;background:var(--panel,#17171d)"; + const title = document.createElement("div"); + title.textContent = "Relay settings"; + title.style.cssText = "font-weight:600;margin-bottom:2px"; + const field = (label, key, ph, pw) => { + const l = document.createElement("label"); + l.style.cssText = "display:flex;flex-direction:column;gap:2px;font-size:11px;color:var(--muted,#9a9aa8)"; + l.appendChild(document.createTextNode(label)); + const inp = document.createElement("input"); + inp.type = pw ? "password" : "text"; + inp.placeholder = ph || ""; + inp.value = S.get(key) || ""; + inp.style.cssText = + "padding:5px 7px;border:1px solid var(--border,#33333d);border-radius:6px;background:var(--bg,#0e0e12);color:var(--text,#e8e8ee);font:12px var(--mono,ui-monospace,monospace)"; + inp.addEventListener("input", () => S.set(key, inp.value)); + l.appendChild(inp); + return l; + }; + const save = document.createElement("button"); + save.textContent = "Connect"; + styleBtn(save, true); + save.addEventListener("click", () => { host.flash("Relay settings saved."); tick(); }); + wrap.append( + title, + field("Relay URL", "relayUrl", "http://26.0.0.1:8787"), + field("Token (optional)", "relayToken", "shared secret", true), + field("Room", "room", "team"), + save, + ); + return wrap; + } + + function renderMembers(container) { + container.textContent = ""; + if (!getCfg().url) return; + if (!members.length) { + const empty = document.createElement("div"); + empty.style.cssText = "color:var(--muted,#9a9aa8);padding:8px 0"; + empty.textContent = status === "error" ? "Can't reach the relay — check the URL/token." : "No teammates online yet."; + container.appendChild(empty); + return; + } + for (const m of members) { + const meta = m.meta || {}; + const oc = Number(meta.openCount || 0); + const row = document.createElement("div"); + row.style.cssText = "display:flex;align-items:center;gap:8px;padding:7px 0;border-top:1px solid var(--border,#26262e)"; + const infoCol = document.createElement("div"); + infoCol.style.flex = "1"; + const name = document.createElement("div"); + name.style.fontWeight = "600"; + name.textContent = (m.name || m.id) + (m.id === myId ? " (you)" : ""); + const sub = document.createElement("div"); + sub.style.cssText = "color:var(--muted,#9a9aa8);font-size:11px"; + sub.textContent = m.id + " · " + oc + " open" + (meta.access ? " · " + meta.access : ""); + infoCol.append(name, sub); + row.appendChild(infoCol); + + if (isCoordinator() && m.id !== myId) { + const btn = document.createElement("button"); + const idle = oc > 0 ? "Force shelve" : "Nothing open"; + btn.textContent = idle; + btn.disabled = oc === 0; + styleBtn(btn, oc > 0); + if (oc > 0) { + btn.title = "Ask this teammate's client to shelve their open work to the server (nothing is submitted)."; + btn.addEventListener("click", async () => { + btn.disabled = true; btn.textContent = "Requesting…"; styleBtn(btn, false); + try { await send(m.id, "shelve-request", {}); host.flash("Asked " + (m.name || m.id) + " to shelve their open work…"); } + catch (e) { host.flash("Couldn't reach the relay: " + e, true); } + setTimeout(() => { btn.disabled = false; btn.textContent = idle; styleBtn(btn, true); }, 2500); + }); + } + row.appendChild(btn); + } + container.appendChild(row); + } + } + + // ---- dock panel ("Team") --------------------------------------------------- + host.ui.addDockPanel({ + id: "team", + title: "Team", + icon: "people", + render: (el) => { + el.textContent = ""; + el.style.cssText = + "display:flex;flex-direction:column;height:100%;gap:9px;padding:11px 13px;overflow:auto;" + + "font:12px/1.5 var(--sans,system-ui,sans-serif);color:var(--text,#e8e8ee)"; + + const head = document.createElement("div"); + head.style.cssText = "display:flex;align-items:center;gap:8px"; + const dot = document.createElement("span"); + dot.style.cssText = "width:8px;height:8px;border-radius:50%;flex:0 0 auto"; + const statusText = document.createElement("span"); + statusText.style.cssText = "color:var(--muted,#9a9aa8);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"; + const gear = document.createElement("button"); + gear.textContent = "Settings"; + styleBtn(gear, false); + head.append(dot, statusText, gear); + + const settings = buildSettings(); + settings.style.display = getCfg().url ? "none" : "flex"; + gear.addEventListener("click", () => { + settings.style.display = settings.style.display === "none" ? "flex" : "none"; + }); + + const membersEl = document.createElement("div"); + el.append(head, settings, membersEl); + + const refresh = () => { + dot.style.background = + status === "ok" ? "var(--ok,#3ecf8e)" : status === "error" ? "var(--err,#ff6b6b)" : "var(--muted,#9a9aa8)"; + statusText.textContent = statusMsg || ""; + renderMembers(membersEl); + }; + updaters.add(refresh); + refresh(); + return () => { updaters.delete(refresh); }; + }, + }); + + host.log("Team Relay activated (id " + myId + ")"); +} diff --git a/plugins/team-relay/plugin.json b/plugins/team-relay/plugin.json new file mode 100644 index 0000000..bac4cf1 --- /dev/null +++ b/plugins/team-relay/plugin.json @@ -0,0 +1,12 @@ +{ + "id": "team-relay", + "name": "Team Relay", + "version": "1.0.0", + "author": "Exbyte Studios", + "description": "See who on the team is online and what they have open, over a lightweight relay on your VPN. Coordinators (admin/super) can force-shelve a teammate's open work to the server when they've stepped away — the teammate's client does the shelve; nothing is submitted. Configure the relay URL in the Team tab. Requires the Exbyte relay service.", + "entry": "index.js", + "minAppVersion": "0.4.0", + "permissions": ["team", "notify"], + "defaultEnabled": false, + "contributes": { "dock": ["team"] } +} diff --git a/registry.json b/registry.json index 90a6bfb..20c5739 100644 --- a/registry.json +++ b/registry.json @@ -1,6 +1,6 @@ { "schema": "exbyte-depot-plugin-registry/1", - "updated": "2026-07-09T12:00:00Z", + "updated": "2026-07-10T00:00:00Z", "plugins": [ { "id": "unreal", @@ -20,6 +20,25 @@ ], "baseUrl": "https://gitea.exbytestudios.com/ExbytePublicServices/exbyte-depot-plugins/raw/branch/main/plugins/unreal/" }, + { + "id": "team-relay", + "name": "Team Relay", + "version": "1.0.0", + "author": "Exbyte Studios", + "description": "Team presence + coordinator shelve tools over a lightweight relay. See who's online and what they have open; coordinators (admin/super) can force-shelve a teammate's open work to the server when they step away. Nothing is submitted. Configure the relay URL in the Team tab.", + "permissions": [ + "team", + "notify" + ], + "minAppVersion": "0.4.0", + "official": true, + "autoInstall": false, + "files": [ + "plugin.json", + "index.js" + ], + "baseUrl": "https://gitea.exbytestudios.com/ExbytePublicServices/exbyte-depot-plugins/raw/branch/main/plugins/team-relay/" + }, { "id": "hello-exbyte", "name": "Hello Exbyte", @@ -40,4 +59,4 @@ "baseUrl": "https://gitea.exbytestudios.com/ExbytePublicServices/exbyte-depot-plugins/raw/branch/main/plugins/hello-exbyte/" } ] -} \ No newline at end of file +}