Add team-relay plugin (presence + coordinator force-shelve) to registry
This commit is contained in:
243
plugins/team-relay/index.js
Normal file
243
plugins/team-relay/index.js
Normal file
@ -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 + ")");
|
||||
}
|
||||
12
plugins/team-relay/plugin.json
Normal file
12
plugins/team-relay/plugin.json
Normal file
@ -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"] }
|
||||
}
|
||||
@ -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/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user