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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user