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:
Exbyte Studios
2026-07-10 01:23:27 +03:00
parent dc88269baf
commit 20c94ef9f0
10 changed files with 386 additions and 4 deletions

2
src-tauri/Cargo.lock generated
View File

@ -992,7 +992,7 @@ dependencies = [
[[package]]
name = "exbyte-depot"
version = "0.3.8"
version = "0.4.0"
dependencies = [
"encoding_rs",
"reqwest 0.12.28",

View File

@ -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"

View File

@ -2315,6 +2315,53 @@ async fn p4_unshelve(state: State<'_, AppState>, change: String) -> Result<Strin
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.
#[tauri::command]
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())
}
/// 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<()> {
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,

View File

@ -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",