// 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 + ")"); }