0.4.0 — Team Relay: presence + coordinator force-shelve over a generic relay
- 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 <noreply@anthropic.com>
This commit is contained in:
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "exbyte-depot",
|
"name": "exbyte-depot",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.9",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
243
plugins-examples/team-relay/index.js
Normal file
243
plugins-examples/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-examples/team-relay/plugin.json
Normal file
12
plugins-examples/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"] }
|
||||||
|
}
|
||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -992,7 +992,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "exbyte-depot"
|
name = "exbyte-depot"
|
||||||
version = "0.3.8"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "exbyte-depot"
|
name = "exbyte-depot"
|
||||||
version = "0.3.9"
|
version = "0.4.0"
|
||||||
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
||||||
authors = ["Exbyte Studios"]
|
authors = ["Exbyte Studios"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@ -2315,6 +2315,53 @@ async fn p4_unshelve(state: State<'_, AppState>, change: String) -> Result<Strin
|
|||||||
run_text(&conn, &["unshelve", "-s", &change, "-f"])
|
run_text(&conn, &["unshelve", "-s", &change, "-f"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shelve everything the user has open in their DEFAULT changelist to the server
|
||||||
|
/// in one shot: create a numbered pending changelist, move the open files into
|
||||||
|
/// it, and `shelve` it. Returns `{ change, files }`. This is the primitive the
|
||||||
|
/// team-relay plugin drives so a coordinator can push a teammate's local work to
|
||||||
|
/// the server (as a shelf — nothing is submitted). The teammate's client runs
|
||||||
|
/// this; it uses ordinary client-side p4 commands and never touches the server
|
||||||
|
/// config. Files already parked in other numbered changelists are left alone.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn p4_shelve_open(state: State<'_, AppState>, description: String) -> Result<Value, String> {
|
||||||
|
let conn = current(&state)?;
|
||||||
|
let opened = run_json(&conn, &["opened", "-c", "default"])?;
|
||||||
|
let files: Vec<String> = 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<String, String> {
|
||||||
|
let conn = current(&state)?;
|
||||||
|
run_text(&conn, &["protects", "-m"]).map(|s| s.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete the shelved files of a changelist.
|
/// Delete the shelved files of a changelist.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn p4_delete_shelf(state: State<'_, AppState>, change: String) -> Result<String, String> {
|
async fn p4_delete_shelf(state: State<'_, AppState>, change: String) -> Result<String, String> {
|
||||||
@ -3006,6 +3053,48 @@ async fn registry_http_get(url: String) -> Result<String, String> {
|
|||||||
resp.text().await.map_err(|e| e.to_string())
|
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<String>,
|
||||||
|
token: Option<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
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<()> {
|
fn copy_dir_recursive(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
|
||||||
std::fs::create_dir_all(to)?;
|
std::fs::create_dir_all(to)?;
|
||||||
for e in std::fs::read_dir(from)? {
|
for e in std::fs::read_dir(from)? {
|
||||||
@ -3166,6 +3255,9 @@ pub fn run() {
|
|||||||
plugin_set_enabled,
|
plugin_set_enabled,
|
||||||
plugin_write_file,
|
plugin_write_file,
|
||||||
registry_http_get,
|
registry_http_get,
|
||||||
|
relay_http,
|
||||||
|
p4_shelve_open,
|
||||||
|
p4_protects_max,
|
||||||
plugin_install,
|
plugin_install,
|
||||||
plugin_remove,
|
plugin_remove,
|
||||||
plugins_dir_path,
|
plugins_dir_path,
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Exbyte Depot",
|
"productName": "Exbyte Depot",
|
||||||
"mainBinaryName": "Exbyte Depot",
|
"mainBinaryName": "Exbyte Depot",
|
||||||
"version": "0.3.9",
|
"version": "0.4.0",
|
||||||
"identifier": "com.bonchellon.exbyte-depot",
|
"identifier": "com.bonchellon.exbyte-depot",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@ -640,6 +640,15 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
|||||||
launch: () => { void launchUE(); },
|
launch: () => { void launchUE(); },
|
||||||
readLog: (tb?: number) => p4.ueLog(uprojectRef.current, tb ?? 200_000),
|
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);
|
setPluginVer((v) => v + 1);
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
@ -114,6 +114,14 @@ export const p4 = {
|
|||||||
pluginSetEnabled: (id: string, enabled: boolean) => invoke<void>("plugin_set_enabled", { id, enabled }),
|
pluginSetEnabled: (id: string, enabled: boolean) => invoke<void>("plugin_set_enabled", { id, enabled }),
|
||||||
pluginWriteFile: (id: string, name: string, content: string) => invoke<void>("plugin_write_file", { id, name, content }),
|
pluginWriteFile: (id: string, name: string, content: string) => invoke<void>("plugin_write_file", { id, name, content }),
|
||||||
registryHttpGet: (url: string) => invoke<string>("registry_http_get", { url }),
|
registryHttpGet: (url: string) => invoke<string>("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<string>("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<string>("p4_protects_max"),
|
||||||
pluginInstall: (src: string) => invoke<string>("plugin_install", { src }),
|
pluginInstall: (src: string) => invoke<string>("plugin_install", { src }),
|
||||||
pluginRemove: (id: string) => invoke<void>("plugin_remove", { id }),
|
pluginRemove: (id: string) => invoke<void>("plugin_remove", { id }),
|
||||||
pluginsDirPath: () => invoke<string>("plugins_dir_path"),
|
pluginsDirPath: () => invoke<string>("plugins_dir_path"),
|
||||||
|
|||||||
@ -42,6 +42,21 @@ export interface UeBridge {
|
|||||||
readLog: (tailBytes?: number) => Promise<string>;
|
readLog: (tailBytes?: number) => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<string>; // "super" | "admin" | "write" | …
|
||||||
|
openCount: () => Promise<number>; // files open in this workspace
|
||||||
|
shelveOpen: (desc: string) => Promise<{ change: string; files: number }>;
|
||||||
|
submitShelved: (change: string) => Promise<string>;
|
||||||
|
// dumb HTTP pipe to the configured relay (backend does the request; no CORS)
|
||||||
|
relay: (url: string, method: string, body?: string, token?: string) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- host context (provided by the app) -------------------------------------
|
// ---- host context (provided by the app) -------------------------------------
|
||||||
export interface HostContext {
|
export interface HostContext {
|
||||||
flash: (text: string, err?: boolean) => void;
|
flash: (text: string, err?: boolean) => void;
|
||||||
@ -50,6 +65,7 @@ export interface HostContext {
|
|||||||
selectedFile: () => { depotFile?: string } | null;
|
selectedFile: () => { depotFile?: string } | null;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
ue?: UeBridge;
|
ue?: UeBridge;
|
||||||
|
team?: TeamBridge;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- the Host SDK a plugin receives -----------------------------------------
|
// ---- the Host SDK a plugin receives -----------------------------------------
|
||||||
@ -75,6 +91,7 @@ export interface HostApi {
|
|||||||
selectedFile: () => { depotFile?: string } | null;
|
selectedFile: () => { depotFile?: string } | null;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
ue?: UeBridge; // present only with the "ue" permission
|
ue?: UeBridge; // present only with the "ue" permission
|
||||||
|
team?: TeamBridge; // present only with the "team" permission
|
||||||
theme: () => Record<string, string>;
|
theme: () => Record<string, string>;
|
||||||
t: typeof t;
|
t: typeof t;
|
||||||
log: (...a: unknown[]) => void;
|
log: (...a: unknown[]) => void;
|
||||||
@ -125,6 +142,7 @@ function makeHost(info: PluginInfo, ctx: HostContext): HostApi {
|
|||||||
log: (...a) => console.log(`[plugin:${info.id}]`, ...a),
|
log: (...a) => console.log(`[plugin:${info.id}]`, ...a),
|
||||||
};
|
};
|
||||||
if (has("ue") && ctx.ue) host.ue = ctx.ue; // Unreal bridge, permission-gated
|
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;
|
return host;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user