From 20c94ef9f06c617b54ad37402588646866813a06 Mon Sep 17 00:00:00 2001 From: Exbyte Studios Date: Fri, 10 Jul 2026 01:23:27 +0300 Subject: [PATCH] =?UTF-8?q?0.4.0=20=E2=80=94=20Team=20Relay:=20presence=20?= =?UTF-8?q?+=20coordinator=20force-shelve=20over=20a=20generic=20relay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - host.team bridge + 'team' permission (SDK) - backend: relay_http (generic HTTP pipe), p4_shelve_open, p4_protects_max - new plugin team-relay (opt-in): Team dock, presence loop, force-shelve - relay server (exbyte-relay/) — generic zero-dep message bus, no Perforce knowledge Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- plugins-examples/team-relay/index.js | 243 ++++++++++++++++++++++++ plugins-examples/team-relay/plugin.json | 12 ++ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 92 +++++++++ src-tauri/tauri.conf.json | 2 +- src/App.tsx | 9 + src/p4.ts | 8 + src/plugins.ts | 18 ++ 10 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 plugins-examples/team-relay/index.js create mode 100644 plugins-examples/team-relay/plugin.json diff --git a/package.json b/package.json index 7b4521c..ba424b5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "exbyte-depot", "private": true, - "version": "0.3.9", + "version": "0.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/plugins-examples/team-relay/index.js b/plugins-examples/team-relay/index.js new file mode 100644 index 0000000..70c7f6c --- /dev/null +++ b/plugins-examples/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-examples/team-relay/plugin.json b/plugins-examples/team-relay/plugin.json new file mode 100644 index 0000000..bac4cf1 --- /dev/null +++ b/plugins-examples/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/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bd60912..f536c3f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -992,7 +992,7 @@ dependencies = [ [[package]] name = "exbyte-depot" -version = "0.3.8" +version = "0.4.0" dependencies = [ "encoding_rs", "reqwest 0.12.28", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 03e1a00..a139262 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exbyte-depot" -version = "0.3.9" +version = "0.4.0" description = "Exbyte Depot — native Perforce client by Exbyte Studios" authors = ["Exbyte Studios"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 731c08f..e1e7229 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2315,6 +2315,53 @@ async fn p4_unshelve(state: State<'_, AppState>, change: String) -> Result, description: String) -> Result { + let conn = current(&state)?; + let opened = run_json(&conn, &["opened", "-c", "default"])?; + let files: Vec = opened + .iter() + .filter_map(|v| v.get("depotFile").and_then(|d| d.as_str()).map(String::from)) + .collect(); + if files.is_empty() { + return Err("No open files in the default changelist".into()); + } + let desc = if description.trim().is_empty() { + "Remote shelve (Team Relay)".to_string() + } else { + description + }; + let cl = create_changelist(&conn, &desc)?; + // Move the open files into the new changelist, batched so a huge default + // changelist doesn't overflow the Windows command line. + let prefix_len = "reopen -c ".len() + cl.len(); + for r in file_batches(&files, prefix_len) { + let mut args: Vec<&str> = vec!["reopen", "-c", &cl]; + for f in &files[r] { + args.push(f.as_str()); + } + run_text(&conn, &args)?; + } + run_text(&conn, &["shelve", "-c", &cl])?; + Ok(serde_json::json!({ "change": cl, "files": files.len() })) +} + +/// The caller's maximum protection level on this server (`p4 protects -m`), +/// e.g. "super" / "admin" / "write". Read-only; used to gate coordinator-only +/// actions in the UI (the server still enforces the real permission). +#[tauri::command] +async fn p4_protects_max(state: State<'_, AppState>) -> Result { + let conn = current(&state)?; + run_text(&conn, &["protects", "-m"]).map(|s| s.trim().to_string()) +} + /// Delete the shelved files of a changelist. #[tauri::command] async fn p4_delete_shelf(state: State<'_, AppState>, change: String) -> Result { @@ -3006,6 +3053,48 @@ async fn registry_http_get(url: String) -> Result { resp.text().await.map_err(|e| e.to_string()) } +/// Generic HTTP pipe for plugins that talk to a self-hosted service (e.g. the +/// team relay on the VPN). The webview can't reach arbitrary hosts — CSP pins +/// `connect-src`, and cross-origin `fetch` hits CORS — so the plugin routes its +/// requests through here. This is a DUMB pipe: it has no idea what the service +/// is, which keeps the relay URL out of the app (the plugin holds it) and lets +/// one primitive serve any future relay-backed plugin. http/https only; an +/// optional bearer token authenticates against the caller's own service. +#[tauri::command] +async fn relay_http( + url: String, + method: String, + body: Option, + token: Option, +) -> Result { + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Err("Relay URL must be http(s)".into()); + } + let client = reqwest::Client::builder() + .user_agent("ExbyteDepot") + .timeout(std::time::Duration::from_secs(20)) + .build() + .map_err(|e| e.to_string())?; + let mut req = match method.to_uppercase().as_str() { + "GET" => client.get(&url), + "POST" => client.post(&url), + other => return Err(format!("Unsupported method {other}")), + }; + if let Some(tok) = token.filter(|t| !t.is_empty()) { + req = req.header("authorization", format!("Bearer {tok}")); + } + if let Some(b) = body { + req = req.header("content-type", "application/json").body(b); + } + let resp = req.send().await.map_err(|e| e.to_string())?; + let status = resp.status(); + let text = resp.text().await.map_err(|e| e.to_string())?; + if !status.is_success() { + return Err(format!("HTTP {}: {}", status.as_u16(), text.trim())); + } + Ok(text) +} + fn copy_dir_recursive(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> { std::fs::create_dir_all(to)?; for e in std::fs::read_dir(from)? { @@ -3166,6 +3255,9 @@ pub fn run() { plugin_set_enabled, plugin_write_file, registry_http_get, + relay_http, + p4_shelve_open, + p4_protects_max, plugin_install, plugin_remove, plugins_dir_path, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fbbbf9f..cf77d98 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Exbyte Depot", "mainBinaryName": "Exbyte Depot", - "version": "0.3.9", + "version": "0.4.0", "identifier": "com.bonchellon.exbyte-depot", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index ec27d1f..11d047d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -640,6 +640,15 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan launch: () => { void launchUE(); }, readLog: (tb?: number) => p4.ueLog(uprojectRef.current, tb ?? 200_000), }, + // Team bridge — presence + coordinator shelve tools; plugin owns the logic + team: { + whoami: () => (info ? { user: info.userName, client: info.clientName, root: info.clientRoot } : null), + maxAccess: () => p4.protectsMax().catch(() => ""), + openCount: () => p4.opened("").then((a) => a.length).catch(() => 0), + shelveOpen: (desc: string) => p4.shelveOpen(desc), + submitShelved: (change: string) => p4.submitShelved(change), + relay: (url: string, method: string, body?: string, token?: string) => p4.relayHttp(url, method, body, token), + }, }); setPluginVer((v) => v + 1); return res; diff --git a/src/p4.ts b/src/p4.ts index 0b76f8b..edb6c58 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -114,6 +114,14 @@ export const p4 = { pluginSetEnabled: (id: string, enabled: boolean) => invoke("plugin_set_enabled", { id, enabled }), pluginWriteFile: (id: string, name: string, content: string) => invoke("plugin_write_file", { id, name, content }), registryHttpGet: (url: string) => invoke("registry_http_get", { url }), + // generic HTTP pipe for relay-backed plugins (any http(s) URL, optional bearer) + relayHttp: (url: string, method: string, body?: string, token?: string) => + invoke("relay_http", { url, method, body, token }), + // shelve everything open in the default changelist → { change, files } + shelveOpen: (description: string) => + invoke<{ change: string; files: number }>("p4_shelve_open", { description }), + // caller's max protection level on the server (super/admin/write/…) + protectsMax: () => invoke("p4_protects_max"), pluginInstall: (src: string) => invoke("plugin_install", { src }), pluginRemove: (id: string) => invoke("plugin_remove", { id }), pluginsDirPath: () => invoke("plugins_dir_path"), diff --git a/src/plugins.ts b/src/plugins.ts index 1a4b27b..9d6ba93 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -42,6 +42,21 @@ export interface UeBridge { readLog: (tailBytes?: number) => Promise; } +// Team bridge — presence + coordinator tools. The plugin drives these; the core +// just exposes the p4 primitives + a generic HTTP pipe to the relay. Gated by +// the "team" permission. Nothing here touches the Perforce server config: shelve +// is an ordinary client-side operation and `relay` talks only to the URL the +// plugin was configured with. +export interface TeamBridge { + whoami: () => { user?: string; client?: string; root?: string } | null; + maxAccess: () => Promise; // "super" | "admin" | "write" | … + openCount: () => Promise; // files open in this workspace + shelveOpen: (desc: string) => Promise<{ change: string; files: number }>; + submitShelved: (change: string) => Promise; + // dumb HTTP pipe to the configured relay (backend does the request; no CORS) + relay: (url: string, method: string, body?: string, token?: string) => Promise; +} + // ---- host context (provided by the app) ------------------------------------- export interface HostContext { flash: (text: string, err?: boolean) => void; @@ -50,6 +65,7 @@ export interface HostContext { selectedFile: () => { depotFile?: string } | null; refresh: () => void; ue?: UeBridge; + team?: TeamBridge; } // ---- the Host SDK a plugin receives ----------------------------------------- @@ -75,6 +91,7 @@ export interface HostApi { selectedFile: () => { depotFile?: string } | null; refresh: () => void; ue?: UeBridge; // present only with the "ue" permission + team?: TeamBridge; // present only with the "team" permission theme: () => Record; t: typeof t; log: (...a: unknown[]) => void; @@ -125,6 +142,7 @@ function makeHost(info: PluginInfo, ctx: HostContext): HostApi { log: (...a) => console.log(`[plugin:${info.id}]`, ...a), }; if (has("ue") && ctx.ue) host.ue = ctx.ue; // Unreal bridge, permission-gated + if (has("team") && ctx.team) host.team = ctx.team; // Team bridge, permission-gated return host; }