// Exbyte Depot — Perforce client backend. // Thin wrappers around the `p4` CLI that return JSON (`-Mj`) to the frontend. use serde_json::Value; use std::io::Write; use std::process::{Command, Stdio}; use std::sync::Mutex; use tauri::State; #[derive(Default, Clone)] struct Conn { port: String, user: String, client: String, } #[derive(Default)] struct AppState(Mutex); /// Build a `p4` command with the connection's global flags applied. /// On Windows we set CREATE_NO_WINDOW so no console flashes on every call. fn p4_cmd(conn: &Conn, tagged: bool) -> Command { let mut c = Command::new("p4"); #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); // CREATE_NO_WINDOW } if !conn.port.is_empty() { c.args(["-p", &conn.port]); } if !conn.user.is_empty() { c.args(["-u", &conn.user]); } if !conn.client.is_empty() { c.args(["-c", &conn.client]); } if tagged { c.arg("-ztag").arg("-Mj"); // tagged, JSON-marshalled fields (one object per line) } c } /// Run a tagged (JSON) p4 command and collect the objects it prints. fn run_json(conn: &Conn, args: &[&str]) -> Result, String> { let out = p4_cmd(conn, true) .args(args) .output() .map_err(|e| format!("Failed to start p4: {e}"))?; let text = String::from_utf8_lossy(&out.stdout); let mut items = Vec::new(); for line in text.lines() { let t = line.trim(); if t.is_empty() { continue; } let v: Value = match serde_json::from_str(t) { Ok(v) => v, Err(_) => continue, }; // real error: code=="error" or severity >= 3 (E_FAILED / E_FATAL) let is_error = v.get("code").and_then(|c| c.as_str()) == Some("error") || v.get("severity").and_then(|s| s.as_i64()).map_or(false, |s| s >= 3); if is_error { let msg = v .get("data") .and_then(|d| d.as_str()) .unwrap_or("unknown p4 error") .trim() .to_string(); return Err(msg); } // informational message (e.g. "File(s) not opened."), not a data record — skip let is_message = v.get("data").is_some() && (v.get("generic").is_some() || v.get("severity").is_some() || v.get("level").is_some()); if is_message { continue; } items.push(v); } if items.is_empty() && !out.status.success() { let e = String::from_utf8_lossy(&out.stderr).trim().to_string(); if !e.is_empty() { return Err(e); } } Ok(items) } /// Trust the SSL fingerprint (first-time connect to an ssl: server). fn trust(port: &str) { let mut c = Command::new("p4"); #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } let _ = c.args(["-p", port, "trust", "-y", "-f"]).output(); } /// List workspaces (clients) for a user — used to populate the connect screen. #[tauri::command] async fn p4_clients(port: String, user: String) -> Result, String> { trust(&port); let conn = Conn { port, user: user.clone(), client: String::new(), }; run_json(&conn, &["clients", "-u", &user]) } /// Authenticate. Runs `p4 login` feeding the password on stdin, then stores /// the connection and returns `p4 info`. #[tauri::command] async fn p4_login( state: State<'_, AppState>, port: String, user: String, password: String, client: String, ) -> Result { trust(&port); let conn = Conn { port: port.clone(), user: user.clone(), client: client.clone(), }; let mut child = p4_cmd(&conn, false) .arg("login") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("Failed to start p4: {e}"))?; if let Some(si) = child.stdin.as_mut() { si.write_all(format!("{password}\n").as_bytes()) .map_err(|e| e.to_string())?; } let out = child.wait_with_output().map_err(|e| e.to_string())?; if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); let err = if err.trim().is_empty() { String::from_utf8_lossy(&out.stdout).trim().to_string() } else { err.trim().to_string() }; return Err(if err.is_empty() { "Login failed (wrong user or password)".into() } else { err }); } *state.0.lock().unwrap() = conn.clone(); let info = run_json(&conn, &["info"])?; Ok(info.into_iter().next().unwrap_or(Value::Null)) } /// Restore a saved session using the on-disk p4 ticket (no password). /// Fails if the ticket has expired — the UI then falls back to the login form. #[tauri::command] async fn p4_restore( state: State<'_, AppState>, port: String, user: String, client: String, ) -> Result { trust(&port); let conn = Conn { port, user, client }; // `p4 login -s` reports ticket status; errors if not authenticated. run_json(&conn, &["login", "-s"])?; *state.0.lock().unwrap() = conn.clone(); let info = run_json(&conn, &["info"])?; Ok(info.into_iter().next().unwrap_or(Value::Null)) } fn current(state: &State) -> Result { let c = state.0.lock().unwrap().clone(); if c.port.is_empty() { return Err("No active connection".into()); } Ok(c) } #[tauri::command] async fn p4_info(state: State<'_, AppState>) -> Result { let conn = current(&state)?; let info = run_json(&conn, &["info"])?; Ok(info.into_iter().next().unwrap_or(Value::Null)) } /// Build a `path/...` spec from a scope depot folder (empty = whole workspace). fn scope_spec(scope: &str) -> String { if scope.is_empty() { "//...".to_string() } else { format!("{}/...", scope.trim_end_matches(['/', '\\'])) } } /// Files currently open, optionally scoped to a depot folder. #[tauri::command] async fn p4_opened(state: State<'_, AppState>, scope: String) -> Result, String> { let conn = current(&state)?; if scope.is_empty() { run_json(&conn, &["opened"]) } else { run_json(&conn, &["opened", &scope_spec(&scope)]) } } /// List sub-directories of a depot path (for the working-folder picker). #[tauri::command] async fn p4_dirs(state: State<'_, AppState>, path: String) -> Result, String> { let conn = current(&state)?; let spec = if path.is_empty() { "//*".to_string() } else { format!("{}/*", path.trim_end_matches(['/', '\\'])) }; run_json(&conn, &["dirs", &spec]) } /// Pending changelists for the current user that actually contain files. /// Empty pending changelists (leftovers from a reverted/aborted commit) are /// cleaned up so they never show a phantom "ready to Submit" or fail submit. #[tauri::command] async fn p4_changes(state: State<'_, AppState>) -> Result, String> { let conn = current(&state)?; let changes = run_json( &conn, &["changes", "-s", "pending", "-u", &conn.user, "-c", &conn.client], )?; // which pending changelists hold at least one opened file? let opened = run_json(&conn, &["opened"]).unwrap_or_default(); let mut with_files = std::collections::HashSet::new(); for o in &opened { if let Some(ch) = o.get("change").and_then(|c| c.as_str()) { with_files.insert(ch.to_string()); } } let mut result = Vec::new(); for c in changes { let num = c .get("change") .and_then(|c| c.as_str()) .unwrap_or("") .to_string(); if num.is_empty() { continue; } if with_files.contains(&num) { result.push(c); } else { // empty pending changelist — delete it (ignore failures, e.g. shelved files) let _ = run_text(&conn, &["change", "-d", &num]); } } Ok(result) } /// Submitted history for a depot path (History tab). #[tauri::command] async fn p4_history(state: State<'_, AppState>, path: String) -> Result, String> { let conn = current(&state)?; let spec = if path.is_empty() { "//...".to_string() } else { path }; run_json(&conn, &["changes", "-s", "submitted", "-m", "50", &spec]) } #[tauri::command] async fn p4_disconnect(state: State<'_, AppState>) -> Result<(), String> { *state.0.lock().unwrap() = Conn::default(); Ok(()) } /// Run a non-tagged p4 command (raw text out). Errors carry stderr. fn run_text(conn: &Conn, args: &[&str]) -> Result { let out = p4_cmd(conn, false) .args(args) .output() .map_err(|e| format!("Failed to start p4: {e}"))?; let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); if !out.status.success() && !stderr.is_empty() { return Err(stderr); } Ok(if stdout.is_empty() { stderr } else { stdout }) } /// Get Latest — sync to head, optionally scoped to a depot folder. #[tauri::command] async fn p4_sync(state: State<'_, AppState>, scope: String) -> Result { let conn = current(&state)?; let msg = run_text(&conn, &["sync", &scope_spec(&scope)])?; Ok(if msg.is_empty() { "Already up to date — nothing to sync.".into() } else { msg }) } /// Unified diff of an opened file vs. the have revision. #[tauri::command] async fn p4_diff(state: State<'_, AppState>, file: String) -> Result { let conn = current(&state)?; run_text(&conn, &["diff", "-du", &file]) } /// Revert the given depot files (discard local edits). #[tauri::command] async fn p4_revert(state: State<'_, AppState>, files: Vec) -> Result { let conn = current(&state)?; let mut args: Vec<&str> = vec!["revert"]; for f in &files { args.push(f.as_str()); } run_text(&conn, &args) } /// Create an empty numbered changelist with a description; returns its number. /// (`p4 submit` accepts only one file arg, so partial submits go through a /// numbered changelist: create → reopen selected files into it → submit -c.) fn create_changelist(conn: &Conn, desc: &str) -> Result { let body = desc.trim().replace('\n', "\n\t"); let spec = format!("Change:\tnew\nStatus:\tnew\nDescription:\n\t{body}\n"); let mut child = p4_cmd(conn, false) .args(["change", "-i"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| format!("Failed to start p4: {e}"))?; if let Some(si) = child.stdin.as_mut() { si.write_all(spec.as_bytes()).map_err(|e| e.to_string())?; } let out = child.wait_with_output().map_err(|e| e.to_string())?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); } // stdout: "Change 23 created." let s = String::from_utf8_lossy(&out.stdout); s.split_whitespace() .find(|w| w.chars().all(|c| c.is_ascii_digit()) && !w.is_empty()) .map(|n| n.to_string()) .ok_or_else(|| format!("Failed to create changelist: {}", s.trim())) } /// "Commit" (local): create a pending numbered changelist with a description and /// move the selected files into it. Nothing goes to the server yet — this is /// revertable. Returns the changelist number. #[tauri::command] async fn p4_commit( state: State<'_, AppState>, description: String, files: Vec, ) -> Result { let conn = current(&state)?; if files.is_empty() { return Err("No files to commit".into()); } let cl = create_changelist(&conn, &description)?; let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl]; for f in &files { reopen.push(f.as_str()); } run_text(&conn, &reopen)?; Ok(cl) } /// Submit a numbered pending changelist to the server (the "push"). #[tauri::command] async fn p4_submit_change(state: State<'_, AppState>, change: String) -> Result { let conn = current(&state)?; run_text(&conn, &["submit", "-c", &change]) } /// Submit the selected files with a description. #[tauri::command] async fn p4_submit( state: State<'_, AppState>, description: String, files: Vec, ) -> Result { let conn = current(&state)?; if files.is_empty() { return Err("No files selected to submit".into()); } let total = run_json(&conn, &["opened"])?.len(); // all opened files selected → submit the whole default changelist in one go if files.len() >= total { return run_text(&conn, &["submit", "-d", &description]); } // partial submit → dedicated numbered changelist let cl = create_changelist(&conn, &description)?; let mut reopen: Vec<&str> = vec!["reopen", "-c", &cl]; for f in &files { reopen.push(f.as_str()); } run_text(&conn, &reopen)?; run_text(&conn, &["submit", "-c", &cl]) } /// Run a p4 command over a list of file paths, returning the opened records. fn files_cmd(conn: &Conn, cmd: &str, files: &[String]) -> Result, String> { let mut args: Vec<&str> = vec![cmd]; for f in files { args.push(f.as_str()); } run_json(conn, &args) } /// Mark files for add. #[tauri::command] async fn p4_add(state: State<'_, AppState>, files: Vec) -> Result, String> { files_cmd(¤t(&state)?, "add", &files) } /// Open files for edit (check out). #[tauri::command] async fn p4_edit(state: State<'_, AppState>, files: Vec) -> Result, String> { files_cmd(¤t(&state)?, "edit", &files) } /// Mark files for delete. #[tauri::command] async fn p4_delete(state: State<'_, AppState>, files: Vec) -> Result, String> { files_cmd(¤t(&state)?, "delete", &files) } /// Reconcile a path — auto-detect adds/edits/deletes vs. the depot. /// This is what a drag-drop of files/folders uses. #[tauri::command] async fn p4_reconcile(state: State<'_, AppState>, path: String) -> Result, String> { let conn = current(&state)?; // depot spec (//...) → recurse; local folder → recurse; local file → as-is let spec = if path.starts_with("//") { if path.ends_with("...") { path.clone() } else { format!("{}/...", path.trim_end_matches('/')) } } else if std::path::Path::new(&path).is_dir() { format!("{}/...", path.trim_end_matches(['/', '\\'])) } else { path.clone() }; run_json(&conn, &["reconcile", "-e", "-a", "-d", &spec]) } /// Scan the working scope for new / changed / deleted files and open them /// (reconcile). This is what surfaces assets created outside the client — /// e.g. new .uasset files that Unreal wrote straight to disk but that were /// never `p4 add`-ed, so they don't yet show up in `p4 opened`. #[tauri::command] async fn p4_scan(state: State<'_, AppState>, scope: String) -> Result, String> { let conn = current(&state)?; run_json(&conn, &["reconcile", "-e", "-a", "-d", &scope_spec(&scope)]) } /// Undo (roll back) an already-submitted changelist. Perforce (2019.1+) does this /// with `p4 undo`: it opens the reverting revisions, which we drop into a fresh /// numbered changelist and submit — so the rollback is itself a new change, exactly /// like GitHub Desktop's "Revert this commit". Returns the submit output. #[tauri::command] async fn p4_undo_change(state: State<'_, AppState>, change: String) -> Result { let conn = current(&state)?; let cl = create_changelist(&conn, &format!("Revert change {change}"))?; let spec = format!("//...@={change}"); // -c keeps the undone files isolated in our changelist (won't touch other open work) run_text(&conn, &["undo", "-c", &cl, &spec])?; run_text(&conn, &["submit", "-c", &cl]) } /// Reveal a depot folder/file (or a raw local path) in the OS file explorer. /// Depot paths are resolved to the local working copy via `p4 where`. #[tauri::command] async fn open_in_explorer(state: State<'_, AppState>, target: String) -> Result<(), String> { let local = if target.starts_with("//") { let conn = current(&state)?; let spec = format!("{}/...", target.trim_end_matches(['/', '.'])); let p = run_json(&conn, &["where", &spec])? .into_iter() .next() .and_then(|v| v.get("path").and_then(|x| x.as_str()).map(String::from)) .ok_or_else(|| "Could not resolve the local path".to_string())?; // strip the trailing "..." wildcard and separators → the folder itself p.trim_end_matches(['.', '/', '\\']).to_string() } else { target }; let path = std::path::Path::new(&local); if !path.exists() { return Err(format!("Path not synced to disk yet: {local}")); } let mut c = Command::new("explorer"); #[cfg(windows)] { use std::os::windows::process::CommandExt; c.creation_flags(0x0800_0000); } // reveal-and-select for files, open-folder for directories if path.is_file() { c.arg(format!("/select,{local}")); } else { c.arg(&local); } c.spawn().map_err(|e| format!("Could not open Explorer: {e}"))?; Ok(()) } /// Files and metadata of a submitted changelist (History detail). #[tauri::command] async fn p4_describe(state: State<'_, AppState>, change: String) -> Result { let conn = current(&state)?; let v = run_json(&conn, &["describe", "-s", &change])?; Ok(v.into_iter().next().unwrap_or(Value::Null)) } /// Read raw bytes of a local file — used by the 3D model / texture viewer. #[tauri::command] async fn read_file(path: String) -> Result { std::fs::read(&path) .map(tauri::ipc::Response::new) .map_err(|e| format!("Could not read file: {e}")) } /// Read the local working-copy bytes of a depot file (resolves via `p4 where`). #[tauri::command] async fn read_depot(state: State<'_, AppState>, depot: String) -> Result { let conn = current(&state)?; let local = run_json(&conn, &["where", &depot])? .into_iter() .next() .and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from)) .ok_or_else(|| "Could not resolve the file path".to_string())?; std::fs::read(&local) .map(tauri::ipc::Response::new) .map_err(|e| format!("Could not read file: {e}")) } /// Switch the active workspace (client) without re-authenticating. #[tauri::command] async fn p4_switch_client(state: State<'_, AppState>, client: String) -> Result { let conn = { let mut c = state.0.lock().unwrap(); if c.port.is_empty() { return Err("No active connection".into()); } c.client = client; c.clone() }; let info = run_json(&conn, &["info"])?; Ok(info.into_iter().next().unwrap_or(Value::Null)) } /// List depots on the server (for the Depot-path picker). #[tauri::command] async fn p4_depots(state: State<'_, AppState>) -> Result, String> { let conn = current(&state)?; run_json(&conn, &["depots"]) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let mut builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()); #[cfg(desktop)] { builder = builder .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()); } builder .manage(AppState::default()) .invoke_handler(tauri::generate_handler![ p4_clients, p4_login, p4_restore, p4_info, p4_opened, p4_changes, p4_history, p4_disconnect, p4_sync, p4_diff, p4_revert, p4_submit, p4_commit, p4_submit_change, p4_add, p4_edit, p4_delete, p4_reconcile, p4_scan, p4_undo_change, open_in_explorer, p4_describe, read_file, read_depot, p4_switch_client, p4_depots, p4_dirs, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }